diff --git a/README.md b/README.md index b154963..039a6e9 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,18 @@ fizz.ID(id string) fizz.Deprecated(deprecated bool) // Add an additional response to the operation. -// model and header may be `nil`. -fizz.Response(statusCode, desc string, model interface{}, headers []*ResponseHeader) +// The example argument will populate a single example in the response schema. +// For populating multiple examples, use fizz.ResponseWithExamples. +// Notice that example and examples fields are mutually exclusive. +// model, header, and example may be `nil`. +fizz.Response(statusCode, desc string, model interface{}, headers []*ResponseHeader, example interface{}) + +// ResponseWithExamples is a variant of Response that supports providing multiple examples. +// Examples argument will populate multiple examples in the response schema. +// For populating a single example, use fizz.Response. +// Notice that example and examples fields are mutually exclusive. +// model, header, and examples may be `nil`. +fizz.ResponseWithExamples(statusCode, desc string, model interface{}, headers []*ResponseHeader, examples map[string]interface{}) // Add an additional header to the default response. // Model can be of any type, and may also be `nil`, diff --git a/examples/market/router.go b/examples/market/router.go index 0660512..729f832 100644 --- a/examples/market/router.go +++ b/examples/market/router.go @@ -46,20 +46,25 @@ func routes(grp *fizz.RouterGroup) { // Add a new fruit to the market. grp.POST("", []fizz.OperationOption{ fizz.Summary("Add a fruit to the market"), - fizz.Response("400", "Bad request", nil, nil), + fizz.Response("400", "Bad request", nil, nil, + map[string]interface{}{"error": "fruit already exists"}, + ), }, tonic.Handler(CreateFruit, 200)) // Remove a fruit from the market, // probably because it rotted. grp.DELETE("/:name", []fizz.OperationOption{ fizz.Summary("Remove a fruit from the market"), - fizz.Response("400", "Fruit not found", nil, nil), + fizz.ResponseWithExamples("400", "Bad request", nil, nil, map[string]interface{}{ + "fruitNotFound": map[string]interface{}{"error": "fruit not found"}, + "invalidApiKey": map[string]interface{}{"error": "invalid api key"}, + }), }, tonic.Handler(DeleteFruit, 204)) // List all available fruits. grp.GET("", []fizz.OperationOption{ fizz.Summary("List the fruits of the market"), - fizz.Response("400", "Bad request", nil, nil), + fizz.Response("400", "Bad request", nil, nil, nil), fizz.Header("X-Market-Listing-Size", "Listing size", fizz.Long), }, tonic.Handler(ListFruits, 200)) } diff --git a/fizz.go b/fizz.go index 31c6269..aeacbe7 100644 --- a/fizz.go +++ b/fizz.go @@ -310,13 +310,27 @@ func Deprecated(deprecated bool) func(*openapi.OperationInfo) { } // Response adds an additional response to the operation. -func Response(statusCode, desc string, model interface{}, headers []*openapi.ResponseHeader) func(*openapi.OperationInfo) { +func Response(statusCode, desc string, model interface{}, headers []*openapi.ResponseHeader, example interface{}) func(*openapi.OperationInfo) { return func(o *openapi.OperationInfo) { - o.Responses = append(o.Responses, &openapi.OperationReponse{ + o.Responses = append(o.Responses, &openapi.OperationResponse{ Code: statusCode, Description: desc, Model: model, Headers: headers, + Example: example, + }) + } +} + +// ResponseWithExamples is a variant of Response that accept many examples. +func ResponseWithExamples(statusCode, desc string, model interface{}, headers []*openapi.ResponseHeader, examples map[string]interface{}) func(*openapi.OperationInfo) { + return func(o *openapi.OperationInfo) { + o.Responses = append(o.Responses, &openapi.OperationResponse{ + Code: statusCode, + Description: desc, + Model: model, + Headers: headers, + Examples: examples, }) } } diff --git a/fizz_test.go b/fizz_test.go index a49290d..5b86d63 100644 --- a/fizz_test.go +++ b/fizz_test.go @@ -259,6 +259,11 @@ func TestSpecHandler(t *testing.T) { Description: "Rate limit", Model: Integer, }, + }, nil), + Response("404", "", String, nil, "not-found-example"), + ResponseWithExamples("400", "", String, nil, map[string]interface{}{ + "one": "message1", + "two": "message2", }), }, tonic.Handler(func(c *gin.Context) error { diff --git a/openapi/generator.go b/openapi/generator.go index ca1178f..28b5be7 100644 --- a/openapi/generator.go +++ b/openapi/generator.go @@ -274,7 +274,7 @@ func (g *Generator) AddOperation(path, method, tag string, in, out reflect.Type, // Generate the default response from the tonic // handler return type. If the handler has no output // type, the response won't have a schema. - if err := g.setOperationResponse(op, out, strconv.Itoa(info.StatusCode), tonic.MediaType(), info.StatusDescription, info.Headers); err != nil { + if err := g.setOperationResponse(op, out, strconv.Itoa(info.StatusCode), tonic.MediaType(), info.StatusDescription, info.Headers, nil, nil); err != nil { return nil, err } // Generate additional responses from the operation @@ -287,6 +287,8 @@ func (g *Generator) AddOperation(path, method, tag string, in, out reflect.Type, tonic.MediaType(), resp.Description, resp.Headers, + resp.Example, + resp.Examples, ); err != nil { return nil, err } @@ -345,11 +347,16 @@ func isResponseCodeRange(code string) bool { // setOperationResponse adds a response to the operation that // return the type t with the given media type and status code. -func (g *Generator) setOperationResponse(op *Operation, t reflect.Type, code, mt, desc string, headers []*ResponseHeader) error { +func (g *Generator) setOperationResponse(op *Operation, t reflect.Type, code, mt, desc string, headers []*ResponseHeader, example interface{}, examples map[string]interface{}) error { if _, ok := op.Responses[code]; ok { // A response already exists for this code. return fmt.Errorf("response with code %s already exists", code) } + if example != nil && examples != nil { + // Cannot set both 'example' and 'examples' values + return fmt.Errorf("'example' and 'examples' are mutually exclusive") + } + // Check that the response code is valid per the spec: // https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#patterned-fields-1 if code != "default" { @@ -371,12 +378,24 @@ func (g *Generator) setOperationResponse(op *Operation, t reflect.Type, code, mt Content: make(map[string]*MediaTypeOrRef), Headers: make(map[string]*HeaderOrRef), } + + var castedExamples map[string]*ExampleOrRef + if examples != nil { + castedExamples = make(map[string]*ExampleOrRef) + for name, val := range examples { + castedExamples[name] = &ExampleOrRef{Example: &Example{Value: val}} + } + } + // The response may have no content type specified, // in which case we don't assign a schema. schema := g.newSchemaFromType(t) - if schema != nil { + + if schema != nil || example != nil || castedExamples != nil { r.Content[mt] = &MediaTypeOrRef{MediaType: &MediaType{ - Schema: schema, + Schema: schema, + Example: example, + Examples: castedExamples, }} } // Assign headers. diff --git a/openapi/generator_test.go b/openapi/generator_test.go index 1dbfd6c..63c8729 100644 --- a/openapi/generator_test.go +++ b/openapi/generator_test.go @@ -364,13 +364,13 @@ func TestAddOperation(t *testing.T) { Summary: "ABC", Description: "XYZ", Deprecated: true, - Responses: []*OperationReponse{ - &OperationReponse{ + Responses: []*OperationResponse{ + &OperationResponse{ Code: "400", Description: "Bad Request", Model: CustomError{}, }, - &OperationReponse{ + &OperationResponse{ Code: "5XX", Description: "Server Errors", }, @@ -516,21 +516,75 @@ func TestSetOperationResponseError(t *testing.T) { op := &Operation{ Responses: make(Responses), } - err := g.setOperationResponse(op, reflect.TypeOf(new(string)), "200", "application/json", "", nil) + err := g.setOperationResponse(op, reflect.TypeOf(new(string)), "200", "application/json", "", nil, nil, nil) assert.Nil(t, err) // Add another response with same code. - err = g.setOperationResponse(op, reflect.TypeOf(new(int)), "200", "application/xml", "", nil) + err = g.setOperationResponse(op, reflect.TypeOf(new(int)), "200", "application/xml", "", nil, nil, nil) assert.NotNil(t, err) // Add invalid response code that cannot // be converted to an integer. - err = g.setOperationResponse(op, reflect.TypeOf(new(bool)), "two-hundred", "", "", nil) + err = g.setOperationResponse(op, reflect.TypeOf(new(bool)), "two-hundred", "", "", nil, nil, nil) assert.NotNil(t, err) // Add out of range response code. - err = g.setOperationResponse(op, reflect.TypeOf(new(bool)), "777", "", "", nil) + err = g.setOperationResponse(op, reflect.TypeOf(new(bool)), "777", "", "", nil, nil, nil) assert.NotNil(t, err) + + // Cannot set both example and examples + err = g.setOperationResponse(op, reflect.TypeOf(new(bool)), "404", "", "", nil, "notFoundExample", map[string]interface{}{"badRequest": "message"}) + assert.NotNil(t, err) +} + +// TestSetOperationResponseExample tests that +// one example is set correctly. +func TestSetOperationResponseExample(t *testing.T) { + g := gen(t) + op := &Operation{ + Responses: make(Responses), + } + + error1 := map[string]interface{}{"error": "message1"} + + err := g.setOperationResponse(op, reflect.TypeOf(new(string)), "400", "application/json", "", nil, error1, nil) + assert.Nil(t, err) + + // assert example set correctly + mt := op.Responses["400"].Response.Content["application/json"].MediaType + assert.Equal(t, error1, mt.Example) + + // examples should be empty + assert.Nil(t, mt.Examples) +} + +// TestSetOperationResponseExamples tests that +// multiple examples are set correctly. +func TestSetOperationResponseExamples(t *testing.T) { + g := gen(t) + op := &Operation{ + Responses: make(Responses), + } + + error1 := map[string]interface{}{"error": "message1"} + error2 := map[string]interface{}{"error": "message2"} + + err := g.setOperationResponse(op, reflect.TypeOf(new(string)), "400", "application/json", "", nil, nil, + map[string]interface{}{ + "one": error1, + "two": error2, + }, + ) + assert.Nil(t, err) + + // assert examples set correctly + mt := op.Responses["400"].Response.Content["application/json"].MediaType + assert.Equal(t, 2, len(mt.Examples)) + assert.Equal(t, error1, mt.Examples["one"].Example.Value) + assert.Equal(t, error2, mt.Examples["two"].Example.Value) + + // example should be empty + assert.Nil(t, mt.Example) } // TestSetOperationParamsError tests the various error diff --git a/openapi/operation.go b/openapi/operation.go index 3238705..880a453 100644 --- a/openapi/operation.go +++ b/openapi/operation.go @@ -11,7 +11,7 @@ type OperationInfo struct { Description string Deprecated bool InputModel interface{} - Responses []*OperationReponse + Responses []*OperationResponse } // ResponseHeader represents a single header that @@ -22,13 +22,15 @@ type ResponseHeader struct { Model interface{} } -// OperationReponse represents a single response of an +// OperationResponse represents a single response of an // API operation. -type OperationReponse struct { +type OperationResponse struct { // The response code can be "default" // according to OAS3 spec. Code string Description string Model interface{} Headers []*ResponseHeader + Example interface{} + Examples map[string]interface{} } diff --git a/testdata/spec.json b/testdata/spec.json index ebee5a2..30d8cb1 100644 --- a/testdata/spec.json +++ b/testdata/spec.json @@ -38,6 +38,35 @@ } } }, + "400": { + "description": "Bad Request", + "content": { + "application/json": { + "schema": { + "type": "string" + }, + "examples": { + "one": { + "value": "message1" + }, + "two": { + "value": "message2" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json": { + "schema": { + "type": "string" + }, + "example": "not-found-example" + } + } + }, "429": { "description": "Too Many Requests", "headers": { @@ -140,4 +169,4 @@ } } } -} \ No newline at end of file +} diff --git a/testdata/spec.yaml b/testdata/spec.yaml index 9eb4dff..09b1952 100644 --- a/testdata/spec.yaml +++ b/testdata/spec.yaml @@ -28,6 +28,24 @@ paths: description: Unique request ID schema: type: string + '400': + description: Bad Request + content: + application/json: + schema: + type: string + examples: + one: + value: message1 + two: + value: message2 + '404': + description: Not Found + content: + application/json: + schema: + type: string + example: not-found-example '429': description: Too Many Requests headers: