├── .gitignore ├── openapi3edit ├── examples │ └── operations │ │ └── main.go ├── null.go ├── spec_edit.go ├── paths_test.go ├── required.go ├── deprecated.go ├── operations_references_fix.go ├── README.md ├── schemas_keys.go ├── delete.go ├── multi.go └── schemas_flatten.go ├── docs ├── images │ ├── logo_swaggman_600x150.png │ ├── logo_swaggman_600x150.psd │ ├── logo_swaggman_4000x1000.png │ ├── logo_swaggman_4000x1000.psd │ └── icon_green-hat.svg ├── index.md ├── openapi3 │ └── inspect │ │ └── missing_descriptions.md ├── openapi3lint │ └── custom_rules.md ├── openapi3_fix.md └── recommendations_openapi3.md ├── cmd ├── oas3lint │ ├── policy_int-have-std-format.json │ ├── policy_api_ringcentral.json │ └── main.go ├── oas2meta │ └── main.go ├── openapi2csv │ └── main.go ├── openapi2postman │ └── main.go ├── openapi2to3 │ └── main.go ├── spectrum │ └── main.go └── oas3validate │ └── main.go ├── openapi3 ├── springopenapi3 │ ├── README.md │ └── spring_parser_test.go ├── content.go ├── copy.go ├── openapi3_url.go ├── path_test.go ├── extensions │ └── formats.go ├── components.go ├── schemas_test.go ├── constants.go ├── json_pointer.go ├── ontology.go ├── path_more.go ├── inspect.go ├── spec_table_response_status.go ├── extension_props.go ├── json_pointer_test.go ├── openapi3postman2 │ ├── configuration.go │ └── folders.go ├── merge_options.go ├── intersection.go ├── path.go ├── spec_meta.go ├── ontology │ └── tag_ontology_test.go ├── schemas.go ├── openapi3html │ ├── pageparams.go │ ├── openapi3html │ │ └── page.qtpl │ └── apiregistry │ │ └── main.go ├── visit.go ├── parameter.go └── read.go ├── examples ├── openapi3table │ ├── README.md │ ├── example_simple.html │ └── main.go ├── ringcentral-engage-digital │ ├── swaggman.cfg.json │ ├── ringcentral-engage-digital.postman2.base.json │ └── convert.go └── ringcentral │ ├── convert.go │ └── ringcentral.postman2.base.json ├── .github ├── dependabot.yaml └── workflows │ ├── lint.yaml │ └── ci.yaml ├── endpoint.go ├── openapi3lint ├── rule_collection.go ├── rule_empty.go ├── rule_example.go ├── lintutil │ ├── scope.go │ └── rules.go ├── rule_collection_simple.go ├── ruleopsummaryexist │ └── rule_operation_summary_exist.go ├── extensions │ ├── rule_collection.go │ └── ruletaggroupexist │ │ └── rule_tag_group_exist.go ├── ruleintstdformat │ └── rule_datatype_int_format_standard_exist.go ├── ruleopsummarystylefirstuppercase │ └── rule_operation_summary_first_uppercase.go ├── ruletagstylefirstuppercase │ └── rule_tag_style_first_uppercase.go ├── ruleschemareferences │ └── rule_schema_references_exist.go ├── ruleopidstyle │ └── rule_operation_operationid_style_camelcase.go ├── README.md ├── ruleschemapropenumstyle │ └── rule_schema_prop_enum_style.go ├── ruleopxpropertystringexist │ └── rule_op_xproperty_string_exist.go ├── ruleschemaobjectpropsexist │ └── rule_schema_object_props_exist.go └── rulepathparamstyle │ └── rule_path_param_style.go ├── mkdocs.yml ├── ext ├── taxonomy │ ├── taxonomy.go │ └── categories.go └── taggroups │ └── taggroups.go ├── openapi2 ├── spec_meta.go ├── convert.go ├── openapi2openapi3 │ └── convert.go ├── read.go ├── spec_more.go ├── openapi2csv │ └── spec_to_csv.go ├── copy.go ├── merge.go └── count.go ├── spec_meta.go ├── .golangci.yaml ├── postman2 ├── request.go ├── headers_util.go ├── simple │ └── postman_collection_v2_simple.go ├── collection.go └── url.go ├── LICENSE ├── go.mod └── raml08 └── raml08openapi3 └── operations.go /.gitignore: -------------------------------------------------------------------------------- 1 | _* 2 | ~$* 3 | .DS_Store 4 | .idea 5 | vendor -------------------------------------------------------------------------------- /openapi3edit/examples/operations/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | func main() { 4 | 5 | } 6 | -------------------------------------------------------------------------------- /docs/images/logo_swaggman_600x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grokify/spectrum/HEAD/docs/images/logo_swaggman_600x150.png -------------------------------------------------------------------------------- /docs/images/logo_swaggman_600x150.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grokify/spectrum/HEAD/docs/images/logo_swaggman_600x150.psd -------------------------------------------------------------------------------- /docs/images/logo_swaggman_4000x1000.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grokify/spectrum/HEAD/docs/images/logo_swaggman_4000x1000.png -------------------------------------------------------------------------------- /docs/images/logo_swaggman_4000x1000.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/grokify/spectrum/HEAD/docs/images/logo_swaggman_4000x1000.psd -------------------------------------------------------------------------------- /cmd/oas3lint/policy_int-have-std-format.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules":{ 3 | "datatype-int-format-int32-int64": { 4 | "severity": "error" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /openapi3/springopenapi3/README.md: -------------------------------------------------------------------------------- 1 | # From Spring 2 | 3 | This package provides some code useful for converting Spring Java code to OpenAPI 3.0. At this moment it only converts Spring model classes to OpenAPI 3.0 schema objects. -------------------------------------------------------------------------------- /examples/openapi3table/README.md: -------------------------------------------------------------------------------- 1 | # Create Table 2 | 3 | 1. Download RingCentral OAS2 YAML spec 4 | 1. Convert to JSON using https://github.com/bronze1man/yaml2json 5 | 1. Convert to OAS3 YAML using https://mermade.org.uk/api/v1/convert 6 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: / 5 | schedule: 6 | interval: daily 7 | - package-ecosystem: github-actions 8 | directory: / 9 | schedule: 10 | interval: daily 11 | -------------------------------------------------------------------------------- /endpoint.go: -------------------------------------------------------------------------------- 1 | package spectrum 2 | 3 | import "strings" 4 | 5 | func EndpointString(method, path string) string { 6 | method = strings.ToUpper(strings.TrimSpace(method)) 7 | path = strings.TrimSpace(path) 8 | return strings.Join([]string{method, path}, " ") 9 | } 10 | -------------------------------------------------------------------------------- /openapi3lint/rule_collection.go: -------------------------------------------------------------------------------- 1 | package openapi3lint 2 | 3 | type RuleCollections []RuleCollection 4 | 5 | type RuleCollection interface { 6 | Name() string 7 | RuleNames() []string 8 | RuleExists(ruleName string) bool 9 | Rule(ruleName string) (Rule, error) 10 | } 11 | -------------------------------------------------------------------------------- /examples/ringcentral-engage-digital/swaggman.cfg.json: -------------------------------------------------------------------------------- 1 | { 2 | "useXTagGroups": true , 3 | "postmanServerUrlApiBasePath": "1.0", 4 | "postmanServerUrl": "{{RINGCENTRAL_ENGAGE_SERVER_URL}}", 5 | "postmanHeaders": [ 6 | { 7 | "key": "Authorization", 8 | "value": "Bearer {{RINGCENTRAL_ENGAGE_ACCESS_TOKEN}}" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | lint: 6 | strategy: 7 | matrix: 8 | go-version: [1.x] 9 | platform: [ubuntu-latest] 10 | runs-on: ${{ matrix.platform }} 11 | steps: 12 | - uses: actions/checkout@v6.0.1 13 | - name: golangci-lint 14 | uses: golangci/golangci-lint-action@v9.2.0 15 | with: 16 | version: latest 17 | args: --timeout 3m --verbose -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Spectrum - OpenAPI Spec Utility 2 | 3 | Spectrum is a multi-purpose OpenAPI speec utility. 4 | 5 | It can perform the following: 6 | 7 | OpenAPI Spec 3 8 | 9 | * Convert to Postman Collection v2 10 | * Merge Multiple OAS3 Specs 11 | * Validate OAS3 Specs 12 | * Programmatically examine and modify OAS3 Specs 13 | * Programmatically fix OAS3 Specs 14 | 15 | OpenAPI Spec 2 16 | 17 | * Convert to Postman Collection v2 18 | * Merge Multiple OAS2 Specs -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Spectrum 2 | repo_url: https://github.com/grokify/spectrum 3 | edit_uri: tree/master/docs 4 | docs_dir: docs 5 | theme: readthedocs 6 | 7 | markdown_extensions: 8 | - codehilite: 9 | linenums: "None" 10 | 11 | nav: 12 | - Home: index.md 13 | - OpenAPI3Edit: 14 | - Missing Descriptions: openapi3/inspect/missing_descriptions.md 15 | - OpenAPI3Lint: 16 | - Overview: openapi3lint.md 17 | - Custom Rules: openapi3lint/custom_rules.md 18 | -------------------------------------------------------------------------------- /openapi3/content.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | oas3 "github.com/getkin/kin-openapi/openapi3" 5 | ) 6 | 7 | func ContentToSchemaRefMap(content oas3.Content) map[string]string { 8 | mss := map[string]string{} 9 | // type oas3.Content map[string]*MediaType 10 | for ct, mediaType := range content { 11 | if mediaType.Schema != nil && len(mediaType.Schema.Ref) > 0 { 12 | mss[ct] = mediaType.Schema.Ref 13 | } else { 14 | mss[ct] = "" 15 | } 16 | } 17 | return mss 18 | } 19 | -------------------------------------------------------------------------------- /cmd/oas2meta/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "os" 7 | 8 | "github.com/grokify/mogo/fmt/fmtutil" 9 | "github.com/grokify/spectrum/openapi2" 10 | ) 11 | 12 | func main() { 13 | if len(os.Args) < 2 { 14 | slog.Error("usage: oas2meta ...") 15 | os.Exit(1) 16 | } else if set, err := openapi2.NewSpecMetaSetFilepaths(os.Args[1:]); err != nil { 17 | slog.Error(err.Error()) 18 | os.Exit(2) 19 | } else { 20 | fmtutil.MustPrintJSON(set) 21 | fmt.Println("DONE") 22 | os.Exit(0) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /ext/taxonomy/taxonomy.go: -------------------------------------------------------------------------------- 1 | package taxonomy 2 | 3 | import ( 4 | "github.com/grokify/spectrum/openapi3" 5 | "github.com/grokify/spectrum/openapi3edit" 6 | ) 7 | 8 | type Taxonomy struct { 9 | Category CategoryRef `json:"category"` 10 | Slug string `json:"slug"` 11 | } 12 | 13 | func (tax *Taxonomy) AddToSpec(spec *openapi3.Spec) { 14 | if spec != nil { 15 | se := openapi3edit.SpecEdit{} 16 | se.SpecSet(spec) 17 | se.ExtensionSet(XTaxonomy, tax) 18 | } 19 | } 20 | 21 | type CategoryRef struct { 22 | Ref string `json:"$ref"` 23 | } 24 | -------------------------------------------------------------------------------- /openapi3edit/null.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import oas3 "github.com/getkin/kin-openapi/openapi3" 4 | 5 | // PathsNullToEmpty converts a `path` property from `null` to 6 | // an empty set `{}` to satisfy OpenAPI Generator which will 7 | // fail on the following error "-attribute paths is not of type `object`" 8 | func (se *SpecEdit) PathsNullToEmpty() { 9 | if se.SpecMore.Spec != nil && se.SpecMore.Spec.Paths == nil { 10 | se.SpecMore.Spec.Paths = oas3.NewPaths() 11 | // se.SpecMore.Spec.Paths = map[string]*oas3.PathItem{} // getkin v0.121.0 to v0.122.0 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /openapi3/copy.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | ) 8 | 9 | func CopySchemaStandard(schema oas3.Schema) (oas3.Schema, error) { 10 | bytes, err := json.Marshal(schema) 11 | if err != nil { 12 | return oas3.Schema{}, err 13 | } 14 | var newSchema oas3.Schema 15 | err = json.Unmarshal(bytes, &newSchema) 16 | if err != nil { 17 | return oas3.Schema{}, err 18 | } 19 | // newSchema.ExtensionProps = oas3.ExtensionProps{} 20 | newSchema.Extensions = map[string]any{} 21 | return newSchema, nil 22 | } 23 | -------------------------------------------------------------------------------- /openapi3/openapi3_url.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/grokify/mogo/net/urlutil" 7 | ) 8 | 9 | func BuildAPIURLOAS(specServerURL, overrideServerURL, specPath string) string { 10 | overrideServerURL = strings.TrimSpace(overrideServerURL) 11 | specServerURL = strings.TrimSpace(specServerURL) 12 | specPath = strings.TrimSpace(specPath) 13 | serverURL := specServerURL 14 | if len(overrideServerURL) > 0 { 15 | serverURL = overrideServerURL 16 | } 17 | return urlutil.CondenseURI( 18 | strings.Join([]string{serverURL, specPath}, "/")) 19 | } 20 | -------------------------------------------------------------------------------- /openapi3edit/spec_edit.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import "github.com/grokify/spectrum/openapi3" 4 | 5 | type SpecEdit struct { 6 | SpecMore openapi3.SpecMore 7 | } 8 | 9 | func NewSpecEdit(spec *openapi3.Spec) SpecEdit { 10 | return SpecEdit{ 11 | SpecMore: openapi3.SpecMore{Spec: spec}} 12 | } 13 | 14 | func (se *SpecEdit) ExtensionSet(key string, val any) { 15 | // se.SpecMore.Spec.ExtensionProps.Extensions[key] = val 16 | se.SpecMore.Spec.Extensions[key] = val 17 | } 18 | 19 | func (se *SpecEdit) SpecSet(spec *openapi3.Spec) { 20 | se.SpecMore = openapi3.SpecMore{Spec: spec} 21 | } 22 | -------------------------------------------------------------------------------- /openapi3/path_test.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var pathVarsToGenericTests = []struct { 8 | v string 9 | want string 10 | }{ 11 | {"/user/{userId}", "/user/{}"}, 12 | {"/user/{userId}/email/{emailId}", "/user/{}/email/{}"}, 13 | } 14 | 15 | // TestDMYHM2ParseTime ensures timeutil.DateDMYHM2 is parsed to GMT timezone. 16 | func TestPathVarsToGeneric(t *testing.T) { 17 | for _, tt := range pathVarsToGenericTests { 18 | got := PathVarsToGeneric(tt.v) 19 | if got != tt.want { 20 | t.Errorf("modify.PathVarsToGeneric(\"%v\") Mismatch: want [%v], got [%v]", tt.v, tt.want, got) 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /openapi3edit/paths_test.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | /* 4 | import ( 5 | "testing" 6 | ) 7 | 8 | var pathVarsToGenericTests = []struct { 9 | v string 10 | want string 11 | }{ 12 | {"/user/{userId}", "/user/{}"}, 13 | {"/user/{userId}/email/{emailId}", "/user/{}/email/{}"}, 14 | } 15 | 16 | // TestDMYHM2ParseTime ensures timeutil.DateDMYHM2 is parsed to GMT timezone. 17 | func TestPathVarsToGeneric(t *testing.T) { 18 | for _, tt := range pathVarsToGenericTests { 19 | got := PathVarsToGeneric(tt.v) 20 | if got != tt.want { 21 | t.Errorf("modify.PathVarsToGeneric(\"%v\") Mismatch: want [%v], got [%v]", tt.v, tt.want, got) 22 | } 23 | } 24 | } 25 | */ 26 | -------------------------------------------------------------------------------- /openapi2/spec_meta.go: -------------------------------------------------------------------------------- 1 | package openapi2 2 | 3 | import ( 4 | "github.com/grokify/spectrum" 5 | ) 6 | 7 | func NewSpecMetaFilepath(filepath string) (*spectrum.SpecMeta, error) { 8 | if s, err := ReadOpenAPI2KinSpecFile(filepath); err != nil { 9 | return nil, err 10 | } else { 11 | sm := SpecMore{Spec: s} 12 | return sm.Meta(), nil 13 | } 14 | } 15 | 16 | func NewSpecMetaSetFilepaths(filepaths []string) (*spectrum.SpecMetaSet, error) { 17 | set := spectrum.NewSpecMetaSet() 18 | for _, fp := range filepaths { 19 | if m, err := NewSpecMetaFilepath(fp); err != nil { 20 | return nil, err 21 | } else { 22 | set.Data[fp] = *m 23 | } 24 | } 25 | return set, nil 26 | } 27 | -------------------------------------------------------------------------------- /openapi3/extensions/formats.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import "fmt" 4 | 5 | type Format struct { 6 | StandardType string 7 | XFormat string 8 | StandardFormat string 9 | } 10 | 11 | func Formats() map[string]Format { 12 | data := map[string]Format{ 13 | "uint16": { 14 | XFormat: "uint16", 15 | StandardType: "string", 16 | StandardFormat: "int32", 17 | }, 18 | "uint32": { 19 | XFormat: "uint32", 20 | StandardType: "string", 21 | StandardFormat: "int64", 22 | }, 23 | } 24 | for k, v := range data { 25 | if k != v.XFormat { 26 | panic(fmt.Sprintf("mismatch [%s] [%s]", k, v.XFormat)) 27 | } 28 | } 29 | return data 30 | } 31 | -------------------------------------------------------------------------------- /openapi3/components.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "strings" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | ) 8 | 9 | func (sm *SpecMore) ComponentRequestBody(componentPath string) *oas3.RequestBodyRef { 10 | componentPathParts := strings.Split(strings.TrimSpace(componentPath), "/") 11 | if len(componentPathParts) != 4 || 12 | componentPathParts[0] != "#" || 13 | componentPathParts[1] != "components" || 14 | componentPathParts[2] != "requestBodies" || 15 | len(componentPathParts[3]) == 0 { 16 | return nil 17 | } 18 | if reqBodyRef, ok := sm.Spec.Components.RequestBodies[componentPathParts[3]]; ok { 19 | return reqBodyRef 20 | } 21 | return nil 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | workflow_dispatch: 10 | jobs: 11 | test: 12 | strategy: 13 | matrix: 14 | go-version: [1.25.x, 1.24.x] 15 | platform: [ubuntu-latest, macos-latest, windows-latest] 16 | runs-on: ${{ matrix.platform }} 17 | steps: 18 | - name: Install Go 19 | if: success() 20 | uses: actions/setup-go@v6 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | - name: Checkout code 24 | uses: actions/checkout@v6.0.1 25 | - name: Run tests 26 | run: go test -v -covermode=count ./... 27 | -------------------------------------------------------------------------------- /openapi3lint/rule_empty.go: -------------------------------------------------------------------------------- 1 | package openapi3lint 2 | 3 | import ( 4 | oas3 "github.com/getkin/kin-openapi/openapi3" 5 | "github.com/grokify/spectrum/openapi3" 6 | "github.com/grokify/spectrum/openapi3lint/lintutil" 7 | ) 8 | 9 | type EmptyRule struct{} 10 | 11 | func (rule EmptyRule) Name() string { return "" } 12 | func (rule EmptyRule) Scope() string { return "" } 13 | func (rule EmptyRule) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 14 | return []lintutil.PolicyViolation{} 15 | } 16 | func (rule EmptyRule) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 17 | return []lintutil.PolicyViolation{} 18 | } 19 | -------------------------------------------------------------------------------- /examples/ringcentral-engage-digital/ringcentral-engage-digital.postman2.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "variables": [], 3 | "info": { 4 | "name": "RingCentral Engage Digital REST API", 5 | "description": "This is a collection of API requests associated with the Basic usage plan in RingCentral Connect Platform for Sandbox Testing.\n\nPlease use a Postman environment with the following variables: RC_SERVER_HOSTNAME, RC_APP_KEY, RC_APP_SECRET, RC_USERNAME, RC_EXTENSION, RC_PASSWORD\n\nTo use these resources, you must ALWAYS start by executing one of the OAuth \"Auth\" Requests first, then you can use any other requests (for the 60 minutes that the access_token you obtained is valid).", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 7 | } 8 | } -------------------------------------------------------------------------------- /openapi3lint/rule_example.go: -------------------------------------------------------------------------------- 1 | package openapi3lint 2 | 3 | import ( 4 | "github.com/grokify/mogo/log/severity" 5 | "github.com/grokify/spectrum/openapi3lint/lintutil" 6 | ) 7 | 8 | func RulesConfigExample1() map[string]RuleConfig { 9 | rules := map[string]RuleConfig{ 10 | lintutil.RulenameOpIDStyleCamelCase: {}, 11 | lintutil.RulenameOpSummaryExist: {}, 12 | lintutil.RulenameOpSummaryStyleFirstUpperCase: {}, 13 | lintutil.RulenamePathParamStylePascalCase: {}, 14 | lintutil.RulenameSchemaObjectPropsExist: {}, 15 | lintutil.RulenameSchemaPropEnumStylePascalCase: {}, 16 | lintutil.RulenameTagStyleFirstUpperCase: {}, 17 | } 18 | for name, cfg := range rules { 19 | cfg.Severity = severity.SeverityError 20 | rules[name] = cfg 21 | } 22 | return rules 23 | } 24 | -------------------------------------------------------------------------------- /openapi3/schemas_test.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var schemaPointerExpandTests = []struct { 8 | prefix string 9 | schemaName string 10 | want string 11 | }{ 12 | {"", "FooBar", "#/components/schemas/FooBar"}, 13 | {"spec.json", "FooBar", "spec.json#/components/schemas/FooBar"}, 14 | {"spec.json", "#/components/schemas/FooBar", "spec.json#/components/schemas/FooBar"}, 15 | } 16 | 17 | // TestDMYHM2ParseTime ensures timeutil.DateDMYHM2 is parsed to GMT timezone. 18 | func TestSchemaPointerExpand(t *testing.T) { 19 | for _, tt := range schemaPointerExpandTests { 20 | got := SchemaPointerExpand(tt.prefix, tt.schemaName) 21 | if got != tt.want { 22 | t.Errorf("openapi3.SchemaPointerExpand(\"%s\",\"%s\") Mismatch: want [%v], got [%v]", 23 | tt.prefix, tt.schemaName, tt.want, got) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /openapi3/constants.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | const ( 4 | OASVersionLatest = "3.1.0" 5 | OASVersionDefault = "3.0.3" 6 | apiVersionDefault = "0.0.1" 7 | 8 | TypeArray = "array" 9 | TypeBoolean = "boolean" 10 | TypeInteger = "integer" 11 | TypeObject = "object" 12 | TypeString = "string" 13 | FormatDate = "date" 14 | FormatDateTime = "date-time" 15 | FormatInt32 = "int32" 16 | FormatInt64 = "int64" 17 | 18 | PropertyOperationID = "operationId" 19 | PropertySummary = "summary" 20 | PropertyTags = "tags" 21 | 22 | InCookie = "cookie" 23 | InHeader = "header" 24 | InPath = "path" 25 | InQuery = "query" 26 | 27 | PointerComponentsRequestBodies = "#/components/requestBodies" 28 | PointerComponentsSchemas = "#/components/schemas" 29 | PointerComponentsSchemasFormat = `#/components/schemas/%s` 30 | ) 31 | -------------------------------------------------------------------------------- /spec_meta.go: -------------------------------------------------------------------------------- 1 | package spectrum 2 | 3 | type SpecMeta struct { 4 | Counts SpecMetaCounts 5 | Names SpecMetaNames 6 | } 7 | 8 | func NewSpecMeta() *SpecMeta { 9 | return &SpecMeta{ 10 | Counts: SpecMetaCounts{}, 11 | Names: SpecMetaNames{}} 12 | } 13 | 14 | func (m *SpecMeta) Inflate() { 15 | m.Counts = m.Names.Counts() 16 | } 17 | 18 | type SpecMetaCounts struct { 19 | Endpoints int 20 | Paths int 21 | Models int 22 | } 23 | 24 | type SpecMetaNames struct { 25 | Endpoints []string 26 | Models []string 27 | Paths []string 28 | } 29 | 30 | func (n SpecMetaNames) Counts() SpecMetaCounts { 31 | return SpecMetaCounts{ 32 | Endpoints: len(n.Endpoints), 33 | Models: len(n.Models), 34 | Paths: len(n.Paths), 35 | } 36 | } 37 | 38 | type SpecMetaSet struct { 39 | Data map[string]SpecMeta 40 | } 41 | 42 | func NewSpecMetaSet() *SpecMetaSet { 43 | return &SpecMetaSet{Data: map[string]SpecMeta{}} 44 | } 45 | -------------------------------------------------------------------------------- /openapi3/json_pointer.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "github.com/grokify/mogo/encoding/jsonpointer" 5 | ) 6 | 7 | type JSONPointer jsonpointer.JSONPointer 8 | 9 | func ParseJSONPointer(s string) (JSONPointer, error) { 10 | ptr, err := jsonpointer.ParseJSONPointer(s) 11 | return JSONPointer(ptr), err 12 | } 13 | 14 | const ( 15 | PathComponents = "components" 16 | PathParameters = "parameters" 17 | PathPath = "path" 18 | PathSchemas = "schemas" 19 | 20 | PathComponentsParameters = "#/components/parameters" 21 | ) 22 | 23 | func (p *JSONPointer) IsTopParameter() (string, bool) { 24 | if len(p.Path) == 3 && p.Path[0] == PathComponents && p.Path[1] == PathParameters { 25 | return p.Path[2], true 26 | } 27 | return "", false 28 | } 29 | 30 | func (p *JSONPointer) IsTopSchema() (string, bool) { 31 | if len(p.Path) == 3 && p.Path[0] == PathComponents && p.Path[1] == PathSchemas { 32 | return p.Path[2], true 33 | } 34 | return "", false 35 | } 36 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - dogsled 5 | - dupl 6 | - gosec 7 | - misspell 8 | - nakedret 9 | - staticcheck 10 | - unconvert 11 | - unparam 12 | - whitespace 13 | settings: 14 | staticcheck: 15 | checks: 16 | - -ST1003 17 | - all 18 | exclusions: 19 | generated: lax 20 | presets: 21 | - comments 22 | - common-false-positives 23 | - legacy 24 | - std-error-handling 25 | rules: 26 | - path: openapi2/copy.go 27 | text: '`copyOrIgnoreEndpoint` - `path` is unused' 28 | - path: openapi2/copy.go 29 | text: '`copyOrIgnoreEndpoint` - `specOld` is unused' 30 | paths: 31 | - third_party$ 32 | - builtin$ 33 | - examples$ 34 | formatters: 35 | enable: 36 | - gofmt 37 | - goimports 38 | exclusions: 39 | generated: lax 40 | paths: 41 | - third_party$ 42 | - builtin$ 43 | - examples$ 44 | -------------------------------------------------------------------------------- /openapi3/ontology.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import oas3 "github.com/getkin/kin-openapi/openapi3" 4 | 5 | // OntologySet is a set of ontologies which can be used to understand 6 | // ontologies by a key, such as filename or by tag. 7 | type OntologySet struct { 8 | Ontologies map[string]Ontology `json:"ontologies"` 9 | } 10 | 11 | // Ontology returns the naming structure of an OpenAPI Spec. It is useful 12 | // for understanding the naming conventions of an existing OpenAPI Spec. 13 | // For example, the relationship of operationIDs to paths and the relationship 14 | // of parameter name component keys to parameter names. 15 | type Ontology struct { 16 | Operations map[string]OperationMeta `json:"operationIDs"` 17 | SchemaNames []string `json:"schemaNames"` 18 | Parameters oas3.ParametersMap `json:"parameters"` 19 | } 20 | 21 | func NewOntology() Ontology { 22 | return Ontology{ 23 | Operations: map[string]OperationMeta{}, 24 | SchemaNames: []string{}, 25 | Parameters: oas3.ParametersMap{}} 26 | } 27 | -------------------------------------------------------------------------------- /postman2/request.go: -------------------------------------------------------------------------------- 1 | package postman2 2 | 3 | type Request struct { 4 | URL *URL `json:"url,omitempty"` 5 | Method string `json:"method,omitempty"` 6 | Header []Header `json:"header,omitempty"` 7 | Body *RequestBody `json:"body,omitempty"` 8 | Description string `json:"description,omitempty"` 9 | } 10 | 11 | type Header struct { 12 | Key string `json:"key,omitempty"` 13 | Value string `json:"value,omitempty"` 14 | Description string `json:"description,omitempty"` 15 | } 16 | 17 | type RequestBody struct { 18 | Mode string `json:"mode,omitempty"` // `raw`, `urlencoded`, `formdata`,`file`,`graphql` 19 | Raw string `json:"raw,omitempty"` 20 | URLEncoded []URLEncodedParam `json:"urlencoded,omitempty"` 21 | } 22 | 23 | type URLEncodedParam struct { 24 | Key string `json:"key,omitempty"` 25 | Value string `json:"value,omitempty"` 26 | Type string `json:"type,omitempty"` 27 | Enabled bool `json:"enabled,omitempty"` 28 | } 29 | -------------------------------------------------------------------------------- /openapi2/convert.go: -------------------------------------------------------------------------------- 1 | package openapi2 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/getkin/kin-openapi/openapi2conv" 8 | yaml "gopkg.in/yaml.v3" 9 | ) 10 | 11 | func ConvertSpectrumOAS2ToKinOAS2(smSpec *Specification) (*Spec, error) { 12 | bytes, err := json.Marshal(smSpec) 13 | if err != nil { 14 | return nil, err 15 | } 16 | var kinSpec Spec 17 | err = json.Unmarshal(bytes, &kinSpec) 18 | return &kinSpec, err 19 | } 20 | 21 | func ConvertOAS2FileToOAS3File(oas2file, oas3file string, perm os.FileMode, pretty bool) error { 22 | oas2, err := ReadOpenAPI2KinSpecFile(oas2file) 23 | if err != nil { 24 | return err 25 | } 26 | oas3, err := openapi2conv.ToV3(oas2) 27 | if err != nil { 28 | return err 29 | } 30 | if FilenameIsYAML(oas3file) { 31 | bytes, err := yaml.Marshal(oas3) 32 | if err != nil { 33 | return err 34 | } 35 | return os.WriteFile(oas3file, bytes, perm) 36 | } 37 | if bytes, err := oas3.MarshalJSON(); err != nil { 38 | return err 39 | } else { 40 | return os.WriteFile(oas3file, bytes, perm) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /openapi3/path_more.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "reflect" 7 | 8 | oas3 "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/grokify/mogo/net/http/httputilmore" 10 | ) 11 | 12 | type PathItemMore struct { 13 | PathItem *oas3.PathItem 14 | } 15 | 16 | func (pm *PathItemMore) AddPathItemOperations(add *oas3.PathItem, overwriteOpration bool) error { 17 | if add == nil { 18 | return nil 19 | } else if pm.PathItem == nil { 20 | return errors.New("path item is not set") 21 | } 22 | methods := httputilmore.Methods() 23 | for _, method := range methods { 24 | opAdd := add.GetOperation(method) 25 | if opAdd == nil { 26 | continue 27 | } else if overwriteOpration { 28 | pm.PathItem.SetOperation(method, opAdd) 29 | } else { 30 | opSrc := pm.PathItem.GetOperation(method) 31 | if opSrc == nil { 32 | pm.PathItem.SetOperation(method, opAdd) 33 | } else if !reflect.DeepEqual(opAdd, opSrc) { 34 | return fmt.Errorf("operation collision on op id (%s)", opSrc.OperationID) 35 | } 36 | } 37 | } 38 | return nil 39 | } 40 | -------------------------------------------------------------------------------- /openapi3lint/lintutil/scope.go: -------------------------------------------------------------------------------- 1 | package lintutil 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | ScopeOperation = "operation" 10 | ScopeSpecification = "specification" 11 | ) 12 | 13 | var mapStringScope = map[string]string{ 14 | "operation": ScopeOperation, 15 | "oper": ScopeOperation, 16 | "op": ScopeOperation, 17 | "specification": ScopeSpecification, 18 | "spec": ScopeSpecification, 19 | "": ScopeSpecification, 20 | } 21 | 22 | func ParseScope(s string) (string, error) { 23 | s = strings.ToLower(strings.TrimSpace(s)) 24 | if scope, ok := mapStringScope[s]; ok { 25 | return scope, nil 26 | } 27 | return "", fmt.Errorf("unknown scope [%s]", s) 28 | } 29 | 30 | func ScopeMatch(wantScope, tryScope string) bool { 31 | wantScopeCanonical, err := ParseScope(wantScope) 32 | if err != nil { 33 | return false 34 | } 35 | tryScopeCanonical, err := ParseScope(tryScope) 36 | if err != nil { 37 | return false 38 | } 39 | if wantScopeCanonical != tryScopeCanonical { 40 | return false 41 | } 42 | return true 43 | } 44 | -------------------------------------------------------------------------------- /cmd/oas3lint/policy_api_ringcentral.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RingCentral Policy", 3 | "version": "v0.1.0", 4 | "lastUpdated": "2021-06-10T00:00:00Z", 5 | "includeStandardRules": true, 6 | "rules":{ 7 | "datatype-int-format-standard-exist": { 8 | "severity": "error" 9 | }, 10 | "operation-operationid-style-camelcase":{ 11 | "severity": "error" 12 | }, 13 | "operation-summary-exist":{ 14 | "severity": "error" 15 | }, 16 | "operation-summary-style-first-uppercase":{ 17 | "severity": "error" 18 | }, 19 | "path-param-style-camelcase":{ 20 | "severity": "error" 21 | }, 22 | "schema-object-properties-exist":{ 23 | "severity": "error" 24 | }, 25 | "schema-property-enum-style-pascalcase":{ 26 | "severity": "error" 27 | }, 28 | "tag-style-first-uppercase":{ 29 | "severity": "error" 30 | }, 31 | "x-tag-has-group":{ 32 | "severity": "error" 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017-2025 John Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /cmd/openapi2csv/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "regexp" 7 | 8 | "github.com/grokify/mogo/os/osutil" 9 | csv "github.com/grokify/spectrum/openapi2/openapi2csv" 10 | flags "github.com/jessevdk/go-flags" 11 | ) 12 | 13 | type Options struct { 14 | Directory string `short:"d" long:"dir" description:"Source Directory" required:"true"` 15 | Regexp string `short:"r" long:"regexp" description:"matching " required:"true"` 16 | Output string `short:"o" long:"output" description:"Output CSV File" required:"true"` 17 | } 18 | 19 | func main() { 20 | var opts Options 21 | _, err := flags.Parse(&opts) 22 | if err != nil { 23 | log.Fatal(err) 24 | } 25 | 26 | entries, err := osutil.ReadDirMore( 27 | opts.Directory, regexp.MustCompile(opts.Regexp), false, true, false) 28 | if err != nil { 29 | log.Fatal(err) 30 | } 31 | filepaths := entries.Names(opts.Directory) 32 | tbl, err := csv.TableFromSpecFiles(filepaths, true) 33 | if err != nil { 34 | log.Fatalf("TableFromSpecFiles [%v]\n", err.Error()) 35 | } 36 | 37 | err = tbl.WriteCSV(opts.Output) 38 | if err != nil { 39 | log.Fatal(err) 40 | } 41 | fmt.Printf("WROTE [%v]\n", opts.Output) 42 | 43 | fmt.Println("DONE") 44 | } 45 | -------------------------------------------------------------------------------- /openapi3edit/required.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import ( 4 | "regexp" 5 | 6 | "github.com/grokify/mogo/type/stringsutil" 7 | "github.com/grokify/spectrum/openapi3" 8 | ) 9 | 10 | // SchemaPropertiesSetOptional sets properties as optional if the description matches a regexp 11 | // such as var rxOptionalDefault = regexp.MustCompile(`(?i)\boptional\b`) 12 | func (se *SpecEdit) SchemaPropertiesSetOptional(rxOptional *regexp.Regexp) error { 13 | if se.SpecMore.Spec == nil { 14 | return openapi3.ErrSpecNotSet 15 | } 16 | if rxOptional == nil { 17 | return nil 18 | } 19 | for _, schemaRef := range se.SpecMore.Spec.Components.Schemas { 20 | if len(schemaRef.Ref) == 0 && schemaRef.Value != nil { 21 | required := []string{} 22 | for propName, propRef := range schemaRef.Value.Properties { 23 | if len(propRef.Ref) == 0 && propRef.Value != nil { 24 | if len(propRef.Value.Description) > 0 && 25 | !rxOptional.MatchString(propRef.Value.Description) { 26 | required = append(required, propName) 27 | } 28 | } 29 | } 30 | if len(required) > 1 { 31 | required = stringsutil.SliceCondenseSpace(required, true, true) 32 | } 33 | schemaRef.Value.Required = required 34 | } 35 | } 36 | return nil 37 | } 38 | -------------------------------------------------------------------------------- /cmd/openapi2postman/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/grokify/spectrum/openapi2/openapi2postman2" 8 | "github.com/grokify/spectrum/postman2" 9 | flags "github.com/jessevdk/go-flags" 10 | ) 11 | 12 | // Convert yaml2json: https://github.com/bronze1man/yaml2json ... yaml2json_darwin_amd64 13 | 14 | type Options struct { 15 | PostmanBase string `short:"b" long:"base" description:"Basic Postman File"` 16 | Postman string `short:"p" long:"postman" description:"Output Postman File" required:"true"` 17 | Swagger string `short:"s" long:"swagger" description:"Input Swagger File" required:"true"` 18 | } 19 | 20 | func main() { 21 | var opts Options 22 | _, err := flags.Parse(&opts) 23 | if err != nil { 24 | log.Fatal(err) 25 | } 26 | 27 | cfg := openapi2postman2.Configuration{ 28 | PostmanURLHostname: "{{RINGCENTRAL_SERVER_HOSTNAME}}", 29 | PostmanHeaders: []postman2.Header{{ 30 | Key: "Authorization", 31 | Value: "Bearer {{my_access_token}}"}}} 32 | 33 | conv := openapi2postman2.NewConverter(cfg) 34 | err = conv.MergeConvert(opts.Swagger, opts.PostmanBase, opts.Postman) 35 | 36 | if err != nil { 37 | log.Fatal(err) 38 | } else { 39 | fmt.Printf("Wrote %v\n", opts.Postman) 40 | } 41 | 42 | fmt.Println("DONE") 43 | } 44 | -------------------------------------------------------------------------------- /openapi3/inspect.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // ExtensionNames is not complete yet. 8 | func (sm *SpecMore) ExtensionNames() map[string]int { 9 | extNames := map[string]int{} 10 | for _, schRef := range sm.Spec.Components.Schemas { 11 | if schRef.Value == nil { 12 | continue 13 | } 14 | sch := schRef.Value 15 | for extName := range sch.Extensions { 16 | // for extName := range sch.ExtensionProps.Extensions { 17 | count, ok := extNames[extName] 18 | if !ok { 19 | extNames[extName] = 1 20 | } 21 | extNames[extName] = count + 1 22 | } 23 | } 24 | return extNames 25 | } 26 | 27 | func (sm *SpecMore) HasComponentSchema(componentSchemaName string, caseInsensitiveCaseMatch bool) bool { 28 | componentSchemaName = strings.TrimSpace(componentSchemaName) 29 | if caseInsensitiveCaseMatch { 30 | componentSchemaName = strings.ToLower(componentSchemaName) 31 | } 32 | if len(sm.Spec.Components.Schemas) == 0 { 33 | return false 34 | } 35 | if _, ok := sm.Spec.Components.Schemas[componentSchemaName]; ok { 36 | return true 37 | } 38 | if caseInsensitiveCaseMatch { 39 | for nameTry := range sm.Spec.Components.Schemas { 40 | if strings.EqualFold(strings.TrimSpace(nameTry), componentSchemaName) { 41 | return true 42 | } 43 | } 44 | } 45 | return false 46 | } 47 | -------------------------------------------------------------------------------- /openapi3lint/rule_collection_simple.go: -------------------------------------------------------------------------------- 1 | package openapi3lint 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "sort" 7 | "strings" 8 | ) 9 | 10 | type RuleCollectionSimple struct { 11 | name string 12 | rules map[string]Rule 13 | } 14 | 15 | func (simple RuleCollectionSimple) AddRule(rule Rule) error { 16 | ruleName := rule.Name() 17 | if len(strings.TrimSpace(ruleName)) == 0 { 18 | return errors.New("rule has no name") 19 | } 20 | if simple.rules == nil { 21 | simple.rules = map[string]Rule{} 22 | } 23 | simple.rules[ruleName] = rule 24 | return nil 25 | } 26 | 27 | func (simple RuleCollectionSimple) Name() string { 28 | if len(strings.TrimSpace(simple.name)) > 0 { 29 | return simple.name 30 | } 31 | return strings.Join(simple.RuleNames(), ",") 32 | } 33 | 34 | func (simple RuleCollectionSimple) RuleNames() []string { 35 | names := []string{} 36 | for ruleName := range simple.rules { 37 | names = append(names, ruleName) 38 | } 39 | sort.Strings(names) 40 | return names 41 | } 42 | 43 | func (simple RuleCollectionSimple) RuleExists(ruleName string) bool { 44 | if _, ok := simple.rules[ruleName]; ok { 45 | return true 46 | } 47 | return false 48 | } 49 | 50 | func (simple RuleCollectionSimple) Rule(ruleName string) (Rule, error) { 51 | if rule, ok := simple.rules[ruleName]; ok { 52 | return rule, nil 53 | } 54 | return EmptyRule{}, fmt.Errorf("rule not found [%s]", ruleName) 55 | } 56 | -------------------------------------------------------------------------------- /cmd/openapi2to3/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/grokify/mogo/os/osutil" 9 | "github.com/grokify/spectrum/openapi2" 10 | flags "github.com/jessevdk/go-flags" 11 | ) 12 | 13 | // install: go get github.com/grokify/spectrum/apps/openapi2to3 14 | 15 | type Options struct { 16 | OAS2File string `short:"i" long:"input" description:"Input filepath" required:"true"` 17 | OAS3File string `short:"o" long:"output" description:"Output filepath" required:"true"` 18 | Pretty []bool `short:"p" long:"pretty" description:"Pretty print output"` 19 | } 20 | 21 | func main() { 22 | opts := Options{} 23 | _, err := flags.Parse(&opts) 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | opts.OAS2File = strings.TrimSpace(opts.OAS2File) 28 | opts.OAS3File = strings.TrimSpace(opts.OAS3File) 29 | isFile, err := osutil.IsFile(opts.OAS2File, true) 30 | if err != nil { 31 | log.Fatal(err) 32 | } else if !isFile { 33 | log.Fatalf("E_INPUT_FILE_IS_NOT_NONEMPTY_FILE [%v]", opts.OAS2File) 34 | } 35 | 36 | var wantPretty bool 37 | if len(opts.Pretty) > 0 { 38 | wantPretty = true 39 | } else { 40 | wantPretty = false 41 | } 42 | 43 | err = openapi2.ConvertOAS2FileToOAS3File(opts.OAS2File, opts.OAS3File, 0644, wantPretty) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | fmt.Printf("WROTE [%v]\n", opts.OAS3File) 48 | 49 | fmt.Println("DONE") 50 | } 51 | -------------------------------------------------------------------------------- /openapi3/spec_table_response_status.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | oas3 "github.com/getkin/kin-openapi/openapi3" 5 | "github.com/grokify/gocharts/v2/data/histogram" 6 | ) 7 | 8 | func (sm *SpecMore) StatusCodesHistogram() *histogram.HistogramSets { 9 | hsets := histogram.NewHistogramSets("Response Codes by Endpoint") 10 | if sm.Spec == nil { 11 | return hsets 12 | } 13 | VisitOperations(sm.Spec, func(path, method string, op *oas3.Operation) { 14 | /* 15 | if op == nil || 16 | op.Responses == nil || 17 | len(op.Responses) == 0 { // getkin v0.121.0 to v0.122.0 18 | return 19 | } 20 | */ 21 | if op == nil || op.Responses == nil { 22 | return 23 | } 24 | responsesMap := op.Responses.Map() // getkin v0.121.0 to v0.122.0 25 | if len(responsesMap) == 0 { 26 | return 27 | } 28 | for responseStatusCode := range responsesMap { 29 | hsets.Add(path, method, responseStatusCode, 1, true) 30 | } 31 | }) 32 | return hsets 33 | } 34 | 35 | func (sm *SpecMore) WriteFileXLSXOperationStatusCodes(filename string) error { 36 | hsets := sm.StatusCodesHistogram() 37 | // return hsets.WriteXLSXPivot(filename, hsets.Name, "Method", "Path", "", "") 38 | return hsets.WriteXLSXPivot(filename, histogram.SetsTablePivotOpts{ 39 | TableName: hsets.Name, 40 | ColNameHistogramSet: "Method", 41 | ColNameHistogram: "Path", 42 | InclBinCounts: true, 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /openapi3lint/ruleopsummaryexist/rule_operation_summary_exist.go: -------------------------------------------------------------------------------- 1 | package ruleopsummaryexist 2 | 3 | import ( 4 | "strings" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/net/urlutil" 8 | "github.com/grokify/spectrum/openapi3" 9 | "github.com/grokify/spectrum/openapi3lint/lintutil" 10 | ) 11 | 12 | type RuleOperationSummaryExist struct { 13 | name string 14 | } 15 | 16 | func NewRule() RuleOperationSummaryExist { 17 | return RuleOperationSummaryExist{ 18 | name: lintutil.RulenameOpSummaryExist} 19 | } 20 | 21 | func (rule RuleOperationSummaryExist) Name() string { 22 | return rule.name 23 | } 24 | 25 | func (rule RuleOperationSummaryExist) Scope() string { 26 | return lintutil.ScopeOperation 27 | } 28 | 29 | func (rule RuleOperationSummaryExist) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 30 | vios := []lintutil.PolicyViolation{} 31 | if spec == nil || op == nil { 32 | return vios 33 | } 34 | 35 | summary := strings.TrimSpace(op.Summary) 36 | if len(summary) > 0 { 37 | return vios 38 | } 39 | 40 | return []lintutil.PolicyViolation{{ 41 | RuleName: rule.Name(), 42 | Location: urlutil.JoinAbsolute(opPointer, openapi3.PropertySummary), 43 | Value: op.Summary}} 44 | } 45 | 46 | func (rule RuleOperationSummaryExist) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 47 | return []lintutil.PolicyViolation{} 48 | } 49 | -------------------------------------------------------------------------------- /openapi3lint/extensions/rule_collection.go: -------------------------------------------------------------------------------- 1 | package extensions 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | 8 | "github.com/grokify/spectrum/openapi3lint" 9 | "github.com/grokify/spectrum/openapi3lint/extensions/ruletaggroupexist" 10 | ) 11 | 12 | type RuleCollectionExtensions struct { 13 | name string 14 | ruleNames map[string]int 15 | } 16 | 17 | func NewRuleCollectionExtensions() RuleCollectionExtensions { 18 | rules := RuleCollectionExtensions{ 19 | name: "Spectrum OpenAPI 3 Lint Extensions Rule Collection", 20 | ruleNames: map[string]int{}} 21 | names := rules.RuleNames() 22 | for _, name := range names { 23 | rules.ruleNames[name] = 1 24 | } 25 | return rules 26 | } 27 | 28 | func (std RuleCollectionExtensions) Name() string { 29 | return std.name 30 | } 31 | 32 | func (std RuleCollectionExtensions) RuleExists(ruleName string) bool { 33 | if _, ok := std.ruleNames[ruleName]; ok { 34 | return true 35 | } 36 | return false 37 | } 38 | 39 | func (std RuleCollectionExtensions) RuleNames() []string { 40 | rulenames := []string{ 41 | ruletaggroupexist.RuleName, 42 | } 43 | sort.Strings(rulenames) 44 | return rulenames 45 | } 46 | 47 | func (std RuleCollectionExtensions) Rule(name string) (openapi3lint.Rule, error) { 48 | name = strings.ToLower(strings.TrimSpace(name)) 49 | switch name { 50 | case ruletaggroupexist.RuleName: 51 | return ruletaggroupexist.NewRule(), nil 52 | } 53 | 54 | return openapi3lint.EmptyRule{}, fmt.Errorf("NewExtensionRule: rule [%s] not found", name) 55 | } 56 | -------------------------------------------------------------------------------- /docs/images/icon_green-hat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | image/svg+xml 4 | 5 | Layer 1 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /openapi3/extension_props.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | XTagGroups = "x-tag-groups" 10 | XThrottlingGroup = "x-throttling-group" 11 | ) 12 | 13 | type ExtensionPropsParent interface{} 14 | 15 | // ExtensionPropStringOrEmpty converts extension prop value from `json.RawMessage` to `string`. 16 | func (om *OperationMore) ExtensionPropStringOrEmpty(key string) string { 17 | if om.Operation == nil { 18 | return "" 19 | } 20 | // str, err := GetExtensionPropString(om.Operation.ExtensionProps, key) 21 | str, err := GetExtensionPropString(om.Operation.Extensions, key) 22 | if err != nil { 23 | return "" 24 | } 25 | return str 26 | } 27 | 28 | // GetExtensionPropStringOrEmpty converts extension prop value from `json.RawMessage` to `string`. 29 | func GetExtensionPropStringOrEmpty(xprops map[string]any, key string) string { 30 | // func GetExtensionPropStringOrEmpty(xprops oas3.ExtensionProps, key string) string { 31 | str, err := GetExtensionPropString(xprops, key) 32 | if err != nil { 33 | return "" 34 | } 35 | return str 36 | } 37 | 38 | // GetExtensionPropString converts extension prop value from `json.RawMessage` to `string`. 39 | func GetExtensionPropString(xprops map[string]any, key string) (string, error) { 40 | // func GetExtensionPropString(xprops oas3.ExtensionProps, key string) (string, error) { 41 | // iface, ok := xprops.Extensions[key] 42 | iface, ok := xprops[key] 43 | if !ok { 44 | return "", fmt.Errorf("extension prop key [%s] not found", key) 45 | } 46 | // Important to use %s instead of %v. 47 | return strings.Trim(fmt.Sprintf("%s", iface), "\""), nil 48 | } 49 | -------------------------------------------------------------------------------- /examples/ringcentral-engage-digital/convert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/grokify/spectrum/openapi3/openapi3postman2" 8 | "github.com/grokify/spectrum/postman2" 9 | "github.com/jessevdk/go-flags" 10 | ) 11 | 12 | // Convert yaml2json: https://github.com/bronze1man/yaml2json ... yaml2json_darwin_amd64 13 | // var ApiUrlFormat string = "https://{account}.api.engagement.dimelo.com/1.0" 14 | 15 | type Options struct { 16 | PostmanBase string `short:"b" long:"base" description:"Basic Postman File"` 17 | Postman string `short:"p" long:"postman" description:"Output Postman File" required:"true"` 18 | OpenAPISpec string `short:"s" long:"openapispec" description:"Input OpenAPI Spec File" required:"true"` 19 | } 20 | 21 | func main() { 22 | var opts Options 23 | _, err := flags.Parse(&opts) 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | 28 | cfg := openapi3postman2.Configuration{ 29 | UseXTagGroups: true, 30 | PostmanServerURLBasePath: "1.0", 31 | PostmanServerURL: "{{RINGCENTRAL_ENGAGE_SERVER_URL}}", 32 | PostmanHeaders: []postman2.Header{{ 33 | Key: "Authorization", 34 | Value: "Bearer {{RINGCENTRAL_ENGAGE_ACCESS_TOKEN}}"}}} 35 | 36 | conv := openapi3postman2.NewConverter(cfg) 37 | 38 | merge := true 39 | 40 | if merge && len(opts.PostmanBase) > 0 { 41 | err = conv.MergeConvert(opts.OpenAPISpec, opts.PostmanBase, opts.Postman) 42 | } else { 43 | err = conv.ConvertFile(opts.OpenAPISpec, opts.Postman) 44 | } 45 | 46 | if err != nil { 47 | log.Fatal(err) 48 | } else { 49 | fmt.Printf("Wrote %v\n", opts.Postman) 50 | } 51 | 52 | fmt.Println("DONE") 53 | } 54 | -------------------------------------------------------------------------------- /openapi3/json_pointer_test.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "golang.org/x/exp/slices" 8 | ) 9 | 10 | var jsonPointerTests = []struct { 11 | jsonPointer string 12 | document string 13 | path []string 14 | isTopParam bool 15 | isTopSchema bool 16 | }{ 17 | {"mydoc.yaml#/components/schemas/FooBar", "mydoc.yaml", []string{"components", "schemas", "FooBar"}, false, true}, 18 | {"#/components/schemas/FooBar", "", []string{"components", "schemas", "FooBar"}, false, true}, 19 | } 20 | 21 | // TestParseJSONPointer ensures the `ParseJSONPointer` is working properly. 22 | func TestParseJSONPointer(t *testing.T) { 23 | for _, tt := range jsonPointerTests { 24 | ptr, err := ParseJSONPointer(tt.jsonPointer) 25 | if err != nil { 26 | t.Errorf("openapi3.ParseJSONPointer(\"%s\") Error [%s]", 27 | tt.jsonPointer, err.Error()) 28 | } 29 | if ptr.Document != tt.document { 30 | t.Errorf("JSONPointer.Document Mismatch: want [%v], got [%v]", 31 | tt.document, ptr.Document) 32 | } 33 | if !slices.Equal(ptr.Path, tt.path) { 34 | t.Errorf("JSONPointer.Path Mismatch: want [%v], got [%v]", 35 | strings.Join(tt.path, ", "), strings.Join(ptr.Path, ", ")) 36 | } 37 | _, gotIsTopParam := ptr.IsTopParameter() 38 | if gotIsTopParam != tt.isTopParam { 39 | t.Errorf("JSONPointer.IsTopParameter() Mismatch: want [%v], got [%v]", 40 | tt.isTopParam, gotIsTopParam) 41 | } 42 | _, gotIsTopSchema := ptr.IsTopSchema() 43 | if gotIsTopSchema != tt.isTopSchema { 44 | t.Errorf("JSONPointer.IsTopSchema() Mismatch: want [%v], got [%v]", 45 | tt.isTopSchema, gotIsTopSchema) 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /openapi3lint/ruleintstdformat/rule_datatype_int_format_standard_exist.go: -------------------------------------------------------------------------------- 1 | package ruleintstdformat 2 | 3 | import ( 4 | oas3 "github.com/getkin/kin-openapi/openapi3" 5 | "github.com/grokify/mogo/net/urlutil" 6 | "github.com/grokify/spectrum/openapi3" 7 | "github.com/grokify/spectrum/openapi3lint/lintutil" 8 | ) 9 | 10 | type RuleDatatypeIntFormatStandardExist struct { 11 | name string 12 | } 13 | 14 | func NewRule() RuleDatatypeIntFormatStandardExist { 15 | return RuleDatatypeIntFormatStandardExist{ 16 | name: lintutil.RulenameDatatypeIntFormatStandardExist} 17 | } 18 | 19 | func (rule RuleDatatypeIntFormatStandardExist) Name() string { 20 | return rule.name 21 | } 22 | 23 | func (rule RuleDatatypeIntFormatStandardExist) Scope() string { 24 | return lintutil.ScopeSpecification 25 | } 26 | 27 | func (rule RuleDatatypeIntFormatStandardExist) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 28 | return []lintutil.PolicyViolation{} 29 | } 30 | 31 | func (rule RuleDatatypeIntFormatStandardExist) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 32 | vios := []lintutil.PolicyViolation{} 33 | openapi3.VisitTypesFormats( 34 | spec, 35 | func(jsonPointerRoot, oasType, oasFormat string) { 36 | if oasType == openapi3.TypeInteger && 37 | oasFormat != openapi3.FormatInt32 && 38 | oasFormat != openapi3.FormatInt64 { 39 | vios = append(vios, lintutil.PolicyViolation{ 40 | RuleName: rule.Name(), 41 | Location: urlutil.JoinAbsolute(pointerBase+jsonPointerRoot, "format"), 42 | Value: oasFormat}) 43 | } 44 | }, 45 | ) 46 | return vios 47 | } 48 | -------------------------------------------------------------------------------- /openapi3/openapi3postman2/configuration.go: -------------------------------------------------------------------------------- 1 | package openapi3postman2 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/grokify/spectrum/postman2" 10 | ) 11 | 12 | //const DefaultContentTypePreferences string = `multipart/form-data,application/json,application/x-www-form-urlencoded,application/xml,text/plain` 13 | //var defaultContentTypePreferencesSlice = strings.Split(DefaultContentTypePreferences, ",") 14 | 15 | // Configuration is a Spectrum configuration that holds information on how 16 | // to create the Postman 2.0 collection including overriding Swagger 2.0 17 | // spec values. 18 | type Configuration struct { 19 | // PostmanServerURLBasePath supports setting the base path as an environment variable 20 | // such as {{MY_API_BASE_URL}} 21 | PostmanServerURLBasePath string `json:"postmanServerUrlApiBasePath,omitempty"` 22 | PostmanServerURL string `json:"postmanServerUrl,omitempty"` 23 | PostmanURLHostname string `json:"postmanURLHostname,omitempty"` 24 | PostmanHeaders []postman2.Header `json:"postmanHeaders,omitempty"` 25 | UseXTagGroups bool `json:"useXTagGroups,omitempty"` 26 | RequestBodyFunc func(urlPath string) string 27 | } 28 | 29 | func ConfigurationReadFile(filename string) (Configuration, error) { 30 | filename = strings.TrimSpace(filename) 31 | cfg := Configuration{} 32 | if len(filename) == 0 { 33 | return cfg, fmt.Errorf("empty file provided [%s]", filename) 34 | } 35 | bytes, err := os.ReadFile(filename) 36 | if err != nil { 37 | return cfg, err 38 | } 39 | err = json.Unmarshal(bytes, &cfg) 40 | return cfg, err 41 | } 42 | -------------------------------------------------------------------------------- /openapi2/openapi2openapi3/convert.go: -------------------------------------------------------------------------------- 1 | // openapi2openapi3 relies on `swagger2openapi` to convert 2 | // Swagger 2.0 specs to OpenAPI 3.0 specs. 3 | package openapi2openapi3 4 | 5 | import ( 6 | "errors" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/grokify/mogo/errors/errorsutil" 11 | "github.com/grokify/mogo/os/executil" 12 | "github.com/grokify/mogo/type/stringsutil" 13 | ) 14 | 15 | const cmdSwagger2OpenAPI = "swagger2openapi" 16 | 17 | func Convert(filenames []string, outdir string, renameFunc func(string) string) errorsutil.ErrorInfos { 18 | errinfos := errorsutil.ErrorInfos{} 19 | for _, srcpath := range filenames { 20 | _, srcfile := filepath.Split(srcpath) 21 | outfile := renameFunc(srcfile) 22 | outpath := filepath.Join(outdir, outfile) 23 | 24 | qtr := stringsutil.Quoter{Beg: "", End: ""} 25 | cmd := strings.Join([]string{ 26 | cmdSwagger2OpenAPI, 27 | qtr.Quote(srcpath)}, " ") 28 | 29 | _, stderr, err := executil.ExecToFiles(cmd, outpath, "", 0644) 30 | if err != nil { 31 | if err.Error() == "exit status 1" { 32 | ei := errorsutil.ErrorInfo{ 33 | Input: srcpath, 34 | Correct: outpath, 35 | Description: cmd, 36 | Error: errors.New(stderr.String())} 37 | errinfos = append(errinfos, &ei) 38 | } else { 39 | ei := errorsutil.ErrorInfo{ 40 | Input: srcpath, 41 | Correct: outpath, 42 | Description: cmd, 43 | Error: err} 44 | errinfos = append(errinfos, &ei) 45 | } 46 | } else { 47 | ei := errorsutil.ErrorInfo{ 48 | Input: srcpath, 49 | Correct: outpath, 50 | Description: cmd, 51 | Error: nil} 52 | errinfos = append(errinfos, &ei) 53 | } 54 | } 55 | errinfos.Inflate() 56 | return errinfos 57 | } 58 | -------------------------------------------------------------------------------- /openapi3edit/deprecated.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import ( 4 | "regexp" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/spectrum/openapi3" 8 | ) 9 | 10 | func (se *SpecEdit) SchemasSetDeprecated(newDeprecated bool) { 11 | if se.SpecMore.Spec == nil { 12 | return 13 | } 14 | for _, schemaRef := range se.SpecMore.Spec.Components.Schemas { 15 | if len(schemaRef.Ref) == 0 && schemaRef.Value != nil { 16 | schemaRef.Value.Deprecated = newDeprecated 17 | } 18 | } 19 | } 20 | 21 | func (se *SpecEdit) OperationsSetDeprecated(newDeprecated bool) { 22 | if se.SpecMore.Spec == nil { 23 | return 24 | } 25 | openapi3.VisitOperations( 26 | se.SpecMore.Spec, 27 | func(path, method string, op *oas3.Operation) { 28 | if op != nil { 29 | op.Deprecated = newDeprecated 30 | } 31 | }, 32 | ) 33 | } 34 | 35 | var rxDeprecated = regexp.MustCompile(`(?i)\bdeprecated\b`) 36 | 37 | func (se *SpecEdit) SetDeprecatedImplicit() { 38 | if se.SpecMore.Spec == nil { 39 | return 40 | } 41 | openapi3.VisitOperations( 42 | se.SpecMore.Spec, 43 | func(path, method string, op *oas3.Operation) { 44 | if op != nil && rxDeprecated.MatchString(op.Description) { 45 | op.Deprecated = true 46 | } 47 | }, 48 | ) 49 | for _, schemaRef := range se.SpecMore.Spec.Components.Schemas { 50 | if len(schemaRef.Ref) == 0 && schemaRef.Value != nil { 51 | if rxDeprecated.MatchString(schemaRef.Value.Description) { 52 | schemaRef.Value.Deprecated = true 53 | } 54 | for _, propRef := range schemaRef.Value.Properties { 55 | if len(propRef.Ref) == 0 && propRef.Value != nil { 56 | if rxDeprecated.MatchString(propRef.Value.Description) { 57 | propRef.Value.Deprecated = true 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /openapi2/read.go: -------------------------------------------------------------------------------- 1 | package openapi2 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "regexp" 7 | "strings" 8 | 9 | "sigs.k8s.io/yaml" 10 | ) 11 | 12 | func ReadOpenAPI2SpecFile(filename string) (*Specification, error) { 13 | spec, err := ReadOpenAPI2SpecFileDirect(filename) 14 | return &spec, err 15 | } 16 | 17 | func ReadSwagger2SpecFile(filepath string) (Specification, error) { 18 | return ReadOpenAPI2SpecFileDirect(filepath) 19 | } 20 | 21 | func ReadOpenAPI2SpecFileDirect(filename string) (Specification, error) { 22 | var spec Specification 23 | bytes, err := os.ReadFile(filename) 24 | if err != nil { 25 | return spec, err 26 | } 27 | rx := regexp.MustCompile(`.ya?ml$`) 28 | if rx.MatchString(strings.ToLower(strings.TrimSpace(filename))) { 29 | err = yaml.Unmarshal(bytes, &spec) 30 | } else { 31 | err = json.Unmarshal(bytes, &spec) 32 | } 33 | return spec, err 34 | } 35 | 36 | /*func ReadSwagger2Spec(filepath string) (Specification, error) { 37 | bytes, err := os.ReadFile(filepath) 38 | if err != nil { 39 | return Specification{}, err 40 | } 41 | return NewSpecificationFromBytes(bytes) 42 | }*/ 43 | 44 | func ReadOpenAPI2KinSpecFile(filename string) (*Spec, error) { 45 | var swag Spec 46 | bytes, err := os.ReadFile(filename) 47 | if err != nil { 48 | return &swag, err 49 | } 50 | if FilenameIsYAML(filename) { 51 | err = yaml.Unmarshal(bytes, &swag) 52 | } else { 53 | err = json.Unmarshal(bytes, &swag) 54 | } 55 | return &swag, err 56 | } 57 | 58 | var rxYAMLExtension = regexp.MustCompile(`.ya?ml$`) 59 | 60 | // FilenameIsYAML checks to see if a filename ends 61 | // in `.yml` or `.yaml` with a case-insensitive match. 62 | func FilenameIsYAML(filename string) bool { 63 | return rxYAMLExtension.MatchString(strings.ToLower(strings.TrimSpace(filename))) 64 | } 65 | -------------------------------------------------------------------------------- /postman2/headers_util.go: -------------------------------------------------------------------------------- 1 | package postman2 2 | 3 | import ( 4 | "strings" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/net/http/httputilmore" 8 | "github.com/grokify/mogo/type/stringsutil" 9 | "github.com/grokify/spectrum/openapi3" 10 | ) 11 | 12 | const DefaultMediaTypePreferences string = `multipart/form-data,application/json,application/x-www-form-urlencoded,application/xml,text/plain` 13 | 14 | func DefaultMediaTypePreferencesSlice() []string { 15 | return strings.Split(DefaultMediaTypePreferences, ",") 16 | } 17 | 18 | func AppendPostmanHeaderValueLower(headers []Header, headerName string, options, preferenceOrder []string) ([]Header, string) { 19 | headerName = strings.TrimSpace(headerName) 20 | headerValue := stringsutil.SliceChooseOnePreferredLowerTrimSpace(options, preferenceOrder) 21 | if len(headerName) > 0 && len(headerValue) > 0 { 22 | headers = append(headers, Header{ 23 | Key: headerName, 24 | Value: headerValue}) 25 | } 26 | return headers, headerValue 27 | } 28 | 29 | func AddOperationReqResMediaTypeHeaders(headers []Header, operation *oas3.Operation, spec *openapi3.Spec, reqPreferences []string, resPreferences []string) ([]Header, string, string, error) { 30 | om := openapi3.OperationMore{Operation: operation} 31 | reqMediaTypes, err := om.RequestMediaTypes(spec) 32 | if err != nil { 33 | return []Header{}, "", "", err 34 | } 35 | headers, reqMediaType := AppendPostmanHeaderValueLower( 36 | headers, 37 | httputilmore.HeaderContentType, 38 | reqMediaTypes, 39 | reqPreferences, 40 | ) 41 | headers, resMediaType := AppendPostmanHeaderValueLower( 42 | headers, 43 | httputilmore.HeaderAccept, 44 | om.ResponseMediaTypes(), 45 | resPreferences, 46 | ) 47 | return headers, reqMediaType, resMediaType, nil 48 | } 49 | -------------------------------------------------------------------------------- /openapi3lint/ruleopsummarystylefirstuppercase/rule_operation_summary_first_uppercase.go: -------------------------------------------------------------------------------- 1 | package ruleopsummarystylefirstuppercase 2 | 3 | import ( 4 | "strings" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/net/urlutil" 8 | "github.com/grokify/mogo/type/stringsutil" 9 | "github.com/grokify/spectrum/openapi3" 10 | "github.com/grokify/spectrum/openapi3lint/lintutil" 11 | ) 12 | 13 | type RuleOperationSummaryStyleFirstUpperCase struct { 14 | name string 15 | } 16 | 17 | func NewRule() RuleOperationSummaryStyleFirstUpperCase { 18 | return RuleOperationSummaryStyleFirstUpperCase{ 19 | name: lintutil.RulenameOpSummaryStyleFirstUpperCase} 20 | } 21 | 22 | func (rule RuleOperationSummaryStyleFirstUpperCase) Name() string { 23 | return rule.name 24 | } 25 | 26 | func (rule RuleOperationSummaryStyleFirstUpperCase) Scope() string { 27 | return lintutil.ScopeOperation 28 | } 29 | 30 | func (rule RuleOperationSummaryStyleFirstUpperCase) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 31 | vios := []lintutil.PolicyViolation{} 32 | if spec == nil || op == nil || len(op.Summary) == 0 { 33 | return vios 34 | } 35 | 36 | summary := strings.TrimSpace(op.Summary) 37 | if len(summary) > 0 { 38 | return vios 39 | } 40 | if len(summary) == 0 { 41 | return vios 42 | } 43 | 44 | if summary != stringsutil.ToUpperFirst(summary, false) { 45 | return []lintutil.PolicyViolation{{ 46 | RuleName: rule.Name(), 47 | Location: urlutil.JoinAbsolute(opPointer, openapi3.PropertySummary), 48 | Value: op.Summary}} 49 | } 50 | return vios 51 | } 52 | 53 | func (rule RuleOperationSummaryStyleFirstUpperCase) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 54 | return []lintutil.PolicyViolation{} 55 | } 56 | -------------------------------------------------------------------------------- /examples/ringcentral/convert.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/grokify/spectrum/openapi2/openapi2postman2" 8 | "github.com/grokify/spectrum/openapi3" 9 | "github.com/grokify/spectrum/postman2" 10 | "github.com/jessevdk/go-flags" 11 | ) 12 | 13 | // Convert yaml2json: https://github.com/bronze1man/yaml2json ... yaml2json_darwin_amd64 14 | 15 | type Options struct { 16 | PostmanBase string `short:"b" long:"base" description:"Basic Postman File"` 17 | Postman string `short:"p" long:"postman" description:"Output Postman File" required:"true"` 18 | OpenAPISpec string `short:"s" long:"openapispec" description:"Input OpenAPI Spec File" required:"true"` 19 | } 20 | 21 | func main() { 22 | var opts Options 23 | _, err := flags.Parse(&opts) 24 | if err != nil { 25 | log.Fatal(err) 26 | } 27 | 28 | cfg := openapi2postman2.Configuration{ 29 | PostmanURLBase: "{{RINGCENTRAL_SERVER_URL}}", 30 | PostmanHeaders: []postman2.Header{{ 31 | Key: "Authorization", 32 | Value: "Bearer {{my_access_token}}"}}} 33 | 34 | conv := openapi2postman2.NewConverter(cfg) 35 | err = conv.MergeConvert(opts.OpenAPISpec, opts.PostmanBase, opts.Postman) 36 | 37 | if err != nil { 38 | log.Fatal(err) 39 | } else { 40 | fmt.Printf("Wrote %v\n", opts.Postman) 41 | } 42 | 43 | fmt.Println("DONE") 44 | } 45 | 46 | func ReadFile(filename string) { 47 | spec, err := openapi3.ReadFile(filename, true) 48 | if err != nil { 49 | log.Fatal(err) 50 | } 51 | sm := openapi3.SpecMore{Spec: spec} 52 | 53 | err = sm.SchemaPropertiesWithoutDescriptionsWriteFile("rc-platform.yml.schema-properties_missing-descriptions.txt") 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | 58 | err = sm.OperationParametersWithoutDescriptionsWriteFile("rc-platform.yml.op-params_missing-descriptions.txt") 59 | if err != nil { 60 | log.Fatal(err) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /openapi3lint/extensions/ruletaggroupexist/rule_tag_group_exist.go: -------------------------------------------------------------------------------- 1 | package ruletaggroupexist 2 | 3 | import ( 4 | "strconv" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/net/urlutil" 8 | "github.com/grokify/spectrum/ext/taggroups" 9 | "github.com/grokify/spectrum/openapi3" 10 | "github.com/grokify/spectrum/openapi3lint/lintutil" 11 | ) 12 | 13 | const ( 14 | RuleName = "x-tag-has-group" 15 | ) 16 | 17 | type RuleTagHasGroup struct { 18 | name string 19 | } 20 | 21 | func NewRule() RuleTagHasGroup { 22 | return RuleTagHasGroup{ 23 | name: RuleName} 24 | } 25 | 26 | func (rule RuleTagHasGroup) Name() string { 27 | return RuleName 28 | } 29 | 30 | func (rule RuleTagHasGroup) Scope() string { 31 | return lintutil.ScopeOperation 32 | } 33 | 34 | func (rule RuleTagHasGroup) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 35 | if spec == nil || op == nil || len(op.Tags) == 0 { 36 | return nil 37 | } 38 | // sm := openapi3.SpecMore{Spec: spec} 39 | // tagGroups, err := sm.TagGroups() 40 | tagGroups, err := taggroups.SpecTagGroups(spec) 41 | if err != nil { 42 | vio := lintutil.PolicyViolation{ 43 | RuleName: rule.Name(), 44 | Location: "#/x-tag-groups", 45 | Value: err.Error()} 46 | return []lintutil.PolicyViolation{vio} 47 | } 48 | 49 | vios := []lintutil.PolicyViolation{} 50 | for i, tagName := range op.Tags { 51 | if !tagGroups.Exists(tagName) { 52 | vios = append(vios, lintutil.PolicyViolation{ 53 | RuleName: rule.Name(), 54 | Location: urlutil.JoinAbsolute(opPointer, openapi3.PropertyTags, strconv.Itoa(i)), 55 | Value: tagName}) 56 | } 57 | } 58 | return vios 59 | } 60 | 61 | func (rule RuleTagHasGroup) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 62 | return []lintutil.PolicyViolation{} 63 | } 64 | -------------------------------------------------------------------------------- /openapi3lint/lintutil/rules.go: -------------------------------------------------------------------------------- 1 | package lintutil 2 | 3 | const ( 4 | RulenameDatatypeIntFormatStandardExist = "datatype-int-format-standard-exist" 5 | RuleOpDescExist = "operation-description-exist" 6 | RuleOpIDExist = "operation-operationid-exist" 7 | 8 | RulenameOpIDStyleCamelCase = "operation-operationid-style-camelcase" 9 | RulenameOpIDStyleKebabCase = "operation-operationid-style-kebabcase" 10 | RulenameOpIDStylePascalCase = "operation-operationid-style-pascalcase" 11 | RulenameOpIDStyleSnakeCase = "operation-operationid-style-snakecase" 12 | 13 | RulenameSchemaNameStylePascalCase = "schema-name-style-pascalcase" 14 | RulenameSchemaHasReference = "schema-has-reference" 15 | RulenameSchemaReferenceHasSchema = "schema-reference-has-schema" 16 | 17 | RulenameOpSummaryExist = "operation-summary-exist" 18 | RulenameOpSummaryStyleFirstUpperCase = "operation-summary-style-first-uppercase" 19 | 20 | RuleOpTagsCountOneOnly = "operation-tags-count-one" 21 | RulePathParamNameExist = "path-param-name-exist" 22 | 23 | RulenamePathParamStyleCamelCase = "path-param-style-camelcase" 24 | RulenamePathParamStyleKebabCase = "path-param-style-kebabcase" 25 | RulenamePathParamStylePascalCase = "path-param-style-pascalcase" 26 | RulenamePathParamStyleSnakeCase = "path-param-style-snakecase" 27 | 28 | RulenameSchemaPropEnumStyleCamelCase = "schema-property-enum-style-camelcase" 29 | RulenameSchemaPropEnumStyleKebabCase = "schema-property-enum-style-kebabcase" 30 | RulenameSchemaPropEnumStylePascalCase = "schema-property-enum-style-pascalcase" 31 | RulenameSchemaPropEnumStyleSnakeCase = "schema-property-enum-style-snakecase" 32 | 33 | RuleSchemaPropDescExist = "property-description-exist" 34 | 35 | RulenameSchemaObjectPropsExist = "schema-object-properties-exist" 36 | 37 | RulenameTagStyleFirstUpperCase = "tag-style-first-uppercase" 38 | ) 39 | -------------------------------------------------------------------------------- /docs/openapi3/inspect/missing_descriptions.md: -------------------------------------------------------------------------------- 1 | # Identify Missing Descriptions 2 | 3 | Descriptions are important to aid understanding of various objects in the OpenAPI spec. 4 | 5 | Spectrum provides an ability to list operation parameters and schema properties with missing descriptions. 6 | 7 | ## Missing Operation Parameter Descriptions 8 | 9 | ```golang 10 | specmore := openapi3.SpecMore{Spec: spec} 11 | 12 | // OperationPropertiesDescriptionStatus returns a 13 | // map[string]map[string]int as a `mogo/maputil.MapStringMapStringInt` 14 | // where `1` indicates with desc and `0` indicates without desc. 15 | status := specmore.OperationPropertiesDescriptionStatus() 16 | 17 | // OperationParametersDescriptionStatusCounts returns 18 | // counts of operations with, without and all operations. 19 | countWith, countWithout, countAll := 20 | specmore.OperationParametersDescriptionStatusCounts() 21 | 22 | // OperationParametersWithoutDescriptionsWriteFile 23 | // will write the operationIds and param names to a file 24 | err := specmore.OperationParametersWithoutDescriptionsWriteFile( 25 | "missing-descs_op-params.txt") 26 | ``` 27 | 28 | ## Missing Schema Property Descriptions 29 | 30 | ```golang 31 | specmore := openapi3.SpecMore{Spec: spec} 32 | 33 | // SchemaPropertiesDescriptionStatus returns a 34 | // map[string]map[string]int as a `mogo/maputil.MapStringMapStringInt` 35 | // where `1` indicates with desc and `0` indicates without desc. 36 | status := specmore.SchemaPropertiesDescriptionStatus() 37 | 38 | // SchemaPropertiesDescriptionStatusCounts returns counts of 39 | // schema properties with, without and all operations. 40 | countWith, countWithout, countAll := 41 | specmore.SchemaPropertiesDescriptionStatusCounts() 42 | 43 | // SchemaPropertiesWithoutDescriptionsWriteFile 44 | // will write the schema names and property names to a file 45 | err := specmore.SchemaPropertiesWithoutDescriptionsWriteFile( 46 | "missing-descs_schema-props.txt") 47 | ``` -------------------------------------------------------------------------------- /openapi3/merge_options.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "reflect" 5 | "regexp" 6 | 7 | oas3 "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/grokify/gocharts/v2/data/table/tabulator" 9 | ) 10 | 11 | type CollisionCheckResult int 12 | 13 | const ( 14 | CollisionCheckSame CollisionCheckResult = iota 15 | CollisionCheckOverwrite 16 | CollisionCheckError 17 | CollisionCheckSkip 18 | ) 19 | 20 | type MergeOptions struct { 21 | FileRx *regexp.Regexp 22 | SchemaFunc func(schemaName string, sch1, sch2 interface{}, hint2 string) CollisionCheckResult 23 | CollisionCheckResult CollisionCheckResult 24 | ValidateEach bool 25 | ValidateFinal bool 26 | TableColumns *tabulator.ColumnSet 27 | TableOpFilterFunc func(path, method string, op *oas3.Operation) bool 28 | TableAddlColFormatFuncs *OperationMoreStringFuncMap 29 | } 30 | 31 | func NewMergeOptionsSkip() *MergeOptions { 32 | return &MergeOptions{ 33 | CollisionCheckResult: CollisionCheckSkip, 34 | SchemaFunc: SchemaCheckCollisionSkip} 35 | } 36 | 37 | func (mo *MergeOptions) CheckSchemaCollision(schemaName string, sch1, sch2 interface{}, hint2 string) CollisionCheckResult { 38 | if mo.CollisionCheckResult == CollisionCheckSkip { 39 | mo.SchemaFunc = SchemaCheckCollisionSkip 40 | } else if mo.SchemaFunc == nil { 41 | mo.SchemaFunc = SchemaCheckCollisionDefault 42 | } 43 | return mo.SchemaFunc(schemaName, sch1, sch2, hint2) 44 | } 45 | 46 | func SchemaCheckCollisionDefault(schemaName string, item1, item2 interface{}, item2Note string) CollisionCheckResult { 47 | if reflect.DeepEqual(item1, item2) { 48 | return CollisionCheckSame 49 | } 50 | return CollisionCheckError 51 | } 52 | 53 | func SchemaCheckCollisionSkip(schemaName string, item1, item2 interface{}, item2Note string) CollisionCheckResult { 54 | if reflect.DeepEqual(item1, item2) { 55 | return CollisionCheckSame 56 | } 57 | return CollisionCheckSkip 58 | } 59 | -------------------------------------------------------------------------------- /openapi2/spec_more.go: -------------------------------------------------------------------------------- 1 | package openapi2 2 | 3 | import ( 4 | "net/http" 5 | "sort" 6 | 7 | "github.com/grokify/spectrum" 8 | ) 9 | 10 | type SpecMore struct { 11 | Spec *Spec 12 | } 13 | 14 | func (sm *SpecMore) Meta() *spectrum.SpecMeta { 15 | meta := spectrum.NewSpecMeta() 16 | if sm.Spec == nil { 17 | return meta 18 | } 19 | meta.Names = sm.Names() 20 | meta.Inflate() 21 | return meta 22 | } 23 | 24 | func (sm *SpecMore) Names() spectrum.SpecMetaNames { 25 | out := spectrum.SpecMetaNames{ 26 | Endpoints: sm.Endpoints(), 27 | Models: sm.ModelNames(), 28 | Paths: sm.PathNames(), 29 | } 30 | return out 31 | } 32 | 33 | func (sm *SpecMore) Endpoints() []string { 34 | var out []string 35 | if sm.Spec == nil { 36 | return out 37 | } 38 | for k, pathItem := range sm.Spec.Paths { 39 | if pathItem.Delete != nil { 40 | out = append(out, spectrum.EndpointString(http.MethodDelete, k)) 41 | } 42 | if pathItem.Get != nil { 43 | out = append(out, spectrum.EndpointString(http.MethodGet, k)) 44 | } 45 | if pathItem.Head != nil { 46 | out = append(out, spectrum.EndpointString(http.MethodHead, k)) 47 | } 48 | if pathItem.Options != nil { 49 | out = append(out, spectrum.EndpointString(http.MethodOptions, k)) 50 | } 51 | if pathItem.Patch != nil { 52 | out = append(out, spectrum.EndpointString(http.MethodPatch, k)) 53 | } 54 | if pathItem.Post != nil { 55 | out = append(out, spectrum.EndpointString(http.MethodPost, k)) 56 | } 57 | if pathItem.Put != nil { 58 | out = append(out, spectrum.EndpointString(http.MethodPut, k)) 59 | } 60 | } 61 | sort.Strings(out) 62 | return out 63 | } 64 | 65 | func (sm *SpecMore) ModelNames() []string { 66 | var out []string 67 | for k := range sm.Spec.Definitions { 68 | out = append(out, k) 69 | } 70 | sort.Strings(out) 71 | return out 72 | } 73 | 74 | func (sm *SpecMore) PathNames() []string { 75 | var out []string 76 | for k := range sm.Spec.Paths { 77 | out = append(out, k) 78 | } 79 | sort.Strings(out) 80 | return out 81 | } 82 | -------------------------------------------------------------------------------- /examples/openapi3table/example_simple.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 45 | 46 | -------------------------------------------------------------------------------- /examples/openapi3table/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | "github.com/grokify/gocharts/v2/data/table/tabulator" 9 | "github.com/grokify/spectrum/openapi3" 10 | "github.com/grokify/spectrum/openapi3/openapi3html" 11 | "github.com/jessevdk/go-flags" 12 | ) 13 | 14 | type Options struct { 15 | OpenAPISpec string `short:"o" long:"openapispec" description:"Input OpenAPI Spec File" required:"true"` 16 | } 17 | 18 | func main() { 19 | var opts Options 20 | _, err := flags.Parse(&opts) 21 | if err != nil { 22 | log.Fatal(err) 23 | } 24 | 25 | spec, err := openapi3.ReadFile(opts.OpenAPISpec, true) 26 | if err != nil { 27 | log.Fatal(err) 28 | } 29 | 30 | pageParams := openapi3html.PageParams{ 31 | PageTitle: spec.Info.Title, 32 | PageLink: "https://developers.ringcentral.com", 33 | TableDomID: "apitable", 34 | ColumnSet: ColumnTexts()} 35 | err = pageParams.AddSpec(spec) 36 | if err != nil { 37 | log.Fatal(err) 38 | } 39 | 40 | pageHTML := openapi3html.SpectrumUIPage(pageParams) 41 | 42 | filename := "api-regisry.html" 43 | err = os.WriteFile(filename, []byte(pageHTML), 0600) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | fmt.Printf("WROTE [%s]\n", filename) 48 | 49 | fmt.Println("DONE") 50 | } 51 | 52 | func ColumnTexts() *tabulator.ColumnSet { 53 | columns := []tabulator.Column{ 54 | { 55 | Display: "Method", 56 | Slug: "method"}, 57 | { 58 | Display: "Path", 59 | Slug: "path"}, 60 | { 61 | Display: "OperationID", 62 | Slug: "operationId"}, 63 | { 64 | Display: "Summary", 65 | Slug: "summary"}, 66 | { 67 | Display: "Tags", 68 | Slug: "tags"}, 69 | { 70 | Display: "API Group", 71 | Slug: "x-api-group"}, 72 | { 73 | Display: "Throttling", 74 | Slug: "x-throttling-group"}, 75 | { 76 | Display: "App Permission", 77 | Slug: "x-app-permission"}, 78 | { 79 | Display: "User Permissions", 80 | Slug: "x-user-permission"}, 81 | { 82 | Display: "Docs Level", 83 | Slug: "x-docs-level"}, 84 | } 85 | return &tabulator.ColumnSet{Columns: columns} 86 | } 87 | -------------------------------------------------------------------------------- /docs/openapi3lint/custom_rules.md: -------------------------------------------------------------------------------- 1 | # OpenAPI3Lint - Custom Rules 2 | 3 | Ther eare two parttst of using custom rules: 4 | 5 | 1. Custom Rule 6 | 1. Custom Rule Collection 7 | 8 | Simple rules can be used directly with the simple Rule Collection, `RuleCollectionSimple`, however, more complex rules can be built using custom Rule Collections. Complex rules allow multiple rule names to be used with a single rule definition, with the Rule Collection handling the instantiation. 9 | 10 | ## Custom Rules 11 | 12 | Custom rules are created using the `Rule` interface. After implementing aa custom rule, load it into a `Policy` to execute. 13 | 14 | Use `Policy.AddRule(rule Rule, errorOnCollision bool)` to add a rule. 15 | 16 | ### Rule Interface 17 | 18 | A rule has the following interface: 19 | 20 | ```go 21 | type Rule interface { 22 | Name() string 23 | Scope() string 24 | Severity() string 25 | ProcessSpec(spec *oas3.Swagger, pointerBase string) *lintutil.PolicyViolationsSets 26 | ProcessOperation(spec *oas3.Swagger, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation 27 | } 28 | ``` 29 | 30 | #### Functions 31 | 32 | * `Name()` should return the name of a rule in kebab case. 33 | * `Scope()` should return the type of object / property operated on. This affects the processing function provided. As of now, `operation` and `specfication` are supported. 34 | * `Severity()` should return a syslog like severity level supported by `github.com/grokify/mogo/log/severity`. This should be updated for the `Policy` used. 35 | * `ProcessSpec(spec *oas3.Swagger, pointerBase string)` is a function to process a rule at the top specfication level. `pointerBase` is used to provide JSON Pointer info before the `#`. This is executed when `Scope()` is set to `specification`. 36 | * `ProcessOperation(spec *oas3.Swagger, op *oas3.Operation, opPointer, path, method string)` is executed when `Scope()` is set to `operation`. 37 | 38 | ## Rule Collection 39 | 40 | ```go 41 | type RuleCollection interface { 42 | Name() string 43 | RuleNames() []string 44 | RuleExists(ruleName string) bool 45 | Rule(ruleName string) (Rule, error) 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /openapi3edit/operations_references_fix.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | oas3 "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/grokify/spectrum/openapi3" 10 | ) 11 | 12 | const ( 13 | oas2BasePathDefinitions = "#/definitions/" 14 | oas3BasePathComponentsSchemas = "#/components/schemas/" 15 | ) 16 | 17 | var rxOAS2RefDefinition = regexp.MustCompile(`^#/definitions/(.*)`) 18 | 19 | func (se *SpecEdit) OperationsFixResponseReferences() []*openapi3.OperationMeta { 20 | errorOperations := []*openapi3.OperationMeta{} 21 | if se.SpecMore.Spec == nil { 22 | return errorOperations 23 | } 24 | openapi3.VisitOperations(se.SpecMore.Spec, func(path, method string, op *oas3.Operation) { 25 | if op == nil { 26 | return 27 | } 28 | respsMap := op.Responses.Map() // getkin v0.121.0 to v0.122.0 29 | for resCode, resRef := range respsMap { 30 | // for resCode, resRef := range op.Responses { // getkin v0.121.0 to v0.122.0 31 | if strings.Index(resRef.Ref, oas2BasePathDefinitions) == 0 { 32 | resRef.Ref = strings.TrimSpace(resRef.Ref) 33 | m := rxOAS2RefDefinition.FindStringSubmatch(resRef.Ref) 34 | if len(m) > 0 { 35 | resRefOrig := resRef.Ref 36 | resRef.Ref = oas3BasePathComponentsSchemas + m[1] 37 | om := openapi3.OperationToMeta(path, method, op, []string{}) 38 | om.MetaNotes = append(om.MetaNotes, fmt.Sprintf("E_BAD_RESPONSE_REF_OAS2_DEF [%s] type[%s]", resCode, resRefOrig)) 39 | errorOperations = append(errorOperations, om) 40 | } 41 | } 42 | } 43 | }) 44 | return errorOperations 45 | } 46 | 47 | func FixFile(input, output string, prefix, indent string, validateOutput bool) (*openapi3.Spec, []*openapi3.OperationMeta, error) { 48 | spec, err := openapi3.ReadFile(input, false) 49 | if err != nil { 50 | return spec, []*openapi3.OperationMeta{}, err 51 | } 52 | se := NewSpecEdit(spec) 53 | errs := se.OperationsFixResponseReferences() 54 | output = strings.TrimSpace(output) 55 | if len(output) > 0 { 56 | smore := openapi3.SpecMore{Spec: spec} 57 | return spec, errs, smore.WriteFileJSON(output, 0644, prefix, indent) 58 | } 59 | return spec, errs, err 60 | } 61 | -------------------------------------------------------------------------------- /openapi3lint/ruletagstylefirstuppercase/rule_tag_style_first_uppercase.go: -------------------------------------------------------------------------------- 1 | package ruletagstylefirstuppercase 2 | 3 | import ( 4 | "strconv" 5 | 6 | "github.com/grokify/mogo/encoding/jsonpointer" 7 | "github.com/grokify/mogo/text/stringcase" 8 | "github.com/grokify/spectrum/openapi3" 9 | "github.com/grokify/spectrum/openapi3lint/lintutil" 10 | ) 11 | 12 | type RuleTagStyleFirstUpperCase struct { 13 | name string 14 | } 15 | 16 | func NewRule() RuleTagStyleFirstUpperCase { 17 | return RuleTagStyleFirstUpperCase{ 18 | name: lintutil.RulenameTagStyleFirstUpperCase} 19 | } 20 | 21 | func (rule RuleTagStyleFirstUpperCase) Name() string { 22 | return rule.name 23 | } 24 | 25 | func (rule RuleTagStyleFirstUpperCase) Scope() string { 26 | return lintutil.ScopeOperation 27 | } 28 | 29 | func (rule RuleTagStyleFirstUpperCase) ProcessOperation(spec *openapi3.Spec, op *openapi3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 30 | return nil 31 | } 32 | 33 | func (rule RuleTagStyleFirstUpperCase) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 34 | vios := []lintutil.PolicyViolation{} 35 | openapi3.VisitOperations(spec, func(path, method string, op *openapi3.Operation) { 36 | if op == nil { 37 | return 38 | } 39 | opLoc := jsonpointer.PointerSubEscapeAll( 40 | "%s#/paths/%s/%s/tags/", 41 | pointerBase, 42 | path, 43 | method) 44 | for i, tag := range op.Tags { 45 | if !stringcase.IsFirstAlphaUpper(tag) { 46 | vios = append(vios, lintutil.PolicyViolation{ 47 | RuleName: rule.Name(), 48 | Location: opLoc + strconv.Itoa(i), 49 | Value: tag}) 50 | } 51 | } 52 | }) 53 | for i, tag := range spec.Tags { 54 | jsLoc := jsonpointer.PointerSubEscapeAll( 55 | "%s#/tags/%d/name", pointerBase, i) 56 | if tag == nil { 57 | vios = append(vios, lintutil.PolicyViolation{ 58 | RuleName: rule.Name(), 59 | Location: jsLoc, 60 | Value: ""}) 61 | } else if !stringcase.IsFirstAlphaUpper(tag.Name) { 62 | vios = append(vios, lintutil.PolicyViolation{ 63 | RuleName: rule.Name(), 64 | Location: jsLoc, 65 | Value: tag.Name}) 66 | } 67 | } 68 | return vios 69 | } 70 | -------------------------------------------------------------------------------- /openapi3lint/ruleschemareferences/rule_schema_references_exist.go: -------------------------------------------------------------------------------- 1 | package ruleschemareferences 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | oas3 "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/grokify/spectrum/openapi3" 9 | "github.com/grokify/spectrum/openapi3lint/lintutil" 10 | ) 11 | 12 | type RuleSchemaReferences struct { 13 | name string 14 | } 15 | 16 | func NewRule(ruleName string) (RuleSchemaReferences, error) { 17 | ruleNameCanonical := strings.ToLower(strings.TrimSpace(ruleName)) 18 | rule := RuleSchemaReferences{ 19 | name: ruleNameCanonical} 20 | if ruleNameCanonical != lintutil.RulenameSchemaHasReference && 21 | ruleNameCanonical != lintutil.RulenameSchemaReferenceHasSchema { 22 | return rule, fmt.Errorf("rule [%s] not supported", ruleName) 23 | } 24 | return rule, nil 25 | } 26 | 27 | func (rule RuleSchemaReferences) Name() string { 28 | return rule.name 29 | } 30 | 31 | func (rule RuleSchemaReferences) Scope() string { 32 | return lintutil.ScopeSpecification 33 | } 34 | 35 | func (rule RuleSchemaReferences) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 36 | return []lintutil.PolicyViolation{} 37 | } 38 | 39 | func (rule RuleSchemaReferences) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 40 | violations := []lintutil.PolicyViolation{} 41 | 42 | sm := openapi3.SpecMore{Spec: spec} 43 | schemaNoRef, _, refNoSchema, err := sm.SchemaNamesStatus() 44 | if err != nil { 45 | return violations 46 | } 47 | switch rule.name { 48 | case lintutil.RulenameSchemaHasReference: 49 | for _, schemaName := range schemaNoRef { 50 | violations = append(violations, lintutil.PolicyViolation{ 51 | RuleName: lintutil.RulenameSchemaHasReference, 52 | Location: openapi3.SchemaPointerExpand(pointerBase, schemaName)}) 53 | } 54 | case lintutil.RulenameSchemaReferenceHasSchema: 55 | for _, schemaName := range refNoSchema { 56 | violations = append(violations, lintutil.PolicyViolation{ 57 | RuleName: lintutil.RulenameSchemaReferenceHasSchema, 58 | Location: openapi3.SchemaPointerExpand(pointerBase, schemaName)}) 59 | } 60 | } 61 | return violations 62 | } 63 | -------------------------------------------------------------------------------- /cmd/spectrum/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/grokify/mogo/errors/errorsutil" 9 | "github.com/grokify/spectrum/openapi3" 10 | "github.com/grokify/spectrum/openapi3/openapi3postman2" 11 | flags "github.com/jessevdk/go-flags" 12 | ) 13 | 14 | // Convert yaml2json: https://github.com/bronze1man/yaml2json ... yaml2json_darwin_amd64 15 | 16 | type Options struct { 17 | OpenAPIFile string `short:"O" long:"openapiFile" description:"Input Swagger File" required:"true"` 18 | Config string `short:"C" long:"config" description:"Spectrum Config File"` 19 | PostmanBase string `short:"B" long:"basePostmanFile" description:"Basic Postman File"` 20 | Postman string `short:"P" long:"postmanFile" description:"Output Postman File"` 21 | XLSXFile string `short:"X" long:"xlsxFile" description:"Output XLSX File"` 22 | } 23 | 24 | func (opts *Options) TrimSpace() { 25 | opts.Config = strings.TrimSpace(opts.Config) 26 | opts.PostmanBase = strings.TrimSpace(opts.PostmanBase) 27 | opts.Postman = strings.TrimSpace(opts.Postman) 28 | opts.OpenAPIFile = strings.TrimSpace(opts.OpenAPIFile) 29 | } 30 | 31 | func main() { 32 | var opts Options 33 | _, err := flags.Parse(&opts) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | 38 | spec, err := openapi3.ReadFile(opts.OpenAPIFile, true) 39 | if err != nil { 40 | log.Fatal(err) 41 | } 42 | 43 | if len(opts.Postman) > 0 { 44 | cfg3 := openapi3postman2.Configuration{} 45 | 46 | if len(opts.Config) > 0 { 47 | cfg3, err = openapi3postman2.ConfigurationReadFile(opts.Config) 48 | if err != nil { 49 | log.Fatal(errorsutil.Wrap(err, "openapi3postman2.ConfigurationReadFile")) 50 | } 51 | } 52 | 53 | conv := openapi3postman2.Converter{ 54 | Configuration: cfg3, 55 | OpenAPISpec: spec} 56 | 57 | err = conv.MergeConvert( 58 | opts.OpenAPIFile, 59 | opts.PostmanBase, 60 | opts.Postman) 61 | 62 | if err != nil { 63 | log.Fatal(errorsutil.Wrap(err, "spectrum.main << conv.MergeConvert")) 64 | } 65 | 66 | fmt.Printf("wrote Postman collection [%s]\n", opts.Postman) 67 | } 68 | if len(opts.XLSXFile) > 0 { 69 | sm := openapi3.SpecMore{Spec: spec} 70 | err := sm.WriteFileXLSX(opts.XLSXFile, nil, nil, nil) 71 | if err != nil { 72 | log.Fatal(err) 73 | } 74 | } 75 | fmt.Println("DONE") 76 | } 77 | -------------------------------------------------------------------------------- /openapi3/intersection.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "sort" 5 | 6 | "github.com/grokify/mogo/type/stringsutil" 7 | ) 8 | 9 | type SpecMetadata struct { 10 | Endpoints []string 11 | OperationIDs []string 12 | SchemaNames []string 13 | } 14 | 15 | func NewSpecMetadata() SpecMetadata { 16 | return SpecMetadata{ 17 | Endpoints: []string{}, 18 | OperationIDs: []string{}, 19 | SchemaNames: []string{}} 20 | } 21 | 22 | func (sm *SpecMore) Metadata() SpecMetadata { 23 | md := NewSpecMetadata() 24 | if sm.Spec == nil { 25 | return md 26 | } 27 | mapOpIDs := sm.OperationIDsCounts() 28 | for key := range mapOpIDs { 29 | md.OperationIDs = append(md.OperationIDs, key) 30 | } 31 | md.Endpoints = sm.PathMethods(true) 32 | md.SchemaNames = sm.SchemaNames() 33 | return md 34 | } 35 | 36 | func (md *SpecMetadata) Intersection(md2 SpecMetadata) SpecMetadata { 37 | idata := SpecMetadata{ 38 | Endpoints: stringsutil.SliceIntersection(md.Endpoints, md2.Endpoints), 39 | OperationIDs: stringsutil.SliceIntersection(md.OperationIDs, md2.OperationIDs), 40 | SchemaNames: stringsutil.SliceIntersection(md.SchemaNames, md2.SchemaNames)} 41 | return idata 42 | } 43 | 44 | func (md *SpecMetadata) IsEmpty() bool { 45 | if len(md.Endpoints) == 0 && 46 | len(md.OperationIDs) == 0 && 47 | len(md.SchemaNames) == 0 { 48 | return true 49 | } 50 | return false 51 | } 52 | 53 | func (md *SpecMetadata) Sort() { 54 | sort.Strings(md.Endpoints) 55 | sort.Strings(md.OperationIDs) 56 | sort.Strings(md.SchemaNames) 57 | } 58 | 59 | type IntersectionData struct { 60 | Spec1 SpecMetadata 61 | Spec2 SpecMetadata 62 | Intersection SpecMetadata 63 | } 64 | 65 | func (idata *IntersectionData) Sort() { 66 | idata.Spec1.Sort() 67 | idata.Spec2.Sort() 68 | idata.Intersection.Sort() 69 | } 70 | 71 | func NewIntersectionData() IntersectionData { 72 | return IntersectionData{ 73 | Spec1: NewSpecMetadata(), 74 | Spec2: NewSpecMetadata(), 75 | Intersection: NewSpecMetadata()} 76 | } 77 | 78 | func SpecsIntersection(spec1, spec2 *Spec) IntersectionData { 79 | sm1 := SpecMore{Spec: spec1} 80 | sm2 := SpecMore{Spec: spec2} 81 | idata := IntersectionData{ 82 | Spec1: sm1.Metadata(), 83 | Spec2: sm2.Metadata()} 84 | idata.Intersection = idata.Spec1.Intersection(idata.Spec2) 85 | return idata 86 | } 87 | -------------------------------------------------------------------------------- /openapi3lint/ruleopidstyle/rule_operation_operationid_style_camelcase.go: -------------------------------------------------------------------------------- 1 | package ruleopidstyle 2 | 3 | import ( 4 | "fmt" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/net/urlutil" 8 | "github.com/grokify/mogo/text/stringcase" 9 | "github.com/grokify/spectrum/openapi3" 10 | "github.com/grokify/spectrum/openapi3lint/lintutil" 11 | ) 12 | 13 | type RuleOperationOperationIDStyle struct { 14 | name string 15 | stringCase string 16 | } 17 | 18 | func NewRule(requiredStringCase string) (RuleOperationOperationIDStyle, error) { 19 | canonicalCase, err := stringcase.Parse(requiredStringCase) 20 | if err != nil { 21 | return RuleOperationOperationIDStyle{}, 22 | fmt.Errorf("invalid string case [%s]", requiredStringCase) 23 | } 24 | rule := RuleOperationOperationIDStyle{ 25 | stringCase: canonicalCase} 26 | switch canonicalCase { 27 | case stringcase.CamelCase: 28 | rule.name = lintutil.RulenameOpIDStyleCamelCase 29 | case stringcase.KebabCase: 30 | rule.name = lintutil.RulenameOpIDStyleKebabCase 31 | case stringcase.PascalCase: 32 | rule.name = lintutil.RulenameOpIDStylePascalCase 33 | case stringcase.SnakeCase: 34 | rule.name = lintutil.RulenameOpIDStyleSnakeCase 35 | default: 36 | return rule, fmt.Errorf("invalid string case [%s]", canonicalCase) 37 | } 38 | return rule, nil 39 | } 40 | 41 | func (rule RuleOperationOperationIDStyle) Name() string { 42 | return rule.name 43 | } 44 | 45 | func (rule RuleOperationOperationIDStyle) Scope() string { 46 | return lintutil.ScopeOperation 47 | } 48 | 49 | func (rule RuleOperationOperationIDStyle) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 50 | if spec == nil || op == nil || len(op.OperationID) == 0 { 51 | return nil 52 | } 53 | isWantCase, err := stringcase.IsCase(rule.stringCase, op.OperationID) 54 | if err == nil && isWantCase { 55 | return nil 56 | } 57 | vio := lintutil.PolicyViolation{ 58 | RuleName: rule.Name(), 59 | Location: urlutil.JoinAbsolute(opPointer, openapi3.PropertyOperationID), 60 | Value: op.OperationID} 61 | if err != nil { 62 | vio.Data = map[string]string{ 63 | "error": err.Error()} 64 | } 65 | return []lintutil.PolicyViolation{vio} 66 | } 67 | 68 | func (rule RuleOperationOperationIDStyle) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 69 | return []lintutil.PolicyViolation{} 70 | } 71 | -------------------------------------------------------------------------------- /openapi3/openapi3postman2/folders.go: -------------------------------------------------------------------------------- 1 | package openapi3postman2 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | oas3 "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/grokify/mogo/net/http/httputilmore" 9 | "github.com/grokify/spectrum/ext/taggroups" 10 | "github.com/grokify/spectrum/openapi3" 11 | "github.com/grokify/spectrum/postman2" 12 | ) 13 | 14 | func CreateTagsAndTagGroups(pman postman2.Collection, spec *openapi3.Spec) (postman2.Collection, error) { 15 | // oas3specMore := openapi3.SpecMore{Spec: spec} 16 | tagGroupSet, err := taggroups.SpecTagGroups(spec) 17 | // tagGroupSet, err := openapi3.SpecTagGroups(spec) 18 | if err != nil { 19 | return pman, err 20 | } 21 | if len(tagGroupSet.TagGroups) > 0 { 22 | return addFoldersFromTagGroups(pman, tagGroupSet, spec.Tags) 23 | } 24 | return addFoldersFromTags(pman, spec.Tags), nil 25 | } 26 | 27 | func addFoldersFromTagGroups(pman postman2.Collection, tgSet taggroups.TagGroupSet, tags oas3.Tags) (postman2.Collection, error) { 28 | tagsMore := openapi3.TagsMore{Tags: tags} 29 | for _, tg := range tgSet.TagGroups { 30 | tg.Name = strings.TrimSpace(tg.Name) 31 | if len(tg.Name) == 0 && len(tg.Tags) > 0 { 32 | return pman, fmt.Errorf("E_TAG_GROUP_EMPTY_NAME TAGS [%s]", strings.Join(tg.Tags, ",")) 33 | } 34 | topFolder := pman.GetOrNewFolder(tg.Name) 35 | if topFolder.Item == nil { 36 | topFolder.Item = []*postman2.Item{} 37 | } 38 | for _, tagName := range tg.Tags { 39 | tagName = strings.TrimSpace(tagName) 40 | if len(tagName) == 0 { 41 | continue 42 | } 43 | subFolder := &postman2.Item{Name: tagName} 44 | tag := tagsMore.Get(tagName) 45 | if tag != nil { 46 | subFolder.Description = &postman2.Description{ 47 | Content: strings.TrimSpace(tag.Description), 48 | Type: httputilmore.ContentTypeTextPlain} 49 | } 50 | topFolder.UpsertSubItem(subFolder) 51 | } 52 | pman.SetFolder(topFolder) 53 | } 54 | return pman, nil 55 | } 56 | 57 | func addFoldersFromTags(pman postman2.Collection, tags oas3.Tags) postman2.Collection { 58 | for _, tag := range tags { 59 | if tag == nil { 60 | continue 61 | } 62 | folder := &postman2.Item{ 63 | Name: strings.TrimSpace(tag.Name), 64 | Description: &postman2.Description{ 65 | Content: strings.TrimSpace(tag.Description), 66 | Type: httputilmore.ContentTypeTextPlain}} 67 | if len(folder.Name) == 0 { 68 | continue 69 | } 70 | pman.SetFolder(folder) 71 | } 72 | return pman 73 | } 74 | -------------------------------------------------------------------------------- /openapi2/openapi2csv/spec_to_csv.go: -------------------------------------------------------------------------------- 1 | package openapi2csv 2 | 3 | import ( 4 | "net/http" 5 | "path/filepath" 6 | 7 | oas2 "github.com/getkin/kin-openapi/openapi2" 8 | "github.com/grokify/gocharts/v2/data/table" 9 | "github.com/grokify/spectrum/openapi2" 10 | ) 11 | 12 | func TableFromSpecFiles(files []string, includeFilename bool) (*table.Table, error) { 13 | tbl := table.NewTable("") 14 | tblp := &tbl 15 | tbl.Columns = []string{} 16 | if includeFilename { 17 | tbl.Columns = append(tbl.Columns, "Filename") 18 | } 19 | tbl.Columns = append(tbl.Columns, []string{"Path", "Method", "OperationID", "Summary", "Description"}...) 20 | for _, file := range files { 21 | spec, err := openapi2.ReadOpenAPI2KinSpecFile(file) 22 | if err != nil { 23 | return tblp, err 24 | } 25 | prefix := []string{} 26 | if includeFilename { 27 | _, filename := filepath.Split(file) 28 | prefix = []string{filename} 29 | } 30 | tblp = TableAddOpenAPI2Spec(tblp, spec, prefix) 31 | } 32 | return tblp, nil 33 | } 34 | 35 | func TableAddOpenAPI2Spec(tbl *table.Table, spec *openapi2.Spec, prefix []string) *table.Table { 36 | for url, path := range spec.Paths { 37 | tbl = TableAddOpenAPI2Path(tbl, path, append(prefix, url)) 38 | } 39 | return tbl 40 | } 41 | 42 | // prefix can be `filename`,`path` 43 | func TableAddOpenAPI2Path(tbl *table.Table, path *oas2.PathItem, prefix []string) *table.Table { 44 | if path.Delete != nil { 45 | tbl.Rows = append(tbl.Rows, pathOpenAPI2ToRow(prefix, path.Delete, http.MethodDelete)) 46 | } 47 | if path.Get != nil { 48 | tbl.Rows = append(tbl.Rows, pathOpenAPI2ToRow(prefix, path.Get, http.MethodGet)) 49 | } 50 | if path.Head != nil { 51 | tbl.Rows = append(tbl.Rows, pathOpenAPI2ToRow(prefix, path.Head, http.MethodHead)) 52 | } 53 | if path.Options != nil { 54 | tbl.Rows = append(tbl.Rows, pathOpenAPI2ToRow(prefix, path.Options, http.MethodOptions)) 55 | } 56 | if path.Patch != nil { 57 | tbl.Rows = append(tbl.Rows, pathOpenAPI2ToRow(prefix, path.Patch, http.MethodPatch)) 58 | } 59 | if path.Post != nil { 60 | tbl.Rows = append(tbl.Rows, pathOpenAPI2ToRow(prefix, path.Post, http.MethodPost)) 61 | } 62 | if path.Put != nil { 63 | tbl.Rows = append(tbl.Rows, pathOpenAPI2ToRow(prefix, path.Put, http.MethodPut)) 64 | } 65 | return tbl 66 | } 67 | 68 | func pathOpenAPI2ToRow(prefix []string, op *oas2.Operation, method string) []string { 69 | row := prefix 70 | row = append(row, method, op.OperationID, op.Summary, op.Description) 71 | return row 72 | } 73 | -------------------------------------------------------------------------------- /openapi3edit/README.md: -------------------------------------------------------------------------------- 1 | # Spectrum OpenAPI 3 Inspect, Modify & Compare 2 | 3 | [![Docs][docs-godoc-svg]][docs-godoc-link] 4 | 5 | Spectrum `openapi3edit` is a library to assist in inspecting and modifying OpenAPI specs. 6 | 7 | OpenAPI specifications can be large and have many endpoints which can make it difficult to manage. Additionally, some services may consist of many specs created by different people, teams and software, so some ability to make various specs consistent is desirable, especially when the individual specs need to be merged into a master spec. 8 | 9 | Key Features include: 10 | 11 | * Inspect: Various functions to examine aspects of a OpenAPI 3 spec including OperationIDs, paths, endpoint, schemas, tags, etc. 12 | * Modify: Ability to modify various properties programmatically. 13 | * Intersection: Ability to compare two specs and show the overlap. 14 | 15 | ## Usage 16 | 17 | Steps for clean merging of multiple specs. 18 | 19 | 1. Examine all specs for consistent operationIds and tags 20 | 1. Ensure that all specs can be merged to common server base URL and paths 21 | 1. Optionally delete endpoint security from each spec and add it to merged spec 22 | 1. Check / deletee overlapping operationIDs, endpoints (method+path) and schema components 23 | 1. Validate resulting spec 24 | 25 | ### Inspect & Modify 26 | 27 | Use `SpecMoreModifyMulti` and `SpecMoreModifyMultiOpts` to handle 28 | to inspect and modify mulitple files. 29 | 30 | ### Compare 31 | 32 | Use `openapi3edit.SpecsIntersection()` 33 | 34 | ``` 35 | // spec1 and spec2 are *github.com/getkin/kin-openapi/openapi3.Swagger 36 | intersectionData := openapi3edit.SpecsIntersection(spec1, spec2) 37 | intersectionData.Sort() 38 | ``` 39 | 40 | ### Delete 41 | 42 | After running intersection, you can use the resulting data to delete those items from a spec using `SpecDeleteProperties`. Be sure to validate afterwards. 43 | 44 | This is useful when merging to specs with an overlap. To check for cleanliness of merging, you can: 45 | 46 | 1. run an intersection 47 | 1. delete the intersection from one of the sepcs and ensures it still validates 48 | 1. merge the specs 49 | 50 | 51 | ## Examples 52 | 53 | ### Add Bearer Token Auth 54 | 55 | ```go 56 | openapi3edit.SecuritySchemeAddBearertoken( 57 | spec, "", "", 58 | []string{}, 59 | []string{ 60 | "Authentication", 61 | }, 62 | ) 63 | ``` 64 | 65 | [docs-godoc-svg]: https://pkg.go.dev/badge/github.com/grokify/spectrum 66 | [docs-godoc-link]: https://pkg.go.dev/github.com/grokify/spectrum/openapi3edit -------------------------------------------------------------------------------- /docs/openapi3_fix.md: -------------------------------------------------------------------------------- 1 | # OpenAPI 3 Specs Auto-Fixer 2 | 3 | In addition to enabling introspection and programmatic modification of 4 | OpenAPI 3 specifications, Spectrum can automatically fix/update some 5 | issues with specs. 6 | 7 | ## Path Parameters - Examination and Resolution 8 | 9 | Path parameters are required to be defined. The following will identify or 10 | automatically add add operation path parameters if they are missing. It 11 | will also move path parameters to the top of the parameter list and maintain 12 | the order in which they appear in the URL path. Other parameters will 13 | maintain their original order. 14 | 15 | ```go 16 | // `spec` is *openapi3.Swagger, `true` indicates whether to auto-fix. 17 | ops, err = modify.ValidateFixOperationPathParameters(spec, true) 18 | if err != nil { 19 | fmtutil.PrintJSON(ops) 20 | log.Fatal(err) 21 | } 22 | ``` 23 | 24 | ## Response Type - Examination and Resolution 25 | 26 | Sometimes a spec can be misdefined to use a `application/json` response MIME 27 | type when the schema returned doesn't support JSON, e.g. with the type is 28 | `string` or `integer`. The following will examine and optionally update the 29 | type to `text/plain` to resolve then issue when the response is mis-classified 30 | as `application/json` or some other incompatible MIME type. 31 | 32 | ```go 33 | // `spec` is *openapi3.Swagger, `true` indicates whether to auto-fix. 34 | ops, err := modify.ValidateFixOperationResponseTypes(spec, true) 35 | if err != nil { 36 | fmtutil.PrintJSON(ops) 37 | log.Fatal(err) 38 | } 39 | ``` 40 | 41 | ## Move Request Bodies 42 | 43 | Some OpenAPI 3 spec defintions can use request body references like the following 44 | which may not be supported by all tools. 45 | 46 | ```json 47 | { 48 | "requestBody": { 49 | "$ref": "#/components/requestBodies/MyObject" 50 | } 51 | } 52 | ``` 53 | 54 | Some tools are better able to handle a `requestBody` definition 55 | as follows: 56 | 57 | ```json 58 | { 59 | "requestBody": { 60 | "content": { 61 | "application/json": { 62 | "schema": { 63 | "$ref": "#/components/schemas/MyObject" 64 | } 65 | } 66 | } 67 | } 68 | } 69 | ``` 70 | 71 | The following will move the request body definition so that `content` 72 | and MIME types are specified directly in the operation definition. 73 | 74 | ```go 75 | // `spec` is *openapi3.Swagger, `true` indicates whether to auto-fix. 76 | ops, err = modify.MoveRequestBodies(spec, true) 77 | if err != nil { 78 | fmtutil.PrintJSON(ops) 79 | log.Fatal(err) 80 | } 81 | ``` -------------------------------------------------------------------------------- /openapi3edit/schemas_keys.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | 7 | "github.com/grokify/mogo/errors/errorsutil" 8 | "github.com/grokify/mogo/type/maputil" 9 | "github.com/grokify/mogo/type/stringsutil/transform" 10 | "github.com/grokify/spectrum/openapi3" 11 | ) 12 | 13 | func (se *SpecEdit) SchemaKeysModify(xf func(string) string) error { 14 | if se.SpecMore.Spec == nil { 15 | return openapi3.ErrSpecNotSet 16 | } else if xf == nil { 17 | return nil 18 | } 19 | spec := se.SpecMore.Spec 20 | specSchemaKeysModifySchemaRefs(spec, xf) 21 | err := specSchemaKeysModifySchemaKeys(spec, xf) 22 | if err != nil { 23 | return errorsutil.Wrap(err, "SpecSchemaKeysModifySchemaKeys") 24 | } 25 | return se.SpecMore.Validate() 26 | } 27 | 28 | func specSchemaKeysModifySchemaRefs(spec *openapi3.Spec, xf func(string) string) { 29 | se := NewSpecEdit(spec) 30 | se.SchemaRefsModify(FuncSchemaRefModFromSchemaKeyMod(xf)) 31 | } 32 | 33 | var rxComponentSchemasKey = regexp.MustCompile(`^(.*#/components/schemas/)([^/]+)(.*)$`) 34 | 35 | // FuncSchemaRefModFromSchemaKeyMod takles a function for modifying schema keys and turns 36 | // it into a function for modifying JSON schema pointers for schemas keys. 37 | func FuncSchemaRefModFromSchemaKeyMod(xf func(string) string) func(string) string { 38 | return func(s string) string { 39 | m := rxComponentSchemasKey.FindStringSubmatch(s) 40 | if len(m) == 0 { 41 | return s 42 | } 43 | substr := m[2] 44 | mod := xf(substr) 45 | if substr == mod { 46 | return s 47 | } 48 | return m[1] + mod + m[3] 49 | } 50 | } 51 | 52 | // specSchemaKeysModifySchemaKeys only modifies keys in `components.schemas`. Running this 53 | // by itself does not result in a validating spec. 54 | func specSchemaKeysModifySchemaKeys(spec *openapi3.Spec, xf func(string) string) error { 55 | schKeys := maputil.StringKeys(spec.Components.Schemas, nil) 56 | xfMap, _, err := transform.TransformMap(xf, schKeys) 57 | if err != nil { 58 | return err 59 | } 60 | if !maputil.UniqueValues(xfMap) { 61 | return errors.New("collisions") 62 | } 63 | for _, schKey := range schKeys { 64 | schRef, ok := spec.Components.Schemas[schKey] 65 | if !ok { 66 | panic("schema key not found") 67 | } 68 | newSchKey := xf(schKey) 69 | if schKey == newSchKey { 70 | continue 71 | } 72 | spec.Components.Schemas[newSchKey] = schRef 73 | delete(spec.Components.Schemas, schKey) 74 | } 75 | schKeysNew := maputil.StringKeys(spec.Components.Schemas, nil) 76 | if len(schKeys) != len(schKeysNew) { 77 | return errors.New("old and new key mismatch") 78 | } 79 | return nil 80 | } 81 | -------------------------------------------------------------------------------- /cmd/oas3validate/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | "strings" 7 | 8 | "github.com/grokify/gocharts/v2/data/histogram" 9 | "github.com/grokify/mogo/fmt/fmtutil" 10 | "github.com/grokify/mogo/net/urlutil" 11 | "github.com/grokify/spectrum/openapi3" 12 | flags "github.com/jessevdk/go-flags" 13 | ) 14 | 15 | type Options struct { 16 | SpecFileOAS3 string `short:"s" long:"specfile" description:"Input OAS Spec File" required:"true"` 17 | WriteOpStatusCodeXlsx string `long:"writeopstatus" description:"Output File" required:"false"` 18 | XlsxWrite string `short:"x" long:"xlsxwrite" description:"Output File" required:"false"` 19 | } 20 | 21 | func main() { 22 | var opts Options 23 | _, err := flags.Parse(&opts) 24 | if err != nil { 25 | slog.Error(err.Error()) 26 | os.Exit(1) 27 | } 28 | 29 | var spec *openapi3.Spec 30 | 31 | if urlutil.IsHTTP(opts.SpecFileOAS3, true, true) { 32 | spec, err = openapi3.ReadURL(opts.SpecFileOAS3) 33 | } else { 34 | spec, err = openapi3.ReadFile(opts.SpecFileOAS3, true) 35 | } 36 | if err != nil { 37 | slog.Error(err.Error()) 38 | os.Exit(2) 39 | } 40 | 41 | sm := openapi3.SpecMore{Spec: spec} 42 | 43 | slog.Info( 44 | "validSpecInfo", 45 | "filename", opts.SpecFileOAS3, 46 | "title", spec.Info.Title, 47 | "opsCount", sm.OperationsCount()) 48 | 49 | sortBy := histogram.SortValueDesc 50 | ops := sm.OperationCountsByTag() 51 | err = ops.WriteTableASCII(os.Stdout, 52 | []string{"Tag", "Operation Count"}, sortBy, true) 53 | if err != nil { 54 | slog.Error(err.Error()) 55 | os.Exit(3) 56 | } 57 | 58 | ops2 := ops.ItemCounts(sortBy) 59 | err = fmtutil.PrintJSON(ops2) 60 | if err != nil { 61 | slog.Error(err.Error()) 62 | os.Exit(3) 63 | } 64 | 65 | md := ops2.Markdown("1. Count: ", ", Category: ", true, true) 66 | slog.Info(md) 67 | opts.XlsxWrite = strings.TrimSpace(opts.XlsxWrite) 68 | if len(opts.XlsxWrite) > 0 { 69 | err := sm.WriteFileXLSX(opts.XlsxWrite, nil, nil, nil) 70 | if err != nil { 71 | slog.Error(err.Error()) 72 | os.Exit(4) 73 | } 74 | } 75 | 76 | opts.WriteOpStatusCodeXlsx = strings.TrimSpace(opts.WriteOpStatusCodeXlsx) 77 | if len(opts.WriteOpStatusCodeXlsx) > 0 { 78 | err := sm.WriteFileXLSXOperationStatusCodes( 79 | opts.WriteOpStatusCodeXlsx) 80 | if err != nil { 81 | slog.Error(err.Error()) 82 | os.Exit(5) 83 | } 84 | } 85 | 86 | opIDs := sm.OperationIDs() 87 | slog.Info("operationIDs", "count", len(opIDs)) 88 | 89 | endpoints := sm.PathMethods(true) 90 | slog.Info("endpoints", "count", len(endpoints)) 91 | 92 | slog.Info("DONE") 93 | os.Exit(0) 94 | } 95 | -------------------------------------------------------------------------------- /openapi2/copy.go: -------------------------------------------------------------------------------- 1 | package openapi2 2 | 3 | import ( 4 | "net/http" 5 | "strings" 6 | 7 | "github.com/qdm12/reprint" 8 | ) 9 | 10 | func CopyEndpointsByTag(tag string, specOld, specNew Specification) (Specification, error) { 11 | var err error 12 | for url, path := range specOld.Paths { 13 | if path.Delete != nil { 14 | specNew, err = copyOrIgnoreEndpoint(http.MethodDelete, *path.Delete, url, path, tag, specNew) 15 | if err != nil { 16 | return specNew, err 17 | } 18 | } 19 | if path.Get != nil { 20 | specNew, err = copyOrIgnoreEndpoint(http.MethodGet, *path.Get, url, path, tag, specNew) 21 | if err != nil { 22 | return specNew, err 23 | } 24 | } 25 | if path.Head != nil { 26 | specNew, err = copyOrIgnoreEndpoint(http.MethodHead, *path.Head, url, path, tag, specNew) 27 | if err != nil { 28 | return specNew, err 29 | } 30 | } 31 | if path.Options != nil { 32 | specNew, err = copyOrIgnoreEndpoint(http.MethodOptions, *path.Options, url, path, tag, specNew) 33 | if err != nil { 34 | return specNew, err 35 | } 36 | } 37 | if path.Patch != nil { 38 | specNew, err = copyOrIgnoreEndpoint(http.MethodPatch, *path.Patch, url, path, tag, specNew) 39 | if err != nil { 40 | return specNew, err 41 | } 42 | } 43 | if path.Post != nil { 44 | specNew, err = copyOrIgnoreEndpoint(http.MethodPost, *path.Post, url, path, tag, specNew) 45 | if err != nil { 46 | return specNew, err 47 | } 48 | } 49 | if path.Put != nil { 50 | specNew, err = copyOrIgnoreEndpoint(http.MethodPut, *path.Put, url, path, tag, specNew) 51 | if err != nil { 52 | return specNew, err 53 | } 54 | } 55 | } 56 | return specNew, nil 57 | } 58 | 59 | func copyOrIgnoreEndpoint(method string, endpoint Endpoint, url string, path Path, wantTag string, specNew Specification) (Specification, error) { 60 | // TODO: copy referenced objects, e.g. schema objects, which may need `specOld`. 61 | wantTag = strings.TrimSpace(wantTag) 62 | if len(wantTag) != 0 { 63 | match := false 64 | for _, tryTag := range endpoint.Tags { 65 | if strings.TrimSpace(tryTag) == wantTag { 66 | match = true 67 | } 68 | } 69 | if !match { 70 | return specNew, nil 71 | } 72 | } 73 | if _, ok := specNew.Paths[url]; ok { 74 | return specNew, nil 75 | } 76 | 77 | pathNew := reprint.This(path).(Path) // ref: https://stackoverflow.com/a/77412997/1908967 78 | err := pathNew.SetEndpoint(method, endpoint) 79 | if err != nil { 80 | return specNew, err 81 | } 82 | if specNew.Paths == nil { 83 | specNew.Paths = map[string]Path{} 84 | } 85 | specNew.Paths[url] = pathNew 86 | return specNew, nil 87 | } 88 | -------------------------------------------------------------------------------- /openapi3/path.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "net/http" 5 | "regexp" 6 | "sort" 7 | 8 | oas3 "github.com/getkin/kin-openapi/openapi3" 9 | ) 10 | 11 | var RxPathParam = regexp.MustCompile(`\{([^\{\}]+)\}`) 12 | 13 | func PathParams(p string) []string { 14 | params := []string{} 15 | m := RxPathParam.FindAllStringSubmatch(p, -1) 16 | for _, mi := range m { 17 | params = append(params, mi[1]) 18 | } 19 | return params 20 | } 21 | 22 | func PathItemHasEndpoints(pathItem *oas3.PathItem) bool { 23 | if pathItem.Connect != nil || pathItem.Delete != nil || 24 | pathItem.Get != nil || pathItem.Head != nil || 25 | pathItem.Options != nil || 26 | pathItem.Patch != nil || pathItem.Post != nil || 27 | pathItem.Put != nil || pathItem.Trace != nil { 28 | return true 29 | } 30 | return false 31 | } 32 | 33 | func PathMethods(pathItem *oas3.PathItem) []string { 34 | methods := []string{} 35 | if pathItem.Connect != nil { 36 | methods = append(methods, http.MethodConnect) 37 | } 38 | if pathItem.Delete != nil { 39 | methods = append(methods, http.MethodDelete) 40 | } 41 | if pathItem.Get != nil { 42 | methods = append(methods, http.MethodGet) 43 | } 44 | if pathItem.Head != nil { 45 | methods = append(methods, http.MethodHead) 46 | } 47 | if pathItem.Options != nil { 48 | methods = append(methods, http.MethodOptions) 49 | } 50 | if pathItem.Patch != nil { 51 | methods = append(methods, http.MethodPatch) 52 | } 53 | if pathItem.Post != nil { 54 | methods = append(methods, http.MethodPost) 55 | } 56 | if pathItem.Put != nil { 57 | methods = append(methods, http.MethodPut) 58 | } 59 | if pathItem.Trace != nil { 60 | methods = append(methods, http.MethodTrace) 61 | } 62 | return methods 63 | } 64 | 65 | var rxPathVarToGeneric = regexp.MustCompile(`{[^}{]*}`) 66 | 67 | func PathVarsToGeneric(input string) string { 68 | return rxPathVarToGeneric.ReplaceAllString(input, "{}") 69 | } 70 | 71 | func PathMatchGeneric(path1, path2 string) bool { 72 | return PathVarsToGeneric(path1) == PathVarsToGeneric(path2) 73 | } 74 | 75 | func (sm *SpecMore) PathMethods(generic bool) []string { 76 | endpoints := []string{} 77 | if sm.Spec == nil { 78 | return endpoints 79 | } 80 | pathsMap := sm.Spec.Paths.Map() 81 | for url, pathItem := range pathsMap { 82 | // for url, pathItem := range sm.Spec.Paths { // getkin v0.121.0 to v0.122.0 83 | if generic { 84 | url = PathVarsToGeneric(url) 85 | } 86 | pathMethods := PathMethods(pathItem) 87 | for _, pathMethod := range pathMethods { 88 | endpoints = append(endpoints, url+" "+pathMethod) 89 | } 90 | } 91 | sort.Strings(endpoints) 92 | return endpoints 93 | } 94 | -------------------------------------------------------------------------------- /openapi3/spec_meta.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | 7 | oas3 "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/grokify/mogo/os/osutil" 9 | ) 10 | 11 | // NewSpec returns a new OpenAPI 3 spec that will validate. 12 | // Specifically, it includes an OAS version, sets `info` to 13 | // be an empty object instead of null and sets apiVersion. 14 | func NewSpec(oasVersion, apiTitle, apiVersion string) *Spec { 15 | oasVersion = strings.TrimSpace(oasVersion) 16 | if len(oasVersion) == 0 { 17 | oasVersion = OASVersionLatest 18 | } 19 | apiVersion = strings.TrimSpace(apiVersion) 20 | if len(apiVersion) == 0 { 21 | apiVersion = apiVersionDefault 22 | } 23 | return &Spec{ 24 | OpenAPI: oasVersion, 25 | Info: &oas3.Info{ 26 | Title: strings.TrimSpace(apiTitle), 27 | Version: apiVersion}} 28 | } 29 | 30 | type SpecMetas struct { 31 | Metas []SpecMeta 32 | } 33 | 34 | func (metas *SpecMetas) Filepaths(validOnly bool) []string { 35 | files := []string{} 36 | for _, meta := range metas.Metas { 37 | if validOnly && !meta.IsValid { 38 | continue 39 | } 40 | meta.Filepath = strings.TrimSpace(meta.Filepath) 41 | if len(meta.Filepath) > 0 { 42 | files = append(files, meta.Filepath) 43 | } 44 | } 45 | return files 46 | } 47 | 48 | type SpecMeta struct { 49 | Filepath string 50 | Version int 51 | IsValid bool 52 | ValidationError string 53 | } 54 | 55 | func ReadSpecMetasDir(dir string, rx *regexp.Regexp) (SpecMetas, error) { 56 | metas := SpecMetas{Metas: []SpecMeta{}} 57 | if entries, err := osutil.ReadDirMore(dir, rx, false, true, false); err != nil { 58 | return metas, err 59 | } else { 60 | return ReadSpecMetasFiles(entries.Names(dir)) 61 | } 62 | } 63 | 64 | func ReadSpecMetasFiles(files []string) (SpecMetas, error) { 65 | metas := SpecMetas{Metas: []SpecMeta{}} 66 | for _, f := range files { 67 | _, err := ReadFile(f, true) 68 | meta := SpecMeta{ 69 | Filepath: f, 70 | Version: 3} 71 | if err != nil { 72 | meta.ValidationError = err.Error() 73 | } else { 74 | meta.IsValid = true 75 | } 76 | metas.Metas = append(metas.Metas, meta) 77 | } 78 | return metas, nil 79 | } 80 | 81 | func (metas *SpecMetas) Merge(validatesOnly bool, mergeOpts *MergeOptions) (SpecMore, error) { 82 | return MergeSpecMetas(metas, validatesOnly, mergeOpts) 83 | } 84 | 85 | func MergeSpecMetas(metas *SpecMetas, validatesOnly bool, mergeOpts *MergeOptions) (SpecMore, error) { 86 | specMore := SpecMore{} 87 | filepaths := metas.Filepaths(validatesOnly) 88 | spec, err := MergeFiles(filepaths, mergeOpts) 89 | if err != nil { 90 | return specMore, err 91 | } 92 | specMore.Spec = spec 93 | return specMore, nil 94 | } 95 | -------------------------------------------------------------------------------- /openapi3/ontology/tag_ontology_test.go: -------------------------------------------------------------------------------- 1 | package ontology 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/grokify/mogo/text/stringcase" 9 | ) 10 | 11 | var onologyTestStruct = Ontology{ 12 | OperationIDCase: stringcase.KebabCase, 13 | SchemaNameCase: stringcase.SnakeCase, 14 | SchemaNameReponseSuffix: "response", 15 | PathVarCase: stringcase.SnakeCase, 16 | PathIDPrefix: "", 17 | PathIDSuffix: "id", 18 | SpecFileCase: stringcase.SnakeCase, 19 | SpecFilePrefix: "spec", 20 | SpecFileExt: ".yaml", 21 | SpecFileResourceIsPlural: false, 22 | } 23 | 24 | var tagOnologyTests = []struct { 25 | tagOntology TagOnology 26 | tagOntologyData TagOnologyData 27 | }{ 28 | { 29 | tagOntology: TagOnology{ 30 | ResourceNameSingular: "pet animal", 31 | ResourceNameSingularTitle: "Pet Animal", 32 | ResourceNameSingularShort: "pet", 33 | ResourceNamePlural: "pet animals", 34 | ResourceNamePluralTitle: "Pet Animals", 35 | DeterminerSinglar: "a", 36 | DeterminerPlural: "", 37 | }, 38 | tagOntologyData: TagOnologyData{ 39 | Tag: "Pet Animals", 40 | CreateOperationID: "create-pet-animal", 41 | ReadOperationID: "get-pet-animal", 42 | UpdateOperationID: "update-pet-animal", 43 | DeleteOperationID: "delete-pet-animal", 44 | ListOperationID: "list-pet-animals", 45 | CreateSummary: "Create a pet animal", 46 | ReadSummary: "Describe a pet animal", 47 | UpdateSummary: "Update a pet animal", 48 | DeleteSummary: "Delete a pet animal", 49 | ListSummary: "List pet animals", 50 | ResourcePathVar: "pet_id", 51 | ResourceSchemaNameRequest: "pet_animal", 52 | ResourceSchemaNameResponse: "pet_animal_response", 53 | SpecFilename: "spec_pet_animal.yaml", 54 | }, 55 | }, 56 | } 57 | 58 | // TestParseJSONPointer ensures the `ParseJSONPointer` is working properly. 59 | func TestTagOnologyTests(t *testing.T) { 60 | for _, tt := range tagOnologyTests { 61 | to := tt.tagOntology 62 | to.Ontology = onologyTestStruct 63 | gotData := to.Data() 64 | wantData := tt.tagOntologyData 65 | if gotData.CreateSummary != wantData.CreateSummary { 66 | fmt.Printf("MISMATCH_SUMMARIES:\nwant [%s]\nrecv [%s]\n", wantData.CreateSummary, gotData.CreateSummary) 67 | } 68 | mismatchFields, isEqual := gotData.Equal(wantData) 69 | if !isEqual { 70 | t.Errorf("ontology.TagOnologyData.Data(...) Mistmatch fields [%s] want [%v] got [%v]", 71 | strings.Join(mismatchFields, ","), 72 | wantData, gotData) 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /docs/recommendations_openapi3.md: -------------------------------------------------------------------------------- 1 | # OpenAPI 3 Spec 2 | 3 | This document is a recommended set of minimal OpenAPI 3 spec properties so that a spec can be used by various ecosystem tools such as API References, API Explorers and Client SDK generators. 4 | 5 | ## Operation 6 | 7 | | Property | Requirement | Notes | 8 | |----------|-------------|-------| 9 | | `description` | `MUST` | Used by API References | 10 | | `operationId` | `MUST` | May be used to auto-generate client SDK method names | 11 | | `responses` | `MUST` | Minimally must have 2xx successful response. Other responses including errors are desirable | 12 | | `summary` | `MUST` | Used in API References, such as Swagger UI and ReadMe.io | 13 | | `tags` | `MUST` | There should have 1 and only 1 tag. Tags are used to organize endpoints in API References and Client SDKs. More than 1 tag may not be supported will in some software | 14 | 15 | ### Operation Parameter 16 | 17 | | Property | Requirement | Notes | 18 | |----------|-------------|-------| 19 | | `in` | `MUST` | This describes where the parameter appears. | 20 | | `name` | `MUST` | This is the name of the parameter. | 21 | | `required` | `MUST` | This is the name of the parameter. | 22 | | `description` | `SHOULD` | This describes where the parameter appears. | 23 | | `schema.type` or `schema.$ref` | `MUST` | Type property must be present. Schema paramters are typically not objects which would be defined by a `$ref`, though JSON bodes are. | 24 | | `schema.format` | `SHOULD` | Format property should be present. For `integer` type, if using `long`, explicitly set `format` to `int64`. For Date Time properties, only set `format` to `date-time` or `date` if the fields correspond to IETF RFC-3339. If date/time formats do not correspond to RFC-3339, leave `format` empty and add format information in the `description` property | 25 | 26 | ## Schema 27 | 28 | | Property | Requirement | Notes | 29 | |----------|-------------|-------| 30 | | `required` | `MUST` | When required fields are present, they should be indicated | 31 | 32 | ### Schema Property 33 | 34 | | Property | Requirement | Notes | 35 | |----------|-------------|-------| 36 | | `description` | `SHOULD` | Description should be included when available. For fields with ambiguous `format` information such as non-RFC-3339 date/times, the format should be included in the description. | 37 | | `type` or `$ref` | `MUST` | Type property must be present. | 38 | | `format` | `SHOULD` | Format property should be present. For `integer` type, if using `long`, explicitly set `format` to `int64`. For Date Time properties, only set `format` to `date-time` or `date` if the fields correspond to IETF RFC-3339. If date/time formats do not correspond to RFC-3339, leave `format` empty and add format information in the `description` property | -------------------------------------------------------------------------------- /openapi3lint/README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI 3 Lint 2 | 3 | This is a Go-based OpenAPI 3 spec linter. 4 | 5 | Spectrum is designed to support a multi-file, multi-user, async editing process where linting reports need to be resilinent to mutiple changes to the specs occurring between the time the validation is run and resolved. 6 | 7 | ## Why Spectrum Linter? 8 | 9 | There are a few linters available. 10 | 11 | The reasons this exists are: 12 | 13 | 1. written in Go so its easy to use/modify for Go devs 14 | 2. policy violations are grouped by rule, vs. line number for easier mitigation 15 | 3. policy violations are identified by JSON Schema pointere vs. line number for easier identification when using merged files 16 | 17 | ## Standard Rules 18 | 19 | The following rules are built into Spectrum. 20 | 21 | 1. `datatype-int-format-standard-exist`: ensures data types of `integer` have a standard format (`int32` or `int64`) 22 | 1. `operation-operationid-style-camelcase`: ensures operationIds use camelCase 23 | 1. `operation-operationid-style-kebabcase`: ensures operationIds use kebab-case 24 | 1. `operation-operationid-style-pascalcase`: ensures operationIds use PascalCase 25 | 1. `operation-operationid-style-snakecase`: ensures operationIds use snake_case 26 | 1. `operation-summary-exist` ensures a summary exists. 27 | 1. `operation-summary-style-first-uppercase`: ensures summary starts with capitalized first character 28 | 1. `path-param-style-camelcase`: path parms are camel case 29 | 1. `path-param-style-kebabcase`: path parms are kebab case 30 | 1. `path-param-style-pascalcase`: path parms are Pascal case 31 | 1. `path-param-style-snakecase`: path parms are snake case 32 | 1. `schema-has-reference`: ensures schemas have references 33 | 1. `schema-object-properties-exist`: schema of type `object` have `properties` or `additionalProperties` defined 34 | 1. `schema-property-enum-style-camelcase`: schema property enums are camel case 35 | 1. `schema-property-enum-style-kebabcase`: schema property enums are kebab case 36 | 1. `schema-property-enum-style-pascalcase`: schema property enums are Pascal case 37 | 1. `schema-property-enum-style-snakecase`: schema property enums are snake case 38 | 1. `schema-reference-has-schema`: ensures schma JSON pointers reference existing schemas 39 | 1. `tag-style-first-uppercase`: Tag names have capitalized first character 40 | 41 | ## Other Linters 42 | 43 | There are other linters available. To date, Spectrum Linter hasn't beeen inspired by any of them, though there is a desire and effort to align on rule names and potentially rule definitions to achieve similar behavior. 44 | 45 | 1. Mermade OAS-Kit - https://github.com/mermade/oas-kit 46 | 1. https://mermade.github.io/oas-kit/default-rules.html 47 | 1. https://mermade.github.io/oas-kit/linter-rules.html 48 | 1. Spectral - https://github.com/stoplightio/spectral 49 | 1. Inspired by Speccy 50 | 1. Speccy - https://github.com/wework/speccy 51 | 1. Inspired by OAS-Kit -------------------------------------------------------------------------------- /openapi3/schemas.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | oas3 "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/grokify/mogo/encoding/jsonpointer" 9 | "github.com/grokify/mogo/type/stringsutil" 10 | ) 11 | 12 | func SchemaPointerExpand(prefix, schemaName string) string { 13 | // https://swagger.io/docs/specification/components/ 14 | prefix = strings.TrimSpace(prefix) 15 | schemaName = strings.TrimSpace(schemaName) 16 | pointer := schemaName 17 | if !strings.Contains(schemaName, PointerComponentsSchemas) { 18 | pointer = PointerComponentsSchemas + "/" + jsonpointer.PropertyNameEscape(schemaName) 19 | } 20 | if len(prefix) > 0 && strings.Index(pointer, "#") == 0 { 21 | pointer = prefix + pointer 22 | } 23 | return pointer 24 | } 25 | 26 | func ReadSchemaFile(filename string) (*oas3.Schema, error) { 27 | data, err := os.ReadFile(filename) 28 | if err != nil { 29 | return nil, err 30 | } 31 | sch := oas3.NewSchema() 32 | err = sch.UnmarshalJSON(data) 33 | return sch, err 34 | } 35 | 36 | // AdditionalPropertiesAllowed checks for additional properties, which exists in Schema structs. 37 | func AdditionalPropertiesAllowed(aprops oas3.AdditionalProperties) bool { 38 | if aprops.Has != nil { 39 | return *aprops.Has 40 | } else { 41 | return false 42 | } 43 | } 44 | 45 | func AdditionalPropertiesExists(props oas3.AdditionalProperties) bool { 46 | if props.Has == nil || !*props.Has || props.Schema == nil { 47 | return false 48 | } else if strings.TrimSpace(props.Schema.Ref) != "" { 49 | return true 50 | } else if props.Schema.Value == nil { 51 | return false 52 | } else { 53 | return true 54 | } 55 | } 56 | 57 | // NewTypes returns a value that is suitable for `oas3.Schema.Value.Type`. 58 | // In `0.123.0` and earlier, this is a `string`. In `0.124.0` and later, this is 59 | // of type `oas3.Types`. `oas3` is `github.com/getkin/kin-openapi/openapi3`. 60 | func NewTypesRef(t ...string) *oas3.Types { 61 | ot := oas3.Types{} 62 | for _, ti := range t { 63 | ot = append(ot, ti) 64 | } 65 | return &ot 66 | } 67 | 68 | // TypesRefIs returns if the supplied `*oas3.Types` is any of the supplied values. 69 | // It returns false if `*oas3.Types` is false, or `type` an empty slice, or 70 | // none of the supplied types match. `oas3` is `github.com/getkin/kin-openapi/openapi3`. 71 | func TypesRefIs(t *oas3.Types, types ...string) bool { 72 | types = stringsutil.SliceCondenseSpace(types, true, false) 73 | if t == nil || len(*t) == 0 { 74 | if len(types) == 0 { 75 | return true 76 | } else { 77 | return false 78 | } 79 | } else if len(types) == 0 { 80 | return false 81 | } 82 | for _, typ := range types { 83 | if t.Is(typ) { 84 | return true 85 | } 86 | } 87 | return false 88 | } 89 | 90 | // TypesRefString returns a string if `oas3.Types` is not nil and has a single type. 91 | func TypesRefString(t *oas3.Types) string { 92 | if t == nil || len(*t) != 1 { 93 | return "" 94 | } else { 95 | return (*t)[0] 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /openapi3/springopenapi3/spring_parser_test.go: -------------------------------------------------------------------------------- 1 | package springopenapi3 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/grokify/spectrum/openapi3" 7 | ) 8 | 9 | var parseLineTests = []struct { 10 | v string 11 | oasName string 12 | oasType string 13 | oasFormat string 14 | oasDefault interface{} 15 | explicitCustomTypes []string 16 | }{ 17 | {"private Boolean myPropBoolean;", "myPropBoolean", "boolean", "", nil, []string{}}, 18 | {"private DateTime myPropDateTime;", "myPropDateTime", "string", "", nil, []string{}}, 19 | {"private Integer myPropInteger = 1;", "myPropInteger", "integer", "", 1, []string{}}, 20 | {"private Long myPropLong = 1;", "myPropLong", "integer", "int64", 1, []string{}}, 21 | {"private String myPropString;", "myPropString", "string", "", nil, []string{}}, 22 | {"private String myPropString = \"\";", "myPropString", "string", "", "", []string{}}, 23 | {"private String myPropString = \"AVAILABLE\";", "myPropString", "string", "", "AVAILABLE", []string{}}, 24 | {"private List myPropStrings = new ArrayList<>();", "myPropStrings", "array", "", nil, []string{}}, 25 | {"private List myPropStrings = new ArrayList<>();", "myPropStrings", "array", "", nil, []string{}}, 26 | {"private List myPropStrings;", "myPropStrings", "array", "", nil, []string{}}, 27 | {"private List myPropStrings;", "myPropStrings", "array", "", nil, []string{}}, 28 | } 29 | 30 | func TestParseLine(t *testing.T) { 31 | for _, tt := range parseLineTests { 32 | name, schemaRef, err := ParseSpringLineToSchemaRef(tt.v, tt.explicitCustomTypes) 33 | if err != nil { 34 | t.Errorf("fromspring.ParseSpringLineToSchema() [%v]", err) 35 | } 36 | schema := schemaRef.Value 37 | if tt.oasName != name || tt.oasType != openapi3.TypesRefString(schema.Type) || tt.oasFormat != schema.Format { 38 | t.Errorf(`fromspring.ParseSpringLineToSchema("%s") MISMATCH W[%v]G[%v] [%v][%v] [%v][%v]`, tt.v, tt.oasName, name, tt.oasType, schema.Type, tt.oasFormat, schema.Format) 39 | } 40 | //fmtutil.PrintJSON(schema) 41 | } 42 | } 43 | 44 | /* 45 | const CampaignLeadSearchCriteriaSimple = `private List leadIds = new ArrayList<>(); 46 | private List listIds = new ArrayList<>(); 47 | private List externIds = new ArrayList<>(); 48 | private List physicalStates; 49 | private List agentDispositions; 50 | private List leadPhoneNumbers = new ArrayList<>(); 51 | private boolean orphanedLeadsOnly; 52 | private String callerId; 53 | private String leadPhoneNum; 54 | private List campaignIds = new ArrayList<>(); 55 | private String firstName; 56 | private String lastName; 57 | private String address1; 58 | private String address2; 59 | private String city; 60 | private String zip; 61 | private String emailAddress; 62 | private String auxData1; 63 | private String auxData2; 64 | private String auxData3; 65 | private String auxData4; 66 | private String auxData5; 67 | private Integer pendingAgentId; 68 | private Integer agentId;` 69 | */ 70 | -------------------------------------------------------------------------------- /openapi3/openapi3html/pageparams.go: -------------------------------------------------------------------------------- 1 | package openapi3html 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html" 7 | "os" 8 | "strings" 9 | 10 | oas3 "github.com/getkin/kin-openapi/openapi3" 11 | "github.com/grokify/gocharts/v2/data/table" 12 | "github.com/grokify/gocharts/v2/data/table/tabulator" 13 | "github.com/grokify/spectrum/openapi3" 14 | ) 15 | 16 | type PageParams struct { 17 | PageTitle string 18 | PageLink string 19 | TableDomID string 20 | Spec *openapi3.Spec 21 | ColumnSet *tabulator.ColumnSet 22 | OpsFilterFunc func(path, method string, op *oas3.Operation) bool 23 | OpsAdditionalFormatFuncs *openapi3.OperationMoreStringFuncMap 24 | TableJSON []byte 25 | } 26 | 27 | func (pp *PageParams) PageLinkHTML() string { 28 | pp.PageLink = strings.TrimSpace(pp.PageLink) 29 | if len(pp.PageLink) == 0 { 30 | return html.EscapeString(pp.PageTitle) 31 | } 32 | return fmt.Sprintf("%s", pp.PageLink, 33 | html.EscapeString(pp.PageTitle)) 34 | } 35 | 36 | func (pp *PageParams) AddSpec(spec *openapi3.Spec) error { 37 | sm := openapi3.SpecMore{Spec: spec} 38 | tbl, err := sm.OperationsTable(pp.ColumnSet, pp.OpsFilterFunc, pp.OpsAdditionalFormatFuncs) 39 | if err != nil { 40 | return err 41 | } 42 | return pp.AddOperationsTable(tbl) 43 | } 44 | 45 | func (pp *PageParams) AddOperationsTable(tbl *table.Table) error { 46 | docs := tbl.ToDocuments() 47 | jdocs, err := json.Marshal(docs) 48 | if err != nil { 49 | return err 50 | } 51 | pp.TableJSON = jdocs 52 | return nil 53 | } 54 | 55 | func (pp *PageParams) TableJSONBytesOrEmpty() []byte { 56 | empty := []byte("[]") 57 | if len(pp.TableJSON) > 0 { 58 | return pp.TableJSON 59 | } 60 | if pp.Spec != nil { 61 | err := pp.AddSpec(pp.Spec) 62 | if err != nil { 63 | return empty 64 | } 65 | return pp.TableJSON 66 | } 67 | return empty 68 | } 69 | 70 | func (pp *PageParams) TabulatorColumnsJSONBytesOrEmpty() []byte { 71 | if pp.ColumnSet == nil || len(pp.ColumnSet.Columns) == 0 { 72 | colSet := openapi3.OpTableColumnsDefault(false) 73 | tcols := tabulator.BuildColumnsTabulator(colSet.Columns) 74 | return tcols.MustColumnsJSON() 75 | } 76 | tcols := tabulator.BuildColumnsTabulator(pp.ColumnSet.Columns) 77 | return tcols.MustColumnsJSON() 78 | } 79 | 80 | func (pp *PageParams) WriteFile(filename string) error { 81 | f, err := os.Create(filename) 82 | if err != nil { 83 | return err 84 | } 85 | defer f.Close() 86 | WriteSpectrumUIPage(f, *pp) 87 | return nil 88 | } 89 | 90 | /* 91 | func DefaultColumns() text.Texts { 92 | return text.Texts{ 93 | { 94 | Display: "Method", 95 | Slug: "method"}, 96 | { 97 | Display: "Path", 98 | Slug: "path"}, 99 | { 100 | Display: "OperationID", 101 | Slug: "operationId"}, 102 | { 103 | Display: "Summary", 104 | Slug: "summary"}, 105 | { 106 | Display: "Tags", 107 | Slug: "tags"}, 108 | } 109 | } 110 | */ 111 | -------------------------------------------------------------------------------- /ext/taxonomy/categories.go: -------------------------------------------------------------------------------- 1 | package taxonomy 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | 8 | mapslice "github.com/ake-persson/mapslice-json" 9 | oas3 "github.com/getkin/kin-openapi/openapi3" 10 | "github.com/grokify/mogo/encoding/jsonutil" 11 | "github.com/grokify/mogo/errors/errorsutil" 12 | "github.com/grokify/spectrum/openapi3" 13 | "sigs.k8s.io/yaml" 14 | ) 15 | 16 | const XTaxonomy = "x-taxonomy" 17 | 18 | type Category struct { 19 | Key string `json:"-"` 20 | Slug string `json:"slug,omitempty"` 21 | Title string `json:"title,omitempty"` 22 | Description string `json:"description,omitempty"` 23 | Tags []oas3.Tag `json:"tags,omitempty" yaml:"tags,omitempty"` 24 | } 25 | 26 | func (cat *Category) Taxonomy(catsFilepath string) (Taxonomy, error) { 27 | if len(cat.Key) == 0 { 28 | return Taxonomy{}, errors.New("categgory `Key` not set") 29 | } 30 | return Taxonomy{ 31 | Category: CategoryRef{Ref: fmt.Sprintf("%s#/%s", catsFilepath, cat.Key)}, 32 | Slug: cat.Slug}, nil 33 | } 34 | 35 | type Categories []Category 36 | 37 | var ErrCategoryNotFound = errors.New("category not found") 38 | 39 | // SpecAddXTaxonomy expects `catTitle` to be the same as the OAS3 `tag` name. 40 | func (cats *Categories) SpecAddXTaxonomy(spec *openapi3.Spec, catTitle, catsFilepath string) error { 41 | if spec == nil { 42 | return openapi3.ErrSpecNotSet 43 | } 44 | cat, err := cats.Category(catTitle) 45 | if err != nil { 46 | return errorsutil.Wrapf(ErrCategoryNotFound, "category (%s)", catTitle) 47 | } 48 | tax, err := cat.Taxonomy(catsFilepath) 49 | if err != nil { 50 | return err 51 | } 52 | tax.AddToSpec(spec) 53 | return nil 54 | } 55 | 56 | func (cats *Categories) Category(title string) (Category, error) { 57 | for _, cat := range *cats { 58 | if cat.Title == title { 59 | return cat, nil 60 | } 61 | } 62 | return Category{}, errorsutil.Wrapf(ErrCategoryNotFound, "category (%s)", title) 63 | } 64 | 65 | func (cats *Categories) MapSlice() mapslice.MapSlice { 66 | ms := mapslice.MapSlice{} 67 | for _, cat := range *cats { 68 | ms = append(ms, mapslice.MapItem{Key: cat.Key, Value: cat}) 69 | } 70 | return ms 71 | } 72 | 73 | func (cats *Categories) MarshalJSON(prefix, indent string) ([]byte, error) { 74 | return jsonutil.MarshalSimple(cats.MapSlice(), prefix, indent) 75 | } 76 | 77 | func (cats *Categories) MarshalYAML() ([]byte, error) { 78 | if jbytes, err := cats.MarshalJSON("", ""); err != nil { 79 | return []byte{}, err 80 | } else { 81 | return yaml.JSONToYAML(jbytes) 82 | } 83 | } 84 | 85 | func (cats *Categories) WriteFileJSON(filename string, perm os.FileMode, prefix, indent string) error { 86 | if jsonData, err := cats.MarshalJSON(prefix, indent); err != nil { 87 | return err 88 | } else { 89 | return os.WriteFile(filename, jsonData, perm) 90 | } 91 | } 92 | 93 | func (cats *Categories) WriteFileYAML(filename string, perm os.FileMode) error { 94 | if ybytes, err := cats.MarshalYAML(); err != nil { 95 | return err 96 | } else { 97 | return os.WriteFile(filename, ybytes, perm) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /openapi3lint/ruleschemapropenumstyle/rule_schema_prop_enum_style.go: -------------------------------------------------------------------------------- 1 | package ruleschemapropenumstyle 2 | 3 | import ( 4 | "fmt" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/encoding/jsonpointer" 8 | "github.com/grokify/mogo/text/stringcase" 9 | "github.com/grokify/spectrum/openapi3" 10 | "github.com/grokify/spectrum/openapi3lint/lintutil" 11 | ) 12 | 13 | type RuleSchemaPropEnumStyle struct { 14 | name string 15 | stringCase string 16 | } 17 | 18 | func NewRule(requiredStringCase string) (RuleSchemaPropEnumStyle, error) { 19 | canonicalCase, err := stringcase.Parse(requiredStringCase) 20 | if err != nil { 21 | return RuleSchemaPropEnumStyle{}, 22 | fmt.Errorf("invalid string case [%s]", requiredStringCase) 23 | } 24 | rule := RuleSchemaPropEnumStyle{ 25 | stringCase: canonicalCase} 26 | switch canonicalCase { 27 | case stringcase.CamelCase: 28 | rule.name = lintutil.RulenameSchemaPropEnumStyleCamelCase 29 | case stringcase.KebabCase: 30 | rule.name = lintutil.RulenameSchemaPropEnumStyleKebabCase 31 | case stringcase.PascalCase: 32 | rule.name = lintutil.RulenameSchemaPropEnumStylePascalCase 33 | case stringcase.SnakeCase: 34 | rule.name = lintutil.RulenameSchemaPropEnumStyleSnakeCase 35 | default: 36 | return rule, fmt.Errorf("invalid string case [%s]", canonicalCase) 37 | } 38 | return rule, nil 39 | } 40 | 41 | func (rule RuleSchemaPropEnumStyle) Name() string { 42 | return rule.name 43 | } 44 | 45 | func (rule RuleSchemaPropEnumStyle) Scope() string { 46 | return lintutil.ScopeSpecification 47 | } 48 | 49 | func (rule RuleSchemaPropEnumStyle) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 50 | return nil 51 | } 52 | 53 | func (rule RuleSchemaPropEnumStyle) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 54 | vios := []lintutil.PolicyViolation{} 55 | 56 | for schName, schRef := range spec.Components.Schemas { 57 | if schRef == nil || schRef.Value == nil || !openapi3.TypesRefIs(schRef.Value.Type, openapi3.TypeObject) { 58 | continue 59 | } 60 | 61 | for propName, propRef := range schRef.Value.Properties { 62 | if propRef.Value == nil || !openapi3.TypesRefIs(propRef.Value.Type, openapi3.TypeString) || 63 | len(propRef.Value.Enum) == 0 { 64 | continue 65 | } 66 | for i, enumValue := range propRef.Value.Enum { 67 | if enumValueString, ok := enumValue.(string); ok { 68 | jsPtr := jsonpointer.PointerSubEscapeAll( 69 | "%s#/components/schemas/%s/properties/%s/%d", 70 | pointerBase, schName, propName, i) 71 | isWantCase, err := stringcase.IsCase(rule.stringCase, enumValueString) 72 | if err != nil { 73 | // should never happen as rule.stringCase should be validated. 74 | vios = append(vios, lintutil.PolicyViolation{ 75 | RuleName: rule.Name(), 76 | Location: jsPtr, 77 | Value: enumValueString}) 78 | } else if !isWantCase { 79 | vios = append(vios, lintutil.PolicyViolation{ 80 | RuleName: rule.Name(), 81 | Location: jsPtr, 82 | Value: enumValueString}) 83 | } 84 | } 85 | } 86 | } 87 | } 88 | 89 | return vios 90 | } 91 | -------------------------------------------------------------------------------- /openapi2/merge.go: -------------------------------------------------------------------------------- 1 | package openapi2 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | 10 | "github.com/grokify/mogo/errors/errorsutil" 11 | "github.com/grokify/mogo/os/osutil" 12 | ) 13 | 14 | var jsonFileRx = regexp.MustCompile(`(?i)\.json\s*$`) 15 | 16 | func MergeDirectory(dir string) (Specification, error) { 17 | entries, err := osutil.ReadDirMore(dir, jsonFileRx, false, true, false) 18 | if err != nil { 19 | return Specification{}, err 20 | } 21 | if len(entries) == 0 { 22 | return Specification{}, fmt.Errorf("no JSON files found in directory [%s]", dir) 23 | } 24 | var specMaster Specification 25 | for i, entry := range entries { 26 | thisSpecFilepath := filepath.Join(dir, entry.Name()) 27 | thisSpec, err := ReadOpenAPI2SpecFileDirect(thisSpecFilepath) 28 | if err != nil { 29 | return specMaster, err 30 | } 31 | if i == 0 { 32 | specMaster = thisSpec 33 | } else { 34 | specMaster = Merge(specMaster, thisSpec) 35 | } 36 | } 37 | return specMaster, nil 38 | } 39 | 40 | func MergeFilepaths(filepaths []string) (Specification, error) { 41 | var specMaster Specification 42 | for i, fpath := range filepaths { 43 | fmt.Printf("[%v][%v]\n", i, fpath) 44 | thisSpec, err := ReadOpenAPI2SpecFileDirect(fpath) 45 | if err != nil { 46 | return specMaster, errorsutil.Wrap(err, fmt.Sprintf("E_READ_SPEC [%v]", fpath)) 47 | } 48 | if i == 0 { 49 | specMaster = thisSpec 50 | } else { 51 | specMaster = Merge(specMaster, thisSpec) 52 | } 53 | } 54 | return specMaster, nil 55 | } 56 | 57 | func Merge(specMaster, specExtra Specification) Specification { 58 | specMaster = MergeTags(specMaster, specExtra) 59 | specMaster = MergePaths(specMaster, specExtra) 60 | return MergeDefinitions(specMaster, specExtra) 61 | } 62 | 63 | func MergeTags(specMaster, specExtra Specification) Specification { 64 | tagsMap := map[string]int{} 65 | for _, tag := range specMaster.Tags { 66 | tagsMap[tag.Name] = 1 67 | } 68 | for _, tag := range specExtra.Tags { 69 | tag.Name = strings.TrimSpace(tag.Name) 70 | if _, ok := tagsMap[tag.Name]; !ok { 71 | specMaster.Tags = append(specMaster.Tags, tag) 72 | } 73 | } 74 | return specMaster 75 | } 76 | 77 | func MergePaths(specMaster, specExtra Specification) Specification { 78 | for url, path := range specExtra.Paths { 79 | specMaster.Paths[url] = path 80 | } 81 | return specMaster 82 | } 83 | 84 | func MergeDefinitions(specMaster, specExtra Specification) Specification { 85 | for definitionName, def := range specExtra.Definitions { 86 | if specMaster.Definitions == nil { 87 | specMaster.Definitions = map[string]Definition{} 88 | } 89 | specMaster.Definitions[definitionName] = def 90 | } 91 | return specMaster 92 | } 93 | 94 | func WriteFileDirMerge(outfile, inputDir string, perm os.FileMode) error { 95 | spec, err := MergeDirectory(inputDir) 96 | if err != nil { 97 | return errorsutil.Wrap(err, "E_OPENAPI3_MERGE_DIRECTORY_FAILED") 98 | } 99 | 100 | err = osutil.WriteFileJSON(outfile, spec, perm, "", " ") 101 | if err != nil { 102 | return errorsutil.Wrap(err, "E_OPENAPI3_WRITE_FAILED") 103 | } 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grokify/spectrum 2 | 3 | go 1.24.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/ake-persson/mapslice-json v0.0.0-20210720081907-22c8edf57807 9 | github.com/buaazp/fasthttprouter v0.1.1 10 | github.com/getkin/kin-openapi v0.133.0 11 | github.com/grokify/gocharts/v2 v2.26.6 12 | github.com/grokify/mogo v0.72.0 13 | github.com/grokify/sogo v0.12.12 14 | github.com/jessevdk/go-flags v1.6.1 15 | github.com/qdm12/reprint v0.0.0-20200326205758-722754a53494 16 | github.com/rs/zerolog v1.34.0 17 | github.com/valyala/fasthttp v1.68.0 18 | github.com/valyala/quicktemplate v1.8.0 19 | golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 20 | gopkg.in/yaml.v3 v3.0.1 21 | sigs.k8s.io/yaml v1.6.0 22 | ) 23 | 24 | require ( 25 | github.com/andybalholm/brotli v1.2.0 // indirect 26 | github.com/apex/gateway v1.1.2 // indirect 27 | github.com/aws/aws-lambda-go v1.50.0 // indirect 28 | github.com/clipperhouse/displaywidth v0.6.1 // indirect 29 | github.com/clipperhouse/stringish v0.1.1 // indirect 30 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 31 | github.com/fatih/color v1.18.0 // indirect 32 | github.com/go-openapi/jsonpointer v0.22.3 // indirect 33 | github.com/go-openapi/swag/jsonname v0.25.3 // indirect 34 | github.com/gorilla/mux v1.8.1 // indirect 35 | github.com/grokify/base36 v1.0.5 // indirect 36 | github.com/huandu/xstrings v1.5.0 // indirect 37 | github.com/iancoleman/strcase v0.3.0 // indirect 38 | github.com/josharian/intern v1.0.0 // indirect 39 | github.com/json-iterator/go v1.1.12 // indirect 40 | github.com/klauspost/compress v1.18.1 // indirect 41 | github.com/mailru/easyjson v0.9.1 // indirect 42 | github.com/mattn/go-colorable v0.1.14 // indirect 43 | github.com/mattn/go-isatty v0.0.20 // indirect 44 | github.com/mattn/go-runewidth v0.0.19 // indirect 45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 46 | github.com/modern-go/reflect2 v1.0.2 // indirect 47 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect 48 | github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect 49 | github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect 50 | github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect 51 | github.com/olekukonko/errors v1.1.0 // indirect 52 | github.com/olekukonko/ll v0.1.3 // indirect 53 | github.com/olekukonko/tablewriter v1.1.2 // indirect 54 | github.com/perimeterx/marshmallow v1.1.5 // indirect 55 | github.com/pkg/errors v0.9.1 // indirect 56 | github.com/richardlehane/mscfb v1.0.4 // indirect 57 | github.com/richardlehane/msoleps v1.0.4 // indirect 58 | github.com/tiendc/go-deepcopy v1.7.2 // indirect 59 | github.com/valyala/bytebufferpool v1.0.0 // indirect 60 | github.com/woodsbury/decimal128 v1.4.0 // indirect 61 | github.com/xuri/efp v0.0.1 // indirect 62 | github.com/xuri/excelize/v2 v2.10.0 // indirect 63 | github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect 64 | go.yaml.in/yaml/v2 v2.4.3 // indirect 65 | golang.org/x/crypto v0.45.0 // indirect 66 | golang.org/x/image v0.33.0 // indirect 67 | golang.org/x/net v0.47.0 // indirect 68 | golang.org/x/sys v0.38.0 // indirect 69 | golang.org/x/text v0.31.0 // indirect 70 | gonum.org/v1/gonum v0.16.0 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /openapi3lint/ruleopxpropertystringexist/rule_op_xproperty_string_exist.go: -------------------------------------------------------------------------------- 1 | package ruleopxpropertystringexist 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | 7 | oas3 "github.com/getkin/kin-openapi/openapi3" 8 | "github.com/grokify/mogo/encoding/jsonpointer" 9 | "github.com/grokify/spectrum/openapi3" 10 | "github.com/grokify/spectrum/openapi3lint/lintutil" 11 | ) 12 | 13 | type RuleOperationXPropertyStringExist struct { 14 | name string 15 | xPropertyName string 16 | inclSpec bool 17 | inclPathItem bool 18 | } 19 | 20 | func NewRule(ruleName, xPropertyName string, inclSpec, inclPathItem bool) (RuleOperationXPropertyStringExist, error) { 21 | ruleName = strings.ToLower(strings.TrimSpace(ruleName)) 22 | xPropertyName = strings.TrimSpace(xPropertyName) 23 | 24 | if len(ruleName) == 0 { 25 | return RuleOperationXPropertyStringExist{}, 26 | errors.New("rule name not provided") 27 | } 28 | if len(xPropertyName) == 0 { 29 | return RuleOperationXPropertyStringExist{}, 30 | errors.New("x-property name not provided") 31 | } 32 | 33 | rule := RuleOperationXPropertyStringExist{ 34 | name: ruleName, 35 | xPropertyName: xPropertyName, 36 | inclSpec: inclSpec, 37 | inclPathItem: inclPathItem} 38 | return rule, nil 39 | } 40 | 41 | func (rule RuleOperationXPropertyStringExist) Name() string { 42 | return rule.name 43 | } 44 | 45 | func (rule RuleOperationXPropertyStringExist) Scope() string { 46 | return lintutil.ScopeSpecification 47 | } 48 | 49 | func (rule RuleOperationXPropertyStringExist) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 50 | return []lintutil.PolicyViolation{} 51 | } 52 | 53 | func (rule RuleOperationXPropertyStringExist) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 54 | vios := []lintutil.PolicyViolation{} 55 | if spec == nil { 56 | return vios 57 | } 58 | if rule.inclSpec { 59 | propVal := strings.TrimSpace(openapi3.GetExtensionPropStringOrEmpty( 60 | spec.Extensions, rule.xPropertyName)) 61 | // spec.ExtensionProps, rule.xPropertyName)) 62 | if len(propVal) > 0 { 63 | return vios 64 | } 65 | } 66 | pathsMap := spec.Paths.Map() 67 | for pathURL, pathItem := range pathsMap { 68 | // for pathURL, pathItem := range spec.Paths { // getkin v0.121.0 to v0.122.0 69 | if pathItem == nil { 70 | continue 71 | } 72 | if rule.inclPathItem { 73 | propVal := strings.TrimSpace(openapi3.GetExtensionPropStringOrEmpty( 74 | pathItem.Extensions, rule.xPropertyName)) 75 | // pathItem.ExtensionProps, rule.xPropertyName)) 76 | if len(propVal) > 0 { 77 | continue 78 | } 79 | } 80 | openapi3.VisitOperationsPathItem(pathURL, pathItem, 81 | func(path, method string, op *oas3.Operation) { 82 | if op == nil { 83 | return 84 | } 85 | propVal := strings.TrimSpace(openapi3.GetExtensionPropStringOrEmpty( 86 | op.Extensions, rule.xPropertyName)) 87 | // op.ExtensionProps, rule.xPropertyName)) 88 | if len(propVal) == 0 { 89 | vios = append(vios, lintutil.PolicyViolation{ 90 | RuleName: rule.Name(), 91 | Location: jsonpointer.PointerSubEscapeAll( 92 | "%s#/paths/%s/%s/%s", 93 | pointerBase, pathURL, method, rule.xPropertyName, 94 | ), 95 | }) 96 | } 97 | }, 98 | ) 99 | } 100 | return vios 101 | } 102 | -------------------------------------------------------------------------------- /openapi3edit/delete.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import ( 4 | "net/http" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/net/urlutil" 8 | "github.com/grokify/spectrum/openapi3" 9 | ) 10 | 11 | func (se *SpecEdit) DeleteProperties(md openapi3.SpecMetadata) { 12 | if se.SpecMore.Spec == nil { 13 | return 14 | } 15 | spec := se.SpecMore.Spec 16 | for _, opID := range md.OperationIDs { 17 | se.DeleteOperations( 18 | func(urlpath, method string, op *oas3.Operation) bool { 19 | if op != nil && op.OperationID == opID { 20 | return true 21 | } 22 | return false 23 | }) 24 | } 25 | for _, epDel := range md.Endpoints { 26 | se.DeleteOperations( 27 | func(urlpath, method string, op *oas3.Operation) bool { 28 | if op == nil { 29 | return false 30 | } 31 | if epDel == urlutil.EndpointString(urlpath, method, false) || 32 | epDel == urlutil.EndpointString(urlpath, method, true) { 33 | return true 34 | } 35 | return false 36 | }) 37 | } 38 | for _, schemaNameDel := range md.SchemaNames { 39 | for schemaNameTry := range spec.Components.Schemas { 40 | if schemaNameDel == schemaNameTry { 41 | delete(spec.Components.Schemas, schemaNameTry) 42 | } 43 | } 44 | } 45 | } 46 | 47 | func (se *SpecEdit) DeleteOperations(delThis func(urlpath, method string, op *oas3.Operation) bool) { 48 | if se.SpecMore.Spec == nil { 49 | return 50 | } 51 | // newPaths := oas3.Paths{} // getkin v0.121.0 to v0.122.0 52 | newPaths := oas3.NewPaths() 53 | 54 | pathsMap := se.SpecMore.Spec.Paths.Map() 55 | for urlpath, pathItem := range pathsMap { 56 | // for urlpath, pathItem := range se.SpecMore.Spec.Paths { // getkin v0.121.0 to v0.122.0 57 | newPathItem := oas3.PathItem{ 58 | // ExtensionProps: pathItem.ExtensionProps, 59 | Extensions: pathItem.Extensions, 60 | Ref: pathItem.Ref, 61 | Summary: pathItem.Summary, 62 | Description: pathItem.Description, 63 | Servers: pathItem.Servers, 64 | Parameters: pathItem.Parameters} 65 | if pathItem.Connect != nil && !delThis(urlpath, http.MethodConnect, pathItem.Connect) { 66 | newPathItem.Connect = pathItem.Connect 67 | } 68 | if pathItem.Delete != nil && !delThis(urlpath, http.MethodDelete, pathItem.Delete) { 69 | newPathItem.Delete = pathItem.Delete 70 | } 71 | if pathItem.Get != nil && !delThis(urlpath, http.MethodGet, pathItem.Get) { 72 | newPathItem.Get = pathItem.Get 73 | } 74 | if pathItem.Head != nil && !delThis(urlpath, http.MethodHead, pathItem.Head) { 75 | newPathItem.Head = pathItem.Head 76 | } 77 | if pathItem.Options != nil && !delThis(urlpath, http.MethodOptions, pathItem.Options) { 78 | newPathItem.Options = pathItem.Options 79 | } 80 | if pathItem.Patch != nil && !delThis(urlpath, http.MethodPatch, pathItem.Patch) { 81 | newPathItem.Patch = pathItem.Patch 82 | } 83 | if pathItem.Post != nil && !delThis(urlpath, http.MethodPost, pathItem.Post) { 84 | newPathItem.Post = pathItem.Post 85 | } 86 | if pathItem.Put != nil && !delThis(urlpath, http.MethodPut, pathItem.Put) { 87 | newPathItem.Put = pathItem.Put 88 | } 89 | if pathItem.Trace != nil && !delThis(urlpath, http.MethodTrace, pathItem.Trace) { 90 | newPathItem.Trace = pathItem.Trace 91 | } 92 | if openapi3.PathItemHasEndpoints(&newPathItem) { 93 | newPaths.Set(urlpath, &newPathItem) 94 | // newPaths[urlpath] = &newPathItem // getkin v0.121.0 to v0.122.0 95 | } 96 | } 97 | se.SpecMore.Spec.Paths = newPaths 98 | } 99 | -------------------------------------------------------------------------------- /openapi3lint/ruleschemaobjectpropsexist/rule_schema_object_props_exist.go: -------------------------------------------------------------------------------- 1 | // ruleschemaobjectpropsexist ensures that schema objects have properties. 2 | package ruleschemaobjectpropsexist 3 | 4 | import ( 5 | oas3 "github.com/getkin/kin-openapi/openapi3" 6 | "github.com/grokify/mogo/encoding/jsonpointer" 7 | "github.com/grokify/spectrum/openapi3" 8 | "github.com/grokify/spectrum/openapi3lint/lintutil" 9 | ) 10 | 11 | type RuleSchemaObjectPropsExist struct { 12 | name string 13 | } 14 | 15 | func NewRule() RuleSchemaObjectPropsExist { 16 | return RuleSchemaObjectPropsExist{ 17 | name: lintutil.RulenameSchemaObjectPropsExist} 18 | } 19 | 20 | func (rule RuleSchemaObjectPropsExist) Name() string { 21 | return rule.name 22 | } 23 | 24 | func (rule RuleSchemaObjectPropsExist) Scope() string { 25 | return lintutil.ScopeSpecification 26 | } 27 | 28 | func (rule RuleSchemaObjectPropsExist) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 29 | return nil 30 | } 31 | 32 | func (rule RuleSchemaObjectPropsExist) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 33 | vios := []lintutil.PolicyViolation{} 34 | 35 | for schName, schRef := range spec.Components.Schemas { 36 | if schRef == nil || schRef.Value == nil || !openapi3.TypesRefIs(schRef.Value.Type, openapi3.TypeObject) { 37 | continue 38 | } 39 | /* 40 | if len(schRef.Value.Properties) == 0 && schRef.Value.AdditionalProperties == nil && 41 | (schRef.Value.AdditionalPropertiesAllowed == nil || !*schRef.Value.AdditionalPropertiesAllowed) { 42 | vios = append(vios, lintutil.PolicyViolation{ 43 | RuleName: rule.Name(), 44 | Location: jsonpointer.PointerSubEscapeAll( 45 | "%s#/components/schemas/%s", 46 | pointerBase, schName)}) 47 | } 48 | */ 49 | if len(schRef.Value.Properties) == 0 && !openapi3.AdditionalPropertiesExists(schRef.Value.AdditionalProperties) { 50 | // (!openapi3.AdditionalPropertiesAllowed(schRef.Value.AdditionalProperties) || 51 | // len(schRef.Value.AdditionalProperties) == 0) { 52 | vios = append(vios, lintutil.PolicyViolation{ 53 | RuleName: rule.Name(), 54 | Location: jsonpointer.PointerSubEscapeAll( 55 | "%s#/components/schemas/%s", 56 | pointerBase, schName)}) 57 | } 58 | for propName, propRef := range schRef.Value.Properties { 59 | if propRef == nil || propRef.Value == nil || !openapi3.TypesRefIs(propRef.Value.Type, openapi3.TypeObject) { 60 | continue 61 | } 62 | /* 63 | if len(propRef.Value.Properties) == 0 && 64 | propRef.Value.AdditionalProperties == nil && 65 | (propRef.Value.AdditionalPropertiesAllowed == nil || !*propRef.Value.AdditionalPropertiesAllowed) { 66 | vios = append(vios, lintutil.PolicyViolation{ 67 | RuleName: rule.Name(), 68 | Location: jsonpointer.PointerSubEscapeAll( 69 | "%s#/components/schemas/%s/properties/%s", 70 | pointerBase, schName, propName)}) 71 | } 72 | */ 73 | if len(propRef.Value.Properties) == 0 && !openapi3.AdditionalPropertiesExists(propRef.Value.AdditionalProperties) { 74 | // propRef.Value.AdditionalProperties == nil && 75 | // (propRef.Value.AdditionalPropertiesAllowed == nil || !*propRef.Value.AdditionalPropertiesAllowed) { 76 | vios = append(vios, lintutil.PolicyViolation{ 77 | RuleName: rule.Name(), 78 | Location: jsonpointer.PointerSubEscapeAll( 79 | "%s#/components/schemas/%s/properties/%s", 80 | pointerBase, schName, propName)}) 81 | } 82 | } 83 | } 84 | return vios 85 | } 86 | -------------------------------------------------------------------------------- /cmd/oas3lint/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | 7 | "github.com/grokify/mogo/fmt/fmtutil" 8 | "github.com/grokify/mogo/log/logutil" 9 | "github.com/grokify/mogo/os/osutil" 10 | "github.com/grokify/spectrum/openapi3lint" 11 | "github.com/grokify/spectrum/openapi3lint/lintutil" 12 | flags "github.com/jessevdk/go-flags" 13 | ) 14 | 15 | type Options struct { 16 | PolicyFile string `short:"p" long:"policyfile" description:"Policy File" required:"true"` 17 | InputFileOAS3 string `short:"i" long:"inputspec" description:"Input OAS Spec File or Dir" required:"false"` 18 | Severity string `short:"s" long:"severity" description:"Severity level"` 19 | } 20 | 21 | func main() { 22 | var opts Options 23 | _, err := flags.Parse(&opts) 24 | logutil.FatalErr(err) 25 | fmtutil.MustPrintJSON(opts) 26 | 27 | vsets, err := ValidateSpecFiles(opts.InputFileOAS3, opts.PolicyFile, opts.Severity) 28 | logutil.FatalErr(err) 29 | 30 | fmtutil.MustPrintJSON(vsets.LocationsByRule()) 31 | fmtutil.MustPrintJSON(vsets.CountsByRule()) 32 | 33 | fmt.Println("DONE") 34 | } 35 | 36 | func ValidateSpecFiles(specFileOrDir string, policyfile, sev string) (*lintutil.PolicyViolationsSets, error) { 37 | files, err := filesFromFileOrDir(specFileOrDir) 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | polCfg, err := openapi3lint.NewPolicyConfigFile(policyfile) 43 | if err != nil { 44 | return nil, err 45 | } 46 | //polCfg.AddRuleCollection(extensions.NewRuleCollectionExtensions()) 47 | //logutil.FatalErr(fmtutil.PrintJSON(polCfg)) 48 | //logutil.FatalErr(fmtutil.PrintJSON(polCfg.RuleNames())) 49 | 50 | pol, err := polCfg.Policy() 51 | if err != nil { 52 | return nil, err 53 | } 54 | fmtutil.MustPrintJSON(pol) 55 | fmtutil.MustPrintJSON(pol.RuleNames()) 56 | 57 | return pol.ValidateSpecFiles(sev, files) 58 | } 59 | 60 | func filesFromFileOrDir(filename string) ([]string, error) { 61 | return osutil.Filenames(filename, regexp.MustCompile(`(?i)\.(json|yaml|yml)$`), false, false) 62 | } 63 | 64 | /* 65 | func filesFromFileOrDirOld(filename string) ([]string, error) { 66 | var files []string 67 | if len(filename) > 0 { 68 | isDir, err := osutil.IsDir(filename) 69 | if err != nil { 70 | return files, err 71 | } 72 | 73 | if isDir { 74 | entries, err := osutil.ReadDirMore(filename, 75 | regexp.MustCompile(`(?i)\.(json|yaml|yml)$`), false, true, false) 76 | logutil.FatalErr(err) 77 | 78 | files = osutil.DirEntries(entries).Names(filename, true) 79 | } else { 80 | files = []string{filename} 81 | } 82 | } else { 83 | files = []string{filename} 84 | } 85 | return files, nil 86 | } 87 | */ 88 | 89 | /* 90 | func getPolicyConfig() openapi3lint.PolicyConfig { 91 | return openapi3lint.PolicyConfig{ 92 | Rules: map[string]openapi3lint.RuleConfig{ 93 | openapi3lint.RuleOpIdStyleCamelCase: { 94 | Severity: severity.SeverityError}, 95 | openapi3lint.RuleOpSummaryExist: { 96 | Severity: severity.SeverityError}, 97 | openapi3lint.RuleOpSummaryCaseFirstCapitalized: { 98 | Severity: severity.SeverityError}, 99 | openapi3lint.RulePathParamStyleCamelCase: { 100 | Severity: severity.SeverityError}, 101 | openapi3lint.RuleSchemaObjectPropsExist: { 102 | Severity: severity.SeverityError}, 103 | openapi3lint.RuleSchemaPropEnumStylePascalCase: { 104 | Severity: severity.SeverityError}, 105 | openapi3lint.RuleTagCaseFirstCapitalized: { 106 | Severity: severity.SeverityError}, 107 | }, 108 | } 109 | } 110 | */ 111 | -------------------------------------------------------------------------------- /openapi3edit/multi.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import ( 4 | "fmt" 5 | 6 | oas3 "github.com/getkin/kin-openapi/openapi3" 7 | "github.com/grokify/mogo/errors/errorsutil" 8 | "github.com/grokify/mogo/fmt/fmtutil" 9 | "github.com/grokify/spectrum/openapi3" 10 | ) 11 | 12 | type SpecMoreModifyMultiOpts struct { 13 | OperationsDeleteFunc func(opPath, opMethod string, op *oas3.Operation) bool 14 | OperationsRenameIDsFunc func(string, string, *oas3.Operation) 15 | OperationsRemoveSecurity bool 16 | OperationsShowIDs bool 17 | OperationsExec bool 18 | Paths SpecPathsModifyOpts 19 | PathsShow bool 20 | PathsExec bool 21 | TagsOperationFunc func(string, string, *oas3.Operation) 22 | Tags map[string]string 23 | TagsShow bool 24 | TagsExec bool 25 | } 26 | 27 | // SpecMoreModifyMulti is used to perform multiple updates on an OpenAPI 3 spec. 28 | func SpecMoreModifyMulti(sm *openapi3.SpecMore, opts SpecMoreModifyMultiOpts) error { 29 | se := SpecEdit{SpecMore: *sm} 30 | if opts.OperationsShowIDs { 31 | // fmtutil.PrintJSON(SpecOperationIds(sm.Spec)) 32 | oldIDs := sm.OperationIDsCounts() 33 | if opts.OperationsShowIDs { 34 | err := fmtutil.PrintJSON(oldIDs) 35 | if err != nil { 36 | return err 37 | } 38 | } 39 | for id, count := range oldIDs { 40 | if count != 1 { 41 | return fmt.Errorf("E_OPERATION_ID_BAD_COUNT ID[%s]COUNT[%d]", id, count) 42 | } 43 | } 44 | } 45 | if opts.OperationsExec { 46 | if opts.OperationsDeleteFunc != nil { 47 | se.DeleteOperations(opts.OperationsDeleteFunc) 48 | } 49 | if opts.OperationsRenameIDsFunc != nil { 50 | openapi3.VisitOperations(sm.Spec, opts.OperationsRenameIDsFunc) 51 | } 52 | // UpdateOperationIds(sm.Spec, opts.OperationIdsRename) 53 | newIDs := sm.OperationIDsCounts() 54 | if opts.OperationsShowIDs { 55 | err := fmtutil.PrintJSON(newIDs) 56 | if err != nil { 57 | return err 58 | } 59 | } 60 | for id, count := range newIDs { 61 | if count != 1 { 62 | return fmt.Errorf("E_OPERATION_ID_BAD_COUNT_AFTER_RENAME ID[%s]COUNT[%d]", id, count) 63 | } 64 | } 65 | 66 | if opts.OperationsRemoveSecurity { 67 | err := se.OperationsSecurityRemove([]string{}) 68 | if err != nil { 69 | return err 70 | } 71 | } 72 | } 73 | // Update Paths 74 | if opts.PathsShow { 75 | err := fmtutil.PrintJSON(InspectPaths(sm.Spec)) 76 | if err != nil { 77 | return err 78 | } 79 | } 80 | if opts.PathsExec { 81 | err := se.PathsModify(opts.Paths) 82 | if err != nil { 83 | return errorsutil.Wrap(err, "specModifyMulti") 84 | } 85 | if opts.PathsShow { 86 | err := fmtutil.PrintJSON(InspectPaths(sm.Spec)) 87 | if err != nil { 88 | return err 89 | } 90 | } 91 | } 92 | 93 | // Update Tags 94 | if opts.TagsOperationFunc != nil || len(opts.Tags) > 0 { 95 | if opts.TagsShow { 96 | err := fmtutil.PrintJSON(sm.TagsMap(&openapi3.TagsOpts{InclDefs: true, InclOps: true})) 97 | if err != nil { 98 | return err 99 | } 100 | } 101 | if opts.TagsExec { 102 | if opts.TagsOperationFunc != nil { 103 | openapi3.VisitOperations(sm.Spec, opts.TagsOperationFunc) 104 | } 105 | se := SpecEdit{SpecMore: *sm} 106 | se.TagsModify(opts.Tags) 107 | if opts.TagsShow { 108 | err := fmtutil.PrintJSON(sm.TagsMap(&openapi3.TagsOpts{InclDefs: true, InclOps: true})) 109 | if err != nil { 110 | return err 111 | } 112 | } 113 | } 114 | } 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /openapi3/visit.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | oas3 "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/grokify/mogo/encoding/jsonpointer" 10 | ) 11 | 12 | const ( 13 | jPtrParamFormat = "#/components/parameters/%s" 14 | // jPtrSchemasRoot = "#/components/schemas/" 15 | jPtrSchemaPropertyFormat = "#/components/schemas/%s/properties/%s" 16 | ) 17 | 18 | func VisitTypesFormats(spec *Spec, visitTypeFormat func(jsonPointerRoot, oasType, oasFormat string)) { 19 | for schemaName, schemaRef := range spec.Components.Schemas { 20 | if schemaRef.Value == nil { 21 | continue 22 | } 23 | for propName, propRef := range schemaRef.Value.Properties { 24 | if propRef.Value == nil || propRef.Value.Type == nil { 25 | continue 26 | } 27 | for _, t := range *propRef.Value.Type { 28 | visitTypeFormat( 29 | fmt.Sprintf(jPtrSchemaPropertyFormat, schemaName, propName), 30 | t, 31 | propRef.Value.Format) 32 | } 33 | } 34 | } 35 | for paramName, paramRef := range spec.Components.Parameters { 36 | if paramRef.Value == nil || 37 | paramRef.Value.Schema == nil || 38 | paramRef.Value.Schema.Value == nil || 39 | paramRef.Value.Schema.Value.Type == nil { 40 | continue 41 | } 42 | for _, t := range *paramRef.Value.Schema.Value.Type { 43 | visitTypeFormat( 44 | fmt.Sprintf(jPtrParamFormat, paramName), 45 | t, 46 | paramRef.Value.Schema.Value.Format) 47 | } 48 | } 49 | VisitOperations( 50 | spec, 51 | func(path, method string, op *oas3.Operation) { 52 | if op == nil { 53 | return 54 | } 55 | for i, paramRef := range op.Parameters { 56 | if paramRef.Value == nil || 57 | paramRef.Value.Schema == nil || 58 | paramRef.Value.Schema.Value == nil || 59 | paramRef.Value.Schema.Value.Type == nil { 60 | continue 61 | } 62 | for _, t := range *paramRef.Value.Schema.Value.Type { 63 | visitTypeFormat( 64 | jsonpointer.PointerSubEscapeAll( 65 | "#/paths/%s/%s/parameters/%d/schema", path, strings.ToLower(method), i), 66 | t, 67 | paramRef.Value.Schema.Value.Format) 68 | } 69 | } 70 | }, 71 | ) 72 | } 73 | 74 | func VisitOperationsPathItem(path string, pathItem *oas3.PathItem, visitOp func(path, method string, op *oas3.Operation)) { 75 | pathURL := path 76 | if pathItem == nil { 77 | return 78 | } 79 | if pathItem.Connect != nil { 80 | visitOp(pathURL, http.MethodConnect, pathItem.Connect) 81 | } 82 | if pathItem.Delete != nil { 83 | visitOp(pathURL, http.MethodDelete, pathItem.Delete) 84 | } 85 | if pathItem.Get != nil { 86 | visitOp(pathURL, http.MethodGet, pathItem.Get) 87 | } 88 | if pathItem.Head != nil { 89 | visitOp(pathURL, http.MethodHead, pathItem.Head) 90 | } 91 | if pathItem.Options != nil { 92 | visitOp(pathURL, http.MethodOptions, pathItem.Options) 93 | } 94 | if pathItem.Patch != nil { 95 | visitOp(pathURL, http.MethodPatch, pathItem.Patch) 96 | } 97 | if pathItem.Post != nil { 98 | visitOp(pathURL, http.MethodPost, pathItem.Post) 99 | } 100 | if pathItem.Put != nil { 101 | visitOp(pathURL, http.MethodPut, pathItem.Put) 102 | } 103 | if pathItem.Trace != nil { 104 | visitOp(pathURL, http.MethodTrace, pathItem.Trace) 105 | } 106 | } 107 | 108 | func VisitOperations(spec *Spec, visitOp func(path, method string, op *oas3.Operation)) { 109 | pathsMap := spec.Paths.Map() 110 | for path, pathItem := range pathsMap { 111 | // for path, pathItem := range spec.Paths { // getkin v0.121.0 to v0.122.0 112 | VisitOperationsPathItem(path, pathItem, visitOp) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /openapi2/count.go: -------------------------------------------------------------------------------- 1 | package openapi2 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/grokify/gocharts/v2/data/histogram" 10 | "github.com/grokify/mogo/encoding/csvutil" 11 | "github.com/grokify/mogo/type/stringsutil" 12 | ) 13 | 14 | func CountEndpointsByTag(spec Specification, tagsFilter []string) *histogram.HistogramSet { 15 | tagsFilter = stringsutil.SliceCondenseSpace(tagsFilter, true, true) 16 | hist := histogram.NewHistogramSet("endpoints by tag") 17 | for url, path := range spec.Paths { 18 | hist = countEndpointByTag(hist, tagsFilter, url, http.MethodGet, path.Get) 19 | hist = countEndpointByTag(hist, tagsFilter, url, http.MethodPatch, path.Patch) 20 | hist = countEndpointByTag(hist, tagsFilter, url, http.MethodPut, path.Put) 21 | hist = countEndpointByTag(hist, tagsFilter, url, http.MethodPost, path.Post) 22 | hist = countEndpointByTag(hist, tagsFilter, url, http.MethodDelete, path.Delete) 23 | } 24 | return hist 25 | } 26 | 27 | func countEndpointByTag(hist *histogram.HistogramSet, tagsFilter []string, url string, method string, ep *Endpoint) *histogram.HistogramSet { 28 | if ep == nil { 29 | return hist 30 | } 31 | method = strings.ToUpper(strings.TrimSpace(method)) 32 | url = strings.TrimSpace(url) 33 | endpoint := method + " " + url 34 | for _, tag := range ep.Tags { 35 | tag = strings.TrimSpace(tag) 36 | add := true 37 | if len(tagsFilter) > 0 { // have tagsFilter 38 | add = false 39 | for _, try := range tagsFilter { 40 | if tag == try { 41 | add = true 42 | } 43 | } 44 | } 45 | if !add { 46 | continue 47 | } 48 | if len(tag) > 0 { 49 | hist.Add(tag, endpoint, 1) 50 | } 51 | } 52 | return hist 53 | } 54 | 55 | func WriteEndpointCountCSV(filename string, hset histogram.HistogramSet) error { 56 | writer, file, err := csvutil.NewWriterFile(filename) 57 | if err != nil { 58 | return err 59 | } 60 | //defer file.Close() 61 | //defer writer.Close() 62 | header := []string{"Tag", "Tag Endpoint Count", "Method", "Path"} 63 | err = writer.Write(header) 64 | if err != nil { 65 | return err 66 | } 67 | for tagName, hist := range hset.Items { 68 | hist.Inflate() 69 | for endpoint := range hist.Items { 70 | parts := strings.Split(endpoint, " ") 71 | if len(parts) >= 2 { 72 | row := []string{ 73 | tagName, 74 | strconv.Itoa(len(hist.Items)), 75 | strings.ToUpper(parts[0]), 76 | strings.Join(parts[1:], " ")} 77 | err := writer.Write(row) 78 | if err != nil { 79 | return err 80 | } 81 | } 82 | } 83 | } 84 | writer.Flush() 85 | if err = writer.Error(); err != nil { 86 | return err 87 | } else { 88 | return file.Close() 89 | } 90 | } 91 | 92 | // EndpointCount returns a count of the endpoints for a specification. 93 | func EndpointCount(spec Specification) int { 94 | endpoints := map[string]int{} 95 | for url, path := range spec.Paths { 96 | url = strings.TrimSpace(url) 97 | if path.Get != nil && !path.Get.IsEmpty() { 98 | endpoints[fmt.Sprintf("%s %s", http.MethodGet, url)] = 1 99 | } 100 | if path.Patch != nil && !path.Patch.IsEmpty() { 101 | endpoints[fmt.Sprintf("%s %s", http.MethodPatch, url)] = 1 102 | } 103 | if path.Post != nil && !path.Post.IsEmpty() { 104 | endpoints[fmt.Sprintf("%s %s", http.MethodPost, url)] = 1 105 | } 106 | if path.Put != nil && !path.Put.IsEmpty() { 107 | endpoints[fmt.Sprintf("%s %s", http.MethodPut, url)] = 1 108 | } 109 | if path.Delete != nil && !path.Delete.IsEmpty() { 110 | endpoints[fmt.Sprintf("%s %s", http.MethodDelete, url)] = 1 111 | } 112 | } 113 | return len(endpoints) 114 | } 115 | -------------------------------------------------------------------------------- /openapi3/openapi3html/openapi3html/page.qtpl: -------------------------------------------------------------------------------- 1 | {% func SpectrumUIPage(data PageParams) %} 2 | 3 | 4 | 5 | {%s data.PageTitle %} 6 | 7 | 8 | 9 | 10 | 11 |

{%s= data.PageLinkHTML() %}

12 | 13 |
14 | 15 | 110 | 111 | 112 | 113 | {% endfunc %} -------------------------------------------------------------------------------- /openapi3lint/rulepathparamstyle/rule_path_param_style.go: -------------------------------------------------------------------------------- 1 | package rulepathparamstyle 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | 8 | oas3 "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/grokify/mogo/encoding/jsonpointer" 10 | "github.com/grokify/mogo/text/stringcase" 11 | "github.com/grokify/spectrum/openapi3" 12 | "github.com/grokify/spectrum/openapi3lint/lintutil" 13 | ) 14 | 15 | type RulePathParamStyle struct { 16 | name string 17 | stringCase string 18 | } 19 | 20 | func NewRule(requiredStringCase string) (RulePathParamStyle, error) { 21 | canonicalCase, err := stringcase.Parse(requiredStringCase) 22 | if err != nil { 23 | return RulePathParamStyle{}, 24 | fmt.Errorf("invalid string case [%s]", requiredStringCase) 25 | } 26 | rule := RulePathParamStyle{ 27 | stringCase: canonicalCase} 28 | switch canonicalCase { 29 | case stringcase.CamelCase: 30 | rule.name = lintutil.RulenamePathParamStyleCamelCase 31 | case stringcase.KebabCase: 32 | rule.name = lintutil.RulenamePathParamStyleKebabCase 33 | case stringcase.PascalCase: 34 | rule.name = lintutil.RulenamePathParamStylePascalCase 35 | case stringcase.SnakeCase: 36 | rule.name = lintutil.RulenamePathParamStyleSnakeCase 37 | default: 38 | return rule, fmt.Errorf("invalid string case [%s]", canonicalCase) 39 | } 40 | return rule, nil 41 | } 42 | 43 | func (rule RulePathParamStyle) Name() string { 44 | return rule.name 45 | } 46 | 47 | func (rule RulePathParamStyle) Scope() string { 48 | return lintutil.ScopeOperation 49 | } 50 | 51 | func (rule RulePathParamStyle) ProcessOperation(spec *openapi3.Spec, op *oas3.Operation, opPointer, path, method string) []lintutil.PolicyViolation { 52 | return nil 53 | } 54 | 55 | var rxParams = regexp.MustCompile(`{([^/]*)}`) 56 | 57 | func (rule RulePathParamStyle) ProcessSpec(spec *openapi3.Spec, pointerBase string) []lintutil.PolicyViolation { 58 | vios := []lintutil.PolicyViolation{} 59 | 60 | pathsMap := spec.Paths.Map() 61 | for pathURL := range pathsMap { 62 | // for pathURL := range spec.Paths { // getkin v0.121.0 to v0.122.0 63 | m := rxParams.FindAllStringSubmatch(pathURL, -1) 64 | if len(m) == 0 { 65 | continue 66 | } 67 | jsPtr := jsonpointer.PointerSubEscapeAll("%s#/paths/%s", 68 | pointerBase, pathURL) 69 | for _, mi := range m { 70 | isWantCase, err := stringcase.IsCase(rule.stringCase, mi[1]) 71 | if err != nil { 72 | vios = append(vios, lintutil.PolicyViolation{ 73 | RuleName: rule.Name(), 74 | Location: jsPtr, 75 | Value: err.Error()}) 76 | } else if !isWantCase { 77 | vios = append(vios, lintutil.PolicyViolation{ 78 | RuleName: rule.Name(), 79 | Location: jsPtr}) 80 | } 81 | } 82 | } 83 | 84 | openapi3.VisitOperations(spec, func(path, method string, op *oas3.Operation) { 85 | if op == nil { 86 | return 87 | } 88 | jsPtr := jsonpointer.PointerSubEscapeAll( 89 | "%s#/paths/%s/%s/parameters/", 90 | pointerBase, 91 | path, 92 | method) 93 | for i, paramRef := range op.Parameters { 94 | if paramRef == nil || paramRef.Value == nil { 95 | continue 96 | } 97 | isWantCase, err := stringcase.IsCase(rule.stringCase, paramRef.Value.Name) 98 | if err != nil { 99 | // error should not occur as stringCase should be pre-validated. 100 | vios = append(vios, lintutil.PolicyViolation{ 101 | RuleName: rule.Name(), 102 | Location: jsPtr + strconv.Itoa(i), 103 | Value: paramRef.Value.Name + " " + err.Error()}) 104 | } else if !isWantCase { 105 | vios = append(vios, lintutil.PolicyViolation{ 106 | RuleName: rule.Name(), 107 | Location: jsPtr + strconv.Itoa(i), 108 | Value: paramRef.Value.Name}) 109 | } 110 | } 111 | }) 112 | 113 | return vios 114 | } 115 | -------------------------------------------------------------------------------- /openapi3/parameter.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | 8 | oas3 "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/grokify/mogo/encoding/jsonpointer" 10 | "github.com/grokify/mogo/type/maputil" 11 | "github.com/grokify/mogo/type/stringsutil" 12 | ) 13 | 14 | // ParameterNames covers path template parameters, operation parameter names/referecnes, compoents keys/names 15 | // Parameter path names only. This is useful for viewing and modifying parameter template naems. 16 | type ParameterNames struct { 17 | Components maputil.MapStringSlice 18 | Operations maputil.MapStringSlice 19 | Paths maputil.MapStringSlice 20 | } 21 | 22 | func (pn *ParameterNames) Names() []string { 23 | names := map[string]int{} 24 | for name := range pn.Components { 25 | names[name]++ 26 | } 27 | for name := range pn.Operations { 28 | names[name]++ 29 | } 30 | for name := range pn.Paths { 31 | names[name]++ 32 | } 33 | return maputil.StringKeys(names, nil) 34 | } 35 | 36 | func NewParameterNames() ParameterNames { 37 | return ParameterNames{ 38 | Components: maputil.MapStringSlice{}, 39 | Operations: maputil.MapStringSlice{}, 40 | Paths: maputil.MapStringSlice{}, 41 | } 42 | } 43 | 44 | // ParameterPathNames returns a set of parameter names. Parameter names exist in (1) path URLs, 45 | // (2) operation parameters and (3) spec component parameters. 46 | func (sm *SpecMore) ParameterPathNames() ParameterNames { 47 | if sm.Spec == nil { 48 | return NewParameterNames() 49 | } 50 | return ParameterNames{ 51 | Components: sm.ParamPathNamesComponents(), 52 | Operations: sm.ParamPathNamesOperations(), 53 | Paths: sm.ParamPathNamesPaths()} 54 | } 55 | 56 | func (sm *SpecMore) ParamPathNamesComponents() map[string][]string { 57 | names := url.Values{} 58 | if sm.Spec == nil { 59 | return names 60 | } 61 | for paramKey, paramRef := range sm.Spec.Components.Parameters { 62 | if paramRef.Value == nil { 63 | continue 64 | } 65 | if strings.ToLower(strings.TrimSpace(paramRef.Value.In)) != InPath { 66 | continue 67 | } 68 | if len(paramRef.Value.Name) > 0 { 69 | jpath := jsonpointer.PointerSubEscapeAll(`#/components/parameters/%s/name`, paramKey) 70 | names.Add(paramRef.Value.Name, jpath) 71 | } 72 | } 73 | return names 74 | } 75 | 76 | func (sm *SpecMore) ParamPathNamesOperations() map[string][]string { 77 | names := url.Values{} 78 | if sm.Spec == nil { 79 | return names 80 | } 81 | VisitOperations(sm.Spec, func(opPath, opMethod string, op *oas3.Operation) { 82 | if op == nil { 83 | return 84 | } 85 | for i, paramRef := range op.Parameters { 86 | if paramRef.Value == nil { 87 | continue 88 | } 89 | if strings.ToLower(strings.TrimSpace(paramRef.Value.In)) != InPath { 90 | continue 91 | } 92 | jpath := jsonpointer.PointerSubEscapeAll(`#/paths/%s/%s/parameters/%d/name`, opPath, opMethod, i) 93 | names.Add(paramRef.Value.Name, jpath) 94 | } 95 | }) 96 | return names 97 | } 98 | 99 | func (sm *SpecMore) ParamPathNamesPaths() map[string][]string { 100 | names := url.Values{} 101 | if sm.Spec == nil { 102 | return names 103 | } 104 | pathsMap := sm.Spec.Paths.Map() 105 | for pathURL := range pathsMap { 106 | // for pathURL := range sm.Spec.Paths { // getkin v0.121.0 to v0.122.0 107 | m := PathParams(pathURL) 108 | if len(m) > 0 { 109 | jpath := jsonpointer.PointerSubEscapeAll(`#/paths/%s`, pathURL) 110 | for _, paramName := range m { 111 | names.Add(paramName, jpath) 112 | } 113 | } 114 | } 115 | return names 116 | } 117 | 118 | var rxParens = regexp.MustCompile(`{([^}{}]+)}`) 119 | 120 | func ParsePathParametersParens(urlPath string) []string { 121 | paramNames := []string{} 122 | m := rxParens.FindAllStringSubmatch(urlPath, -1) 123 | if len(m) == 0 { 124 | return paramNames 125 | } 126 | for _, n := range m { 127 | if len(n) == 2 { 128 | varName := strings.TrimSpace(n[1]) 129 | paramNames = append(paramNames, varName) 130 | } 131 | } 132 | if len(paramNames) > 0 { 133 | paramNames = stringsutil.SliceCondenseSpace(paramNames, true, false) 134 | } 135 | return paramNames 136 | } 137 | -------------------------------------------------------------------------------- /openapi3/read.go: -------------------------------------------------------------------------------- 1 | package openapi3 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "os" 8 | "regexp" 9 | "strings" 10 | 11 | oas3 "github.com/getkin/kin-openapi/openapi3" 12 | "github.com/grokify/mogo/encoding/jsonutil" 13 | "github.com/grokify/mogo/errors/errorsutil" 14 | "sigs.k8s.io/yaml" 15 | ) 16 | 17 | var rxYamlExtension = regexp.MustCompile(`(?i)\.ya?ml\s*$`) 18 | 19 | func ReadURL(oas3url string) (*Spec, error) { 20 | resp, err := http.Get(oas3url) // #nosec G107 21 | if err != nil { 22 | return nil, err 23 | } 24 | bytes, err := io.ReadAll(resp.Body) 25 | if err != nil { 26 | return nil, err 27 | } 28 | return Parse(bytes) 29 | } 30 | 31 | // ReadFile does optional validation which is useful when 32 | // merging incomplete spec files. 33 | func ReadFile(oas3file string, validate bool) (*Spec, error) { 34 | if validate { 35 | return readAndValidateFile(oas3file) 36 | } 37 | bytes, err := os.ReadFile(oas3file) 38 | if err != nil { 39 | return nil, errorsutil.Wrapf(err, "ReadFile.ReadFile.Error.Filename file: (%v)", oas3file) 40 | } 41 | if rxYamlExtension.MatchString(oas3file) { 42 | bytes, err = yaml.YAMLToJSON(bytes) 43 | if err != nil { 44 | return nil, err 45 | } 46 | } 47 | spec := &Spec{} 48 | err = spec.UnmarshalJSON(bytes) 49 | if err != nil { 50 | return nil, errorsutil.Wrapf(err, "error ReadFile.UnmarshalJSON.Error.Filename file: (%s) ", oas3file) 51 | } 52 | return spec, nil 53 | } 54 | 55 | func readAndValidateFile(oas3file string) (*Spec, error) { 56 | data, err := os.ReadFile(oas3file) 57 | if err != nil { 58 | return nil, errorsutil.Wrap(err, "E_READ_FILE_ERROR") 59 | } 60 | return readAndValidateBytes(data) 61 | } 62 | 63 | func readAndValidateBytes(b []byte) (*Spec, error) { 64 | spec, err := oas3.NewLoader().LoadFromData(b) 65 | if err != nil { 66 | return spec, errorsutil.Wrap(err, "error `oas3.NewLoader().LoadFromData(bytes)`") 67 | } 68 | _, err = validateMore(spec) 69 | return spec, err 70 | } 71 | 72 | // Parse will parse a byte array to an `*oas3.Swagger` struct. 73 | // It will use JSON first. If unsuccessful, it will attempt to 74 | // parse it as YAML. 75 | func Parse(oas3Bytes []byte) (*Spec, error) { 76 | spec := &Spec{} 77 | err := spec.UnmarshalJSON(oas3Bytes) 78 | if err != nil { 79 | bytes, err2 := yaml.YAMLToJSON(oas3Bytes) 80 | if err2 != nil { 81 | return spec, err 82 | } 83 | spec = &Spec{} 84 | err3 := spec.UnmarshalJSON(bytes) 85 | return spec, err3 86 | } 87 | return spec, err 88 | } 89 | 90 | type ValidationStatus struct { 91 | Status bool 92 | Message string 93 | Context string 94 | OpenAPI string 95 | } 96 | 97 | func validateMore(spec *Spec) (ValidationStatus, error) { 98 | vs := ValidationStatus{} 99 | if spec == nil { 100 | return vs, ErrSpecNotSet 101 | } 102 | version := "" 103 | if spec.Info == nil { 104 | vs := ValidationStatus{ 105 | Context: "#/info", 106 | Message: "expect spec to have info object)", 107 | OpenAPI: spec.OpenAPI} 108 | return vs, fmt.Errorf("E_OPENAPI3_MISSING_KEY [%s]", "info") 109 | } else { 110 | version = strings.TrimSpace(spec.Info.Version) 111 | if len(strings.TrimSpace(version)) == 0 { 112 | version = OASVersionDefault 113 | } 114 | vs.OpenAPI = version 115 | 116 | if len(version) == 0 { 117 | jdata, err := jsonutil.MarshalSimple(spec.Info, "", " ") 118 | if err != nil { 119 | return vs, err 120 | } 121 | vs := ValidationStatus{ 122 | Context: "#/info", 123 | Message: fmt.Sprintf("expect Object %s to have key version\nmissing keys:version", string(jdata)), 124 | OpenAPI: spec.OpenAPI} 125 | return vs, fmt.Errorf("E_OPENAPI3_MISSING_KEY [%s]", "info/version") 126 | } 127 | vs.Status = true 128 | } 129 | return vs, nil 130 | } 131 | 132 | func (sm *SpecMore) ValidateMore() (ValidationStatus, error) { 133 | if sm.Spec == nil { 134 | return ValidationStatus{}, ErrSpecNotSet 135 | } 136 | return validateMore(sm.Spec) 137 | } 138 | 139 | func (sm *SpecMore) Validate() error { 140 | jbytes, err := sm.MarshalJSON("", "") 141 | if err != nil { 142 | return err 143 | } 144 | _, err = readAndValidateBytes(jbytes) 145 | return err 146 | } 147 | -------------------------------------------------------------------------------- /postman2/simple/postman_collection_v2_simple.go: -------------------------------------------------------------------------------- 1 | package simple 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "strings" 7 | 8 | "github.com/grokify/mogo/errors/errorsutil" 9 | "github.com/grokify/mogo/net/http/httputilmore" 10 | "github.com/grokify/spectrum/postman2" 11 | ) 12 | 13 | type Collection struct { 14 | Info postman2.CollectionInfo `json:"info"` 15 | Item []*Item `json:"item"` 16 | } 17 | 18 | func NewCollectionFromBytes(data []byte) (Collection, error) { 19 | pman := Collection{} 20 | err := json.Unmarshal(data, &pman) 21 | if err != nil { 22 | err = errorsutil.Wrap(err, "spectrum.postman2.simple.NewCollectionFromBytes << json.Unmarshal") 23 | } 24 | return pman, err 25 | } 26 | 27 | func NewCanonicalCollectionFromBytes(data []byte) (postman2.Collection, error) { 28 | collection, errTry := postman2.NewCollectionFromBytes(data) 29 | if errTry == nil { 30 | collection.Inflate() 31 | return collection, nil 32 | } 33 | simpleCollection, err := NewCollectionFromBytes(data) 34 | if err != nil { 35 | err = errorsutil.Wrap(errTry, err.Error()) 36 | err = errorsutil.Wrap(err, "spectrum.postman2.simple.NewCanonicalCollectionFromBytes << NewCollectionFromBytes") 37 | return collection, err 38 | } 39 | collection = simpleCollection.ToCanonical() 40 | collection.Inflate() 41 | return collection, nil 42 | } 43 | 44 | func ReadCanonicalCollection(filepath string) (postman2.Collection, error) { 45 | bytes, err := os.ReadFile(filepath) 46 | if err != nil { 47 | err = errorsutil.Wrap(err, "spectrum.postman2.ReadCanonicalCollection << os.ReadFile") 48 | return postman2.Collection{}, err 49 | } 50 | return NewCanonicalCollectionFromBytes(bytes) 51 | } 52 | 53 | func (col *Collection) ToCanonical() postman2.Collection { 54 | cCollection := postman2.Collection{ 55 | Info: col.Info, 56 | Item: []*postman2.Item{}} 57 | for _, folder := range col.Item { 58 | cCollection.Item = append(cCollection.Item, folder.ToCanonical()) 59 | } 60 | return cCollection 61 | } 62 | 63 | type Item struct { 64 | Name string `json:"name,omitempty"` // Folder,API 65 | Description string `json:"description,omitempty"` // Folder 66 | Item []*Item `json:"item,omitempty"` // Folder 67 | Event []postman2.Event `json:"event,omitempty"` // API 68 | Request Request `json:"request,omitempty"` // API 69 | } 70 | 71 | func (thisItem *Item) ToCanonical() *postman2.Item { 72 | canRequest := thisItem.Request.ToCanonical() 73 | canItem := &postman2.Item{ 74 | Name: thisItem.Name, 75 | Item: []*postman2.Item{}, 76 | Event: thisItem.Event, 77 | Request: &canRequest} 78 | thisItem.Description = strings.TrimSpace(thisItem.Description) 79 | if len(thisItem.Description) > 0 { 80 | canItem.Description = &postman2.Description{ 81 | Content: thisItem.Description, 82 | Type: httputilmore.ContentTypeTextMarkdown} 83 | } 84 | for _, subItem := range thisItem.Item { 85 | canItem.Item = append(canItem.Item, subItem.ToCanonical()) 86 | } 87 | return canItem 88 | } 89 | 90 | type APIItem struct { 91 | Name string `json:"name,omitempty"` 92 | Event []postman2.Event `json:"event,omitempty"` 93 | Request Request `json:"request,omitempty"` 94 | } 95 | 96 | func (apiItem *APIItem) ToCanonical() postman2.Item { 97 | canReq := apiItem.Request.ToCanonical() 98 | return postman2.Item{ 99 | Name: apiItem.Name, 100 | Event: apiItem.Event, 101 | Request: &canReq} 102 | } 103 | 104 | type Request struct { 105 | URL string `json:"url,omitempty"` 106 | Method string `json:"method,omitempty"` 107 | Header []postman2.Header `json:"header,omitempty"` 108 | Body postman2.RequestBody `json:"body,omitempty"` 109 | Description string `json:"description,omitempty"` 110 | } 111 | 112 | func (req *Request) ToCanonical() postman2.Request { 113 | pmURL := postman2.NewURL(req.URL) 114 | return postman2.Request{ 115 | URL: &pmURL, 116 | Method: req.Method, 117 | Header: req.Header, 118 | Body: &req.Body, 119 | Description: req.Description} 120 | } 121 | -------------------------------------------------------------------------------- /examples/ringcentral/ringcentral.postman2.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "variables": [], 3 | "info": { 4 | "name": "RingCentral REST API", 5 | "description": "This is a collection of API requests associated with the Basic usage plan in RingCentral Connect Platform for Sandbox Testing.\n\nPlease use a Postman environment with the following variables: RC_SERVER_HOSTNAME, RC_APP_KEY, RC_APP_SECRET, RC_USERNAME, RC_EXTENSION, RC_PASSWORD\n\nTo use these resources, you must ALWAYS start by executing one of the OAuth \"Auth\" Requests first, then you can use any other requests (for the 60 minutes that the access_token you obtained is valid).", 6 | "schema": "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" 7 | }, 8 | "item": [ 9 | { 10 | "name": "Auth", 11 | "description": "YOU MUST UPDATE VALUES IN THE \"Pre-request Script\" section of ALL items in this folder to match your RingCentral Sandbox Account.", 12 | "item": [ 13 | { 14 | "name": "Fetch access_token", 15 | "event": [ 16 | { 17 | "listen": "test", 18 | "script": { 19 | "type": "text/javascript", 20 | "exec": [ 21 | "var jsonData = JSON.parse(responseBody);", 22 | "tests[\"Contains an access_token\"] = jsonData.hasOwnProperty('access_token');", 23 | "", 24 | "tests[\"Status code is 200\"] = responseCode.code === 200;", 25 | "", 26 | "postman.setEnvironmentVariable(\"my_access_token\", jsonData.access_token);" 27 | ] 28 | } 29 | }, 30 | { 31 | "listen": "prerequest", 32 | "script": { 33 | "type": "text/javascript", 34 | "exec": [ 35 | "// Populate the Postman environment with:", 36 | "// RINGCENTRAL_SERVER_URL, RINGCENTRAL_CLIENT_ID, RINGCENTRAL_CLIENT_SECRET,", 37 | "// RINGCENTRAL_USERNAME, RINGCENTRAL_EXTENSION, RINGCENTRAL_PASSWORD", 38 | "var appKey = environment[\"RINGCENTRAL_CLIENT_ID\"];", 39 | "var appSecret = environment[\"RINGCENTRAL_CLIENT_SECRET\"];", 40 | "var apiKey = encodeBasicAuthHeader(appKey, appSecret);", 41 | "", 42 | "postman.setEnvironmentVariable(\"basic_auth_header\", \"Basic \".concat(apiKey));", 43 | "", 44 | "function encodeBasicAuthHeader(appKey, appSecret) {", 45 | " var apiKey = appKey + ':' + appSecret;", 46 | " return btoa(apiKey);", 47 | "}" 48 | ] 49 | } 50 | } 51 | ], 52 | "request": { 53 | "url": { 54 | "raw": "{{RINGCENTRAL_SERVER_URL}}/restapi/oauth/token", 55 | "auth": null, 56 | "host": [ 57 | "{{RINGCENTRAL_SERVER_URL}}" 58 | ], 59 | "path": [ 60 | "restapi", 61 | "oauth", 62 | "token" 63 | ] 64 | }, 65 | "method": "POST", 66 | "header": [ 67 | { 68 | "key": "Authorization", 69 | "value": "{{basic_auth_header}}", 70 | "description": "" 71 | }, 72 | { 73 | "key": "Accept", 74 | "value": "application/json", 75 | "description": "" 76 | }, 77 | { 78 | "key": "Content-Type", 79 | "value": "application/x-www-form-urlencoded;charset=UTF-8", 80 | "description": "" 81 | } 82 | ], 83 | "body": { 84 | "mode": "urlencoded", 85 | "urlencoded": [ 86 | { 87 | "key": "grant_type", 88 | "value": "password", 89 | "type": "text", 90 | "enabled": true 91 | }, 92 | { 93 | "key": "username", 94 | "value": "{{RINGCENTRAL_USERNAME}}", 95 | "type": "text", 96 | "enabled": true 97 | }, 98 | { 99 | "key": "password", 100 | "value": "{{RINGCENTRAL_PASSWORD}}", 101 | "type": "text", 102 | "enabled": true 103 | }, 104 | { 105 | "key": "extension", 106 | "value": "{{RINGCENTRAL_EXTENSION}}", 107 | "type": "text", 108 | "enabled": true 109 | } 110 | ] 111 | }, 112 | "description": "Fetch access_token from DevTest environment as Admin" 113 | }, 114 | "response": [] 115 | } 116 | ] 117 | } 118 | ] 119 | } -------------------------------------------------------------------------------- /postman2/collection.go: -------------------------------------------------------------------------------- 1 | package postman2 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | "strings" 7 | 8 | "github.com/grokify/mogo/errors/errorsutil" 9 | "github.com/grokify/mogo/net/http/httputilmore" 10 | ) 11 | 12 | const ( 13 | SchemaURL210 = "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 14 | SchemaURL200 = "https://schema.getpostman.com/json/collection/v2.0.0/collection.json" 15 | ) 16 | 17 | type Collection struct { 18 | Info CollectionInfo `json:"info"` 19 | Item []*Item `json:"item"` 20 | Event []Event `json:"event,omitempty"` 21 | } 22 | 23 | func ReadFile(filename string) (Collection, error) { 24 | b, err := os.ReadFile(filename) 25 | if err != nil { 26 | return Collection{}, err 27 | } 28 | return NewCollectionFromBytes(b) 29 | } 30 | 31 | func NewCollectionFromBytes(data []byte) (Collection, error) { 32 | col := Collection{} 33 | err := json.Unmarshal(data, &col) 34 | if err != nil { 35 | err = errorsutil.Wrap(err, "spectrum.postman2.NewCollectionFromBytes << json.Unmarshal") 36 | return col, err 37 | } 38 | col.Inflate() 39 | return col, nil 40 | } 41 | 42 | func (col *Collection) GetOrNewFolder(folderName string) *Item { 43 | for _, folder := range col.Item { 44 | if folder.Name == folderName { 45 | return folder 46 | } 47 | } 48 | folder := &Item{Name: folderName, Item: []*Item{}} 49 | col.Item = append(col.Item, folder) 50 | return folder 51 | } 52 | 53 | func (col *Collection) SetFolder(newFolder *Item) { 54 | if newFolder == nil || len(strings.TrimSpace(newFolder.Name)) == 0 { 55 | return 56 | } 57 | for i, folder := range col.Item { 58 | if newFolder.Name == folder.Name { 59 | col.Item[i] = newFolder 60 | return 61 | } 62 | } 63 | col.Item = append(col.Item, newFolder) 64 | } 65 | 66 | func (col *Collection) Inflate() { 67 | col.Info.Schema = strings.TrimSpace(col.Info.Schema) 68 | if len(col.Info.Schema) == 0 { 69 | col.Info.Schema = SchemaURL210 70 | } 71 | col.InflateRawURLs() 72 | } 73 | 74 | func (col *Collection) InflateRawURLs() { 75 | for _, folder := range col.Item { 76 | for j, api := range folder.Item { 77 | if api.Request.URL.IsRawOnly() && 78 | len(strings.TrimSpace(api.Request.URL.Raw)) > 0 { 79 | url := NewURL(strings.TrimSpace(api.Request.URL.Raw)) 80 | url.Auth = api.Request.URL.Auth 81 | url.Variable = api.Request.URL.Variable 82 | folder.Item[j].Request.URL = &url 83 | } 84 | } 85 | } 86 | } 87 | 88 | type CollectionInfo struct { 89 | Name string `json:"name,omitempty"` 90 | PostmanID string `json:"_postman_id,omitempty"` 91 | Description string `json:"description,omitempty"` 92 | Schema string `json:"schema,omitempty"` 93 | } 94 | 95 | // Item can represent a folder or an API 96 | type Item struct { 97 | Name string `json:"name,omitempty"` // Folder,Operation 98 | Description *Description `json:"description,omitempty"` // Folder 99 | Item []*Item `json:"item,omitempty"` // Folder 100 | IsSubFolder bool `json:"_postman_isSubFolder,omitempty"` // Folder 101 | Event []Event `json:"event,omitempty"` // Operation 102 | Request *Request `json:"request,omitempty"` // Operation 103 | } 104 | 105 | func (item *Item) UpsertSubItem(newItem *Item) { 106 | if newItem == nil || len(strings.TrimSpace(newItem.Name)) == 0 { 107 | return 108 | } 109 | for i, itemTry := range item.Item { 110 | if itemTry.Name == newItem.Name { 111 | item.Item[i] = newItem 112 | return 113 | } 114 | } 115 | item.Item = append(item.Item, newItem) 116 | } 117 | 118 | type Description struct { 119 | Content string `json:"content,omitempty"` 120 | Type string `json:"type,omitempty"` 121 | Version string `json:"version,omitempty"` 122 | } 123 | 124 | func (desc *Description) Inflate() { 125 | desc.Content = strings.TrimSpace(desc.Content) 126 | desc.Type = strings.TrimSpace(desc.Type) 127 | if len(desc.Content) > 0 && len(desc.Type) == 0 { 128 | desc.Type = httputilmore.ContentTypeTextPlain 129 | } 130 | } 131 | 132 | type Event struct { 133 | Listen string `json:"listen"` 134 | Script Script `json:"script"` 135 | } 136 | 137 | type Script struct { 138 | Type string `json:"type,omitempty"` 139 | Exec []string `json:"exec,omitempty"` 140 | } 141 | -------------------------------------------------------------------------------- /raml08/raml08openapi3/operations.go: -------------------------------------------------------------------------------- 1 | package raml08openapi3 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | 10 | oas3 "github.com/getkin/kin-openapi/openapi3" 11 | "github.com/grokify/mogo/net/http/httputilmore" 12 | "github.com/grokify/mogo/net/urlutil" 13 | "github.com/grokify/spectrum/openapi3" 14 | ) 15 | 16 | // ReadFileOperations reads a RAML v0.8 file and returns a set of `openapi3edit.OperationMore` structs. 17 | // The properties `path`, `method`, `summary`, `description` are populated. OpenAPI `summary` is populated 18 | // by the `displayName` property. Currently, this reads a JSON formatted file into a map[string]interface. 19 | // This is useful after converting a RAML v0.8 spec using https://github.com/daviemakz/oas-raml-converter-cli. 20 | func ReadFileOperations(filename string) (*openapi3.OperationMores, error) { 21 | bytes, err := os.ReadFile(filename) 22 | if err != nil { 23 | return nil, err 24 | } 25 | msa := map[string]any{} 26 | err = json.Unmarshal(bytes, &msa) 27 | if err != nil { 28 | return nil, err 29 | } 30 | // omSet := &openapi3.OperationMoreSet{ 31 | // OperationMores: []openapi3.OperationMore{}} 32 | oms := &openapi3.OperationMores{} 33 | err = msaPaths("", msa, oms) 34 | return oms, err 35 | } 36 | 37 | const ( 38 | RAMLKeyDescription = "description" 39 | RAMLKeyDisplayName = "displayName" 40 | ) 41 | 42 | var ( 43 | ErrOperationMoreSetMissing = errors.New("required parameter operationSetMore is empty") 44 | ErrRAMLDescriptionNotString = errors.New("format for RAML description is not string") 45 | ErrRAMLDispNameNotString = errors.New("format for RAML description is not string") 46 | ) 47 | 48 | // msaPaths is a recursive function. Use "" for the basePath for RAML root. The structure of RAML 49 | // appears to be that properties for metadata and sub-paths are co-mingled when sub-paths starting 50 | // with a slash `/`. This method walks the operation segements and collects the following information 51 | // using the `openapi3edit.OperationMore` struct: `path`, `method`, `summary`, `description`. OpenAPI 52 | // `summary` is populated by the `displayName` property. 53 | func msaPaths(basePath string, msa map[string]any, oms *openapi3.OperationMores) error { 54 | if len(msa) == 0 { 55 | return nil 56 | } else if oms == nil { 57 | return ErrOperationMoreSetMissing 58 | } 59 | basePath = strings.TrimSpace(basePath) 60 | if len(basePath) > 0 { 61 | // only do if not at root. 62 | pathOms, err := operationMoresFromPathItem(basePath, msa) 63 | if err != nil { 64 | return err 65 | } else if len(pathOms) > 0 { 66 | *oms = append(*oms, pathOms...) 67 | } 68 | } 69 | 70 | for k, msaAny := range msa { 71 | k = strings.TrimSpace(k) 72 | if len(k) == 0 || strings.Index(k, "/") != 0 { 73 | continue 74 | } 75 | childMSA, ok := msaAny.(map[string]any) 76 | if !ok { 77 | return fmt.Errorf("value is not map[string]any for key [%s]", k) 78 | } 79 | childAbsPath := urlutil.JoinAbsolute(basePath, k) 80 | err := msaPaths(childAbsPath, childMSA, oms) 81 | if err != nil { 82 | return err 83 | } 84 | } 85 | return nil 86 | } 87 | 88 | func operationMoresFromPathItem(opPath string, opPathItem map[string]any) ([]openapi3.OperationMore, error) { 89 | oms := []openapi3.OperationMore{} 90 | for k, valAny := range opPathItem { 91 | // check if current `opPathItem`` property is an HTTP method, and add operations if so. 92 | methodCanonical, err := httputilmore.ParseHTTPMethod(k) 93 | if err != nil { // err means not known HTTP Method 94 | continue 95 | } 96 | om := openapi3.OperationMore{ 97 | Path: opPath, 98 | Method: string(methodCanonical), 99 | Operation: &oas3.Operation{}} 100 | opMSA := valAny.(map[string]any) 101 | if descAny, ok := opMSA[RAMLKeyDescription]; ok { 102 | if descStr, ok := descAny.(string); ok { 103 | om.Operation.Description = strings.TrimSpace(descStr) 104 | } else { 105 | return oms, ErrRAMLDescriptionNotString 106 | } 107 | } 108 | if dispNameAny, ok := opMSA[RAMLKeyDisplayName]; ok { 109 | if dispNameStr, ok := dispNameAny.(string); ok { 110 | om.Operation.Summary = strings.TrimSpace(dispNameStr) 111 | } else { 112 | return oms, ErrRAMLDispNameNotString 113 | } 114 | } 115 | oms = append(oms, om) 116 | } 117 | return oms, nil 118 | } 119 | -------------------------------------------------------------------------------- /openapi3/openapi3html/apiregistry/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "strings" 8 | 9 | "github.com/buaazp/fasthttprouter" 10 | "github.com/grokify/mogo/log/logutil" 11 | "github.com/grokify/mogo/net/http/httputilmore" 12 | "github.com/grokify/mogo/strconv/strconvutil" 13 | "github.com/grokify/mogo/type/stringsutil" 14 | "github.com/grokify/sogo/net/http/anyhttp" 15 | "github.com/grokify/sogo/net/http/httpsimple" 16 | "github.com/grokify/spectrum/openapi3" 17 | "github.com/grokify/spectrum/openapi3/openapi3html" 18 | "github.com/rs/zerolog" 19 | "github.com/rs/zerolog/log" 20 | "github.com/valyala/fasthttp" 21 | ) 22 | 23 | type Server struct { 24 | Port int 25 | Engine string 26 | } 27 | 28 | func NewServer() Server { 29 | return Server{ 30 | Port: strconvutil.AtoiOrDefault(os.Getenv("PORT"), 8080), 31 | Engine: stringsutil.FirstNonEmpty(os.Getenv("ENGINE"), "nethttp")} 32 | } 33 | 34 | func (svr *Server) HandleAPIRegistryNetHTTP(res http.ResponseWriter, req *http.Request) { 35 | log.Debug().Msg("FUNC_HandleNetHTTP__BEGIN") 36 | svr.HandleAPIRegistryAnyEngine(anyhttp.NewResReqNetHTTP(res, req)) 37 | } 38 | 39 | func (svr *Server) HandleAPIRegistryFastHTTP(ctx *fasthttp.RequestCtx) { 40 | log.Debug().Msg("HANDLE_FastHTTP") 41 | svr.HandleAPIRegistryAnyEngine(anyhttp.NewResReqFastHTTP(ctx)) 42 | } 43 | 44 | func (svr *Server) HandleAPIRegistryAnyEngine(aRes anyhttp.Response, aReq anyhttp.Request) { 45 | log.Debug().Msg("FUNC_HandleAnyEngine__BEGIN") 46 | aRes.SetContentType(httputilmore.ContentTypeTextHTMLUtf8) 47 | err := aReq.ParseForm() 48 | if err != nil { 49 | logutil.PrintErr(SetResponseError(aRes, err.Error())) 50 | return 51 | } 52 | 53 | specURL := strings.TrimSpace(aReq.QueryArgs().GetString("url")) 54 | log.Debug(). 55 | Str("url", specURL). 56 | Msg("SpecURL") 57 | 58 | if len(specURL) == 0 { 59 | logutil.PrintErr(SetResponseError(aRes, "No OpenAPI 3.0 Spec URL")) 60 | return 61 | } 62 | resp, err := http.Get(specURL) // #nosec G107 63 | if err != nil || resp.StatusCode > 299 { 64 | logutil.PrintErr(SetResponseError(aRes, err.Error())) 65 | return 66 | } 67 | 68 | bytes, err := io.ReadAll(resp.Body) 69 | if err != nil { 70 | logutil.PrintErr(SetResponseError(aRes, err.Error())) 71 | return 72 | } 73 | 74 | spec, err := openapi3.Parse(bytes) 75 | if err != nil { 76 | logutil.PrintErr(SetResponseError(aRes, err.Error())) 77 | return 78 | } 79 | 80 | oas3HTMLParams := openapi3html.PageParams{ 81 | PageTitle: spec.Info.Title, 82 | TableDomID: "specTable", 83 | Spec: spec} 84 | 85 | oas3PageHTML := openapi3html.SpectrumUIPage(oas3HTMLParams) 86 | 87 | aRes.SetHeader(httputilmore.HeaderContentType, httputilmore.ContentTypeTextHTMLUtf8) 88 | _, err = aRes.SetBodyBytes([]byte(oas3PageHTML)) 89 | logutil.PrintErr(err) 90 | } 91 | 92 | func SetResponseError(aRes anyhttp.Response, bodyText string) error { 93 | bodyText = ` 94 | 95 | 96 |

` + bodyText + `

` 97 | 98 | _, err := aRes.SetBodyBytes([]byte(bodyText)) 99 | return err 100 | } 101 | 102 | /* 103 | const ErrorPage = ` 104 | 105 |

Error

106 | ` 107 | */ 108 | 109 | func (svr Server) PortInt() int { return svr.Port } 110 | func (svr Server) HTTPEngine() string { return svr.Engine } 111 | func (svr Server) RouterFast() *fasthttprouter.Router { return nil } 112 | 113 | func (svr Server) Router() http.Handler { 114 | /* 115 | mux := mux.NewRouter() 116 | mux.HandleFunc("/ping", http.HandlerFunc(httpsimple.HandleTestNetHTTP)) 117 | mux.HandleFunc("/ping/", http.HandlerFunc(httpsimple.HandleTestNetHTTP)) 118 | mux.HandleFunc("/", http.HandlerFunc(svr.HandleAPIRegistryNetHTTP)) 119 | return mux 120 | */ 121 | mux := http.NewServeMux() 122 | mux.HandleFunc("/", http.HandlerFunc(svr.HandleAPIRegistryNetHTTP)) 123 | mux.HandleFunc("/ping", http.HandlerFunc(httpsimple.HandleTestNetHTTP)) 124 | mux.HandleFunc("/ping/", http.HandlerFunc(httpsimple.HandleTestNetHTTP)) 125 | return mux 126 | } 127 | 128 | func main() { 129 | zerolog.SetGlobalLevel(zerolog.DebugLevel) 130 | 131 | svr := NewServer() 132 | 133 | done := make(chan bool) 134 | go httpsimple.Serve(svr) 135 | log.Info().Int("port", svr.Port).Msg("Server listening") 136 | <-done 137 | } 138 | -------------------------------------------------------------------------------- /postman2/url.go: -------------------------------------------------------------------------------- 1 | package postman2 2 | 3 | import ( 4 | "net/url" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/grokify/mogo/net/urlutil" 9 | ) 10 | 11 | // URL is the Postman URL used in the Postman 2.0 Collection Spec. 12 | type URL struct { 13 | Raw string `json:"raw,omitempty"` 14 | Protocol string `json:"protocol,omitempty"` 15 | Auth map[string]string `json:"auth,omitempty"` 16 | Host []string `json:"host,omitempty"` 17 | Path []string `json:"path,omitempty"` 18 | Query []URLQuery `json:"query,omitempty"` 19 | Variable []URLVariable `json:"variable,omitempty"` 20 | } 21 | 22 | // URLParameters is a temp struct to hold parsed parameters. 23 | type URLParameters struct { 24 | Query []URLQuery `json:"query,omitempty"` 25 | Variable []URLVariable `json:"variable,omitempty"` 26 | } 27 | 28 | // NewURLParameters returns an initialized empty struct. 29 | func NewURLParameters() URLParameters { 30 | return URLParameters{ 31 | Query: []URLQuery{}, 32 | Variable: []URLVariable{}, 33 | } 34 | } 35 | 36 | func (pmURL *URL) IsRawOnly() bool { 37 | pmURL.Protocol = strings.TrimSpace(pmURL.Protocol) 38 | if len(pmURL.Protocol) > 0 || 39 | len(pmURL.Host) > 0 || 40 | len(pmURL.Path) > 0 { 41 | return false 42 | } 43 | return true 44 | } 45 | 46 | type URLQuery struct { 47 | Key string `json:"key,omitempty"` 48 | Value string `json:"value,omitempty"` 49 | Description string `json:"description,omitempty"` 50 | Disabled bool `json:"disabled,omitempty"` 51 | } 52 | 53 | type URLVariable struct { 54 | Key string `json:"key,omitempty"` 55 | Value interface{} `json:"value,omitempty"` 56 | Description URLVariableDescription `json:"description,omitempty"` 57 | Disabled bool `json:"disabled,omitempty"` 58 | ID string `json:"id,omitempty"` // Old, pre 2.0.1 59 | } 60 | 61 | type URLVariableDescription struct { 62 | Content string `json:"content,omitempty"` 63 | Type string `json:"type,omitempty"` 64 | } 65 | 66 | func NewURLForGoURL(goURL url.URL) URL { 67 | pmURL := URL{Variable: []URLVariable{}} 68 | goURL.Scheme = strings.TrimSpace(goURL.Scheme) 69 | goURL.Host = strings.TrimSpace(goURL.Host) 70 | goURL.Path = strings.TrimSpace(goURL.Path) 71 | urlParts := []string{} 72 | if len(goURL.Host) > 0 { 73 | pmURL.Host = strings.Split(goURL.Host, ".") 74 | urlParts = append(urlParts, goURL.Host) 75 | } 76 | if len(goURL.Path) > 0 { 77 | pmURL.Path = strings.Split(goURL.Path, "/") 78 | urlParts = append(urlParts, goURL.Path) 79 | } 80 | rawURL := strings.Join(urlParts, "/") 81 | if len(goURL.Scheme) > 0 { 82 | pmURL.Protocol = goURL.Scheme 83 | rawURL = goURL.Scheme + "://" + rawURL 84 | } 85 | pmURL.Raw = rawURL 86 | return pmURL 87 | } 88 | 89 | var simpleURLRx = regexp.MustCompile(`^([a-z][0-9a-z]+)://([^/]+)/(.*)$`) 90 | 91 | func NewURL(rawURL string) URL { 92 | rawURL = strings.TrimSpace(rawURL) 93 | pmURL := URL{Raw: rawURL, Variable: []URLVariable{}} 94 | rs1 := simpleURLRx.FindAllStringSubmatch(rawURL, -1) 95 | 96 | if len(rs1) > 0 { 97 | for _, m := range rs1 { 98 | pmURL.Protocol = m[1] 99 | hostname := m[2] 100 | path := m[3] 101 | pmURL.Host = strings.Split(hostname, ".") 102 | pmURL.Path = urlutil.SplitPath(path, true, true) 103 | } 104 | } else if strings.Index(rawURL, "{") == 0 { 105 | parts := urlutil.SplitPath(rawURL, true, true) 106 | if len(parts) > 0 { 107 | pmURL.Host = []string{parts[0]} 108 | } 109 | if len(parts) > 1 { 110 | pmURL.Path = parts[1:] 111 | } 112 | } 113 | 114 | return pmURL 115 | } 116 | 117 | // AddVariable adds a Postman Variable to the struct. 118 | func (pmURL *URL) AddVariable(key string, value interface{}) { 119 | variable := URLVariable{ID: key, Value: value} 120 | pmURL.Variable = append(pmURL.Variable, variable) 121 | } 122 | 123 | const ( 124 | apiURLOasToPostmanVarMatch string = `(^|[^\{])\{([^\/\{\}]+)\}([^\}]|$)` 125 | apiURLOasToPostmanVarReplace string = "$1:$2$3" 126 | ) 127 | 128 | var apiURLOasToPostmanVarMatchRx = regexp.MustCompile( 129 | apiURLOasToPostmanVarMatch) 130 | 131 | func APIURLOasToPostman(urlWithOasVars string) string { 132 | return apiURLOasToPostmanVarMatchRx.ReplaceAllString( 133 | urlWithOasVars, apiURLOasToPostmanVarReplace) 134 | } 135 | -------------------------------------------------------------------------------- /ext/taggroups/taggroups.go: -------------------------------------------------------------------------------- 1 | package taggroups 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | 9 | "github.com/grokify/mogo/type/stringsutil" 10 | "github.com/grokify/spectrum/openapi3" 11 | "github.com/grokify/spectrum/openapi3edit" 12 | ) 13 | 14 | const ( 15 | XTagGroupsPropertyNameRedocly = "x-tagGroups" // Redocly format 16 | XTagGroupsPropertyNameKebab = "x-tag-groups" // Alternate format 17 | XTagGroupsPropertyName = XTagGroupsPropertyNameRedocly 18 | ) 19 | 20 | type TagGroupSet struct { 21 | TagGroups []TagGroup 22 | } 23 | 24 | func NewTagGroupSet() TagGroupSet { 25 | return TagGroupSet{TagGroups: []TagGroup{}} 26 | } 27 | 28 | func (set *TagGroupSet) Exists(tagName string) bool { 29 | for _, tg := range set.TagGroups { 30 | for _, tgTagName := range tg.Tags { 31 | if tagName == tgTagName { 32 | return true 33 | } 34 | } 35 | } 36 | return false 37 | } 38 | 39 | func (set *TagGroupSet) GetTagGroupNamesForTagNames(wantTagNames ...string) []string { 40 | tagGroupNames := []string{} 41 | for _, tg := range set.TagGroups { 42 | for _, tgTagName := range tg.Tags { 43 | for _, wantTagName := range wantTagNames { 44 | if wantTagName == tgTagName { 45 | tagGroupNames = append(tagGroupNames, tg.Name) 46 | } 47 | } 48 | } 49 | } 50 | return stringsutil.SliceCondenseSpace(tagGroupNames, true, true) 51 | } 52 | 53 | func (set *TagGroupSet) AddToSpec(spec *openapi3.Spec) error { 54 | if len(set.TagGroups) == 0 { 55 | return nil 56 | } 57 | missing := SpecTagsWithoutGroups(spec, *set) 58 | if len(missing) > 0 { 59 | return fmt.Errorf("E_TAGS_WITHOUT_GROUPS [%s]", strings.Join(missing, ",")) 60 | } 61 | se := openapi3edit.SpecEdit{} 62 | se.SpecSet(spec) 63 | se.ExtensionSet(XTagGroupsPropertyName, set.TagGroups) 64 | // spec.ExtensionProps.Extensions[XTagGroupsPropertyName] = set.TagGroups 65 | return nil 66 | } 67 | 68 | // OperationMoreTagGroupNames this function is meant to be used with `SpecMore.Table()` 69 | // and must follow the `OperationMoreStringFunc` interface. 70 | func (set *TagGroupSet) OperationMoreTagGroupNames(opm *openapi3.OperationMore) string { 71 | // row = append(row, strings.Join(tgs.GetTagGroupNamesForTagNames(op.Tags...), ", ")) 72 | if opm == nil || opm.Operation == nil { 73 | return "" 74 | } 75 | return strings.Join(set.GetTagGroupNamesForTagNames(opm.Operation.Tags...), ", ") 76 | } 77 | 78 | type TagGroup struct { 79 | Name string `json:"name"` 80 | Popular bool `json:"popular"` 81 | Tags []string `json:"tags"` 82 | } 83 | 84 | /* 85 | func (sm *SpecMore) TagsWithoutGroups() ([]string, []string, []string, error) { 86 | tgs, err := sm.TagGroups() 87 | if err != nil { 88 | return []string{}, []string{}, []string{}, err 89 | } 90 | allTags := []string{} 91 | 92 | topTags := stringsutil.SliceCondenseSpace(sm.Tags(true, false), true, true) 93 | allTags = append(allTags, topTags...) 94 | 95 | opsTags := stringsutil.SliceCondenseSpace(sm.Tags(false, true), true, true) 96 | allTags = append(allTags, opsTags...) 97 | 98 | allTags = stringsutil.SliceCondenseSpace(allTags, true, true) 99 | return allTags, topTags, opsTags, nil 100 | } 101 | */ 102 | 103 | func SpecTagsWithoutGroups(spec *openapi3.Spec, tagGroupSet TagGroupSet) []string { 104 | missing := []string{} 105 | for _, tag := range spec.Tags { 106 | if !tagGroupSet.Exists(tag.Name) { 107 | missing = append(missing, tag.Name) 108 | } 109 | } 110 | return missing 111 | } 112 | 113 | // SpecTagGroups parses a TagGroupSet from an OpenAPI3 spec. 114 | func SpecTagGroups(spec *openapi3.Spec) (TagGroupSet, error) { 115 | sm := openapi3.SpecMore{Spec: spec} 116 | tgs := NewTagGroupSet() 117 | // iface, ok := sm.Spec.ExtensionProps.Extensions[XTagGroupsPropertyName] 118 | iface, ok := sm.Spec.Extensions[XTagGroupsPropertyName] 119 | if !ok { 120 | return tgs, nil 121 | } 122 | 123 | tagGroups := []TagGroup{} 124 | if reflect.TypeOf(iface) == reflect.TypeOf(tagGroups) { 125 | tgs.TagGroups = iface.([]TagGroup) 126 | return tgs, nil 127 | } 128 | 129 | // message is stored as `json.RawMessage` when the data 130 | // is read in from JSON, vs. set via code. 131 | rawMessage := iface.(json.RawMessage) 132 | err := json.Unmarshal(rawMessage, &tagGroups) 133 | if err != nil { 134 | return tgs, err 135 | } 136 | tgs.TagGroups = tagGroups 137 | // delete(sm.Spec.ExtensionProps.Extensions, XTagGroupsPropertyName) 138 | // sm.Spec.ExtensionProps.Extensions[XTagGroupsPropertyName] = tagGroups 139 | delete(sm.Spec.Extensions, XTagGroupsPropertyName) 140 | sm.Spec.Extensions[XTagGroupsPropertyName] = tagGroups 141 | return tgs, nil 142 | } 143 | -------------------------------------------------------------------------------- /openapi3edit/schemas_flatten.go: -------------------------------------------------------------------------------- 1 | package openapi3edit 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strings" 7 | 8 | oas3 "github.com/getkin/kin-openapi/openapi3" 9 | "github.com/grokify/mogo/type/stringsutil" 10 | "github.com/grokify/spectrum/openapi3" 11 | ) 12 | 13 | func (se *SpecEdit) SchemasFlatten() { 14 | if se.SpecMore.Spec == nil { 15 | return 16 | } 17 | for schName, schRef := range se.SpecMore.Spec.Components.Schemas { 18 | if schRef == nil || schRef.Value == nil { 19 | continue 20 | } 21 | se.SchemasFlattenSchemaRef("", schName, schRef) 22 | } 23 | } 24 | 25 | func (se *SpecEdit) SchemasFlattenSchemaRef(baseName, schName string, schRef *oas3.SchemaRef) { 26 | if se.SpecMore.Spec == nil || schRef == nil { 27 | return 28 | } 29 | spec := se.SpecMore.Spec 30 | 31 | basePlusSchName := baseName + stringsutil.ToUpperFirst(schName, false) 32 | if len(baseName) == 0 { 33 | basePlusSchName = schName 34 | } 35 | 36 | for propName, propRef := range schRef.Value.Properties { 37 | if propRef == nil || propRef.Value == nil { 38 | continue 39 | } 40 | if openapi3.TypesRefIs(propRef.Value.Type, openapi3.TypeArray) { 41 | itemsRef := propRef.Value.Items 42 | if itemsRef == nil { 43 | continue 44 | } 45 | itemsRef.Ref = strings.TrimSpace(itemsRef.Ref) 46 | if itemsRef.Value != nil && 47 | (openapi3.TypesRefIs(itemsRef.Value.Type, openapi3.TypeArray, openapi3.TypeObject)) { 48 | if len(itemsRef.Ref) > 0 { 49 | propRef.Value.Items = oas3.NewSchemaRef(itemsRef.Ref, nil) 50 | } else { 51 | newSchemaName := basePlusSchName + stringsutil.ToUpperFirst(propName, false) 52 | if _, ok := spec.Components.Schemas[newSchemaName]; ok { 53 | fmt.Printf("BASE_NAME [%s] SCH_NAME [%s] PROP_NAME [%s] ARRAY\n", baseName, schName, propName) 54 | panic("collision") 55 | } 56 | se.SchemasFlattenSchemaRef(basePlusSchName, propName, itemsRef) 57 | spec.Components.Schemas[newSchemaName] = itemsRef 58 | propRef.Value.Items = oas3.NewSchemaRef(openapi3.PointerComponentsSchemas+"/"+newSchemaName, nil) 59 | } 60 | //itemsRef.Value = nil 61 | //itemsRef.Ref = openapi3.PointerComponentsSchemas + "/" + newSchemaName 62 | } 63 | //} else if propRef.Value.Type == openapi3.TypeObject { 64 | } else if openapi3.TypesRefIs(propRef.Value.Type, openapi3.TypeObject) { 65 | if len(propRef.Value.Properties) > 0 { 66 | newSchemaName := basePlusSchName + stringsutil.ToUpperFirst(propName, false) 67 | if _, ok := spec.Components.Schemas[newSchemaName]; ok { 68 | fmt.Printf("BASE_NAME [%s] SCH_NAME [%s] PROP_NAME [%s] OBJECT\n", baseName, schName, propName) 69 | panic("collision") 70 | } 71 | //SpecSchemasFlattenSchemaRef(spec, basePlusSchName, propName, propRef) 72 | se.SchemasFlattenSchemaRef("", newSchemaName, propRef) 73 | spec.Components.Schemas[newSchemaName] = propRef 74 | schRef.Value.Properties[propName] = oas3.NewSchemaRef(openapi3.PointerComponentsSchemas+"/"+newSchemaName, nil) 75 | //SpecSchemasFlattenSchemaRef(spec, basePlusSchName, propName, propRef) 76 | //schRef.Value.Properties[propName] = oas3.NewSchemaRef(newSchemaName, nil) 77 | } 78 | } 79 | } 80 | } 81 | 82 | var ErrEmptySchemaName = errors.New("empty schema name encountered") 83 | 84 | // SchemaRefsFlatten flattens Schema refs. 85 | func (se *SpecEdit) SchemaRefsFlatten() error { 86 | if se.SpecMore.Spec == nil { 87 | return openapi3.ErrSpecNotSet 88 | } 89 | spec := se.SpecMore.Spec 90 | // func SpecFlattenSchemaRefs(spec *openapi3.Spec, visitSchemaRefFunc func(schName string, schRef *oas3.SchemaRef) error) error { 91 | for schName, schRef := range spec.Components.Schemas { 92 | for propSchemaName, propSchemaRef := range schRef.Value.Properties { 93 | if len(strings.TrimSpace(propSchemaName)) == 0 { 94 | return ErrEmptySchemaName 95 | } 96 | // visitSchemaRefFunc(propSchemaName, propSchemaRef) 97 | if len(propSchemaRef.Ref) == 0 && propSchemaRef.Value != nil { 98 | if openapi3.TypesRefIs(propSchemaRef.Value.Type, openapi3.TypeObject, openapi3.TypeArray) { 99 | newRootSchemaName := propSchemaName 100 | if _, ok := spec.Components.Schemas[newRootSchemaName]; ok { 101 | newRootSchemaName = schName + stringsutil.ToUpperFirst(newRootSchemaName, false) 102 | if _, ok := spec.Components.Schemas[newRootSchemaName]; ok { 103 | return fmt.Errorf("schema collision [%s]", newRootSchemaName) 104 | } 105 | } 106 | spec.Components.Schemas[newRootSchemaName] = propSchemaRef 107 | propSchemaRef.Value = nil 108 | propSchemaRef.Ref = openapi3.PointerComponentsSchemas + "/" + newRootSchemaName 109 | } 110 | } 111 | } 112 | } 113 | return nil 114 | } 115 | --------------------------------------------------------------------------------