From 987797bf3c582601a84ff0f011352e9dbd24a2d1 Mon Sep 17 00:00:00 2001 From: Edward McFarlane <3036610+emcfarlane@users.noreply.github.com> Date: Fri, 26 May 2023 23:31:15 +0100 Subject: [PATCH] surface: Set status and content-type in response fields (#385) Ensures both V2 and V3 derived APIs include the content/type values in the surface fields names like '200 application/json'. --- surface/model_openapiv2.go | 64 +++++++++++--------- surface/model_openapiv2_test.go | 55 +++++++++++++++++ surface/model_openapiv3.go | 74 ++++++++++++----------- surface/model_openapiv3_test.go | 55 +++++++++++++++++ surface/testdata/v2.0/petstore.json | 62 +++++++++++++++++++ surface/testdata/v2.0/petstore.model.json | 61 +++++++++++++++++++ surface/testdata/v3.0/petstore.json | 70 +++++++++++++++++++++ surface/testdata/v3.0/petstore.model.json | 61 +++++++++++++++++++ 8 files changed, 440 insertions(+), 62 deletions(-) create mode 100644 surface/model_openapiv2_test.go create mode 100644 surface/model_openapiv3_test.go create mode 100644 surface/testdata/v2.0/petstore.json create mode 100644 surface/testdata/v2.0/petstore.model.json create mode 100644 surface/testdata/v3.0/petstore.json create mode 100644 surface/testdata/v3.0/petstore.model.json diff --git a/surface/model_openapiv2.go b/surface/model_openapiv2.go index 9974f028..19878e17 100644 --- a/surface/model_openapiv2.go +++ b/surface/model_openapiv2.go @@ -23,25 +23,26 @@ import ( ) type OpenAPI2Builder struct { - model *Model + model *Model + document *openapiv2.Document } // NewModelFromOpenAPI2 builds a model of an API service for use in code generation. func NewModelFromOpenAPI2(document *openapiv2.Document, sourceName string) (*Model, error) { - return newOpenAPI2Builder().buildModel(document, sourceName) + return newOpenAPI2Builder(document).buildModel(document, sourceName) } -func newOpenAPI2Builder() *OpenAPI2Builder { - return &OpenAPI2Builder{model: &Model{}} +func newOpenAPI2Builder(document *openapiv2.Document) *OpenAPI2Builder { + return &OpenAPI2Builder{model: &Model{}, document: document} } // Fills the surface model with information from a parsed OpenAPI description. The surface model provides that information // in a way that is more processable by plugins like gnostic-go-generator or gnostic-grpc. // Since OpenAPI schemas can be indefinitely nested, it is a recursive approach to build all Types and Methods. // The basic idea is that whenever we have "named OpenAPI object" (e.g.: NamedSchemaOrReference, NamedMediaType) we: -// 1. Create a Type with that name -// 2. Recursively execute according methods on child schemas (see buildFromSchema function) -// 3. Return a FieldInfo object that describes how the created Type should be represented inside another Type as field. +// 1. Create a Type with that name +// 2. Recursively execute according methods on child schemas (see buildFromSchema function) +// 3. Return a FieldInfo object that describes how the created Type should be represented inside another Type as field. func (b *OpenAPI2Builder) buildModel(document *openapiv2.Document, sourceName string) (*Model, error) { b.model.Types = make([]*Type, 0) b.model.Methods = make([]*Method, 0) @@ -108,13 +109,16 @@ func (b *OpenAPI2Builder) buildFromResponseDefinitions(responses *openapiv2.Resp return } for _, namedResponse := range responses.AdditionalProperties { - fInfo := b.buildFromResponse(namedResponse.Name, namedResponse.Value) - // In certain cases no type will be created during the recursion: e.g.: the schema is of type scalar, array - // or an reference. So we check whether the surface model Type already exists, and if not then we create it. - if t := findType(b.model.Types, namedResponse.Name); t == nil { - t = makeType(namedResponse.Name) - makeFieldAndAppendToType(fInfo, t, "value") - b.model.addType(t) + for _, contentType := range b.document.Produces { + name := namedResponse.Name + " " + contentType + fInfo := b.buildFromResponse(name, namedResponse.Value) + // In certain cases no type will be created during the recursion: e.g.: the schema is of type scalar, array + // or an reference. So we check whether the surface model Type already exists, and if not then we create it. + if t := findType(b.model.Types, namedResponse.Name); t == nil { + t = makeType(namedResponse.Name) + makeFieldAndAppendToType(fInfo, t, "value") + b.model.addType(t) + } } } } @@ -208,7 +212,14 @@ func (b *OpenAPI2Builder) buildFromNamedOperation(name string, operation *openap operationResponses.Description = operationResponses.Name + " holds responses of " + name for _, namedResponse := range responses.ResponseCode { fieldInfo := b.buildFromResponseOrRef(operation.OperationId+convertStatusCodeToText(namedResponse.Name), namedResponse.Value) - makeFieldAndAppendToType(fieldInfo, operationResponses, namedResponse.Name) + produces := b.document.Produces + if operation.Produces != nil { + produces = operation.Produces + } + for _, contentType := range produces { + name := namedResponse.Name + " " + contentType + makeFieldAndAppendToType(fieldInfo, operationResponses, name) + } } if len(operationResponses.Fields) > 0 { b.model.addType(operationResponses) @@ -325,13 +336,13 @@ func (b *OpenAPI2Builder) buildFromPrimitiveItems(name string, items *openapiv2. // A helper method to differentiate between references and actual objects func (b *OpenAPI2Builder) buildFromResponseOrRef(name string, responseOrRef *openapiv2.ResponseValue) (fInfo *FieldInfo) { - fInfo = &FieldInfo{} if response := responseOrRef.GetResponse(); response != nil { - fInfo = b.buildFromResponse(name, response) - return fInfo + return b.buildFromResponse(name, response) } else if ref := responseOrRef.GetJsonReference(); ref != nil { - fInfo.fieldKind, fInfo.fieldType = FieldKind_REFERENCE, validTypeForRef(ref.XRef) - return fInfo + return &FieldInfo{ + fieldKind: FieldKind_REFERENCE, + fieldType: validTypeForRef(ref.XRef), + } } return nil } @@ -339,8 +350,7 @@ func (b *OpenAPI2Builder) buildFromResponseOrRef(name string, responseOrRef *ope // A helper method to propagate the information up the call stack func (b *OpenAPI2Builder) buildFromResponse(name string, response *openapiv2.Response) (fInfo *FieldInfo) { if response.Schema != nil && response.Schema.GetSchema() != nil { - fInfo = b.buildFromSchemaOrReference(name, response.Schema.GetSchema()) - return fInfo + return b.buildFromSchemaOrReference(name, response.Schema.GetSchema()) } return nil } @@ -358,11 +368,11 @@ func (b *OpenAPI2Builder) buildFromSchemaOrReference(name string, schema *openap } // Given an OpenAPI schema there are two possibilities: -// 1. The schema is an object/array: We create a type for the object, recursively call according methods for child -// schemas, and then return information on how to use the created Type as field. -// 2. The schema has a scalar type: We return information on how to represent a scalar schema as Field. Fields are -// created whenever Types are created (higher up in the callstack). This possibility can be considered as the "base condition" -// for the recursive approach. +// 1. The schema is an object/array: We create a type for the object, recursively call according methods for child +// schemas, and then return information on how to use the created Type as field. +// 2. The schema has a scalar type: We return information on how to represent a scalar schema as Field. Fields are +// created whenever Types are created (higher up in the callstack). This possibility can be considered as the "base condition" +// for the recursive approach. func (b *OpenAPI2Builder) buildFromSchema(name string, schema *openapiv2.Schema) (fInfo *FieldInfo) { fInfo = &FieldInfo{} diff --git a/surface/model_openapiv2_test.go b/surface/model_openapiv2_test.go new file mode 100644 index 00000000..78d002f2 --- /dev/null +++ b/surface/model_openapiv2_test.go @@ -0,0 +1,55 @@ +package surface_v1 + +import ( + "os" + "testing" + + openapiv2 "github.com/google/gnostic/openapiv2" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestModelOpenAPIV2(t *testing.T) { + refFile := "testdata/v2.0/petstore.json" + modelFile := "testdata/v2.0/petstore.model.json" + + bFile, err := os.ReadFile(refFile) + if err != nil { + t.Logf("Failed to read file: %+v", err) + t.FailNow() + } + bModel, err := os.ReadFile(modelFile) + if err != nil { + t.Logf("Failed to read file: %+v", err) + t.FailNow() + } + + docv2, err := openapiv2.ParseDocument(bFile) + if err != nil { + t.Logf("Failed to parse document: %+v", err) + t.FailNow() + } + + m, err := NewModelFromOpenAPI2(docv2, refFile) + if err != nil { + t.Logf("Failed to create model: %+v", err) + t.FailNow() + } + + var model Model + if err := protojson.Unmarshal(bModel, &model); err != nil { + t.Logf("Failed to unmarshal model: %+v", err) + t.FailNow() + } + + cmpOpts := []cmp.Option{ + protocmp.Transform(), + } + if diff := cmp.Diff(&model, m, cmpOpts...); diff != "" { + t.Errorf("Model mismatch (-want +got):\n%s", diff) + } + x, _ := protojson.Marshal(m) + t.Logf("Model: %s", x) +} diff --git a/surface/model_openapiv3.go b/surface/model_openapiv3.go index 910f5cf3..e9ecdfed 100644 --- a/surface/model_openapiv3.go +++ b/surface/model_openapiv3.go @@ -40,9 +40,9 @@ func newOpenAPI3Builder(document *openapiv3.Document) *OpenAPI3Builder { // in a way that is more processable by plugins like gnostic-go-generator or gnostic-grpc. // Since OpenAPI schemas can be indefinitely nested, it is a recursive approach to build all Types and Methods. // The basic idea is that whenever we have "named OpenAPI object" (e.g.: NamedSchemaOrReference, NamedMediaType) we: -// 1. Create a Type with that name -// 2. Recursively create sub schemas (see buildFromSchema function) -// 3. Return a FieldInfo object that describes how the created Type should be represented inside another Type as field. +// 1. Create a Type with that name +// 2. Recursively create sub schemas (see buildFromSchema function) +// 3. Return a FieldInfo object that describes how the created Type should be represented inside another Type as field. func (b *OpenAPI3Builder) buildModel(document *openapiv3.Document, sourceName string) (*Model, error) { b.model.Types = make([]*Type, 0) b.model.Methods = make([]*Method, 0) @@ -86,8 +86,10 @@ func (b *OpenAPI3Builder) buildFromComponents(components *openapiv3.Components) } for _, namedResponses := range components.GetResponses().GetAdditionalProperties() { - fInfo := b.buildFromResponseOrRef(namedResponses.Name, namedResponses.Value) - b.checkForExistence(namedResponses.Name, fInfo) + fInfos := b.buildFromResponseOrRef(namedResponses.Name, namedResponses.Value) + for _, fInfo := range fInfos { + b.checkForExistence(namedResponses.Name, fInfo) + } } for _, namedRequestBody := range components.GetRequestBodies().GetAdditionalProperties() { @@ -192,12 +194,17 @@ func (b *OpenAPI3Builder) buildFromNamedOperation(name string, operation *openap operationResponses := makeType(name + "Responses") operationResponses.Description = operationResponses.Name + " holds responses of " + name for _, namedResponse := range responses.ResponseOrReference { - fieldInfo := b.buildFromResponseOrRef(operation.OperationId+convertStatusCodeToText(namedResponse.Name), namedResponse.Value) - makeFieldAndAppendToType(fieldInfo, operationResponses, namedResponse.Name) + fieldInfos := b.buildFromResponseOrRef(namedResponse.Name, namedResponse.Value) + for _, fieldInfo := range fieldInfos { + // For responses the name of the field is contained inside fieldInfo. That is why we pass "" as fieldName. + makeFieldAndAppendToType(fieldInfo, operationResponses, "") + } } if responses.Default != nil { - fieldInfo := b.buildFromResponseOrRef(operation.OperationId+"Default", responses.Default) - makeFieldAndAppendToType(fieldInfo, operationResponses, "default") + fieldInfos := b.buildFromResponseOrRef(operation.OperationId+"Default", responses.Default) + for _, fieldInfo := range fieldInfos { + makeFieldAndAppendToType(fieldInfo, operationResponses, "default") + } } if len(operationResponses.Fields) > 0 { b.model.addType(operationResponses) @@ -280,53 +287,50 @@ func (b *OpenAPI3Builder) buildFromRequestBody(name string, reqBody *openapiv3.R } // A helper method to differentiate between references and actual objects -func (b *OpenAPI3Builder) buildFromResponseOrRef(name string, responseOrRef *openapiv3.ResponseOrReference) (fInfo *FieldInfo) { - fInfo = &FieldInfo{} +func (b *OpenAPI3Builder) buildFromResponseOrRef(name string, responseOrRef *openapiv3.ResponseOrReference) (fInfo []*FieldInfo) { if response := responseOrRef.GetResponse(); response != nil { - fInfo = b.buildFromResponse(name, response) - return fInfo + return b.buildFromResponse(name, response) } else if ref := responseOrRef.GetReference(); ref != nil { - fInfo.fieldKind, fInfo.fieldType = FieldKind_REFERENCE, validTypeForRef(ref.XRef) - return fInfo + return []*FieldInfo{{ + fieldKind: FieldKind_REFERENCE, + fieldType: validTypeForRef(ref.XRef), + }} } return nil } // Builds a Type for 'response' and returns information on how to use this Type as field. -func (b *OpenAPI3Builder) buildFromResponse(name string, response *openapiv3.Response) (fInfo *FieldInfo) { - fInfo = &FieldInfo{} - if response.Content != nil && response.Content.AdditionalProperties != nil { - schemaType := makeType(name) +func (b *OpenAPI3Builder) buildFromResponse(name string, response *openapiv3.Response) (fInfos []*FieldInfo) { + if response.Content != nil { for _, namedMediaType := range response.Content.AdditionalProperties { - fieldInfo := b.buildFromSchemaOrReference(name+namedMediaType.Name, namedMediaType.GetValue().GetSchema()) - makeFieldAndAppendToType(fieldInfo, schemaType, namedMediaType.Name) + name := name + " " + namedMediaType.Name + fieldInfo := b.buildFromSchemaOrReference(name, namedMediaType.GetValue().GetSchema()) + fieldInfo.fieldName = name + fInfos = append(fInfos, fieldInfo) } - b.model.addType(schemaType) - fInfo.fieldKind, fInfo.fieldType = FieldKind_REFERENCE, schemaType.Name - return fInfo } - return nil + return } // A helper method to differentiate between references and actual objects func (b *OpenAPI3Builder) buildFromSchemaOrReference(name string, schemaOrReference *openapiv3.SchemaOrReference) (fInfo *FieldInfo) { - fInfo = &FieldInfo{} if schema := schemaOrReference.GetSchema(); schema != nil { - fInfo = b.buildFromSchema(name, schema) - return fInfo + return b.buildFromSchema(name, schema) } else if ref := schemaOrReference.GetReference(); ref != nil { - fInfo.fieldKind, fInfo.fieldType = FieldKind_REFERENCE, validTypeForRef(ref.XRef) - return fInfo + return &FieldInfo{ + fieldKind: FieldKind_REFERENCE, + fieldType: validTypeForRef(ref.XRef), + } } return nil } // Given an OpenAPI schema there are two possibilities: -// 1. The schema is an object/array: We create a type for the object, recursively call according methods for child -// schemas, and then return information on how to use the created Type as field. -// 2. The schema has a scalar type: We return information on how to represent a scalar schema as Field. Fields are -// created whenever Types are created (higher up in the callstack). This possibility can be considered as the "base condition" -// for the recursive approach. +// 1. The schema is an object/array: We create a type for the object, recursively call according methods for child +// schemas, and then return information on how to use the created Type as field. +// 2. The schema has a scalar type: We return information on how to represent a scalar schema as Field. Fields are +// created whenever Types are created (higher up in the callstack). This possibility can be considered as the "base condition" +// for the recursive approach. func (b *OpenAPI3Builder) buildFromSchema(name string, schema *openapiv3.Schema) (fInfo *FieldInfo) { fInfo = &FieldInfo{} // Data types according to: https://swagger.io/docs/specification/data-models/data-types/ diff --git a/surface/model_openapiv3_test.go b/surface/model_openapiv3_test.go new file mode 100644 index 00000000..863a1614 --- /dev/null +++ b/surface/model_openapiv3_test.go @@ -0,0 +1,55 @@ +package surface_v1 + +import ( + "os" + "testing" + + openapiv3 "github.com/google/gnostic/openapiv3" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/testing/protocmp" +) + +func TestModelOpenAPIV3(t *testing.T) { + refFile := "testdata/v3.0/petstore.json" + modelFile := "testdata/v3.0/petstore.model.json" + + bFile, err := os.ReadFile(refFile) + if err != nil { + t.Logf("Failed to read file: %+v", err) + t.FailNow() + } + bModel, err := os.ReadFile(modelFile) + if err != nil { + t.Logf("Failed to read file: %+v", err) + t.FailNow() + } + + docv3, err := openapiv3.ParseDocument(bFile) + if err != nil { + t.Logf("Failed to parse document: %+v", err) + t.FailNow() + } + + m, err := NewModelFromOpenAPI3(docv3, refFile) + if err != nil { + t.Logf("Failed to create model: %+v", err) + t.FailNow() + } + + var model Model + if err := protojson.Unmarshal(bModel, &model); err != nil { + t.Logf("Failed to unmarshal model: %+v", err) + t.FailNow() + } + + cmpOpts := []cmp.Option{ + protocmp.Transform(), + } + if diff := cmp.Diff(&model, m, cmpOpts...); diff != "" { + t.Errorf("Model mismatch (-want +got):\n%s", diff) + } + x, _ := protojson.Marshal(m) + t.Logf("Model: %s", x) +} diff --git a/surface/testdata/v2.0/petstore.json b/surface/testdata/v2.0/petstore.json new file mode 100644 index 00000000..e0784cbd --- /dev/null +++ b/surface/testdata/v2.0/petstore.json @@ -0,0 +1,62 @@ +{ + "swagger": "2.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "license": { + "name": "MIT" + } + }, + "host": "petstore.swagger.io", + "basePath": "/v1", + "schemes": ["http"], + "consumes": ["application/json"], + "produces": ["application/json"], + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "produces": ["application/json", "application/xml"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "type": "integer", + "format": "int32" + } + ], + "responses": { + "200": { + "description": "A list of pets.", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/Pet" + } + } + } + } + } + } + }, + "definitions": { + "Pet": { + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } +} diff --git a/surface/testdata/v2.0/petstore.model.json b/surface/testdata/v2.0/petstore.model.json new file mode 100644 index 00000000..ae52635a --- /dev/null +++ b/surface/testdata/v2.0/petstore.model.json @@ -0,0 +1,61 @@ +{ + "name": "Swagger Petstore", + "types": [ + { + "name": "Pet", + "fields": [ + { + "name": "id", + "type": "integer", + "format": "int64" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "tag", + "type": "string" + } + ] + }, + { + "name": "ListPetsParameters", + "description": "ListPetsParameters holds parameters to ListPets", + "fields": [ + { + "name": "limit", + "type": "integer", + "format": "int32", + "position": "QUERY" + } + ] + }, + { + "name": "ListPetsResponses", + "description": "ListPetsResponses holds responses of ListPets", + "fields": [ + { + "name": "200 application/json", + "type": "Pet", + "kind": "ARRAY" + }, + { + "name": "200 application/xml", + "type": "Pet", + "kind": "ARRAY" + } + ] + } + ], + "methods": [ + { + "operation": "listPets", + "path": "/pets", + "method": "GET", + "name": "ListPets", + "parametersTypeName": "ListPetsParameters", + "responsesTypeName": "ListPetsResponses" + } + ] +} diff --git a/surface/testdata/v3.0/petstore.json b/surface/testdata/v3.0/petstore.json new file mode 100644 index 00000000..98522c74 --- /dev/null +++ b/surface/testdata/v3.0/petstore.json @@ -0,0 +1,70 @@ +{ + "openapi": "3.0", + "info": { + "version": "1.0.0", + "title": "OpenAPI Petstore" + }, + "paths": { + "/pets": { + "get": { + "summary": "List all pets", + "operationId": "listPets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "description": "How many items to return at one time (max 100)", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "A list of pets.", + "content": { + "application/xml": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + }, + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "required": ["id", "name"], + "properties": { + "id": { + "type": "integer", + "format": "int64" + }, + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + } + } + } +} diff --git a/surface/testdata/v3.0/petstore.model.json b/surface/testdata/v3.0/petstore.model.json new file mode 100644 index 00000000..8e7ae853 --- /dev/null +++ b/surface/testdata/v3.0/petstore.model.json @@ -0,0 +1,61 @@ +{ + "name": "OpenAPI Petstore", + "types": [ + { + "name": "Pet", + "fields": [ + { + "name": "id", + "type": "integer", + "format": "int64" + }, + { + "name": "name", + "type": "string" + }, + { + "name": "tag", + "type": "string" + } + ] + }, + { + "name": "ListPetsParameters", + "description": "ListPetsParameters holds parameters to ListPets", + "fields": [ + { + "name": "limit", + "type": "integer", + "format": "int32", + "position": "QUERY" + } + ] + }, + { + "name": "ListPetsResponses", + "description": "ListPetsResponses holds responses of ListPets", + "fields": [ + { + "name": "200 application/xml", + "type": "Pet", + "kind": "ARRAY" + }, + { + "name": "200 application/json", + "type": "Pet", + "kind": "ARRAY" + } + ] + } + ], + "methods": [ + { + "operation": "listPets", + "path": "/pets", + "method": "GET", + "name": "ListPets", + "parametersTypeName": "ListPetsParameters", + "responsesTypeName": "ListPetsResponses" + } + ] +}