Skip to content

Commit

Permalink
surface: Set status and content-type in response fields (#385)
Browse files Browse the repository at this point in the history
Ensures both V2 and V3 derived APIs include the content/type values in
the surface fields names like '200 application/json'.
  • Loading branch information
emcfarlane committed May 26, 2023
1 parent dd1001c commit 987797b
Show file tree
Hide file tree
Showing 8 changed files with 440 additions and 62 deletions.
64 changes: 37 additions & 27 deletions surface/model_openapiv2.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -325,22 +336,21 @@ 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
}

// 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
}
Expand All @@ -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{}

Expand Down
55 changes: 55 additions & 0 deletions surface/model_openapiv2_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
74 changes: 39 additions & 35 deletions surface/model_openapiv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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/
Expand Down
55 changes: 55 additions & 0 deletions surface/model_openapiv3_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading

0 comments on commit 987797b

Please sign in to comment.