diff --git a/datamodel/datamodel.go b/datamodel/datamodel.go new file mode 100644 index 0000000..ee7e9e2 --- /dev/null +++ b/datamodel/datamodel.go @@ -0,0 +1,16 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +// Package datamodel contains two sets of models, high and low. +// +// The low level (or plumbing) models are designed to capture every single detail about specification, including +// all lines, columns, positions, tags, comments and essentially everything you would ever want to know. +// Positions of every key, value and meta-data that is lost when blindly un-marshaling JSON/YAML into a struct. +// +// The high model (porcelain) is a much simpler representation of the low model, keys are simple strings and indices +// are numbers. When developing consumers of the model, the high model is really what you want to use instead of the +// low model, it's much easier to navigate and is designed for easy consumption. +// +// The high model requires the low model to be built. Every high model has a 'GoLow' method that allows the consumer +// to 'drop down' from the porcelain API to the plumbing API, which gives instant access to everything low. +package datamodel diff --git a/datamodel/model_utils.go b/datamodel/model_utils.go index 5cbd7a7..9f0ce6e 100644 --- a/datamodel/model_utils.go +++ b/datamodel/model_utils.go @@ -1,224 +1,38 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + package datamodel import ( _ "embed" - "encoding/json" - "errors" - "fmt" - "github.com/pb33f/libopenapi/utils" - "gopkg.in/yaml.v3" - "strings" ) +// Constants used by utilities to determine the version of OpenAPI that we're referring to. const ( OAS2 = "oas2" OAS3 = "oas3" OAS31 = "oas3_1" ) +// OpenAPI3SchemaData is an embedded version of the OpenAPI 3 Schema //go:embed schemas/oas3-schema.json -var OpenAPI3SchemaData string +var OpenAPI3SchemaData string // embedded OAS3 schema +// OpenAPI2SchemaData is an embedded version of the OpenAPI 2 (Swagger) Schema //go:embed schemas/swagger2-schema.json -var OpenAPI2SchemaData string +var OpenAPI2SchemaData string // embedded OAS3 schema +// OAS3_1Format defines documents that can only be version 3.1 var OAS3_1Format = []string{OAS31} + +// OAS3Format defines documents that can only be version 3.0 var OAS3Format = []string{OAS3} + +// OAS3AllFormat defines documents that compose all 3+ versions var OAS3AllFormat = []string{OAS3, OAS31} + +// OAS2Format defines documents that compose swagger documnets (version 2.0) var OAS2Format = []string{OAS2} + +// AllFormats defines all versions of OpenAPI var AllFormats = []string{OAS3, OAS31, OAS2} - -// ExtractSpecInfo will look at a supplied OpenAPI specification, and return a *SpecInfo pointer, or an error -// if the spec cannot be parsed correctly. -func ExtractSpecInfo(spec []byte) (*SpecInfo, error) { - - var parsedSpec yaml.Node - - specVersion := &SpecInfo{} - specVersion.JsonParsingChannel = make(chan bool) - - // set original bytes - specVersion.SpecBytes = &spec - - runes := []rune(strings.TrimSpace(string(spec))) - if len(runes) <= 0 { - return specVersion, errors.New("there are no runes in the spec") - } - - if runes[0] == '{' && runes[len(runes)-1] == '}' { - specVersion.SpecFileType = "json" - } else { - specVersion.SpecFileType = "yaml" - } - - err := yaml.Unmarshal(spec, &parsedSpec) - if err != nil { - return nil, fmt.Errorf("unable to parse specification: %s", err.Error()) - } - - specVersion.RootNode = &parsedSpec - - _, openAPI3 := utils.FindKeyNode(utils.OpenApi3, parsedSpec.Content) - _, openAPI2 := utils.FindKeyNode(utils.OpenApi2, parsedSpec.Content) - _, asyncAPI := utils.FindKeyNode(utils.AsyncApi, parsedSpec.Content) - - parseJSON := func(bytes []byte, spec *SpecInfo) { - var jsonSpec map[string]interface{} - - // no point in worrying about errors here, extract JSON friendly format. - // run in a separate thread, don't block. - - if spec.SpecType == utils.OpenApi3 { - spec.APISchema = OpenAPI3SchemaData - } - if spec.SpecType == utils.OpenApi2 { - spec.APISchema = OpenAPI2SchemaData - } - - if utils.IsYAML(string(bytes)) { - yaml.Unmarshal(bytes, &jsonSpec) - jsonData, _ := json.Marshal(jsonSpec) - spec.SpecJSONBytes = &jsonData - spec.SpecJSON = &jsonSpec - } else { - json.Unmarshal(bytes, &jsonSpec) - spec.SpecJSONBytes = &bytes - spec.SpecJSON = &jsonSpec - } - spec.JsonParsingChannel <- true - close(spec.JsonParsingChannel) - } - // check for specific keys - if openAPI3 != nil { - specVersion.SpecType = utils.OpenApi3 - version, majorVersion, versionError := parseVersionTypeData(openAPI3.Value) - if versionError != nil { - return nil, versionError - } - - // parse JSON - go parseJSON(spec, specVersion) - - // double check for the right version, people mix this up. - if majorVersion < 3 { - specVersion.Error = errors.New("spec is defined as an openapi spec, but is using a swagger (2.0), or unknown version") - return specVersion, specVersion.Error - } - specVersion.Version = version - specVersion.SpecFormat = OAS3 - } - if openAPI2 != nil { - specVersion.SpecType = utils.OpenApi2 - version, majorVersion, versionError := parseVersionTypeData(openAPI2.Value) - if versionError != nil { - return nil, versionError - } - - // parse JSON - go parseJSON(spec, specVersion) - - // I am not certain this edge-case is very frequent, but let's make sure we handle it anyway. - if majorVersion > 2 { - specVersion.Error = errors.New("spec is defined as a swagger (openapi 2.0) spec, but is an openapi 3 or unknown version") - return specVersion, specVersion.Error - } - specVersion.Version = version - specVersion.SpecFormat = OAS2 - } - if asyncAPI != nil { - specVersion.SpecType = utils.AsyncApi - version, majorVersion, versionErr := parseVersionTypeData(asyncAPI.Value) - if versionErr != nil { - return nil, versionErr - } - - // parse JSON - go parseJSON(spec, specVersion) - - // so far there is only 2 as a major release of AsyncAPI - if majorVersion > 2 { - specVersion.Error = errors.New("spec is defined as asyncapi, but has a major version that is invalid") - return specVersion, specVersion.Error - } - specVersion.Version = version - // TODO: format for AsyncAPI. - - } - - if specVersion.SpecType == "" { - - // parse JSON - go parseJSON(spec, specVersion) - - specVersion.Error = errors.New("spec type not supported by vacuum, sorry") - return specVersion, specVersion.Error - } - - return specVersion, nil -} - -func parseVersionTypeData(d interface{}) (string, int, error) { - r := []rune(strings.TrimSpace(fmt.Sprintf("%v", d))) - if len(r) <= 0 { - return "", 0, fmt.Errorf("unable to extract version from: %v", d) - } - return string(r), int(r[0]) - '0', nil -} - -// AreValuesCorrectlyTyped will look through an array of unknown values and check they match -// against the supplied type as a string. The return value is empty if everything is OK, or it -// contains failures in the form of a value as a key and a message as to why it's not valid -func AreValuesCorrectlyTyped(valType string, values interface{}) map[string]string { - var arr []interface{} - if _, ok := values.([]interface{}); !ok { - return nil - } - arr = values.([]interface{}) - - results := make(map[string]string) - for _, v := range arr { - switch v.(type) { - case string: - if valType != "string" { - results[v.(string)] = fmt.Sprintf("enum value '%v' is a "+ - "string, but it's defined as a '%v'", v, valType) - } - case int64: - if valType != "integer" && valType != "number" { - results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ - "integer, but it's defined as a '%v'", v, valType) - } - case int: - if valType != "integer" && valType != "number" { - results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ - "integer, but it's defined as a '%v'", v, valType) - } - case float64: - if valType != "number" { - results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ - "number, but it's defined as a '%v'", v, valType) - } - case bool: - if valType != "boolean" { - results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ - "boolean, but it's defined as a '%v'", v, valType) - } - } - } - return results -} - -// CheckEnumForDuplicates will check an array of nodes to check if there are any duplicates. -func CheckEnumForDuplicates(seq []*yaml.Node) []*yaml.Node { - var res []*yaml.Node - seen := make(map[string]*yaml.Node) - - for _, enum := range seq { - if seen[enum.Value] != nil { - res = append(res, enum) - continue - } - seen[enum.Value] = enum - } - return res -} diff --git a/datamodel/model_utils_test.go b/datamodel/model_utils_test.go index 18bef68..025a443 100644 --- a/datamodel/model_utils_test.go +++ b/datamodel/model_utils_test.go @@ -1,274 +1 @@ package datamodel - -import ( - "github.com/pb33f/libopenapi/utils" - "github.com/stretchr/testify/assert" - "gopkg.in/yaml.v3" - "testing" -) - -const ( - // OpenApi3 is used by all OpenAPI 3+ docs - OpenApi3 = "openapi" - - // OpenApi2 is used by all OpenAPI 2 docs, formerly known as swagger. - OpenApi2 = "swagger" - - // AsyncApi is used by akk AsyncAPI docs, all versions. - AsyncApi = "asyncapi" -) - -var goodJSON = `{"name":"kitty", "noises":["meow","purrrr","gggrrraaaaaooooww"]}` -var badJSON = `{"name":"kitty, "noises":[{"meow","purrrr","gggrrraaaaaooooww"]}}` -var goodYAML = `name: kitty -noises: -- meow -- purrr -- gggggrrraaaaaaaaaooooooowwwwwww -` - -var badYAML = `name: kitty - noises: - - meow - - purrr - - gggggrrraaaaaaaaaooooooowwwwwww -` - -var OpenApiWat = `openapi: 3.2 -info: - title: Test API, valid, but not quite valid -servers: - - url: https://quobix.com/api` - -var OpenApiFalse = `openapi: false -info: - title: Test API version is a bool? -servers: - - url: https://quobix.com/api` - -var OpenApiOne = `openapi: 1.0.1 -info: - title: Test API version is what version? -servers: - - url: https://quobix.com/api` - -var OpenApi3Spec = `openapi: 3.0.1 -info: - title: Test API -tags: - - name: "Test" - - name: "Test 2" -servers: - - url: https://quobix.com/api` - -var OpenApi2Spec = `swagger: 2.0.1 -info: - title: Test API -tags: - - name: "Test" -servers: - - url: https://quobix.com/api` - -var OpenApi2SpecOdd = `swagger: 3.0.1 -info: - title: Test API -tags: - - name: "Test" -servers: - - url: https://quobix.com/api` - -var AsyncAPISpec = `asyncapi: 2.0.0 -info: - title: Hello world application - version: '0.1.0' -channels: - hello: - publish: - message: - payload: - type: string - pattern: '^hello .+$'` - -var AsyncAPISpecOdd = `asyncapi: 3.0.0 -info: - title: Hello world application - version: '0.1.0'` - -func TestExtractSpecInfo_ValidJSON(t *testing.T) { - r, e := ExtractSpecInfo([]byte(goodJSON)) - <-r.JsonParsingChannel - assert.Greater(t, len(*r.SpecJSONBytes), 0) - assert.Error(t, e) -} - -func TestExtractSpecInfo_InvalidJSON(t *testing.T) { - _, e := ExtractSpecInfo([]byte(badJSON)) - assert.Error(t, e) -} - -func TestExtractSpecInfo_Nothing(t *testing.T) { - _, e := ExtractSpecInfo([]byte("")) - assert.Error(t, e) -} - -func TestExtractSpecInfo_ValidYAML(t *testing.T) { - r, e := ExtractSpecInfo([]byte(goodYAML)) - <-r.JsonParsingChannel - assert.Greater(t, len(*r.SpecJSONBytes), 0) - assert.Error(t, e) -} - -func TestExtractSpecInfo_InvalidYAML(t *testing.T) { - _, e := ExtractSpecInfo([]byte(badYAML)) - assert.Error(t, e) -} - -func TestExtractSpecInfo_InvalidOpenAPIVersion(t *testing.T) { - _, e := ExtractSpecInfo([]byte(OpenApiOne)) - assert.Error(t, e) -} - -func TestExtractSpecInfo_OpenAPI3(t *testing.T) { - - r, e := ExtractSpecInfo([]byte(OpenApi3Spec)) - assert.Nil(t, e) - assert.Equal(t, utils.OpenApi3, r.SpecType) - assert.Equal(t, "3.0.1", r.Version) - - <-r.JsonParsingChannel - assert.Greater(t, len(*r.SpecJSONBytes), 0) - -} - -func TestExtractSpecInfo_OpenAPIWat(t *testing.T) { - - r, e := ExtractSpecInfo([]byte(OpenApiWat)) - assert.Nil(t, e) - assert.Equal(t, OpenApi3, r.SpecType) - assert.Equal(t, "3.2", r.Version) -} - -func TestExtractSpecInfo_OpenAPIFalse(t *testing.T) { - - spec, e := ExtractSpecInfo([]byte(OpenApiFalse)) - assert.NoError(t, e) - assert.Equal(t, "false", spec.Version) -} - -func TestExtractSpecInfo_OpenAPI2(t *testing.T) { - - r, e := ExtractSpecInfo([]byte(OpenApi2Spec)) - assert.Nil(t, e) - assert.Equal(t, OpenApi2, r.SpecType) - assert.Equal(t, "2.0.1", r.Version) - - <-r.JsonParsingChannel - assert.Greater(t, len(*r.SpecJSONBytes), 0) -} - -func TestExtractSpecInfo_OpenAPI2_OddVersion(t *testing.T) { - - _, e := ExtractSpecInfo([]byte(OpenApi2SpecOdd)) - assert.NotNil(t, e) - assert.Equal(t, - "spec is defined as a swagger (openapi 2.0) spec, but is an openapi 3 or unknown version", e.Error()) -} - -func TestExtractSpecInfo_AsyncAPI(t *testing.T) { - - r, e := ExtractSpecInfo([]byte(AsyncAPISpec)) - assert.Nil(t, e) - assert.Equal(t, AsyncApi, r.SpecType) - assert.Equal(t, "2.0.0", r.Version) - <-r.JsonParsingChannel - assert.Greater(t, len(*r.SpecJSONBytes), 0) -} - -func TestExtractSpecInfo_AsyncAPI_OddVersion(t *testing.T) { - - _, e := ExtractSpecInfo([]byte(AsyncAPISpecOdd)) - assert.NotNil(t, e) - assert.Equal(t, - "spec is defined as asyncapi, but has a major version that is invalid", e.Error()) -} - -func TestAreValuesCorrectlyTyped(t *testing.T) { - - assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{"hi"}), 0) - assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{1}), 1) - assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{"nice", 123, int64(12345)}), 2) - assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{1.2, "burgers"}), 1) - assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{true, false, "what"}), 2) - - assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{1, 2, 3, 4}), 0) - assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{"no way!"}), 1) - assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{"nice", 123, int64(12345)}), 1) - assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{999, 1.2, "burgers"}), 2) - assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{true, false, "what"}), 3) - - assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{1.2345}), 0) - assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{"no way!"}), 1) - assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{"nice", 123, 2.353}), 1) - assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{999, 1.2, "burgers"}), 1) - assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{true, false, "what"}), 3) - - assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{true, false, true}), 0) - assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{"no way!"}), 1) - assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{"nice", 123, 2.353, true}), 3) - assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{true, true, "burgers"}), 1) - assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{true, false, "what", 1.2, 4}), 3) - - assert.Nil(t, AreValuesCorrectlyTyped("boolean", []string{"hi"})) - -} - -func TestCheckEnumForDuplicates_Success(t *testing.T) { - yml := "- yes\n- no\n- crisps" - var rootNode yaml.Node - yaml.Unmarshal([]byte(yml), &rootNode) - assert.Len(t, CheckEnumForDuplicates(rootNode.Content[0].Content), 0) - -} - -func TestCheckEnumForDuplicates_Fail(t *testing.T) { - yml := "- yes\n- no\n- crisps\n- no" - var rootNode yaml.Node - yaml.Unmarshal([]byte(yml), &rootNode) - assert.Len(t, CheckEnumForDuplicates(rootNode.Content[0].Content), 1) - -} - -func TestCheckEnumForDuplicates_FailMultiple(t *testing.T) { - yml := "- yes\n- no\n- crisps\n- no\n- rice\n- yes\n- no" - - var rootNode yaml.Node - yaml.Unmarshal([]byte(yml), &rootNode) - assert.Len(t, CheckEnumForDuplicates(rootNode.Content[0].Content), 3) -} - -func TestExtractSpecInfo_BadVersion_OpenAPI3(t *testing.T) { - - yml := `openapi: - should: fail` - - _, err := ExtractSpecInfo([]byte(yml)) - assert.Error(t, err) -} - -func TestExtractSpecInfo_BadVersion_Swagger(t *testing.T) { - - yml := `swagger: - should: fail` - - _, err := ExtractSpecInfo([]byte(yml)) - assert.Error(t, err) -} - -func TestExtractSpecInfo_BadVersion_AsyncAPI(t *testing.T) { - - yml := `asyncapi: - should: fail` - - _, err := ExtractSpecInfo([]byte(yml)) - assert.Error(t, err) -} diff --git a/datamodel/spec_info.go b/datamodel/spec_info.go index 33ac459..ed0d141 100644 --- a/datamodel/spec_info.go +++ b/datamodel/spec_info.go @@ -4,18 +4,23 @@ package datamodel import ( + "encoding/json" + "errors" + "fmt" + "github.com/pb33f/libopenapi/utils" "gopkg.in/yaml.v3" + "strings" "time" ) -// SpecInfo represents information about a supplied specification. +// SpecInfo represents a 'ready-to-process' OpenAPI Document. type SpecInfo struct { SpecType string `json:"type"` Version string `json:"version"` SpecFormat string `json:"format"` SpecFileType string `json:"fileType"` + SpecBytes *[]byte `json:"bytes"` // the original byte array RootNode *yaml.Node `json:"-"` // reference to the root node of the spec. - SpecBytes *[]byte `json:"bytes"` // the original bytes SpecJSONBytes *[]byte `json:"-"` // original bytes converted to JSON SpecJSON *map[string]interface{} `json:"-"` // standard JSON map of original bytes Error error `json:"-"` // something go wrong? @@ -25,7 +30,149 @@ type SpecInfo struct { } // GetJSONParsingChannel returns a channel that will close once async JSON parsing is completed. -// This is required as rules may start executing before we're even done reading in the spec to JSON. +// This is really useful if your application wants to analyze the JSON via SpecJSON. the library will +// return *SpecInfo BEFORE the JSON is done parsing, so things are as fast as possible. +// +// If you want to know when parsing is done, listen on the channel for a bool. func (si SpecInfo) GetJSONParsingChannel() chan bool { return si.JsonParsingChannel } + +// ExtractSpecInfo accepts an OpenAPI/Swagger specification that has been read into a byte array +// and will return a *SpecInfo pointer, which contains details on the version and an un-marshaled +// *yaml.Node root node tree. The root node tree is what's used by the library when building out models. +// +// If the spec cannot be parsed correctly then an error will be returned, otherwise the error is nil. +func ExtractSpecInfo(spec []byte) (*SpecInfo, error) { + + var parsedSpec yaml.Node + + specVersion := &SpecInfo{} + specVersion.JsonParsingChannel = make(chan bool) + + // set original bytes + specVersion.SpecBytes = &spec + + runes := []rune(strings.TrimSpace(string(spec))) + if len(runes) <= 0 { + return specVersion, errors.New("there is nothing in the spec, it's empty - so there is nothing to be done") + } + + if runes[0] == '{' && runes[len(runes)-1] == '}' { + specVersion.SpecFileType = "json" + } else { + specVersion.SpecFileType = "yaml" + } + + err := yaml.Unmarshal(spec, &parsedSpec) + if err != nil { + return nil, fmt.Errorf("unable to parse specification: %s", err.Error()) + } + + specVersion.RootNode = &parsedSpec + + _, openAPI3 := utils.FindKeyNode(utils.OpenApi3, parsedSpec.Content) + _, openAPI2 := utils.FindKeyNode(utils.OpenApi2, parsedSpec.Content) + _, asyncAPI := utils.FindKeyNode(utils.AsyncApi, parsedSpec.Content) + + parseJSON := func(bytes []byte, spec *SpecInfo) { + var jsonSpec map[string]interface{} + + // no point in worrying about errors here, extract JSON friendly format. + // run in a separate thread, don't block. + + if spec.SpecType == utils.OpenApi3 { + spec.APISchema = OpenAPI3SchemaData + } + if spec.SpecType == utils.OpenApi2 { + spec.APISchema = OpenAPI2SchemaData + } + + if utils.IsYAML(string(bytes)) { + _ = yaml.Unmarshal(bytes, &jsonSpec) + jsonData, _ := json.Marshal(jsonSpec) + spec.SpecJSONBytes = &jsonData + spec.SpecJSON = &jsonSpec + } else { + _ = json.Unmarshal(bytes, &jsonSpec) + spec.SpecJSONBytes = &bytes + spec.SpecJSON = &jsonSpec + } + spec.JsonParsingChannel <- true + close(spec.JsonParsingChannel) + } + // check for specific keys + if openAPI3 != nil { + specVersion.SpecType = utils.OpenApi3 + version, majorVersion, versionError := parseVersionTypeData(openAPI3.Value) + if versionError != nil { + return nil, versionError + } + + // parse JSON + go parseJSON(spec, specVersion) + + // double check for the right version, people mix this up. + if majorVersion < 3 { + specVersion.Error = errors.New("spec is defined as an openapi spec, but is using a swagger (2.0), or unknown version") + return specVersion, specVersion.Error + } + specVersion.Version = version + specVersion.SpecFormat = OAS3 + } + if openAPI2 != nil { + specVersion.SpecType = utils.OpenApi2 + version, majorVersion, versionError := parseVersionTypeData(openAPI2.Value) + if versionError != nil { + return nil, versionError + } + + // parse JSON + go parseJSON(spec, specVersion) + + // I am not certain this edge-case is very frequent, but let's make sure we handle it anyway. + if majorVersion > 2 { + specVersion.Error = errors.New("spec is defined as a swagger (openapi 2.0) spec, but is an openapi 3 or unknown version") + return specVersion, specVersion.Error + } + specVersion.Version = version + specVersion.SpecFormat = OAS2 + } + if asyncAPI != nil { + specVersion.SpecType = utils.AsyncApi + version, majorVersion, versionErr := parseVersionTypeData(asyncAPI.Value) + if versionErr != nil { + return nil, versionErr + } + + // parse JSON + go parseJSON(spec, specVersion) + + // so far there is only 2 as a major release of AsyncAPI + if majorVersion > 2 { + specVersion.Error = errors.New("spec is defined as asyncapi, but has a major version that is invalid") + return specVersion, specVersion.Error + } + specVersion.Version = version + // TODO: format for AsyncAPI. + + } + + if specVersion.SpecType == "" { + // parse JSON + go parseJSON(spec, specVersion) + + specVersion.Error = errors.New("spec type not supported by vacuum, sorry") + return specVersion, specVersion.Error + } + return specVersion, nil +} + +// extract version number from specification +func parseVersionTypeData(d interface{}) (string, int, error) { + r := []rune(strings.TrimSpace(fmt.Sprintf("%v", d))) + if len(r) <= 0 { + return "", 0, fmt.Errorf("unable to extract version from: %v", d) + } + return string(r), int(r[0]) - '0', nil +} diff --git a/datamodel/spec_info_test.go b/datamodel/spec_info_test.go index 0e24f26..5b4c324 100644 --- a/datamodel/spec_info_test.go +++ b/datamodel/spec_info_test.go @@ -4,15 +4,228 @@ package datamodel import ( - "github.com/stretchr/testify/assert" - "testing" + "github.com/pb33f/libopenapi/utils" + "github.com/stretchr/testify/assert" + "testing" +) + +const ( + // OpenApi3 is used by all OpenAPI 3+ docs + OpenApi3 = "openapi" + + // OpenApi2 is used by all OpenAPI 2 docs, formerly known as swagger. + OpenApi2 = "swagger" + + // AsyncApi is used by akk AsyncAPI docs, all versions. + AsyncApi = "asyncapi" ) func TestSpecInfo_GetJSONParsingChannel(t *testing.T) { - // dumb, but we need to ensure coverage is as high as we can make it. - bchan := make(chan bool) - si := &SpecInfo{JsonParsingChannel: bchan} - assert.Equal(t, si.GetJSONParsingChannel(), bchan) + // dumb, but we need to ensure coverage is as high as we can make it. + bchan := make(chan bool) + si := &SpecInfo{JsonParsingChannel: bchan} + assert.Equal(t, si.GetJSONParsingChannel(), bchan) } + +var goodJSON = `{"name":"kitty", "noises":["meow","purrrr","gggrrraaaaaooooww"]}` +var badJSON = `{"name":"kitty, "noises":[{"meow","purrrr","gggrrraaaaaooooww"]}}` +var goodYAML = `name: kitty +noises: +- meow +- purrr +- gggggrrraaaaaaaaaooooooowwwwwww +` + +var badYAML = `name: kitty + noises: + - meow + - purrr + - gggggrrraaaaaaaaaooooooowwwwwww +` + +var OpenApiWat = `openapi: 3.2 +info: + title: Test API, valid, but not quite valid +servers: + - url: https://quobix.com/api` + +var OpenApiFalse = `openapi: false +info: + title: Test API version is a bool? +servers: + - url: https://quobix.com/api` + +var OpenApiOne = `openapi: 1.0.1 +info: + title: Test API version is what version? +servers: + - url: https://quobix.com/api` + +var OpenApi3Spec = `openapi: 3.0.1 +info: + title: Test API +tags: + - name: "Test" + - name: "Test 2" +servers: + - url: https://quobix.com/api` + +var OpenApi2Spec = `swagger: 2.0.1 +info: + title: Test API +tags: + - name: "Test" +servers: + - url: https://quobix.com/api` + +var OpenApi2SpecOdd = `swagger: 3.0.1 +info: + title: Test API +tags: + - name: "Test" +servers: + - url: https://quobix.com/api` + +var AsyncAPISpec = `asyncapi: 2.0.0 +info: + title: Hello world application + version: '0.1.0' +channels: + hello: + publish: + message: + payload: + type: string + pattern: '^hello .+$'` + +var AsyncAPISpecOdd = `asyncapi: 3.0.0 +info: + title: Hello world application + version: '0.1.0'` + +func TestExtractSpecInfo_ValidJSON(t *testing.T) { + r, e := ExtractSpecInfo([]byte(goodJSON)) + <-r.JsonParsingChannel + assert.Greater(t, len(*r.SpecJSONBytes), 0) + assert.Error(t, e) +} + +func TestExtractSpecInfo_InvalidJSON(t *testing.T) { + _, e := ExtractSpecInfo([]byte(badJSON)) + assert.Error(t, e) +} + +func TestExtractSpecInfo_Nothing(t *testing.T) { + _, e := ExtractSpecInfo([]byte("")) + assert.Error(t, e) +} + +func TestExtractSpecInfo_ValidYAML(t *testing.T) { + r, e := ExtractSpecInfo([]byte(goodYAML)) + <-r.JsonParsingChannel + assert.Greater(t, len(*r.SpecJSONBytes), 0) + assert.Error(t, e) +} + +func TestExtractSpecInfo_InvalidYAML(t *testing.T) { + _, e := ExtractSpecInfo([]byte(badYAML)) + assert.Error(t, e) +} + +func TestExtractSpecInfo_InvalidOpenAPIVersion(t *testing.T) { + _, e := ExtractSpecInfo([]byte(OpenApiOne)) + assert.Error(t, e) +} + +func TestExtractSpecInfo_OpenAPI3(t *testing.T) { + + r, e := ExtractSpecInfo([]byte(OpenApi3Spec)) + assert.Nil(t, e) + assert.Equal(t, utils.OpenApi3, r.SpecType) + assert.Equal(t, "3.0.1", r.Version) + + <-r.JsonParsingChannel + assert.Greater(t, len(*r.SpecJSONBytes), 0) + +} + +func TestExtractSpecInfo_OpenAPIWat(t *testing.T) { + + r, e := ExtractSpecInfo([]byte(OpenApiWat)) + assert.Nil(t, e) + assert.Equal(t, OpenApi3, r.SpecType) + assert.Equal(t, "3.2", r.Version) +} + +func TestExtractSpecInfo_OpenAPIFalse(t *testing.T) { + + spec, e := ExtractSpecInfo([]byte(OpenApiFalse)) + assert.NoError(t, e) + assert.Equal(t, "false", spec.Version) +} + +func TestExtractSpecInfo_OpenAPI2(t *testing.T) { + + r, e := ExtractSpecInfo([]byte(OpenApi2Spec)) + assert.Nil(t, e) + assert.Equal(t, OpenApi2, r.SpecType) + assert.Equal(t, "2.0.1", r.Version) + + <-r.JsonParsingChannel + assert.Greater(t, len(*r.SpecJSONBytes), 0) +} + +func TestExtractSpecInfo_OpenAPI2_OddVersion(t *testing.T) { + + _, e := ExtractSpecInfo([]byte(OpenApi2SpecOdd)) + assert.NotNil(t, e) + assert.Equal(t, + "spec is defined as a swagger (openapi 2.0) spec, but is an openapi 3 or unknown version", e.Error()) +} + +func TestExtractSpecInfo_AsyncAPI(t *testing.T) { + + r, e := ExtractSpecInfo([]byte(AsyncAPISpec)) + assert.Nil(t, e) + assert.Equal(t, AsyncApi, r.SpecType) + assert.Equal(t, "2.0.0", r.Version) + <-r.JsonParsingChannel + assert.Greater(t, len(*r.SpecJSONBytes), 0) +} + +func TestExtractSpecInfo_AsyncAPI_OddVersion(t *testing.T) { + + _, e := ExtractSpecInfo([]byte(AsyncAPISpecOdd)) + assert.NotNil(t, e) + assert.Equal(t, + "spec is defined as asyncapi, but has a major version that is invalid", e.Error()) +} + +func TestExtractSpecInfo_BadVersion_OpenAPI3(t *testing.T) { + + yml := `openapi: + should: fail` + + _, err := ExtractSpecInfo([]byte(yml)) + assert.Error(t, err) +} + +func TestExtractSpecInfo_BadVersion_Swagger(t *testing.T) { + + yml := `swagger: + should: fail` + + _, err := ExtractSpecInfo([]byte(yml)) + assert.Error(t, err) +} + +func TestExtractSpecInfo_BadVersion_AsyncAPI(t *testing.T) { + + yml := `asyncapi: + should: fail` + + _, err := ExtractSpecInfo([]byte(yml)) + assert.Error(t, err) +} diff --git a/document.go b/document.go index 5cafa50..2084df6 100644 --- a/document.go +++ b/document.go @@ -4,25 +4,80 @@ package main import ( + "fmt" "github.com/pb33f/libopenapi/datamodel" v2high "github.com/pb33f/libopenapi/datamodel/high/2.0" v3high "github.com/pb33f/libopenapi/datamodel/high/3.0" + v2low "github.com/pb33f/libopenapi/datamodel/low/2.0" + v3low "github.com/pb33f/libopenapi/datamodel/low/3.0" ) -type Document[T any] struct { +type Document struct { version string info *datamodel.SpecInfo - Model T } -func (d *Document[T]) GetVersion() string { +type DocumentModel[T v2high.Swagger | v3high.Document] struct { + Model T +} + +func NewDocument(specByteArray []byte) (*Document, error) { + info, err := datamodel.ExtractSpecInfo(specByteArray) + if err != nil { + return nil, err + } + d := new(Document) + d.version = info.Version + d.info = info + return d, nil +} + +func (d *Document) GetVersion() string { return d.version } -func (d *Document[T]) BuildV2Document() (*v2high.Swagger, error) { - return nil, nil +func (d *Document) GetSpecInfo() *datamodel.SpecInfo { + return d.info } -func (d *Document[T]) BuildV3Document() (*v3high.Document, error) { - return nil, nil +func (d *Document) BuildV2Document() (*DocumentModel[v2high.Swagger], []error) { + var errors []error + if d.info == nil { + errors = append(errors, fmt.Errorf("unable to build swagger document, no specification has been loaded")) + return nil, errors + } + if d.info.SpecFormat != datamodel.OAS2 { + errors = append(errors, fmt.Errorf("unable to build swagger document, "+ + "supplied spec is a different version (%v). Try 'BuildV3Document()'", d.info.SpecFormat)) + return nil, errors + } + lowDoc, err := v2low.CreateDocument(d.info) + if err != nil { + return nil, err + } + highDoc := v2high.NewSwaggerDocument(lowDoc) + return &DocumentModel[v2high.Swagger]{ + Model: *highDoc, + }, nil +} + +func (d *Document) BuildV3Document() (*DocumentModel[v3high.Document], []error) { + var errors []error + if d.info == nil { + errors = append(errors, fmt.Errorf("unable to build document, no specification has been loaded")) + return nil, errors + } + if d.info.SpecFormat != datamodel.OAS3 { + errors = append(errors, fmt.Errorf("unable to build openapi document, "+ + "supplied spec is a different version (%v). Try 'BuildV2Document()'", d.info.SpecFormat)) + return nil, errors + } + lowDoc, err := v3low.CreateDocument(d.info) + if err != nil { + return nil, err + } + highDoc := v3high.NewDocument(lowDoc) + return &DocumentModel[v3high.Document]{ + Model: *highDoc, + }, nil } diff --git a/document_test.go b/document_test.go index d602a10..6643d88 100644 --- a/document_test.go +++ b/document_test.go @@ -4,23 +4,105 @@ package main import ( + "fmt" + "github.com/stretchr/testify/assert" "testing" ) -func TestLoadDocument_Simple(t *testing.T) { - // - //yml := `openapi: 3.0.1` - //doc, err := LoadDocument([]byte(yml)) - //assert.NoError(t, err) - //assert.Equal(t, "3.0.1", doc.GetVersion()) +func TestLoadDocument_Simple_V2(t *testing.T) { + + yml := `swagger: 2.0.1` + doc, err := NewDocument([]byte(yml)) + assert.NoError(t, err) + assert.Equal(t, "2.0.1", doc.GetVersion()) + + v2Doc, docErr := doc.BuildV2Document() + assert.Len(t, docErr, 0) + assert.NotNil(t, v2Doc) + assert.NotNil(t, doc.GetSpecInfo()) + + fmt.Print() } -func TestLoadDocument_WithInfo(t *testing.T) { +func TestLoadDocument_Simple_V2_Error(t *testing.T) { - //yml := `openapi: 3.0.1` - //doc, err := LoadDocument([]byte(yml)) - //assert.NoError(t, err) - //assert.Equal(t, "3.0.1", doc.GetVersion()) + yml := `swagger: 2.0` + doc, err := NewDocument([]byte(yml)) + assert.NoError(t, err) + v2Doc, docErr := doc.BuildV3Document() + assert.Len(t, docErr, 1) + assert.Nil(t, v2Doc) +} + +func TestLoadDocument_Simple_V2_Error_BadSpec(t *testing.T) { + + yml := `swagger: 2.0 +definitions: + thing: + $ref: bork` + doc, err := NewDocument([]byte(yml)) + assert.NoError(t, err) + + v2Doc, docErr := doc.BuildV2Document() + assert.Len(t, docErr, 1) + assert.Nil(t, v2Doc) +} + +func TestLoadDocument_Simple_V3_Error(t *testing.T) { + + yml := `openapi: 3.0.1` + doc, err := NewDocument([]byte(yml)) + assert.NoError(t, err) + + v2Doc, docErr := doc.BuildV2Document() + assert.Len(t, docErr, 1) + assert.Nil(t, v2Doc) +} + +func TestLoadDocument_Error_V2NoSpec(t *testing.T) { + + doc := new(Document) // not how this should be instantiated. + _, err := doc.BuildV2Document() + assert.Len(t, err, 1) +} + +func TestLoadDocument_Error_V3NoSpec(t *testing.T) { + + doc := new(Document) // not how this should be instantiated. + _, err := doc.BuildV3Document() + assert.Len(t, err, 1) +} + +func TestLoadDocument_Empty(t *testing.T) { + yml := `` + _, err := NewDocument([]byte(yml)) + assert.Error(t, err) +} + +func TestLoadDocument_Simple_V3(t *testing.T) { + + yml := `openapi: 3.0.1` + doc, err := NewDocument([]byte(yml)) + assert.NoError(t, err) + assert.Equal(t, "3.0.1", doc.GetVersion()) + + v3Doc, docErr := doc.BuildV3Document() + assert.Len(t, docErr, 0) + assert.NotNil(t, v3Doc) +} + +func TestLoadDocument_Simple_V3_Error_BadSpec(t *testing.T) { + + yml := `openapi: 3.0 +paths: + "/some": + $ref: bork` + doc, err := NewDocument([]byte(yml)) + assert.NoError(t, err) + + v3Doc, docErr := doc.BuildV3Document() + assert.Len(t, docErr, 1) + assert.Nil(t, v3Doc) } diff --git a/utils/type_check.go b/utils/type_check.go new file mode 100644 index 0000000..aceee4b --- /dev/null +++ b/utils/type_check.go @@ -0,0 +1,49 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package utils + +import "fmt" + +// AreValuesCorrectlyTyped will look through an array of unknown values and check they match +// against the supplied type as a string. The return value is empty if everything is OK, or it +// contains failures in the form of a value as a key and a message as to why it's not valid +func AreValuesCorrectlyTyped(valType string, values interface{}) map[string]string { + var arr []interface{} + if _, ok := values.([]interface{}); !ok { + return nil + } + arr = values.([]interface{}) + + results := make(map[string]string) + for _, v := range arr { + switch v.(type) { + case string: + if valType != "string" { + results[v.(string)] = fmt.Sprintf("enum value '%v' is a "+ + "string, but it's defined as a '%v'", v, valType) + } + case int64: + if valType != "integer" && valType != "number" { + results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ + "integer, but it's defined as a '%v'", v, valType) + } + case int: + if valType != "integer" && valType != "number" { + results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ + "integer, but it's defined as a '%v'", v, valType) + } + case float64: + if valType != "number" { + results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ + "number, but it's defined as a '%v'", v, valType) + } + case bool: + if valType != "boolean" { + results[fmt.Sprintf("%v", v)] = fmt.Sprintf("enum value '%v' is a "+ + "boolean, but it's defined as a '%v'", v, valType) + } + } + } + return results +} diff --git a/utils/type_check_test.go b/utils/type_check_test.go new file mode 100644 index 0000000..7f81b31 --- /dev/null +++ b/utils/type_check_test.go @@ -0,0 +1,37 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package utils + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestAreValuesCorrectlyTyped(t *testing.T) { + assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{"hi"}), 0) + assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{1}), 1) + assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{"nice", 123, int64(12345)}), 2) + assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{1.2, "burgers"}), 1) + assert.Len(t, AreValuesCorrectlyTyped("string", []interface{}{true, false, "what"}), 2) + + assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{1, 2, 3, 4}), 0) + assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{"no way!"}), 1) + assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{"nice", 123, int64(12345)}), 1) + assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{999, 1.2, "burgers"}), 2) + assert.Len(t, AreValuesCorrectlyTyped("integer", []interface{}{true, false, "what"}), 3) + + assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{1.2345}), 0) + assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{"no way!"}), 1) + assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{"nice", 123, 2.353}), 1) + assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{999, 1.2, "burgers"}), 1) + assert.Len(t, AreValuesCorrectlyTyped("number", []interface{}{true, false, "what"}), 3) + + assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{true, false, true}), 0) + assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{"no way!"}), 1) + assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{"nice", 123, 2.353, true}), 3) + assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{true, true, "burgers"}), 1) + assert.Len(t, AreValuesCorrectlyTyped("boolean", []interface{}{true, false, "what", 1.2, 4}), 3) + assert.Nil(t, AreValuesCorrectlyTyped("boolean", []string{"hi"})) + +} diff --git a/utils/utils.go b/utils/utils.go index 52ab310..af71bb9 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -563,3 +563,18 @@ func DetectCase(input string) Case { } return RegularCase } + +// CheckEnumForDuplicates will check an array of nodes to check if there are any duplicate values. +func CheckEnumForDuplicates(seq []*yaml.Node) []*yaml.Node { + var res []*yaml.Node + seen := make(map[string]*yaml.Node) + + for _, enum := range seq { + if seen[enum.Value] != nil { + res = append(res, enum) + continue + } + seen[enum.Value] = enum + } + return res +} diff --git a/utils/utils_test.go b/utils/utils_test.go index facf6b5..c93cf25 100644 --- a/utils/utils_test.go +++ b/utils/utils_test.go @@ -659,3 +659,27 @@ func TestIsNodeRefValue_False(t *testing.T) { assert.Nil(t, node) assert.Empty(t, val) } + +func TestCheckEnumForDuplicates_Success(t *testing.T) { + yml := "- yes\n- no\n- crisps" + var rootNode yaml.Node + yaml.Unmarshal([]byte(yml), &rootNode) + assert.Len(t, CheckEnumForDuplicates(rootNode.Content[0].Content), 0) + +} + +func TestCheckEnumForDuplicates_Fail(t *testing.T) { + yml := "- yes\n- no\n- crisps\n- no" + var rootNode yaml.Node + yaml.Unmarshal([]byte(yml), &rootNode) + assert.Len(t, CheckEnumForDuplicates(rootNode.Content[0].Content), 1) + +} + +func TestCheckEnumForDuplicates_FailMultiple(t *testing.T) { + yml := "- yes\n- no\n- crisps\n- no\n- rice\n- yes\n- no" + + var rootNode yaml.Node + yaml.Unmarshal([]byte(yml), &rootNode) + assert.Len(t, CheckEnumForDuplicates(rootNode.Content[0].Content), 3) +}