From f798e25ece55d933fd4d76f67d65f985d6b3d5e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 15 Oct 2026 09:26:56 +0200 Subject: [PATCH 1/2] fix(codegen): preserve annotated schema types Resolve described references and keep scalar allOf wrappers as PHP types. --- codegen/pkg/generator/generator.go | 1 + codegen/pkg/generator/schema_annotations.go | 208 ++++++++++++++++++ .../pkg/generator/schema_annotations_test.go | 50 +++++ .../testdata/schema-annotations.json | 88 ++++++++ 4 files changed, 347 insertions(+) create mode 100644 codegen/pkg/generator/schema_annotations.go create mode 100644 codegen/pkg/generator/schema_annotations_test.go create mode 100644 codegen/pkg/generator/testdata/schema-annotations.json diff --git a/codegen/pkg/generator/generator.go b/codegen/pkg/generator/generator.go index d452438..9ddae6e 100644 --- a/codegen/pkg/generator/generator.go +++ b/codegen/pkg/generator/generator.go @@ -80,6 +80,7 @@ func (g *Generator) Load(spec *v3.Document) error { return fmt.Errorf("nil spec") } + normalizeSchemaAnnotations(spec) g.spec = spec g.tagLookup = make(map[string]*base.Tag) for _, tag := range spec.Tags { diff --git a/codegen/pkg/generator/schema_annotations.go b/codegen/pkg/generator/schema_annotations.go new file mode 100644 index 0000000..2d0cd3f --- /dev/null +++ b/codegen/pkg/generator/schema_annotations.go @@ -0,0 +1,208 @@ +package generator + +import ( + "iter" + "slices" + "strings" + + "github.com/pb33f/libopenapi/datamodel/high/base" + v3 "github.com/pb33f/libopenapi/datamodel/high/v3" +) + +// normalizeSchemaAnnotations gives the generator a resolved view of annotated +// references and single-schema allOf wrappers without changing reference names. +// Only annotation-only branches are removed; validation constraints stay intact. +func normalizeSchemaAnnotations(doc *v3.Document) { + if doc == nil { + return + } + seen := make(map[*base.SchemaProxy]bool) + var visit func(*base.SchemaProxy) + visit = func(proxy *base.SchemaProxy) { + if proxy == nil || seen[proxy] { + return + } + seen[proxy] = true + originalRef := proxy.GetReference() + schema := proxy.Schema() + if schema == nil { + return + } + if proxy.IsTransformedRefWithSiblings() && annotationOnlySchema(schema, false) && doc.Components != nil && doc.Components.Schemas != nil { + ref := proxy.GetReference() + name, ok := strings.CutPrefix(ref, "#/components/schemas/") + if ok { + target := doc.Components.Schemas.GetOrZero(name) + visit(target) + if target != nil && target.Schema() != nil { + resolved := *target.Schema() + copySchemaAnnotations(&resolved, schema) + *proxy = *base.CreateSchemaProxyRefWithSchema(ref, &resolved) + schema = proxy.Schema() + } + } + } + for _, child := range schema.Properties.FromOldest() { + visit(child) + } + if schema.Items != nil && schema.Items.IsA() { + visit(schema.Items.A) + } + if schema.AdditionalProperties != nil && schema.AdditionalProperties.IsA() { + visit(schema.AdditionalProperties.A) + } + for _, child := range schema.AllOf { + visit(child) + } + for _, child := range schema.AnyOf { + visit(child) + } + for _, child := range schema.OneOf { + visit(child) + } + if len(schema.AllOf) == 0 || !annotationOnlySchema(schema, true) { + return + } + var target *base.SchemaProxy + for _, child := range schema.AllOf { + if child == nil || child.Schema() == nil { + return + } + if annotationOnlySchema(child.Schema(), false) && !child.IsReference() { + continue + } + if target != nil { + return + } + target = child + } + if target == nil { + return + } + // Keep object composition and its dependency edges. Existing generators + // already flatten objects, and those edges determine public exports. + shape := target.Schema() + if slices.Contains(shape.Type, "object") || + (shape.Properties != nil && shape.Properties.Len() > 0) || + shape.AdditionalProperties != nil || len(shape.AllOf) > 0 { + return + } + resolved := *shape + for _, child := range schema.AllOf { + if child != target { + copySchemaAnnotations(&resolved, child.Schema()) + } + } + copySchemaAnnotations(&resolved, schema) + if originalRef != "" { + *proxy = *base.CreateSchemaProxyRefWithSchema(originalRef, &resolved) + } else if target.IsReference() { + *proxy = *base.CreateSchemaProxyRefWithSchema(target.GetReference(), &resolved) + } else { + *proxy = *base.CreateSchemaProxy(&resolved) + } + } + if doc.Components != nil && doc.Components.Schemas != nil { + for _, schema := range doc.Components.Schemas.FromOldest() { + visit(schema) + } + } + visitContent := func(content iter.Seq2[string, *v3.MediaType]) { + for _, media := range content { + if media != nil { + visit(media.Schema) + } + } + } + visitParameter := func(parameter *v3.Parameter) { + if parameter == nil { + return + } + visit(parameter.Schema) + visitContent(parameter.Content.FromOldest()) + } + visitResponse := func(response *v3.Response) { + if response != nil { + visitContent(response.Content.FromOldest()) + } + } + visitPath := func(item *v3.PathItem) { + if item == nil { + return + } + for _, parameter := range item.Parameters { + visitParameter(parameter) + } + for _, operation := range item.GetOperations().FromOldest() { + for _, parameter := range operation.Parameters { + visitParameter(parameter) + } + if operation.RequestBody != nil { + visitContent(operation.RequestBody.Content.FromOldest()) + } + if operation.Responses != nil { + for _, response := range operation.Responses.Codes.FromOldest() { + visitResponse(response) + } + visitResponse(operation.Responses.Default) + } + } + } + if doc.Paths != nil { + for _, item := range doc.Paths.PathItems.FromOldest() { + visitPath(item) + } + } + for _, item := range doc.Webhooks.FromOldest() { + visitPath(item) + } +} + +func annotationOnlySchema(schema *base.Schema, allowAllOf bool) bool { + if schema == nil || schema.GoLow() == nil || schema.GoLow().RootNode == nil { + return false + } + node := schema.GoLow().RootNode + for i := 0; i+1 < len(node.Content); i += 2 { + switch node.Content[i].Value { + case "title", "description", "$comment", "default", "example", "examples", "deprecated", "readOnly", "writeOnly", "externalDocs": + case "allOf": + if !allowAllOf { + return false + } + default: + return false + } + } + return true +} + +func copySchemaAnnotations(target, source *base.Schema) { + if source.Title != "" { + target.Title = source.Title + } + if source.Description != "" { + target.Description = source.Description + } + if source.Default != nil { + target.Default = source.Default + } + if source.Example != nil { + target.Example = source.Example + } + if source.Examples != nil { + target.Examples = source.Examples + } + if source.Deprecated != nil { + target.Deprecated = source.Deprecated + } + if source.ReadOnly != nil { + target.ReadOnly = source.ReadOnly + } + if source.WriteOnly != nil { + target.WriteOnly = source.WriteOnly + } + if source.ExternalDocs != nil { + target.ExternalDocs = source.ExternalDocs + } +} diff --git a/codegen/pkg/generator/schema_annotations_test.go b/codegen/pkg/generator/schema_annotations_test.go new file mode 100644 index 0000000..d05e97a --- /dev/null +++ b/codegen/pkg/generator/schema_annotations_test.go @@ -0,0 +1,50 @@ +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi" +) + +func TestGenerateAnnotatedSchemas(t *testing.T) { + t.Parallel() + spec, err := os.ReadFile("testdata/schema-annotations.json") + if err != nil { + t.Fatal(err) + } + document, err := libopenapi.NewDocument(spec) + if err != nil { + t.Fatal(err) + } + model, err := document.BuildV3Model() + if err != nil { + t.Fatal(err) + } + out := t.TempDir() + generator := New(Config{Out: out}) + if err := generator.Load(&model.Model); err != nil { + t.Fatal(err) + } + if err := generator.Build(); err != nil { + t.Fatal(err) + } + checks := map[string][]string{ + "Address.php": {"public string $country;"}, + "Merchant.php": {"public Address $address;", "public string $phoneNumber;", "@var string[]|null"}, + "ExtendedAddress.php": {"public string $country;", "public ?string $label = null;"}, + } + for file, snippets := range checks { + data, err := os.ReadFile(filepath.Join(out, "Types", file)) + if err != nil { + t.Fatal(err) + } + for _, snippet := range snippets { + if !strings.Contains(string(data), snippet) { + t.Errorf("%s missing %q", file, snippet) + } + } + } +} diff --git a/codegen/pkg/generator/testdata/schema-annotations.json b/codegen/pkg/generator/testdata/schema-annotations.json new file mode 100644 index 0000000..a1c3de9 --- /dev/null +++ b/codegen/pkg/generator/testdata/schema-annotations.json @@ -0,0 +1,88 @@ +{ + "openapi": "3.1.0", + "info": { "title": "Schema annotations", "version": "1.0.0" }, + "servers": [{ "url": "https://api.sumup.com" }], + "tags": [{ "name": "Merchants", "description": "Merchant API." }], + "paths": { + "/merchants": { + "post": { + "tags": ["Merchants"], + "operationId": "CreateMerchant", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Merchant" } + } + } + }, + "responses": { + "200": { + "description": "Created merchant", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Merchant" } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "CountryCode": { "type": "string" }, + "PhoneNumber": { "type": "string" }, + "Address": { + "type": "object", + "required": ["country"], + "properties": { + "country": { + "allOf": [ + { "description": "Address country.", "example": "DE" }, + { "$ref": "#/components/schemas/CountryCode" } + ] + } + } + }, + "ExtendedAddress": { + "allOf": [ + { + "$ref": "#/components/schemas/Address", + "description": "Inherited address." + }, + { + "type": "object", + "properties": { "label": { "type": "string" } } + } + ] + }, + "Merchant": { + "type": "object", + "required": ["address", "phone_number"], + "properties": { + "address": { + "$ref": "#/components/schemas/Address", + "description": "Merchant address." + }, + "phone_number": { + "$ref": "#/components/schemas/PhoneNumber", + "description": "Merchant phone number." + }, + "extended_address": { + "$ref": "#/components/schemas/ExtendedAddress" + }, + "country_codes": { + "type": "array", + "items": { + "allOf": [ + { "description": "Country item." }, + { "$ref": "#/components/schemas/CountryCode" } + ] + } + } + } + } + } + } +} From 5be7c0ff8bdfad7dc92c6ff6f5a43f6bef9d380d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matou=C5=A1=20Dzivjak?= Date: Thu, 15 Oct 2026 09:37:40 +0200 Subject: [PATCH 2/2] fix(http): handle response-less guzzle exceptions Keep transport errors and response handling compatible with Guzzle 7 and 8. --- src/HttpClient/GuzzleClient.php | 8 ++++---- tests/GuzzleClientTest.php | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 4 deletions(-) create mode 100644 tests/GuzzleClientTest.php diff --git a/src/HttpClient/GuzzleClient.php b/src/HttpClient/GuzzleClient.php index 10a3a2a..125b3ed 100644 --- a/src/HttpClient/GuzzleClient.php +++ b/src/HttpClient/GuzzleClient.php @@ -110,10 +110,10 @@ function ($retry) use ($backoffMs) { try { $response = $client->request($method, $url, $requestParams); - } catch (\GuzzleHttp\Exception\RequestException $exception) { - if ($exception->hasResponse()) { - $response = $exception->getResponse(); - } else { + } catch (\GuzzleHttp\Exception\TransferException $exception) { + // Guzzle 8 exposes getResponse() only on response-bearing exceptions. + $response = method_exists($exception, 'getResponse') ? $exception->getResponse() : null; + if (!$response instanceof \Psr\Http\Message\ResponseInterface) { throw new ConnectionException($exception->getMessage(), $exception->getCode()); } } diff --git a/tests/GuzzleClientTest.php b/tests/GuzzleClientTest.php new file mode 100644 index 0000000..606488f --- /dev/null +++ b/tests/GuzzleClientTest.php @@ -0,0 +1,19 @@ +expectException(ConnectionException::class); + + $client->send('GET', '/', [], []); + } +}