Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codegen/pkg/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
208 changes: 208 additions & 0 deletions codegen/pkg/generator/schema_annotations.go
Original file line number Diff line number Diff line change
@@ -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
}
}
50 changes: 50 additions & 0 deletions codegen/pkg/generator/schema_annotations_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
88 changes: 88 additions & 0 deletions codegen/pkg/generator/testdata/schema-annotations.json
Original file line number Diff line number Diff line change
@@ -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" }
]
}
}
}
}
}
}
}
8 changes: 4 additions & 4 deletions src/HttpClient/GuzzleClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
Expand Down
19 changes: 19 additions & 0 deletions tests/GuzzleClientTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace SumUp\Tests;

use PHPUnit\Framework\TestCase;
use SumUp\Exception\ConnectionException;
use SumUp\HttpClient\GuzzleClient;

class GuzzleClientTest extends TestCase
{
public function testRequestFailureWithoutResponseRaisesConnectionException(): void
{
$client = new GuzzleClient('unsupported://example.test');

$this->expectException(ConnectionException::class);

$client->send('GET', '/', [], []);
}
}
Loading