Skip to content
Draft
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
69 changes: 68 additions & 1 deletion openapi3/reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type Reflector struct {
jsonschema.Reflector

Spec *Spec

// ReuseResponses enables moving common response definitions to components/responses
// and using references instead of inline definitions.
ReuseResponses bool
Comment on lines +24 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: Medium

Speculative: The feature introduces a global flag that affects all operations, but there's no way to control response reuse on a per-operation basis. In complex APIs, some operations might benefit from response reuse while others should keep inline responses for clarity. The related_context shows diverse operation patterns that might have different response reuse requirements.

Code Suggestion:

// Consider operation-level override
type OperationContext struct {
    // ... existing fields ...
    ReuseResponses *bool // nil means use reflector setting
}

}

// NewReflector creates an instance of OpenAPI 3.0 reflector.
Expand Down Expand Up @@ -592,6 +596,58 @@ func (r *Reflector) collectDefinition() func(name string, schema jsonschema.Sche
}
}

// generateResponseName creates a consistent name for a response based on its status and schema.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

The response name generation logic creates potential naming collisions. When different operations use the same response structure type, they'll generate identical names. However, when different status codes use different structures that happen to have the same type name, they'll also collide. This violates OpenAPI's requirement that component names be unique. The related_context shows multiple test files creating Reflector instances, and in real usage, different packages could have structs with identical names.

Code Suggestion:

// Include HTTP status in the name to ensure uniqueness across different status codes
if structType.Name() != "" {
    return structType.Name() + strconv.Itoa(httpStatus)
}

Evidence: method:generateResponseName

func (r *Reflector) generateResponseName(httpStatus int, cu openapi.ContentUnit) string {
statusText := http.StatusText(httpStatus)
if statusText == "" {
statusText = strconv.Itoa(httpStatus)
}

// Clean up status text to create a valid name
name := strings.ReplaceAll(statusText, " ", "")

// If there's a structure type, try to use it
if cu.Structure != nil {
structType := reflect.TypeOf(cu.Structure)
if structType != nil {
// Handle pointer types
if structType.Kind() == reflect.Ptr {
structType = structType.Elem()
}
// If it has a name, use it
if structType.Name() != "" {
return structType.Name()
}
}
}

return "Response" + name
}

// collectResponse stores a response in components/responses if ReuseResponses is enabled.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

The current implementation doesn't verify that existing responses with the same name are actually equivalent to the new response. If two different responses generate the same name (due to the naming collision issue), the second response will incorrectly reference the first response's definition. This could lead to incorrect API documentation where different response types are documented as being the same.

Code Suggestion:

// Add response equivalence check before reusing
if existing, exists := r.Spec.Components.Responses.MapOfResponseOrRefValues[name]; exists {
    // Compare key response properties before reusing
    if responsesAreEquivalent(existing, resp) {
        return name
    }
    // Generate unique name for different responses with same base name
    name = generateUniqueName(name, r.Spec.Components.Responses)
}

Evidence: method:collectResponse

// Returns the reference name if stored, empty string otherwise.
func (r *Reflector) collectResponse(httpStatus int, resp *Response, cu openapi.ContentUnit) string {
if !r.ReuseResponses {
return ""
}

name := r.generateResponseName(httpStatus, cu)

// Check if already exists
if r.Spec != nil && r.Spec.Components != nil && r.Spec.Components.Responses != nil {
if _, exists := r.Spec.Components.Responses.MapOfResponseOrRefValues[name]; exists {
return name
}
}

// Add to components
r.SpecEns().ComponentsEns().ResponsesEns().WithMapOfResponseOrRefValuesItem(name, ResponseOrRef{
Response: resp,
})

return name
}

