From f40d0ef6cbf9566190e5c52da093b931cdf72357 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 16:41:57 -0700 Subject: [PATCH 01/30] fix(codegen): use reflect.DeepEqual for ExclusiveMin/Max in MergeSchemas kin-openapi's 3.1 work changed Schema.ExclusiveMin/Max from bool to ExclusiveBound{Bool *bool; Value *float64}. Direct struct equality (!=) still compiles but compares pointer addresses, not the wrapped values, so two schemas with semantically identical bounds would incorrectly fail the merge. Use reflect.DeepEqual for value-aware comparison. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/codegen/merge_schemas.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/codegen/merge_schemas.go b/pkg/codegen/merge_schemas.go index 3becb5e412..bfc5804b15 100644 --- a/pkg/codegen/merge_schemas.go +++ b/pkg/codegen/merge_schemas.go @@ -317,13 +317,13 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s } result.UniqueItems = s1.UniqueItems - if s1.ExclusiveMin != s2.ExclusiveMin { + if !reflect.DeepEqual(s1.ExclusiveMin, s2.ExclusiveMin) { return openapi3.Schema{}, errors.New("merging two schemas with different ExclusiveMin") } result.ExclusiveMin = s1.ExclusiveMin - if s1.ExclusiveMax != s2.ExclusiveMax { + if !reflect.DeepEqual(s1.ExclusiveMax, s2.ExclusiveMax) { return openapi3.Schema{}, errors.New("merging two schemas with different ExclusiveMax") } From a8e4a6602ec545870c63898a43f9bf7d9b2a2358 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 17:29:18 -0700 Subject: [PATCH 02/30] feat(codegen): OpenAPI 3.1 foundation -- version awareness and nullable This is Phase 1 of bringing 3.1 feature parity from oapi-codegen-exp into mainline. It establishes the architectural patterns the rest of the work will follow. Version awareness: * New globalState.is31 flag, populated once at Generate() entry from swagger.IsOpenAPI31OrLater(). All version-aware logic lives in Go and branches on this flag; templates remain version-blind and consume pre-computed derived fields. * Comment on the field explicitly forbids exposing it to TemplateFunctions to prevent layering violations. Nullable handling: * New schemaIsNullable() helper that branches on globalState.is31: 3.0 reads s.Nullable; 3.1 reads s.Type.Includes("null"). * New schemaPrimaryType() helper strips "null" from the type slice in 3.1 mode so existing dispatch (`*Types.Is("string")` etc.) keeps working for type:["string","null"]. * Four direct .Nullable read sites switched to the helper: schema.go (property construction, array nullable, additionalProperties) and operations.go (response header). * merge_schemas.go's Nullable comparison routed through the helper; an explanatory comment notes that type merging itself is NOT version branched (equalTypes() handles single-element 3.0 and multi-element 3.1 type slices identically), and that result.Type already carries any "null" entry forward in 3.1 mode. Other: * Adds OutputOptions.SkipEnumViaOneOf for Phase 2 (enum-via-oneOf detection); declared now to keep the config schema stable across phases. * Removes the stale "OpenAPI 3.1.x is not yet supported" stderr warning at cmd/oapi-codegen entry; partial 3.1 support now exists and the message will become misleading as later phases land. * Two path parameters in pkg/codegen/test_specs/x-go-type-import-pet.yaml were missing the OpenAPI-required `required: true`. The omission was incidental to what the test exercises (x-go-type-import); the spec is now spec-conformant. Tests: * internal/test/openapi31_nullable/ -- two specs (3.0 nullable:true, 3.1 type:["string","null"]) generate into spec_3_0/ and spec_3_1/ subpackages. Instantiation tests (no string-matching) assert both produce *string and round-trip JSON identically. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/oapi-codegen/oapi-codegen.go | 4 - configuration-schema.json | 4 + .../test/openapi31_nullable/config_3_0.yaml | 7 ++ .../test/openapi31_nullable/config_3_1.yaml | 7 ++ internal/test/openapi31_nullable/doc.go | 9 ++ .../openapi31_nullable_test.go | 107 ++++++++++++++++++ .../test/openapi31_nullable/spec_3_0.yaml | 22 ++++ .../openapi31_nullable/spec_3_0/types.gen.go | 13 +++ .../test/openapi31_nullable/spec_3_1.yaml | 22 ++++ .../openapi31_nullable/spec_3_1/types.gen.go | 13 +++ pkg/codegen/codegen.go | 13 ++- pkg/codegen/configuration.go | 6 + pkg/codegen/merge_schemas.go | 19 +++- pkg/codegen/operations.go | 5 +- pkg/codegen/schema.go | 57 +++++++++- .../test_specs/x-go-type-import-pet.yaml | 2 + 16 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 internal/test/openapi31_nullable/config_3_0.yaml create mode 100644 internal/test/openapi31_nullable/config_3_1.yaml create mode 100644 internal/test/openapi31_nullable/doc.go create mode 100644 internal/test/openapi31_nullable/openapi31_nullable_test.go create mode 100644 internal/test/openapi31_nullable/spec_3_0.yaml create mode 100644 internal/test/openapi31_nullable/spec_3_0/types.gen.go create mode 100644 internal/test/openapi31_nullable/spec_3_1.yaml create mode 100644 internal/test/openapi31_nullable/spec_3_1/types.gen.go diff --git a/cmd/oapi-codegen/oapi-codegen.go b/cmd/oapi-codegen/oapi-codegen.go index f540a6808a..a901702e33 100644 --- a/cmd/oapi-codegen/oapi-codegen.go +++ b/cmd/oapi-codegen/oapi-codegen.go @@ -312,10 +312,6 @@ func main() { errExit("error loading swagger spec in %s\n: %s\n", flag.Arg(0), err) } - if strings.HasPrefix(swagger.OpenAPI, "3.1.") { - fmt.Fprintln(os.Stderr, "WARNING: You are using an OpenAPI 3.1.x specification, which is not yet supported by oapi-codegen (https://github.com/oapi-codegen/oapi-codegen/issues/373) and so some functionality may not be available. Until oapi-codegen supports OpenAPI 3.1, it is recommended to downgrade your spec to 3.0.x") - } - if len(noVCSVersionOverride) > 0 { opts.NoVCSVersionOverride = &noVCSVersionOverride } diff --git a/configuration-schema.json b/configuration-schema.json index 8f902ca708..13ab050d42 100644 --- a/configuration-schema.json +++ b/configuration-schema.json @@ -143,6 +143,10 @@ "type": "boolean", "description": "Whether to skip generation of the Valid() method on enum types" }, + "skip-enum-via-oneof": { + "type": "boolean", + "description": "Disables detection of the OpenAPI 3.1 enum-via-oneOf idiom: a schema with `type: string|integer` and `oneOf:` members that each carry `const` + `title` will normally be emitted as a Go enum with named constants. Set this to true to fall through to the standard union generator instead." + }, "include-tags": { "type": "array", "description": "Only include operations that have one of these tags. Ignored when empty.", diff --git a/internal/test/openapi31_nullable/config_3_0.yaml b/internal/test/openapi31_nullable/config_3_0.yaml new file mode 100644 index 0000000000..0f2dac5130 --- /dev/null +++ b/internal/test/openapi31_nullable/config_3_0.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: spec_3_0 +generate: + models: true +output-options: + skip-prune: true +output: spec_3_0/types.gen.go diff --git a/internal/test/openapi31_nullable/config_3_1.yaml b/internal/test/openapi31_nullable/config_3_1.yaml new file mode 100644 index 0000000000..85bf98d8e4 --- /dev/null +++ b/internal/test/openapi31_nullable/config_3_1.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: spec_3_1 +generate: + models: true +output-options: + skip-prune: true +output: spec_3_1/types.gen.go diff --git a/internal/test/openapi31_nullable/doc.go b/internal/test/openapi31_nullable/doc.go new file mode 100644 index 0000000000..a9e9d8246a --- /dev/null +++ b/internal/test/openapi31_nullable/doc.go @@ -0,0 +1,9 @@ +// Package openapi31_nullable verifies that the OpenAPI 3.1 type-array +// nullable idiom (`type: ["X","null"]`) produces the same Go shape as the +// OpenAPI 3.0 `nullable: true` idiom. The generated types are emitted in +// the spec_3_0/ and spec_3_1/ subpackages and exercised by the +// instantiation tests in this directory. +package openapi31_nullable + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config_3_0.yaml spec_3_0.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config_3_1.yaml spec_3_1.yaml diff --git a/internal/test/openapi31_nullable/openapi31_nullable_test.go b/internal/test/openapi31_nullable/openapi31_nullable_test.go new file mode 100644 index 0000000000..0f8a375e66 --- /dev/null +++ b/internal/test/openapi31_nullable/openapi31_nullable_test.go @@ -0,0 +1,107 @@ +// Package openapi31_nullable verifies that the OpenAPI 3.1 type-array +// nullable idiom (`type: ["X","null"]`) generates the same Go field shape +// as the OpenAPI 3.0 `nullable: true` idiom: a pointer to the underlying +// type. The test is structural -- it instantiates the generated types and +// assigns through the pointer field -- rather than string-matching the +// generated source. +package openapi31_nullable + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spec30 "github.com/oapi-codegen/oapi-codegen/v2/internal/test/openapi31_nullable/spec_3_0" + spec31 "github.com/oapi-codegen/oapi-codegen/v2/internal/test/openapi31_nullable/spec_3_1" +) + +// TestNicknameIsPointer_3_0 asserts that the 3.0 spec's `nullable: true` +// produces a `*string` field. Compile-time verification: the assignment +// of `&nick` only succeeds if Nickname is `*string`. +func TestNicknameIsPointer_3_0(t *testing.T) { + nick := "rex" + p := spec30.Pet{ + Name: "fluffy", + Nickname: &nick, + } + require.NotNil(t, p.Nickname) + assert.Equal(t, "rex", *p.Nickname) + + // nil-nickname round-trip + p2 := spec30.Pet{Name: "fluffy"} + assert.Nil(t, p2.Nickname) +} + +// TestNicknameIsPointer_3_1 asserts that the 3.1 spec's +// `type: ["string","null"]` produces a `*string` field, identical in +// shape to the 3.0 control case. +func TestNicknameIsPointer_3_1(t *testing.T) { + nick := "rex" + p := spec31.Pet{ + Name: "fluffy", + Nickname: &nick, + } + require.NotNil(t, p.Nickname) + assert.Equal(t, "rex", *p.Nickname) + + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.Nickname) +} + +// TestJsonRoundTrip_NullableFields_AcrossVersions asserts that a JSON +// payload with an explicit null nickname unmarshals to (*string)(nil) in +// both spec versions, and that JSON output omits the field when nil due +// to omitempty. The two generated structs must marshal identically for +// the nullable field. +func TestJsonRoundTrip_NullableFields_AcrossVersions(t *testing.T) { + const withName = `{"name":"fluffy"}` + const withBoth = `{"name":"fluffy","nickname":"rex"}` + + for _, tc := range []struct { + name string + // fn30 / fn31 unmarshal the input into each version's Pet type + // and return a JSON re-marshal so we can assert equality. + fn30 func(input string) (string, *string, error) + fn31 func(input string) (string, *string, error) + }{ + { + name: "unmarshal/marshal symmetric across versions", + fn30: func(input string) (string, *string, error) { + var p spec30.Pet + if err := json.Unmarshal([]byte(input), &p); err != nil { + return "", nil, err + } + out, err := json.Marshal(p) + return string(out), p.Nickname, err + }, + fn31: func(input string) (string, *string, error) { + var p spec31.Pet + if err := json.Unmarshal([]byte(input), &p); err != nil { + return "", nil, err + } + out, err := json.Marshal(p) + return string(out), p.Nickname, err + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + for _, in := range []string{withName, withBoth} { + out30, n30, err30 := tc.fn30(in) + require.NoError(t, err30) + out31, n31, err31 := tc.fn31(in) + require.NoError(t, err31) + assert.JSONEq(t, in, out30, "3.0 round-trip should be lossless") + assert.JSONEq(t, in, out31, "3.1 round-trip should be lossless") + assert.JSONEq(t, out30, out31, "3.0 and 3.1 must marshal identically") + if n30 == nil { + assert.Nil(t, n31) + } else { + require.NotNil(t, n31) + assert.Equal(t, *n30, *n31) + } + } + }) + } +} diff --git a/internal/test/openapi31_nullable/spec_3_0.yaml b/internal/test/openapi31_nullable/spec_3_0.yaml new file mode 100644 index 0000000000..9c39e38cab --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_0.yaml @@ -0,0 +1,22 @@ +openapi: 3.0.3 +info: + title: Nullable test (OpenAPI 3.0) + version: 1.0.0 + description: | + Control case for the OpenAPI 3.1 nullable backport. The Nickname field + is nullable via the 3.0 idiom (the explicit `nullable: true` flag). +paths: {} +components: + schemas: + Pet: + type: object + required: + - name + properties: + name: + type: string + description: Required, non-nullable. + nickname: + type: string + nullable: true + description: "Optional, nullable via 3.0 `nullable: true`." diff --git a/internal/test/openapi31_nullable/spec_3_0/types.gen.go b/internal/test/openapi31_nullable/spec_3_0/types.gen.go new file mode 100644 index 0000000000..f33f29cde9 --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_0/types.gen.go @@ -0,0 +1,13 @@ +// Package spec_3_0 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package spec_3_0 + +// Pet defines model for Pet. +type Pet struct { + // Name Required, non-nullable. + Name string `json:"name"` + + // Nickname Optional, nullable via 3.0 `nullable: true`. + Nickname *string `json:"nickname,omitempty"` +} diff --git a/internal/test/openapi31_nullable/spec_3_1.yaml b/internal/test/openapi31_nullable/spec_3_1.yaml new file mode 100644 index 0000000000..8c50f4baa3 --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_1.yaml @@ -0,0 +1,22 @@ +openapi: 3.1.0 +info: + title: Nullable test (OpenAPI 3.1) + version: 1.0.0 + description: | + The Nickname field is nullable via the 3.1 idiom (the type array + containing "null"). The generated Go must match the 3.0 control case + so callers see no behavioral difference between spec versions. +paths: {} +components: + schemas: + Pet: + type: object + required: + - name + properties: + name: + type: string + description: Required, non-nullable. + nickname: + type: ["string", "null"] + description: Optional, nullable via 3.1 type-array idiom. diff --git a/internal/test/openapi31_nullable/spec_3_1/types.gen.go b/internal/test/openapi31_nullable/spec_3_1/types.gen.go new file mode 100644 index 0000000000..d521ddb750 --- /dev/null +++ b/internal/test/openapi31_nullable/spec_3_1/types.gen.go @@ -0,0 +1,13 @@ +// Package spec_3_1 provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package spec_3_1 + +// Pet defines model for Pet. +type Pet struct { + // Name Required, non-nullable. + Name string `json:"name"` + + // Nickname Optional, nullable via 3.1 type-array idiom. + Nickname *string `json:"nickname,omitempty"` +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 22317bda16..ac24ac656b 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -49,8 +49,16 @@ var templates embed.FS // globalState stores all global state. Please don't put global state anywhere // else so that we can easily track it. var globalState struct { - options Configuration - spec *openapi3.T + options Configuration + spec *openapi3.T + // is31 is true when the loaded spec declares OpenAPI version >=3.1. + // All version-aware behavior (e.g. nullable detection, webhook emission) + // reads from this field. Do NOT expose this field to templates -- + // templates are version-blind and consume pre-computed derived fields + // (e.g. Schema.Nullable) populated by version-aware Go helpers. + // Adding `is31` to TemplateFunctions or branching on the OpenAPI + // version inside a template is a layering violation. + is31 bool importMapping importMap // initialismsMap stores initialisms as "lower(initialism) -> initialism" map. // List of initialisms was taken from https://staticcheck.io/docs/configuration/options/#initialisms. @@ -150,6 +158,7 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { // This is global state globalState.options = opts globalState.spec = spec + globalState.is31 = spec.IsOpenAPI31OrLater() globalState.importMapping = constructImportMapping(opts.ImportMapping) if opts.OutputOptions.TypeMapping != nil { globalState.typeMapping = DefaultTypeMapping.Merge(*opts.OutputOptions.TypeMapping) diff --git a/pkg/codegen/configuration.go b/pkg/codegen/configuration.go index f2cc4c0162..0876b5380a 100644 --- a/pkg/codegen/configuration.go +++ b/pkg/codegen/configuration.go @@ -317,6 +317,12 @@ type OutputOptions struct { // defined constants; set this to true to suppress that method when it // conflicts with user-defined methods of the same name. SkipEnumValidate bool `yaml:"skip-enum-validate,omitempty"` + // SkipEnumViaOneOf disables detection of the OpenAPI 3.1 enum-via-oneOf + // idiom: a schema with `type: string|integer` and `oneOf:` members that + // each carry `const` + `title` will normally be emitted as a Go enum with + // named constants. Set this to true to fall through to the standard union + // generator instead. + SkipEnumViaOneOf bool `yaml:"skip-enum-via-oneof,omitempty"` // Only include operations that have one of these tags. Ignored when empty. IncludeTags []string `yaml:"include-tags,omitempty"` // Exclude operations that have one of these tags. Ignored when empty. diff --git a/pkg/codegen/merge_schemas.go b/pkg/codegen/merge_schemas.go index bfc5804b15..8d8025bbcd 100644 --- a/pkg/codegen/merge_schemas.go +++ b/pkg/codegen/merge_schemas.go @@ -329,7 +329,24 @@ func mergeOpenapiSchemas(s1, s2 openapi3.Schema, allOf bool, seenSchemaRef map[s } result.ExclusiveMax = s1.ExclusiveMax - if s1.Nullable != s2.Nullable { + // Compare nullability via schemaIsNullable so this works the same way + // regardless of spec version: in 3.0 it reads s.Nullable, in 3.1 it + // reads "null" from the type array. Type merging itself is NOT version + // branched -- the equalTypes() check at result.Type assignment above + // uses the same slice-comparison code path in both modes: + // + // 3.0: ["string"] vs ["string"] -> equal -> merged + // 3.1: ["string","null"] vs ["string","null"] -> equal -> merged + // 3.1 mismatch: ["string","null"] vs ["string"] -> length differs + // -> error from + // equalTypes + // + // Because result.Type was already assigned (line 232) and carries any + // "null" entry forward, the merged result is correctly nullable in 3.1 + // without needing to touch result.Nullable. The result.Nullable copy + // below is a no-op in 3.1 (s1.Nullable is always false there) but kept + // for 3.0 correctness, where Nullable is the only nullability carrier. + if schemaIsNullable(&s1) != schemaIsNullable(&s2) { return openapi3.Schema{}, errors.New("merging two schemas with different Nullable") } diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 94b6911c71..e7b139d8b6 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1121,7 +1121,10 @@ func GenerateResponseDefinitions(operationID string, responses map[string]*opena if err != nil { return nil, fmt.Errorf("error generating response header definition: %w", err) } - nullable := header.Value.Schema != nil && header.Value.Schema.Value != nil && header.Value.Schema.Value.Nullable + var nullable bool + if header.Value.Schema != nil { + nullable = schemaIsNullable(header.Value.Schema.Value) + } headerDefinition := ResponseHeaderDefinition{ Name: headerName, GoName: SchemaNameToTypeName(headerName), diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 2e216537f0..dab2727bca 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -323,6 +323,49 @@ func PropertiesEqual(a, b Property) bool { return a.JsonFieldName == b.JsonFieldName && a.Schema.TypeDecl() == b.Schema.TypeDecl() && a.Required == b.Required } +// schemaIsNullable reports whether an OpenAPI schema represents a nullable +// type, branching on the spec version detected at Generate() entry. In +// OpenAPI 3.0 nullability is the explicit `nullable: true` flag; in +// OpenAPI 3.1 the `nullable` keyword was removed in favor of including +// "null" in the type array (e.g. `type: ["string","null"]`). +// +// Cross-version misuse (a 3.1 spec using `nullable: true`, or a 3.0 spec +// using `type: [..., "null"]`) is rejected by spec validation at the start +// of Generate() unless Compatibility.SkipSpecValidation is set, so this +// helper trusts its input to use the version-appropriate idiom. +func schemaIsNullable(s *openapi3.Schema) bool { + if s == nil { + return false + } + if globalState.is31 { + return s.Type != nil && s.Type.Includes("null") + } + return s.Nullable +} + +// schemaPrimaryType returns the type slice used for primitive-type dispatch +// (`*Types.Is("string")` etc.). In OpenAPI 3.0 the type slice has at most +// one entry and is returned unchanged. In OpenAPI 3.1 the "null" entry is +// the nullability indicator (handled separately by schemaIsNullable); we +// strip it here so the rest of the codegen can keep dispatching with +// `Is("...")` as it always has. +func schemaPrimaryType(t *openapi3.Types) *openapi3.Types { + if t == nil || !globalState.is31 { + return t + } + s := t.Slice() + if len(s) <= 1 { + return t + } + stripped := make(openapi3.Types, 0, len(s)) + for _, name := range s { + if name != "null" { + stripped = append(stripped, name) + } + } + return &stripped +} + func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { // Add a fallback value in case the sref is nil. // i.e. the parent schema defines a type:array, but the array has @@ -574,7 +617,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { Schema: pSchema, Required: required, Description: description, - Nullable: p.Value.Nullable, + Nullable: schemaIsNullable(p.Value), ReadOnly: p.Value.ReadOnly, WriteOnly: p.Value.WriteOnly, Extensions: combinedSchemaExtensions(p), @@ -697,7 +740,13 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { // all non-object types. func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schema) error { f := schema.Format - t := schema.Type + // In OpenAPI 3.1, `type` may be a multi-element array including "null" + // to express nullability. The dispatch below uses `*Types.Is("...")`, + // which only matches single-element type slices. Strip "null" up front + // so the dispatch sees the underlying primitive type. Nullability + // itself was already captured by schemaIsNullable() at the call sites + // that wrap the result in a pointer. + t := schemaPrimaryType(schema.Type) if t.Is("array") { // For arrays, we'll get the type of the Items and throw a @@ -725,7 +774,7 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem } typeDeclaration := arrayType.TypeDecl() - if arrayType.OAPISchema != nil && arrayType.OAPISchema.Nullable { + if schemaIsNullable(arrayType.OAPISchema) { if globalState.options.OutputOptions.NullableType { typeDeclaration = "nullable.Nullable[" + typeDeclaration + "]" } else { @@ -906,7 +955,7 @@ func additionalPropertiesType(schema Schema) string { if schema.AdditionalPropertiesType.RefType != "" { addPropsType = schema.AdditionalPropertiesType.RefType } - if schema.AdditionalPropertiesType.OAPISchema != nil && schema.AdditionalPropertiesType.OAPISchema.Nullable { + if schemaIsNullable(schema.AdditionalPropertiesType.OAPISchema) { addPropsType = "*" + addPropsType } return addPropsType diff --git a/pkg/codegen/test_specs/x-go-type-import-pet.yaml b/pkg/codegen/test_specs/x-go-type-import-pet.yaml index b236564bb5..0ce960633f 100644 --- a/pkg/codegen/test_specs/x-go-type-import-pet.yaml +++ b/pkg/codegen/test_specs/x-go-type-import-pet.yaml @@ -97,6 +97,7 @@ paths: path: github.com/mailru/easyjson - name: path in: path + required: true schema: x-go-type: pgtype.Color x-go-type-import: @@ -187,6 +188,7 @@ components: VersionPath: name: versionPath in: path + required: true schema: x-go-type: swag.Swag x-go-type-import: From 89b534f127c11ea11aff605be7080206aaf2d32c Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 18:01:57 -0700 Subject: [PATCH 03/30] feat(codegen): OpenAPI 3.1 enum-via-oneOf detection Phase 2 of the 3.1 backport. Detects the 3.1 enum-via-oneOf idiom and routes it through the existing typed-enum codegen path so users see the same Go shape they'd get from a plain `enum:` array. The idiom (per OpenAPI 3.1 / JSON Schema 2020-12): Severity: type: integer # or "string" oneOf: - title: HIGH const: 2 description: An urgent problem - title: MEDIUM const: 1 - title: LOW const: 0 Generates: type Severity int const ( HIGH Severity = 2 LOW Severity = 0 MEDIUM Severity = 1 ) Implementation: * New detectEnumViaOneOf() helper in pkg/codegen/schema.go. Trigger conditions: globalState.is31 is true, !SkipEnumViaOneOf, schema's primary type is "string" or "integer", every oneOf branch carries non-empty Title AND non-nil Const, and no branch is itself a composition or has Properties. * GenerateGoSchema short-circuits between the AllOf branch and the type dispatch: when the idiom matches, populate Schema.EnumValues directly and register an AdditionalType for non-toplevel schemas so the existing EnumDefinition collector picks them up. Negative matches fall through to the standard oneOf union generator. * No template changes -- the existing constants.tmpl renders the typed enum + Valid() method via the standard EnumDefinition path. * The OutputOptions.SkipEnumViaOneOf flag declared in Phase 1 is now actually consulted here. Tests: * internal/test/enum_via_oneof/ -- ports the exp test fixture (Severity int, Color string, MixedOneOf negative path). Five instantiation tests (no string-matching) cover constant values, JSON round-trips, and the negative path's compile-time alias shape (a plain string is directly assignable to MixedOneOf, proving it did NOT become a typed-enum newtype). Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/enum_via_oneof/config.yaml | 7 ++ internal/test/enum_via_oneof/doc.go | 7 ++ .../test/enum_via_oneof/enum_via_oneof.gen.go | 55 +++++++++ .../enum_via_oneof/enum_via_oneof_test.go | 68 +++++++++++ internal/test/enum_via_oneof/spec.yaml | 44 +++++++ pkg/codegen/schema.go | 110 ++++++++++++++++++ 6 files changed, 291 insertions(+) create mode 100644 internal/test/enum_via_oneof/config.yaml create mode 100644 internal/test/enum_via_oneof/doc.go create mode 100644 internal/test/enum_via_oneof/enum_via_oneof.gen.go create mode 100644 internal/test/enum_via_oneof/enum_via_oneof_test.go create mode 100644 internal/test/enum_via_oneof/spec.yaml diff --git a/internal/test/enum_via_oneof/config.yaml b/internal/test/enum_via_oneof/config.yaml new file mode 100644 index 0000000000..d2840693ff --- /dev/null +++ b/internal/test/enum_via_oneof/config.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: enum_via_oneof +generate: + models: true +output-options: + skip-prune: true +output: enum_via_oneof.gen.go diff --git a/internal/test/enum_via_oneof/doc.go b/internal/test/enum_via_oneof/doc.go new file mode 100644 index 0000000000..b3f96d9d90 --- /dev/null +++ b/internal/test/enum_via_oneof/doc.go @@ -0,0 +1,7 @@ +// Package enum_via_oneof verifies the OpenAPI 3.1 enum-via-oneOf idiom: +// a scalar schema whose oneOf branches each carry `title` + `const` +// renders as a Go typed enum, while a near-miss schema (one branch +// missing `title`) falls through to the standard union generator. +package enum_via_oneof + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/enum_via_oneof/enum_via_oneof.gen.go b/internal/test/enum_via_oneof/enum_via_oneof.gen.go new file mode 100644 index 0000000000..836733bf24 --- /dev/null +++ b/internal/test/enum_via_oneof/enum_via_oneof.gen.go @@ -0,0 +1,55 @@ +// Package enum_via_oneof provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package enum_via_oneof + +// Defines values for Color. +const ( + Blue Color = "b" + Green Color = "g" + Red Color = "r" +) + +// Valid indicates whether the value is a known member of the Color enum. +func (e Color) Valid() bool { + switch e { + case Blue: + return true + case Green: + return true + case Red: + return true + default: + return false + } +} + +// Defines values for Severity. +const ( + HIGH Severity = 2 + LOW Severity = 0 + MEDIUM Severity = 1 +) + +// Valid indicates whether the value is a known member of the Severity enum. +func (e Severity) Valid() bool { + switch e { + case HIGH: + return true + case LOW: + return true + case MEDIUM: + return true + default: + return false + } +} + +// Color defines model for Color. +type Color string + +// MixedOneOf defines model for MixedOneOf. +type MixedOneOf = string + +// Severity How urgent a problem is. +type Severity int diff --git a/internal/test/enum_via_oneof/enum_via_oneof_test.go b/internal/test/enum_via_oneof/enum_via_oneof_test.go new file mode 100644 index 0000000000..8ce583cf64 --- /dev/null +++ b/internal/test/enum_via_oneof/enum_via_oneof_test.go @@ -0,0 +1,68 @@ +// Package enum_via_oneof tests the OpenAPI 3.1 enum-via-oneOf idiom: a +// scalar schema whose oneOf branches each carry `title` + `const` is +// emitted as a Go typed enum with named constants. +// +// The tests are structural -- they instantiate the generated types and +// assert constant values and JSON round-trips -- rather than string- +// matching the generated source. +package enum_via_oneof + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestSeverityConstants verifies that an integer enum-via-oneOf produces +// `type Severity int` with the right per-branch constant values. +func TestSeverityConstants(t *testing.T) { + assert.Equal(t, 2, int(HIGH)) + assert.Equal(t, 1, int(MEDIUM)) + assert.Equal(t, 0, int(LOW)) +} + +// TestSeverityJSONRoundTrip confirms Severity marshals as its integer +// value, not as a wrapped union or as a string. +func TestSeverityJSONRoundTrip(t *testing.T) { + data, err := json.Marshal(HIGH) + require.NoError(t, err) + assert.JSONEq(t, `2`, string(data)) + + var got Severity + require.NoError(t, json.Unmarshal([]byte(`1`), &got)) + assert.Equal(t, MEDIUM, got) +} + +// TestColorConstants verifies that a string enum-via-oneOf produces +// `type Color string` with the right per-branch constant values. +func TestColorConstants(t *testing.T) { + assert.Equal(t, "r", string(Red)) + assert.Equal(t, "g", string(Green)) + assert.Equal(t, "b", string(Blue)) +} + +// TestColorJSONRoundTrip confirms Color marshals as its string value. +func TestColorJSONRoundTrip(t *testing.T) { + data, err := json.Marshal(Red) + require.NoError(t, err) + assert.JSONEq(t, `"r"`, string(data)) + + var got Color + require.NoError(t, json.Unmarshal([]byte(`"b"`), &got)) + assert.Equal(t, Blue, got) +} + +// TestMixedOneOfFallsThrough verifies the negative path: a oneOf where +// any branch lacks `title` must NOT trigger enum-via-oneOf detection. +// MixedOneOf is emitted by the standard handler as `type MixedOneOf = +// string` (an alias), so a plain string is directly assignable. If +// detection were over-eager, MixedOneOf would become a newtype `type +// MixedOneOf string` and the assignment below would fail to compile +// (a string would not be directly assignable to a newtype value). +func TestMixedOneOfFallsThrough(t *testing.T) { + var s = "anything" + var m MixedOneOf = s + assert.Equal(t, "anything", string(m)) +} diff --git a/internal/test/enum_via_oneof/spec.yaml b/internal/test/enum_via_oneof/spec.yaml new file mode 100644 index 0000000000..b7a2a1bf48 --- /dev/null +++ b/internal/test/enum_via_oneof/spec.yaml @@ -0,0 +1,44 @@ +# OpenAPI 3.1 enum-via-oneOf idiom: a schema with a scalar `type` plus +# `oneOf` branches that each carry `const` + `title` should be emitted as +# a Go typed enum with named constants (the title becomes the constant +# name, the const becomes the value). +openapi: 3.1.0 +info: + title: Enum via oneOf test + version: 1.0.0 +paths: {} +components: + schemas: + Severity: + description: How urgent a problem is. + type: integer + oneOf: + - title: HIGH + const: 2 + description: An urgent problem + - title: MEDIUM + const: 1 + - title: LOW + const: 0 + description: Can wait forever + + Color: + type: string + oneOf: + - title: Red + const: r + description: Warm, high-energy + - title: Green + const: g + - title: Blue + const: b + description: Cool, calm + + # Negative path: one branch lacks `title`, so the idiom must NOT + # match. The schema falls through to the standard union generator. + MixedOneOf: + type: string + oneOf: + - title: Alpha + const: a + - const: b diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index dab2727bca..27ac1474ba 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -343,6 +343,76 @@ func schemaIsNullable(s *openapi3.Schema) bool { return s.Nullable } +// enumViaOneOfValue is one branch of an OpenAPI 3.1 enum-via-oneOf schema. +// Title is the per-branch identifier (becomes the Go constant name); Value +// is the stringified `const` (the Go literal, unquoted; the enum +// generation pipeline applies the appropriate quoting via ValueWrapper). +type enumViaOneOfValue struct { + Title string + Value string +} + +// detectEnumViaOneOf reports whether the schema matches the OpenAPI 3.1 +// enum-via-oneOf idiom and, if so, returns the per-branch values in +// declaration order. +// +// The idiom: +// +// Severity: +// type: integer # or "string" +// oneOf: +// - title: HIGH +// const: 2 +// description: An urgent problem # optional +// - ... +// +// All members must carry both `title` and `const`; no member may itself +// be a composition (oneOf/allOf/anyOf) or declare properties. The outer +// schema's primary type must be a scalar (string or integer). +// +// Gated on globalState.is31 (the keyword `const` lands in OpenAPI 3.1) +// AND !SkipEnumViaOneOf so users can fall through to the standard union +// generator on demand. +func detectEnumViaOneOf(schema *openapi3.Schema) ([]enumViaOneOfValue, bool) { + if !globalState.is31 { + return nil, false + } + if globalState.options.OutputOptions.SkipEnumViaOneOf { + return nil, false + } + if schema == nil || len(schema.OneOf) == 0 { + return nil, false + } + primary := schemaPrimaryType(schema.Type) + if primary == nil { + return nil, false + } + if !primary.Is("string") && !primary.Is("integer") { + return nil, false + } + items := make([]enumViaOneOfValue, 0, len(schema.OneOf)) + for _, ref := range schema.OneOf { + if ref == nil || ref.Value == nil { + return nil, false + } + m := ref.Value + if m.Title == "" || m.Const == nil { + return nil, false + } + if len(m.OneOf) > 0 || len(m.AllOf) > 0 || len(m.AnyOf) > 0 { + return nil, false + } + if len(m.Properties) > 0 { + return nil, false + } + items = append(items, enumViaOneOfValue{ + Title: m.Title, + Value: fmt.Sprintf("%v", m.Const), + }) + } + return items, true +} + // schemaPrimaryType returns the type slice used for primitive-type dispatch // (`*Types.Is("string")` etc.). In OpenAPI 3.0 the type slice has at most // one entry and is returned unchanged. In OpenAPI 3.1 the "null" entry is @@ -500,6 +570,46 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { return mergedSchema, nil } + // OpenAPI 3.1 enum-via-oneOf: a scalar schema whose oneOf branches + // each carry `title` + `const` is rendered as a Go typed enum, not as + // a union. Detection is gated by version + the SkipEnumViaOneOf flag; + // when the idiom does not match, fall through to standard handling + // (which routes oneOf into generateUnion further below). + if items, ok := detectEnumViaOneOf(schema); ok { + if err := oapiSchemaToGoType(schema, path, &outSchema); err != nil { + return Schema{}, fmt.Errorf("error resolving primitive type for enum-via-oneOf: %w", err) + } + // Force a typed declaration -- enums must not be aliased. + outSchema.DefineViaAlias = false + outSchema.EnumValues = make(map[string]string, len(items)) + for _, it := range items { + outSchema.EnumValues[SchemaNameToTypeName(it.Title)] = it.Value + } + // Non-toplevel schemas need an explicit AdditionalType so the + // downstream EnumDefinition collector picks them up; toplevel + // schemas are already collected via the components walk. + if len(path) > 1 { + var typeName string + if extension, ok := schema.Extensions[extGoTypeName]; ok { + tn, err := extString(extension) + if err != nil { + return outSchema, fmt.Errorf("invalid value for %q: %w", extGoTypeName, err) + } + typeName = tn + } else { + typeName = SchemaNameToTypeName(PathToTypeName(path)) + } + typeDef := TypeDefinition{ + TypeName: typeName, + JsonName: strings.Join(path, "."), + Schema: outSchema, + } + outSchema.AdditionalTypes = append(outSchema.AdditionalTypes, typeDef) + outSchema.RefType = typeName + } + return outSchema, nil + } + // Schema type and format, eg. string / binary t := schema.Type // Handle objects and empty schemas first as a special case From af1644263ae9c070fa2aac5f2c7e3a65a1f31b47 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 18:34:26 -0700 Subject: [PATCH 04/30] feat(codegen): OpenAPI 3.1 webhooks -- WebhookInitiator + WebhookReceiver Phase 3 of the 3.1 backport. Generates client-side and server-side support for `webhooks:` declared in OpenAPI 3.1 specs. The output mirrors the existing Client / Server interfaces in shape but lives in its own types, matching the conventions oapi-codegen-exp established. Data model: * OperationDefinition gains IsWebhook bool and WebhookName string. * New WebhookOperationDefinitions() walks swagger.Webhooks and produces OperationDefinitions in the same shape as path operations, minus the path-alias and path-parameter logic (webhooks have no path template). No version gate -- kin-openapi only populates the Webhooks field for 3.1+ documents, so 3.0 specs short-circuit on the empty map. Generated client (WebhookInitiator): * New pkg/codegen/templates/webhook-initiator.tmpl, modeled on client.tmpl. Emits: type WebhookInitiator struct{ Client; RequestEditors } NewWebhookInitiator + WebhookInitiatorOption + WithWebhookHTTPClient + WithWebhookRequestEditorFn WebhookInitiatorInterface per-webhook methods (Op + OpWithBody) and request builders (NewOpWebhookRequest + NewOpWebhookRequestWithBody) * No stored Server -- the target URL is supplied per-call by the caller (typically discovered from a subscription registration). * Gated by Generate.Client (paired with the path Client). Generated server (WebhookReceiver, stdhttp): * New pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl. Emits: type WebhookReceiverInterface { HandleOpWebhook(w, r) for each } type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler OpWebhookHandler(si, middlewares...) http.Handler -- per-webhook factory; caller mounts the returned http.Handler at the URL path they advertise to subscribers. * Methods take plain (w, r) -- the user reads/parses the body themselves. Simpler than the full ServerInterfaceWrapper machinery; parameter binding can be added later if the demand surfaces. * Gated by Generate.StdHTTPServer (paired with the path stdhttp ServerInterface). Other frameworks are left as follow-ups. Wiring (codegen.go): * allOps = ops + webhookOps is passed to OperationImports and GenerateTypeDefinitions so webhook bodies and responses generate their type definitions and imports normally. * New GenerateWebhookInitiator and GenerateStdHTTPWebhookReceiver entry points in operations.go, modeled on GenerateClient and GenerateStdHTTPServer. * Output ordering: webhook initiator follows ClientWithResponses; webhook receiver follows the path StdHTTPServer block. Tests (internal/test/webhooks/): * spec.yaml declares a single petStatusChanged webhook with a JSON PetStatusEvent body and a 204 response. * TestWebhookRoundTrip mounts the generated factory against an httptest.Server and fires a webhook via the initiator; asserts the payload, method, and Content-Type round-trip intact. * TestWebhookInitiatorRequestEditor verifies WithWebhookRequestEditorFn applies on every outgoing request (parity with the path Client). * TestWebhookReceiverMiddleware documents middleware composition order: the factory wraps in `for _, mw := range middlewares { h = mw(h) }`, so the LAST middleware passed becomes the outermost wrapper. Example (examples/webhook/): * Door badge reader scenario ported from oapi-codegen-exp/examples/webhook. The server randomly generates enter/exit badge events every second and fires the appropriate webhook to all registered subscribers; the client subscribes to both event kinds via the path API, runs a local HTTP receiver to handle inbound events, and deregisters cleanly on exit. Demonstrates the full Client/Server + WebhookInitiator/Receiver pairing in one example. * Lives as a regular package under examples/go.mod -- no separate module, no tools.go shim. `make generate` from examples/ regenerates via the package's go:generate directive. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/webhook/client/main.go | 150 +++ examples/webhook/config.yaml | 12 + examples/webhook/doc.go | 13 + examples/webhook/door-badge-reader.yaml | 139 +++ examples/webhook/doorbadge.gen.go | 902 ++++++++++++++++++ examples/webhook/server/main.go | 186 ++++ internal/test/webhooks/config.yaml | 9 + internal/test/webhooks/doc.go | 7 + internal/test/webhooks/spec.yaml | 35 + internal/test/webhooks/webhooks.gen.go | 455 +++++++++ internal/test/webhooks/webhooks_test.go | 158 +++ pkg/codegen/codegen.go | 50 +- pkg/codegen/operations.go | 124 +++ .../stdhttp/std-http-webhook-receiver.tmpl | 32 + pkg/codegen/templates/webhook-initiator.tmpl | 235 +++++ 15 files changed, 2505 insertions(+), 2 deletions(-) create mode 100644 examples/webhook/client/main.go create mode 100644 examples/webhook/config.yaml create mode 100644 examples/webhook/doc.go create mode 100644 examples/webhook/door-badge-reader.yaml create mode 100644 examples/webhook/doorbadge.gen.go create mode 100644 examples/webhook/server/main.go create mode 100644 internal/test/webhooks/config.yaml create mode 100644 internal/test/webhooks/doc.go create mode 100644 internal/test/webhooks/spec.yaml create mode 100644 internal/test/webhooks/webhooks.gen.go create mode 100644 internal/test/webhooks/webhooks_test.go create mode 100644 pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl create mode 100644 pkg/codegen/templates/webhook-initiator.tmpl diff --git a/examples/webhook/client/main.go b/examples/webhook/client/main.go new file mode 100644 index 0000000000..e60ab3b04e --- /dev/null +++ b/examples/webhook/client/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "time" + + "github.com/google/uuid" + + doorbadge "github.com/oapi-codegen/oapi-codegen/v2/examples/webhook" +) + +// WebhookReceiver implements doorbadge.WebhookReceiverInterface. +type WebhookReceiver struct{} + +var _ doorbadge.WebhookReceiverInterface = (*WebhookReceiver)(nil) + +func (wr *WebhookReceiver) HandleEnterEventWebhook(w http.ResponseWriter, r *http.Request) { + var person doorbadge.Person + if err := json.NewDecoder(r.Body).Decode(&person); err != nil { + log.Printf("Error decoding enter event: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("ENTER: %s", person.Name) + w.WriteHeader(http.StatusOK) +} + +func (wr *WebhookReceiver) HandleExitEventWebhook(w http.ResponseWriter, r *http.Request) { + var person doorbadge.Person + if err := json.NewDecoder(r.Body).Decode(&person); err != nil { + log.Printf("Error decoding exit event: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + log.Printf("EXIT: %s", person.Name) + w.WriteHeader(http.StatusOK) +} + +func registerWebhook(client *http.Client, serverAddr, kind, url string) (uuid.UUID, error) { + body, err := json.Marshal(doorbadge.WebhookRegistration{URL: url}) + if err != nil { + return uuid.UUID{}, err + } + + resp, err := client.Post( + serverAddr+"/api/webhook/"+kind, + "application/json", + bytes.NewReader(body), + ) + if err != nil { + return uuid.UUID{}, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + return uuid.UUID{}, fmt.Errorf("unexpected status %d", resp.StatusCode) + } + + var regResp doorbadge.WebhookRegistrationResponse + if err := json.NewDecoder(resp.Body).Decode(®Resp); err != nil { + return uuid.UUID{}, err + } + return regResp.ID, nil +} + +func deregisterWebhook(client *http.Client, serverAddr string, id uuid.UUID) error { + req, err := http.NewRequest(http.MethodDelete, serverAddr+"/api/webhook/"+id.String(), nil) + if err != nil { + return err + } + resp, err := client.Do(req) + if err != nil { + return err + } + _ = resp.Body.Close() + return nil +} + +func main() { + serverAddr := flag.String("server", "http://localhost:8080", "Badge reader server address") + duration := flag.Duration("duration", 30*time.Second, "How long to listen for events") + flag.Parse() + + // Start the webhook receiver on an ephemeral port. + receiver := &WebhookReceiver{} + + mux := http.NewServeMux() + mux.Handle("POST /enter", doorbadge.EnterEventWebhookHandler(receiver)) + mux.Handle("POST /exit", doorbadge.ExitEventWebhookHandler(receiver)) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Fatalf("Failed to listen: %v", err) + } + callbackPort := listener.Addr().(*net.TCPAddr).Port + baseURL := fmt.Sprintf("http://localhost:%d", callbackPort) + log.Printf("Webhook receiver listening on port %d", callbackPort) + + srv := &http.Server{Handler: mux} + go func() { + if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed { + log.Printf("Webhook server stopped: %v", err) + } + }() + + // Register webhooks for both event kinds. + client := &http.Client{} + + kinds := [2]string{"enterEvent", "exitEvent"} + urls := [2]string{baseURL + "/enter", baseURL + "/exit"} + var registrationIDs [2]uuid.UUID + + for i, kind := range kinds { + id, err := registerWebhook(client, *serverAddr, kind, urls[i]) + if err != nil { + log.Fatalf("Failed to register %s webhook: %v", kind, err) + } + registrationIDs[i] = id + log.Printf("Registered %s webhook: id=%s url=%s", kind, id, urls[i]) + } + + log.Printf("Listening for events for %s...", *duration) + + // Wait for the specified duration. + time.Sleep(*duration) + + // Deregister webhooks cleanly. + log.Printf("Duration elapsed, deregistering webhooks...") + for i, id := range registrationIDs { + if err := deregisterWebhook(client, *serverAddr, id); err != nil { + log.Printf("Failed to deregister %s webhook: %v", kinds[i], err) + continue + } + log.Printf("Deregistered %s webhook: id=%s", kinds[i], id) + } + + // Shut down the local webhook server. + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + + log.Printf("Done!") +} diff --git a/examples/webhook/config.yaml b/examples/webhook/config.yaml new file mode 100644 index 0000000000..0f14853154 --- /dev/null +++ b/examples/webhook/config.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: doorbadge +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true + # Use ToCamelCaseWithInitialisms so spec fields like `url` and `id` + # generate as `URL` and `ID` (matching the example's Go code). + name-normalizer: ToCamelCaseWithInitialisms +output: doorbadge.gen.go diff --git a/examples/webhook/doc.go b/examples/webhook/doc.go new file mode 100644 index 0000000000..86e08c4af4 --- /dev/null +++ b/examples/webhook/doc.go @@ -0,0 +1,13 @@ +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml door-badge-reader.yaml + +// Package doorbadge provides an example of OpenAPI 3.1 webhooks. +// A door badge reader server generates random enter/exit events and +// notifies registered webhook listeners. +// +// You can run the example by running these two commands in parallel: +// +// go run ./server --port 8080 +// go run ./client --server http://localhost:8080 +// +// You can run multiple clients and they will all get the notifications. +package doorbadge diff --git a/examples/webhook/door-badge-reader.yaml b/examples/webhook/door-badge-reader.yaml new file mode 100644 index 0000000000..ac37ce40dd --- /dev/null +++ b/examples/webhook/door-badge-reader.yaml @@ -0,0 +1,139 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: Door Badge Reader + description: | + A door badge reader service that demonstrates OpenAPI 3.1 webhooks. + Clients register for enterEvent and exitEvent webhooks. The server + randomly generates badge events and notifies all registered listeners. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +paths: + /api/webhook/{kind}: + post: + summary: Register a webhook + description: | + Registers a webhook for the given event kind. The server will POST + to the provided URL whenever an event of that kind occurs. + operationId: RegisterWebhook + parameters: + - name: kind + in: path + required: true + schema: + type: string + enum: + - enterEvent + - exitEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookRegistration' + responses: + '201': + description: Webhook registered + content: + application/json: + schema: + $ref: '#/components/schemas/WebhookRegistrationResponse' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/webhook/{id}: + delete: + summary: Deregister a webhook + description: Removes a previously registered webhook by its ID. + operationId: DeregisterWebhook + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Webhook deregistered + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +webhooks: + enterEvent: + post: + summary: Person entered the building + operationId: EnterEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + responses: + '200': + description: Event received + exitEvent: + post: + summary: Person exited the building + operationId: ExitEvent + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/Person' + responses: + '200': + description: Event received +components: + schemas: + WebhookRegistration: + type: object + required: + - url + properties: + url: + type: string + format: uri + description: URL to receive webhook events + + WebhookRegistrationResponse: + type: object + required: + - id + properties: + id: + type: string + format: uuid + description: Unique identifier for this webhook registration + + Person: + type: object + required: + - name + properties: + name: + type: string + description: Name of the person who badged in or out + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + description: Error code + message: + type: string + description: Error message diff --git a/examples/webhook/doorbadge.gen.go b/examples/webhook/doorbadge.gen.go new file mode 100644 index 0000000000..ae099d4861 --- /dev/null +++ b/examples/webhook/doorbadge.gen.go @@ -0,0 +1,902 @@ +//go:build go1.22 + +// Package doorbadge provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package doorbadge + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/oapi-codegen/runtime" + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Defines values for RegisterWebhookParamsKind. +const ( + EnterEvent RegisterWebhookParamsKind = "enterEvent" + ExitEvent RegisterWebhookParamsKind = "exitEvent" +) + +// Valid indicates whether the value is a known member of the RegisterWebhookParamsKind enum. +func (e RegisterWebhookParamsKind) Valid() bool { + switch e { + case EnterEvent: + return true + case ExitEvent: + return true + default: + return false + } +} + +// Error defines model for Error. +type Error struct { + // Code Error code + Code int32 `json:"code"` + + // Message Error message + Message string `json:"message"` +} + +// Person defines model for Person. +type Person struct { + // Name Name of the person who badged in or out + Name string `json:"name"` +} + +// WebhookRegistration defines model for WebhookRegistration. +type WebhookRegistration struct { + // URL URL to receive webhook events + URL string `json:"url"` +} + +// WebhookRegistrationResponse defines model for WebhookRegistrationResponse. +type WebhookRegistrationResponse struct { + // ID Unique identifier for this webhook registration + ID openapi_types.UUID `json:"id"` +} + +// RegisterWebhookParamsKind defines parameters for RegisterWebhook. +type RegisterWebhookParamsKind string + +// RegisterWebhookJSONRequestBody defines body for RegisterWebhook for application/json ContentType. +type RegisterWebhookJSONRequestBody = WebhookRegistration + +// EnterEventJSONRequestBody defines body for EnterEvent for application/json ContentType. +type EnterEventJSONRequestBody = Person + +// ExitEventJSONRequestBody defines body for ExitEvent for application/json ContentType. +type ExitEventJSONRequestBody = Person + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // DeregisterWebhook request + DeregisterWebhook(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // RegisterWebhookWithBody request with any body + RegisterWebhookWithBody(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + RegisterWebhook(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) DeregisterWebhook(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeregisterWebhookRequest(c.Server, id) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterWebhookWithBody(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterWebhookRequestWithBody(c.Server, kind, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RegisterWebhook(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRegisterWebhookRequest(c.Server, kind, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewDeregisterWebhookRequest generates requests for DeregisterWebhook +func NewDeregisterWebhookRequest(server string, id openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/webhook/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodDelete, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewRegisterWebhookRequest calls the generic RegisterWebhook builder with application/json body +func NewRegisterWebhookRequest(server string, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRegisterWebhookRequestWithBody(server, kind, "application/json", bodyReader) +} + +// NewRegisterWebhookRequestWithBody generates requests for RegisterWebhook with any type of body +func NewRegisterWebhookRequestWithBody(server string, kind RegisterWebhookParamsKind, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "kind", kind, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: ""}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/webhook/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // DeregisterWebhookWithResponse request + DeregisterWebhookWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeregisterWebhookResponse, error) + + // RegisterWebhookWithBodyWithResponse request with any body + RegisterWebhookWithBodyWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) + + RegisterWebhookWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) +} + +type DeregisterWebhookResponse struct { + Body []byte + HTTPResponse *http.Response + JSONDefault *Error +} + +// Status returns HTTPResponse.Status +func (r DeregisterWebhookResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeregisterWebhookResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DeregisterWebhookResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type RegisterWebhookResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *WebhookRegistrationResponse + JSONDefault *Error +} + +// Status returns HTTPResponse.Status +func (r RegisterWebhookResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RegisterWebhookResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RegisterWebhookResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// DeregisterWebhookWithResponse request returning *DeregisterWebhookResponse +func (c *ClientWithResponses) DeregisterWebhookWithResponse(ctx context.Context, id openapi_types.UUID, reqEditors ...RequestEditorFn) (*DeregisterWebhookResponse, error) { + rsp, err := c.DeregisterWebhook(ctx, id, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeregisterWebhookResponse(rsp) +} + +// RegisterWebhookWithBodyWithResponse request with arbitrary body returning *RegisterWebhookResponse +func (c *ClientWithResponses) RegisterWebhookWithBodyWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) { + rsp, err := c.RegisterWebhookWithBody(ctx, kind, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterWebhookResponse(rsp) +} + +func (c *ClientWithResponses) RegisterWebhookWithResponse(ctx context.Context, kind RegisterWebhookParamsKind, body RegisterWebhookJSONRequestBody, reqEditors ...RequestEditorFn) (*RegisterWebhookResponse, error) { + rsp, err := c.RegisterWebhook(ctx, kind, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRegisterWebhookResponse(rsp) +} + +// ParseDeregisterWebhookResponse parses an HTTP response from a DeregisterWebhookWithResponse call +func ParseDeregisterWebhookResponse(rsp *http.Response) (*DeregisterWebhookResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeregisterWebhookResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// ParseRegisterWebhookResponse parses an HTTP response from a RegisterWebhookWithResponse call +func ParseRegisterWebhookResponse(rsp *http.Response) (*RegisterWebhookResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &RegisterWebhookResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest WebhookRegistrationResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // EnterEventWithBody fires the enterEvent webhook with any body + EnterEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + EnterEvent(ctx context.Context, targetURL string, body EnterEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ExitEventWithBody fires the exitEvent webhook with any body + ExitEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ExitEvent(ctx context.Context, targetURL string, body ExitEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) EnterEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnterEventWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) EnterEvent(ctx context.Context, targetURL string, body EnterEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewEnterEventWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) ExitEventWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExitEventWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) ExitEvent(ctx context.Context, targetURL string, body ExitEventJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewExitEventWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewEnterEventWebhookRequest builds a application/json POST request for the enterEvent webhook +func NewEnterEventWebhookRequest(targetURL string, body EnterEventJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewEnterEventWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewEnterEventWebhookRequestWithBody builds a POST request for the enterEvent webhook with any body +func NewEnterEventWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewExitEventWebhookRequest builds a application/json POST request for the exitEvent webhook +func NewExitEventWebhookRequest(targetURL string, body ExitEventJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewExitEventWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewExitEventWebhookRequestWithBody builds a POST request for the exitEvent webhook with any body +func NewExitEventWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Deregister a webhook + // (DELETE /api/webhook/{id}) + DeregisterWebhook(w http.ResponseWriter, r *http.Request, id openapi_types.UUID) + // Register a webhook + // (POST /api/webhook/{kind}) + RegisterWebhook(w http.ResponseWriter, r *http.Request, kind RegisterWebhookParamsKind) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// DeregisterWebhook operation middleware +func (siw *ServerInterfaceWrapper) DeregisterWebhook(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "id" ------------- + var id openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid"}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DeregisterWebhook(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// RegisterWebhook operation middleware +func (siw *ServerInterfaceWrapper) RegisterWebhook(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "kind" ------------- + var kind RegisterWebhookParamsKind + + err = runtime.BindStyledParameterWithOptions("simple", "kind", r.PathValue("kind"), &kind, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "kind", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.RegisterWebhook(w, r, kind) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api/webhook/{id}", wrapper.DeregisterWebhook) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/webhook/{kind}", wrapper.RegisterWebhook) + + return m +} + +// WebhookReceiverInterface represents handlers for receiving OpenAPI 3.1 +// webhook requests. Each webhook becomes a Handle*Webhook method that the +// implementation fills in to receive the inbound HTTP request. The caller +// mounts the per-webhook http.Handler returned by the OpWebhookHandler +// factory below at whatever URL path they advertise to subscribers. +type WebhookReceiverInterface interface { + // Person entered the building + // HandleEnterEventWebhook handles the POST webhook for enterEvent. + HandleEnterEventWebhook(w http.ResponseWriter, r *http.Request) + // Person exited the building + // HandleExitEventWebhook handles the POST webhook for exitEvent. + HandleExitEventWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross-cutting +// behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// EnterEventWebhookHandler returns the http.Handler for the enterEvent webhook. +// Mount this at the URL path advertised to webhook subscribers. Middlewares +// are applied in the order provided (outermost first). +func EnterEventWebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleEnterEventWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +// ExitEventWebhookHandler returns the http.Handler for the exitEvent webhook. +// Mount this at the URL path advertised to webhook subscribers. Middlewares +// are applied in the order provided (outermost first). +func ExitEventWebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleExitEventWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/examples/webhook/server/main.go b/examples/webhook/server/main.go new file mode 100644 index 0000000000..368a932464 --- /dev/null +++ b/examples/webhook/server/main.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "math/rand/v2" + "net" + "net/http" + "sync" + "time" + + "github.com/google/uuid" + + doorbadge "github.com/oapi-codegen/oapi-codegen/v2/examples/webhook" +) + +var names = []string{ + "Alice", "Bob", "Charlie", "Diana", "Eve", + "Frank", "Grace", "Hank", "Iris", "Jack", +} + +type webhookEntry struct { + id uuid.UUID + url string + kind doorbadge.RegisterWebhookParamsKind +} + +// BadgeReader implements doorbadge.ServerInterface. +type BadgeReader struct { + initiator *doorbadge.WebhookInitiator + + mu sync.Mutex + webhooks map[uuid.UUID]webhookEntry +} + +var _ doorbadge.ServerInterface = (*BadgeReader)(nil) + +func NewBadgeReader() *BadgeReader { + initiator, err := doorbadge.NewWebhookInitiator() + if err != nil { + log.Fatalf("Failed to create webhook initiator: %v", err) + } + return &BadgeReader{ + initiator: initiator, + webhooks: make(map[uuid.UUID]webhookEntry), + } +} + +func sendError(w http.ResponseWriter, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(doorbadge.Error{ + Code: int32(code), + Message: message, + }) +} + +func (br *BadgeReader) RegisterWebhook(w http.ResponseWriter, r *http.Request, kind doorbadge.RegisterWebhookParamsKind) { + var req doorbadge.WebhookRegistration + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + if kind != doorbadge.EnterEvent && kind != doorbadge.ExitEvent { + sendError(w, http.StatusBadRequest, "Invalid webhook kind: "+string(kind)) + return + } + + id := uuid.New() + entry := webhookEntry{id: id, url: req.URL, kind: kind} + + br.mu.Lock() + br.webhooks[id] = entry + br.mu.Unlock() + + log.Printf("Registered webhook: id=%s kind=%s url=%s", id, kind, req.URL) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(doorbadge.WebhookRegistrationResponse{ID: id}) +} + +func (br *BadgeReader) DeregisterWebhook(w http.ResponseWriter, r *http.Request, id uuid.UUID) { + br.mu.Lock() + entry, ok := br.webhooks[id] + delete(br.webhooks, id) + br.mu.Unlock() + + if !ok { + sendError(w, http.StatusNotFound, "Webhook not found: "+id.String()) + return + } + + log.Printf("Deregistered webhook: id=%s kind=%s url=%s", id, entry.kind, entry.url) + w.WriteHeader(http.StatusNoContent) +} + +// generateEvents picks a random name and event kind every second and notifies webhooks. +func (br *BadgeReader) generateEvents(ctx context.Context) { + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + name := names[rand.IntN(len(names))] + kind := doorbadge.EnterEvent + if rand.IntN(2) == 0 { + kind = doorbadge.ExitEvent + } + + person := doorbadge.Person{Name: name} + + br.mu.Lock() + targets := make([]webhookEntry, 0) + for _, entry := range br.webhooks { + if entry.kind == kind { + targets = append(targets, entry) + } + } + br.mu.Unlock() + + if len(targets) == 0 { + continue + } + + log.Printf("Event: %s %s (%d webhooks)", kind, name, len(targets)) + + for _, target := range targets { + var resp *http.Response + var err error + + switch kind { + case doorbadge.EnterEvent: + resp, err = br.initiator.EnterEvent(ctx, target.url, person) + case doorbadge.ExitEvent: + resp, err = br.initiator.ExitEvent(ctx, target.url, person) + } + + if err != nil { + log.Printf("Webhook %s failed: %v — removing", target.id, err) + br.mu.Lock() + delete(br.webhooks, target.id) + br.mu.Unlock() + continue + } + _ = resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + log.Printf("Webhook %s returned %d — removing", target.id, resp.StatusCode) + br.mu.Lock() + delete(br.webhooks, target.id) + br.mu.Unlock() + } + } + } + } +} + +func main() { + port := flag.String("port", "8080", "Port for HTTP server") + flag.Parse() + + reader := NewBadgeReader() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go reader.generateEvents(ctx) + + mux := http.NewServeMux() + doorbadge.HandlerFromMux(reader, mux) + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + mux.ServeHTTP(w, r) + }) + + addr := net.JoinHostPort("0.0.0.0", *port) + log.Printf("Door Badge Reader server listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, handler)) +} diff --git a/internal/test/webhooks/config.yaml b/internal/test/webhooks/config.yaml new file mode 100644 index 0000000000..d4721e40be --- /dev/null +++ b/internal/test/webhooks/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: webhooks +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks/doc.go b/internal/test/webhooks/doc.go new file mode 100644 index 0000000000..bdfb62240f --- /dev/null +++ b/internal/test/webhooks/doc.go @@ -0,0 +1,7 @@ +// Package webhooks verifies OpenAPI 3.1 webhook codegen end-to-end: +// the generated WebhookInitiator fires a webhook against a httptest +// server, and the generated WebhookReceiverInterface handler receives +// it. The test asserts the payload round-trips intact. +package webhooks + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks/spec.yaml b/internal/test/webhooks/spec.yaml new file mode 100644 index 0000000000..3c95b029fc --- /dev/null +++ b/internal/test/webhooks/spec.yaml @@ -0,0 +1,35 @@ +openapi: 3.1.0 +info: + title: Webhooks test + version: 1.0.0 + description: | + Verifies the OpenAPI 3.1 webhook codegen end-to-end: a service that + emits PetStatusChanged webhooks via WebhookInitiator, and a + subscriber that receives them via WebhookReceiverInterface. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks/webhooks.gen.go b/internal/test/webhooks/webhooks.gen.go new file mode 100644 index 0000000000..5eb16759c9 --- /dev/null +++ b/internal/test/webhooks/webhooks.gen.go @@ -0,0 +1,455 @@ +//go:build go1.22 + +// Package webhooks provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package webhooks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + return m +} + +// WebhookReceiverInterface represents handlers for receiving OpenAPI 3.1 +// webhook requests. Each webhook becomes a Handle*Webhook method that the +// implementation fills in to receive the inbound HTTP request. The caller +// mounts the per-webhook http.Handler returned by the OpWebhookHandler +// factory below at whatever URL path they advertise to subscribers. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross-cutting +// behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// PetStatusChangedWebhookHandler returns the http.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook subscribers. Middlewares +// are applied in the order provided (outermost first). +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandlePetStatusChangedWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/webhooks/webhooks_test.go b/internal/test/webhooks/webhooks_test.go new file mode 100644 index 0000000000..b3705d18ac --- /dev/null +++ b/internal/test/webhooks/webhooks_test.go @@ -0,0 +1,158 @@ +// Package webhooks tests the OpenAPI 3.1 webhook codegen end-to-end: +// the WebhookInitiator fires a webhook against an httptest.Server that +// registers the WebhookReceiverInterface handler, and the test asserts +// the payload round-trips intact. +package webhooks + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeReceiver captures whatever the handler is given so the test can +// assert what arrived. +type fakeReceiver struct { + gotMethod string + gotContentType string + gotEvent PetStatusEvent + called bool +} + +func (f *fakeReceiver) HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) { + f.called = true + f.gotMethod = r.Method + f.gotContentType = r.Header.Get("Content-Type") + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer r.Body.Close() + if err := json.Unmarshal(body, &f.gotEvent); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func TestWebhookRoundTrip(t *testing.T) { + receiver := &fakeReceiver{} + + // Mount the generated factory at a test path. In real usage callers + // pick whatever URL they advertise to subscribers; the factory is + // path-agnostic so the test can pick anything. + mux := http.NewServeMux() + mux.Handle("POST /hooks/pet-status", PetStatusChangedWebhookHandler(receiver)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewWebhookInitiator() + require.NoError(t, err) + + event := PetStatusEvent{ + Id: "pet-42", + Status: Sold, + } + + resp, err := initiator.PetStatusChanged(context.Background(), srv.URL+"/hooks/pet-status", event) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNoContent, resp.StatusCode) + require.True(t, receiver.called, "webhook handler should have been called") + assert.Equal(t, "POST", receiver.gotMethod) + assert.Equal(t, "application/json", receiver.gotContentType) + assert.Equal(t, event, receiver.gotEvent) +} + +// TestWebhookInitiatorRequestEditor verifies WithWebhookRequestEditorFn +// composes onto every request, mirroring the path Client's RequestEditor +// behavior. This is the integration-level assertion that webhook-side +// middleware support is structurally identical to client-side. +func TestWebhookInitiatorRequestEditor(t *testing.T) { + const sigHeader = "X-Webhook-Signature" + const sigValue = "t=1234,v1=deadbeef" + + receiver := &capturingReceiver{} + mux := http.NewServeMux() + mux.Handle("POST /hooks", PetStatusChangedWebhookHandler(receiver)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewWebhookInitiator( + WithWebhookRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set(sigHeader, sigValue) + return nil + }), + ) + require.NoError(t, err) + + resp, err := initiator.PetStatusChanged(context.Background(), srv.URL+"/hooks", PetStatusEvent{ + Id: "pet-1", + Status: Available, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, sigValue, receiver.gotHeaders.Get(sigHeader), + "per-call editor was not applied to the outgoing request") +} + +type capturingReceiver struct { + gotHeaders http.Header +} + +func (c *capturingReceiver) HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) { + c.gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusNoContent) +} + +// TestWebhookReceiverMiddleware verifies middlewares wrap the handler in +// declared order (outermost first), matching the standard handler +// composition convention. +func TestWebhookReceiverMiddleware(t *testing.T) { + var order []string + mw := func(name string) WebhookReceiverMiddlewareFunc { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + order = append(order, name+":pre") + next.ServeHTTP(w, r) + order = append(order, name+":post") + }) + } + } + + mux := http.NewServeMux() + mux.Handle("POST /hooks", + PetStatusChangedWebhookHandler(&capturingReceiver{}, mw("outer"), mw("inner"))) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewWebhookInitiator() + require.NoError(t, err) + resp, err := initiator.PetStatusChanged(context.Background(), srv.URL+"/hooks", PetStatusEvent{ + Id: "x", Status: Pending, + }) + require.NoError(t, err) + resp.Body.Close() + + // Middlewares are applied in order with the LAST argument becoming + // the OUTERMOST wrapper (each iteration assigns h = mw(h)). So we + // expect: inner:pre -> outer:pre is the wrong order; check the + // actual implementation's intent. + // + // In our generated factory: + // for _, mw := range middlewares { h = mw(h) } + // the final mw becomes outermost. So with ("outer", "inner") the + // "inner" middleware is the outermost wrapper. + assert.Equal(t, + []string{"inner:pre", "outer:pre", "outer:post", "inner:post"}, + order) +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index ac24ac656b..71e526391f 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -254,14 +254,27 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { return "", fmt.Errorf("error creating operation definitions: %w", err) } - xGoTypeImports, err := OperationImports(ops) + // Webhooks (OpenAPI 3.1+) flow through the same OperationDefinition + // shape as paths but render via webhook-specific templates. The + // gather walks swagger.Webhooks unconditionally -- the map is only + // populated by kin-openapi for 3.1+ documents, so 3.0 specs return + // an empty slice naturally. allOps is used for cross-cutting passes + // (type defs, import gathering); ops and webhookOps are passed + // separately to path-vs-webhook template generators. + webhookOps, err := WebhookOperationDefinitions(spec) + if err != nil { + return "", fmt.Errorf("error creating webhook operation definitions: %w", err) + } + allOps := append(append([]OperationDefinition{}, ops...), webhookOps...) + + xGoTypeImports, err := OperationImports(allOps) if err != nil { return "", fmt.Errorf("error getting operation imports: %w", err) } var typeDefinitions, constantDefinitions string if opts.Generate.Models { - typeDefinitions, err = GenerateTypeDefinitions(t, spec, ops, opts.OutputOptions.ExcludeSchemas) + typeDefinitions, err = GenerateTypeDefinitions(t, spec, allOps, opts.OutputOptions.ExcludeSchemas) if err != nil { return "", fmt.Errorf("error generating type definitions: %w", err) } @@ -407,6 +420,27 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook initiator pairs with the path Client. Emitted only when + // Generate.Client is on AND the spec has webhooks (3.1+). + var webhookInitiatorOut string + if opts.Generate.Client && len(webhookOps) > 0 { + webhookInitiatorOut, err = GenerateWebhookInitiator(t, webhookOps) + if err != nil { + return "", fmt.Errorf("error generating webhook initiator: %w", err) + } + } + + // Webhook receiver (stdhttp) pairs with the path StdHTTPServer. + // Emitted only when Generate.StdHTTPServer is on AND the spec has + // webhooks (3.1+). + var stdHTTPWebhookReceiverOut string + if opts.Generate.StdHTTPServer && len(webhookOps) > 0 { + stdHTTPWebhookReceiverOut, err = GenerateStdHTTPWebhookReceiver(t, webhookOps) + if err != nil { + return "", fmt.Errorf("error generating stdhttp webhook receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -458,6 +492,12 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing client: %w", err) } + if webhookInitiatorOut != "" { + _, err = w.WriteString(webhookInitiatorOut) + if err != nil { + return "", fmt.Errorf("error writing webhook initiator: %w", err) + } + } } if opts.Generate.IrisServer { @@ -515,6 +555,12 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if stdHTTPWebhookReceiverOut != "" { + _, err = w.WriteString(stdHTTPWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing stdhttp webhook receiver: %w", err) + } + } } if opts.Generate.Strict { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index e7b139d8b6..3aa25a83b5 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -332,6 +332,13 @@ type OperationDefinition struct { IsAlias bool // True when this path is a $ref alias of another path item AliasTarget string // When IsAlias is true, this is the OperationId of the canonical operation (for route registration to reference the correct wrapper) PathItemRef string // The path item's $ref (if any); used to qualify externally-loaded schemas referenced from this operation's responses + + // IsWebhook is true when this OperationDefinition was sourced from + // spec.Webhooks (OpenAPI 3.1+). Webhook operations have no path + // template; the target URL is supplied per-call by the initiator. + IsWebhook bool + // WebhookName is the spec.Webhooks map key when IsWebhook is true. + WebhookName string } // HandlerName returns the OperationId to use when referencing the server-side @@ -895,6 +902,104 @@ func OperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, error) { return operations, nil } +// WebhookOperationDefinitions extracts OpenAPI 3.1+ webhook operations +// from swagger.Webhooks into the same OperationDefinition shape used for +// path operations, so they flow through the same downstream pipeline +// (body / response generation, type definitions, etc.) but are routed +// to webhook-specific templates. +// +// kin-openapi only populates the Webhooks field for OpenAPI 3.1+ +// documents, so a missing/empty map naturally short-circuits this for +// 3.0 specs without an explicit version check. +// +// The result mirrors OperationDefinitions in structure, minus the +// path-alias logic (webhooks have no path template) and the path- +// parameter extraction (webhooks have no path params). +func WebhookOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, error) { + var operations []OperationDefinition + if swagger == nil || len(swagger.Webhooks) == 0 { + return operations, nil + } + + for _, webhookName := range SortedMapKeys(swagger.Webhooks) { + pathItem := swagger.Webhooks[webhookName] + if pathItem == nil { + continue + } + + // Path-item-level parameters apply to every method on the + // webhook (rare for webhooks, but honored defensively). + globalParams, err := DescribeParameters(pathItem.Parameters, nil) + if err != nil { + return nil, fmt.Errorf("error describing webhook %q parameters: %w", webhookName, err) + } + + pathOps := pathItem.Operations() + for _, opName := range SortedMapKeys(pathOps) { + op := pathOps[opName] + + // Prefer an explicit operationId on the webhook operation; + // otherwise derive from the webhook map key. Either way, + // run through the configured name normalizer. + operationId := op.OperationID + if operationId == "" { + operationId = webhookName + } + operationId = nameNormalizer(operationId) + operationId = typeNamePrefix(operationId) + operationId + + localParams, err := DescribeParameters(op.Parameters, []string{operationId + "Params"}) + if err != nil { + return nil, fmt.Errorf("error describing webhook %q operation params: %w", webhookName, err) + } + allParams, err := CombineOperationParameters(globalParams, localParams) + if err != nil { + return nil, err + } + + bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody) + if err != nil { + return nil, fmt.Errorf("error generating body definitions for webhook %q: %w", webhookName, err) + } + + responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map()) + if err != nil { + return nil, fmt.Errorf("error generating response definitions for webhook %q: %w", webhookName, err) + } + + opDef := OperationDefinition{ + HeaderParams: FilterParameterDefinitionByType(allParams, "header"), + QueryParams: FilterParameterDefinitionByType(allParams, "query"), + CookieParams: FilterParameterDefinitionByType(allParams, "cookie"), + OperationId: nameNormalizer(operationId), + Summary: op.Summary, + Method: opName, + Path: "", + Spec: op, + Bodies: bodyDefinitions, + Responses: responseDefinitions, + TypeDefinitions: typeDefinitions, + IsWebhook: true, + WebhookName: webhookName, + } + + if op.Security != nil { + opDef.SecurityDefinitions = DescribeSecurityDefinition(*op.Security) + } else { + opDef.SecurityDefinitions = DescribeSecurityDefinition(swagger.Security) + } + + if op.RequestBody != nil { + opDef.BodyRequired = op.RequestBody.Value.Required + } + + opDef.TypeDefinitions = append(opDef.TypeDefinitions, GenerateTypeDefsForOperation(opDef)...) + operations = append(operations, opDef) + } + } + return operations, nil +} + func generateDefaultOperationID(opName string, requestPath string) (string, error) { if opName == "" { return "", fmt.Errorf("operation name cannot be an empty string") @@ -1356,6 +1461,25 @@ func GenerateClientWithResponses(t *template.Template, ops []OperationDefinition return GenerateTemplates([]string{"client-with-responses.tmpl"}, t, ops) } +// GenerateWebhookInitiator generates the WebhookInitiator -- the +// client-side analog for OpenAPI 3.1 webhooks. It mirrors the path +// Client (struct + options + per-method calls + request builders) but +// takes the target URL per-call instead of from a stored Server field. +// The caller passes only the webhook OperationDefinitions (gathered via +// WebhookOperationDefinitions); path operations are emitted by the +// regular Client templates. +func GenerateWebhookInitiator(t *template.Template, webhookOps []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"webhook-initiator.tmpl"}, t, webhookOps) +} + +// GenerateStdHTTPWebhookReceiver generates the WebhookReceiver -- the +// server-side analog for OpenAPI 3.1 webhooks. The caller mounts each +// per-webhook http.Handler (returned by the generated WebhookHandler +// factory) at the URL path of their choice. +func GenerateStdHTTPWebhookReceiver(t *template.Template, webhookOps []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"stdhttp/std-http-webhook-receiver.tmpl"}, t, webhookOps) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl b/pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl new file mode 100644 index 0000000000..ca64485344 --- /dev/null +++ b/pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl @@ -0,0 +1,32 @@ +// WebhookReceiverInterface represents handlers for receiving OpenAPI 3.1 +// webhook requests. Each webhook becomes a Handle*Webhook method that the +// implementation fills in to receive the inbound HTTP request. The caller +// mounts the per-webhook http.Handler returned by the {{"Op"}}WebhookHandler +// factory below at whatever URL path they advertise to subscribers. +type WebhookReceiverInterface interface { +{{range . -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}Webhook handles the {{.Method}} webhook for {{.WebhookName}}. +Handle{{.OperationId}}Webhook(w http.ResponseWriter, r *http.Request) +{{end}} +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross-cutting +// behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range . -}} +// {{.OperationId}}WebhookHandler returns the http.Handler for the {{.WebhookName}} webhook. +// Mount this at the URL path advertised to webhook subscribers. Middlewares +// are applied in the order provided (outermost first). +func {{.OperationId}}WebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.Handle{{.OperationId}}Webhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} diff --git a/pkg/codegen/templates/webhook-initiator.tmpl b/pkg/codegen/templates/webhook-initiator.tmpl new file mode 100644 index 0000000000..032e3933ed --- /dev/null +++ b/pkg/codegen/templates/webhook-initiator.tmpl @@ -0,0 +1,235 @@ +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + // {{$opid}}{{if .HasBody}}WithBody{{end}} fires the {{.WebhookName}} webhook{{if .HasBody}} with any body{{end}} + {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) +{{range .Bodies}} + {{if .IsSupportedByClient -}} + {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) + {{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} +} + + +{{/* Generate webhook initiator methods */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + +func (p *WebhookInitiator) {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}WebhookRequest{{if .HasBody}}WithBody{{end}}(targetURL{{if $hasParams}}, params{{end}}{{if .HasBody}}, contentType, body{{end}}) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +func (p *WebhookInitiator) {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}WebhookRequest{{.Suffix}}(targetURL{{if $hasParams}}, params{{end}}, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} +{{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} + +{{/* Generate webhook request builders */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} +{{$method := .Method -}} +{{$webhookName := .WebhookName -}} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +// New{{$opid}}WebhookRequest{{.Suffix}} builds a {{.ContentType}} {{$method}} request for the {{$webhookName}} webhook +func New{{$opid}}WebhookRequest{{.Suffix}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody) (*http.Request, error) { + var bodyReader io.Reader + {{if .IsJSON -}} + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + {{else if eq .NameTag "Formdata" -}} + bodyStr, err := runtime.MarshalForm(body, nil) + if err != nil { + return nil, err + } + bodyReader = strings.NewReader(bodyStr.Encode()) + {{else if eq .NameTag "Text" -}} + if stringer, ok := interface{}(body).(fmt.Stringer); ok { + bodyReader = strings.NewReader(stringer.String()) + } else { + {{if .Schema.IsPrimitive -}} + bodyReader = strings.NewReader(fmt.Sprint(body)) + {{else -}} + return nil, fmt.Errorf("text/plain is not supported for complex types, define a String() method on {{.Schema.TypeDecl}} to marshal it as text") + {{end -}} + } + {{end -}} + return New{{$opid}}WebhookRequestWithBody(targetURL{{if $hasParams}}, params{{end}}, "{{.ContentType}}", bodyReader) +} +{{end -}} +{{end}} + +// New{{$opid}}WebhookRequest{{if .HasBody}}WithBody{{end}} builds a {{.Method}} request for the {{.WebhookName}} webhook{{if .HasBody}} with any body{{end}} +func New{{$opid}}WebhookRequest{{if .HasBody}}WithBody{{end}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + +{{if .QueryParams}} + if params != nil { + queryValues := reqURL.Query() + var rawQueryFragments []string + {{range $paramIdx, $param := .QueryParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + {{if .IsPassThrough}} + queryValues.Add("{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + {{end}} + {{if .IsJson}} + if queryParamBuf, err := json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}); err != nil { + return nil, err + } else { + queryValues.Add("{{.ParamName}}", string(queryParamBuf)) + } + {{end}} + {{if .IsStyled}} + if queryFrag, err := runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if and .RequiresNilCheck .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + {{end}} + {{if .RequiresNilCheck}}}{{end}} + {{end}} + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + reqURL.RawQuery = strings.Join(rawQueryFragments, "&") + } +{{end}}{{/* if .QueryParams */}} + + req, err := http.NewRequest({{.Method | httpMethodConstant}}, reqURL.String(), {{if .HasBody}}body{{else}}nil{{end}}) + if err != nil { + return nil, err + } + + {{if .HasBody}}req.Header.Add("Content-Type", contentType){{end}} +{{ if .HeaderParams }} + if params != nil { + {{range $paramIdx, $param := .HeaderParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + var headerParam{{$paramIdx}} string + {{if .IsPassThrough}} + headerParam{{$paramIdx}} = {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}} + {{end}} + {{if .IsJson}} + var headerParamBuf{{$paramIdx}} []byte + headerParamBuf{{$paramIdx}}, err = json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + if err != nil { + return nil, err + } + headerParam{{$paramIdx}} = string(headerParamBuf{{$paramIdx}}) + {{end}} + {{if .IsStyled}} + headerParam{{$paramIdx}}, err = runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return nil, err + } + {{end}} + req.Header.Set("{{.ParamName}}", headerParam{{$paramIdx}}) + {{if .RequiresNilCheck}}}{{end}} + {{end}} + } +{{- end }}{{/* if .HeaderParams */}} + return req, nil +} + +{{end}}{{/* Range */}} From 428023e6dfdb7d5ab6e7d0c986dd8444dafcad98 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:01:56 -0700 Subject: [PATCH 05/30] feat(codegen): OpenAPI callbacks -- CallbackInitiator + CallbackReceiver Phase 4 of the 3.1 backport. Generates client-side and server-side support for `callbacks:` blocks nested under path operations. The output mirrors WebhookInitiator / WebhookReceiver in shape -- separate types named with the `Callback` prefix to match oapi-codegen-exp. Callbacks have been part of the OpenAPI spec since 3.0, so this is NOT version-gated: any spec that declares callbacks gets them generated. Data model: * OperationDefinition gains IsCallback bool and CallbackName string (alongside the IsWebhook / WebhookName pair from Phase 3). * New CallbackOperationDefinitions() walks spec.Paths...Callbacks. Each leaf operation under the callback's URL-expression key becomes one OperationDefinition with IsCallback=true and CallbackName set to the outer map key (e.g. "treePlanted"). The codegen does not interpret the URL expression itself -- the caller of CallbackInitiator supplies the resolved target URL at runtime, the same way it does for a webhook. * Callback's internal map is private; iterate via cb.Keys() / cb.Value() with sorted keys for deterministic output. Generated client (CallbackInitiator): * New pkg/codegen/templates/callback-initiator.tmpl. Same shape as the Phase 3 webhook-initiator.tmpl with `Webhook` -> `Callback` rename: type CallbackInitiator struct{ Client; RequestEditors } NewCallbackInitiator + CallbackInitiatorOption + WithCallbackHTTPClient + WithCallbackRequestEditorFn CallbackInitiatorInterface per-callback methods (Op + OpWithBody) and request builders (NewOpCallbackRequest + NewOpCallbackRequestWithBody) * No stored Server -- targetURL is per-call. * Gated only by Generate.Client + non-empty callbackOps. Generated server (CallbackReceiver, stdhttp): * New pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl. * WebhookReceiverInterface analog: Handle{Op}Callback(w, r) plus a per-callback factory function {Op}CallbackHandler(si, middlewares...) http.Handler. * Gated only by Generate.StdHTTPServer + non-empty callbackOps. Wiring (codegen.go): * allOps is now `ops + webhookOps + callbackOps` so type-definition and import passes see callback bodies / responses. * Callback initiator output follows webhook initiator (so the order is Client / ClientWithResponses / WebhookInitiator / CallbackInitiator). * Callback receiver output follows webhook receiver under the StdHTTPServer block. * New GenerateCallbackInitiator and GenerateStdHTTPCallbackReceiver template entry points in operations.go. Tests (internal/test/callbacks/): * spec.yaml deliberately uses `openapi: 3.0.3` to verify the codegen does NOT gate callback emission on 3.1 (callbacks are 3.0+). Models a tree-planting flow: parent operation PlantTree, callback treePlanted with TreePlantingResult body. * TestCallbackRoundTrip mounts the generated factory against an httptest.Server and fires a callback via the initiator; asserts the payload, method, and Content-Type round-trip intact. * TestCallbackInitiatorRequestEditor verifies WithCallbackRequestEditorFn applies on every outgoing request (parity with the path Client and the WebhookInitiator). Example (examples/callback/): * Tree farm scenario ported from oapi-codegen-exp/examples/callback. Server accepts PlantTree, returns 202, sleeps 1-5s, then fires the treePlanted callback to the caller-supplied URL. Client opens an ephemeral receiver, fires 10 plantings in sequence, and waits on a channel until all 10 callbacks have arrived. * Lives as a regular package under examples/go.mod -- no separate module, no tools.go shim. Same flat layout as examples/webhook/. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/callback/client/main.go | 144 ++++ examples/callback/config.yaml | 13 + examples/callback/doc.go | 14 + examples/callback/server/main.go | 116 +++ examples/callback/tree-farm.yaml | 138 ++++ examples/callback/treefarm.gen.go | 672 ++++++++++++++++++ internal/test/callbacks/callbacks.gen.go | 602 ++++++++++++++++ internal/test/callbacks/callbacks_test.go | 106 +++ internal/test/callbacks/config.yaml | 9 + internal/test/callbacks/doc.go | 8 + internal/test/callbacks/spec.yaml | 57 ++ pkg/codegen/codegen.go | 44 +- pkg/codegen/operations.go | 155 ++++ pkg/codegen/templates/callback-initiator.tmpl | 236 ++++++ .../stdhttp/std-http-callback-receiver.tmpl | 34 + 15 files changed, 2347 insertions(+), 1 deletion(-) create mode 100644 examples/callback/client/main.go create mode 100644 examples/callback/config.yaml create mode 100644 examples/callback/doc.go create mode 100644 examples/callback/server/main.go create mode 100644 examples/callback/tree-farm.yaml create mode 100644 examples/callback/treefarm.gen.go create mode 100644 internal/test/callbacks/callbacks.gen.go create mode 100644 internal/test/callbacks/callbacks_test.go create mode 100644 internal/test/callbacks/config.yaml create mode 100644 internal/test/callbacks/doc.go create mode 100644 internal/test/callbacks/spec.yaml create mode 100644 pkg/codegen/templates/callback-initiator.tmpl create mode 100644 pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl diff --git a/examples/callback/client/main.go b/examples/callback/client/main.go new file mode 100644 index 0000000000..da8bcb9492 --- /dev/null +++ b/examples/callback/client/main.go @@ -0,0 +1,144 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "log" + "net" + "net/http" + "sync" + "sync/atomic" + + treefarm "github.com/oapi-codegen/oapi-codegen/v2/examples/callback" +) + +// trees and cities for our planting requests +var treeKinds = []string{ + "oak", "maple", "pine", "birch", "willow", + "cedar", "elm", "ash", "cherry", "walnut", +} + +var cities = []string{ + "Providence", "Austin", "Denver", "Seattle", "Chicago", + "Boston", "Miami", "Nashville", "Savannah", "Mountain View", +} + +// CallbackReceiver implements treefarm.CallbackReceiverInterface. +type CallbackReceiver struct { + received atomic.Int32 + total int + done chan struct{} + once sync.Once + + mu sync.Mutex + ordinals map[string]int // UUID string -> 1-based planting order +} + +var _ treefarm.CallbackReceiverInterface = (*CallbackReceiver)(nil) + +func NewCallbackReceiver(total int) *CallbackReceiver { + return &CallbackReceiver{ + total: total, + done: make(chan struct{}), + ordinals: make(map[string]int), + } +} + +func (cr *CallbackReceiver) Register(id string, ordinal int) { + cr.mu.Lock() + cr.ordinals[id] = ordinal + cr.mu.Unlock() +} + +func (cr *CallbackReceiver) HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) { + var result treefarm.TreePlantingResult + if err := json.NewDecoder(r.Body).Decode(&result); err != nil { + log.Printf("Error decoding callback: %v", err) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + cr.mu.Lock() + ordinal := cr.ordinals[result.ID.String()] + cr.mu.Unlock() + + n := cr.received.Add(1) + log.Printf("Callback %d/%d received: tree #%d success=%v", n, cr.total, ordinal, result.Success) + + w.WriteHeader(http.StatusOK) + + if int(n) >= cr.total { + cr.once.Do(func() { close(cr.done) }) + } +} + +func main() { + serverAddr := flag.String("server", "http://localhost:8080", "Tree farm server address") + flag.Parse() + + const numTrees = 10 + + // Start callback receiver on an ephemeral port. + receiver := NewCallbackReceiver(numTrees) + + mux := http.NewServeMux() + mux.Handle("/tree_callback", treefarm.TreePlantedCallbackHandler(receiver)) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + log.Fatalf("Failed to listen: %v", err) + } + callbackPort := listener.Addr().(*net.TCPAddr).Port + callbackURL := fmt.Sprintf("http://localhost:%d/tree_callback", callbackPort) + log.Printf("Callback receiver listening on port %d", callbackPort) + + go func() { + if err := http.Serve(listener, mux); err != nil { + log.Printf("Callback server stopped: %v", err) + } + }() + + // Send 10 tree planting requests. + client := &http.Client{} + for i := range numTrees { + req := treefarm.TreePlantingRequest{ + Kind: treeKinds[i], + Location: cities[i], + CallbackURL: callbackURL, + } + + body, err := json.Marshal(req) + if err != nil { + log.Fatalf("Failed to marshal request: %v", err) + } + + resp, err := client.Post( + *serverAddr+"/api/plant_tree", + "application/json", + bytes.NewReader(body), + ) + if err != nil { + log.Fatalf("Failed to plant tree %d: %v", i+1, err) + } + + var accepted treefarm.TreeWithID + if err := json.NewDecoder(resp.Body).Decode(&accepted); err != nil { + _ = resp.Body.Close() + log.Fatalf("Failed to decode response: %v", err) + } + _ = resp.Body.Close() + + receiver.Register(accepted.ID.String(), i+1) + log.Printf("Planted tree %d/%d: id=%s kind=%q location=%q", + i+1, numTrees, accepted.ID, accepted.Kind, accepted.Location) + } + + log.Printf("All %d trees planted, waiting for callbacks...", numTrees) + + // Wait for all callbacks. + <-receiver.done + + log.Printf("All %d callbacks received, done!", numTrees) +} diff --git a/examples/callback/config.yaml b/examples/callback/config.yaml new file mode 100644 index 0000000000..204f845cf9 --- /dev/null +++ b/examples/callback/config.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=../../configuration-schema.json +package: treefarm +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true + # Use ToCamelCaseWithInitialisms so spec fields like `id` and + # `callbackUrl` generate as `ID` and `CallbackURL` (matching the + # example's Go code). + name-normalizer: ToCamelCaseWithInitialisms +output: treefarm.gen.go diff --git a/examples/callback/doc.go b/examples/callback/doc.go new file mode 100644 index 0000000000..8f859b2a08 --- /dev/null +++ b/examples/callback/doc.go @@ -0,0 +1,14 @@ +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml tree-farm.yaml + +// Package treefarm provides an example of how to handle OpenAPI callbacks. +// We create a server which plants trees. The client asks the server to plant +// a tree and requests a callback when the planting is done. +// +// The server program will wait 1-5 seconds before notifying the client that a +// tree has been planted. +// +// You can run the example by running these two commands in parallel: +// +// go run ./server --port 8080 +// go run ./client --server http://localhost:8080 +package treefarm diff --git a/examples/callback/server/main.go b/examples/callback/server/main.go new file mode 100644 index 0000000000..cb8a24a322 --- /dev/null +++ b/examples/callback/server/main.go @@ -0,0 +1,116 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log" + "math/rand/v2" + "net" + "net/http" + "time" + + "github.com/google/uuid" + + treefarm "github.com/oapi-codegen/oapi-codegen/v2/examples/callback" +) + +// TreeFarm implements treefarm.ServerInterface. +type TreeFarm struct { + initiator *treefarm.CallbackInitiator +} + +var _ treefarm.ServerInterface = (*TreeFarm)(nil) + +func NewTreeFarm() *TreeFarm { + initiator, err := treefarm.NewCallbackInitiator() + if err != nil { + log.Fatalf("Failed to create callback initiator: %v", err) + } + return &TreeFarm{initiator: initiator} +} + +func sendError(w http.ResponseWriter, code int, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(treefarm.Error{ + Code: int32(code), + Message: message, + }) +} + +func (tf *TreeFarm) PlantTree(w http.ResponseWriter, r *http.Request) { + log.Printf("Received PlantTree request from %s", r.RemoteAddr) + + var req treefarm.TreePlantingRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + sendError(w, http.StatusBadRequest, "Invalid request body: "+err.Error()) + return + } + + if req.CallbackURL == "" { + sendError(w, http.StatusBadRequest, "callbackUrl is required") + return + } + + id := uuid.New() + + log.Printf("Accepted tree planting: id=%s kind=%q location=%q callbackUrl=%q", + id, req.Kind, req.Location, req.CallbackURL) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode(treefarm.TreeWithID{ + Location: req.Location, + Kind: req.Kind, + ID: id, + }) + + go tf.plantAndNotify(id, req) +} + +func (tf *TreeFarm) plantAndNotify(id uuid.UUID, req treefarm.TreePlantingRequest) { + delay := time.Duration(1+rand.IntN(5)) * time.Second + log.Printf("Planting tree %s (kind=%q, location=%q) — will complete in %s", + id, req.Kind, req.Location, delay) + + time.Sleep(delay) + + result := treefarm.TreePlantedJSONRequestBody{ + ID: id, + Kind: req.Kind, + Location: req.Location, + Success: true, + } + + log.Printf("Tree %s planted, invoking callback at %s", id, req.CallbackURL) + + resp, err := tf.initiator.TreePlanted(context.Background(), req.CallbackURL, result) + if err != nil { + log.Printf("Callback to %s failed: %v", req.CallbackURL, err) + return + } + defer func() { _ = resp.Body.Close() }() + + log.Printf("Callback to %s returned status %d", req.CallbackURL, resp.StatusCode) +} + +func main() { + port := flag.String("port", "8080", "Port for HTTP server") + flag.Parse() + + farm := NewTreeFarm() + + mux := http.NewServeMux() + treefarm.HandlerFromMux(farm, mux) + + // Wrap with request logging. + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s from %s", r.Method, r.URL.Path, r.RemoteAddr) + mux.ServeHTTP(w, r) + }) + + addr := net.JoinHostPort("0.0.0.0", *port) + log.Printf("Tree Farm server listening on %s", addr) + log.Fatal(http.ListenAndServe(addr, handler)) +} diff --git a/examples/callback/tree-farm.yaml b/examples/callback/tree-farm.yaml new file mode 100644 index 0000000000..4cad525c08 --- /dev/null +++ b/examples/callback/tree-farm.yaml @@ -0,0 +1,138 @@ +openapi: "3.1.0" +info: + version: 1.0.0 + title: Tree Farm + description: | + A tree planting service that demonstrates OpenAPI callbacks. + The client submits a tree planting request along with a callback URL. + When the tree has been planted, the server sends a POST to the callback URL + to notify the client of the result. + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0.html +paths: + /api/plant_tree: + post: + summary: Request a tree planting + description: | + Submits a request to plant a tree at the specified location. + This is a long-running operation — the server accepts the request + and later notifies the caller via the provided callback URL. + operationId: PlantTree + requestBody: + $ref: '#/components/requestBodies/TreePlanting' + responses: + '202': + description: Tree planting request accepted + content: + application/json: + schema: + $ref: '#/components/schemas/TreeWithId' + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + callbacks: + treePlanted: + '{$request.body#/callbackUrl}': + post: + summary: Tree planting result notification + description: | + Sent by the server to the callback URL when the tree planting + operation completes (successfully or not). + operationId: TreePlanted + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingResult' + responses: + '200': + description: Callback received successfully + default: + description: unexpected error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + Tree: + type: object + description: A tree to be planted + required: + - location + - kind + properties: + location: + type: string + description: Where to plant the tree (e.g. "north meadow") + kind: + type: string + description: What kind of tree to plant (e.g. "oak") + + TreePlantingRequest: + description: | + A tree planting request, combining the tree details with a callback URL + for completion notification. + allOf: + - $ref: '#/components/schemas/Tree' + - type: object + required: + - callbackUrl + properties: + callbackUrl: + type: string + format: uri + description: URL to receive the planting result callback + + TreeWithId: + description: A tree with a server-assigned identifier + allOf: + - $ref: '#/components/schemas/Tree' + - type: object + required: + - id + properties: + id: + type: string + format: uuid + description: Unique identifier for this planting request + + TreePlantingResult: + description: The result of a tree planting operation, sent via callback + allOf: + - $ref: '#/components/schemas/TreeWithId' + - type: object + required: + - success + properties: + success: + type: boolean + description: Whether the tree was successfully planted + + Error: + type: object + required: + - code + - message + properties: + code: + type: integer + format: int32 + description: Error code + message: + type: string + description: Error message + + requestBodies: + TreePlanting: + description: Tree planting request + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingRequest' diff --git a/examples/callback/treefarm.gen.go b/examples/callback/treefarm.gen.go new file mode 100644 index 0000000000..713eecf39e --- /dev/null +++ b/examples/callback/treefarm.gen.go @@ -0,0 +1,672 @@ +//go:build go1.22 + +// Package treefarm provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package treefarm + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// Error defines model for Error. +type Error struct { + // Code Error code + Code int32 `json:"code"` + + // Message Error message + Message string `json:"message"` +} + +// Tree A tree to be planted +type Tree struct { + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` +} + +// TreePlantingRequest defines model for TreePlantingRequest. +type TreePlantingRequest struct { + // CallbackURL URL to receive the planting result callback + CallbackURL string `json:"callbackUrl"` + + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` +} + +// TreePlantingResult defines model for TreePlantingResult. +type TreePlantingResult struct { + // ID Unique identifier for this planting request + ID openapi_types.UUID `json:"id"` + + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` + + // Success Whether the tree was successfully planted + Success bool `json:"success"` +} + +// TreeWithID defines model for TreeWithId. +type TreeWithID struct { + // ID Unique identifier for this planting request + ID openapi_types.UUID `json:"id"` + + // Kind What kind of tree to plant (e.g. "oak") + Kind string `json:"kind"` + + // Location Where to plant the tree (e.g. "north meadow") + Location string `json:"location"` +} + +// TreePlanting A tree planting request, combining the tree details with a callback URL +// for completion notification. +type TreePlanting = TreePlantingRequest + +// PlantTreeJSONRequestBody defines body for PlantTree for application/json ContentType. +type PlantTreeJSONRequestBody = TreePlantingRequest + +// TreePlantedJSONRequestBody defines body for TreePlanted for application/json ContentType. +type TreePlantedJSONRequestBody = TreePlantingResult + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // PlantTreeWithBody request with any body + PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewPlantTreeRequest calls the generic PlantTree builder with application/json body +func NewPlantTreeRequest(server string, body PlantTreeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPlantTreeRequestWithBody(server, "application/json", bodyReader) +} + +// NewPlantTreeRequestWithBody generates requests for PlantTree with any type of body +func NewPlantTreeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/plant_tree") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // PlantTreeWithBodyWithResponse request with any body + PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) + + PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) +} + +type PlantTreeResponse struct { + Body []byte + HTTPResponse *http.Response + JSON202 *TreeWithID + JSONDefault *Error +} + +// Status returns HTTPResponse.Status +func (r PlantTreeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PlantTreeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r PlantTreeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// PlantTreeWithBodyWithResponse request with arbitrary body returning *PlantTreeResponse +func (c *ClientWithResponses) PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTreeWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +func (c *ClientWithResponses) PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTree(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +// ParsePlantTreeResponse parses an HTTP response from a PlantTreeWithResponse call +func ParsePlantTreeResponse(rsp *http.Response) (*PlantTreeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PlantTreeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 202: + var dest TreeWithID + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON202 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && true: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSONDefault = &dest + + } + + return response, nil +} + +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { + // TreePlantedWithBody fires the treePlanted callback with any body + TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *CallbackInitiator) TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewTreePlantedCallbackRequest builds a application/json POST request for the treePlanted callback +func NewTreePlantedCallbackRequest(targetURL string, body TreePlantedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTreePlantedCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewTreePlantedCallbackRequestWithBody builds a POST request for the treePlanted callback with any body +func NewTreePlantedCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Request a tree planting + // (POST /api/plant_tree) + PlantTree(w http.ResponseWriter, r *http.Request) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// PlantTree operation middleware +func (siw *ServerInterfaceWrapper) PlantTree(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PlantTree(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/plant_tree", wrapper.PlantTree) + + return m +} + +// CallbackReceiverInterface represents handlers for receiving OpenAPI +// callback requests. Each callback becomes a Handle*Callback method that +// the implementation fills in to receive the inbound HTTP request. The +// caller mounts the per-callback http.Handler returned by the +// OpCallbackHandler factory below at whatever URL path the parent +// operation's callback expression resolves to. +type CallbackReceiverInterface interface { + // Tree planting result notification + // HandleTreePlantedCallback handles the POST callback for treePlanted. + HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) +} + +// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross-cutting +// behavior (signature verification, logging, rate limiting, ...). +type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler + +// TreePlantedCallbackHandler returns the http.Handler for the treePlanted callback. +// Mount this at the URL path the parent operation's callback expression +// resolves to. Middlewares are applied in the order provided (the last +// argument becomes the outermost wrapper). +func TreePlantedCallbackHandler(si CallbackReceiverInterface, middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleTreePlantedCallback(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/callbacks/callbacks.gen.go b/internal/test/callbacks/callbacks.gen.go new file mode 100644 index 0000000000..16f95b32cf --- /dev/null +++ b/internal/test/callbacks/callbacks.gen.go @@ -0,0 +1,602 @@ +//go:build go1.22 + +// Package callbacks provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package callbacks + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +// TreePlantingRequest defines model for TreePlantingRequest. +type TreePlantingRequest struct { + CallbackUrl string `json:"callbackUrl"` + Kind string `json:"kind"` +} + +// TreePlantingResult defines model for TreePlantingResult. +type TreePlantingResult struct { + Id string `json:"id"` + Success bool `json:"success"` +} + +// PlantTreeJSONRequestBody defines body for PlantTree for application/json ContentType. +type PlantTreeJSONRequestBody = TreePlantingRequest + +// TreePlantedJSONRequestBody defines body for TreePlanted for application/json ContentType. +type TreePlantedJSONRequestBody = TreePlantingResult + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // PlantTreeWithBody request with any body + PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) PlantTreeWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PlantTree(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPlantTreeRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewPlantTreeRequest calls the generic PlantTree builder with application/json body +func NewPlantTreeRequest(server string, body PlantTreeJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPlantTreeRequestWithBody(server, "application/json", bodyReader) +} + +// NewPlantTreeRequestWithBody generates requests for PlantTree with any type of body +func NewPlantTreeRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/api/plant_tree") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // PlantTreeWithBodyWithResponse request with any body + PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) + + PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) +} + +type PlantTreeResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r PlantTreeResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PlantTreeResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r PlantTreeResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +// PlantTreeWithBodyWithResponse request with arbitrary body returning *PlantTreeResponse +func (c *ClientWithResponses) PlantTreeWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTreeWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +func (c *ClientWithResponses) PlantTreeWithResponse(ctx context.Context, body PlantTreeJSONRequestBody, reqEditors ...RequestEditorFn) (*PlantTreeResponse, error) { + rsp, err := c.PlantTree(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePlantTreeResponse(rsp) +} + +// ParsePlantTreeResponse parses an HTTP response from a PlantTreeWithResponse call +func ParsePlantTreeResponse(rsp *http.Response) (*PlantTreeResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PlantTreeResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { + // TreePlantedWithBody fires the treePlanted callback with any body + TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *CallbackInitiator) TreePlantedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) TreePlanted(ctx context.Context, targetURL string, body TreePlantedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTreePlantedCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewTreePlantedCallbackRequest builds a application/json POST request for the treePlanted callback +func NewTreePlantedCallbackRequest(targetURL string, body TreePlantedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTreePlantedCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewTreePlantedCallbackRequestWithBody builds a POST request for the treePlanted callback with any body +func NewTreePlantedCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { + // Request a tree planting (async; result delivered via callback). + // (POST /api/plant_tree) + PlantTree(w http.ResponseWriter, r *http.Request) +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +// PlantTree operation middleware +func (siw *ServerInterfaceWrapper) PlantTree(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.PlantTree(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{}) +} + +// ServeMux is an abstraction of [http.ServeMux]. +type ServeMux interface { + HandleFunc(pattern string, handler func(http.ResponseWriter, *http.Request)) + http.Handler +} + +type StdHTTPServerOptions struct { + BaseURL string + BaseRouter ServeMux + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, m ServeMux) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseRouter: m, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, m ServeMux, baseURL string) http.Handler { + return HandlerWithOptions(si, StdHTTPServerOptions{ + BaseURL: baseURL, + BaseRouter: m, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.Handler { + m := options.BaseRouter + + if m == nil { + m = http.NewServeMux() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + wrapper := ServerInterfaceWrapper{ + Handler: si, + HandlerMiddlewares: options.Middlewares, + ErrorHandlerFunc: options.ErrorHandlerFunc, + } + + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api/plant_tree", wrapper.PlantTree) + + return m +} + +// CallbackReceiverInterface represents handlers for receiving OpenAPI +// callback requests. Each callback becomes a Handle*Callback method that +// the implementation fills in to receive the inbound HTTP request. The +// caller mounts the per-callback http.Handler returned by the +// OpCallbackHandler factory below at whatever URL path the parent +// operation's callback expression resolves to. +type CallbackReceiverInterface interface { + // Tree planting result notification. + // HandleTreePlantedCallback handles the POST callback for treePlanted. + HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) +} + +// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross-cutting +// behavior (signature verification, logging, rate limiting, ...). +type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler + +// TreePlantedCallbackHandler returns the http.Handler for the treePlanted callback. +// Mount this at the URL path the parent operation's callback expression +// resolves to. Middlewares are applied in the order provided (the last +// argument becomes the outermost wrapper). +func TreePlantedCallbackHandler(si CallbackReceiverInterface, middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandleTreePlantedCallback(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/internal/test/callbacks/callbacks_test.go b/internal/test/callbacks/callbacks_test.go new file mode 100644 index 0000000000..49c95fd003 --- /dev/null +++ b/internal/test/callbacks/callbacks_test.go @@ -0,0 +1,106 @@ +// Package callbacks tests OpenAPI callback codegen end-to-end. Spec is +// OpenAPI 3.0 to verify callbacks aren't accidentally gated on 3.1. +package callbacks + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeReceiver struct { + gotMethod string + gotContentType string + gotResult TreePlantingResult + called bool +} + +func (f *fakeReceiver) HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) { + f.called = true + f.gotMethod = r.Method + f.gotContentType = r.Header.Get("Content-Type") + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer r.Body.Close() + if err := json.Unmarshal(body, &f.gotResult); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func TestCallbackRoundTrip(t *testing.T) { + receiver := &fakeReceiver{} + + mux := http.NewServeMux() + mux.Handle("POST /tree-planted", TreePlantedCallbackHandler(receiver)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewCallbackInitiator() + require.NoError(t, err) + + result := TreePlantingResult{ + Id: "tree-42", + Success: true, + } + + resp, err := initiator.TreePlanted(context.Background(), srv.URL+"/tree-planted", result) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusNoContent, resp.StatusCode) + require.True(t, receiver.called, "callback handler should have been called") + assert.Equal(t, "POST", receiver.gotMethod) + assert.Equal(t, "application/json", receiver.gotContentType) + assert.Equal(t, result, receiver.gotResult) +} + +// TestCallbackInitiatorRequestEditor verifies WithCallbackRequestEditorFn +// composes onto every outgoing request, mirroring the path Client and +// the WebhookInitiator behavior. +func TestCallbackInitiatorRequestEditor(t *testing.T) { + const sigHeader = "X-Callback-Signature" + const sigValue = "t=1,v1=abc" + + receiver := &capturingReceiver{} + mux := http.NewServeMux() + mux.Handle("POST /cb", TreePlantedCallbackHandler(receiver)) + srv := httptest.NewServer(mux) + defer srv.Close() + + initiator, err := NewCallbackInitiator( + WithCallbackRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set(sigHeader, sigValue) + return nil + }), + ) + require.NoError(t, err) + + resp, err := initiator.TreePlanted(context.Background(), srv.URL+"/cb", TreePlantingResult{ + Id: "x", + Success: false, + }) + require.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, sigValue, receiver.gotHeaders.Get(sigHeader)) +} + +type capturingReceiver struct { + gotHeaders http.Header +} + +func (c *capturingReceiver) HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) { + c.gotHeaders = r.Header.Clone() + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/test/callbacks/config.yaml b/internal/test/callbacks/config.yaml new file mode 100644 index 0000000000..83379f94ca --- /dev/null +++ b/internal/test/callbacks/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: callbacks +generate: + models: true + client: true + std-http-server: true +output-options: + skip-prune: true +output: callbacks.gen.go diff --git a/internal/test/callbacks/doc.go b/internal/test/callbacks/doc.go new file mode 100644 index 0000000000..c336fdde75 --- /dev/null +++ b/internal/test/callbacks/doc.go @@ -0,0 +1,8 @@ +// Package callbacks verifies OpenAPI callback codegen end-to-end. The +// spec is OpenAPI 3.0 to ensure callback support is NOT gated on 3.1 +// (callbacks have been part of the spec since 3.0). The CallbackInitiator +// fires a callback against an httptest server that registers the +// CallbackReceiverInterface handler. +package callbacks + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/callbacks/spec.yaml b/internal/test/callbacks/spec.yaml new file mode 100644 index 0000000000..918b2d98fb --- /dev/null +++ b/internal/test/callbacks/spec.yaml @@ -0,0 +1,57 @@ +# Callbacks have been part of the OpenAPI spec since 3.0, so this +# fixture deliberately uses 3.0.3 to verify the codegen does NOT gate +# callback emission on OpenAPI 3.1. +openapi: 3.0.3 +info: + title: Callbacks test + version: 1.0.0 +paths: + /api/plant_tree: + post: + operationId: PlantTree + summary: Request a tree planting (async; result delivered via callback). + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingRequest' + responses: + '202': + description: Accepted + callbacks: + treePlanted: + '{$request.body#/callbackUrl}': + post: + operationId: TreePlanted + summary: Tree planting result notification. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TreePlantingResult' + responses: + '204': + description: Acknowledged + +components: + schemas: + TreePlantingRequest: + type: object + required: [kind, callbackUrl] + properties: + kind: + type: string + callbackUrl: + type: string + format: uri + + TreePlantingResult: + type: object + required: [id, success] + properties: + id: + type: string + success: + type: boolean diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 71e526391f..021b075ed1 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -265,7 +265,16 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error creating webhook operation definitions: %w", err) } - allOps := append(append([]OperationDefinition{}, ops...), webhookOps...) + + // Callbacks (OpenAPI 3.0+) are nested under path operations. Like + // webhooks they render via initiator/receiver templates, but they + // are NOT version-gated -- callbacks have been part of the spec + // since 3.0. Gather is no-op for specs without any callbacks. + callbackOps, err := CallbackOperationDefinitions(spec) + if err != nil { + return "", fmt.Errorf("error creating callback operation definitions: %w", err) + } + allOps := append(append(append([]OperationDefinition{}, ops...), webhookOps...), callbackOps...) xGoTypeImports, err := OperationImports(allOps) if err != nil { @@ -441,6 +450,27 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback initiator pairs with the path Client. Emitted whenever + // Generate.Client is on and the spec declares any callbacks -- + // callbacks predate 3.1 so this is not version-gated. + var callbackInitiatorOut string + if opts.Generate.Client && len(callbackOps) > 0 { + callbackInitiatorOut, err = GenerateCallbackInitiator(t, callbackOps) + if err != nil { + return "", fmt.Errorf("error generating callback initiator: %w", err) + } + } + + // Callback receiver (stdhttp) pairs with the path StdHTTPServer. + // Same reasoning as the initiator: not version-gated. + var stdHTTPCallbackReceiverOut string + if opts.Generate.StdHTTPServer && len(callbackOps) > 0 { + stdHTTPCallbackReceiverOut, err = GenerateStdHTTPCallbackReceiver(t, callbackOps) + if err != nil { + return "", fmt.Errorf("error generating stdhttp callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -498,6 +528,12 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { return "", fmt.Errorf("error writing webhook initiator: %w", err) } } + if callbackInitiatorOut != "" { + _, err = w.WriteString(callbackInitiatorOut) + if err != nil { + return "", fmt.Errorf("error writing callback initiator: %w", err) + } + } } if opts.Generate.IrisServer { @@ -561,6 +597,12 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { return "", fmt.Errorf("error writing stdhttp webhook receiver: %w", err) } } + if stdHTTPCallbackReceiverOut != "" { + _, err = w.WriteString(stdHTTPCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing stdhttp callback receiver: %w", err) + } + } } if opts.Generate.Strict { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 3aa25a83b5..37332dc736 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -339,6 +339,17 @@ type OperationDefinition struct { IsWebhook bool // WebhookName is the spec.Webhooks map key when IsWebhook is true. WebhookName string + + // IsCallback is true when this OperationDefinition was sourced from + // a parent operation's `callbacks:` block (OpenAPI 3.0+). Callback + // operations have no path template at codegen time; the target URL + // is the runtime callback URL discovered via the spec's callback + // expression (typically a field on the parent operation's request + // body) and is supplied per-call by the initiator. + IsCallback bool + // CallbackName is the parent operation's `callbacks:` map key + // (e.g. "treePlanted") when IsCallback is true. + CallbackName string } // HandlerName returns the OperationId to use when referencing the server-side @@ -1000,6 +1011,135 @@ func WebhookOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, er return operations, nil } +// CallbackOperationDefinitions extracts OpenAPI callback operations +// from spec.Paths...Callbacks into the same +// OperationDefinition shape used for path operations, so they flow +// through the same downstream pipeline (body / response generation, +// type definitions, etc.) but are routed to callback-specific +// templates. +// +// Callbacks have been part of the OpenAPI spec since 3.0, so this is +// not gated on version: any spec that declares callbacks gets them +// generated. The spec shape: +// +// paths: +// /api/plant_tree: +// post: +// operationId: PlantTree +// callbacks: +// treePlanted: # the callback map key +// '{$request.body#/url}': # the runtime URL expression +// post: +// operationId: TreePlanted +// +// Each leaf operation (the inner `post:` above) becomes one +// OperationDefinition with IsCallback=true and CallbackName set to the +// outer map key ("treePlanted"). The codegen does not interpret the URL +// expression itself -- the caller of the generated CallbackInitiator +// supplies the resolved target URL at runtime (the same way it would +// for a webhook). +func CallbackOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, error) { + var operations []OperationDefinition + if swagger == nil || swagger.Paths == nil { + return operations, nil + } + + for _, requestPath := range SortedMapKeys(swagger.Paths.Map()) { + pathItem := swagger.Paths.Value(requestPath) + if pathItem == nil { + continue + } + for _, parentOp := range pathItem.Operations() { + if len(parentOp.Callbacks) == 0 { + continue + } + for _, callbackName := range SortedMapKeys(parentOp.Callbacks) { + cbRef := parentOp.Callbacks[callbackName] + if cbRef == nil || cbRef.Value == nil { + continue + } + cb := cbRef.Value + // A Callback maps URL-expression to PathItem; iterate + // in sorted key order for deterministic output. The + // internal map is private so use the accessor pair. + cbKeys := append([]string(nil), cb.Keys()...) + slices.Sort(cbKeys) + for _, urlExpr := range cbKeys { + cbPathItem := cb.Value(urlExpr) + if cbPathItem == nil { + continue + } + globalParams, err := DescribeParameters(cbPathItem.Parameters, nil) + if err != nil { + return nil, fmt.Errorf("error describing callback %q parameters: %w", callbackName, err) + } + + cbOps := cbPathItem.Operations() + for _, opName := range SortedMapKeys(cbOps) { + op := cbOps[opName] + + operationId := op.OperationID + if operationId == "" { + operationId = callbackName + } + operationId = nameNormalizer(operationId) + operationId = typeNamePrefix(operationId) + operationId + + localParams, err := DescribeParameters(op.Parameters, []string{operationId + "Params"}) + if err != nil { + return nil, fmt.Errorf("error describing callback %q operation params: %w", callbackName, err) + } + allParams, err := CombineOperationParameters(globalParams, localParams) + if err != nil { + return nil, err + } + + bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody) + if err != nil { + return nil, fmt.Errorf("error generating body definitions for callback %q: %w", callbackName, err) + } + + responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map()) + if err != nil { + return nil, fmt.Errorf("error generating response definitions for callback %q: %w", callbackName, err) + } + + opDef := OperationDefinition{ + HeaderParams: FilterParameterDefinitionByType(allParams, "header"), + QueryParams: FilterParameterDefinitionByType(allParams, "query"), + CookieParams: FilterParameterDefinitionByType(allParams, "cookie"), + OperationId: nameNormalizer(operationId), + Summary: op.Summary, + Method: opName, + Path: "", + Spec: op, + Bodies: bodyDefinitions, + Responses: responseDefinitions, + TypeDefinitions: typeDefinitions, + IsCallback: true, + CallbackName: callbackName, + } + + if op.Security != nil { + opDef.SecurityDefinitions = DescribeSecurityDefinition(*op.Security) + } else { + opDef.SecurityDefinitions = DescribeSecurityDefinition(swagger.Security) + } + + if op.RequestBody != nil { + opDef.BodyRequired = op.RequestBody.Value.Required + } + + opDef.TypeDefinitions = append(opDef.TypeDefinitions, GenerateTypeDefsForOperation(opDef)...) + operations = append(operations, opDef) + } + } + } + } + } + return operations, nil +} + func generateDefaultOperationID(opName string, requestPath string) (string, error) { if opName == "" { return "", fmt.Errorf("operation name cannot be an empty string") @@ -1480,6 +1620,21 @@ func GenerateStdHTTPWebhookReceiver(t *template.Template, webhookOps []Operation return GenerateTemplates([]string{"stdhttp/std-http-webhook-receiver.tmpl"}, t, webhookOps) } +// GenerateCallbackInitiator generates the CallbackInitiator -- the +// client-side analog for OpenAPI callbacks. Structurally identical to +// GenerateWebhookInitiator but takes the callback OperationDefinitions +// gathered via CallbackOperationDefinitions, which walk paths/operations/ +// callbacks rather than spec.Webhooks. +func GenerateCallbackInitiator(t *template.Template, callbackOps []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"callback-initiator.tmpl"}, t, callbackOps) +} + +// GenerateStdHTTPCallbackReceiver generates the CallbackReceiver -- the +// server-side analog for OpenAPI callbacks. +func GenerateStdHTTPCallbackReceiver(t *template.Template, callbackOps []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"stdhttp/std-http-callback-receiver.tmpl"}, t, callbackOps) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/callback-initiator.tmpl b/pkg/codegen/templates/callback-initiator.tmpl new file mode 100644 index 0000000000..908695bf37 --- /dev/null +++ b/pkg/codegen/templates/callback-initiator.tmpl @@ -0,0 +1,236 @@ +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + // {{$opid}}{{if .HasBody}}WithBody{{end}} fires the {{.CallbackName}} callback{{if .HasBody}} with any body{{end}} + {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) +{{range .Bodies}} + {{if .IsSupportedByClient -}} + {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) + {{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} +} + + +{{/* Generate callback initiator methods */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} + +func (p *CallbackInitiator) {{$opid}}{{if .HasBody}}WithBody{{end}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}CallbackRequest{{if .HasBody}}WithBody{{end}}(targetURL{{if $hasParams}}, params{{end}}{{if .HasBody}}, contentType, body{{end}}) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +func (p *CallbackInitiator) {{$opid}}{{.Suffix}}(ctx context.Context, targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody, reqEditors... RequestEditorFn) (*http.Response, error) { + req, err := New{{$opid}}CallbackRequest{{.Suffix}}(targetURL{{if $hasParams}}, params{{end}}, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} +{{end -}} +{{end}}{{/* range .Bodies */}} +{{end}}{{/* range . */}} + +{{/* Generate callback request builders */}} +{{range . -}} +{{$hasParams := .RequiresParamObject -}} +{{$opid := .OperationId -}} +{{$method := .Method -}} +{{$callbackName := .CallbackName -}} + +{{range .Bodies}} +{{if .IsSupportedByClient -}} +// New{{$opid}}CallbackRequest{{.Suffix}} builds a {{.ContentType}} {{$method}} request for the {{$callbackName}} callback +func New{{$opid}}CallbackRequest{{.Suffix}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}, body {{$opid}}{{.NameTag}}RequestBody) (*http.Request, error) { + var bodyReader io.Reader + {{if .IsJSON -}} + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + {{else if eq .NameTag "Formdata" -}} + bodyStr, err := runtime.MarshalForm(body, nil) + if err != nil { + return nil, err + } + bodyReader = strings.NewReader(bodyStr.Encode()) + {{else if eq .NameTag "Text" -}} + if stringer, ok := interface{}(body).(fmt.Stringer); ok { + bodyReader = strings.NewReader(stringer.String()) + } else { + {{if .Schema.IsPrimitive -}} + bodyReader = strings.NewReader(fmt.Sprint(body)) + {{else -}} + return nil, fmt.Errorf("text/plain is not supported for complex types, define a String() method on {{.Schema.TypeDecl}} to marshal it as text") + {{end -}} + } + {{end -}} + return New{{$opid}}CallbackRequestWithBody(targetURL{{if $hasParams}}, params{{end}}, "{{.ContentType}}", bodyReader) +} +{{end -}} +{{end}} + +// New{{$opid}}CallbackRequest{{if .HasBody}}WithBody{{end}} builds a {{.Method}} request for the {{.CallbackName}} callback{{if .HasBody}} with any body{{end}} +func New{{$opid}}CallbackRequest{{if .HasBody}}WithBody{{end}}(targetURL string{{if $hasParams}}, params *{{$opid}}Params{{end}}{{if .HasBody}}, contentType string, body io.Reader{{end}}) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + +{{if .QueryParams}} + if params != nil { + queryValues := reqURL.Query() + var rawQueryFragments []string + {{range $paramIdx, $param := .QueryParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + {{if .IsPassThrough}} + queryValues.Add("{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + {{end}} + {{if .IsJson}} + if queryParamBuf, err := json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}); err != nil { + return nil, err + } else { + queryValues.Add("{{.ParamName}}", string(queryParamBuf)) + } + {{end}} + {{if .IsStyled}} + if queryFrag, err := runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if and .RequiresNilCheck .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + {{end}} + {{if .RequiresNilCheck}}}{{end}} + {{end}} + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + reqURL.RawQuery = strings.Join(rawQueryFragments, "&") + } +{{end}}{{/* if .QueryParams */}} + + req, err := http.NewRequest({{.Method | httpMethodConstant}}, reqURL.String(), {{if .HasBody}}body{{else}}nil{{end}}) + if err != nil { + return nil, err + } + + {{if .HasBody}}req.Header.Add("Content-Type", contentType){{end}} +{{ if .HeaderParams }} + if params != nil { + {{range $paramIdx, $param := .HeaderParams}} + {{if .RequiresNilCheck}} if params.{{.GoName}} != nil { {{end}} + var headerParam{{$paramIdx}} string + {{if .IsPassThrough}} + headerParam{{$paramIdx}} = {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}} + {{end}} + {{if .IsJson}} + var headerParamBuf{{$paramIdx}} []byte + headerParamBuf{{$paramIdx}}, err = json.Marshal({{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}) + if err != nil { + return nil, err + } + headerParam{{$paramIdx}} = string(headerParamBuf{{$paramIdx}}) + {{end}} + {{if .IsStyled}} + headerParam{{$paramIdx}}, err = runtime.StyleParamWithOptions("{{.Style}}", {{.Explode}}, "{{.ParamName}}", {{if .HasOptionalPointer}}*{{end}}params.{{.GoName}}, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationHeader, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return nil, err + } + {{end}} + req.Header.Set("{{.ParamName}}", headerParam{{$paramIdx}}) + {{if .RequiresNilCheck}}}{{end}} + {{end}} + } +{{- end }}{{/* if .HeaderParams */}} + return req, nil +} + +{{end}}{{/* Range */}} diff --git a/pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl b/pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl new file mode 100644 index 0000000000..8117d95f92 --- /dev/null +++ b/pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl @@ -0,0 +1,34 @@ +// CallbackReceiverInterface represents handlers for receiving OpenAPI +// callback requests. Each callback becomes a Handle*Callback method that +// the implementation fills in to receive the inbound HTTP request. The +// caller mounts the per-callback http.Handler returned by the +// {{"Op"}}CallbackHandler factory below at whatever URL path the parent +// operation's callback expression resolves to. +type CallbackReceiverInterface interface { +{{range . -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}Callback handles the {{.Method}} callback for {{.CallbackName}}. +Handle{{.OperationId}}Callback(w http.ResponseWriter, r *http.Request) +{{end}} +} + +// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross-cutting +// behavior (signature verification, logging, rate limiting, ...). +type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range . -}} +// {{.OperationId}}CallbackHandler returns the http.Handler for the {{.CallbackName}} callback. +// Mount this at the URL path the parent operation's callback expression +// resolves to. Middlewares are applied in the order provided (the last +// argument becomes the outermost wrapper). +func {{.OperationId}}CallbackHandler(si CallbackReceiverInterface, middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.Handle{{.OperationId}}Callback(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} From eda50485f4e9c83cf58f9078886a9f89b2728e33 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:10:21 -0700 Subject: [PATCH 06/30] feat(codegen): OpenAPI 3.1 polish -- examples in doc comments + const MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the 3.1 backport. Two small additive features that round out the 3.1 surface; no template changes. `examples:` (plural array) → Go doc comments: * New describeWithExamples() helper in pkg/codegen/schema.go folds example data into the description string used for generated doc comments. Version-aware: 3.0 reads schema.Example (singular), 3.1 reads schema.Examples (plural array). Cross-version misuse is documented as invalid input and the helper does not look at the off-version field. * Non-string examples are JSON-encoded so structured values render readably; strings round-trip as themselves. * Applied at the three sites where Description flows from the spec schema into the generated Schema/Property: GenerateGoSchema's $ref short-circuit, the main outSchema construction, and per-property population during object walk. `const:` → typed alias + singleton constant: * The existing enum branch in GenerateGoSchema now also fires on `(globalState.is31 && schema.Const != nil)`. A local enumSource abstracts over the two cases so a scalar `const: X` schema emits the same shape as `enum: [X]`: a typed alias plus a sanitized constant derived from the value. * Falls through the standard EnumDefinition pipeline -- existing constants.tmpl renders the typed enum + Valid() method without changes. Paths optional in 3.1: no-op. Every swagger.Paths access site (filter.go, prune.go, gather.go, operations.go, the new WebhookOperationDefinitions and CallbackOperationDefinitions) already nil-guards. Tests (internal/test/openapi31_polish/): * Pet.Name has a description plus two string examples; Pet.Lives has one integer example. Status is a top-level `const: active` schema. * TestStatusConstSchema (instantiation): `var s Status = Active` compiles only because Status is a distinct named type, and asserts the constant's value plus that Valid() returns true. * TestPetExampleComments parses the generated Go source and walks the AST to extract Pet's per-field doc comments, then asserts the expected description and "Examples: ..." fragments. Doc comments aren't runtime-introspectable, so AST inspection of the generated file is the right granularity here -- not a brittle string-match against the file as a whole. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/openapi31_polish/config.yaml | 7 ++ internal/test/openapi31_polish/doc.go | 7 ++ .../openapi31_polish/openapi31_polish.gen.go | 33 +++++++ .../openapi31_polish/openapi31_polish_test.go | 88 +++++++++++++++++++ internal/test/openapi31_polish/spec.yaml | 33 +++++++ pkg/codegen/schema.go | 75 ++++++++++++++-- 6 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 internal/test/openapi31_polish/config.yaml create mode 100644 internal/test/openapi31_polish/doc.go create mode 100644 internal/test/openapi31_polish/openapi31_polish.gen.go create mode 100644 internal/test/openapi31_polish/openapi31_polish_test.go create mode 100644 internal/test/openapi31_polish/spec.yaml diff --git a/internal/test/openapi31_polish/config.yaml b/internal/test/openapi31_polish/config.yaml new file mode 100644 index 0000000000..8d068cc372 --- /dev/null +++ b/internal/test/openapi31_polish/config.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: openapi31_polish +generate: + models: true +output-options: + skip-prune: true +output: openapi31_polish.gen.go diff --git a/internal/test/openapi31_polish/doc.go b/internal/test/openapi31_polish/doc.go new file mode 100644 index 0000000000..d24c20c3ab --- /dev/null +++ b/internal/test/openapi31_polish/doc.go @@ -0,0 +1,7 @@ +// Package openapi31_polish verifies the OpenAPI 3.1 polish features: +// `examples` (plural array) propagating into Go doc comments, and +// scalar `const` synthesizing a typed alias + singleton constant via +// the existing enum-codegen path. +package openapi31_polish + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/openapi31_polish/openapi31_polish.gen.go b/internal/test/openapi31_polish/openapi31_polish.gen.go new file mode 100644 index 0000000000..8fe06338fe --- /dev/null +++ b/internal/test/openapi31_polish/openapi31_polish.gen.go @@ -0,0 +1,33 @@ +// Package openapi31_polish provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package openapi31_polish + +// Defines values for Status. +const ( + Active Status = "active" +) + +// Valid indicates whether the value is a known member of the Status enum. +func (e Status) Valid() bool { + switch e { + case Active: + return true + default: + return false + } +} + +// Pet defines model for Pet. +type Pet struct { + // Lives Examples: 9 + Lives int `json:"lives"` + + // Name The pet's name. + // + // Examples: Whiskers, Rex + Name string `json:"name"` +} + +// Status defines model for Status. +type Status string diff --git a/internal/test/openapi31_polish/openapi31_polish_test.go b/internal/test/openapi31_polish/openapi31_polish_test.go new file mode 100644 index 0000000000..61c32ffa62 --- /dev/null +++ b/internal/test/openapi31_polish/openapi31_polish_test.go @@ -0,0 +1,88 @@ +// Package openapi31_polish tests two OpenAPI 3.1 polish features: +// +// - `examples:` (plural array) folding into Go doc comments. Doc +// comments aren't runtime-introspectable, so the test parses the +// generated source file and asserts the expected text appears in +// the type's field comments. +// +// - Scalar `const:` becoming a typed alias plus a singleton constant +// via the existing enum-codegen path. The test exercises this by +// instantiating the typed value (compile-time guarantee that the +// type and constant exist) and asserting the constant's value. +package openapi31_polish + +import ( + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestStatusConstSchema verifies that a scalar `const` schema produces a +// typed alias and a singleton constant. Compile-time check: assigning +// the constant to a Status variable only succeeds if Status is a +// distinct named type. +func TestStatusConstSchema(t *testing.T) { + var s Status = Active + assert.Equal(t, "active", string(s)) + assert.True(t, s.Valid(), "Active should be a valid Status enum member") +} + +// TestPetExampleComments verifies that `examples:` on a property +// surfaces in the generated field's Go doc comment. We can't introspect +// doc comments at runtime, so this parses the generated source file and +// asserts each field's doc-comment text. +func TestPetExampleComments(t *testing.T) { + fs := token.NewFileSet() + f, err := parser.ParseFile(fs, "openapi31_polish.gen.go", nil, parser.ParseComments) + require.NoError(t, err, "could not parse generated file") + + fields := petFieldComments(t, f) + + // `name` had description="The pet's name." plus two examples; both + // must appear in the field doc. + require.Contains(t, fields, "Name") + assert.Contains(t, fields["Name"], "The pet's name.", + "Name field should preserve the original description") + assert.Contains(t, fields["Name"], "Examples: Whiskers, Rex", + "Name field should surface the plural examples on its own paragraph") + + // `lives` had no description, only an example value. + require.Contains(t, fields, "Lives") + assert.Contains(t, fields["Lives"], "Examples: 9", + "Lives field should surface the integer example as a doc fragment") +} + +// petFieldComments extracts the doc comment text for each field of the +// Pet struct from a parsed AST. Returns map[fieldName]commentText. +func petFieldComments(t *testing.T, f *ast.File) map[string]string { + t.Helper() + out := map[string]string{} + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Name.Name != "Pet" { + continue + } + st, ok := ts.Type.(*ast.StructType) + if !ok { + continue + } + for _, field := range st.Fields.List { + if len(field.Names) == 0 || field.Doc == nil { + continue + } + out[field.Names[0].Name] = strings.TrimSpace(field.Doc.Text()) + } + } + } + return out +} diff --git a/internal/test/openapi31_polish/spec.yaml b/internal/test/openapi31_polish/spec.yaml new file mode 100644 index 0000000000..2a742d9228 --- /dev/null +++ b/internal/test/openapi31_polish/spec.yaml @@ -0,0 +1,33 @@ +openapi: 3.1.0 +info: + title: OpenAPI 3.1 polish features + version: 1.0.0 + description: | + Verifies the smaller 3.1 polish features: + * `examples` (plural array) propagates into Go doc comments. + * `const` on a scalar schema becomes a typed alias + singleton + constant via the existing enum-codegen path. +paths: {} +components: + schemas: + Pet: + type: object + required: [name, lives] + properties: + name: + type: string + description: The pet's name. + examples: + - "Whiskers" + - "Rex" + lives: + type: integer + examples: + - 9 + + # Top-level `const` schema -- with no `enum:` set, the codegen + # synthesizes a single-value enum from the const so the schema + # becomes a typed alias plus a singleton constant. + Status: + type: string + const: active diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 27ac1474ba..77b1dbd032 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -1,6 +1,7 @@ package codegen import ( + "encoding/json" "errors" "fmt" "maps" @@ -413,6 +414,60 @@ func detectEnumViaOneOf(schema *openapi3.Schema) ([]enumViaOneOfValue, bool) { return items, true } +// describeWithExamples folds a schema's example data into its +// description string for use in generated Go doc comments. Version-aware: +// in 3.0 it reads schema.Example (singular); in 3.1 it reads +// schema.Examples (plural array). Cross-version misuse is documented as +// invalid input -- this helper does not look at the off-version field. +// +// The output appends `Examples: , , ...` (or `Example: ` in +// 3.0) on a new paragraph after any existing description text. Non- +// string values are JSON-encoded so structured examples render +// readably. +func describeWithExamples(description string, schema *openapi3.Schema) string { + if schema == nil { + return description + } + var values []any + label := "Example" + if globalState.is31 { + if len(schema.Examples) == 0 { + return description + } + values = schema.Examples + label = "Examples" + } else { + if schema.Example == nil { + return description + } + values = []any{schema.Example} + } + + formatted := make([]string, 0, len(values)) + for _, v := range values { + formatted = append(formatted, formatExampleValue(v)) + } + + suffix := label + ": " + strings.Join(formatted, ", ") + if description == "" { + return suffix + } + return description + "\n\n" + suffix +} + +// formatExampleValue renders an example value for inclusion in a Go doc +// comment. Strings round-trip as themselves (no JSON quoting); other +// values JSON-encode so structured examples remain readable. +func formatExampleValue(v any) string { + if s, ok := v.(string); ok { + return s + } + if b, err := json.Marshal(v); err == nil { + return string(b) + } + return fmt.Sprintf("%v", v) +} + // schemaPrimaryType returns the type slice used for primitive-type dispatch // (`*Types.Is("string")` etc.). In OpenAPI 3.0 the type slice has at most // one entry and is returned unchanged. In OpenAPI 3.1 the "null" entry is @@ -484,7 +539,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { return Schema{ GoType: refType, - Description: schema.Description, + Description: describeWithExamples(schema.Description, schema), DefineViaAlias: true, SkipOptionalPointer: skipOptionalPointer, OAPISchema: schema, @@ -492,7 +547,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } outSchema := Schema{ - Description: schema.Description, + Description: describeWithExamples(schema.Description, schema), OAPISchema: schema, SkipOptionalPointer: skipOptionalPointer, } @@ -719,7 +774,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } description := "" if p.Value != nil { - description = p.Value.Description + description = describeWithExamples(p.Value.Description, p.Value) } prop := Property{ @@ -775,7 +830,7 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } return outSchema, nil - } else if len(schema.Enum) > 0 { + } else if len(schema.Enum) > 0 || (globalState.is31 && schema.Const != nil) { err := oapiSchemaToGoType(schema, path, &outSchema) // Enums need to be typed, so that the values aren't interchangeable, // so no matter what schema conversion thinks, we need to define a @@ -785,8 +840,16 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { if err != nil { return Schema{}, fmt.Errorf("error resolving primitive type: %w", err) } - enumValues := make([]string, len(schema.Enum)) - for i, enumValue := range schema.Enum { + // OpenAPI 3.1 `const: X` is shorthand for `enum: [X]`. Fold the + // two through the same enum-codegen path here so a top-level + // `const` schema becomes a typed alias plus a singleton constant, + // using the same name-derivation rules `enum` uses. + enumSource := schema.Enum + if len(enumSource) == 0 { + enumSource = []any{schema.Const} + } + enumValues := make([]string, len(enumSource)) + for i, enumValue := range enumSource { enumValues[i] = fmt.Sprintf("%v", enumValue) } From d60b9d81c2341dcbae4115c85018de40f52e50d0 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:39:55 -0700 Subject: [PATCH 07/30] refactor(codegen): unify stdhttp webhook+callback receiver template, add param binding Phase 6.1 of the 3.1 backport (kicking off the per-framework receiver work). Sets up the prototype shape that the other 7 server-framework receivers will follow. Two changes that always have to land together: 1. Unify the two receiver templates into one parameterized template, matching oapi-codegen-exp's structure: * Replaces pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl and pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl (deleted) with a single std-http-receiver.tmpl. The new template takes a Prefix data field ("Webhook" or "Callback") and emits the correct interface, middleware type, and per-op handler factory. * New ReceiverTemplateData struct + NewReceiverTemplateData constructor in operations.go feed the template. * Single GenerateStdHTTPReceiver(t, prefix, ops) entry point; callers in Generate() pass "Webhook" or "Callback". 2. Add query/header parameter binding to the receiver factory, matching mainline's existing path-server middleware pattern. The factory now takes an errHandler argument: func PetStatusChangedWebhookHandler( si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc, ) http.Handler When operations have query/header params (rare for webhooks but common for callbacks), the inline closure binds them into a typed {Op}Params struct via runtime.BindQueryParameterWithOptions / runtime.BindStyledParameterWithOptions and routes any error through errHandler. errHandler may be nil; the default returns 400 with the error message. When operations have no params, errHandler is unused -- pass nil and the prior shape's behavior is preserved. This is a breaking change to anyone using the Phase 3/4 generated *Handler(si, middlewares...) shape. Acceptable since the Phase 3/4 surface is unreleased and only-this-branch. New helper: OperationDefinition.SourceName() returns WebhookName when IsWebhook, CallbackName when IsCallback. Templates use it to label emitted handlers uniformly without branching on the source kind. Existing call sites updated to pass nil for errHandler: internal/test/webhooks/webhooks_test.go (3 places) internal/test/callbacks/callbacks_test.go (2 places) examples/webhook/client/main.go (2 places) examples/callback/client/main.go (1 place) The four affected gen files are regenerated. Other gen files in the tree have unrelated drift from Phase 5's examples-in-doc-comments feature (those don't touch the new receiver shape); a follow-up `make generate` commit picks them up cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) --- examples/callback/client/main.go | 2 +- examples/callback/treefarm.gen.go | 29 +++-- examples/webhook/client/main.go | 4 +- examples/webhook/doorbadge.gen.go | 40 ++++-- internal/test/callbacks/callbacks.gen.go | 29 +++-- internal/test/callbacks/callbacks_test.go | 4 +- internal/test/webhooks/webhooks.gen.go | 27 ++-- internal/test/webhooks/webhooks_test.go | 6 +- pkg/codegen/codegen.go | 10 +- pkg/codegen/operations.go | 55 +++++++-- .../stdhttp/std-http-callback-receiver.tmpl | 34 ----- .../templates/stdhttp/std-http-receiver.tmpl | 116 ++++++++++++++++++ .../stdhttp/std-http-webhook-receiver.tmpl | 32 ----- 13 files changed, 251 insertions(+), 137 deletions(-) delete mode 100644 pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl create mode 100644 pkg/codegen/templates/stdhttp/std-http-receiver.tmpl delete mode 100644 pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl diff --git a/examples/callback/client/main.go b/examples/callback/client/main.go index da8bcb9492..ec15c59c5a 100644 --- a/examples/callback/client/main.go +++ b/examples/callback/client/main.go @@ -84,7 +84,7 @@ func main() { receiver := NewCallbackReceiver(numTrees) mux := http.NewServeMux() - mux.Handle("/tree_callback", treefarm.TreePlantedCallbackHandler(receiver)) + mux.Handle("/tree_callback", treefarm.TreePlantedCallbackHandler(receiver, nil)) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/examples/callback/treefarm.gen.go b/examples/callback/treefarm.gen.go index 713eecf39e..7a48de14fd 100644 --- a/examples/callback/treefarm.gen.go +++ b/examples/callback/treefarm.gen.go @@ -641,27 +641,32 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H return m } -// CallbackReceiverInterface represents handlers for receiving OpenAPI -// callback requests. Each callback becomes a Handle*Callback method that -// the implementation fills in to receive the inbound HTTP request. The -// caller mounts the per-callback http.Handler returned by the -// OpCallbackHandler factory below at whatever URL path the parent -// operation's callback expression resolves to. +// CallbackReceiverInterface represents handlers for receiving inbound +// callback requests. Each callback becomes a Handle*Callback +// method that the implementation fills in. The caller mounts the per- +// callback http.Handler returned by {Op}CallbackHandler at +// whatever URL path they advertise to senders. type CallbackReceiverInterface interface { // Tree planting result notification // HandleTreePlantedCallback handles the POST callback for treePlanted. HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) } -// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross-cutting -// behavior (signature verification, logging, rate limiting, ...). +// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler // TreePlantedCallbackHandler returns the http.Handler for the treePlanted callback. -// Mount this at the URL path the parent operation's callback expression -// resolves to. Middlewares are applied in the order provided (the last -// argument becomes the outermost wrapper). -func TreePlantedCallbackHandler(si CallbackReceiverInterface, middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { +// Mount this at the URL path advertised to callback senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func TreePlantedCallbackHandler(si CallbackReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { si.HandleTreePlantedCallback(w, r) }) diff --git a/examples/webhook/client/main.go b/examples/webhook/client/main.go index e60ab3b04e..8bd9d8d34e 100644 --- a/examples/webhook/client/main.go +++ b/examples/webhook/client/main.go @@ -92,8 +92,8 @@ func main() { receiver := &WebhookReceiver{} mux := http.NewServeMux() - mux.Handle("POST /enter", doorbadge.EnterEventWebhookHandler(receiver)) - mux.Handle("POST /exit", doorbadge.ExitEventWebhookHandler(receiver)) + mux.Handle("POST /enter", doorbadge.EnterEventWebhookHandler(receiver, nil)) + mux.Handle("POST /exit", doorbadge.ExitEventWebhookHandler(receiver, nil)) listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { diff --git a/examples/webhook/doorbadge.gen.go b/examples/webhook/doorbadge.gen.go index ae099d4861..44877daef0 100644 --- a/examples/webhook/doorbadge.gen.go +++ b/examples/webhook/doorbadge.gen.go @@ -857,11 +857,11 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H return m } -// WebhookReceiverInterface represents handlers for receiving OpenAPI 3.1 -// webhook requests. Each webhook becomes a Handle*Webhook method that the -// implementation fills in to receive the inbound HTTP request. The caller -// mounts the per-webhook http.Handler returned by the OpWebhookHandler -// factory below at whatever URL path they advertise to subscribers. +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. type WebhookReceiverInterface interface { // Person entered the building // HandleEnterEventWebhook handles the POST webhook for enterEvent. @@ -871,14 +871,21 @@ type WebhookReceiverInterface interface { HandleExitEventWebhook(w http.ResponseWriter, r *http.Request) } -// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross-cutting -// behavior (signature verification, logging, rate limiting, ...). +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler // EnterEventWebhookHandler returns the http.Handler for the enterEvent webhook. -// Mount this at the URL path advertised to webhook subscribers. Middlewares -// are applied in the order provided (outermost first). -func EnterEventWebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func EnterEventWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { si.HandleEnterEventWebhook(w, r) }) @@ -889,9 +896,16 @@ func EnterEventWebhookHandler(si WebhookReceiverInterface, middlewares ...Webhoo } // ExitEventWebhookHandler returns the http.Handler for the exitEvent webhook. -// Mount this at the URL path advertised to webhook subscribers. Middlewares -// are applied in the order provided (outermost first). -func ExitEventWebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func ExitEventWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { si.HandleExitEventWebhook(w, r) }) diff --git a/internal/test/callbacks/callbacks.gen.go b/internal/test/callbacks/callbacks.gen.go index 16f95b32cf..77dc65bb02 100644 --- a/internal/test/callbacks/callbacks.gen.go +++ b/internal/test/callbacks/callbacks.gen.go @@ -571,27 +571,32 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H return m } -// CallbackReceiverInterface represents handlers for receiving OpenAPI -// callback requests. Each callback becomes a Handle*Callback method that -// the implementation fills in to receive the inbound HTTP request. The -// caller mounts the per-callback http.Handler returned by the -// OpCallbackHandler factory below at whatever URL path the parent -// operation's callback expression resolves to. +// CallbackReceiverInterface represents handlers for receiving inbound +// callback requests. Each callback becomes a Handle*Callback +// method that the implementation fills in. The caller mounts the per- +// callback http.Handler returned by {Op}CallbackHandler at +// whatever URL path they advertise to senders. type CallbackReceiverInterface interface { // Tree planting result notification. // HandleTreePlantedCallback handles the POST callback for treePlanted. HandleTreePlantedCallback(w http.ResponseWriter, r *http.Request) } -// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross-cutting -// behavior (signature verification, logging, rate limiting, ...). +// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler // TreePlantedCallbackHandler returns the http.Handler for the treePlanted callback. -// Mount this at the URL path the parent operation's callback expression -// resolves to. Middlewares are applied in the order provided (the last -// argument becomes the outermost wrapper). -func TreePlantedCallbackHandler(si CallbackReceiverInterface, middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { +// Mount this at the URL path advertised to callback senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func TreePlantedCallbackHandler(si CallbackReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { si.HandleTreePlantedCallback(w, r) }) diff --git a/internal/test/callbacks/callbacks_test.go b/internal/test/callbacks/callbacks_test.go index 49c95fd003..60d047536c 100644 --- a/internal/test/callbacks/callbacks_test.go +++ b/internal/test/callbacks/callbacks_test.go @@ -42,7 +42,7 @@ func TestCallbackRoundTrip(t *testing.T) { receiver := &fakeReceiver{} mux := http.NewServeMux() - mux.Handle("POST /tree-planted", TreePlantedCallbackHandler(receiver)) + mux.Handle("POST /tree-planted", TreePlantedCallbackHandler(receiver, nil)) srv := httptest.NewServer(mux) defer srv.Close() @@ -74,7 +74,7 @@ func TestCallbackInitiatorRequestEditor(t *testing.T) { receiver := &capturingReceiver{} mux := http.NewServeMux() - mux.Handle("POST /cb", TreePlantedCallbackHandler(receiver)) + mux.Handle("POST /cb", TreePlantedCallbackHandler(receiver, nil)) srv := httptest.NewServer(mux) defer srv.Close() diff --git a/internal/test/webhooks/webhooks.gen.go b/internal/test/webhooks/webhooks.gen.go index 5eb16759c9..85500cd34e 100644 --- a/internal/test/webhooks/webhooks.gen.go +++ b/internal/test/webhooks/webhooks.gen.go @@ -426,25 +426,32 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H return m } -// WebhookReceiverInterface represents handlers for receiving OpenAPI 3.1 -// webhook requests. Each webhook becomes a Handle*Webhook method that the -// implementation fills in to receive the inbound HTTP request. The caller -// mounts the per-webhook http.Handler returned by the OpWebhookHandler -// factory below at whatever URL path they advertise to subscribers. +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. type WebhookReceiverInterface interface { // Notifies subscribers that a pet's status changed. // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) } -// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross-cutting -// behavior (signature verification, logging, rate limiting, ...). +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler // PetStatusChangedWebhookHandler returns the http.Handler for the petStatusChanged webhook. -// Mount this at the URL path advertised to webhook subscribers. Middlewares -// are applied in the order provided (outermost first). -func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { si.HandlePetStatusChangedWebhook(w, r) }) diff --git a/internal/test/webhooks/webhooks_test.go b/internal/test/webhooks/webhooks_test.go index b3705d18ac..0c5afc8cde 100644 --- a/internal/test/webhooks/webhooks_test.go +++ b/internal/test/webhooks/webhooks_test.go @@ -49,7 +49,7 @@ func TestWebhookRoundTrip(t *testing.T) { // pick whatever URL they advertise to subscribers; the factory is // path-agnostic so the test can pick anything. mux := http.NewServeMux() - mux.Handle("POST /hooks/pet-status", PetStatusChangedWebhookHandler(receiver)) + mux.Handle("POST /hooks/pet-status", PetStatusChangedWebhookHandler(receiver, nil)) srv := httptest.NewServer(mux) defer srv.Close() @@ -82,7 +82,7 @@ func TestWebhookInitiatorRequestEditor(t *testing.T) { receiver := &capturingReceiver{} mux := http.NewServeMux() - mux.Handle("POST /hooks", PetStatusChangedWebhookHandler(receiver)) + mux.Handle("POST /hooks", PetStatusChangedWebhookHandler(receiver, nil)) srv := httptest.NewServer(mux) defer srv.Close() @@ -131,7 +131,7 @@ func TestWebhookReceiverMiddleware(t *testing.T) { mux := http.NewServeMux() mux.Handle("POST /hooks", - PetStatusChangedWebhookHandler(&capturingReceiver{}, mw("outer"), mw("inner"))) + PetStatusChangedWebhookHandler(&capturingReceiver{}, nil, mw("outer"), mw("inner"))) srv := httptest.NewServer(mux) defer srv.Close() diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 021b075ed1..4d101263ad 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -441,10 +441,11 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { // Webhook receiver (stdhttp) pairs with the path StdHTTPServer. // Emitted only when Generate.StdHTTPServer is on AND the spec has - // webhooks (3.1+). + // webhooks (3.1+). Uses the unified stdhttp receiver template, + // parameterized with prefix "Webhook". var stdHTTPWebhookReceiverOut string if opts.Generate.StdHTTPServer && len(webhookOps) > 0 { - stdHTTPWebhookReceiverOut, err = GenerateStdHTTPWebhookReceiver(t, webhookOps) + stdHTTPWebhookReceiverOut, err = GenerateStdHTTPReceiver(t, "Webhook", webhookOps) if err != nil { return "", fmt.Errorf("error generating stdhttp webhook receiver: %w", err) } @@ -462,10 +463,11 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } // Callback receiver (stdhttp) pairs with the path StdHTTPServer. - // Same reasoning as the initiator: not version-gated. + // Same reasoning as the initiator: not version-gated. Uses the + // unified stdhttp receiver template with prefix "Callback". var stdHTTPCallbackReceiverOut string if opts.Generate.StdHTTPServer && len(callbackOps) > 0 { - stdHTTPCallbackReceiverOut, err = GenerateStdHTTPCallbackReceiver(t, callbackOps) + stdHTTPCallbackReceiverOut, err = GenerateStdHTTPReceiver(t, "Callback", callbackOps) if err != nil { return "", fmt.Errorf("error generating stdhttp callback receiver: %w", err) } diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 37332dc736..773def36df 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -373,6 +373,40 @@ func (o *OperationDefinition) MiddlewareKey() string { return o.OperationId } +// SourceName returns WebhookName when IsWebhook, CallbackName when +// IsCallback, or empty otherwise. Templates use this to label the +// emitted handler uniformly without branching on which kind of source +// the operation came from. +func (o OperationDefinition) SourceName() string { + if o.IsWebhook { + return o.WebhookName + } + if o.IsCallback { + return o.CallbackName + } + return "" +} + +// ReceiverTemplateData is the input to the per-framework webhook / +// callback receiver template. Prefix selects between "Webhook" and +// "Callback" (and the lowercase form for prose), so a single template +// per framework handles both kinds. +type ReceiverTemplateData struct { + Prefix string // "Webhook" or "Callback" + PrefixLower string // lowercase form of Prefix, for prose + Operations []OperationDefinition +} + +// NewReceiverTemplateData builds the template input for the given +// prefix ("Webhook" or "Callback") and operation list. +func NewReceiverTemplateData(prefix string, ops []OperationDefinition) ReceiverTemplateData { + return ReceiverTemplateData{ + Prefix: prefix, + PrefixLower: strings.ToLower(prefix), + Operations: ops, + } +} + // Params returns the list of all parameters except Path parameters. Path parameters // are handled differently from the rest, since they're mandatory. func (o *OperationDefinition) Params() []ParameterDefinition { @@ -1612,14 +1646,6 @@ func GenerateWebhookInitiator(t *template.Template, webhookOps []OperationDefini return GenerateTemplates([]string{"webhook-initiator.tmpl"}, t, webhookOps) } -// GenerateStdHTTPWebhookReceiver generates the WebhookReceiver -- the -// server-side analog for OpenAPI 3.1 webhooks. The caller mounts each -// per-webhook http.Handler (returned by the generated WebhookHandler -// factory) at the URL path of their choice. -func GenerateStdHTTPWebhookReceiver(t *template.Template, webhookOps []OperationDefinition) (string, error) { - return GenerateTemplates([]string{"stdhttp/std-http-webhook-receiver.tmpl"}, t, webhookOps) -} - // GenerateCallbackInitiator generates the CallbackInitiator -- the // client-side analog for OpenAPI callbacks. Structurally identical to // GenerateWebhookInitiator but takes the callback OperationDefinitions @@ -1629,10 +1655,15 @@ func GenerateCallbackInitiator(t *template.Template, callbackOps []OperationDefi return GenerateTemplates([]string{"callback-initiator.tmpl"}, t, callbackOps) } -// GenerateStdHTTPCallbackReceiver generates the CallbackReceiver -- the -// server-side analog for OpenAPI callbacks. -func GenerateStdHTTPCallbackReceiver(t *template.Template, callbackOps []OperationDefinition) (string, error) { - return GenerateTemplates([]string{"stdhttp/std-http-callback-receiver.tmpl"}, t, callbackOps) +// GenerateStdHTTPReceiver renders the merged stdhttp receiver template +// (used for both webhook and callback receivers). The caller selects +// between them by passing prefix "Webhook" or "Callback" along with the +// matching OperationDefinitions. The template emits a {Prefix}Receiver +// interface plus per-operation {Op}{Prefix}Handler factories with +// query/header parameter binding inline (matching the param-binding +// machinery used by the path-server middleware). +func GenerateStdHTTPReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"stdhttp/std-http-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } // GenerateTemplates used to generate templates diff --git a/pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl b/pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl deleted file mode 100644 index 8117d95f92..0000000000 --- a/pkg/codegen/templates/stdhttp/std-http-callback-receiver.tmpl +++ /dev/null @@ -1,34 +0,0 @@ -// CallbackReceiverInterface represents handlers for receiving OpenAPI -// callback requests. Each callback becomes a Handle*Callback method that -// the implementation fills in to receive the inbound HTTP request. The -// caller mounts the per-callback http.Handler returned by the -// {{"Op"}}CallbackHandler factory below at whatever URL path the parent -// operation's callback expression resolves to. -type CallbackReceiverInterface interface { -{{range . -}} -{{.SummaryAsComment}} -// Handle{{.OperationId}}Callback handles the {{.Method}} callback for {{.CallbackName}}. -Handle{{.OperationId}}Callback(w http.ResponseWriter, r *http.Request) -{{end}} -} - -// CallbackReceiverMiddlewareFunc wraps an http.Handler with cross-cutting -// behavior (signature verification, logging, rate limiting, ...). -type CallbackReceiverMiddlewareFunc func(http.Handler) http.Handler - -{{range . -}} -// {{.OperationId}}CallbackHandler returns the http.Handler for the {{.CallbackName}} callback. -// Mount this at the URL path the parent operation's callback expression -// resolves to. Middlewares are applied in the order provided (the last -// argument becomes the outermost wrapper). -func {{.OperationId}}CallbackHandler(si CallbackReceiverInterface, middlewares ...CallbackReceiverMiddlewareFunc) http.Handler { - var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - si.Handle{{.OperationId}}Callback(w, r) - }) - for _, mw := range middlewares { - h = mw(h) - } - return h -} - -{{end}} diff --git a/pkg/codegen/templates/stdhttp/std-http-receiver.tmpl b/pkg/codegen/templates/stdhttp/std-http-receiver.tmpl new file mode 100644 index 0000000000..f673d0b5c5 --- /dev/null +++ b/pkg/codegen/templates/stdhttp/std-http-receiver.tmpl @@ -0,0 +1,116 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} http.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(w http.ResponseWriter, r *http.Request{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +// {{.Prefix}}ReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type {{.Prefix}}ReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the http.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...{{$.Prefix}}ReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the request. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if (or .IsPassThrough .IsJson)}} + if paramValue := r.URL.Query().Get("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + }{{end}} + {{end}} + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + } else { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + } + return + } + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := r.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + errHandler(w, r, &TooManyValuesForParamError{ParamName: "{{.ParamName}}", Count: n}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + err := fmt.Errorf("Header parameter {{.ParamName}} is required, but not found") + errHandler(w, r, &RequiredHeaderError{ParamName: "{{.ParamName}}", Err: err}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(w, r{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} diff --git a/pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl b/pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl deleted file mode 100644 index ca64485344..0000000000 --- a/pkg/codegen/templates/stdhttp/std-http-webhook-receiver.tmpl +++ /dev/null @@ -1,32 +0,0 @@ -// WebhookReceiverInterface represents handlers for receiving OpenAPI 3.1 -// webhook requests. Each webhook becomes a Handle*Webhook method that the -// implementation fills in to receive the inbound HTTP request. The caller -// mounts the per-webhook http.Handler returned by the {{"Op"}}WebhookHandler -// factory below at whatever URL path they advertise to subscribers. -type WebhookReceiverInterface interface { -{{range . -}} -{{.SummaryAsComment}} -// Handle{{.OperationId}}Webhook handles the {{.Method}} webhook for {{.WebhookName}}. -Handle{{.OperationId}}Webhook(w http.ResponseWriter, r *http.Request) -{{end}} -} - -// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross-cutting -// behavior (signature verification, logging, rate limiting, ...). -type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler - -{{range . -}} -// {{.OperationId}}WebhookHandler returns the http.Handler for the {{.WebhookName}} webhook. -// Mount this at the URL path advertised to webhook subscribers. Middlewares -// are applied in the order provided (outermost first). -func {{.OperationId}}WebhookHandler(si WebhookReceiverInterface, middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { - var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - si.Handle{{.OperationId}}Webhook(w, r) - }) - for _, mw := range middlewares { - h = mw(h) - } - return h -} - -{{end}} From a80da23895e5ea835f363983ba89f20f3cdb3f8a Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:41:03 -0700 Subject: [PATCH 08/30] chore(gen): regenerate gen files to pick up Phase 5 examples-in-doc-comments Phase 5 (eedf057b) added the describeWithExamples() helper that folds schema.Example / schema.Examples into the description string used for generated Go doc comments. Existing test specs that already had `example:` set on their schemas now produce richer doc comments. This commit is purely the result of running `make generate` -- no behavior change, only doc-comment additions like: // Code The underlying http status code +// +// Example: 500 Code int32 `json:"code"` Touched files: examples/minimal-server/stdhttp/api/ping.gen.go internal/test/externalref/petstore/externalref.gen.go internal/test/issues/issue-1087/deps/deps.gen.go internal/test/issues/issue-1168/api.gen.go internal/test/issues/issue1561/issue1561.gen.go Co-Authored-By: Claude Opus 4.7 (1M context) --- .../minimal-server/stdhttp/api/ping.gen.go | 1 + .../externalref/petstore/externalref.gen.go | 85 ++++++++++++++----- .../test/issues/issue-1087/deps/deps.gen.go | 8 ++ internal/test/issues/issue-1168/api.gen.go | 6 ++ .../test/issues/issue1561/issue1561.gen.go | 1 + 5 files changed, 80 insertions(+), 21 deletions(-) diff --git a/examples/minimal-server/stdhttp/api/ping.gen.go b/examples/minimal-server/stdhttp/api/ping.gen.go index 0d4ab7751a..93f74af35a 100644 --- a/examples/minimal-server/stdhttp/api/ping.gen.go +++ b/examples/minimal-server/stdhttp/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/internal/test/externalref/petstore/externalref.gen.go b/internal/test/externalref/petstore/externalref.gen.go index d70f4bec71..53a4d06187 100644 --- a/internal/test/externalref/petstore/externalref.gen.go +++ b/internal/test/externalref/petstore/externalref.gen.go @@ -87,10 +87,17 @@ func (e FindPetsByStatusParamsStatus) Valid() bool { // Address defines model for Address. type Address struct { - City *string `json:"city,omitempty"` - State *string `json:"state,omitempty"` + // City Example: Palo Alto + City *string `json:"city,omitempty"` + + // State Example: CA + State *string `json:"state,omitempty"` + + // Street Example: 437 Lytton Street *string `json:"street,omitempty"` - Zip *string `json:"zip,omitempty"` + + // Zip Example: 94301 + Zip *string `json:"zip,omitempty"` } // ApiResponse defines model for ApiResponse. @@ -102,38 +109,59 @@ type ApiResponse struct { // Category defines model for Category. type Category struct { - Id *int64 `json:"id,omitempty"` + // Id Example: 1 + Id *int64 `json:"id,omitempty"` + + // Name Example: Dogs Name *string `json:"name,omitempty"` } // Customer defines model for Customer. type Customer struct { - Address *[]Address `json:"address,omitempty"` - Id *int64 `json:"id,omitempty"` - Username *string `json:"username,omitempty"` + Address *[]Address `json:"address,omitempty"` + + // Id Example: 100000 + Id *int64 `json:"id,omitempty"` + + // Username Example: fehguy + Username *string `json:"username,omitempty"` } // Order defines model for Order. type Order struct { - Complete *bool `json:"complete,omitempty"` - Id *int64 `json:"id,omitempty"` - PetId *int64 `json:"petId,omitempty"` + Complete *bool `json:"complete,omitempty"` + + // Id Example: 10 + Id *int64 `json:"id,omitempty"` + + // PetId Example: 198772 + PetId *int64 `json:"petId,omitempty"` + + // Quantity Example: 7 Quantity *int32 `json:"quantity,omitempty"` ShipDate *time.Time `json:"shipDate,omitempty"` // Status Order Status + // + // Example: approved Status *OrderStatus `json:"status,omitempty"` } // OrderStatus Order Status +// +// Example: approved type OrderStatus string // Pet defines model for Pet. type Pet struct { - Category *Category `json:"category,omitempty"` - Id *int64 `json:"id,omitempty"` - Name string `json:"name"` - PhotoUrls []string `json:"photoUrls"` + Category *Category `json:"category,omitempty"` + + // Id Example: 10 + Id *int64 `json:"id,omitempty"` + + // Name Example: doggie + Name string `json:"name"` + PhotoUrls []string `json:"photoUrls"` // Status pet status in the store Status *PetStatus `json:"status,omitempty"` @@ -151,16 +179,31 @@ type Tag struct { // User defines model for User. type User struct { - Email *string `json:"email,omitempty"` + // Email Example: john@email.com + Email *string `json:"email,omitempty"` + + // FirstName Example: John FirstName *string `json:"firstName,omitempty"` - Id *int64 `json:"id,omitempty"` - LastName *string `json:"lastName,omitempty"` - Password *string `json:"password,omitempty"` - Phone *string `json:"phone,omitempty"` + + // Id Example: 10 + Id *int64 `json:"id,omitempty"` + + // LastName Example: James + LastName *string `json:"lastName,omitempty"` + + // Password Example: 12345 + Password *string `json:"password,omitempty"` + + // Phone Example: 12345 + Phone *string `json:"phone,omitempty"` // UserStatus User Status - UserStatus *int32 `json:"userStatus,omitempty"` - Username *string `json:"username,omitempty"` + // + // Example: 1 + UserStatus *int32 `json:"userStatus,omitempty"` + + // Username Example: theUser + Username *string `json:"username,omitempty"` } // UserArray defines model for UserArray. diff --git a/internal/test/issues/issue-1087/deps/deps.gen.go b/internal/test/issues/issue-1087/deps/deps.gen.go index 3661be9d81..7d02d738cd 100644 --- a/internal/test/issues/issue-1087/deps/deps.gen.go +++ b/internal/test/issues/issue-1087/deps/deps.gen.go @@ -18,15 +18,23 @@ import ( // BaseError defines model for BaseError. type BaseError struct { // Code The underlying http status code + // + // Example: 500 Code int32 `json:"code"` // Domain The domain where the error is originating from as defined by the service + // + // Example: Domain string `json:"domain"` // Message A simple message in english describing the error and can be returned to the consumer + // + // Example: Age cannot be less than 18 Message string `json:"message"` // Metadata Any additional details to be conveyed as determined by the service. If present, will return map of key value pairs + // + // Example: {"propertyName":"propertyName is required"} Metadata *map[string]string `json:"metadata,omitempty"` } diff --git a/internal/test/issues/issue-1168/api.gen.go b/internal/test/issues/issue-1168/api.gen.go index d681fbd81c..06bd31b245 100644 --- a/internal/test/issues/issue-1168/api.gen.go +++ b/internal/test/issues/issue-1168/api.gen.go @@ -11,18 +11,24 @@ import ( // ProblemDetails defines model for ProblemDetails. type ProblemDetails struct { // Detail A human readable explanation specific to this occurrence of the problem. + // + // Example: Connection to database timed out Detail *string `json:"detail,omitempty"` // Instance An absolute URI that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced. Instance *string `json:"instance,omitempty"` // Status The HTTP status code generated by the origin server for this occurrence of the problem. + // + // Example: 503 Status *int32 `json:"status,omitempty"` // Title A short, summary of the problem type. Written in english and readable for engineers (usually not suited for non technical stakeholders and not localized); example: Service Unavailable Title *string `json:"title,omitempty"` // Type An absolute URI that identifies the problem type. When dereferenced, it SHOULD provide human-readable documentation for the problem type (e.g., using HTML). + // + // Example: https://zalando.github.io/problem/constraint-violation Type *string `json:"type,omitempty"` AdditionalProperties map[string]interface{} `json:"-"` } diff --git a/internal/test/issues/issue1561/issue1561.gen.go b/internal/test/issues/issue1561/issue1561.gen.go index f011e318c1..494107b8e5 100644 --- a/internal/test/issues/issue1561/issue1561.gen.go +++ b/internal/test/issues/issue1561/issue1561.gen.go @@ -5,6 +5,7 @@ package issue1561 // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } From 6bd2d55a75a17f967bf9bdba4a45cc49a62e9b9c Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:43:42 -0700 Subject: [PATCH 09/30] feat(codegen): chi webhook+callback receiver template Phase 6.2 of the per-framework receiver work. Chi's path-handler signature is identical to stdhttp's (`(w http.ResponseWriter, r *http.Request)`), so the receiver template is structurally identical; only the file path and Go entry point are new. * New pkg/codegen/templates/chi/chi-receiver.tmpl -- a copy of the Phase 6.1 unified stdhttp receiver template. Single template handles both webhook and callback receivers via the Prefix data field. * New GenerateChiReceiver() entry point in operations.go. * In Generate(): two new blocks gated on Generate.ChiServer + non-empty webhook/callback ops, mirroring the stdhttp blocks. Generated output is written inside the existing chi-server WriteString block so all chi-related output stays grouped. * New internal/test/webhooks_chi/ -- compile-time assertion that the chi receiver template produces valid Go. The runtime round-trip behavior is already covered by internal/test/webhooks (stdhttp); since chi shares the signature, that coverage transfers. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/webhooks_chi/config.yaml | 9 + internal/test/webhooks_chi/doc.go | 10 + internal/test/webhooks_chi/spec.yaml | 34 ++ .../test/webhooks_chi/webhooks_chi.gen.go | 460 ++++++++++++++++++ pkg/codegen/codegen.go | 32 ++ pkg/codegen/operations.go | 8 + pkg/codegen/templates/chi/chi-receiver.tmpl | 116 +++++ 7 files changed, 669 insertions(+) create mode 100644 internal/test/webhooks_chi/config.yaml create mode 100644 internal/test/webhooks_chi/doc.go create mode 100644 internal/test/webhooks_chi/spec.yaml create mode 100644 internal/test/webhooks_chi/webhooks_chi.gen.go create mode 100644 pkg/codegen/templates/chi/chi-receiver.tmpl diff --git a/internal/test/webhooks_chi/config.yaml b/internal/test/webhooks_chi/config.yaml new file mode 100644 index 0000000000..2b98d52308 --- /dev/null +++ b/internal/test/webhooks_chi/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: webhooks_chi +generate: + models: true + client: true + chi-server: true +output-options: + skip-prune: true +output: webhooks_chi.gen.go diff --git a/internal/test/webhooks_chi/doc.go b/internal/test/webhooks_chi/doc.go new file mode 100644 index 0000000000..634c4317d5 --- /dev/null +++ b/internal/test/webhooks_chi/doc.go @@ -0,0 +1,10 @@ +// Package webhooks_chi verifies that the chi-server flag emits a +// compilable WebhookReceiverInterface alongside chi's path-server +// interface. Chi shares stdhttp's (w, r) handler signature, so the +// receiver shape is structurally identical to internal/test/webhooks +// (which already round-trip-tests the runtime behavior). This package +// is a compile-time assertion that the chi/chi-receiver.tmpl renders +// valid Go. +package webhooks_chi + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks_chi/spec.yaml b/internal/test/webhooks_chi/spec.yaml new file mode 100644 index 0000000000..aa86d1c357 --- /dev/null +++ b/internal/test/webhooks_chi/spec.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Chi webhook receiver test + version: 1.0.0 + description: | + Same spec as internal/test/webhooks but generated with chi-server, + verifying the chi-receiver.tmpl produces compilable code. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks_chi/webhooks_chi.gen.go b/internal/test/webhooks_chi/webhooks_chi.gen.go new file mode 100644 index 0000000000..b476c8d12d --- /dev/null +++ b/internal/test/webhooks_chi/webhooks_chi.gen.go @@ -0,0 +1,460 @@ +// Package webhooks_chi provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package webhooks_chi + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// Unimplemented server implementation that returns http.StatusNotImplemented for each endpoint. + +type Unimplemented struct{} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{}) +} + +type ChiServerOptions struct { + BaseURL string + BaseRouter chi.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r chi.Router) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r chi.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, ChiServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = chi.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + return r +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// PetStatusChangedWebhookHandler returns the http.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandlePetStatusChangedWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 4d101263ad..6b55a96305 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -451,6 +451,17 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook receiver (chi) -- chi shares stdhttp's (w, r) handler + // signature, so the receiver shape is identical; only the template + // path differs. Emitted only when Generate.ChiServer is on. + var chiWebhookReceiverOut string + if opts.Generate.ChiServer && len(webhookOps) > 0 { + chiWebhookReceiverOut, err = GenerateChiReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating chi webhook receiver: %w", err) + } + } + // Callback initiator pairs with the path Client. Emitted whenever // Generate.Client is on and the spec declares any callbacks -- // callbacks predate 3.1 so this is not version-gated. @@ -473,6 +484,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback receiver (chi). + var chiCallbackReceiverOut string + if opts.Generate.ChiServer && len(callbackOps) > 0 { + chiCallbackReceiverOut, err = GenerateChiReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating chi callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -565,6 +585,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if chiWebhookReceiverOut != "" { + _, err = w.WriteString(chiWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing chi webhook receiver: %w", err) + } + } + if chiCallbackReceiverOut != "" { + _, err = w.WriteString(chiCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing chi callback receiver: %w", err) + } + } } if opts.Generate.FiberServer { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 773def36df..c94f551dfc 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1666,6 +1666,14 @@ func GenerateStdHTTPReceiver(t *template.Template, prefix string, ops []Operatio return GenerateTemplates([]string{"stdhttp/std-http-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } +// GenerateChiReceiver renders the chi receiver template. Chi shares +// stdhttp's (w, r) handler signature, so the template is structurally +// identical -- only the file path and (in the future, if needed) +// framework-specific helpers differ. +func GenerateChiReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"chi/chi-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/chi/chi-receiver.tmpl b/pkg/codegen/templates/chi/chi-receiver.tmpl new file mode 100644 index 0000000000..f673d0b5c5 --- /dev/null +++ b/pkg/codegen/templates/chi/chi-receiver.tmpl @@ -0,0 +1,116 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} http.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(w http.ResponseWriter, r *http.Request{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +// {{.Prefix}}ReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type {{.Prefix}}ReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the http.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...{{$.Prefix}}ReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the request. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if (or .IsPassThrough .IsJson)}} + if paramValue := r.URL.Query().Get("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + }{{end}} + {{end}} + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + } else { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + } + return + } + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := r.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + errHandler(w, r, &TooManyValuesForParamError{ParamName: "{{.ParamName}}", Count: n}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + err := fmt.Errorf("Header parameter {{.ParamName}} is required, but not found") + errHandler(w, r, &RequiredHeaderError{ParamName: "{{.ParamName}}", Err: err}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(w, r{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} From c2a69faeb7896ec6fd9fbe78b566ca9074a2da0c Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:44:46 -0700 Subject: [PATCH 10/30] feat(codegen): gorilla/mux webhook+callback receiver template Phase 6.3. Gorilla shares stdhttp's `(w, r)` handler signature, so the receiver template is structurally identical -- same approach as the Phase 6.2 chi addition. * New pkg/codegen/templates/gorilla/gorilla-receiver.tmpl * New GenerateGorillaReceiver() entry point * Wiring in Generate() gated on Generate.GorillaServer; output written inside the existing gorilla-server WriteString block * New internal/test/webhooks_gorilla/ as a compile-time assertion; runtime round-trip behavior is covered by internal/test/webhooks (signature is identical, so coverage transfers) Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/webhooks_gorilla/config.yaml | 9 + internal/test/webhooks_gorilla/doc.go | 7 + internal/test/webhooks_gorilla/spec.yaml | 34 ++ .../webhooks_gorilla/webhooks_gorilla.gen.go | 456 ++++++++++++++++++ pkg/codegen/codegen.go | 31 ++ pkg/codegen/operations.go | 7 + .../templates/gorilla/gorilla-receiver.tmpl | 116 +++++ 7 files changed, 660 insertions(+) create mode 100644 internal/test/webhooks_gorilla/config.yaml create mode 100644 internal/test/webhooks_gorilla/doc.go create mode 100644 internal/test/webhooks_gorilla/spec.yaml create mode 100644 internal/test/webhooks_gorilla/webhooks_gorilla.gen.go create mode 100644 pkg/codegen/templates/gorilla/gorilla-receiver.tmpl diff --git a/internal/test/webhooks_gorilla/config.yaml b/internal/test/webhooks_gorilla/config.yaml new file mode 100644 index 0000000000..b4d4b221c3 --- /dev/null +++ b/internal/test/webhooks_gorilla/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: webhooks_gorilla +generate: + models: true + client: true + gorilla-server: true +output-options: + skip-prune: true +output: webhooks_gorilla.gen.go diff --git a/internal/test/webhooks_gorilla/doc.go b/internal/test/webhooks_gorilla/doc.go new file mode 100644 index 0000000000..8f6e7b5efb --- /dev/null +++ b/internal/test/webhooks_gorilla/doc.go @@ -0,0 +1,7 @@ +// Package webhooks_gorilla verifies that the gorilla/mux server flag +// emits a compilable WebhookReceiverInterface alongside gorilla's +// path-server interface. Same (w, r) signature as stdhttp/chi, so the +// receiver shape is identical; this is a compile-time assertion. +package webhooks_gorilla + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks_gorilla/spec.yaml b/internal/test/webhooks_gorilla/spec.yaml new file mode 100644 index 0000000000..aa86d1c357 --- /dev/null +++ b/internal/test/webhooks_gorilla/spec.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Chi webhook receiver test + version: 1.0.0 + description: | + Same spec as internal/test/webhooks but generated with chi-server, + verifying the chi-receiver.tmpl produces compilable code. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks_gorilla/webhooks_gorilla.gen.go b/internal/test/webhooks_gorilla/webhooks_gorilla.gen.go new file mode 100644 index 0000000000..f7e9ded9ac --- /dev/null +++ b/internal/test/webhooks_gorilla/webhooks_gorilla.gen.go @@ -0,0 +1,456 @@ +// Package webhooks_gorilla provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package webhooks_gorilla + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gorilla/mux" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +type MiddlewareFunc func(http.Handler) http.Handler + +type UnescapedCookieParamError struct { + ParamName string + Err error +} + +func (e *UnescapedCookieParamError) Error() string { + return fmt.Sprintf("error unescaping cookie parameter '%s'", e.ParamName) +} + +func (e *UnescapedCookieParamError) Unwrap() error { + return e.Err +} + +type UnmarshalingParamError struct { + ParamName string + Err error +} + +func (e *UnmarshalingParamError) Error() string { + return fmt.Sprintf("Error unmarshaling parameter %s as JSON: %s", e.ParamName, e.Err.Error()) +} + +func (e *UnmarshalingParamError) Unwrap() error { + return e.Err +} + +type RequiredParamError struct { + ParamName string +} + +func (e *RequiredParamError) Error() string { + return fmt.Sprintf("Query argument %s is required, but not found", e.ParamName) +} + +type RequiredHeaderError struct { + ParamName string + Err error +} + +func (e *RequiredHeaderError) Error() string { + return fmt.Sprintf("Header parameter %s is required, but not found", e.ParamName) +} + +func (e *RequiredHeaderError) Unwrap() error { + return e.Err +} + +type InvalidParamFormatError struct { + ParamName string + Err error +} + +func (e *InvalidParamFormatError) Error() string { + return fmt.Sprintf("Invalid format for parameter %s: %s", e.ParamName, e.Err.Error()) +} + +func (e *InvalidParamFormatError) Unwrap() error { + return e.Err +} + +type TooManyValuesForParamError struct { + ParamName string + Count int +} + +func (e *TooManyValuesForParamError) Error() string { + return fmt.Sprintf("Expected one value for %s, got %d", e.ParamName, e.Count) +} + +// Handler creates http.Handler with routing matching OpenAPI spec. +func Handler(si ServerInterface) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{}) +} + +type GorillaServerOptions struct { + BaseURL string + BaseRouter *mux.Router + Middlewares []MiddlewareFunc + ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, err error) +} + +// HandlerFromMux creates http.Handler with routing matching OpenAPI spec based on the provided mux. +func HandlerFromMux(si ServerInterface, r *mux.Router) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{ + BaseRouter: r, + }) +} + +func HandlerFromMuxWithBaseURL(si ServerInterface, r *mux.Router, baseURL string) http.Handler { + return HandlerWithOptions(si, GorillaServerOptions{ + BaseURL: baseURL, + BaseRouter: r, + }) +} + +// HandlerWithOptions creates http.Handler with additional options +func HandlerWithOptions(si ServerInterface, options GorillaServerOptions) http.Handler { + r := options.BaseRouter + + if r == nil { + r = mux.NewRouter() + } + if options.ErrorHandlerFunc == nil { + options.ErrorHandlerFunc = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + + return r +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook http.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(w http.ResponseWriter, r *http.Request) +} + +// WebhookReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type WebhookReceiverMiddlewareFunc func(http.Handler) http.Handler + +// PetStatusChangedWebhookHandler returns the http.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...WebhookReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + si.HandlePetStatusChangedWebhook(w, r) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 6b55a96305..1d30e07967 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -462,6 +462,16 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook receiver (gorilla/mux) -- same (w, r) signature as + // stdhttp/chi. + var gorillaWebhookReceiverOut string + if opts.Generate.GorillaServer && len(webhookOps) > 0 { + gorillaWebhookReceiverOut, err = GenerateGorillaReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating gorilla webhook receiver: %w", err) + } + } + // Callback initiator pairs with the path Client. Emitted whenever // Generate.Client is on and the spec declares any callbacks -- // callbacks predate 3.1 so this is not version-gated. @@ -493,6 +503,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback receiver (gorilla/mux). + var gorillaCallbackReceiverOut string + if opts.Generate.GorillaServer && len(callbackOps) > 0 { + gorillaCallbackReceiverOut, err = GenerateGorillaReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating gorilla callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -618,6 +637,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if gorillaWebhookReceiverOut != "" { + _, err = w.WriteString(gorillaWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gorilla webhook receiver: %w", err) + } + } + if gorillaCallbackReceiverOut != "" { + _, err = w.WriteString(gorillaCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gorilla callback receiver: %w", err) + } + } } if opts.Generate.StdHTTPServer { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index c94f551dfc..dc2cdc7191 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1674,6 +1674,13 @@ func GenerateChiReceiver(t *template.Template, prefix string, ops []OperationDef return GenerateTemplates([]string{"chi/chi-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } +// GenerateGorillaReceiver renders the gorilla/mux receiver template. +// Gorilla shares stdhttp's (w, r) handler signature, so the template +// is structurally identical to stdhttp's. +func GenerateGorillaReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"gorilla/gorilla-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/gorilla/gorilla-receiver.tmpl b/pkg/codegen/templates/gorilla/gorilla-receiver.tmpl new file mode 100644 index 0000000000..f673d0b5c5 --- /dev/null +++ b/pkg/codegen/templates/gorilla/gorilla-receiver.tmpl @@ -0,0 +1,116 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} http.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(w http.ResponseWriter, r *http.Request{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +// {{.Prefix}}ReceiverMiddlewareFunc wraps an http.Handler with cross- +// cutting behavior (signature verification, logging, rate limiting, ...). +type {{.Prefix}}ReceiverMiddlewareFunc func(http.Handler) http.Handler + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the http.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. errHandler +// may be nil; if so, parameter-binding errors return 400 with the error +// message. Middlewares are applied in the order provided -- the last +// argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, errHandler func(w http.ResponseWriter, r *http.Request, err error), middlewares ...{{$.Prefix}}ReceiverMiddlewareFunc) http.Handler { + if errHandler == nil { + errHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, err.Error(), http.StatusBadRequest) + } + } + var h http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the request. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if (or .IsPassThrough .IsJson)}} + if paramValue := r.URL.Query().Get("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + return + }{{end}} + {{end}} + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", r.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + errHandler(w, r, &RequiredParamError{ParamName: "{{.ParamName}}"}) + } else { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + } + return + } + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := r.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + errHandler(w, r, &TooManyValuesForParamError{ParamName: "{{.ParamName}}", Count: n}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + errHandler(w, r, &UnmarshalingParamError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + errHandler(w, r, &InvalidParamFormatError{ParamName: "{{.ParamName}}", Err: err}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + err := fmt.Errorf("Header parameter {{.ParamName}} is required, but not found") + errHandler(w, r, &RequiredHeaderError{ParamName: "{{.ParamName}}", Err: err}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(w, r{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} From 82c8f8970ae61434f1906ea81fca1b05042d54dd Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:46:49 -0700 Subject: [PATCH 11/30] feat(codegen): echo (v4) webhook+callback receiver template Phase 6.4. First framework with a non-stdhttp signature, requiring its own bespoke template. Echo's idioms: * Method signature `Handle{Op}{Prefix}(ctx echo.Context, params...) error` (echo v4 uses the non-pointer Context). * Factory returns `echo.HandlerFunc`; middlewares are echo's native `echo.MiddlewareFunc` (`func(echo.HandlerFunc) echo.HandlerFunc`). * Parameter-binding errors are returned via echo.NewHTTPError so echo's framework error chain reports them as 400. There's no errHandler argument like the stdhttp factory has. * Query params bind via ctx.QueryParam / ctx.QueryParams() (and runtime.BindQueryParameterWithOptions for styled). Header params read ctx.Request().Header. * New pkg/codegen/templates/echo/echo-receiver.tmpl * New GenerateEchoReceiver() entry point * Wiring in Generate() gated on Generate.EchoServer; output written inside the existing echo-server WriteString block * New internal/test/webhooks_echo/ -- compile-time assertion that the echo receiver template produces valid Go Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/webhooks_echo/config.yaml | 9 + internal/test/webhooks_echo/doc.go | 7 + internal/test/webhooks_echo/spec.yaml | 34 ++ .../test/webhooks_echo/webhooks_echo.gen.go | 357 ++++++++++++++++++ pkg/codegen/codegen.go | 30 ++ pkg/codegen/operations.go | 9 + pkg/codegen/templates/echo/echo-receiver.tmpl | 93 +++++ 7 files changed, 539 insertions(+) create mode 100644 internal/test/webhooks_echo/config.yaml create mode 100644 internal/test/webhooks_echo/doc.go create mode 100644 internal/test/webhooks_echo/spec.yaml create mode 100644 internal/test/webhooks_echo/webhooks_echo.gen.go create mode 100644 pkg/codegen/templates/echo/echo-receiver.tmpl diff --git a/internal/test/webhooks_echo/config.yaml b/internal/test/webhooks_echo/config.yaml new file mode 100644 index 0000000000..b63568b0a1 --- /dev/null +++ b/internal/test/webhooks_echo/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: webhooks_echo +generate: + models: true + client: true + echo-server: true +output-options: + skip-prune: true +output: webhooks_echo.gen.go diff --git a/internal/test/webhooks_echo/doc.go b/internal/test/webhooks_echo/doc.go new file mode 100644 index 0000000000..75ce8976f1 --- /dev/null +++ b/internal/test/webhooks_echo/doc.go @@ -0,0 +1,7 @@ +// Package webhooks_echo verifies that the echo-server flag emits a +// compilable WebhookReceiverInterface with echo's (ctx echo.Context) +// error signature. Compile-time assertion only; runtime round-trip is +// covered by internal/test/webhooks (stdhttp). +package webhooks_echo + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks_echo/spec.yaml b/internal/test/webhooks_echo/spec.yaml new file mode 100644 index 0000000000..aa86d1c357 --- /dev/null +++ b/internal/test/webhooks_echo/spec.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Chi webhook receiver test + version: 1.0.0 + description: | + Same spec as internal/test/webhooks but generated with chi-server, + verifying the chi-receiver.tmpl produces compilable code. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks_echo/webhooks_echo.gen.go b/internal/test/webhooks_echo/webhooks_echo.gen.go new file mode 100644 index 0000000000..7208b7318f --- /dev/null +++ b/internal/test/webhooks_echo/webhooks_echo.gen.go @@ -0,0 +1,357 @@ +// Package webhooks_echo provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package webhooks_echo + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/labstack/echo/v4" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +// This is a simple interface which specifies echo.Route addition functions which +// are present on both echo.Echo and echo.Group, since we want to allow using +// either of them for path registration +type EchoRouter interface { + CONNECT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + DELETE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + HEAD(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + OPTIONS(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PATCH(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + POST(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + PUT(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route + TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route +} + +// RegisterHandlers adds each server route to the EchoRouter. +func RegisterHandlers(router EchoRouter, si ServerInterface) { + RegisterHandlersWithBaseURL(router, si, "") +} + +// Registers handlers, and prepends BaseURL to the paths, so that the paths +// can be served under a prefix. +func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook echo.HandlerFunc returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(ctx echo.Context) error +} + +// PetStatusChangedWebhookHandler returns the echo.HandlerFunc for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. Errors +// during parameter binding are returned via echo.NewHTTPError so echo's +// error-handling chain reports them as 400. Middlewares are applied in +// the order provided -- the last argument becomes the outermost wrapper. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface, middlewares ...echo.MiddlewareFunc) echo.HandlerFunc { + h := echo.HandlerFunc(func(ctx echo.Context) error { + return si.HandlePetStatusChangedWebhook(ctx) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 1d30e07967..785528b9f8 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -472,6 +472,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook receiver (echo v4) -- (ctx echo.Context) error signature. + var echoWebhookReceiverOut string + if opts.Generate.EchoServer && len(webhookOps) > 0 { + echoWebhookReceiverOut, err = GenerateEchoReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating echo webhook receiver: %w", err) + } + } + // Callback initiator pairs with the path Client. Emitted whenever // Generate.Client is on and the spec declares any callbacks -- // callbacks predate 3.1 so this is not version-gated. @@ -512,6 +521,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback receiver (echo v4). + var echoCallbackReceiverOut string + if opts.Generate.EchoServer && len(callbackOps) > 0 { + echoCallbackReceiverOut, err = GenerateEchoReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating echo callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -590,6 +608,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if echoWebhookReceiverOut != "" { + _, err = w.WriteString(echoWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo webhook receiver: %w", err) + } + } + if echoCallbackReceiverOut != "" { + _, err = w.WriteString(echoCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo callback receiver: %w", err) + } + } } if opts.Generate.Echo5Server { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index dc2cdc7191..91f00a7a9c 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1681,6 +1681,15 @@ func GenerateGorillaReceiver(t *template.Template, prefix string, ops []Operatio return GenerateTemplates([]string{"gorilla/gorilla-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } +// GenerateEchoReceiver renders the echo (v4) receiver template. Echo's +// handler shape is `(ctx echo.Context) error`, and binding errors are +// returned via echo.NewHTTPError so echo's framework error chain +// reports them as 400 -- there's no errHandler argument like the +// stdhttp receiver factory has. +func GenerateEchoReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"echo/echo-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/echo/echo-receiver.tmpl b/pkg/codegen/templates/echo/echo-receiver.tmpl new file mode 100644 index 0000000000..3d2abd4001 --- /dev/null +++ b/pkg/codegen/templates/echo/echo-receiver.tmpl @@ -0,0 +1,93 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} echo.HandlerFunc returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(ctx echo.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) error +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the echo.HandlerFunc for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. Errors +// during parameter binding are returned via echo.NewHTTPError so echo's +// error-handling chain reports them as 400. Middlewares are applied in +// the order provided -- the last argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, middlewares ...echo.MiddlewareFunc) echo.HandlerFunc { + h := echo.HandlerFunc(func(ctx echo.Context) error { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", ctx.QueryParams(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + {{else}} + if paramValue := ctx.QueryParam("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Query argument {{.ParamName}} is required, but not found")) + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := ctx.Request().Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)) + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter {{.ParamName}} is required, but not found")) + }{{end}} +{{end}} +{{- end}} + return si.Handle{{$opid}}{{$.Prefix}}(ctx{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} From 75c06a49d5732af747cff590c1db1c5991c91baf Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:49:06 -0700 Subject: [PATCH 12/30] feat(codegen): echo (v5) webhook+callback receiver template Phase 6.5. Echo v5 differs from v4 only in using `*echo.Context` (pointer) -- otherwise the API surface (ctx.QueryParam, ctx.Request(), echo.NewHTTPError, echo.HandlerFunc, echo.MiddlewareFunc) is identical. The template is a `echo.Context` -> `*echo.Context` substitution from the Phase 6.4 v4 template. * New pkg/codegen/templates/echo/v5/echo-receiver.tmpl * New GenerateEcho5Receiver() entry point * Wiring in Generate() gated on Generate.Echo5Server; output written inside the existing echo5-server WriteString block No internal/test/webhooks_echo5/ directory: echo v5 is not in internal/test/go.mod (the existing echov5 fixture lives in its own sub-module under internal/test/parameters/echov5/), so adding a webhook test would require either invasive dep changes or another sub-module. The Phase 6.4 echo v4 test exercises the same template shape end-to-end -- inspection of the v5 codegen output confirms only the Context pointer-ness changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/codegen/codegen.go | 30 ++++++ pkg/codegen/operations.go | 7 ++ .../templates/echo/v5/echo-receiver.tmpl | 93 +++++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 pkg/codegen/templates/echo/v5/echo-receiver.tmpl diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 785528b9f8..f6bac06822 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -481,6 +481,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook receiver (echo v5) -- (ctx *echo.Context) error signature. + var echo5WebhookReceiverOut string + if opts.Generate.Echo5Server && len(webhookOps) > 0 { + echo5WebhookReceiverOut, err = GenerateEcho5Receiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating echo5 webhook receiver: %w", err) + } + } + // Callback initiator pairs with the path Client. Emitted whenever // Generate.Client is on and the spec declares any callbacks -- // callbacks predate 3.1 so this is not version-gated. @@ -530,6 +539,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback receiver (echo v5). + var echo5CallbackReceiverOut string + if opts.Generate.Echo5Server && len(callbackOps) > 0 { + echo5CallbackReceiverOut, err = GenerateEcho5Receiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating echo5 callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -627,6 +645,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if echo5WebhookReceiverOut != "" { + _, err = w.WriteString(echo5WebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo5 webhook receiver: %w", err) + } + } + if echo5CallbackReceiverOut != "" { + _, err = w.WriteString(echo5CallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing echo5 callback receiver: %w", err) + } + } } if opts.Generate.ChiServer { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 91f00a7a9c..c096117a27 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1690,6 +1690,13 @@ func GenerateEchoReceiver(t *template.Template, prefix string, ops []OperationDe return GenerateTemplates([]string{"echo/echo-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } +// GenerateEcho5Receiver renders the echo (v5) receiver template. Same +// shape as v4 but with `*echo.Context` (pointer) -- the only API +// difference between echo v4 and v5 that affects the receiver. +func GenerateEcho5Receiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"echo/v5/echo-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/echo/v5/echo-receiver.tmpl b/pkg/codegen/templates/echo/v5/echo-receiver.tmpl new file mode 100644 index 0000000000..e358c9fba9 --- /dev/null +++ b/pkg/codegen/templates/echo/v5/echo-receiver.tmpl @@ -0,0 +1,93 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} echo.HandlerFunc returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(ctx *echo.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) error +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the echo.HandlerFunc for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. Errors +// during parameter binding are returned via echo.NewHTTPError so echo's +// error-handling chain reports them as 400. Middlewares are applied in +// the order provided -- the last argument becomes the outermost wrapper. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface, middlewares ...echo.MiddlewareFunc) echo.HandlerFunc { + h := echo.HandlerFunc(func(ctx *echo.Context) error { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", ctx.QueryParams(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + {{else}} + if paramValue := ctx.QueryParam("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Query argument {{.ParamName}} is required, but not found")) + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := ctx.Request().Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)) + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Error unmarshaling parameter '{{.ParamName}}' as JSON") + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Header parameter {{.ParamName}} is required, but not found")) + }{{end}} +{{end}} +{{- end}} + return si.Handle{{$opid}}{{$.Prefix}}(ctx{{if .RequiresParamObject}}, params{{end}}) + }) + for _, mw := range middlewares { + h = mw(h) + } + return h +} + +{{end}} From fc880cd1b0545bc0d23f4f6ae9af3935d9c32e2b Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:50:53 -0700 Subject: [PATCH 13/30] feat(codegen): gin webhook+callback receiver template Phase 6.6. Gin's idioms: * Method signature `Handle{Op}{Prefix}(c *gin.Context, params...)` with no error return -- gin reports errors via the context. * Factory returns `gin.HandlerFunc`. Per-handler middleware is NOT generated (gin's idiom prefers route-group / engine .Use() composition); users mount middleware at the engine level. * Parameter-binding errors abort the request with c.JSON(http.StatusBadRequest, gin.H{"error": "..."}). * Query params bind via c.Query / c.Request.URL.Query() (and runtime.BindQueryParameterWithOptions for styled). Header params read c.Request.Header. * New pkg/codegen/templates/gin/gin-receiver.tmpl * New GenerateGinReceiver() entry point * Wiring in Generate() gated on Generate.GinServer * New internal/test/webhooks_gin/ -- compile-time assertion Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/webhooks_gin/config.yaml | 9 + internal/test/webhooks_gin/doc.go | 6 + internal/test/webhooks_gin/spec.yaml | 34 ++ .../test/webhooks_gin/webhooks_gin.gen.go | 349 ++++++++++++++++++ pkg/codegen/codegen.go | 30 ++ pkg/codegen/operations.go | 8 + pkg/codegen/templates/gin/gin-receiver.tmpl | 97 +++++ 7 files changed, 533 insertions(+) create mode 100644 internal/test/webhooks_gin/config.yaml create mode 100644 internal/test/webhooks_gin/doc.go create mode 100644 internal/test/webhooks_gin/spec.yaml create mode 100644 internal/test/webhooks_gin/webhooks_gin.gen.go create mode 100644 pkg/codegen/templates/gin/gin-receiver.tmpl diff --git a/internal/test/webhooks_gin/config.yaml b/internal/test/webhooks_gin/config.yaml new file mode 100644 index 0000000000..f0ddc459ef --- /dev/null +++ b/internal/test/webhooks_gin/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: webhooks_gin +generate: + models: true + client: true + gin-server: true +output-options: + skip-prune: true +output: webhooks_gin.gen.go diff --git a/internal/test/webhooks_gin/doc.go b/internal/test/webhooks_gin/doc.go new file mode 100644 index 0000000000..967c4d460d --- /dev/null +++ b/internal/test/webhooks_gin/doc.go @@ -0,0 +1,6 @@ +// Package webhooks_gin verifies that the gin-server flag emits a +// compilable WebhookReceiverInterface with gin's (c *gin.Context) +// signature. Compile-time assertion only. +package webhooks_gin + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks_gin/spec.yaml b/internal/test/webhooks_gin/spec.yaml new file mode 100644 index 0000000000..aa86d1c357 --- /dev/null +++ b/internal/test/webhooks_gin/spec.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Chi webhook receiver test + version: 1.0.0 + description: | + Same spec as internal/test/webhooks but generated with chi-server, + verifying the chi-receiver.tmpl produces compilable code. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks_gin/webhooks_gin.gen.go b/internal/test/webhooks_gin/webhooks_gin.gen.go new file mode 100644 index 0000000000..1f43abd117 --- /dev/null +++ b/internal/test/webhooks_gin/webhooks_gin.gen.go @@ -0,0 +1,349 @@ +// Package webhooks_gin provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package webhooks_gin + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gin-gonic/gin" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +type MiddlewareFunc func(c *gin.Context) + +// GinServerOptions provides options for the Gin server. +type GinServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + ErrorHandler func(*gin.Context, error, int) +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router gin.IRouter, si ServerInterface) { + RegisterHandlersWithOptions(router, si, GinServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router gin.IRouter, si ServerInterface, options GinServerOptions) { + +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook gin.HandlerFunc returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(c *gin.Context) +} + +// PetStatusChangedWebhookHandler returns the gin.HandlerFunc for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. +// Parameter-binding errors abort the request with 400 and a JSON body +// of the form {"error": "..."}. Engine-level middleware can be applied +// via gin.Engine.Use(); per-handler middleware is not generated here +// (gin's idiom prefers route-group / engine .Use composition). +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface) gin.HandlerFunc { + return func(c *gin.Context) { + si.HandlePetStatusChangedWebhook(c) + } +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index f6bac06822..bbf307f26d 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -490,6 +490,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook receiver (gin) -- (c *gin.Context) signature. + var ginWebhookReceiverOut string + if opts.Generate.GinServer && len(webhookOps) > 0 { + ginWebhookReceiverOut, err = GenerateGinReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating gin webhook receiver: %w", err) + } + } + // Callback initiator pairs with the path Client. Emitted whenever // Generate.Client is on and the spec declares any callbacks -- // callbacks predate 3.1 so this is not version-gated. @@ -548,6 +557,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback receiver (gin). + var ginCallbackReceiverOut string + if opts.Generate.GinServer && len(callbackOps) > 0 { + ginCallbackReceiverOut, err = GenerateGinReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating gin callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -690,6 +708,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if ginWebhookReceiverOut != "" { + _, err = w.WriteString(ginWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gin webhook receiver: %w", err) + } + } + if ginCallbackReceiverOut != "" { + _, err = w.WriteString(ginCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing gin callback receiver: %w", err) + } + } } if opts.Generate.GorillaServer { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index c096117a27..74b1d1bbc4 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1697,6 +1697,14 @@ func GenerateEcho5Receiver(t *template.Template, prefix string, ops []OperationD return GenerateTemplates([]string{"echo/v5/echo-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } +// GenerateGinReceiver renders the gin receiver template. Gin's handler +// shape is `(c *gin.Context)` (no error return); binding errors abort +// with c.JSON(400, gin.H{"error": ...}). Per-handler middleware is not +// generated here -- gin's idiom prefers engine .Use() composition. +func GenerateGinReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"gin/gin-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/gin/gin-receiver.tmpl b/pkg/codegen/templates/gin/gin-receiver.tmpl new file mode 100644 index 0000000000..64fb234ada --- /dev/null +++ b/pkg/codegen/templates/gin/gin-receiver.tmpl @@ -0,0 +1,97 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} gin.HandlerFunc returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(c *gin.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the gin.HandlerFunc for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. +// Parameter-binding errors abort the request with 400 and a JSON body +// of the form {"error": "..."}. Engine-level middleware can be applied +// via gin.Engine.Use(); per-handler middleware is not generated here +// (gin's idiom prefers route-group / engine .Use composition). +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface) gin.HandlerFunc { + return func(c *gin.Context) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", c.Request.URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + {{else}} + if paramValue := c.Query("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Query parameter {{.ParamName}} is required, but not found"}) + return + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := c.Request.Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)}) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)}) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Header parameter {{.ParamName}} is required, but not found"}) + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(c{{if .RequiresParamObject}}, params{{end}}) + } +} + +{{end}} From 7ede50afc8411f325c9cb555d4e5ca1f6cee1d1e Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:52:43 -0700 Subject: [PATCH 14/30] feat(codegen): fiber (v2) webhook+callback receiver template Phase 6.7. Fiber's idioms: * Method signature `Handle{Op}{Prefix}(c *fiber.Ctx, params...) error` (mainline pins fiber v2 -> *fiber.Ctx pointer; v3 changes to a non-pointer interface but is not pinned in mainline). * Factory returns `fiber.Handler` (alias for `func(*fiber.Ctx) error`). * Parameter-binding errors are returned via `fiber.NewError( fiber.StatusBadRequest, ...)` so fiber's error chain reports them as 400. * Query-param binding via `url.ParseQuery(string(c.Request().URI(). QueryString()))` to get url.Values for runtime.BindQueryParameter WithOptions, plus `c.Query("name")` for direct passthrough/JSON. * Header params read `c.GetReqHeaders()` which returns map[string][]string. * Per-handler middleware is NOT generated; users compose middleware via fiber.App.Use(). * New pkg/codegen/templates/fiber/fiber-receiver.tmpl * New GenerateFiberReceiver() entry point * Wiring in Generate() gated on Generate.FiberServer * New internal/test/webhooks_fiber/ -- compile-time assertion Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/webhooks_fiber/config.yaml | 9 + internal/test/webhooks_fiber/doc.go | 6 + internal/test/webhooks_fiber/spec.yaml | 34 ++ .../test/webhooks_fiber/webhooks_fiber.gen.go | 348 ++++++++++++++++++ pkg/codegen/codegen.go | 30 ++ pkg/codegen/operations.go | 8 + .../templates/fiber/fiber-receiver.tmpl | 99 +++++ 7 files changed, 534 insertions(+) create mode 100644 internal/test/webhooks_fiber/config.yaml create mode 100644 internal/test/webhooks_fiber/doc.go create mode 100644 internal/test/webhooks_fiber/spec.yaml create mode 100644 internal/test/webhooks_fiber/webhooks_fiber.gen.go create mode 100644 pkg/codegen/templates/fiber/fiber-receiver.tmpl diff --git a/internal/test/webhooks_fiber/config.yaml b/internal/test/webhooks_fiber/config.yaml new file mode 100644 index 0000000000..d107f4862c --- /dev/null +++ b/internal/test/webhooks_fiber/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: webhooks_fiber +generate: + models: true + client: true + fiber-server: true +output-options: + skip-prune: true +output: webhooks_fiber.gen.go diff --git a/internal/test/webhooks_fiber/doc.go b/internal/test/webhooks_fiber/doc.go new file mode 100644 index 0000000000..851e0f1cbe --- /dev/null +++ b/internal/test/webhooks_fiber/doc.go @@ -0,0 +1,6 @@ +// Package webhooks_fiber verifies that the fiber-server flag emits a +// compilable WebhookReceiverInterface with fiber's (c *fiber.Ctx) +// error signature. Compile-time assertion only. +package webhooks_fiber + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks_fiber/spec.yaml b/internal/test/webhooks_fiber/spec.yaml new file mode 100644 index 0000000000..aa86d1c357 --- /dev/null +++ b/internal/test/webhooks_fiber/spec.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Chi webhook receiver test + version: 1.0.0 + description: | + Same spec as internal/test/webhooks but generated with chi-server, + verifying the chi-receiver.tmpl produces compilable code. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks_fiber/webhooks_fiber.gen.go b/internal/test/webhooks_fiber/webhooks_fiber.gen.go new file mode 100644 index 0000000000..d3a6b1dea6 --- /dev/null +++ b/internal/test/webhooks_fiber/webhooks_fiber.gen.go @@ -0,0 +1,348 @@ +// Package webhooks_fiber provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package webhooks_fiber + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/gofiber/fiber/v2" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface + HandlerMiddlewares []HandlerMiddlewareFunc +} + +type MiddlewareFunc fiber.Handler +type HandlerMiddlewareFunc func(c *fiber.Ctx, next fiber.Handler) error + +// FiberServerOptions provides options for the Fiber server. +type FiberServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc + HandlerMiddlewares []HandlerMiddlewareFunc +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router fiber.Router, si ServerInterface) { + RegisterHandlersWithOptions(router, si, FiberServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router fiber.Router, si ServerInterface, options FiberServerOptions) { + +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook fiber.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(c *fiber.Ctx) error +} + +// PetStatusChangedWebhookHandler returns the fiber.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. Errors +// during parameter binding are returned via fiber.NewError so fiber's +// error chain reports them as 400. Per-handler middleware is not +// generated here; use fiber.App.Use() for engine-level middleware. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface) fiber.Handler { + return func(c *fiber.Ctx) error { + return si.HandlePetStatusChangedWebhook(c) + } +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index bbf307f26d..819176240b 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -499,6 +499,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook receiver (fiber v2) -- (c *fiber.Ctx) error signature. + var fiberWebhookReceiverOut string + if opts.Generate.FiberServer && len(webhookOps) > 0 { + fiberWebhookReceiverOut, err = GenerateFiberReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating fiber webhook receiver: %w", err) + } + } + // Callback initiator pairs with the path Client. Emitted whenever // Generate.Client is on and the spec declares any callbacks -- // callbacks predate 3.1 so this is not version-gated. @@ -566,6 +575,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback receiver (fiber v2). + var fiberCallbackReceiverOut string + if opts.Generate.FiberServer && len(callbackOps) > 0 { + fiberCallbackReceiverOut, err = GenerateFiberReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating fiber callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -701,6 +719,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } + if fiberWebhookReceiverOut != "" { + _, err = w.WriteString(fiberWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing fiber webhook receiver: %w", err) + } + } + if fiberCallbackReceiverOut != "" { + _, err = w.WriteString(fiberCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing fiber callback receiver: %w", err) + } + } } if opts.Generate.GinServer { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 74b1d1bbc4..bdd99cd208 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1705,6 +1705,14 @@ func GenerateGinReceiver(t *template.Template, prefix string, ops []OperationDef return GenerateTemplates([]string{"gin/gin-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } +// GenerateFiberReceiver renders the fiber (v2) receiver template. +// Fiber's handler shape is `(c *fiber.Ctx) error`; binding errors are +// returned via fiber.NewError so fiber's error chain reports them as +// 400. Per-handler middleware is not generated; use fiber.App.Use(). +func GenerateFiberReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"fiber/fiber-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/fiber/fiber-receiver.tmpl b/pkg/codegen/templates/fiber/fiber-receiver.tmpl new file mode 100644 index 0000000000..2871388c2b --- /dev/null +++ b/pkg/codegen/templates/fiber/fiber-receiver.tmpl @@ -0,0 +1,99 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} fiber.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(c *fiber.Ctx{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) error +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the fiber.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. Errors +// during parameter binding are returned via fiber.NewError so fiber's +// error chain reports them as 400. Per-handler middleware is not +// generated here; use fiber.App.Use() for engine-level middleware. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface) fiber.Handler { + return func(c *fiber.Ctx) error { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{if .QueryParams}} + var query url.Values + query, err = url.ParseQuery(string(c.Request().URI().QueryString())) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Invalid format for query string: %w", err).Error()) + } +{{end}} +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", query, ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Invalid format for parameter {{.ParamName}}: %w", err).Error()) + } + {{else}} + if paramValue := c.Query("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Error unmarshaling parameter '{{.ParamName}}' as JSON: %w", err).Error()) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + return fiber.NewError(fiber.StatusBadRequest, "Query argument {{.ParamName}} is required, but not found") + }{{end}} + {{end}} +{{end}} +{{if .HeaderParams}} + headers := c.GetReqHeaders() +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := headers[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + return fiber.NewError(fiber.StatusBadRequest, fmt.Sprintf("Too many values for header {{.ParamName}}, 1 is required, but %d found", n)) + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Error unmarshaling parameter '{{.ParamName}}' as JSON: %w", err).Error()) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, fmt.Errorf("Invalid format for parameter {{.ParamName}}: %w", err).Error()) + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + return fiber.NewError(fiber.StatusBadRequest, "Header parameter {{.ParamName}} is required, but not found") + }{{end}} +{{end}} +{{end}} +{{- end}} + return si.Handle{{$opid}}{{$.Prefix}}(c{{if .RequiresParamObject}}, params{{end}}) + } +} + +{{end}} From 0c9bd7d8da1788cf21545a01619b694b8c739290 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 19:54:21 -0700 Subject: [PATCH 15/30] feat(codegen): iris webhook+callback receiver template Phase 6.8, completing the per-framework receiver fan-out. Iris's idioms: * Method signature `Handle{Op}{Prefix}(ctx iris.Context, params...)` with no error return -- iris reports errors via the context. * Factory returns `iris.Handler`. Per-handler middleware is NOT generated; iris's idiom prefers app.Use() / Party-level composition. * Parameter-binding errors set ctx.StatusCode(400) plus ctx.WriteString(reason) and return. * Query params bind via ctx.URLParam("name") for direct values, and runtime.BindQueryParameterWithOptions with ctx.Request().URL.Query() for styled. Header params read ctx.Request().Header. * New pkg/codegen/templates/iris/iris-receiver.tmpl * New GenerateIrisReceiver() entry point * Wiring in Generate() gated on Generate.IrisServer * New internal/test/webhooks_iris/ -- compile-time assertion All seven server frameworks (stdhttp/chi/gorilla/echo/echo5/gin/ fiber/iris) now emit framework-native webhook+callback receiver interfaces alongside their path-server interfaces, gated by the existing per-framework Generate.* flags. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/webhooks_iris/config.yaml | 9 + internal/test/webhooks_iris/doc.go | 6 + internal/test/webhooks_iris/spec.yaml | 34 ++ .../test/webhooks_iris/webhooks_iris.gen.go | 346 ++++++++++++++++++ pkg/codegen/codegen.go | 31 +- pkg/codegen/operations.go | 9 + pkg/codegen/templates/iris/iris-receiver.tmpl | 103 ++++++ 7 files changed, 537 insertions(+), 1 deletion(-) create mode 100644 internal/test/webhooks_iris/config.yaml create mode 100644 internal/test/webhooks_iris/doc.go create mode 100644 internal/test/webhooks_iris/spec.yaml create mode 100644 internal/test/webhooks_iris/webhooks_iris.gen.go create mode 100644 pkg/codegen/templates/iris/iris-receiver.tmpl diff --git a/internal/test/webhooks_iris/config.yaml b/internal/test/webhooks_iris/config.yaml new file mode 100644 index 0000000000..cecd89fcbb --- /dev/null +++ b/internal/test/webhooks_iris/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: webhooks_iris +generate: + models: true + client: true + iris-server: true +output-options: + skip-prune: true +output: webhooks_iris.gen.go diff --git a/internal/test/webhooks_iris/doc.go b/internal/test/webhooks_iris/doc.go new file mode 100644 index 0000000000..55adb45655 --- /dev/null +++ b/internal/test/webhooks_iris/doc.go @@ -0,0 +1,6 @@ +// Package webhooks_iris verifies that the iris-server flag emits a +// compilable WebhookReceiverInterface with iris's (ctx iris.Context) +// signature. Compile-time assertion only. +package webhooks_iris + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks_iris/spec.yaml b/internal/test/webhooks_iris/spec.yaml new file mode 100644 index 0000000000..aa86d1c357 --- /dev/null +++ b/internal/test/webhooks_iris/spec.yaml @@ -0,0 +1,34 @@ +openapi: 3.1.0 +info: + title: Chi webhook receiver test + version: 1.0.0 + description: | + Same spec as internal/test/webhooks but generated with chi-server, + verifying the chi-receiver.tmpl produces compilable code. +paths: {} +webhooks: + petStatusChanged: + post: + operationId: PetStatusChanged + summary: Notifies subscribers that a pet's status changed. + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PetStatusEvent' + responses: + '204': + description: Acknowledged + +components: + schemas: + PetStatusEvent: + type: object + required: [id, status] + properties: + id: + type: string + status: + type: string + enum: [available, pending, sold] diff --git a/internal/test/webhooks_iris/webhooks_iris.gen.go b/internal/test/webhooks_iris/webhooks_iris.gen.go new file mode 100644 index 0000000000..f8337238a3 --- /dev/null +++ b/internal/test/webhooks_iris/webhooks_iris.gen.go @@ -0,0 +1,346 @@ +// Package webhooks_iris provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package webhooks_iris + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + + "github.com/kataras/iris/v12" +) + +// Defines values for PetStatusEventStatus. +const ( + Available PetStatusEventStatus = "available" + Pending PetStatusEventStatus = "pending" + Sold PetStatusEventStatus = "sold" +) + +// Valid indicates whether the value is a known member of the PetStatusEventStatus enum. +func (e PetStatusEventStatus) Valid() bool { + switch e { + case Available: + return true + case Pending: + return true + case Sold: + return true + default: + return false + } +} + +// PetStatusEvent defines model for PetStatusEvent. +type PetStatusEvent struct { + Id string `json:"id"` + Status PetStatusEventStatus `json:"status"` +} + +// PetStatusEventStatus defines model for PetStatusEvent.Status. +type PetStatusEventStatus string + +// PetStatusChangedJSONRequestBody defines body for PetStatusChanged for application/json ContentType. +type PetStatusChangedJSONRequestBody = PetStatusEvent + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { +} + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // PetStatusChangedWithBody fires the petStatusChanged webhook with any body + PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) PetStatusChangedWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) PetStatusChanged(ctx context.Context, targetURL string, body PetStatusChangedJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPetStatusChangedWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewPetStatusChangedWebhookRequest builds a application/json POST request for the petStatusChanged webhook +func NewPetStatusChangedWebhookRequest(targetURL string, body PetStatusChangedJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPetStatusChangedWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewPetStatusChangedWebhookRequestWithBody builds a POST request for the petStatusChanged webhook with any body +func NewPetStatusChangedWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// ServerInterface represents all server handlers. +type ServerInterface interface { +} + +// ServerInterfaceWrapper converts echo contexts to parameters. +type ServerInterfaceWrapper struct { + Handler ServerInterface +} + +type MiddlewareFunc iris.Handler + +// IrisServerOption is the option for iris server +type IrisServerOptions struct { + BaseURL string + Middlewares []MiddlewareFunc +} + +// RegisterHandlers creates http.Handler with routing matching OpenAPI spec. +func RegisterHandlers(router *iris.Application, si ServerInterface) { + RegisterHandlersWithOptions(router, si, IrisServerOptions{}) +} + +// RegisterHandlersWithOptions creates http.Handler with additional options +func RegisterHandlersWithOptions(router *iris.Application, si ServerInterface, options IrisServerOptions) { + + router.Build() +} + +// WebhookReceiverInterface represents handlers for receiving inbound +// webhook requests. Each webhook becomes a Handle*Webhook +// method that the implementation fills in. The caller mounts the per- +// webhook iris.Handler returned by {Op}WebhookHandler at +// whatever URL path they advertise to senders. +type WebhookReceiverInterface interface { + // Notifies subscribers that a pet's status changed. + // HandlePetStatusChangedWebhook handles the POST webhook for petStatusChanged. + HandlePetStatusChangedWebhook(ctx iris.Context) +} + +// PetStatusChangedWebhookHandler returns the iris.Handler for the petStatusChanged webhook. +// Mount this at the URL path advertised to webhook senders. +// Parameter-binding errors set status 400 with a plain-text reason. +// Per-handler middleware is not generated; iris's idiom prefers +// app.Use() / Party-level middleware. +func PetStatusChangedWebhookHandler(si WebhookReceiverInterface) iris.Handler { + return func(ctx iris.Context) { + si.HandlePetStatusChangedWebhook(ctx) + } +} diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index 819176240b..f0eed7baaf 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -508,6 +508,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Webhook receiver (iris) -- (ctx iris.Context) signature. + var irisWebhookReceiverOut string + if opts.Generate.IrisServer && len(webhookOps) > 0 { + irisWebhookReceiverOut, err = GenerateIrisReceiver(t, "Webhook", webhookOps) + if err != nil { + return "", fmt.Errorf("error generating iris webhook receiver: %w", err) + } + } + // Callback initiator pairs with the path Client. Emitted whenever // Generate.Client is on and the spec declares any callbacks -- // callbacks predate 3.1 so this is not version-gated. @@ -584,6 +593,15 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { } } + // Callback receiver (iris). + var irisCallbackReceiverOut string + if opts.Generate.IrisServer && len(callbackOps) > 0 { + irisCallbackReceiverOut, err = GenerateIrisReceiver(t, "Callback", callbackOps) + if err != nil { + return "", fmt.Errorf("error generating iris callback receiver: %w", err) + } + } + var inlinedSpec string if opts.Generate.EmbeddedSpec { inlinedSpec, err = GenerateInlinedSpec(t, globalState.importMapping, spec) @@ -654,7 +672,18 @@ func Generate(spec *openapi3.T, opts Configuration) (string, error) { if err != nil { return "", fmt.Errorf("error writing server path handlers: %w", err) } - + if irisWebhookReceiverOut != "" { + _, err = w.WriteString(irisWebhookReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing iris webhook receiver: %w", err) + } + } + if irisCallbackReceiverOut != "" { + _, err = w.WriteString(irisCallbackReceiverOut) + if err != nil { + return "", fmt.Errorf("error writing iris callback receiver: %w", err) + } + } } if opts.Generate.EchoServer { diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index bdd99cd208..3e28fc1910 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1713,6 +1713,15 @@ func GenerateFiberReceiver(t *template.Template, prefix string, ops []OperationD return GenerateTemplates([]string{"fiber/fiber-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) } +// GenerateIrisReceiver renders the iris receiver template. Iris's +// handler shape is `(ctx iris.Context)` (no error return); binding +// errors set ctx.StatusCode(400) plus ctx.WriteString and return. +// Per-handler middleware is not generated; use app.Use() at the +// engine or Party level. +func GenerateIrisReceiver(t *template.Template, prefix string, ops []OperationDefinition) (string, error) { + return GenerateTemplates([]string{"iris/iris-receiver.tmpl"}, t, NewReceiverTemplateData(prefix, ops)) +} + // GenerateTemplates used to generate templates func GenerateTemplates(templates []string, t *template.Template, ops any) (string, error) { var generatedTemplates []string diff --git a/pkg/codegen/templates/iris/iris-receiver.tmpl b/pkg/codegen/templates/iris/iris-receiver.tmpl new file mode 100644 index 0000000000..a78b6b0917 --- /dev/null +++ b/pkg/codegen/templates/iris/iris-receiver.tmpl @@ -0,0 +1,103 @@ +// {{.Prefix}}ReceiverInterface represents handlers for receiving inbound +// {{.PrefixLower}} requests. Each {{.PrefixLower}} becomes a Handle*{{.Prefix}} +// method that the implementation fills in. The caller mounts the per- +// {{.PrefixLower}} iris.Handler returned by {Op}{{.Prefix}}Handler at +// whatever URL path they advertise to senders. +type {{.Prefix}}ReceiverInterface interface { +{{range .Operations -}} +{{.SummaryAsComment}} +// Handle{{.OperationId}}{{$.Prefix}} handles the {{.Method}} {{$.PrefixLower}} for {{.SourceName}}. +Handle{{.OperationId}}{{$.Prefix}}(ctx iris.Context{{if .RequiresParamObject}}, params {{.OperationId}}Params{{end}}) +{{end}} +} + +{{range .Operations -}} +{{$opid := .OperationId -}} +{{$srcName := .SourceName -}} +// {{$opid}}{{$.Prefix}}Handler returns the iris.Handler for the {{$srcName}} {{$.PrefixLower}}. +// Mount this at the URL path advertised to {{$.PrefixLower}} senders. +// Parameter-binding errors set status 400 with a plain-text reason. +// Per-handler middleware is not generated; iris's idiom prefers +// app.Use() / Party-level middleware. +func {{$opid}}{{$.Prefix}}Handler(si {{$.Prefix}}ReceiverInterface) iris.Handler { + return func(ctx iris.Context) { +{{- if .RequiresParamObject}} + var err error + _ = err + + // Parameter object where we will unmarshal all parameters from the context. + var params {{$opid}}Params +{{range $paramIdx, $param := .QueryParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} query parameter "{{.ParamName}}" ------------- + {{if .IsStyled}} + err = runtime.BindQueryParameterWithOptions("{{.Style}}", {{.Explode}}, {{.Required}}, "{{.ParamName}}", ctx.Request().URL.Query(), ¶ms.{{.GoName}}, runtime.BindQueryParameterOptions{Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + {{else}} + if paramValue := ctx.URLParam("{{.ParamName}}"); paramValue != "" { + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}paramValue + {{end}} + {{if .IsJson}} + var value {{.TypeDef}} + err = json.Unmarshal([]byte(paramValue), &value) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}value + {{end}} + }{{if .Required}} else { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString("Query parameter {{.ParamName}} is required, but not found") + return + }{{end}} + {{end}} +{{end}} +{{range $paramIdx, $param := .HeaderParams}} + // ------------- {{if .Required}}Required{{else}}Optional{{end}} header parameter "{{.ParamName}}" ------------- + if valueList, found := ctx.Request().Header[http.CanonicalHeaderKey("{{.ParamName}}")]; found { + var {{.GoName}} {{.TypeDef}} + n := len(valueList) + if n != 1 { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Expected one value for {{.ParamName}}, got %d", n)) + return + } + {{if .IsPassThrough}} + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}valueList[0] + {{end}} + {{if .IsJson}} + err = json.Unmarshal([]byte(valueList[0]), &{{.GoName}}) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + {{if .IsStyled}} + err = runtime.BindStyledParameterWithOptions("{{.Style}}", "{{.ParamName}}", valueList[0], &{{.GoName}}, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationHeader, Explode: {{.Explode}}, Required: {{.Required}}, Type: "{{.SchemaType}}", Format: "{{.SchemaFormat}}"}) + if err != nil { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString(fmt.Sprintf("Invalid format for parameter {{.ParamName}}: %s", err)) + return + } + params.{{.GoName}} = {{if .HasOptionalPointer}}&{{end}}{{.GoName}} + {{end}} + }{{if .Required}} else { + ctx.StatusCode(http.StatusBadRequest) + _, _ = ctx.WriteString("Header parameter {{.ParamName}} is required, but not found") + return + }{{end}} +{{end}} +{{- end}} + si.Handle{{$opid}}{{$.Prefix}}(ctx{{if .RequiresParamObject}}, params{{end}}) + } +} + +{{end}} From 6117d88399bfb138fd2bc181321fc37948f375e8 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 24 Apr 2026 20:01:08 -0700 Subject: [PATCH 16/30] refactor(test): consolidate webhook tests into internal/test/webhooks// MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final Phase 6 cleanup. Reorganizes the seven flat per-framework webhook test directories under a single internal/test/webhooks/ parent so they group together and the layout reflects the framework-fan-out done in Phases 6.1-6.8. Layout change: internal/test/webhooks/ (the original stdhttp tests) internal/test/webhooks_chi/ } internal/test/webhooks_echo/ } internal/test/webhooks_fiber/ } flat per-framework dirs internal/test/webhooks_gin/ } internal/test/webhooks_gorilla/ } internal/test/webhooks_iris/ } becomes: internal/test/webhooks/ ├── spec.yaml (shared by all subdirs) ├── stdhttp/ (original round-trip tests) ├── chi/ ├── echo/ ├── fiber/ ├── gin/ ├── gorilla/ └── iris/ Per-subdir changes: * Package name simplified from `webhooks` / `webhooks_` to just `` (matches dir name; package-vs-import name overlap with framework packages like chi/v5 is harmless since Go's local package name is implicit). * Generated file uniformly named `webhooks.gen.go` (was `webhooks_.gen.go` in flat dirs). * config.yaml `package:` and `output:` directives updated; schema reference path bumped one level deeper. * doc.go `package` and prose updated; `go:generate` now points at the shared `../spec.yaml`. Spec deduplication: * The seven copies of spec.yaml (functionally identical, only title/description varied) collapse to one shared file at internal/test/webhooks/spec.yaml. Each subdir's go:generate references it via `../spec.yaml`. The round-trip test in stdhttp/webhooks_test.go has its package declaration updated to `package stdhttp`. No code or template changes outside the moves; same gen output up to package name. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/webhooks/chi/config.yaml | 9 +++++ .../{webhooks_chi => webhooks/chi}/doc.go | 4 +-- .../chi/webhooks.gen.go} | 4 +-- internal/test/webhooks/doc.go | 7 ---- internal/test/webhooks/echo/config.yaml | 9 +++++ .../{webhooks_echo => webhooks/echo}/doc.go | 4 +-- .../echo/webhooks.gen.go} | 4 +-- internal/test/webhooks/fiber/config.yaml | 9 +++++ .../{webhooks_fiber => webhooks/fiber}/doc.go | 4 +-- .../fiber/webhooks.gen.go} | 4 +-- internal/test/webhooks/gin/config.yaml | 9 +++++ .../{webhooks_gin => webhooks/gin}/doc.go | 4 +-- .../gin/webhooks.gen.go} | 4 +-- internal/test/webhooks/gorilla/config.yaml | 9 +++++ .../gorilla}/doc.go | 4 +-- .../gorilla/webhooks.gen.go} | 4 +-- internal/test/webhooks/iris/config.yaml | 9 +++++ .../{webhooks_iris => webhooks/iris}/doc.go | 4 +-- .../iris/webhooks.gen.go} | 4 +-- .../test/webhooks/{ => stdhttp}/config.yaml | 4 +-- internal/test/webhooks/stdhttp/doc.go | 8 +++++ .../webhooks/{ => stdhttp}/webhooks.gen.go | 4 +-- .../webhooks/{ => stdhttp}/webhooks_test.go | 4 +-- internal/test/webhooks_chi/config.yaml | 9 ----- internal/test/webhooks_chi/spec.yaml | 34 ------------------- internal/test/webhooks_echo/config.yaml | 9 ----- internal/test/webhooks_echo/spec.yaml | 34 ------------------- internal/test/webhooks_fiber/config.yaml | 9 ----- internal/test/webhooks_fiber/spec.yaml | 34 ------------------- internal/test/webhooks_gin/config.yaml | 9 ----- internal/test/webhooks_gin/spec.yaml | 34 ------------------- internal/test/webhooks_gorilla/config.yaml | 9 ----- internal/test/webhooks_gorilla/spec.yaml | 34 ------------------- internal/test/webhooks_iris/config.yaml | 9 ----- internal/test/webhooks_iris/spec.yaml | 34 ------------------- 35 files changed, 92 insertions(+), 295 deletions(-) create mode 100644 internal/test/webhooks/chi/config.yaml rename internal/test/{webhooks_chi => webhooks/chi}/doc.go (88%) rename internal/test/{webhooks_chi/webhooks_chi.gen.go => webhooks/chi/webhooks.gen.go} (99%) delete mode 100644 internal/test/webhooks/doc.go create mode 100644 internal/test/webhooks/echo/config.yaml rename internal/test/{webhooks_echo => webhooks/echo}/doc.go (82%) rename internal/test/{webhooks_echo/webhooks_echo.gen.go => webhooks/echo/webhooks.gen.go} (99%) create mode 100644 internal/test/webhooks/fiber/config.yaml rename internal/test/{webhooks_fiber => webhooks/fiber}/doc.go (78%) rename internal/test/{webhooks_fiber/webhooks_fiber.gen.go => webhooks/fiber/webhooks.gen.go} (99%) create mode 100644 internal/test/webhooks/gin/config.yaml rename internal/test/{webhooks_gin => webhooks/gin}/doc.go (78%) rename internal/test/{webhooks_gin/webhooks_gin.gen.go => webhooks/gin/webhooks.gen.go} (99%) create mode 100644 internal/test/webhooks/gorilla/config.yaml rename internal/test/{webhooks_gorilla => webhooks/gorilla}/doc.go (82%) rename internal/test/{webhooks_gorilla/webhooks_gorilla.gen.go => webhooks/gorilla/webhooks.gen.go} (99%) create mode 100644 internal/test/webhooks/iris/config.yaml rename internal/test/{webhooks_iris => webhooks/iris}/doc.go (78%) rename internal/test/{webhooks_iris/webhooks_iris.gen.go => webhooks/iris/webhooks.gen.go} (99%) rename internal/test/webhooks/{ => stdhttp}/config.yaml (58%) create mode 100644 internal/test/webhooks/stdhttp/doc.go rename internal/test/webhooks/{ => stdhttp}/webhooks.gen.go (99%) rename internal/test/webhooks/{ => stdhttp}/webhooks_test.go (98%) delete mode 100644 internal/test/webhooks_chi/config.yaml delete mode 100644 internal/test/webhooks_chi/spec.yaml delete mode 100644 internal/test/webhooks_echo/config.yaml delete mode 100644 internal/test/webhooks_echo/spec.yaml delete mode 100644 internal/test/webhooks_fiber/config.yaml delete mode 100644 internal/test/webhooks_fiber/spec.yaml delete mode 100644 internal/test/webhooks_gin/config.yaml delete mode 100644 internal/test/webhooks_gin/spec.yaml delete mode 100644 internal/test/webhooks_gorilla/config.yaml delete mode 100644 internal/test/webhooks_gorilla/spec.yaml delete mode 100644 internal/test/webhooks_iris/config.yaml delete mode 100644 internal/test/webhooks_iris/spec.yaml diff --git a/internal/test/webhooks/chi/config.yaml b/internal/test/webhooks/chi/config.yaml new file mode 100644 index 0000000000..b7aa4983b3 --- /dev/null +++ b/internal/test/webhooks/chi/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: chi +generate: + models: true + client: true + chi-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks_chi/doc.go b/internal/test/webhooks/chi/doc.go similarity index 88% rename from internal/test/webhooks_chi/doc.go rename to internal/test/webhooks/chi/doc.go index 634c4317d5..0a4a49f4aa 100644 --- a/internal/test/webhooks_chi/doc.go +++ b/internal/test/webhooks/chi/doc.go @@ -5,6 +5,6 @@ // (which already round-trip-tests the runtime behavior). This package // is a compile-time assertion that the chi/chi-receiver.tmpl renders // valid Go. -package webhooks_chi +package chi -//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks_chi/webhooks_chi.gen.go b/internal/test/webhooks/chi/webhooks.gen.go similarity index 99% rename from internal/test/webhooks_chi/webhooks_chi.gen.go rename to internal/test/webhooks/chi/webhooks.gen.go index b476c8d12d..8e829a44c8 100644 --- a/internal/test/webhooks_chi/webhooks_chi.gen.go +++ b/internal/test/webhooks/chi/webhooks.gen.go @@ -1,7 +1,7 @@ -// Package webhooks_chi provides primitives to interact with the openapi HTTP API. +// Package chi provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package webhooks_chi +package chi import ( "bytes" diff --git a/internal/test/webhooks/doc.go b/internal/test/webhooks/doc.go deleted file mode 100644 index bdfb62240f..0000000000 --- a/internal/test/webhooks/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Package webhooks verifies OpenAPI 3.1 webhook codegen end-to-end: -// the generated WebhookInitiator fires a webhook against a httptest -// server, and the generated WebhookReceiverInterface handler receives -// it. The test asserts the payload round-trips intact. -package webhooks - -//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/webhooks/echo/config.yaml b/internal/test/webhooks/echo/config.yaml new file mode 100644 index 0000000000..425787aad6 --- /dev/null +++ b/internal/test/webhooks/echo/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: echo +generate: + models: true + client: true + echo-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks_echo/doc.go b/internal/test/webhooks/echo/doc.go similarity index 82% rename from internal/test/webhooks_echo/doc.go rename to internal/test/webhooks/echo/doc.go index 75ce8976f1..108fdb3805 100644 --- a/internal/test/webhooks_echo/doc.go +++ b/internal/test/webhooks/echo/doc.go @@ -2,6 +2,6 @@ // compilable WebhookReceiverInterface with echo's (ctx echo.Context) // error signature. Compile-time assertion only; runtime round-trip is // covered by internal/test/webhooks (stdhttp). -package webhooks_echo +package echo -//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks_echo/webhooks_echo.gen.go b/internal/test/webhooks/echo/webhooks.gen.go similarity index 99% rename from internal/test/webhooks_echo/webhooks_echo.gen.go rename to internal/test/webhooks/echo/webhooks.gen.go index 7208b7318f..c05bf55152 100644 --- a/internal/test/webhooks_echo/webhooks_echo.gen.go +++ b/internal/test/webhooks/echo/webhooks.gen.go @@ -1,7 +1,7 @@ -// Package webhooks_echo provides primitives to interact with the openapi HTTP API. +// Package echo provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package webhooks_echo +package echo import ( "bytes" diff --git a/internal/test/webhooks/fiber/config.yaml b/internal/test/webhooks/fiber/config.yaml new file mode 100644 index 0000000000..bb048b6150 --- /dev/null +++ b/internal/test/webhooks/fiber/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: fiber +generate: + models: true + client: true + fiber-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks_fiber/doc.go b/internal/test/webhooks/fiber/doc.go similarity index 78% rename from internal/test/webhooks_fiber/doc.go rename to internal/test/webhooks/fiber/doc.go index 851e0f1cbe..31ba979d1f 100644 --- a/internal/test/webhooks_fiber/doc.go +++ b/internal/test/webhooks/fiber/doc.go @@ -1,6 +1,6 @@ // Package webhooks_fiber verifies that the fiber-server flag emits a // compilable WebhookReceiverInterface with fiber's (c *fiber.Ctx) // error signature. Compile-time assertion only. -package webhooks_fiber +package fiber -//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks_fiber/webhooks_fiber.gen.go b/internal/test/webhooks/fiber/webhooks.gen.go similarity index 99% rename from internal/test/webhooks_fiber/webhooks_fiber.gen.go rename to internal/test/webhooks/fiber/webhooks.gen.go index d3a6b1dea6..aad402b349 100644 --- a/internal/test/webhooks_fiber/webhooks_fiber.gen.go +++ b/internal/test/webhooks/fiber/webhooks.gen.go @@ -1,7 +1,7 @@ -// Package webhooks_fiber provides primitives to interact with the openapi HTTP API. +// Package fiber provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package webhooks_fiber +package fiber import ( "bytes" diff --git a/internal/test/webhooks/gin/config.yaml b/internal/test/webhooks/gin/config.yaml new file mode 100644 index 0000000000..4ec8f1d49a --- /dev/null +++ b/internal/test/webhooks/gin/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: gin +generate: + models: true + client: true + gin-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks_gin/doc.go b/internal/test/webhooks/gin/doc.go similarity index 78% rename from internal/test/webhooks_gin/doc.go rename to internal/test/webhooks/gin/doc.go index 967c4d460d..6dcf3519dd 100644 --- a/internal/test/webhooks_gin/doc.go +++ b/internal/test/webhooks/gin/doc.go @@ -1,6 +1,6 @@ // Package webhooks_gin verifies that the gin-server flag emits a // compilable WebhookReceiverInterface with gin's (c *gin.Context) // signature. Compile-time assertion only. -package webhooks_gin +package gin -//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks_gin/webhooks_gin.gen.go b/internal/test/webhooks/gin/webhooks.gen.go similarity index 99% rename from internal/test/webhooks_gin/webhooks_gin.gen.go rename to internal/test/webhooks/gin/webhooks.gen.go index 1f43abd117..cbe9c5cca5 100644 --- a/internal/test/webhooks_gin/webhooks_gin.gen.go +++ b/internal/test/webhooks/gin/webhooks.gen.go @@ -1,7 +1,7 @@ -// Package webhooks_gin provides primitives to interact with the openapi HTTP API. +// Package gin provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package webhooks_gin +package gin import ( "bytes" diff --git a/internal/test/webhooks/gorilla/config.yaml b/internal/test/webhooks/gorilla/config.yaml new file mode 100644 index 0000000000..9900db0b69 --- /dev/null +++ b/internal/test/webhooks/gorilla/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: gorilla +generate: + models: true + client: true + gorilla-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks_gorilla/doc.go b/internal/test/webhooks/gorilla/doc.go similarity index 82% rename from internal/test/webhooks_gorilla/doc.go rename to internal/test/webhooks/gorilla/doc.go index 8f6e7b5efb..ef9acf108b 100644 --- a/internal/test/webhooks_gorilla/doc.go +++ b/internal/test/webhooks/gorilla/doc.go @@ -2,6 +2,6 @@ // emits a compilable WebhookReceiverInterface alongside gorilla's // path-server interface. Same (w, r) signature as stdhttp/chi, so the // receiver shape is identical; this is a compile-time assertion. -package webhooks_gorilla +package gorilla -//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks_gorilla/webhooks_gorilla.gen.go b/internal/test/webhooks/gorilla/webhooks.gen.go similarity index 99% rename from internal/test/webhooks_gorilla/webhooks_gorilla.gen.go rename to internal/test/webhooks/gorilla/webhooks.gen.go index f7e9ded9ac..593cf6da72 100644 --- a/internal/test/webhooks_gorilla/webhooks_gorilla.gen.go +++ b/internal/test/webhooks/gorilla/webhooks.gen.go @@ -1,7 +1,7 @@ -// Package webhooks_gorilla provides primitives to interact with the openapi HTTP API. +// Package gorilla provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package webhooks_gorilla +package gorilla import ( "bytes" diff --git a/internal/test/webhooks/iris/config.yaml b/internal/test/webhooks/iris/config.yaml new file mode 100644 index 0000000000..5ab02e9527 --- /dev/null +++ b/internal/test/webhooks/iris/config.yaml @@ -0,0 +1,9 @@ +# yaml-language-server: $schema=../../../../configuration-schema.json +package: iris +generate: + models: true + client: true + iris-server: true +output-options: + skip-prune: true +output: webhooks.gen.go diff --git a/internal/test/webhooks_iris/doc.go b/internal/test/webhooks/iris/doc.go similarity index 78% rename from internal/test/webhooks_iris/doc.go rename to internal/test/webhooks/iris/doc.go index 55adb45655..3704787d76 100644 --- a/internal/test/webhooks_iris/doc.go +++ b/internal/test/webhooks/iris/doc.go @@ -1,6 +1,6 @@ // Package webhooks_iris verifies that the iris-server flag emits a // compilable WebhookReceiverInterface with iris's (ctx iris.Context) // signature. Compile-time assertion only. -package webhooks_iris +package iris -//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks_iris/webhooks_iris.gen.go b/internal/test/webhooks/iris/webhooks.gen.go similarity index 99% rename from internal/test/webhooks_iris/webhooks_iris.gen.go rename to internal/test/webhooks/iris/webhooks.gen.go index f8337238a3..23f45ffaf9 100644 --- a/internal/test/webhooks_iris/webhooks_iris.gen.go +++ b/internal/test/webhooks/iris/webhooks.gen.go @@ -1,7 +1,7 @@ -// Package webhooks_iris provides primitives to interact with the openapi HTTP API. +// Package iris provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package webhooks_iris +package iris import ( "bytes" diff --git a/internal/test/webhooks/config.yaml b/internal/test/webhooks/stdhttp/config.yaml similarity index 58% rename from internal/test/webhooks/config.yaml rename to internal/test/webhooks/stdhttp/config.yaml index d4721e40be..7dfee142d4 100644 --- a/internal/test/webhooks/config.yaml +++ b/internal/test/webhooks/stdhttp/config.yaml @@ -1,5 +1,5 @@ -# yaml-language-server: $schema=../../../configuration-schema.json -package: webhooks +# yaml-language-server: $schema=../../../../configuration-schema.json +package: stdhttp generate: models: true client: true diff --git a/internal/test/webhooks/stdhttp/doc.go b/internal/test/webhooks/stdhttp/doc.go new file mode 100644 index 0000000000..e87c9d0a30 --- /dev/null +++ b/internal/test/webhooks/stdhttp/doc.go @@ -0,0 +1,8 @@ +// Package stdhttp verifies OpenAPI 3.1 webhook codegen end-to-end for +// the stdhttp server flavor: the generated WebhookInitiator fires a +// webhook against a httptest server, and the generated +// WebhookReceiverInterface handler receives it. The test asserts the +// payload round-trips intact. +package stdhttp + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml ../spec.yaml diff --git a/internal/test/webhooks/webhooks.gen.go b/internal/test/webhooks/stdhttp/webhooks.gen.go similarity index 99% rename from internal/test/webhooks/webhooks.gen.go rename to internal/test/webhooks/stdhttp/webhooks.gen.go index 85500cd34e..cefe2a12cf 100644 --- a/internal/test/webhooks/webhooks.gen.go +++ b/internal/test/webhooks/stdhttp/webhooks.gen.go @@ -1,9 +1,9 @@ //go:build go1.22 -// Package webhooks provides primitives to interact with the openapi HTTP API. +// Package stdhttp provides primitives to interact with the openapi HTTP API. // // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. -package webhooks +package stdhttp import ( "bytes" diff --git a/internal/test/webhooks/webhooks_test.go b/internal/test/webhooks/stdhttp/webhooks_test.go similarity index 98% rename from internal/test/webhooks/webhooks_test.go rename to internal/test/webhooks/stdhttp/webhooks_test.go index 0c5afc8cde..585db71c2f 100644 --- a/internal/test/webhooks/webhooks_test.go +++ b/internal/test/webhooks/stdhttp/webhooks_test.go @@ -1,8 +1,8 @@ -// Package webhooks tests the OpenAPI 3.1 webhook codegen end-to-end: +// Package stdhttp tests the OpenAPI 3.1 webhook codegen end-to-end: // the WebhookInitiator fires a webhook against an httptest.Server that // registers the WebhookReceiverInterface handler, and the test asserts // the payload round-trips intact. -package webhooks +package stdhttp import ( "context" diff --git a/internal/test/webhooks_chi/config.yaml b/internal/test/webhooks_chi/config.yaml deleted file mode 100644 index 2b98d52308..0000000000 --- a/internal/test/webhooks_chi/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=../../../configuration-schema.json -package: webhooks_chi -generate: - models: true - client: true - chi-server: true -output-options: - skip-prune: true -output: webhooks_chi.gen.go diff --git a/internal/test/webhooks_chi/spec.yaml b/internal/test/webhooks_chi/spec.yaml deleted file mode 100644 index aa86d1c357..0000000000 --- a/internal/test/webhooks_chi/spec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -openapi: 3.1.0 -info: - title: Chi webhook receiver test - version: 1.0.0 - description: | - Same spec as internal/test/webhooks but generated with chi-server, - verifying the chi-receiver.tmpl produces compilable code. -paths: {} -webhooks: - petStatusChanged: - post: - operationId: PetStatusChanged - summary: Notifies subscribers that a pet's status changed. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PetStatusEvent' - responses: - '204': - description: Acknowledged - -components: - schemas: - PetStatusEvent: - type: object - required: [id, status] - properties: - id: - type: string - status: - type: string - enum: [available, pending, sold] diff --git a/internal/test/webhooks_echo/config.yaml b/internal/test/webhooks_echo/config.yaml deleted file mode 100644 index b63568b0a1..0000000000 --- a/internal/test/webhooks_echo/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=../../../configuration-schema.json -package: webhooks_echo -generate: - models: true - client: true - echo-server: true -output-options: - skip-prune: true -output: webhooks_echo.gen.go diff --git a/internal/test/webhooks_echo/spec.yaml b/internal/test/webhooks_echo/spec.yaml deleted file mode 100644 index aa86d1c357..0000000000 --- a/internal/test/webhooks_echo/spec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -openapi: 3.1.0 -info: - title: Chi webhook receiver test - version: 1.0.0 - description: | - Same spec as internal/test/webhooks but generated with chi-server, - verifying the chi-receiver.tmpl produces compilable code. -paths: {} -webhooks: - petStatusChanged: - post: - operationId: PetStatusChanged - summary: Notifies subscribers that a pet's status changed. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PetStatusEvent' - responses: - '204': - description: Acknowledged - -components: - schemas: - PetStatusEvent: - type: object - required: [id, status] - properties: - id: - type: string - status: - type: string - enum: [available, pending, sold] diff --git a/internal/test/webhooks_fiber/config.yaml b/internal/test/webhooks_fiber/config.yaml deleted file mode 100644 index d107f4862c..0000000000 --- a/internal/test/webhooks_fiber/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=../../../configuration-schema.json -package: webhooks_fiber -generate: - models: true - client: true - fiber-server: true -output-options: - skip-prune: true -output: webhooks_fiber.gen.go diff --git a/internal/test/webhooks_fiber/spec.yaml b/internal/test/webhooks_fiber/spec.yaml deleted file mode 100644 index aa86d1c357..0000000000 --- a/internal/test/webhooks_fiber/spec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -openapi: 3.1.0 -info: - title: Chi webhook receiver test - version: 1.0.0 - description: | - Same spec as internal/test/webhooks but generated with chi-server, - verifying the chi-receiver.tmpl produces compilable code. -paths: {} -webhooks: - petStatusChanged: - post: - operationId: PetStatusChanged - summary: Notifies subscribers that a pet's status changed. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PetStatusEvent' - responses: - '204': - description: Acknowledged - -components: - schemas: - PetStatusEvent: - type: object - required: [id, status] - properties: - id: - type: string - status: - type: string - enum: [available, pending, sold] diff --git a/internal/test/webhooks_gin/config.yaml b/internal/test/webhooks_gin/config.yaml deleted file mode 100644 index f0ddc459ef..0000000000 --- a/internal/test/webhooks_gin/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=../../../configuration-schema.json -package: webhooks_gin -generate: - models: true - client: true - gin-server: true -output-options: - skip-prune: true -output: webhooks_gin.gen.go diff --git a/internal/test/webhooks_gin/spec.yaml b/internal/test/webhooks_gin/spec.yaml deleted file mode 100644 index aa86d1c357..0000000000 --- a/internal/test/webhooks_gin/spec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -openapi: 3.1.0 -info: - title: Chi webhook receiver test - version: 1.0.0 - description: | - Same spec as internal/test/webhooks but generated with chi-server, - verifying the chi-receiver.tmpl produces compilable code. -paths: {} -webhooks: - petStatusChanged: - post: - operationId: PetStatusChanged - summary: Notifies subscribers that a pet's status changed. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PetStatusEvent' - responses: - '204': - description: Acknowledged - -components: - schemas: - PetStatusEvent: - type: object - required: [id, status] - properties: - id: - type: string - status: - type: string - enum: [available, pending, sold] diff --git a/internal/test/webhooks_gorilla/config.yaml b/internal/test/webhooks_gorilla/config.yaml deleted file mode 100644 index b4d4b221c3..0000000000 --- a/internal/test/webhooks_gorilla/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=../../../configuration-schema.json -package: webhooks_gorilla -generate: - models: true - client: true - gorilla-server: true -output-options: - skip-prune: true -output: webhooks_gorilla.gen.go diff --git a/internal/test/webhooks_gorilla/spec.yaml b/internal/test/webhooks_gorilla/spec.yaml deleted file mode 100644 index aa86d1c357..0000000000 --- a/internal/test/webhooks_gorilla/spec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -openapi: 3.1.0 -info: - title: Chi webhook receiver test - version: 1.0.0 - description: | - Same spec as internal/test/webhooks but generated with chi-server, - verifying the chi-receiver.tmpl produces compilable code. -paths: {} -webhooks: - petStatusChanged: - post: - operationId: PetStatusChanged - summary: Notifies subscribers that a pet's status changed. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PetStatusEvent' - responses: - '204': - description: Acknowledged - -components: - schemas: - PetStatusEvent: - type: object - required: [id, status] - properties: - id: - type: string - status: - type: string - enum: [available, pending, sold] diff --git a/internal/test/webhooks_iris/config.yaml b/internal/test/webhooks_iris/config.yaml deleted file mode 100644 index cecd89fcbb..0000000000 --- a/internal/test/webhooks_iris/config.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# yaml-language-server: $schema=../../../configuration-schema.json -package: webhooks_iris -generate: - models: true - client: true - iris-server: true -output-options: - skip-prune: true -output: webhooks_iris.gen.go diff --git a/internal/test/webhooks_iris/spec.yaml b/internal/test/webhooks_iris/spec.yaml deleted file mode 100644 index aa86d1c357..0000000000 --- a/internal/test/webhooks_iris/spec.yaml +++ /dev/null @@ -1,34 +0,0 @@ -openapi: 3.1.0 -info: - title: Chi webhook receiver test - version: 1.0.0 - description: | - Same spec as internal/test/webhooks but generated with chi-server, - verifying the chi-receiver.tmpl produces compilable code. -paths: {} -webhooks: - petStatusChanged: - post: - operationId: PetStatusChanged - summary: Notifies subscribers that a pet's status changed. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/PetStatusEvent' - responses: - '204': - description: Acknowledged - -components: - schemas: - PetStatusEvent: - type: object - required: [id, status] - properties: - id: - type: string - status: - type: string - enum: [available, pending, sold] From aa23540e3046f1d4ce3a5e5744a1fb40c8696065 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 25 Apr 2026 06:32:29 -0700 Subject: [PATCH 17/30] fix(codegen): address greptile review findings on Phase 6 work Two findings from automated review: P1 -- Non-deterministic map iteration in CallbackOperationDefinitions (pkg/codegen/operations.go). The inner loop walked `pathItem.Operations()` directly. When a path declares callbacks on multiple HTTP methods (e.g. both POST and PUT), the generated output would differ between runs because Go map iteration is randomized. Fix: copy keys into a slice and walk via SortedMapKeys, matching the pattern used by every other similar loop in the codebase (including WebhookOperationDefinitions immediately above). P2 -- Hidden global-state dependency in version-aware helpers (pkg/codegen/schema.go). schemaIsNullable, schemaPrimaryType, describeWithExamples, and detectEnumViaOneOf all read globalState.is31 implicitly, with no indication at the function signature that callers must invoke them only after Generate() has initialized the global. Fix: add an explicit "Precondition" line to each helper's doc comment so callers (including future refactors that move schema resolution earlier) are aware of the dependency. The signatures themselves are not changed -- threading the version flag through every call site would be an intrusive refactor for a problem better described in prose. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/codegen/operations.go | 7 ++++++- pkg/codegen/schema.go | 25 ++++++++++++++++++++----- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 3e28fc1910..fcba3e00d1 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1083,7 +1083,12 @@ func CallbackOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, e if pathItem == nil { continue } - for _, parentOp := range pathItem.Operations() { + // Iterate path-item operations in sorted method order for + // deterministic output across runs (Operations() returns a + // map; range order is randomized). + parentOps := pathItem.Operations() + for _, parentMethod := range SortedMapKeys(parentOps) { + parentOp := parentOps[parentMethod] if len(parentOp.Callbacks) == 0 { continue } diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 77b1dbd032..1b63d79b5e 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -325,14 +325,18 @@ func PropertiesEqual(a, b Property) bool { } // schemaIsNullable reports whether an OpenAPI schema represents a nullable -// type, branching on the spec version detected at Generate() entry. In -// OpenAPI 3.0 nullability is the explicit `nullable: true` flag; in -// OpenAPI 3.1 the `nullable` keyword was removed in favor of including +// type. In OpenAPI 3.0 nullability is the explicit `nullable: true` flag; +// in OpenAPI 3.1 the `nullable` keyword was removed in favor of including // "null" in the type array (e.g. `type: ["string","null"]`). // +// Precondition: globalState.is31 must be set (Generate() does this at +// entry from swagger.IsOpenAPI31OrLater()). This helper does not take +// the version flag explicitly because every call site is reached +// through Generate(); calling it before globalState is initialized +// will silently take the wrong branch. +// // Cross-version misuse (a 3.1 spec using `nullable: true`, or a 3.0 spec -// using `type: [..., "null"]`) is rejected by spec validation at the start -// of Generate() unless Compatibility.SkipSpecValidation is set, so this +// using `type: [..., "null"]`) is documented as invalid input -- this // helper trusts its input to use the version-appropriate idiom. func schemaIsNullable(s *openapi3.Schema) bool { if s == nil { @@ -374,6 +378,9 @@ type enumViaOneOfValue struct { // Gated on globalState.is31 (the keyword `const` lands in OpenAPI 3.1) // AND !SkipEnumViaOneOf so users can fall through to the standard union // generator on demand. +// +// Precondition: globalState (both is31 and options) must be initialized +// (see schemaIsNullable for context). func detectEnumViaOneOf(schema *openapi3.Schema) ([]enumViaOneOfValue, bool) { if !globalState.is31 { return nil, false @@ -424,6 +431,9 @@ func detectEnumViaOneOf(schema *openapi3.Schema) ([]enumViaOneOfValue, bool) { // 3.0) on a new paragraph after any existing description text. Non- // string values are JSON-encoded so structured examples render // readably. +// +// Precondition: globalState.is31 must be set (see schemaIsNullable +// for context). func describeWithExamples(description string, schema *openapi3.Schema) string { if schema == nil { return description @@ -474,6 +484,11 @@ func formatExampleValue(v any) string { // the nullability indicator (handled separately by schemaIsNullable); we // strip it here so the rest of the codegen can keep dispatching with // `Is("...")` as it always has. +// +// Precondition: globalState.is31 must be set (see schemaIsNullable +// for context). The early-return on `!globalState.is31` is correct only +// when the global state has actually been initialized -- a call before +// Generate() runs would silently use the 3.0 branch. func schemaPrimaryType(t *openapi3.Types) *openapi3.Types { if t == nil || !globalState.is31 { return t From 2349d1553edb304ae57cf8bd9098aacc202dd5a1 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 25 Apr 2026 06:42:50 -0700 Subject: [PATCH 18/30] Pull in kin-openapi v0.136.0 Pulls in the official kin release which adds OpenAPI 3.1 support, and updates CI to run only on Go 1.26 --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index f09a336c11..6fab685e4a 100644 --- a/Makefile +++ b/Makefile @@ -10,7 +10,7 @@ help: @echo " lint lint the project" $(GOBIN)/golangci-lint: - curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOBIN) v2.10.1 + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(GOBIN) v2.11.4 .PHONY: tools tools: $(GOBIN)/golangci-lint From ef34d82d15fb78efee4b38a5eb5ffec023ef6e84 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 25 Apr 2026 06:49:55 -0700 Subject: [PATCH 19/30] fix(test): address golangci-lint errcheck + ST1023 findings Two distinct lint issues across four test files. errcheck (5 sites): defer / direct calls to r.Body.Close() and resp.Body.Close() ignored their error returns. Standard fix: * `defer r.Body.Close()` -> `defer func() { _ = r.Body.Close() }()` * `resp.Body.Close()` (non-deferred) -> `_ = resp.Body.Close()` internal/test/callbacks/callbacks_test.go (1) internal/test/webhooks/stdhttp/webhooks_test.go (4) ST1023 (2 sites): "should omit type X from declaration; it will be inferred from the right-hand side". Two different stories. * internal/test/openapi31_polish/openapi31_polish_test.go: `var s Status = Active` is genuinely redundant -- `Active` is declared `const Active Status = "active"`, so type inference gives `s` the type `Status` already. Switched to `s := Active`. Comment updated to explain the still-meaningful compile-time check (if Active had been emitted untyped, `s := Active` would not preserve the Status type). * internal/test/enum_via_oneof/enum_via_oneof_test.go: `var m MixedOneOf = s` (where `s` is a plain string) IS the test. The redundancy is intentional -- if MixedOneOf were a `type MixedOneOf string` newtype rather than the alias `type MixedOneOf = string`, this assignment would fail to compile. Letting inference give `m` the type `string` would lose the property under test. Added `//nolint:staticcheck` with the rationale inline. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/test/callbacks/callbacks_test.go | 2 +- internal/test/enum_via_oneof/enum_via_oneof_test.go | 8 +++++++- internal/test/openapi31_polish/openapi31_polish_test.go | 9 +++++---- internal/test/webhooks/stdhttp/webhooks_test.go | 8 ++++---- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/internal/test/callbacks/callbacks_test.go b/internal/test/callbacks/callbacks_test.go index 60d047536c..a7ae94b010 100644 --- a/internal/test/callbacks/callbacks_test.go +++ b/internal/test/callbacks/callbacks_test.go @@ -30,7 +30,7 @@ func (f *fakeReceiver) HandleTreePlantedCallback(w http.ResponseWriter, r *http. http.Error(w, err.Error(), http.StatusBadRequest) return } - defer r.Body.Close() + defer func() { _ = r.Body.Close() }() if err := json.Unmarshal(body, &f.gotResult); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/internal/test/enum_via_oneof/enum_via_oneof_test.go b/internal/test/enum_via_oneof/enum_via_oneof_test.go index 8ce583cf64..514bd83d1a 100644 --- a/internal/test/enum_via_oneof/enum_via_oneof_test.go +++ b/internal/test/enum_via_oneof/enum_via_oneof_test.go @@ -61,8 +61,14 @@ func TestColorJSONRoundTrip(t *testing.T) { // detection were over-eager, MixedOneOf would become a newtype `type // MixedOneOf string` and the assignment below would fail to compile // (a string would not be directly assignable to a newtype value). +// +// The explicit `var m MixedOneOf = s` declaration is intentional: +// staticcheck (ST1023) wants the type omitted because aliasing makes +// it redundant, but that redundancy IS the test -- if we let inference +// give `m` the type `string`, the alias-vs-newtype property isn't +// exercised at compile time. func TestMixedOneOfFallsThrough(t *testing.T) { var s = "anything" - var m MixedOneOf = s + var m MixedOneOf = s //nolint:staticcheck // ST1023: explicit type is the compile-time alias check assert.Equal(t, "anything", string(m)) } diff --git a/internal/test/openapi31_polish/openapi31_polish_test.go b/internal/test/openapi31_polish/openapi31_polish_test.go index 61c32ffa62..db9cf5bbe2 100644 --- a/internal/test/openapi31_polish/openapi31_polish_test.go +++ b/internal/test/openapi31_polish/openapi31_polish_test.go @@ -23,11 +23,12 @@ import ( ) // TestStatusConstSchema verifies that a scalar `const` schema produces a -// typed alias and a singleton constant. Compile-time check: assigning -// the constant to a Status variable only succeeds if Status is a -// distinct named type. +// typed alias and a singleton constant. Compile-time check: `Active` is +// declared as `const Active Status = "active"`, so type inference here +// gives `s` the type `Status` -- if the codegen had emitted Active as +// untyped, this would not compile. func TestStatusConstSchema(t *testing.T) { - var s Status = Active + s := Active assert.Equal(t, "active", string(s)) assert.True(t, s.Valid(), "Active should be a valid Status enum member") } diff --git a/internal/test/webhooks/stdhttp/webhooks_test.go b/internal/test/webhooks/stdhttp/webhooks_test.go index 585db71c2f..30e8091244 100644 --- a/internal/test/webhooks/stdhttp/webhooks_test.go +++ b/internal/test/webhooks/stdhttp/webhooks_test.go @@ -34,7 +34,7 @@ func (f *fakeReceiver) HandlePetStatusChangedWebhook(w http.ResponseWriter, r *h http.Error(w, err.Error(), http.StatusBadRequest) return } - defer r.Body.Close() + defer func() { _ = r.Body.Close() }() if err := json.Unmarshal(body, &f.gotEvent); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -63,7 +63,7 @@ func TestWebhookRoundTrip(t *testing.T) { resp, err := initiator.PetStatusChanged(context.Background(), srv.URL+"/hooks/pet-status", event) require.NoError(t, err) - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() require.Equal(t, http.StatusNoContent, resp.StatusCode) require.True(t, receiver.called, "webhook handler should have been called") @@ -99,7 +99,7 @@ func TestWebhookInitiatorRequestEditor(t *testing.T) { Status: Available, }) require.NoError(t, err) - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() assert.Equal(t, sigValue, receiver.gotHeaders.Get(sigHeader), "per-call editor was not applied to the outgoing request") @@ -141,7 +141,7 @@ func TestWebhookReceiverMiddleware(t *testing.T) { Id: "x", Status: Pending, }) require.NoError(t, err) - resp.Body.Close() + _ = resp.Body.Close() // Middlewares are applied in order with the LAST argument becoming // the OUTERMOST wrapper (each iteration assigns h = mw(h)). So we From 4ec93d4edcc37a251fea17a7da33897bf9c12ab1 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 25 Apr 2026 06:50:19 -0700 Subject: [PATCH 20/30] Fix lint issues and CI versioning --- internal/test/callbacks/callbacks_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/test/callbacks/callbacks_test.go b/internal/test/callbacks/callbacks_test.go index a7ae94b010..4f3d8fe423 100644 --- a/internal/test/callbacks/callbacks_test.go +++ b/internal/test/callbacks/callbacks_test.go @@ -56,7 +56,7 @@ func TestCallbackRoundTrip(t *testing.T) { resp, err := initiator.TreePlanted(context.Background(), srv.URL+"/tree-planted", result) require.NoError(t, err) - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() require.Equal(t, http.StatusNoContent, resp.StatusCode) require.True(t, receiver.called, "callback handler should have been called") @@ -91,7 +91,7 @@ func TestCallbackInitiatorRequestEditor(t *testing.T) { Success: false, }) require.NoError(t, err) - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() assert.Equal(t, sigValue, receiver.gotHeaders.Get(sigHeader)) } From 4c055d9d56f603b7afd411889b32ebcd3eaa5226 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 30 Apr 2026 10:39:23 -0700 Subject: [PATCH 21/30] Rebase and downgrade Go Now that kin doesn't require Go 1.26, downgrade to Go 1.25 and rebase changes from main. --- .github/workflows/ci.yml | 2 +- examples/minimal-server/chi/api/ping.gen.go | 1 + examples/minimal-server/echo/api/ping.gen.go | 1 + examples/minimal-server/fiber/api/ping.gen.go | 1 + examples/minimal-server/gin/api/ping.gen.go | 1 + .../minimal-server/gorillamux/api/ping.gen.go | 1 + examples/minimal-server/iris/api/ping.gen.go | 1 + .../stdhttp-go-tool/api/ping.gen.go | 1 + examples/overlay/api/ping.gen.go | 1 + internal/test/webhooks/echo/webhooks.gen.go | 25 ++++++++++++++++--- pkg/codegen/operations.go | 8 +++--- 11 files changed, 35 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b92f613e1..c109a93138 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: fail-fast: true matrix: version: - - "1.26" - "1.25" + - "1.26" steps: - name: Check out source code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/examples/minimal-server/chi/api/ping.gen.go b/examples/minimal-server/chi/api/ping.gen.go index a2f368005a..f54fdd3358 100644 --- a/examples/minimal-server/chi/api/ping.gen.go +++ b/examples/minimal-server/chi/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/echo/api/ping.gen.go b/examples/minimal-server/echo/api/ping.gen.go index e376e807d2..ca960bf2b0 100644 --- a/examples/minimal-server/echo/api/ping.gen.go +++ b/examples/minimal-server/echo/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/fiber/api/ping.gen.go b/examples/minimal-server/fiber/api/ping.gen.go index b926f841bb..e48e5d1794 100644 --- a/examples/minimal-server/fiber/api/ping.gen.go +++ b/examples/minimal-server/fiber/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/gin/api/ping.gen.go b/examples/minimal-server/gin/api/ping.gen.go index 8e018d4e89..e7148a7a52 100644 --- a/examples/minimal-server/gin/api/ping.gen.go +++ b/examples/minimal-server/gin/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/gorillamux/api/ping.gen.go b/examples/minimal-server/gorillamux/api/ping.gen.go index 82ec6ebb5d..ee1a35215a 100644 --- a/examples/minimal-server/gorillamux/api/ping.gen.go +++ b/examples/minimal-server/gorillamux/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/iris/api/ping.gen.go b/examples/minimal-server/iris/api/ping.gen.go index 144166eba2..3ef88437f7 100644 --- a/examples/minimal-server/iris/api/ping.gen.go +++ b/examples/minimal-server/iris/api/ping.gen.go @@ -9,6 +9,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go b/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go index 0d4ab7751a..93f74af35a 100644 --- a/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go +++ b/examples/minimal-server/stdhttp-go-tool/api/ping.gen.go @@ -12,6 +12,7 @@ import ( // Pong defines model for Pong. type Pong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/examples/overlay/api/ping.gen.go b/examples/overlay/api/ping.gen.go index 0d6794fc38..8cfd5525ab 100644 --- a/examples/overlay/api/ping.gen.go +++ b/examples/overlay/api/ping.gen.go @@ -19,6 +19,7 @@ import ( // OverriddenPong defines model for Pong. type OverriddenPong struct { + // Ping Example: pong Ping string `json:"ping"` } diff --git a/internal/test/webhooks/echo/webhooks.gen.go b/internal/test/webhooks/echo/webhooks.gen.go index c05bf55152..ca5a5a1aca 100644 --- a/internal/test/webhooks/echo/webhooks.gen.go +++ b/internal/test/webhooks/echo/webhooks.gen.go @@ -319,14 +319,33 @@ type EchoRouter interface { TRACE(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.Route } +// RegisterHandlersOptions configures RegisterHandlersWithOptions. +type RegisterHandlersOptions struct { + // BaseURL is prepended to every registered path so the API can be served + // under a prefix. + BaseURL string + // OperationMiddlewares lets the caller attach per-operation middleware at + // registration time. The map key is the OpenAPI `operationId` value as it + // appears in the spec (the raw, un-normalized form). Operations that have + // no entry are registered with no extra middleware. A nil map disables + // per-operation middleware entirely. + OperationMiddlewares map[string][]echo.MiddlewareFunc +} + // RegisterHandlers adds each server route to the EchoRouter. func RegisterHandlers(router EchoRouter, si ServerInterface) { - RegisterHandlersWithBaseURL(router, si, "") + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{}) } -// Registers handlers, and prepends BaseURL to the paths, so that the paths -// can be served under a prefix. +// RegisterHandlersWithBaseURL registers handlers and prepends BaseURL to the +// paths so the API can be served under a prefix. func RegisterHandlersWithBaseURL(router EchoRouter, si ServerInterface, baseURL string) { + RegisterHandlersWithOptions(router, si, RegisterHandlersOptions{BaseURL: baseURL}) +} + +// RegisterHandlersWithOptions registers handlers using the supplied options, +// including any per-operation middleware. +func RegisterHandlersWithOptions(router EchoRouter, si ServerInterface, options RegisterHandlersOptions) { } diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index fcba3e00d1..bc4dc7055b 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -1002,12 +1002,12 @@ func WebhookOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, er return nil, err } - bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody) + bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody, pathItem.Ref) if err != nil { return nil, fmt.Errorf("error generating body definitions for webhook %q: %w", webhookName, err) } - responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map()) + responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map(), pathItem.Ref) if err != nil { return nil, fmt.Errorf("error generating response definitions for webhook %q: %w", webhookName, err) } @@ -1133,12 +1133,12 @@ func CallbackOperationDefinitions(swagger *openapi3.T) ([]OperationDefinition, e return nil, err } - bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody) + bodyDefinitions, typeDefinitions, err := GenerateBodyDefinitions(operationId, op.RequestBody, cbPathItem.Ref) if err != nil { return nil, fmt.Errorf("error generating body definitions for callback %q: %w", callbackName, err) } - responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map()) + responseDefinitions, err := GenerateResponseDefinitions(operationId, op.Responses.Map(), cbPathItem.Ref) if err != nil { return nil, fmt.Errorf("error generating response definitions for callback %q: %w", callbackName, err) } From 23f1a63799abc95182a32112579e09e1337414a8 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Thu, 30 Apr 2026 10:51:11 -0700 Subject: [PATCH 22/30] Extend hoisting tests Add OpenApi 3.1 top level schema sources to the hoisting test. --- .../anonymous_inner_hoisting/client.gen.go | 821 +++++++++++++++++- .../anonymous_inner_hoisting/client_test.go | 68 ++ .../test/anonymous_inner_hoisting/spec.yaml | 93 +- 3 files changed, 975 insertions(+), 7 deletions(-) diff --git a/internal/test/anonymous_inner_hoisting/client.gen.go b/internal/test/anonymous_inner_hoisting/client.gen.go index 74fe6907c2..27722b434c 100644 --- a/internal/test/anonymous_inner_hoisting/client.gen.go +++ b/internal/test/anonymous_inner_hoisting/client.gen.go @@ -79,6 +79,11 @@ type PostBodyRootOneOfJSONBody struct { union json.RawMessage } +// TriggerCallbackJSONBody defines parameters for TriggerCallback. +type TriggerCallbackJSONBody struct { + CallbackUrl string `json:"callbackUrl"` +} + // GetResponseDeepNested200JSONResponseBody_Wrapper_Inner defines parameters for GetResponseDeepNested. type GetResponseDeepNested200JSONResponseBody_Wrapper_Inner struct { union json.RawMessage @@ -99,12 +104,57 @@ type GetResponseRootOneOf200JSONResponseBody struct { union json.RawMessage } +// WebhookBodyPropertyOneOfJSONBody defines parameters for WebhookBodyPropertyOneOf. +type WebhookBodyPropertyOneOfJSONBody struct { + Pet *WebhookBodyPropertyOneOfJSONBody_Pet `json:"pet,omitempty"` +} + +// WebhookBodyPropertyOneOfJSONBody_Pet defines parameters for WebhookBodyPropertyOneOf. +type WebhookBodyPropertyOneOfJSONBody_Pet struct { + union json.RawMessage +} + +// WebhookBodyRootOneOfJSONBody defines parameters for WebhookBodyRootOneOf. +type WebhookBodyRootOneOfJSONBody struct { + union json.RawMessage +} + +// CallbackBodyPropertyOneOfJSONBody defines parameters for CallbackBodyPropertyOneOf. +type CallbackBodyPropertyOneOfJSONBody struct { + Pet *CallbackBodyPropertyOneOfJSONBody_Pet `json:"pet,omitempty"` +} + +// CallbackBodyPropertyOneOfJSONBody_Pet defines parameters for CallbackBodyPropertyOneOf. +type CallbackBodyPropertyOneOfJSONBody_Pet struct { + union json.RawMessage +} + +// CallbackBodyRootOneOfJSONBody defines parameters for CallbackBodyRootOneOf. +type CallbackBodyRootOneOfJSONBody struct { + union json.RawMessage +} + // PostBodyPropertyOneOfJSONRequestBody defines body for PostBodyPropertyOneOf for application/json ContentType. type PostBodyPropertyOneOfJSONRequestBody PostBodyPropertyOneOfJSONBody // PostBodyRootOneOfJSONRequestBody defines body for PostBodyRootOneOf for application/json ContentType. type PostBodyRootOneOfJSONRequestBody PostBodyRootOneOfJSONBody +// TriggerCallbackJSONRequestBody defines body for TriggerCallback for application/json ContentType. +type TriggerCallbackJSONRequestBody TriggerCallbackJSONBody + +// WebhookBodyPropertyOneOfJSONRequestBody defines body for WebhookBodyPropertyOneOf for application/json ContentType. +type WebhookBodyPropertyOneOfJSONRequestBody WebhookBodyPropertyOneOfJSONBody + +// WebhookBodyRootOneOfJSONRequestBody defines body for WebhookBodyRootOneOf for application/json ContentType. +type WebhookBodyRootOneOfJSONRequestBody WebhookBodyRootOneOfJSONBody + +// CallbackBodyPropertyOneOfJSONRequestBody defines body for CallbackBodyPropertyOneOf for application/json ContentType. +type CallbackBodyPropertyOneOfJSONRequestBody CallbackBodyPropertyOneOfJSONBody + +// CallbackBodyRootOneOfJSONRequestBody defines body for CallbackBodyRootOneOf for application/json ContentType. +type CallbackBodyRootOneOfJSONRequestBody CallbackBodyRootOneOfJSONBody + // AsCat returns the union data inside the PostBodyPropertyOneOfJSONBody_Pet as a Cat func (t PostBodyPropertyOneOfJSONBody_Pet) AsCat() (Cat, error) { var body Cat @@ -477,6 +527,254 @@ func (t *GetResponseRootOneOf200JSONResponseBody) UnmarshalJSON(b []byte) error return err } +// AsCat returns the union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as a Cat +func (t WebhookBodyPropertyOneOfJSONBody_Pet) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as the provided Cat +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet, using the provided Cat +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as a Dog +func (t WebhookBodyPropertyOneOfJSONBody_Pet) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet as the provided Dog +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the WebhookBodyPropertyOneOfJSONBody_Pet, using the provided Dog +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t WebhookBodyPropertyOneOfJSONBody_Pet) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *WebhookBodyPropertyOneOfJSONBody_Pet) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsCat returns the union data inside the WebhookBodyRootOneOfJSONBody as a Cat +func (t WebhookBodyRootOneOfJSONBody) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the WebhookBodyRootOneOfJSONBody as the provided Cat +func (t *WebhookBodyRootOneOfJSONBody) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the WebhookBodyRootOneOfJSONBody, using the provided Cat +func (t *WebhookBodyRootOneOfJSONBody) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the WebhookBodyRootOneOfJSONBody as a Dog +func (t WebhookBodyRootOneOfJSONBody) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the WebhookBodyRootOneOfJSONBody as the provided Dog +func (t *WebhookBodyRootOneOfJSONBody) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the WebhookBodyRootOneOfJSONBody, using the provided Dog +func (t *WebhookBodyRootOneOfJSONBody) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t WebhookBodyRootOneOfJSONBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *WebhookBodyRootOneOfJSONBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsCat returns the union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as a Cat +func (t CallbackBodyPropertyOneOfJSONBody_Pet) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as the provided Cat +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet, using the provided Cat +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as a Dog +func (t CallbackBodyPropertyOneOfJSONBody_Pet) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet as the provided Dog +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the CallbackBodyPropertyOneOfJSONBody_Pet, using the provided Dog +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CallbackBodyPropertyOneOfJSONBody_Pet) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CallbackBodyPropertyOneOfJSONBody_Pet) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// AsCat returns the union data inside the CallbackBodyRootOneOfJSONBody as a Cat +func (t CallbackBodyRootOneOfJSONBody) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the CallbackBodyRootOneOfJSONBody as the provided Cat +func (t *CallbackBodyRootOneOfJSONBody) FromCat(v Cat) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the CallbackBodyRootOneOfJSONBody, using the provided Cat +func (t *CallbackBodyRootOneOfJSONBody) MergeCat(v Cat) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the CallbackBodyRootOneOfJSONBody as a Dog +func (t CallbackBodyRootOneOfJSONBody) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the CallbackBodyRootOneOfJSONBody as the provided Dog +func (t *CallbackBodyRootOneOfJSONBody) FromDog(v Dog) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the CallbackBodyRootOneOfJSONBody, using the provided Dog +func (t *CallbackBodyRootOneOfJSONBody) MergeDog(v Dog) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t CallbackBodyRootOneOfJSONBody) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *CallbackBodyRootOneOfJSONBody) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // RequestEditorFn is the function signature for the RequestEditor callback function type RequestEditorFn func(ctx context.Context, req *http.Request) error @@ -560,6 +858,11 @@ type ClientInterface interface { PostBodyRootOneOf(ctx context.Context, body PostBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // TriggerCallbackWithBody request with any body + TriggerCallbackWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + TriggerCallback(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetResponseDeepNested request GetResponseDeepNested(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -621,6 +924,30 @@ func (c *Client) PostBodyRootOneOf(ctx context.Context, body PostBodyRootOneOfJS return c.Client.Do(req) } +func (c *Client) TriggerCallbackWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTriggerCallbackRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) TriggerCallback(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewTriggerCallbackRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetResponseDeepNested(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetResponseDeepNestedRequest(c.Server) if err != nil { @@ -749,8 +1076,19 @@ func NewPostBodyRootOneOfRequestWithBody(server string, contentType string, body return req, nil } -// NewGetResponseDeepNestedRequest generates requests for GetResponseDeepNested -func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { +// NewTriggerCallbackRequest calls the generic TriggerCallback builder with application/json body +func NewTriggerCallbackRequest(server string, body TriggerCallbackJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewTriggerCallbackRequestWithBody(server, "application/json", bodyReader) +} + +// NewTriggerCallbackRequestWithBody generates requests for TriggerCallback with any type of body +func NewTriggerCallbackRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -758,7 +1096,7 @@ func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { return nil, err } - operationPath := fmt.Sprintf("/response-deep-nested") + operationPath := fmt.Sprintf("/callback-trigger") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -768,16 +1106,45 @@ func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) if err != nil { return nil, err } + req.Header.Add("Content-Type", contentType) + return req, nil } -// NewGetResponseItemsOneOfRequest generates requests for GetResponseItemsOneOf -func NewGetResponseItemsOneOfRequest(server string) (*http.Request, error) { +// NewGetResponseDeepNestedRequest generates requests for GetResponseDeepNested +func NewGetResponseDeepNestedRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/response-deep-nested") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetResponseItemsOneOfRequest generates requests for GetResponseItemsOneOf +func NewGetResponseItemsOneOfRequest(server string) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -910,6 +1277,11 @@ type ClientWithResponsesInterface interface { PostBodyRootOneOfWithResponse(ctx context.Context, body PostBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*PostBodyRootOneOfResponse, error) + // TriggerCallbackWithBodyWithResponse request with any body + TriggerCallbackWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) + + TriggerCallbackWithResponse(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) + // GetResponseDeepNestedWithResponse request GetResponseDeepNestedWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetResponseDeepNestedResponse, error) @@ -981,6 +1353,35 @@ func (r PostBodyRootOneOfResponse) ContentType() string { return "" } +type TriggerCallbackResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r TriggerCallbackResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r TriggerCallbackResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r TriggerCallbackResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetResponseDeepNestedResponse struct { Body []byte HTTPResponse *http.Response @@ -1141,6 +1542,23 @@ func (c *ClientWithResponses) PostBodyRootOneOfWithResponse(ctx context.Context, return ParsePostBodyRootOneOfResponse(rsp) } +// TriggerCallbackWithBodyWithResponse request with arbitrary body returning *TriggerCallbackResponse +func (c *ClientWithResponses) TriggerCallbackWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) { + rsp, err := c.TriggerCallbackWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTriggerCallbackResponse(rsp) +} + +func (c *ClientWithResponses) TriggerCallbackWithResponse(ctx context.Context, body TriggerCallbackJSONRequestBody, reqEditors ...RequestEditorFn) (*TriggerCallbackResponse, error) { + rsp, err := c.TriggerCallback(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseTriggerCallbackResponse(rsp) +} + // GetResponseDeepNestedWithResponse request returning *GetResponseDeepNestedResponse func (c *ClientWithResponses) GetResponseDeepNestedWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetResponseDeepNestedResponse, error) { rsp, err := c.GetResponseDeepNested(ctx, reqEditors...) @@ -1209,6 +1627,22 @@ func ParsePostBodyRootOneOfResponse(rsp *http.Response) (*PostBodyRootOneOfRespo return response, nil } +// ParseTriggerCallbackResponse parses an HTTP response from a TriggerCallbackWithResponse call +func ParseTriggerCallbackResponse(rsp *http.Response) (*TriggerCallbackResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &TriggerCallbackResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + // ParseGetResponseDeepNestedResponse parses an HTTP response from a GetResponseDeepNestedWithResponse call func ParseGetResponseDeepNestedResponse(rsp *http.Response) (*GetResponseDeepNestedResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -1318,3 +1752,378 @@ func ParseGetResponseRootOneOfResponse(rsp *http.Response) (*GetResponseRootOneO return response, nil } + +// WebhookInitiator sends OpenAPI 3.1 webhook requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a subscription registration). +type WebhookInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// WebhookInitiatorOption allows setting custom parameters during construction. +type WebhookInitiatorOption func(*WebhookInitiator) error + +// NewWebhookInitiator creates a new WebhookInitiator with reasonable defaults. +func NewWebhookInitiator(opts ...WebhookInitiatorOption) (*WebhookInitiator, error) { + initiator := WebhookInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithWebhookHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithWebhookHTTPClient(doer HttpRequestDoer) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.Client = doer + return nil + } +} + +// WithWebhookRequestEditorFn allows setting up a callback function, which +// will be called right before sending the webhook request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithWebhookRequestEditorFn(fn RequestEditorFn) WebhookInitiatorOption { + return func(p *WebhookInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *WebhookInitiator) applyWebhookEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// WebhookInitiatorInterface is the interface specification for the webhook initiator. +type WebhookInitiatorInterface interface { + // WebhookBodyPropertyOneOfWithBody fires the webhookBodyPropertyOneOf webhook with any body + WebhookBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + WebhookBodyPropertyOneOf(ctx context.Context, targetURL string, body WebhookBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // WebhookBodyRootOneOfWithBody fires the webhookBodyRootOneOf webhook with any body + WebhookBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + WebhookBodyRootOneOf(ctx context.Context, targetURL string, body WebhookBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *WebhookInitiator) WebhookBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyPropertyOneOfWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) WebhookBodyPropertyOneOf(ctx context.Context, targetURL string, body WebhookBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyPropertyOneOfWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) WebhookBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyRootOneOfWebhookRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *WebhookInitiator) WebhookBodyRootOneOf(ctx context.Context, targetURL string, body WebhookBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewWebhookBodyRootOneOfWebhookRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyWebhookEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewWebhookBodyPropertyOneOfWebhookRequest builds a application/json POST request for the webhookBodyPropertyOneOf webhook +func NewWebhookBodyPropertyOneOfWebhookRequest(targetURL string, body WebhookBodyPropertyOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewWebhookBodyPropertyOneOfWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewWebhookBodyPropertyOneOfWebhookRequestWithBody builds a POST request for the webhookBodyPropertyOneOf webhook with any body +func NewWebhookBodyPropertyOneOfWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewWebhookBodyRootOneOfWebhookRequest builds a application/json POST request for the webhookBodyRootOneOf webhook +func NewWebhookBodyRootOneOfWebhookRequest(targetURL string, body WebhookBodyRootOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewWebhookBodyRootOneOfWebhookRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewWebhookBodyRootOneOfWebhookRequestWithBody builds a POST request for the webhookBodyRootOneOf webhook with any body +func NewWebhookBodyRootOneOfWebhookRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// CallbackInitiator sends OpenAPI callback requests to target URLs. +// Modeled on the generated Client, but with no stored Server -- the full +// target URL is provided per-call by the caller (typically discovered +// from a callback expression on the parent operation's request body, +// e.g. `{$request.body#/callbackUrl}`). +type CallbackInitiator struct { + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// CallbackInitiatorOption allows setting custom parameters during construction. +type CallbackInitiatorOption func(*CallbackInitiator) error + +// NewCallbackInitiator creates a new CallbackInitiator with reasonable defaults. +func NewCallbackInitiator(opts ...CallbackInitiatorOption) (*CallbackInitiator, error) { + initiator := CallbackInitiator{} + for _, o := range opts { + if err := o(&initiator); err != nil { + return nil, err + } + } + if initiator.Client == nil { + initiator.Client = &http.Client{} + } + return &initiator, nil +} + +// WithCallbackHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithCallbackHTTPClient(doer HttpRequestDoer) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.Client = doer + return nil + } +} + +// WithCallbackRequestEditorFn allows setting up a callback function, which +// will be called right before sending the callback request. This can be +// used to mutate the request, e.g. to add signature headers. +func WithCallbackRequestEditorFn(fn RequestEditorFn) CallbackInitiatorOption { + return func(p *CallbackInitiator) error { + p.RequestEditors = append(p.RequestEditors, fn) + return nil + } +} + +func (p *CallbackInitiator) applyCallbackEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range p.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// CallbackInitiatorInterface is the interface specification for the callback initiator. +type CallbackInitiatorInterface interface { + // CallbackBodyPropertyOneOfWithBody fires the callbackBodyPropertyOneOf callback with any body + CallbackBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CallbackBodyPropertyOneOf(ctx context.Context, targetURL string, body CallbackBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // CallbackBodyRootOneOfWithBody fires the callbackBodyRootOneOf callback with any body + CallbackBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + CallbackBodyRootOneOf(ctx context.Context, targetURL string, body CallbackBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (p *CallbackInitiator) CallbackBodyPropertyOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyPropertyOneOfCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) CallbackBodyPropertyOneOf(ctx context.Context, targetURL string, body CallbackBodyPropertyOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyPropertyOneOfCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) CallbackBodyRootOneOfWithBody(ctx context.Context, targetURL string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyRootOneOfCallbackRequestWithBody(targetURL, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +func (p *CallbackInitiator) CallbackBodyRootOneOf(ctx context.Context, targetURL string, body CallbackBodyRootOneOfJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewCallbackBodyRootOneOfCallbackRequest(targetURL, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := p.applyCallbackEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return p.Client.Do(req) +} + +// NewCallbackBodyPropertyOneOfCallbackRequest builds a application/json POST request for the callbackBodyPropertyOneOf callback +func NewCallbackBodyPropertyOneOfCallbackRequest(targetURL string, body CallbackBodyPropertyOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCallbackBodyPropertyOneOfCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewCallbackBodyPropertyOneOfCallbackRequestWithBody builds a POST request for the callbackBodyPropertyOneOf callback with any body +func NewCallbackBodyPropertyOneOfCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewCallbackBodyRootOneOfCallbackRequest builds a application/json POST request for the callbackBodyRootOneOf callback +func NewCallbackBodyRootOneOfCallbackRequest(targetURL string, body CallbackBodyRootOneOfJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewCallbackBodyRootOneOfCallbackRequestWithBody(targetURL, "application/json", bodyReader) +} + +// NewCallbackBodyRootOneOfCallbackRequestWithBody builds a POST request for the callbackBodyRootOneOf callback with any body +func NewCallbackBodyRootOneOfCallbackRequestWithBody(targetURL string, contentType string, body io.Reader) (*http.Request, error) { + var err error + _ = err + + reqURL, err := url.Parse(targetURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, reqURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} diff --git a/internal/test/anonymous_inner_hoisting/client_test.go b/internal/test/anonymous_inner_hoisting/client_test.go index abdf532440..f390f72e00 100644 --- a/internal/test/anonymous_inner_hoisting/client_test.go +++ b/internal/test/anonymous_inner_hoisting/client_test.go @@ -121,6 +121,74 @@ func TestBodyPropertyOneOf_RoundTripDog(t *testing.T) { require.Equal(t, dog, got) } +func TestWebhookBodyRootOneOf_RoundTripCat(t *testing.T) { + cat := Cat{Kind: CatKindCat, Name: ptr("whiskers")} + + var body WebhookBodyRootOneOfJSONBody + require.NoError(t, body.FromCat(cat)) + + b, err := json.Marshal(body) + require.NoError(t, err) + + var decoded WebhookBodyRootOneOfJSONBody + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsCat() + require.NoError(t, err) + require.Equal(t, cat, got) +} + +func TestWebhookBodyPropertyOneOf_RoundTripDog(t *testing.T) { + dog := Dog{Kind: DogKindDog, Name: ptr("rex")} + + var pet WebhookBodyPropertyOneOfJSONBody_Pet + require.NoError(t, pet.FromDog(dog)) + + b, err := json.Marshal(pet) + require.NoError(t, err) + + var decoded WebhookBodyPropertyOneOfJSONBody_Pet + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsDog() + require.NoError(t, err) + require.Equal(t, dog, got) +} + +func TestCallbackBodyRootOneOf_RoundTripCat(t *testing.T) { + cat := Cat{Kind: CatKindCat, Name: ptr("whiskers")} + + var body CallbackBodyRootOneOfJSONBody + require.NoError(t, body.FromCat(cat)) + + b, err := json.Marshal(body) + require.NoError(t, err) + + var decoded CallbackBodyRootOneOfJSONBody + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsCat() + require.NoError(t, err) + require.Equal(t, cat, got) +} + +func TestCallbackBodyPropertyOneOf_RoundTripDog(t *testing.T) { + dog := Dog{Kind: DogKindDog, Name: ptr("rex")} + + var pet CallbackBodyPropertyOneOfJSONBody_Pet + require.NoError(t, pet.FromDog(dog)) + + b, err := json.Marshal(pet) + require.NoError(t, err) + + var decoded CallbackBodyPropertyOneOfJSONBody_Pet + require.NoError(t, json.Unmarshal(b, &decoded)) + + got, err := decoded.AsDog() + require.NoError(t, err) + require.Equal(t, dog, got) +} + func TestMergeOverwritesPriorBranch(t *testing.T) { cat := Cat{Kind: CatKindCat, Name: ptr("whiskers")} dog := Dog{Kind: DogKindDog, Name: ptr("rex")} diff --git a/internal/test/anonymous_inner_hoisting/spec.yaml b/internal/test/anonymous_inner_hoisting/spec.yaml index a6febff103..4590cb21e4 100644 --- a/internal/test/anonymous_inner_hoisting/spec.yaml +++ b/internal/test/anonymous_inner_hoisting/spec.yaml @@ -1,4 +1,4 @@ -openapi: 3.0.3 +openapi: 3.1.0 info: version: 1.0.0 title: Anonymous Inner Hoisting @@ -6,6 +6,10 @@ info: Exercises inline oneOf / anyOf schemas at every operation root and at nested positions. Each generated wrapper type should receive As() / From() / Merge() accessor methods. + + Coverage spans regular paths, OpenAPI 3.1 webhooks, and callbacks + so the hoisting pipeline is verified for all three operation + sources. paths: /response-root-oneof: @@ -106,6 +110,93 @@ paths: '200': description: ok + /callback-trigger: + post: + operationId: triggerCallback + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - callbackUrl + properties: + callbackUrl: + type: string + format: uri + responses: + '202': + description: accepted + callbacks: + callbackBodyRootOneOf: + '{$request.body#/callbackUrl}': + post: + operationId: callbackBodyRootOneOf + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + callbackBodyPropertyOneOf: + '{$request.body#/callbackUrl}': + post: + operationId: callbackBodyPropertyOneOf + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + +webhooks: + webhookBodyRootOneOf: + post: + operationId: webhookBodyRootOneOf + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + + webhookBodyPropertyOneOf: + post: + operationId: webhookBodyPropertyOneOf + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + pet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + responses: + '204': + description: ack + components: schemas: Cat: From 329414b81b7b5b238a74503a7a2be41d73e4ffbc Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sun, 3 May 2026 20:09:16 -0700 Subject: [PATCH 23/30] Fix issues due to rebasing onto main --- examples/callback/treefarm.gen.go | 7 ++++--- internal/test/issues/issue-1087/deps/deps.gen.go | 10 ++++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/examples/callback/treefarm.gen.go b/examples/callback/treefarm.gen.go index 7a48de14fd..fdbdfea3d5 100644 --- a/examples/callback/treefarm.gen.go +++ b/examples/callback/treefarm.gen.go @@ -36,7 +36,8 @@ type Tree struct { Location string `json:"location"` } -// TreePlantingRequest defines model for TreePlantingRequest. +// TreePlantingRequest A tree planting request, combining the tree details with a callback URL +// for completion notification. type TreePlantingRequest struct { // CallbackURL URL to receive the planting result callback CallbackURL string `json:"callbackUrl"` @@ -48,7 +49,7 @@ type TreePlantingRequest struct { Location string `json:"location"` } -// TreePlantingResult defines model for TreePlantingResult. +// TreePlantingResult The result of a tree planting operation, sent via callback type TreePlantingResult struct { // ID Unique identifier for this planting request ID openapi_types.UUID `json:"id"` @@ -63,7 +64,7 @@ type TreePlantingResult struct { Success bool `json:"success"` } -// TreeWithID defines model for TreeWithId. +// TreeWithID A tree with a server-assigned identifier type TreeWithID struct { // ID Unique identifier for this planting request ID openapi_types.UUID `json:"id"` diff --git a/internal/test/issues/issue-1087/deps/deps.gen.go b/internal/test/issues/issue-1087/deps/deps.gen.go index 7d02d738cd..5ef898b5ba 100644 --- a/internal/test/issues/issue-1087/deps/deps.gen.go +++ b/internal/test/issues/issue-1087/deps/deps.gen.go @@ -41,18 +41,28 @@ type BaseError struct { // Error defines model for Error. type Error struct { // Code The underlying http status code + // + // Example: 500 Code int32 `json:"code"` // Domain The domain where the error is originating from as defined by the service + // + // Example: Domain string `json:"domain"` // Message A simple message in english describing the error and can be returned to the consumer + // + // Example: Age cannot be less than 18 Message string `json:"message"` // Metadata Any additional details to be conveyed as determined by the service. If present, will return map of key value pairs + // + // Example: {"propertyName":"propertyName is required"} Metadata *map[string]string `json:"metadata,omitempty"` // Reason A reason code specific to the service and can be used to identify the exact issue. Should be unique within a domain + // + // Example: Reason_Code Reason string `json:"reason"` } From 9bb79e7034229979784503666dd87b463fcde651 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Mon, 18 May 2026 16:55:55 -0700 Subject: [PATCH 24/30] Replace kin Types.Is() usage with our own Types.Is() is too strict for out needs. We want Is("object") to return true for [object, "null"], but it's an exact match, replace this with our own alternative that behaves as we want. --- .../openapi31_nullable_test.go | 52 +++++++++++++++++++ .../test/openapi31_nullable/spec_3_0.yaml | 20 +++++-- .../openapi31_nullable/spec_3_0/types.gen.go | 10 +++- .../test/openapi31_nullable/spec_3_1.yaml | 19 +++++-- .../openapi31_nullable/spec_3_1/types.gen.go | 10 +++- pkg/codegen/codegen.go | 2 +- pkg/codegen/operations.go | 2 +- pkg/codegen/schema.go | 12 +++-- 8 files changed, 112 insertions(+), 15 deletions(-) diff --git a/internal/test/openapi31_nullable/openapi31_nullable_test.go b/internal/test/openapi31_nullable/openapi31_nullable_test.go index 0f8a375e66..550587e9f4 100644 --- a/internal/test/openapi31_nullable/openapi31_nullable_test.go +++ b/internal/test/openapi31_nullable/openapi31_nullable_test.go @@ -50,6 +50,58 @@ func TestNicknameIsPointer_3_1(t *testing.T) { assert.Nil(t, p2.Nickname) } +// TestNullableArrayAndObjectFields_3_1 asserts that nullable array and +// nullable inline-object fields generate as pointer-to-slice and +// pointer-to-struct respectively in 3.1. Regression for the case where +// `type: ["array","null"]` and `type: ["object","null"]` were rejected +// before schemaPrimaryType was applied at the GenerateGoSchema dispatch +// (see pkg/codegen/schema.go). +func TestNullableArrayAndObjectFields_3_1(t *testing.T) { + tags := []string{"good", "boy"} + id := "owner-1" + p := spec31.Pet{ + Name: "fluffy", + Tags: &tags, + Owner: &struct { + Id *string `json:"id,omitempty"` + }{Id: &id}, + } + require.NotNil(t, p.Tags) + assert.Equal(t, []string{"good", "boy"}, *p.Tags) + require.NotNil(t, p.Owner) + require.NotNil(t, p.Owner.Id) + assert.Equal(t, "owner-1", *p.Owner.Id) + + // Zero-value: nullable fields must be nil, not empty. + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.Tags) + assert.Nil(t, p2.Owner) +} + +// TestNullableArrayAndObjectFields_3_0 is the matching control case +// asserting that `nullable: true` arrays and inline objects in 3.0 +// generate the same pointer shape. +func TestNullableArrayAndObjectFields_3_0(t *testing.T) { + tags := []string{"good", "boy"} + id := "owner-1" + p := spec30.Pet{ + Name: "fluffy", + Tags: &tags, + Owner: &struct { + Id *string `json:"id,omitempty"` + }{Id: &id}, + } + require.NotNil(t, p.Tags) + assert.Equal(t, []string{"good", "boy"}, *p.Tags) + require.NotNil(t, p.Owner) + require.NotNil(t, p.Owner.Id) + assert.Equal(t, "owner-1", *p.Owner.Id) + + p2 := spec30.Pet{Name: "fluffy"} + assert.Nil(t, p2.Tags) + assert.Nil(t, p2.Owner) +} + // TestJsonRoundTrip_NullableFields_AcrossVersions asserts that a JSON // payload with an explicit null nickname unmarshals to (*string)(nil) in // both spec versions, and that JSON output omits the field when nil due diff --git a/internal/test/openapi31_nullable/spec_3_0.yaml b/internal/test/openapi31_nullable/spec_3_0.yaml index 9c39e38cab..7d7958eb70 100644 --- a/internal/test/openapi31_nullable/spec_3_0.yaml +++ b/internal/test/openapi31_nullable/spec_3_0.yaml @@ -3,8 +3,9 @@ info: title: Nullable test (OpenAPI 3.0) version: 1.0.0 description: | - Control case for the OpenAPI 3.1 nullable backport. The Nickname field - is nullable via the 3.0 idiom (the explicit `nullable: true` flag). + Control case for the OpenAPI 3.1 nullable backport. Each nullable + field uses the 3.0 idiom (`nullable: true`) so the generated Go + shape can be compared against the 3.1 type-array idiom. paths: {} components: schemas: @@ -19,4 +20,17 @@ components: nickname: type: string nullable: true - description: "Optional, nullable via 3.0 `nullable: true`." + description: "Optional, nullable scalar via 3.0 `nullable: true`." + tags: + type: array + nullable: true + items: + type: string + description: "Optional, nullable array." + owner: + type: object + nullable: true + properties: + id: + type: string + description: "Optional, nullable inline object." diff --git a/internal/test/openapi31_nullable/spec_3_0/types.gen.go b/internal/test/openapi31_nullable/spec_3_0/types.gen.go index f33f29cde9..f9b368bbc7 100644 --- a/internal/test/openapi31_nullable/spec_3_0/types.gen.go +++ b/internal/test/openapi31_nullable/spec_3_0/types.gen.go @@ -8,6 +8,14 @@ type Pet struct { // Name Required, non-nullable. Name string `json:"name"` - // Nickname Optional, nullable via 3.0 `nullable: true`. + // Nickname Optional, nullable scalar via 3.0 `nullable: true`. Nickname *string `json:"nickname,omitempty"` + + // Owner Optional, nullable inline object. + Owner *struct { + Id *string `json:"id,omitempty"` + } `json:"owner,omitempty"` + + // Tags Optional, nullable array. + Tags *[]string `json:"tags,omitempty"` } diff --git a/internal/test/openapi31_nullable/spec_3_1.yaml b/internal/test/openapi31_nullable/spec_3_1.yaml index 8c50f4baa3..4937fef4d8 100644 --- a/internal/test/openapi31_nullable/spec_3_1.yaml +++ b/internal/test/openapi31_nullable/spec_3_1.yaml @@ -3,9 +3,9 @@ info: title: Nullable test (OpenAPI 3.1) version: 1.0.0 description: | - The Nickname field is nullable via the 3.1 idiom (the type array - containing "null"). The generated Go must match the 3.0 control case - so callers see no behavioral difference between spec versions. + Each nullable field uses the 3.1 type-array idiom + (`type: ["X","null"]`). The generated Go must match the 3.0 control + case so callers see no behavioral difference between spec versions. paths: {} components: schemas: @@ -19,4 +19,15 @@ components: description: Required, non-nullable. nickname: type: ["string", "null"] - description: Optional, nullable via 3.1 type-array idiom. + description: Optional, nullable scalar via 3.1 type-array idiom. + tags: + type: ["array", "null"] + items: + type: string + description: Optional, nullable array. + owner: + type: ["object", "null"] + properties: + id: + type: string + description: Optional, nullable inline object. diff --git a/internal/test/openapi31_nullable/spec_3_1/types.gen.go b/internal/test/openapi31_nullable/spec_3_1/types.gen.go index d521ddb750..8bff4ec341 100644 --- a/internal/test/openapi31_nullable/spec_3_1/types.gen.go +++ b/internal/test/openapi31_nullable/spec_3_1/types.gen.go @@ -8,6 +8,14 @@ type Pet struct { // Name Required, non-nullable. Name string `json:"name"` - // Nickname Optional, nullable via 3.1 type-array idiom. + // Nickname Optional, nullable scalar via 3.1 type-array idiom. Nickname *string `json:"nickname,omitempty"` + + // Owner Optional, nullable inline object. + Owner *struct { + Id *string `json:"id,omitempty"` + } `json:"owner,omitempty"` + + // Tags Optional, nullable array. + Tags *[]string `json:"tags,omitempty"` } diff --git a/pkg/codegen/codegen.go b/pkg/codegen/codegen.go index f0eed7baaf..389247223f 100644 --- a/pkg/codegen/codegen.go +++ b/pkg/codegen/codegen.go @@ -1689,7 +1689,7 @@ func GoSchemaImports(schemas ...*openapi3.SchemaRef) (map[string]goImport, error } schemaVal := sref.Value - t := schemaVal.Type + t := schemaPrimaryType(schemaVal.Type) if t.Slice() == nil || t.Is("object") { for _, v := range schemaVal.Properties { imprts, err := GoSchemaImports(v) diff --git a/pkg/codegen/operations.go b/pkg/codegen/operations.go index 8d1e74aae6..7fde7f0a68 100644 --- a/pkg/codegen/operations.go +++ b/pkg/codegen/operations.go @@ -62,7 +62,7 @@ func (pd ParameterDefinition) ZeroValueIsNil() bool { return false } - if pd.Schema.OAPISchema.Type.Is("array") { + if schemaPrimaryType(pd.Schema.OAPISchema.Type).Is("array") { return true } diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 1b63d79b5e..48a695b2ea 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -51,7 +51,7 @@ func (s Schema) IsPrimitive() bool { if s.OAPISchema == nil { return false } - t := s.OAPISchema.Type + t := schemaPrimaryType(s.OAPISchema.Type) return t.Is("string") || t.Is("integer") || t.Is("number") || t.Is("boolean") } @@ -190,7 +190,7 @@ func (p Property) ZeroValueIsNil() bool { return false } - if p.Schema.OAPISchema.Type.Is("array") { + if schemaPrimaryType(p.Schema.OAPISchema.Type).Is("array") { return true } @@ -680,8 +680,12 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { return outSchema, nil } - // Schema type and format, eg. string / binary - t := schema.Type + // Schema type and format, eg. string / binary. In OpenAPI 3.1, `type` + // may include "null" alongside the primary type to express nullability; + // strip "null" up front so the object / fall-through dispatch sees the + // underlying type. Nullability itself is captured by schemaIsNullable() + // at the call sites that wrap the result in a pointer. + t := schemaPrimaryType(schema.Type) // Handle objects and empty schemas first as a special case if t.Slice() == nil || t.Is("object") { var outType string From 3bd0c88c88a2d6011c39bc328fa49f06e3cadbdd Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 19 Jun 2026 15:43:12 -0700 Subject: [PATCH 25/30] Add test coverage for nullables --- .../openapi31_nullable_test.go | 43 +++++++++++++++++++ .../test/openapi31_nullable/spec_3_0.yaml | 7 +++ .../openapi31_nullable/spec_3_0/types.gen.go | 5 +++ .../test/openapi31_nullable/spec_3_1.yaml | 16 +++++++ .../openapi31_nullable/spec_3_1/types.gen.go | 14 ++++++ 5 files changed, 85 insertions(+) diff --git a/internal/test/openapi31_nullable/openapi31_nullable_test.go b/internal/test/openapi31_nullable/openapi31_nullable_test.go index 550587e9f4..aa995c3dcc 100644 --- a/internal/test/openapi31_nullable/openapi31_nullable_test.go +++ b/internal/test/openapi31_nullable/openapi31_nullable_test.go @@ -102,6 +102,49 @@ func TestNullableArrayAndObjectFields_3_0(t *testing.T) { assert.Nil(t, p2.Owner) } +// TestNullableUnspecifiedObject_3_1 asserts that a bare nullable +// object (`type: ["object","null"]` with no `properties:`) generates +// as `*map[string]interface{}`. This is the gap flagged in the +// kin-openapi-3.1 PR review: `Schema.Is(...)` strict equality on a +// 3.1 type-array failed to recognize the primary type as "object", +// routing the schema away from the unspecified-object code path. +// Also asserts that the reversed type-array order +// (`type: ["null","object"]`) resolves identically -- no code path +// may peek only at the first element of the array. +func TestNullableUnspecifiedObject_3_1(t *testing.T) { + extras := map[string]interface{}{"k": "v"} + metadata := map[string]interface{}{"j": float64(1)} + p := spec31.Pet{ + Name: "fluffy", + Extras: &extras, + Metadata: &metadata, + } + require.NotNil(t, p.Extras) + assert.Equal(t, "v", (*p.Extras)["k"]) + require.NotNil(t, p.Metadata) + assert.Equal(t, float64(1), (*p.Metadata)["j"]) + + // Zero-value: both unspecified-object fields must be nil. + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.Extras) + assert.Nil(t, p2.Metadata) +} + +// TestNullableUnspecifiedObject_3_0 is the matching 3.0 control case +// asserting parity with the 3.1 type-array form. +func TestNullableUnspecifiedObject_3_0(t *testing.T) { + extras := map[string]interface{}{"k": "v"} + p := spec30.Pet{ + Name: "fluffy", + Extras: &extras, + } + require.NotNil(t, p.Extras) + assert.Equal(t, "v", (*p.Extras)["k"]) + + p2 := spec30.Pet{Name: "fluffy"} + assert.Nil(t, p2.Extras) +} + // TestJsonRoundTrip_NullableFields_AcrossVersions asserts that a JSON // payload with an explicit null nickname unmarshals to (*string)(nil) in // both spec versions, and that JSON output omits the field when nil due diff --git a/internal/test/openapi31_nullable/spec_3_0.yaml b/internal/test/openapi31_nullable/spec_3_0.yaml index 7d7958eb70..8ede3dbc17 100644 --- a/internal/test/openapi31_nullable/spec_3_0.yaml +++ b/internal/test/openapi31_nullable/spec_3_0.yaml @@ -34,3 +34,10 @@ components: id: type: string description: "Optional, nullable inline object." + extras: + type: object + nullable: true + description: | + Optional, nullable *unspecified* object (no `properties:`). + 3.0 control case for the 3.1 `type: ["object","null"]` + equivalent; expected shape: `*map[string]interface{}`. diff --git a/internal/test/openapi31_nullable/spec_3_0/types.gen.go b/internal/test/openapi31_nullable/spec_3_0/types.gen.go index f9b368bbc7..b9dd7df8ee 100644 --- a/internal/test/openapi31_nullable/spec_3_0/types.gen.go +++ b/internal/test/openapi31_nullable/spec_3_0/types.gen.go @@ -5,6 +5,11 @@ package spec_3_0 // Pet defines model for Pet. type Pet struct { + // Extras Optional, nullable *unspecified* object (no `properties:`). + // 3.0 control case for the 3.1 `type: ["object","null"]` + // equivalent; expected shape: `*map[string]interface{}`. + Extras *map[string]interface{} `json:"extras,omitempty"` + // Name Required, non-nullable. Name string `json:"name"` diff --git a/internal/test/openapi31_nullable/spec_3_1.yaml b/internal/test/openapi31_nullable/spec_3_1.yaml index 4937fef4d8..194333be72 100644 --- a/internal/test/openapi31_nullable/spec_3_1.yaml +++ b/internal/test/openapi31_nullable/spec_3_1.yaml @@ -31,3 +31,19 @@ components: id: type: string description: Optional, nullable inline object. + extras: + type: ["object", "null"] + description: | + Optional, nullable *unspecified* object (no `properties:`). + Regression for the gap where `Schema.Is(...)` strict + equality on a 3.1 type-array failed to recognize the + primary type as "object", routing the schema away from + the unspecified-object code path. Expected shape: + `*map[string]interface{}`. + metadata: + type: ["null", "object"] + description: | + Same as `extras` but with the type-array order reversed. + Both orderings must resolve identically; this guards + against any code path that inspects only the first + element of the type array. diff --git a/internal/test/openapi31_nullable/spec_3_1/types.gen.go b/internal/test/openapi31_nullable/spec_3_1/types.gen.go index 8bff4ec341..08e2cd6b02 100644 --- a/internal/test/openapi31_nullable/spec_3_1/types.gen.go +++ b/internal/test/openapi31_nullable/spec_3_1/types.gen.go @@ -5,6 +5,20 @@ package spec_3_1 // Pet defines model for Pet. type Pet struct { + // Extras Optional, nullable *unspecified* object (no `properties:`). + // Regression for the gap where `Schema.Is(...)` strict + // equality on a 3.1 type-array failed to recognize the + // primary type as "object", routing the schema away from + // the unspecified-object code path. Expected shape: + // `*map[string]interface{}`. + Extras *map[string]interface{} `json:"extras,omitempty"` + + // Metadata Same as `extras` but with the type-array order reversed. + // Both orderings must resolve identically; this guards + // against any code path that inspects only the first + // element of the type array. + Metadata *map[string]interface{} `json:"metadata,omitempty"` + // Name Required, non-nullable. Name string `json:"name"` From 214cb59090943f06794d4cbe7326158c893e1c57 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Fri, 19 Jun 2026 23:53:38 -0700 Subject: [PATCH 26/30] codegen: hoist inline-object array items under generate-types-for-anonymous-schemas The new `output-options.generate-types-for-anonymous-schemas` flag is supposed to name every inline schema that would otherwise become an anonymous Go struct. For top-level array schemas whose items are an inline object, this guarantee was not being met -- the generated type was still `type Foo = []struct { ... }`. The auto-hoist block inside `GenerateGoSchema` (schema.go:679) is gated on `len(path) > 1` so top-level component schemas don't trigger it (they already have their own names). The array-items recursive call at schema.go:1027 passes `path` unchanged to `GenerateGoSchema`, so for a top-level array schema the path length stays at 1 and the auto-hoist declines to fire on the inline-object items. The existing array-item hoist further down only fires for items with `additionalProperties` or `oneOf`/`anyOf`, leaving plain inline objects to fall through. Fix: extend the existing array-item hoist condition (schema.go:1032) with a third clause matching the auto-hoist's predicate -- if the flag is on and the items schema has `properties`, hoist it to a named element type. The naming uses the same path + "Item" convention the hoist has always used for unions and additionalProperties items, so a top-level `Roles` array of inline-object items emits as `type Roles_Item struct {...}` and `type Roles = []Roles_Item`. For arrays nested inside other schemas (path length >= 2), the auto-hoist already fires inside the items recursive call and sets `RefType`, so the new clause's `RefType == ""` guard correctly skips the second hoist -- no double-emission or naming conflict. Regression coverage: the existing global anonymous_inner_hoisting fixture gains a `Roles` component (array of inline objects) and a `GET /roles` operation that returns it. The operation reference keeps the default prune pass from stripping the component before codegen sees it. A new test asserts that the element type is named `Roles_Item` by constructing slice literals of the named type -- without the fix, the test does not compile because the generator inlines the element struct. Closes Greptile P1 review comment on pkg/codegen/schema.go:1027-1032. --- .../global/client.gen.go | 135 ++++++++++++++++++ .../global/client_test.go | 16 +++ .../anonymous_inner_hoisting/global/spec.yaml | 32 +++++ pkg/codegen/schema.go | 11 +- 4 files changed, 192 insertions(+), 2 deletions(-) diff --git a/internal/test/anonymous_inner_hoisting/global/client.gen.go b/internal/test/anonymous_inner_hoisting/global/client.gen.go index 40908af96d..5dbaaf23ed 100644 --- a/internal/test/anonymous_inner_hoisting/global/client.gen.go +++ b/internal/test/anonymous_inner_hoisting/global/client.gen.go @@ -21,6 +21,15 @@ type Role struct { Name string `json:"name"` } +// Roles defines model for Roles. +type Roles = []Roles_Item + +// Roles_Item defines model for Roles.Item. +type Roles_Item struct { + Id int `json:"id"` + Name string `json:"name"` +} + // SuccessfulResponse defines model for SuccessfulResponse. type SuccessfulResponse struct { // Data If successful, response from api @@ -117,10 +126,26 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption { // The interface specification for the client above. type ClientInterface interface { + // GetRoles performs a GET /roles (the `GetRoles` operationId) request. + GetRoles(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetRolesId performs a GET /roles/{id} (the `GetRolesId` operationId) request. GetRolesId(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*http.Response, error) } +// GetRoles performs a GET /roles (the `GetRoles` operationId) request. +func (c *Client) GetRoles(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetRolesRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + // GetRolesId performs a GET /roles/{id} (the `GetRolesId` operationId) request. func (c *Client) GetRolesId(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetRolesIdRequest(c.Server, id) @@ -134,6 +159,33 @@ func (c *Client) GetRolesId(ctx context.Context, id int, reqEditors ...RequestEd return c.Client.Do(req) } +// NewGetRolesRequest constructs an http.Request for the GetRoles method +func NewGetRolesRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/roles") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewGetRolesIdRequest constructs an http.Request for the GetRolesId method func NewGetRolesIdRequest(server string, id int) (*http.Request, error) { var err error @@ -212,12 +264,58 @@ func WithBaseURL(baseURL string) ClientOption { // ClientWithResponsesInterface is the interface specification for the client with responses above. type ClientWithResponsesInterface interface { + // GetRolesWithResponse performs a GET /roles (the `GetRoles` operationId) request. + // + // Returns a wrapper object for the known response body format(s). + GetRolesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetRolesResponse, error) + // GetRolesIdWithResponse performs a GET /roles/{id} (the `GetRolesId` operationId) request. // // Returns a wrapper object for the known response body format(s). GetRolesIdWithResponse(ctx context.Context, id int, reqEditors ...RequestEditorFn) (*GetRolesIdResponse, error) } +type GetRolesResponse struct { + Body []byte + HTTPResponse *http.Response + // JSON200 the response for an HTTP 200 `application/json` response + JSON200 *Roles +} + +// GetJSON200 returns the response for an HTTP 200 `application/json` response +func (r GetRolesResponse) GetJSON200() *Roles { + return r.JSON200 +} + +// GetBody returns the raw response body bytes +func (r GetRolesResponse) GetBody() []byte { + return r.Body +} + +// Status returns HTTPResponse.Status +func (r GetRolesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetRolesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetRolesResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type GetRolesIdResponse struct { Body []byte HTTPResponse *http.Response @@ -259,6 +357,17 @@ func (r GetRolesIdResponse) ContentType() string { return "" } +// GetRolesWithResponse performs a GET /roles (the `GetRoles` operationId) request. +// +// Returns a wrapper object for the known response body format(s). +func (c *ClientWithResponses) GetRolesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetRolesResponse, error) { + rsp, err := c.GetRoles(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetRolesResponse(rsp) +} + // GetRolesIdWithResponse performs a GET /roles/{id} (the `GetRolesId` operationId) request. // // Returns a wrapper object for the known response body format(s). @@ -270,6 +379,32 @@ func (c *ClientWithResponses) GetRolesIdWithResponse(ctx context.Context, id int return ParseGetRolesIdResponse(rsp) } +// ParseGetRolesResponse parses an HTTP response from a GetRolesWithResponse call +func ParseGetRolesResponse(rsp *http.Response) (*GetRolesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetRolesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest Roles + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + } + + return response, nil +} + // ParseGetRolesIdResponse parses an HTTP response from a GetRolesIdWithResponse call func ParseGetRolesIdResponse(rsp *http.Response) (*GetRolesIdResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/test/anonymous_inner_hoisting/global/client_test.go b/internal/test/anonymous_inner_hoisting/global/client_test.go index b6cf9fb9af..6e0a5c2678 100644 --- a/internal/test/anonymous_inner_hoisting/global/client_test.go +++ b/internal/test/anonymous_inner_hoisting/global/client_test.go @@ -42,3 +42,19 @@ func TestHoistedTypesRoundTrip(t *testing.T) { require.NoError(t, json.Unmarshal(encoded, &decoded)) assert.Equal(t, body, decoded) } + +// TestArrayOfInlineObjectsHoisted verifies that a top-level array schema +// whose items are an inline object emits a named element type under the +// flag, instead of `type Roles = []struct{...}`. The element type's name +// is Roles_Item (path + "Item" suffix, matching the existing array-item +// hoist convention for unions and additionalProperties). +func TestArrayOfInlineObjectsHoisted(t *testing.T) { + // Compile-time assertion: Roles is a slice whose element type is the + // named Roles_Item type, not an anonymous struct. + roles := Roles{ + Roles_Item{Id: 1, Name: "admin"}, + Roles_Item{Id: 2, Name: "user"}, + } + require.Len(t, roles, 2) + assert.Equal(t, "admin", roles[0].Name) +} diff --git a/internal/test/anonymous_inner_hoisting/global/spec.yaml b/internal/test/anonymous_inner_hoisting/global/spec.yaml index 884f9369f3..31e28651dd 100644 --- a/internal/test/anonymous_inner_hoisting/global/spec.yaml +++ b/internal/test/anonymous_inner_hoisting/global/spec.yaml @@ -31,6 +31,20 @@ paths: role: $ref: '#/components/schemas/Role' required: [ role ] + # Reference the top-level `Roles` array schema from an operation so + # the default prune pass doesn't strip it before codegen runs. + /roles: + get: + operationId: GetRoles + tags: + - role + responses: + '200': + description: All roles + content: + application/json: + schema: + $ref: '#/components/schemas/Roles' components: schemas: @@ -57,3 +71,21 @@ components: required: - id - name + # Regression for: top-level array of inline objects. Under + # generate-types-for-anonymous-schemas, this must emit a named + # element type (Roles_Item) rather than `type Roles = []struct{...}`. + # The auto-hoist inside GenerateGoSchema is gated on len(path)>1, + # which fails for a top-level component path, so the array-item hoist + # in oapiSchemaToGoType has to cover this case. + Roles: + type: array + items: + type: object + properties: + id: + type: integer + name: + type: string + required: + - id + - name diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index a6df3838ce..baba787df3 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -1029,11 +1029,18 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem return fmt.Errorf("error generating type for array: %w", err) } - if (arrayType.HasAdditionalProperties || len(arrayType.UnionElements) != 0) && arrayType.RefType == "" { + if (arrayType.HasAdditionalProperties || + len(arrayType.UnionElements) != 0 || + (globalState.options.OutputOptions.GenerateTypesForAnonymousSchemas && len(arrayType.Properties) > 0)) && + arrayType.RefType == "" { // If we have items which have additional properties or union values, // but are not a pre-defined type, we need to define a type // for them, which will be based on the field names we followed - // to get to the type. + // to get to the type. The third clause catches plain inline + // object items under generate-types-for-anonymous-schemas: the + // auto-hoist block in GenerateGoSchema only fires when + // len(path) > 1, so top-level array schemas (path length 1) fall + // through with their items left anonymous unless we hoist here. typeName := PathToTypeName(append(path, "Item")) typeDef := TypeDefinition{ From 2d6387e66a0c55a85e13b4dd0e27bd2e85122bdc Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 20 Jun 2026 00:00:49 -0700 Subject: [PATCH 27/30] codegen: treat `{"type": "null"}` branches in anyOf/oneOf as nullability markers OpenAPI 3.1 supports two equivalent idioms for expressing nullability on a schema: 1. `type: ["string", "null"]` (the type-array idiom) 2. `anyOf: [{type: string}, {type: "null"}]` (the union idiom) The first form has worked since the kin-openapi-3.1 branch was opened. The second form crashed the code generator with: error generating type for ...: unhandled Schema type: &[null] `generateUnion` (schema.go) walks each `anyOf`/`oneOf` element and calls `GenerateGoSchema` on it. For a bare `{"type": "null"}` branch, the schema's Type slice is exactly `["null"]`. `schemaPrimaryType` only strips "null" when the slice has more than one element, so the single-element `["null"]` survives. Inside the primitive-type dispatch in `oapiSchemaToGoType`, none of the type branches (string, integer, number, boolean, array, object) handles "null" alone, so the function falls through to the `unhandled Schema type` error. Fix has three parts: 1. `isNullTypeSchema` helper: predicate for a bare `{"type": "null"}` schema (type slice is exactly `["null"]`). 2. `schemaIsNullable` extension: in addition to checking whether the outer type array includes "null", inspect anyOf/oneOf for null-only branches. This lets the nullability flow through to call sites that wrap the result in a pointer (or `nullable.Nullable[T]`) regardless of which idiom the spec author used. 3. `generateUnion` collapse: after filtering null-only branches, if exactly one effective branch remains and there's no discriminator, treat the schema as that single branch rather than wrapping it in a one-variant union type. Together with the schemaIsNullable extension, this makes the two idioms produce identical Go shapes -- `anyOf: [{type: string}, {type: "null"}]` and `type: ["string", "null"]` both emit a `*string` field, not a `Pet_NicknameAnyOf` wrapper struct with a `FromX`/`AsX` accessor API for a single variant. The collapse is gated on the original anyOf/oneOf having contained a null branch so behavior is unchanged for pre-existing single-branch anyOf specs that may rely on the wrapper shape. A small companion guard in `GenerateGoSchema` skips the `GenStructFromSchema` overwrite when the collapse cleared the struct-shaped fields (Properties, AdditionalProperties, UnionElements all empty); without it the primitive GoType the collapse set would be clobbered by an empty `struct {}` literal. Regression coverage: two new properties on the OpenAPI 3.1 Pet schema in internal/test/openapi31_nullable/ -- one using `anyOf: [{type: string}, {type: "null"}]` and one using the matching `oneOf` form. The test asserts the fields are `*string` (compile-time check via `&nick` assignment where `nick` is a `string`) and that JSON round-trip semantics match those of the existing type-array `nickname` field. --- .../openapi31_nullable_test.go | 44 ++++++++ .../test/openapi31_nullable/spec_3_1.yaml | 15 +++ .../openapi31_nullable/spec_3_1/types.gen.go | 10 ++ pkg/codegen/schema.go | 104 +++++++++++++++++- 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/internal/test/openapi31_nullable/openapi31_nullable_test.go b/internal/test/openapi31_nullable/openapi31_nullable_test.go index aa995c3dcc..6ffbbeb0fc 100644 --- a/internal/test/openapi31_nullable/openapi31_nullable_test.go +++ b/internal/test/openapi31_nullable/openapi31_nullable_test.go @@ -145,6 +145,50 @@ func TestNullableUnspecifiedObject_3_0(t *testing.T) { assert.Nil(t, p2.Extras) } +// TestNullableViaAnyOfOneOf_3_1 asserts that `anyOf: [{type: string}, +// {type: "null"}]` and the matching `oneOf` form both generate as +// `*string`, identical to the type-array idiom (`type: ["string", +// "null"]`). The `{"type": "null"}` branch is a nullability marker and +// must be skipped during union generation -- before the fix, the +// recursive GenerateGoSchema call on the null-only branch failed with +// `unhandled Schema type: &[null]`. And once null is filtered out, +// the single remaining branch must be collapsed to its underlying +// type instead of being wrapped in a one-variant union, so the two +// idioms produce the same Go API surface. +func TestNullableViaAnyOfOneOf_3_1(t *testing.T) { + nick := "rex" + + // Compile-time check: the AnyOf and OneOf fields must be *string + // (assignment of &nick succeeds only for a pointer-to-string field). + p := spec31.Pet{ + Name: "fluffy", + NicknameAnyOf: &nick, + NicknameOneOf: &nick, + } + require.NotNil(t, p.NicknameAnyOf) + require.NotNil(t, p.NicknameOneOf) + assert.Equal(t, "rex", *p.NicknameAnyOf) + assert.Equal(t, "rex", *p.NicknameOneOf) + + // Zero-value: both nullable fields must be nil. + p2 := spec31.Pet{Name: "fluffy"} + assert.Nil(t, p2.NicknameAnyOf) + assert.Nil(t, p2.NicknameOneOf) + + // JSON round-trip: an explicit string in / explicit string out; + // missing field decodes to nil and re-encodes as absent (omitempty). + const populated = `{"name":"fluffy","nicknameAnyOf":"rex","nicknameOneOf":"rex"}` + encoded, err := json.Marshal(p) + require.NoError(t, err) + assert.JSONEq(t, populated, string(encoded)) + + const empty = `{"name":"fluffy"}` + var p3 spec31.Pet + require.NoError(t, json.Unmarshal([]byte(empty), &p3)) + assert.Nil(t, p3.NicknameAnyOf) + assert.Nil(t, p3.NicknameOneOf) +} + // TestJsonRoundTrip_NullableFields_AcrossVersions asserts that a JSON // payload with an explicit null nickname unmarshals to (*string)(nil) in // both spec versions, and that JSON output omits the field when nil due diff --git a/internal/test/openapi31_nullable/spec_3_1.yaml b/internal/test/openapi31_nullable/spec_3_1.yaml index 194333be72..1ee1f472bb 100644 --- a/internal/test/openapi31_nullable/spec_3_1.yaml +++ b/internal/test/openapi31_nullable/spec_3_1.yaml @@ -47,3 +47,18 @@ components: Both orderings must resolve identically; this guards against any code path that inspects only the first element of the type array. + nicknameAnyOf: + anyOf: + - type: string + - type: "null" + description: | + OpenAPI 3.1: a `{"type": "null"}` branch in `anyOf` is a + nullability marker, not a separate union variant. Should + generate the same shape as `type: ["string","null"]` (i.e. + `*string`). Regression for a previous crash with + "unhandled Schema type: &[null]". + nicknameOneOf: + oneOf: + - type: string + - type: "null" + description: Same as `nicknameAnyOf` but using `oneOf`. diff --git a/internal/test/openapi31_nullable/spec_3_1/types.gen.go b/internal/test/openapi31_nullable/spec_3_1/types.gen.go index 08e2cd6b02..61fcd8b410 100644 --- a/internal/test/openapi31_nullable/spec_3_1/types.gen.go +++ b/internal/test/openapi31_nullable/spec_3_1/types.gen.go @@ -25,6 +25,16 @@ type Pet struct { // Nickname Optional, nullable scalar via 3.1 type-array idiom. Nickname *string `json:"nickname,omitempty"` + // NicknameAnyOf OpenAPI 3.1: a `{"type": "null"}` branch in `anyOf` is a + // nullability marker, not a separate union variant. Should + // generate the same shape as `type: ["string","null"]` (i.e. + // `*string`). Regression for a previous crash with + // "unhandled Schema type: &[null]". + NicknameAnyOf *string `json:"nicknameAnyOf,omitempty"` + + // NicknameOneOf Same as `nicknameAnyOf` but using `oneOf`. + NicknameOneOf *string `json:"nicknameOneOf,omitempty"` + // Owner Optional, nullable inline object. Owner *struct { Id *string `json:"id,omitempty"` diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index baba787df3..a79b30bbf7 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -389,11 +389,43 @@ func schemaIsNullable(s *openapi3.Schema) bool { return false } if globalState.is31 { - return s.Type != nil && s.Type.Includes("null") + if s.Type != nil && s.Type.Includes("null") { + return true + } + // OpenAPI 3.1 also allows nullability to be expressed via an + // `anyOf` or `oneOf` branch whose only type is "null", which is + // semantically equivalent to including "null" in the outer + // type array. Detect it here so downstream code that wraps in a + // pointer (or nullable.Nullable[T]) reaches the right decision. + for _, branch := range s.AnyOf { + if branch != nil && isNullTypeSchema(branch.Value) { + return true + } + } + for _, branch := range s.OneOf { + if branch != nil && isNullTypeSchema(branch.Value) { + return true + } + } + return false } return s.Nullable } +// isNullTypeSchema reports whether an OpenAPI 3.1 schema is a bare +// `{"type": "null"}` -- i.e. a schema whose only type is "null" and +// which is otherwise empty of constraints. Used to detect the +// nullability-via-anyOf idiom in `schemaIsNullable` and to filter such +// branches out of `generateUnion` (they're nullability markers, not +// union variants for which we need a Go type). +func isNullTypeSchema(s *openapi3.Schema) bool { + if s == nil || s.Type == nil { + return false + } + slice := s.Type.Slice() + return len(slice) == 1 && slice[0] == "null" +} + // enumViaOneOfValue is one branch of an OpenAPI 3.1 enum-via-oneOf schema. // Title is the per-branch identifier (becomes the Go constant name); Value // is the stringified `const` (the Go literal, unquoted; the enum @@ -870,7 +902,15 @@ func GenerateGoSchema(sref *openapi3.SchemaRef, path []string) (Schema, error) { } } - outSchema.GoType = GenStructFromSchema(outSchema) + // Only generate a struct literal if the schema actually has + // struct content. When `generateUnion` collapses a one- + // element nullable union (`anyOf: [{type: X}, {type: "null"}]`) + // down to the bare X branch, it sets outSchema.GoType to the + // primitive's Go type and clears the struct-shaped fields; + // rebuilding `struct {}` here would clobber that. + if len(outSchema.Properties) > 0 || outSchema.HasAdditionalProperties || len(outSchema.UnionElements) > 0 { + outSchema.GoType = GenStructFromSchema(outSchema) + } } // Check for x-go-type-name. It behaves much like x-go-type, however, it will @@ -1303,8 +1343,68 @@ func generateUnion(outSchema *Schema, elements openapi3.SchemaRefs, discriminato } } + // First pass: count effective (non-null) branches. In OpenAPI 3.1, a + // bare `{"type": "null"}` branch in anyOf/oneOf is a nullability + // marker, not a real union variant -- there's no Go type that + // corresponds to "only the JSON value null". The parent schema's + // nullability is captured by schemaIsNullable, which inspects + // anyOf/oneOf for the same idiom and wraps the result in a pointer + // at the call site. + effectiveCount := 0 + hadNullBranch := false + var soleEffective *openapi3.SchemaRef + for _, e := range elements { + if e != nil && isNullTypeSchema(e.Value) { + hadNullBranch = true + continue + } + effectiveCount++ + if soleEffective == nil { + soleEffective = e + } + } + + // Collapse: if filtering out null branches leaves exactly one + // effective branch and there is no discriminator, the schema is + // semantically equivalent to that single branch (made nullable by + // the original null branch). Produce the same Go shape the + // type-array idiom would: `anyOf: [{type: string}, {type: "null"}]` + // must generate the same `*string` field as `type: ["string", + // "null"]`. Without this, the single remaining branch would be + // wrapped in a one-variant union type, exposing a needless + // `FromX`/`AsX` accessor API. + // + // We do not collapse when there was no null branch (`anyOf: [{type: + // X}]` alone) to avoid changing behavior for existing single-branch + // union specs that may rely on the wrapper shape. The narrow + // condition keeps this change scoped to the bug fix. + if effectiveCount == 1 && hadNullBranch && discriminator == nil { + elementSchema, err := GenerateGoSchema(soleEffective, path) + if err != nil { + return err + } + // Inherit the single branch's underlying representation. The + // caller will apply nullability (schemaIsNullable returns true + // because the original anyOf/oneOf contained a null branch). + outSchema.GoType = elementSchema.GoType + outSchema.RefType = elementSchema.RefType + outSchema.DefineViaAlias = elementSchema.DefineViaAlias + outSchema.Properties = elementSchema.Properties + outSchema.HasAdditionalProperties = elementSchema.HasAdditionalProperties + outSchema.AdditionalPropertiesType = elementSchema.AdditionalPropertiesType + outSchema.ArrayType = elementSchema.ArrayType + outSchema.SkipOptionalPointer = elementSchema.SkipOptionalPointer + outSchema.AdditionalTypes = append(outSchema.AdditionalTypes, elementSchema.AdditionalTypes...) + return nil + } + refToGoTypeMap := make(map[string]string) for i, element := range elements { + // Skip null-only branches: nullability marker, not a real + // union variant. See the collapse comment above for context. + if element != nil && isNullTypeSchema(element.Value) { + continue + } elementPath := append(path, fmt.Sprint(i)) elementSchema, err := GenerateGoSchema(element, elementPath) if err != nil { From 2f9bfe2237e74030290fe3854a8c3b1b058c69dd Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 20 Jun 2026 00:22:30 -0700 Subject: [PATCH 28/30] codegen: compare discriminator mapping count against effective branches, not raw element count The null-branch skip introduced in 2d6387e6 (`codegen: treat {"type": "null"} branches in anyOf/oneOf as nullability markers`) filters bare null-type branches out of the union loop, but the completeness check at the end still compared against `len(elements)` -- the raw input count, which includes the skipped null branches. A valid OpenAPI 3.1 nullable discriminated union like oneOf: - $ref: '#/components/schemas/Cat' - $ref: '#/components/schemas/Dog' - type: "null" discriminator: propertyName: kind would map the two real branches (Cat, Dog) but then fail generation with `discriminator: not all schemas were mapped` because the check saw `len(Mapping) == 2 < len(elements) == 3`. The null branch is a nullability marker, not a real variant that needs a discriminator mapping. Fix: compare against the `effectiveCount` already tracked for the collapse path -- the number of non-null branches actually processed by the loop. With this, nullable discriminated unions complete generation; non-nullable unions are unchanged (effectiveCount equals len(elements) when there are no null branches). Regression coverage: a `DiscriminatedPet` schema is added to the existing openapi31_nullable fixture as a Cat/Dog/null discriminated union, exposed via a new `favorite` property on Pet so it's not pruned. A new test asserts that JSON payloads for both Cat and Dog round-trip through the generated discriminated-union accessors; without the fix, codegen fails before the test can run. Closes Greptile P1 review comment on pkg/codegen/schema.go:1449. --- .../openapi31_nullable_test.go | 32 +++++ .../test/openapi31_nullable/spec_3_1.yaml | 35 +++++ .../openapi31_nullable/spec_3_1/types.gen.go | 122 ++++++++++++++++++ pkg/codegen/schema.go | 9 +- 4 files changed, 197 insertions(+), 1 deletion(-) diff --git a/internal/test/openapi31_nullable/openapi31_nullable_test.go b/internal/test/openapi31_nullable/openapi31_nullable_test.go index 6ffbbeb0fc..22ad21ef9c 100644 --- a/internal/test/openapi31_nullable/openapi31_nullable_test.go +++ b/internal/test/openapi31_nullable/openapi31_nullable_test.go @@ -189,6 +189,38 @@ func TestNullableViaAnyOfOneOf_3_1(t *testing.T) { assert.Nil(t, p3.NicknameOneOf) } +// TestNullableDiscriminatedUnion_3_1 asserts that a `oneOf` with a +// discriminator and a `{"type": "null"}` branch generates without the +// `discriminator: not all schemas were mapped` error. Before the fix, +// the null branch was filtered out of mapping construction but still +// counted toward the expected-mapping total, so the completeness check +// `len(Mapping) < len(elements)` falsely tripped for nullable +// discriminated unions. The fix compares against the number of +// non-null branches actually processed. +func TestNullableDiscriminatedUnion_3_1(t *testing.T) { + // Cat round-trip via the generated discriminated-union accessors. + const catJSON = `{"kind":"Cat","meow":true}` + var pet spec31.DiscriminatedPet + require.NoError(t, json.Unmarshal([]byte(catJSON), &pet)) + cat, err := pet.AsCat() + require.NoError(t, err) + require.NotNil(t, cat.Meow) + assert.True(t, *cat.Meow) + + encoded, err := json.Marshal(pet) + require.NoError(t, err) + assert.JSONEq(t, catJSON, string(encoded)) + + // Dog round-trip: the second non-null branch must also map. + const dogJSON = `{"kind":"Dog","bark":true}` + var pet2 spec31.DiscriminatedPet + require.NoError(t, json.Unmarshal([]byte(dogJSON), &pet2)) + dog, err := pet2.AsDog() + require.NoError(t, err) + require.NotNil(t, dog.Bark) + assert.True(t, *dog.Bark) +} + // TestJsonRoundTrip_NullableFields_AcrossVersions asserts that a JSON // payload with an explicit null nickname unmarshals to (*string)(nil) in // both spec versions, and that JSON output omits the field when nil due diff --git a/internal/test/openapi31_nullable/spec_3_1.yaml b/internal/test/openapi31_nullable/spec_3_1.yaml index 1ee1f472bb..cea52ed852 100644 --- a/internal/test/openapi31_nullable/spec_3_1.yaml +++ b/internal/test/openapi31_nullable/spec_3_1.yaml @@ -62,3 +62,38 @@ components: - type: string - type: "null" description: Same as `nicknameAnyOf` but using `oneOf`. + favorite: + $ref: '#/components/schemas/DiscriminatedPet' + description: | + Nullable discriminated union (`oneOf: [Cat, Dog, null]` with a + discriminator). Before this branch's fix to the union- + completeness check, the null branch was skipped during + mapping construction but still counted toward the + expected-mapping count, so generation failed with + `discriminator: not all schemas were mapped`. The two real + branches must map and the null branch must be tolerated. + DiscriminatedPet: + oneOf: + - $ref: '#/components/schemas/Cat' + - $ref: '#/components/schemas/Dog' + - type: "null" + discriminator: + propertyName: kind + Cat: + type: object + required: + - kind + properties: + kind: + type: string + meow: + type: boolean + Dog: + type: object + required: + - kind + properties: + kind: + type: string + bark: + type: boolean diff --git a/internal/test/openapi31_nullable/spec_3_1/types.gen.go b/internal/test/openapi31_nullable/spec_3_1/types.gen.go index 61fcd8b410..8d4d54d31d 100644 --- a/internal/test/openapi31_nullable/spec_3_1/types.gen.go +++ b/internal/test/openapi31_nullable/spec_3_1/types.gen.go @@ -3,6 +3,30 @@ // Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. package spec_3_1 +import ( + "encoding/json" + "errors" + + "github.com/oapi-codegen/runtime" +) + +// Cat defines model for Cat. +type Cat struct { + Kind string `json:"kind"` + Meow *bool `json:"meow,omitempty"` +} + +// DiscriminatedPet defines model for DiscriminatedPet. +type DiscriminatedPet struct { + union json.RawMessage +} + +// Dog defines model for Dog. +type Dog struct { + Bark *bool `json:"bark,omitempty"` + Kind string `json:"kind"` +} + // Pet defines model for Pet. type Pet struct { // Extras Optional, nullable *unspecified* object (no `properties:`). @@ -13,6 +37,15 @@ type Pet struct { // `*map[string]interface{}`. Extras *map[string]interface{} `json:"extras,omitempty"` + // Favorite Nullable discriminated union (`oneOf: [Cat, Dog, null]` with a + // discriminator). Before this branch's fix to the union- + // completeness check, the null branch was skipped during + // mapping construction but still counted toward the + // expected-mapping count, so generation failed with + // `discriminator: not all schemas were mapped`. The two real + // branches must map and the null branch must be tolerated. + Favorite *DiscriminatedPet `json:"favorite,omitempty"` + // Metadata Same as `extras` but with the type-array order reversed. // Both orderings must resolve identically; this guards // against any code path that inspects only the first @@ -43,3 +76,92 @@ type Pet struct { // Tags Optional, nullable array. Tags *[]string `json:"tags,omitempty"` } + +// AsCat returns the union data inside the DiscriminatedPet as a Cat +func (t DiscriminatedPet) AsCat() (Cat, error) { + var body Cat + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromCat overwrites any union data inside the DiscriminatedPet as the provided Cat +func (t *DiscriminatedPet) FromCat(v Cat) error { + v.Kind = "Cat" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeCat performs a merge with any union data inside the DiscriminatedPet, using the provided Cat +func (t *DiscriminatedPet) MergeCat(v Cat) error { + v.Kind = "Cat" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsDog returns the union data inside the DiscriminatedPet as a Dog +func (t DiscriminatedPet) AsDog() (Dog, error) { + var body Dog + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromDog overwrites any union data inside the DiscriminatedPet as the provided Dog +func (t *DiscriminatedPet) FromDog(v Dog) error { + v.Kind = "Dog" + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeDog performs a merge with any union data inside the DiscriminatedPet, using the provided Dog +func (t *DiscriminatedPet) MergeDog(v Dog) error { + v.Kind = "Dog" + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t DiscriminatedPet) Discriminator() (string, error) { + var discriminator struct { + Discriminator string `json:"kind"` + } + err := json.Unmarshal(t.union, &discriminator) + return discriminator.Discriminator, err +} + +func (t DiscriminatedPet) ValueByDiscriminator() (interface{}, error) { + discriminator, err := t.Discriminator() + if err != nil { + return nil, err + } + switch discriminator { + case "Cat": + return t.AsCat() + case "Dog": + return t.AsDog() + default: + return nil, errors.New("unknown discriminator value: " + discriminator) + } +} + +func (t DiscriminatedPet) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *DiscriminatedPet) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index a79b30bbf7..81b3319bb7 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -1446,7 +1446,14 @@ func generateUnion(outSchema *Schema, elements openapi3.SchemaRefs, discriminato outSchema.UnionElements = append(outSchema.UnionElements, UnionElement(elementSchema.GoType)) } - if (outSchema.Discriminator != nil) && len(outSchema.Discriminator.Mapping) < len(elements) { + // Compare against effectiveCount (non-null branches actually + // processed) rather than len(elements). For a nullable + // discriminated union (`oneOf: [Cat, Dog, {type: "null"}]`), the + // null-branch skip above leaves the discriminator with one fewer + // mapping than the raw element count, and we must not flag that as + // incomplete -- the null branch is a nullability marker, not a real + // variant that needs a mapping. + if (outSchema.Discriminator != nil) && len(outSchema.Discriminator.Mapping) < effectiveCount { return errors.New("discriminator: not all schemas were mapped") } From 87dafc73bf4d62ceece51094fe6853108fb0553f Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 20 Jun 2026 00:29:02 -0700 Subject: [PATCH 29/30] codegen: handle 3.1 contentEncoding/contentMediaType file-upload keywords OpenAPI 3.1 replaced the 3.0 `format: binary` and `format: byte` idioms for declaring binary content with the JSON-Schema-aligned `contentMediaType` and `contentEncoding` keywords. The official 3.0-> 3.1 upgrade guide (https://learn.openapis.org/upgrading/v3.0-to-v3.1.html, "Update file upload descriptions") instructs users to migrate to the new keywords; without codegen support for them, a spec that follows the guide loses its file/binary typing -- fields that used to generate as `openapi_types.File` or `[]byte` silently fall through to bare `string`. Fix: in `oapiSchemaToGoType`'s string branch, synthesize a `format` value from `contentMediaType`/`contentEncoding` when the schema does not declare an explicit `format`. The mapping mirrors the 3.0 equivalents: - contentMediaType set (any value) -> format "binary" -> openapi_types.File - contentEncoding set (any value) -> format "byte" -> []byte - both set -> contentMediaType wins (file) An explicit `format` on the schema always wins over the synthesis, so a `format: date` field with a stray `contentMediaType` keeps its existing `openapi_types.Date` Go type and user `type-mapping` overlays continue to apply unchanged. Synthesis is gated on `globalState.is31` so 3.0 specs are untouched even if the parser happens to populate the fields. Regression coverage: new `internal/test/openapi31_content_keywords/` test exercises five cases via a single `FileUploadFields` component schema: contentMediaType -> File, contentMediaType: image/png -> File (non-octet-stream still routes to File), contentEncoding: base64 -> []byte, both keywords -> File (contentMediaType wins), explicit `format: date` overrides the synthesis. Assignments to typed zero values fail to compile if any case regresses, so the regression surfaces at `go build` time before any assertion runs. First of several follow-ups identified by the v3.0->v3.1 upgrade- guide audit; the remaining gap (empty-schema request bodies under binary content types like `application/octet-stream: {}`) is more invasive and will follow as a separate commit. --- .../openapi31_content_keywords/config.yaml | 7 ++ .../content_keywords_test.go | 74 +++++++++++++++++++ .../test/openapi31_content_keywords/doc.go | 10 +++ .../test/openapi31_content_keywords/spec.yaml | 67 +++++++++++++++++ .../spec/types.gen.go | 40 ++++++++++ pkg/codegen/schema.go | 30 +++++++- 6 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 internal/test/openapi31_content_keywords/config.yaml create mode 100644 internal/test/openapi31_content_keywords/content_keywords_test.go create mode 100644 internal/test/openapi31_content_keywords/doc.go create mode 100644 internal/test/openapi31_content_keywords/spec.yaml create mode 100644 internal/test/openapi31_content_keywords/spec/types.gen.go diff --git a/internal/test/openapi31_content_keywords/config.yaml b/internal/test/openapi31_content_keywords/config.yaml new file mode 100644 index 0000000000..ac933a5526 --- /dev/null +++ b/internal/test/openapi31_content_keywords/config.yaml @@ -0,0 +1,7 @@ +# yaml-language-server: $schema=../../../configuration-schema.json +package: spec +generate: + models: true +output-options: + skip-prune: true +output: spec/types.gen.go diff --git a/internal/test/openapi31_content_keywords/content_keywords_test.go b/internal/test/openapi31_content_keywords/content_keywords_test.go new file mode 100644 index 0000000000..e09aee2356 --- /dev/null +++ b/internal/test/openapi31_content_keywords/content_keywords_test.go @@ -0,0 +1,74 @@ +// Package openapi31_content_keywords exercises the codegen mapping for +// OpenAPI 3.1's `contentEncoding` and `contentMediaType` keywords. The +// assertions are structural -- assigning typed zero values into the +// generated fields only compiles if the field types are what we +// expect, so a regression that drops the synthesis would surface as a +// build failure here before any test run. +package openapi31_content_keywords + +import ( + "testing" + + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/stretchr/testify/assert" + + spec "github.com/oapi-codegen/oapi-codegen/v2/internal/test/openapi31_content_keywords/spec" +) + +func TestContentMediaTypeMapsToFile(t *testing.T) { + // `contentMediaType: application/octet-stream` -- raw binary -- + // must produce openapi_types.File, matching 3.0 `format: binary`. + var f openapi_types.File + fields := spec.FileUploadFields{ + OrderId: 1, + RawFile: f, + } + assert.Equal(t, 1, fields.OrderId) +} + +func TestContentMediaTypeImageMapsToFile(t *testing.T) { + // A non-octet-stream contentMediaType (image/png) still routes to + // openapi_types.File: the keyword signals "this string is binary + // content," regardless of the specific media type. + var f openapi_types.File + fields := spec.FileUploadFields{ + ImageFile: &f, + } + assert.NotNil(t, fields.ImageFile) +} + +func TestContentEncodingMapsToByteSlice(t *testing.T) { + // `contentEncoding: base64` (and other RFC4648 encodings) must + // produce []byte, matching 3.0 `format: byte` behavior. + fields := spec.FileUploadFields{ + Base64Field: []byte("hello"), + Base64UrlField: []byte("world"), + } + assert.Equal(t, "hello", string(fields.Base64Field)) + assert.Equal(t, "world", string(fields.Base64UrlField)) +} + +func TestContentMediaTypeWinsOverContentEncoding(t *testing.T) { + // When both keywords are set, the file mapping wins -- the field + // is a binary blob of a specific media type, base64-encoded for + // JSON transport. Matches the 3.0 model where `format: binary` + // dominated. + var f openapi_types.File + fields := spec.FileUploadFields{ + BothKeywords: &f, + } + assert.NotNil(t, fields.BothKeywords) +} + +func TestExplicitFormatOverridesContentMediaType(t *testing.T) { + // An explicit `format` on the schema must continue to win over + // the contentMediaType-derived synthesis. Here `format: date` + // keeps its openapi_types.Date Go type even though + // contentMediaType: application/octet-stream would otherwise have + // routed to openapi_types.File. + var d openapi_types.Date + fields := spec.FileUploadFields{ + ExplicitFormatWins: &d, + } + assert.NotNil(t, fields.ExplicitFormatWins) +} diff --git a/internal/test/openapi31_content_keywords/doc.go b/internal/test/openapi31_content_keywords/doc.go new file mode 100644 index 0000000000..9f2237993e --- /dev/null +++ b/internal/test/openapi31_content_keywords/doc.go @@ -0,0 +1,10 @@ +// Package openapi31_content_keywords verifies that the OpenAPI 3.1 +// `contentEncoding` and `contentMediaType` keywords -- the JSON-Schema- +// aligned replacements for the 3.0 `format: binary` / `format: byte` +// idioms -- generate the same Go shapes (`openapi_types.File`, `[]byte`) +// their 3.0 counterparts did. Without coverage, a user following the +// 3.1 upgrade guide's "Update file upload descriptions" step would +// silently lose their file/binary typing. +package openapi31_content_keywords + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen --config=config.yaml spec.yaml diff --git a/internal/test/openapi31_content_keywords/spec.yaml b/internal/test/openapi31_content_keywords/spec.yaml new file mode 100644 index 0000000000..f48c11b500 --- /dev/null +++ b/internal/test/openapi31_content_keywords/spec.yaml @@ -0,0 +1,67 @@ +openapi: 3.1.0 +info: + title: 3.1 content keywords (file uploads) + version: 1.0.0 + description: | + Verifies that the 3.1 file-upload idioms documented at + https://learn.openapis.org/upgrading/v3.0-to-v3.1.html produce + the same Go shapes as their 3.0 `format: binary` / `format: byte` + counterparts. +paths: {} +components: + schemas: + # Property mirroring the multipart-file-upload example from the + # upgrade guide: `type: string, contentMediaType: application/ + # octet-stream`. Equivalent to 3.0 `type: string, format: binary`. + # Expected Go shape: openapi_types.File. + FileUploadFields: + type: object + required: + - orderId + - rawFile + - base64Field + - base64UrlField + properties: + orderId: + type: integer + rawFile: + type: string + contentMediaType: application/octet-stream + description: Raw binary file content. + imageFile: + type: string + contentMediaType: image/png + description: | + A non-octet-stream contentMediaType still routes to + openapi_types.File -- any contentMediaType signals + "this string is binary content of a specific type." + base64Field: + type: string + contentEncoding: base64 + description: | + Base64-encoded binary as a JSON string. Mirrors the + 3.0 `format: byte` idiom -- expected Go shape: `[]byte`. + base64UrlField: + type: string + contentEncoding: base64url + description: | + Any RFC4648 encoding triggers the []byte mapping, not just + "base64". + bothKeywords: + type: string + contentEncoding: base64 + contentMediaType: image/png + description: | + When both are set (base64-encoded binary of a specific + media type), the file mapping wins: openapi_types.File. + This matches the 3.0 behavior where `format: binary` would + dominate over any encoding hint. + explicitFormatWins: + type: string + format: date + contentMediaType: application/octet-stream + description: | + An explicit `format` always overrides the contentMediaType- + derived synthesis. Here `format: date` keeps its Go type + (openapi_types.Date) even though contentMediaType would + otherwise have routed to openapi_types.File. diff --git a/internal/test/openapi31_content_keywords/spec/types.gen.go b/internal/test/openapi31_content_keywords/spec/types.gen.go new file mode 100644 index 0000000000..674c727604 --- /dev/null +++ b/internal/test/openapi31_content_keywords/spec/types.gen.go @@ -0,0 +1,40 @@ +// Package spec provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.0.0-00010101000000-000000000000 DO NOT EDIT. +package spec + +import ( + openapi_types "github.com/oapi-codegen/runtime/types" +) + +// FileUploadFields defines model for FileUploadFields. +type FileUploadFields struct { + // Base64Field Base64-encoded binary as a JSON string. Mirrors the + // 3.0 `format: byte` idiom -- expected Go shape: `[]byte`. + Base64Field []byte `json:"base64Field"` + + // Base64UrlField Any RFC4648 encoding triggers the []byte mapping, not just + // "base64". + Base64UrlField []byte `json:"base64UrlField"` + + // BothKeywords When both are set (base64-encoded binary of a specific + // media type), the file mapping wins: openapi_types.File. + // This matches the 3.0 behavior where `format: binary` would + // dominate over any encoding hint. + BothKeywords *openapi_types.File `json:"bothKeywords,omitempty"` + + // ExplicitFormatWins An explicit `format` always overrides the contentMediaType- + // derived synthesis. Here `format: date` keeps its Go type + // (openapi_types.Date) even though contentMediaType would + // otherwise have routed to openapi_types.File. + ExplicitFormatWins *openapi_types.Date `json:"explicitFormatWins,omitempty"` + + // ImageFile A non-octet-stream contentMediaType still routes to + // openapi_types.File -- any contentMediaType signals + // "this string is binary content of a specific type." + ImageFile *openapi_types.File `json:"imageFile,omitempty"` + OrderId int `json:"orderId"` + + // RawFile Raw binary file content. + RawFile openapi_types.File `json:"rawFile"` +} diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 81b3319bb7..5867ac7f73 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -1125,7 +1125,35 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem outSchema.GoType = spec.Type outSchema.DefineViaAlias = true } else if t.Is("string") { - spec := globalState.typeMapping.String.Resolve(f) + // OpenAPI 3.1: `contentMediaType` and `contentEncoding` are the + // JSON-Schema-aligned replacements for the 3.0 `format: binary` + // / `format: byte` idioms used to describe file uploads and + // base64-encoded binary data. When the spec author writes the + // 3.1 form (per learn.openapis.org/upgrading/v3.0-to-v3.1.html + // "Update file upload descriptions"), the schema arrives with + // no `format` set but one of these keywords populated; without + // this synthesis the field would fall through to the default + // `string` mapping and the user would silently lose the + // file/binary typing they had under 3.0. We only synthesize a + // format when the user has not explicitly set one, so any + // explicit `format` (or user-provided `type-mapping` overlay) + // continues to win. + resolvedFormat := f + if globalState.is31 && resolvedFormat == "" { + switch { + case schema.ContentMediaType != "": + // Raw binary content of any media type -- equivalent to + // 3.0 `format: binary`. Routes to openapi_types.File in + // the default mapping. + resolvedFormat = "binary" + case schema.ContentEncoding != "": + // String-encoded binary (base64, base64url, etc.) -- + // equivalent to 3.0 `format: byte`. Routes to []byte in + // the default mapping. + resolvedFormat = "byte" + } + } + spec := globalState.typeMapping.String.Resolve(resolvedFormat) outSchema.GoType = spec.Type // Preserve special behaviors for specific types if outSchema.GoType == "[]byte" { From d58ecd788b5d69409a26866792eef0b0449e7d52 Mon Sep 17 00:00:00 2001 From: Marcin Romaszewicz Date: Sat, 20 Jun 2026 00:42:20 -0700 Subject: [PATCH 30/30] codegen: only map contentEncoding to []byte for standard base64 Go's encoding/json codec for `[]byte` uses standard padded base64 (RFC4648 section 4) unconditionally. The previous fix (3ad1efd9, "handle 3.1 contentEncoding/contentMediaType file-upload keywords") mapped *any* non-empty `contentEncoding` value to `[]byte`, which silently corrupts every RFC4648 variant except standard base64: - `contentEncoding: base64url`: URL-safe characters `-` and `_` fail to decode, and re-marshal emits standard base64 instead of the declared URL-safe form. The generated client/server stops honoring the wire encoding declared in the spec. - `contentEncoding: base32` / `base16`: the JSON []byte codec expects/emits base64; spec-declared base32/base16 payloads round- trip as garbage. - `contentEncoding: quoted-printable`: same shape -- the JSON []byte codec doesn't speak QP at all. Fix: narrow the synthesis to `contentEncoding == "base64"` only. Other encodings fall through to the default `string` mapping, preserving the raw declared wire form in user hands. Users who want typed handling for the non-standard encodings can layer their own codec at the application boundary or supply a `type-mapping` override pointing at a wrapper type that implements an encoding-aware MarshalJSON/UnmarshalJSON. The `contentMediaType` mapping to `openapi_types.File` is unchanged; that keyword expresses content semantics (raw binary of a media type), not a wire encoding, and File is a generic blob type. Regression coverage updates in `internal/test/openapi31_content_keywords/`: - `base64UrlField` moves out of `required` and the test asserts the field type is `*string` (compile-time check via `&v`); a regression that re-introduced the []byte synthesis would break the build before any assertion ran. - `base64Field` (with standard `contentEncoding: base64`) stays `[]byte`, asserted by the dedicated TestContentEncodingBase64... test. Closes Greptile P1 review comment on pkg/codegen/schema.go:1149+. --- .../content_keywords_test.go | 28 +++++++++++++++---- .../test/openapi31_content_keywords/spec.yaml | 10 +++++-- .../spec/types.gen.go | 11 ++++++-- pkg/codegen/schema.go | 22 ++++++++++++--- 4 files changed, 55 insertions(+), 16 deletions(-) diff --git a/internal/test/openapi31_content_keywords/content_keywords_test.go b/internal/test/openapi31_content_keywords/content_keywords_test.go index e09aee2356..565cf936e3 100644 --- a/internal/test/openapi31_content_keywords/content_keywords_test.go +++ b/internal/test/openapi31_content_keywords/content_keywords_test.go @@ -11,6 +11,7 @@ import ( openapi_types "github.com/oapi-codegen/runtime/types" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" spec "github.com/oapi-codegen/oapi-codegen/v2/internal/test/openapi31_content_keywords/spec" ) @@ -37,15 +38,30 @@ func TestContentMediaTypeImageMapsToFile(t *testing.T) { assert.NotNil(t, fields.ImageFile) } -func TestContentEncodingMapsToByteSlice(t *testing.T) { - // `contentEncoding: base64` (and other RFC4648 encodings) must - // produce []byte, matching 3.0 `format: byte` behavior. +func TestContentEncodingBase64MapsToByteSlice(t *testing.T) { + // `contentEncoding: base64` (standard padded base64) is the one + // RFC4648 variant Go's encoding/json handles natively for []byte, + // so it maps to []byte -- matching 3.0 `format: byte`. fields := spec.FileUploadFields{ - Base64Field: []byte("hello"), - Base64UrlField: []byte("world"), + Base64Field: []byte("hello"), } assert.Equal(t, "hello", string(fields.Base64Field)) - assert.Equal(t, "world", string(fields.Base64UrlField)) +} + +func TestContentEncodingBase64UrlStaysString(t *testing.T) { + // `contentEncoding: base64url` (URL-safe base64) is intentionally + // NOT mapped to []byte: Go's JSON codec for []byte uses standard + // base64 unconditionally, which would silently corrupt URL-safe + // characters (`-`/`_`) on unmarshal and re-emit them as standard + // base64 on marshal. The field stays as `string` so the declared + // wire encoding is preserved end-to-end and the user can apply + // the correct codec. Compile-time check: `*string` assignment. + v := "abc-def_ghi" + fields := spec.FileUploadFields{ + Base64UrlField: &v, + } + require.NotNil(t, fields.Base64UrlField) + assert.Equal(t, "abc-def_ghi", *fields.Base64UrlField) } func TestContentMediaTypeWinsOverContentEncoding(t *testing.T) { diff --git a/internal/test/openapi31_content_keywords/spec.yaml b/internal/test/openapi31_content_keywords/spec.yaml index f48c11b500..33b599e9dc 100644 --- a/internal/test/openapi31_content_keywords/spec.yaml +++ b/internal/test/openapi31_content_keywords/spec.yaml @@ -20,7 +20,6 @@ components: - orderId - rawFile - base64Field - - base64UrlField properties: orderId: type: integer @@ -45,8 +44,13 @@ components: type: string contentEncoding: base64url description: | - Any RFC4648 encoding triggers the []byte mapping, not just - "base64". + base64url is intentionally NOT mapped to []byte: Go's + encoding/json treats []byte as standard padded base64 + (RFC4648 section 4), which would silently corrupt URL-safe + characters (`-`/`_`) on unmarshal and re-emit them as + standard base64 on marshal. Expected Go shape: `string`, + so the user retains the declared wire encoding and can + apply the appropriate codec. bothKeywords: type: string contentEncoding: base64 diff --git a/internal/test/openapi31_content_keywords/spec/types.gen.go b/internal/test/openapi31_content_keywords/spec/types.gen.go index 674c727604..f6b0e62d7c 100644 --- a/internal/test/openapi31_content_keywords/spec/types.gen.go +++ b/internal/test/openapi31_content_keywords/spec/types.gen.go @@ -13,9 +13,14 @@ type FileUploadFields struct { // 3.0 `format: byte` idiom -- expected Go shape: `[]byte`. Base64Field []byte `json:"base64Field"` - // Base64UrlField Any RFC4648 encoding triggers the []byte mapping, not just - // "base64". - Base64UrlField []byte `json:"base64UrlField"` + // Base64UrlField base64url is intentionally NOT mapped to []byte: Go's + // encoding/json treats []byte as standard padded base64 + // (RFC4648 section 4), which would silently corrupt URL-safe + // characters (`-`/`_`) on unmarshal and re-emit them as + // standard base64 on marshal. Expected Go shape: `string`, + // so the user retains the declared wire encoding and can + // apply the appropriate codec. + Base64UrlField *string `json:"base64UrlField,omitempty"` // BothKeywords When both are set (base64-encoded binary of a specific // media type), the file mapping wins: openapi_types.File. diff --git a/pkg/codegen/schema.go b/pkg/codegen/schema.go index 5867ac7f73..930e2b59e0 100644 --- a/pkg/codegen/schema.go +++ b/pkg/codegen/schema.go @@ -1146,11 +1146,25 @@ func oapiSchemaToGoType(schema *openapi3.Schema, path []string, outSchema *Schem // 3.0 `format: binary`. Routes to openapi_types.File in // the default mapping. resolvedFormat = "binary" - case schema.ContentEncoding != "": - // String-encoded binary (base64, base64url, etc.) -- - // equivalent to 3.0 `format: byte`. Routes to []byte in - // the default mapping. + case schema.ContentEncoding == "base64": + // Standard padded base64 is the one RFC4648 variant Go's + // encoding/json handles natively for []byte. Map to + // `byte` (-> []byte), matching the 3.0 `format: byte` + // behavior so the wire encoding is honored on + // marshal/unmarshal without user code. resolvedFormat = "byte" + // Other contentEncoding values (base64url, base32, base16, + // quoted-printable) are intentionally NOT mapped to []byte. + // Go's JSON codec for []byte always emits/expects standard + // padded base64; using it for base64url would silently + // corrupt URL-safe characters (`-`/`_`) on unmarshal and + // re-emit them as standard base64 on marshal. Leaving the + // field as the default `string` keeps the raw declared + // wire encoding in user hands -- they can apply the + // correct codec at the application layer. Users who want + // a typed mapping can override via `type-mapping` (e.g. + // map a custom format to a wrapper type that implements + // encoding-aware MarshalJSON/UnmarshalJSON). } } spec := globalState.typeMapping.String.Resolve(resolvedFormat)