From 285ce19cd7c49ac160fc4bd44a447c873f3fea34 Mon Sep 17 00:00:00 2001 From: Haseeb Majid Date: Sun, 29 Nov 2020 22:40:41 +0000 Subject: [PATCH] feat: support for OpenAPI security spec Co-authored-by: Gregor Casar --- .gitignore | 3 + README.md | 11 ++++ fizz.go | 22 +++++++- fizz_test.go | 34 ++++++++++++ openapi/generator.go | 7 +++ openapi/operation.go | 1 + openapi/spec.go | 127 ++++++++++++++++++++++++++++++++++++++----- testdata/spec.json | 76 +++++++++++++++++++------- testdata/spec.yaml | 24 ++++++++ 9 files changed, 268 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 96011e7..86a6c60 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ coverage.txt # GoLand .idea + +# VSCode +.history \ No newline at end of file diff --git a/README.md b/README.md index cd08cfc..4c7d18b 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,16 @@ fizz.Header(name, desc string, model interface{}) // Override the binding model of the operation. fizz.InputModel(model interface{}) +// Overrides the top-level security requirement of an operation. +// Note that this function can be used more than once to add several requirements. +fizz.Security(security *openapi.SecurityRequirement) + +// Add an empty security requirement to this operation to make other security requirements optional. +fizz.WithOptionalSecurity() + +// Remove any top-level security requirements for this operation. +fizz.WithoutSecurity() + // Add a Code Sample to the operation. fizz.XCodeSample(codeSample *XCodeSample) ``` @@ -319,6 +329,7 @@ Fizz supports some native and imported types. A schema with a proper type and fo * [`time.Duration`](https://golang.org/pkg/time/#Duration) * [`net.URL`](https://golang.org/pkg/net/url/#URL) * [`net.IP`](https://golang.org/pkg/net/#IP) + Note that, according to the doc, the inherent version of the address is a semantic property, and thus cannot be determined by Fizz. Therefore, the format returned is simply `ip`. If you want to specify the version, you can use the tags `format:"ipv4"` or `format:"ipv6"`. * [`uuid.UUID`](https://godoc.org/github.com/gofrs/uuid#UUID) diff --git a/fizz.go b/fizz.go index 306df3f..46d029e 100644 --- a/fizz.go +++ b/fizz.go @@ -353,10 +353,26 @@ func InputModel(model interface{}) func(*openapi.OperationInfo) { } } -// XCodeSample adds a code sample to the operation. -func XCodeSample(cs *openapi.XCodeSample) func(*openapi.OperationInfo) { +// Overrides top-level security requirement for this operation. +// Note that this function can be used more than once to add several requirements. +func Security(security *openapi.SecurityRequirement) func(*openapi.OperationInfo) { return func(o *openapi.OperationInfo) { - o.XCodeSamples = append(o.XCodeSamples, cs) + o.Security = append(o.Security, security) + } +} + +// Add an empty security requirement to this operation to make other security requirements optional. +func WithOptionalSecurity() func(*openapi.OperationInfo) { + return func(o *openapi.OperationInfo) { + var emptyRequirement openapi.SecurityRequirement = make(openapi.SecurityRequirement) + o.Security = append(o.Security, &emptyRequirement) + } +} + +// Remove any top-level security requirements for this operation. +func WithoutSecurity() func(*openapi.OperationInfo) { + return func(o *openapi.OperationInfo) { + o.Security = []*openapi.SecurityRequirement{} } } diff --git a/fizz_test.go b/fizz_test.go index 62f5ba4..fb7a654 100644 --- a/fizz_test.go +++ b/fizz_test.go @@ -271,6 +271,8 @@ func TestSpecHandler(t *testing.T) { Label: "v4.4", Source: "curl http://0.0.0.0:8080", }), + // Explicit override for SecurityRequirement (allow-all) + WithoutSecurity(), }, tonic.Handler(func(c *gin.Context) error { return nil @@ -280,6 +282,8 @@ func TestSpecHandler(t *testing.T) { fizz.GET("/test/:a/:b", []OperationOption{ ID("GetTest2"), InputModel(&testInputModel{}), + WithOptionalSecurity(), + Security(&openapi.SecurityRequirement{"oauth2": []string{"write:pets", "read:pets"}}), }, tonic.Handler(func(c *gin.Context) error { return nil }, 200)) @@ -315,6 +319,36 @@ func TestSpecHandler(t *testing.T) { } fizz.Generator().SetServers(servers) + security := openapi.SecurityRequirement{ + "api_key": []string{}, + "oauth2": []string{"write:pets", "read:pets"}, + } + fizz.Generator().SetSecurityRequirement(&security) + + fizz.Generator().API().Components.SecuritySchemes = map[string]*openapi.SecuritySchemeOrRef{ + "api_key": { + SecurityScheme: &openapi.SecurityScheme{ + Type: "apiKey", + Name: "api_key", + In: "header", + }, + }, + "oauth2": { + SecurityScheme: &openapi.SecurityScheme{ + Type: "oauth2", + Flows: &openapi.OAuthFlows{ + Implicit: &openapi.OAuthFlow{ + AuthorizationURL: "https://example.com/api/oauth/dialog", + Scopes: map[string]string{ + "write:pets": "modify pets in your account", + "read:pets": "read your pets", + }, + }, + }, + }, + }, + } + fizz.GET("/openapi.json", nil, fizz.OpenAPI(infos, "")) // default is JSON fizz.GET("/openapi.yaml", nil, fizz.OpenAPI(infos, "yaml")) diff --git a/openapi/generator.go b/openapi/generator.go index e4202c5..e61c64f 100644 --- a/openapi/generator.go +++ b/openapi/generator.go @@ -104,6 +104,12 @@ func (g *Generator) SetServers(servers []*Server) { g.api.Servers = servers } +// SetSecurityRequirement sets the security options for the +// current specification. +func (g *Generator) SetSecurityRequirement(security *SecurityRequirement) { + g.api.Security = security +} + // API returns a copy of the internal OpenAPI object. func (g *Generator) API() *OpenAPI { cpy := *g.api @@ -251,6 +257,7 @@ func (g *Generator) AddOperation(path, method, tag string, in, out reflect.Type, op.Deprecated = info.Deprecated op.Responses = make(Responses) op.XCodeSamples = info.XCodeSamples + op.Security = info.Security } if tag != "" { op.Tags = append(op.Tags, tag) diff --git a/openapi/operation.go b/openapi/operation.go index 9b43343..61aa607 100644 --- a/openapi/operation.go +++ b/openapi/operation.go @@ -12,6 +12,7 @@ type OperationInfo struct { Deprecated bool InputModel interface{} Responses []*OperationResponse + Security []*SecurityRequirement XCodeSamples []*XCodeSample } diff --git a/openapi/spec.go b/openapi/spec.go index 19ea949..972cb90 100644 --- a/openapi/spec.go +++ b/openapi/spec.go @@ -1,25 +1,28 @@ package openapi +import "encoding/json" + // OpenAPI represents the root document object of // an OpenAPI document. type OpenAPI struct { - OpenAPI string `json:"openapi" yaml:"openapi"` - Info *Info `json:"info" yaml:"info"` - Servers []*Server `json:"servers,omitempty" yaml:"servers,omitempty"` - Paths Paths `json:"paths" yaml:"paths"` - Components *Components `json:"components,omitempty" yaml:"components,omitempty"` - Tags []*Tag `json:"tags,omitempty" yaml:"tags,omitempty"` - XTagGroups []*XTagGroup `json:"x-tagGroups,omitempty" yaml:"x-tagGroups,omitempty"` + OpenAPI string `json:"openapi" yaml:"openapi"` + Info *Info `json:"info" yaml:"info"` + Servers []*Server `json:"servers,omitempty" yaml:"servers,omitempty"` + Paths Paths `json:"paths" yaml:"paths"` + Components *Components `json:"components,omitempty" yaml:"components,omitempty"` + Tags []*Tag `json:"tags,omitempty" yaml:"tags,omitempty"` + Security *SecurityRequirement `json:"security,omitempty" yaml:"security,omitempty"` } // Components holds a set of reusable objects for different // ascpects of the specification. type Components struct { - Schemas map[string]*SchemaOrRef `json:"schemas,omitempty" yaml:"schemas,omitempty"` - Responses map[string]*ResponseOrRef `json:"responses,omitempty" yaml:"responses,omitempty"` - Parameters map[string]*ParameterOrRef `json:"parameters,omitempty" yaml:"parameters,omitempty"` - Examples map[string]*ExampleOrRef `json:"examples,omitempty" yaml:"examples,omitempty"` - Headers map[string]*HeaderOrRef `json:"headers,omitempty" yaml:"headers,omitempty"` + Schemas map[string]*SchemaOrRef `json:"schemas,omitempty" yaml:"schemas,omitempty"` + Responses map[string]*ResponseOrRef `json:"responses,omitempty" yaml:"responses,omitempty"` + Parameters map[string]*ParameterOrRef `json:"parameters,omitempty" yaml:"parameters,omitempty"` + Examples map[string]*ExampleOrRef `json:"examples,omitempty" yaml:"examples,omitempty"` + Headers map[string]*HeaderOrRef `json:"headers,omitempty" yaml:"headers,omitempty"` + SecuritySchemes map[string]*SecuritySchemeOrRef `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` } // Info represents the metadata of an API. @@ -184,6 +187,22 @@ type Schema struct { // Operation describes an API operation on a path. type Operation struct { + Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` + Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + ID string `json:"operationId,omitempty" yaml:"operationId,omitempty"` + Parameters []*ParameterOrRef `json:"parameters,omitempty" yaml:"parameters,omitempty"` + RequestBody *RequestBody `json:"requestBody,omitempty" yaml:"requestBody,omitempty"` + Responses Responses `json:"responses,omitempty" yaml:"responses,omitempty"` + Deprecated bool `json:"deprecated,omitempty" yaml:"deprecated,omitempty"` + Servers []*Server `json:"servers,omitempty" yaml:"servers,omitempty"` + Security []*SecurityRequirement `json:"security" yaml:"security"` + XCodeSamples []*XCodeSample `json:"x-codeSamples,omitempty" yaml:"x-codeSamples,omitempty"` +} + +// A workaround for missing omitnil functionality. +// Explicitely omit the Security field from marshaling when it is nil, but not when empty. +type operationNilOmitted struct { Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` Summary string `json:"summary,omitempty" yaml:"summary,omitempty"` Description string `json:"description,omitempty" yaml:"description,omitempty"` @@ -196,6 +215,38 @@ type Operation struct { XCodeSamples []*XCodeSample `json:"x-codeSamples,omitempty" yaml:"x-codeSamples,omitempty"` } +// MarshalYAML implements yaml.Marshaler for Operation. +// Needed to marshall empty but non-null SecurityRequirements. +func (o *Operation) MarshalYAML() (interface{}, error) { + if o.Security == nil { + return omitOperationNilFields(o), nil + } + return o, nil +} + +// MarshalJSON excludes empty but non-null SecurityRequirements. +func (o *Operation) MarshalJSON() ([]byte, error) { + if o.Security == nil { + return json.Marshal(omitOperationNilFields(o)) + } + return json.Marshal(*o) +} + +func omitOperationNilFields(o *Operation) *operationNilOmitted { + return &operationNilOmitted{ + Tags: o.Tags, + Summary: o.Summary, + Description: o.Description, + ID: o.ID, + Parameters: o.Parameters, + RequestBody: o.RequestBody, + Responses: o.Responses, + Deprecated: o.Deprecated, + Servers: o.Servers, + XCodeSamples: o.XCodeSamples, + } +} + // Responses represents a container for the expected responses // of an opration. It maps a HTTP response code to the expected // response. @@ -309,7 +360,53 @@ type Tag struct { Description string `json:"description,omitempty" yaml:"description,omitempty"` } -// XLogo represents the information about the x-logo extension +// SecuritySchemeOrRef represents a SecurityScheme that can be inlined +// or referenced in the API description. +type SecuritySchemeOrRef struct { + *SecurityScheme + *Reference +} + +// MarshalYAML implements yaml.Marshaler for SecuritySchemeOrRef. +func (sor *SecuritySchemeOrRef) MarshalYAML() (interface{}, error) { + if sor.SecurityScheme != nil { + return sor.SecurityScheme, nil + } + return sor.Reference, nil +} + +// SecurityScheme represents a security scheme that can be used by an operation. +type SecurityScheme struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Scheme string `json:"scheme,omitempty" yaml:"scheme,omitempty"` + BearerFormat string `json:"bearerFormat,omitempty" yaml:"bearerFormat,omitempty"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + In string `json:"in,omitempty" yaml:"in,omitempty"` + Name string `json:"name,omitempty" yaml:"name,omitempty"` + OpenIDConnectURL string `json:"openIdConnectUrl,omitempty" yaml:"openIdConnectUrl,omitempty"` + Flows *OAuthFlows `json:"flows,omitempty" yaml:"flows,omitempty"` +} + +// OAuthFlows represents all the supported OAuth flows. +type OAuthFlows struct { + Implicit *OAuthFlow `json:"implicit,omitempty" yaml:"implicit,omitempty"` + Password *OAuthFlow `json:"password,omitempty" yaml:"password,omitempty"` + ClientCredentials *OAuthFlow `json:"clientCredentials,omitempty" yaml:"clientCredentials,omitempty"` + AuthorizationCode *OAuthFlow `json:"authorizationCode,omitempty" yaml:"authorizationCode,omitempty"` +} + +// OAuthFlow represents an OAuth security scheme. +type OAuthFlow struct { + AuthorizationURL string `json:"authorizationUrl,omitempty" yaml:"authorizationUrl,omitempty"` + TokenURL string `json:"tokenUrl,omitempty" yaml:"tokenUrl,omitempty"` + RefreshURL string `json:"refreshUrl,omitempty" yaml:"refreshUrl,omitempty"` + Scopes map[string]string `json:"scopes,omitempty" yaml:"scopes,omitempty"` +} + +// SecurityRequirement represents the security object in the API specification. +type SecurityRequirement map[string][]string + +// XLogo represents the information about the x-logo extension. // See: https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-logo type XLogo struct { URL string `json:"url,omitempty" yaml:"url,omitempty"` @@ -318,14 +415,14 @@ type XLogo struct { Href string `json:"href,omitempty" yaml:"href,omitempty"` } -// XTagGroup represents the information about the x-tagGroups extension +// XTagGroup represents the information about the x-tagGroups extension. // See: https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-taggroups type XTagGroup struct { Name string `json:"name,omitempty" yaml:"name,omitempty"` Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"` } -// XCodeSample represents the information about the x-codeSample extension +// XCodeSample represents the information about the x-codeSample extension. // See: https://github.com/Redocly/redoc/blob/master/docs/redoc-vendor-extensions.md#x-codesamples type XCodeSample struct { Lang string `json:"lang,omitempty" yaml:"lang,omitempty"` diff --git a/testdata/spec.json b/testdata/spec.json index 1e23c32..ab8da3c 100644 --- a/testdata/spec.json +++ b/testdata/spec.json @@ -5,21 +5,30 @@ "description": "This is a test server.", "version": "1.0.0" }, - "servers": [{ - "url": "https://foo.bar/{basePath}", - "description": "Such Server, Very Wow", - "variables": { - "basePath": { - "enum": [ - "v1", - "v2", - "beta" - ], - "default": "v2", - "description": "version of the API" + "servers": [ + { + "url": "https://foo.bar/{basePath}", + "description": "Such Server, Very Wow", + "variables": { + "basePath": { + "enum": [ + "v1", + "v2", + "beta" + ], + "default": "v2", + "description": "version of the API" + } } } - }], + ], + "security": { + "api_key": [], + "oauth2": [ + "write:pets", + "read:pets" + ] + }, "paths": { "/test/{a}": { "get": { @@ -88,13 +97,14 @@ } }, "deprecated": true, - "x-codeSamples":[ + "x-codeSamples": [ { - "lang":"Shell", - "label":"v4.4", - "source":"curl http://0.0.0.0:8080" + "lang": "Shell", + "label": "v4.4", + "source": "curl http://0.0.0.0:8080" } - ] + ], + "security": [] } }, "/test/{a}/{b}": { @@ -130,7 +140,16 @@ "200": { "description": "OK" } - } + }, + "security": [ + {}, + { + "oauth2": [ + "write:pets", + "read:pets" + ] + } + ] } }, "/test/{c}": { @@ -178,6 +197,25 @@ } } } + }, + "securitySchemes": { + "api_key": { + "type": "apiKey", + "name": "api_key", + "in": "header" + }, + "oauth2": { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://example.com/api/oauth/dialog", + "scopes": { + "write:pets": "modify pets in your account", + "read:pets": "read your pets" + } + } + } + } } } } diff --git a/testdata/spec.yaml b/testdata/spec.yaml index 6640e3e..f548713 100644 --- a/testdata/spec.yaml +++ b/testdata/spec.yaml @@ -14,6 +14,11 @@ servers: - beta default: v2 description: version of the API +security: + api_key: [] + oauth2: + - write:pets + - read:pets paths: /test/{a}: get: @@ -63,6 +68,7 @@ paths: - lang: Shell label: v4.4 source: curl http://0.0.0.0:8080 + security: [] /test/{a}/{b}: get: operationId: GetTest2 @@ -85,6 +91,11 @@ paths: responses: '200': description: OK + security: + - {} + - oauth2: + - write:pets + - read:pets /test/{c}: post: operationId: PostTest @@ -113,3 +124,16 @@ components: value: description: "A nullable value of arbitrary type" nullable: true + securitySchemes: + api_key: + type: apiKey + name: api_key + in: header + oauth2: + type: oauth2 + flows: + implicit: + authorizationUrl: https://example.com/api/oauth/dialog + scopes: + write:pets: modify pets in your account + read:pets: read your pets