func (r *Reflector) parseResponseHeader(resp *Response, oc openapi.OperationContext, cu openapi.ContentUnit) error {
if cu.Structure == nil {
return nil
Expand Down Expand Up @@ -714,7 +770,18 @@ func (r *Reflector) setupResponse(o *Operation, oc openapi.OperationContext) err
resp.Description = http.StatusText(cu.HTTPStatus)
}

ror := ResponseOrRef{Response: resp}
// Try to collect response to components if ReuseResponses is enabled
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 | Confidence: High

When ReuseResponses is enabled and a response is stored in components, the cu.Customize callback receives an empty ResponseOrRef with only a reference set. This breaks the customization capability because the callback can no longer modify the actual response content. The related_context shows extensive usage of operation customization in tests, indicating this is a critical feature.

Code Suggestion:

if refName != "" {
    ror = ResponseOrRef{}
    ror.SetReference("#/components/responses/" + refName)
    // Apply customization to the component response if it exists
    if compResp, exists := r.Spec.Components.Responses.MapOfResponseOrRefValues[refName]; exists {
        if cu.Customize != nil {
            cu.Customize(&compResp)
        }
    }
} else {
    ror = ResponseOrRef{Response: resp}
    if cu.Customize != nil {
        cu.Customize(&ror)
    }
}

Evidence: method:setupResponse

refName := r.collectResponse(cu.HTTPStatus, resp, cu)

var ror ResponseOrRef
if refName != "" {
// Use reference to component
ror = ResponseOrRef{}
ror.SetReference("#/components/responses/" + refName)
} else {
// Use inline response
ror = ResponseOrRef{Response: resp}
}

if cu.Customize != nil {
cu.Customize(&ror)
Expand Down
69 changes: 68 additions & 1 deletion openapi31/reflect.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ type Reflector struct {
jsonschema.Reflector

Spec *Spec

// ReuseResponses enables moving common response definitions to components/responses
// and using references instead of inline definitions.
ReuseResponses bool
}

// NewReflector creates an instance of OpenAPI 3.1 reflector.
Expand Down Expand Up @@ -560,6 +564,58 @@ func (r *Reflector) collectDefinition() func(name string, schema jsonschema.Sche
}
}

// generateResponseName creates a consistent name for a response based on its status and schema.
func (r *Reflector) generateResponseName(httpStatus int, cu openapi.ContentUnit) string {
statusText := http.StatusText(httpStatus)
if statusText == "" {
statusText = strconv.Itoa(httpStatus)
}

// Clean up status text to create a valid name
name := strings.ReplaceAll(statusText, " ", "")

// If there's a structure type, try to use it
if cu.Structure != nil {
structType := reflect.TypeOf(cu.Structure)
if structType != nil {
// Handle pointer types
if structType.Kind() == reflect.Ptr {
structType = structType.Elem()
}
// If it has a name, use it
if structType.Name() != "" {
return structType.Name()
}
}
}

return "Response" + name
}

// collectResponse stores a response in components/responses if ReuseResponses is enabled.
// Returns the reference name if stored, empty string otherwise.
func (r *Reflector) collectResponse(httpStatus int, resp *Response, cu openapi.ContentUnit) string {
if !r.ReuseResponses {
return ""
}

name := r.generateResponseName(httpStatus, cu)

// Check if already exists
if r.Spec != nil && r.Spec.Components != nil && r.Spec.Components.Responses != nil {
if _, exists := r.Spec.Components.Responses[name]; exists {
return name
}
}

// Add to components
r.SpecEns().ComponentsEns().WithResponsesItem(name, ResponseOrReference{
Response: resp,
})

return name
}

func (r *Reflector) parseResponseHeader(resp *Response, oc openapi.OperationContext, cu openapi.ContentUnit) error {
if cu.Structure == nil {
return nil
Expand Down Expand Up @@ -684,7 +740,18 @@ func (r *Reflector) setupResponse(o *Operation, oc openapi.OperationContext) err
resp.Description = http.StatusText(cu.HTTPStatus)
}

ror := ResponseOrReference{Response: resp}
// Try to collect response to components if ReuseResponses is enabled
refName := r.collectResponse(cu.HTTPStatus, resp, cu)

var ror ResponseOrReference
if refName != "" {
// Use reference to component
ror = ResponseOrReference{}
ror.SetReference("#/components/responses/" + refName)
} else {
// Use inline response
ror = ResponseOrReference{Response: resp}
}

if cu.Customize != nil {
cu.Customize(&ror)
Expand Down
133 changes: 133 additions & 0 deletions response_reuse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package openapi_test

import (
"net/http"
"testing"

"github.com/swaggest/openapi-go"
"github.com/swaggest/openapi-go/openapi31"
)

// ErrorResponse represents a common error response structure
type ErrorResponseTest struct {
Message string `json:"message" description:"Error message"`
Code int `json:"code" description:"Error code"`
}

// User represents a user object
type UserTest struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}

func TestResponseReuse(t *testing.T) {
// Create reflector with ReuseResponses enabled
reflector := openapi31.NewReflector()
reflector.ReuseResponses = true

reflector.SpecEns()
reflector.Spec.Info.WithTitle("API with Reusable Responses")
reflector.Spec.Info.WithVersion("1.0.0")
reflector.Spec.Info.WithDescription("Demonstrates response reuse in components")

// Operation 1: Get User
type getUserInput struct {
ID int `path:"id"`
}
getUserOp, _ := reflector.NewOperationContext(http.MethodGet, "/users/{id}")
getUserOp.SetDescription("Get user by ID")
getUserOp.SetID("getUser")
getUserOp.AddReqStructure(getUserInput{})

// Add success response
getUserOp.AddRespStructure(UserTest{}, openapi.WithHTTPStatus(http.StatusOK))

// Add error responses - these will be collected in components/responses
getUserOp.AddRespStructure(ErrorResponseTest{},
openapi.WithHTTPStatus(http.StatusBadRequest))
getUserOp.AddRespStructure(ErrorResponseTest{},
openapi.WithHTTPStatus(http.StatusNotFound))
getUserOp.AddRespStructure(ErrorResponseTest{},
openapi.WithHTTPStatus(http.StatusInternalServerError))

if err := reflector.AddOperation(getUserOp); err != nil {
t.Fatal(err)
}

// Operation 2: Update User - will reuse the same error responses
type updateUserInput struct {
ID int `path:"id"`
}
updateUserOp, _ := reflector.NewOperationContext(http.MethodPut, "/users/{id}")
updateUserOp.SetDescription("Update user by ID")
updateUserOp.SetID("updateUser")
updateUserOp.AddReqStructure(updateUserInput{})

// Add success response
updateUserOp.AddRespStructure(UserTest{}, openapi.WithHTTPStatus(http.StatusOK))

// Add the same error responses - they will reference the components
updateUserOp.AddRespStructure(ErrorResponseTest{},
openapi.WithHTTPStatus(http.StatusBadRequest))
updateUserOp.AddRespStructure(ErrorResponseTest{},
openapi.WithHTTPStatus(http.StatusNotFound))
updateUserOp.AddRespStructure(ErrorResponseTest{},
openapi.WithHTTPStatus(http.StatusInternalServerError))

if err := reflector.AddOperation(updateUserOp); err != nil {
t.Fatal(err)
}

// Marshal to YAML to see the result
schema, err := reflector.Spec.MarshalYAML()
if err != nil {
t.Fatal(err)
}

yamlStr := string(schema)

// Verify that components/responses is populated
if reflector.Spec.Components == nil || reflector.Spec.Components.Responses == nil {
t.Fatal("Expected components/responses to be populated")
}

// Verify we have the error responses in components
if len(reflector.Spec.Components.Responses) == 0 {
t.Fatal("Expected error responses in components/responses")
Comment on lines +90 to +97
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 | Confidence: Medium

Speculative: The tests verify that components are populated but don't validate the actual content or reference correctness. They don't test edge cases like naming collisions, different structures with same names, or the interaction with the Customize callback. More comprehensive validation would ensure the feature works correctly in complex scenarios.

Code Suggestion:

// Add test for naming collision scenario
func TestResponseReuse_NamingCollision(t *testing.T) {
    // Test that different structures with same type name don't collide
}

// Add test for customize callback with reused responses  
func TestResponseReuse_WithCustomize(t *testing.T) {
    // Verify customization works correctly with component references
}

}

t.Logf("Generated OpenAPI spec:\n%s", yamlStr)
t.Logf("Number of component responses: %d", len(reflector.Spec.Components.Responses))
}

func TestResponseReuseDisabled(t *testing.T) {
// Create reflector with ReuseResponses disabled (default)
reflector := openapi31.NewReflector()
// ReuseResponses is false by default

reflector.SpecEns()
reflector.Spec.Info.WithTitle("API without Response Reuse")
reflector.Spec.Info.WithVersion("1.0.0")

// Operation 1
type getUserInput struct {
ID int `path:"id"`
}
getUserOp, _ := reflector.NewOperationContext(http.MethodGet, "/users/{id}")
getUserOp.SetDescription("Get user by ID")
getUserOp.AddReqStructure(getUserInput{})
getUserOp.AddRespStructure(UserTest{}, openapi.WithHTTPStatus(http.StatusOK))
getUserOp.AddRespStructure(ErrorResponseTest{}, openapi.WithHTTPStatus(http.StatusBadRequest))

if err := reflector.AddOperation(getUserOp); err != nil {
t.Fatal(err)
}

// With ReuseResponses disabled, components/responses should be empty or nil
if reflector.Spec.Components != nil && reflector.Spec.Components.Responses != nil && len(reflector.Spec.Components.Responses) > 0 {
t.Fatal("Expected no responses in components/responses when ReuseResponses is disabled")
}

t.Log("Response reuse disabled - responses inlined as expected")
}