From 4b8d8ba22471b8f356946f6fcee6d4d3bf33f213 Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Fri, 18 Jul 2025 11:37:58 +0700 Subject: [PATCH] Add webhooks support --- openapi31/helper.go | 42 +++++++++++++++++++++++++++++++ openapi31/reflect.go | 39 +++++++++++++++++++++-------- openapi31/reflect_test.go | 52 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/openapi31/helper.go b/openapi31/helper.go index 4e64b8f..533a613 100644 --- a/openapi31/helper.go +++ b/openapi31/helper.go @@ -182,6 +182,41 @@ func (s *Spec) AddOperation(method, path string, operation Operation) error { }) } +// AddWebhook validates and sets webhook by name and method. +// +// It will fail if webhook with same name already exists. +func (s *Spec) AddWebhook(method, name string, operation Operation) error { + if _, found := s.Webhooks[name]; found { + return fmt.Errorf("duplicate webhook name: %s", name) + } + + method = strings.ToLower(method) + + // Add "No Content" response if there are no responses configured. + if len(operation.ResponsesEns().MapOfResponseOrReferenceValues) == 0 && operation.Responses.Default == nil { + operation.Responses.WithMapOfResponseOrReferenceValuesItem(strconv.Itoa(http.StatusNoContent), ResponseOrReference{ + Response: &Response{ + Description: http.StatusText(http.StatusNoContent), + }, + }) + } + + method, _, _, err := openapi.SanitizeMethodPath(method, "/") + if err != nil { + return err + } + + pathItem := PathItem{} + + if err := pathItem.SetOperation(method, &operation); err != nil { + return err + } + + s.WithWebhooksItem(name, pathItem.PathItemOrReference()) + + return nil +} + // UnknownParamIsForbidden indicates forbidden unknown parameters. func (o Operation) UnknownParamIsForbidden(in ParameterIn) bool { f, ok := o.MapOfAnything[xForbidUnknown+string(in)].(bool) @@ -281,3 +316,10 @@ func (r *RequestBodyOrReference) SetReference(ref string) { r.ReferenceEns().Ref = ref r.RequestBody = nil } + +// PathItemOrReference exposes PathItem as union type. +func (p *PathItem) PathItemOrReference() PathItemOrReference { + return PathItemOrReference{ + PathItem: p, + } +} diff --git a/openapi31/reflect.go b/openapi31/reflect.go index 1ae8ee8..bf82ac1 100644 --- a/openapi31/reflect.go +++ b/openapi31/reflect.go @@ -28,7 +28,7 @@ func NewReflector() *Reflector { r.DefaultOptions = append(r.DefaultOptions, jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (stop bool, err error) { // See https://spec.openapis.org/oas/v3.1.0.html#data-types. - switch params.Value.Kind() { //nolint:exhaustive // Not all kinds have formats defined. + switch params.Value.Kind() { //nolint // Not all kinds have formats defined. case reflect.Int64: params.Schema.WithFormat("int64") case reflect.Int32: @@ -46,9 +46,9 @@ func NewReflector() *Reflector { } // NewOperationContext initializes openapi.OperationContext to be prepared -// and added later with Reflector.AddOperation. -func (r *Reflector) NewOperationContext(method, pathPattern string) (openapi.OperationContext, error) { - method, pathPattern, pathParams, err := openapi.SanitizeMethodPath(method, pathPattern) +// and added later with Reflector.AddOperation or Reflector.AddWebhook. +func (r *Reflector) NewOperationContext(method, pathPatternOrWebhookName string) (openapi.OperationContext, error) { + method, pathPattern, pathParams, err := openapi.SanitizeMethodPath(method, pathPatternOrWebhookName) if err != nil { return nil, err } @@ -61,7 +61,7 @@ func (r *Reflector) NewOperationContext(method, pathPattern string) (openapi.Ope } if operation != nil { - return nil, fmt.Errorf("operation already exists: %s %s", method, pathPattern) + return nil, fmt.Errorf("operation already exists: %s %s", method, pathPatternOrWebhookName) } operation = &Operation{} @@ -211,24 +211,43 @@ func (o operationContext) Operation() *Operation { // AddOperation configures operation request and response schema. func (r *Reflector) AddOperation(oc openapi.OperationContext) error { + c, err := r.setupOC(oc) + if err != nil { + return err + } + + return r.SpecEns().AddOperation(oc.Method(), oc.PathPattern(), *c.op) +} + +// AddWebhook configures webhook request and response schema. +func (r *Reflector) AddWebhook(oc openapi.OperationContext) error { + c, err := r.setupOC(oc) + if err != nil { + return err + } + + return r.SpecEns().AddWebhook(oc.Method(), c.PathPattern(), *c.op) +} + +func (r *Reflector) setupOC(oc openapi.OperationContext) (operationContext, error) { c, ok := oc.(operationContext) if !ok { - return fmt.Errorf("wrong operation context %T received, %T expected", oc, operationContext{}) + return c, fmt.Errorf("wrong operation context %T received, %T expected", oc, operationContext{}) } if err := r.setupRequest(c.op, oc); err != nil { - return fmt.Errorf("setup request %s %s: %w", oc.Method(), oc.PathPattern(), err) + return c, fmt.Errorf("setup request %s %s: %w", oc.Method(), oc.PathPattern(), err) } if err := c.op.validatePathParams(c.pathParams); err != nil { - return fmt.Errorf("validate path params %s %s: %w", oc.Method(), oc.PathPattern(), err) + return c, fmt.Errorf("validate path params %s %s: %w", oc.Method(), oc.PathPattern(), err) } if err := r.setupResponse(c.op, oc); err != nil { - return fmt.Errorf("setup response %s %s: %w", oc.Method(), oc.PathPattern(), err) + return c, fmt.Errorf("setup response %s %s: %w", oc.Method(), oc.PathPattern(), err) } - return r.SpecEns().AddOperation(oc.Method(), oc.PathPattern(), *c.op) + return c, nil } func (r *Reflector) setupRequest(o *Operation, oc openapi.OperationContext) error { diff --git a/openapi31/reflect_test.go b/openapi31/reflect_test.go index afdf69b..8cf65e1 100644 --- a/openapi31/reflect_test.go +++ b/openapi31/reflect_test.go @@ -1594,3 +1594,55 @@ func TestSelfReference(t *testing.T) { } }`, reflector.SpecSchema()) } + +func TestReflector_AddWebhook(t *testing.T) { + r := openapi31.NewReflector() + + oc, err := r.NewOperationContext(http.MethodPost, "newPet") + require.NoError(t, err) + + type Pet struct { + Name string `json:"name"` + Breed string `json:"breed"` + } + + oc.AddReqStructure(Pet{}, func(cu *openapi.ContentUnit) { + cu.Description = "Information about a new pet in the system" + }) + + oc.AddRespStructure(nil, func(cu *openapi.ContentUnit) { + cu.Description = "Return a 200 status to indicate that the data was received successfully" + cu.HTTPStatus = http.StatusOK + }) + + require.NoError(t, r.AddWebhook(oc)) + + assertjson.EqMarshal(t, `{ + "openapi":"3.1.0","info":{"title":"","version":""},"paths":{}, + "webhooks":{ + "newPet":{ + "post":{ + "requestBody":{ + "description":"Information about a new pet in the system", + "content":{ + "application/json":{"schema":{"$ref":"#/components/schemas/Openapi31TestPet"}} + } + }, + "responses":{ + "200":{ + "description":"Return a 200 status to indicate that the data was received successfully" + } + } + } + } + }, + "components":{ + "schemas":{ + "Openapi31TestPet":{ + "properties":{"breed":{"type":"string"},"name":{"type":"string"}}, + "type":"object" + } + } + } + }`, r.SpecEns()) +}