-
Notifications
You must be signed in to change notification settings - Fork 31
Add ReuseResponses feature to Reflector for OpenAPI 3.0 and 3.1 #156
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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.0 reflector. | ||
|
|
@@ -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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1 | Confidence: High When 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) | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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") | ||
| } | ||
There was a problem hiding this comment.
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_contextshows diverse operation patterns that might have different response reuse requirements.Code Suggestion: