diff --git a/README.md b/README.md index 44f22c5..c182784 100644 --- a/README.md +++ b/README.md @@ -4,3 +4,17 @@ Initial test&try to generate a swagger dynamically. It uses [gorilla-mux](https://github.com/gorilla/mux) and [kin-openapi](https://github.com/getkin/kin-openapi) to automatically generate and serve a swagger file. + +To convert struct to schemas, we use [this library](https://github.com/alecthomas/jsonschema). +The struct should contains the appropriate struct tags to be inserted in json schema. + +### FAQ + +1. How to add format `binary`? +Formats `date-time`, `email`, `hostname`, `ipv4`, `ipv6`, `uri` could be added with tag `jsonschema`. Others format could be added with tag `jsonschema_extra`. Not all the formats are supported (see discovered unsupported formats [here](#discovered-unsupported-schema-features)). + +#### Discovered unsupported schema features + +*Formats*: + +* `uuid` is unsupported by kin-openapi diff --git a/go.mod b/go.mod index b3be1f1..39086db 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/davidebianchi/gorilla-swagger go 1.15 require ( + github.com/alecthomas/jsonschema v0.0.0-20200530073317-71f438968921 github.com/getkin/kin-openapi v0.22.1 github.com/gorilla/mux v1.8.0 - github.com/stretchr/testify v1.5.1 + github.com/iancoleman/orderedmap v0.1.0 // indirect + github.com/stretchr/testify v1.6.1 ) diff --git a/go.sum b/go.sum index baa0a2c..983a16d 100644 --- a/go.sum +++ b/go.sum @@ -1,49 +1,30 @@ -github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI= -github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M= -github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alecthomas/jsonschema v0.0.0-20200530073317-71f438968921 h1:T3+cD5fYvuH36h7EZq+TDpm+d8a6FSD4pQsbmuGGQ8o= +github.com/alecthomas/jsonschema v0.0.0-20200530073317-71f438968921/go.mod h1:/n6+1/DWPltRLWL/VKyUxg6tzsl5kHUCcraimt4vr60= github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/getkin/kin-openapi v0.22.1 h1:ODA1olTp175o//NfHko/uCAAhwUSfm5P4+K52XvTg4w= github.com/getkin/kin-openapi v0.22.1/go.mod h1:WGRs2ZMM1Q8LR1QBEwUxC6RJEfaBcD0s+pcEVXFuAjw= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-openapi/jsonpointer v0.19.3 h1:gihV7YNZK1iK6Tgwwsxo2rJbD1GTbdm72325Bq8FI3w= -github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= -github.com/go-openapi/jsonreference v0.19.4 h1:3Vw+rh13uq2JFNxgnMTGE1rnoieU9FmyE1gvnyylsYg= -github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg= -github.com/go-openapi/spec v0.19.9 h1:9z9cbFuZJ7AcvOHKIY+f6Aevb4vObNDkTEyoMfO7rAc= -github.com/go-openapi/spec v0.19.9/go.mod h1:vqK/dIdLGCosfvYsQV3WfC7N3TiZSnGY2RZKoFK7X28= -github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= -github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e h1:hB2xlXdHp/pmPZq0y3QnmWAArdw9PqbmotexnWx/FU8= -github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 h1:i462o439ZjprVSFSZLZxcsoAe592sZB1rci2Z8j4wdk= +github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= +github.com/iancoleman/orderedmap v0.1.0 h1:2orAxZBJsvimgEBmMWfXaFlzSG2fbQil5qzP3F6cCkg= +github.com/iancoleman/orderedmap v0.1.0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297 h1:k7pJ2yAPLPgbskkFdhRCsA77k2fySZ1zf2zCjvQCiIM= -golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..ff3b76f --- /dev/null +++ b/integration_test.go @@ -0,0 +1,88 @@ +package swagger + +import ( + "context" + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" +) + +const ( + swaggerOpenapiTitle = "test swagger title" + swaggerOpenapiVersion = "test swagger version" +) + +func TestIntegration(t *testing.T) { + t.Run("router works correctly", func(t *testing.T) { + muxRouter := setupSwagger(t) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/hello", nil) + + muxRouter.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + body := readBody(t, w.Result().Body) + require.Equal(t, "OK", body) + + t.Run("and generate swagger", func(t *testing.T) { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, JSONDocumentationPath, nil) + + muxRouter.ServeHTTP(w, r) + + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + body := readBody(t, w.Result().Body) + require.Equal(t, "{\"components\":{},\"info\":{\"title\":\"test swagger title\",\"version\":\"test swagger version\"},\"openapi\":\"3.0.0\",\"paths\":{\"/hello\":{\"get\":{\"responses\":{\"default\":{\"description\":\"\"}}}}}}", body) + }) + }) +} + +func readBody(t *testing.T, requestBody io.ReadCloser) string { + t.Helper() + + body, err := ioutil.ReadAll(requestBody) + require.NoError(t, err) + + return string(body) +} + +func setupSwagger(t *testing.T) *mux.Router { + t.Helper() + + context := context.Background() + muxRouter := mux.NewRouter() + + router, err := New(muxRouter, Options{ + Context: context, + Openapi: &openapi3.Swagger{ + Info: &openapi3.Info{ + Title: swaggerOpenapiTitle, + Version: swaggerOpenapiVersion, + }, + }, + }) + require.NoError(t, err) + + handler := func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`OK`)) + } + operation := Operation{} + + _, err = router.AddRawRoute(http.MethodGet, "/hello", handler, operation) + require.NoError(t, err) + + err = router.GenerateAndExposeSwagger() + require.NoError(t, err) + + return muxRouter +} diff --git a/main.go b/main.go index e50a417..053bf08 100644 --- a/main.go +++ b/main.go @@ -7,7 +7,6 @@ import ( "net/http" "github.com/getkin/kin-openapi/openapi3" - "github.com/getkin/kin-openapi/openapi3filter" "github.com/gorilla/mux" ) @@ -21,93 +20,24 @@ var ( const ( // JSONDocumentationPath is the path of the swagger documentation in json format. JSONDocumentationPath = "/documentation/json" - defaultOpenapiVersion = "3.0.0" ) // Router handle the gorilla mux router and the swagger schema type Router struct { - router *mux.Router - SwaggerSchema *openapi3.Swagger - enableRequestValidation bool - context context.Context - swaggerRouter *openapi3filter.Router + router *mux.Router + swaggerSchema *openapi3.Swagger + context context.Context } -// Handler is the http type handler -type Handler func(w http.ResponseWriter, req *http.Request) - -// GenerateAndExposeSwagger creates a /documentation/json route on router and -// expose the generated swagger -func (r Router) GenerateAndExposeSwagger() error { - if err := r.SwaggerSchema.Validate(r.context); err != nil { - return fmt.Errorf("%w: %s", ErrValidatingSwagger, err) - } - - jsonSwagger, err := r.SwaggerSchema.MarshalJSON() - if err != nil { - return fmt.Errorf("%w: %s", ErrGenerateSwagger, err) - } - - r.router.HandleFunc(JSONDocumentationPath, func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - w.Header().Set("content-type", "application/json") - w.Write(jsonSwagger) - }) - // TODO: add yaml endpoint - - err = r.swaggerRouter.AddSwagger(r.SwaggerSchema) - if err != nil { - return err - } - - return nil -} - -// AddRoute add route to router with specific method, path and handler. Add the -// router also to the swagger schema, after validating it -func (r Router) AddRoute(method string, path string, handler Handler, operation Operation) (*mux.Route, error) { - if operation.Operation != nil { - err := operation.Validate(r.context) - if err != nil { - return nil, err - } - } else { - operation.Operation = openapi3.NewOperation() - operation.Responses = openapi3.NewResponses() - } - r.SwaggerSchema.AddOperation(path, method, operation.Operation) - - if operation.Operation != nil && r.enableRequestValidation { - return r.router.HandleFunc(path, func(h http.ResponseWriter, req *http.Request) { - err := validateRequest(r, req) - if err != nil { - // TODO: add response for validation response - return - } - handler(h, req) - - }).Methods(method), nil - } - return r.router.HandleFunc(path, func(w http.ResponseWriter, req *http.Request) { - // Handle, when content-type is json, the request/response marshalling? Maybe with a specific option. - handler(w, req) - }).Methods(method), nil -} - -func (r Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { - r.router.ServeHTTP(w, req) -} - -// RouterOptions to be passed to create the new router and swagger -type RouterOptions struct { - Context context.Context - EnableRequestValidation bool - Openapi *openapi3.Swagger +// Options to be passed to create the new router and swagger +type Options struct { + Context context.Context + Openapi *openapi3.Swagger } // New generate new router with swagger. Default to OpenAPI 3.0.0 -func New(router *mux.Router, options RouterOptions) (*Router, error) { +func New(router *mux.Router, options Options) (*Router, error) { swagger, err := generateNewValidSwagger(options.Openapi) if err != nil { return nil, fmt.Errorf("%w: %s", ErrValidatingSwagger, err) @@ -119,53 +49,25 @@ func New(router *mux.Router, options RouterOptions) (*Router, error) { } return &Router{ - router: router, - enableRequestValidation: options.EnableRequestValidation, - SwaggerSchema: swagger, - context: ctx, - swaggerRouter: openapi3filter.NewRouter(), + router: router, + swaggerSchema: swagger, + context: ctx, }, nil } -// Operation type -type Operation struct { - *openapi3.Operation - // TODO: handle request and response -} - -func validateRequest(r Router, req *http.Request) error { - // Find route - route, pathParams, err := r.swaggerRouter.FindRoute(req.Method, req.URL) - if err != nil { - return err - } - - // Validate request - requestValidationInput := &openapi3filter.RequestValidationInput{ - Request: req, - PathParams: pathParams, - Route: route, - // TODO: add query params - } - - return openapi3filter.ValidateRequest(req.Context(), requestValidationInput) -} - func generateNewValidSwagger(swagger *openapi3.Swagger) (*openapi3.Swagger, error) { if swagger == nil { - swagger = &openapi3.Swagger{ - OpenAPI: defaultOpenapiVersion, - } + return nil, fmt.Errorf("swagger is required") } if swagger.OpenAPI == "" { swagger.OpenAPI = defaultOpenapiVersion } - if swagger.Paths == nil { swagger.Paths = openapi3.Paths{} } + if swagger.Info == nil { - return nil, fmt.Errorf("swagger info must not be empty") + return nil, fmt.Errorf("swagger info is required") } if swagger.Info.Title == "" { return nil, fmt.Errorf("swagger info title is required") @@ -176,3 +78,24 @@ func generateNewValidSwagger(swagger *openapi3.Swagger) (*openapi3.Swagger, erro return swagger, nil } + +// GenerateAndExposeSwagger creates a /documentation/json route on router and +// expose the generated swagger +func (r Router) GenerateAndExposeSwagger() error { + if err := r.swaggerSchema.Validate(r.context); err != nil { + return fmt.Errorf("%w: %s", ErrValidatingSwagger, err) + } + + jsonSwagger, err := r.swaggerSchema.MarshalJSON() + if err != nil { + return fmt.Errorf("%w: %s", ErrGenerateSwagger, err) + } + r.router.HandleFunc(JSONDocumentationPath, func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(jsonSwagger) + }) + // TODO: add yaml endpoint + + return nil +} diff --git a/main_test.go b/main_test.go index 915c003..aaa25aa 100644 --- a/main_test.go +++ b/main_test.go @@ -2,10 +2,11 @@ package swagger import ( "context" - "io" + "fmt" "io/ioutil" "net/http" "net/http/httptest" + "strings" "testing" "github.com/getkin/kin-openapi/openapi3" @@ -13,76 +14,167 @@ import ( "github.com/stretchr/testify/require" ) -const ( - swaggerOpenapiTitle = "test swagger title" - swaggerOpenapiVersion = "test swagger version" -) +func TestNewRouter(t *testing.T) { + mRouter := mux.NewRouter() -func TestIntegration(t *testing.T) { - t.Run("router works correctly", func(t *testing.T) { - router := setupSwagger(t) + info := &openapi3.Info{ + Title: "my title", + Version: "my version", + } + openapi := &openapi3.Swagger{ + Info: info, + Paths: openapi3.Paths{}, + } + + t.Run("not ok - invalid Openapi option", func(t *testing.T) { + r, err := New(mRouter, Options{}) + + require.Nil(t, r) + require.EqualError(t, err, fmt.Sprintf("%s: swagger is required", ErrValidatingSwagger)) + }) + + t.Run("ok - with default context", func(t *testing.T) { + r, err := New(mRouter, Options{ + Openapi: openapi, + }) + + require.NoError(t, err) + require.Equal(t, &Router{ + context: context.Background(), + router: mRouter, + swaggerSchema: openapi, + }, r) + }) + + t.Run("ok - with custom context", func(t *testing.T) { + type key struct{} + ctx := context.WithValue(context.Background(), key{}, "value") + r, err := New(mRouter, Options{ + Openapi: openapi, + Context: ctx, + }) + + require.NoError(t, err) + require.Equal(t, &Router{ + context: ctx, + router: mRouter, + swaggerSchema: openapi, + }, r) + }) +} + +func TestGenerateValidSwagger(t *testing.T) { + t.Run("not ok - empty swagger info", func(t *testing.T) { + swagger := &openapi3.Swagger{} + + swagger, err := generateNewValidSwagger(swagger) + require.Nil(t, swagger) + require.EqualError(t, err, "swagger info is required") + }) + + t.Run("not ok - empty info title", func(t *testing.T) { + swagger := &openapi3.Swagger{ + Info: &openapi3.Info{}, + } + + swagger, err := generateNewValidSwagger(swagger) + require.Nil(t, swagger) + require.EqualError(t, err, "swagger info title is required") + }) + + t.Run("not ok - empty info version", func(t *testing.T) { + swagger := &openapi3.Swagger{ + Info: &openapi3.Info{ + Title: "title", + }, + } + + swagger, err := generateNewValidSwagger(swagger) + require.Nil(t, swagger) + require.EqualError(t, err, "swagger info version is required") + }) + + t.Run("ok - custom swagger", func(t *testing.T) { + swagger := &openapi3.Swagger{ + Info: &openapi3.Info{}, + } + + swagger, err := generateNewValidSwagger(swagger) + require.Nil(t, swagger) + require.EqualError(t, err, "swagger info title is required") + }) + + t.Run("not ok - swagger is required", func(t *testing.T) { + swagger, err := generateNewValidSwagger(nil) + require.Nil(t, swagger) + require.EqualError(t, err, "swagger is required") + }) + + t.Run("ok", func(t *testing.T) { + info := &openapi3.Info{ + Title: "my title", + Version: "my version", + } + swagger := &openapi3.Swagger{ + Info: info, + } + + swagger, err := generateNewValidSwagger(swagger) + require.NoError(t, err) + require.Equal(t, &openapi3.Swagger{ + OpenAPI: defaultOpenapiVersion, + Info: info, + Paths: openapi3.Paths{}, + }, swagger) + }) +} + +func TestGenerateAndExposeSwagger(t *testing.T) { + t.Run("fails swagger validation", func(t *testing.T) { + mRouter := mux.NewRouter() + router, err := New(mRouter, Options{ + Openapi: &openapi3.Swagger{ + Info: &openapi3.Info{ + Title: "title", + Version: "version", + }, + Components: openapi3.Components{ + Schemas: map[string]*openapi3.SchemaRef{ + "&%": {}, + }, + }, + }, + }) + require.NoError(t, err) + + err = router.GenerateAndExposeSwagger() + require.Error(t, err) + require.True(t, strings.HasPrefix(err.Error(), fmt.Sprintf("%s:", ErrValidatingSwagger))) + }) + + t.Run("correctly expose json documentation from loaded swagger file", func(t *testing.T) { + mRouter := mux.NewRouter() + + swagger, err := openapi3.NewSwaggerLoader().LoadSwaggerFromFile("testdata/users_employees.json") + require.NoError(t, err) + + router, err := New(mRouter, Options{ + Openapi: swagger, + }) + + err = router.GenerateAndExposeSwagger() + require.NoError(t, err) w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, "/hello", nil) - - router.ServeHTTP(w, r) + req := httptest.NewRequest(http.MethodGet, JSONDocumentationPath, nil) + mRouter.ServeHTTP(w, req) require.Equal(t, http.StatusOK, w.Result().StatusCode) + require.True(t, strings.Contains(w.Result().Header.Get("content-type"), "application/json")) body := readBody(t, w.Result().Body) - require.Equal(t, "OK", body) - - t.Run("and generate swagger", func(t *testing.T) { - w := httptest.NewRecorder() - r := httptest.NewRequest(http.MethodGet, JSONDocumentationPath, nil) - - router.ServeHTTP(w, r) - - require.Equal(t, http.StatusOK, w.Result().StatusCode) - - body := readBody(t, w.Result().Body) - require.Equal(t, "{\"components\":{},\"info\":{\"title\":\"test swagger title\",\"version\":\"test swagger version\"},\"openapi\":\"3.0.0\",\"paths\":{\"/hello\":{\"get\":{\"responses\":{\"default\":{\"description\":\"\"}}}}}}", body) - }) + actual, err := ioutil.ReadFile("testdata/users_employees.json") + require.NoError(t, err) + require.JSONEq(t, string(actual), body) }) } - -func readBody (t *testing.T, requestBody io.ReadCloser) string { - t.Helper() - - body, err := ioutil.ReadAll(requestBody) - require.NoError(t, err) - - return string(body) -} - -func setupSwagger(t *testing.T) *Router { - t.Helper() - - context := context.Background() - r := mux.NewRouter() - - router, err := New(r, RouterOptions{ - Context: context, - Openapi: &openapi3.Swagger{ - Info: &openapi3.Info{ - Title: swaggerOpenapiTitle, - Version: swaggerOpenapiVersion, - }, - }, - }) - require.NoError(t, err) - - handler := func(w http.ResponseWriter, req *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`OK`)) - } - operation := Operation{} - - _, err = router.AddRoute(http.MethodGet, "/hello", handler, operation) - require.NoError(t, err) - - err = router.GenerateAndExposeSwagger() - require.NoError(t, err) - - return router -} diff --git a/route.go b/route.go new file mode 100644 index 0000000..4bfd1c2 --- /dev/null +++ b/route.go @@ -0,0 +1,240 @@ +package swagger + +import ( + "errors" + "fmt" + "net/http" + "sort" + + "github.com/alecthomas/jsonschema" + "github.com/getkin/kin-openapi/openapi3" + "github.com/gorilla/mux" +) + +var ( + // ErrResponses is thrown if error occurs generating responses schemas. + ErrResponses = errors.New("errors generating responses schema") + // ErrRequestBody is thrown if error occurs generating responses schemas. + ErrRequestBody = errors.New("errors generating request body schema") + // ErrPathParams is thrown if error occurs generating path params schemas. + ErrPathParams = errors.New("errors generating path parameters schema") + // ErrQuerystring is thrown if error occurs generating querystring params schemas. + ErrQuerystring = errors.New("errors generating querystring schema") +) + +// Operation type +type Operation struct { + *openapi3.Operation +} + +// Handler is the http type handler +type Handler func(w http.ResponseWriter, req *http.Request) + +// AddRawRoute add route to router with specific method, path and handler. Add the +// router also to the swagger schema, after validating it +func (r Router) AddRawRoute(method string, path string, handler Handler, operation Operation) (*mux.Route, error) { + if operation.Operation != nil { + err := operation.Validate(r.context) + if err != nil { + return nil, err + } + } else { + operation.Operation = openapi3.NewOperation() + operation.Responses = openapi3.NewResponses() + } + r.swaggerSchema.AddOperation(path, method, operation.Operation) + + return r.router.HandleFunc(path, func(w http.ResponseWriter, req *http.Request) { + // Handle, when content-type is json, the request/response marshalling? Maybe with a specific option. + handler(w, req) + }).Methods(method), nil +} + +// SchemaValue is the struct containing the schema information. +type SchemaValue struct { + Content interface{} + Description string + + // ContentType is to be used only with RequestBody. Valid ContentType + // are application/json or multipart/form-data. + ContentType string + AllowAdditionalProperties bool +} + +// Schema of the route. +type Schema struct { + PathParams map[string]SchemaValue + Querystring map[string]SchemaValue + Headers map[string]SchemaValue + Cookies map[string]SchemaValue + RequestBody *SchemaValue + Responses map[int]SchemaValue +} + +const ( + pathParamsType = "path" + queryParamType = "query" + headerParamType = "header" + cookieParamType = "cookie" +) + +// AddRoute add a route with json schema inferted by passed schema. +func (r Router) AddRoute(method string, path string, handler Handler, schema Schema) (*mux.Route, error) { + operation := openapi3.NewOperation() + operation.Responses = make(openapi3.Responses) + + err := r.resolveRequestBodySchema(schema.RequestBody, operation) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrRequestBody, err) + } + + err = r.resolveResponsesSchema(schema.Responses, operation) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrResponses, err) + } + + err = r.resolveParameterSchema(pathParamsType, schema.PathParams, operation) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrPathParams, err) + } + + err = r.resolveParameterSchema(queryParamType, schema.Querystring, operation) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrPathParams, err) + } + + err = r.resolveParameterSchema(headerParamType, schema.Headers, operation) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrPathParams, err) + } + + err = r.resolveParameterSchema(cookieParamType, schema.Cookies, operation) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrPathParams, err) + } + + return r.AddRawRoute(method, path, handler, Operation{operation}) +} + +func (r Router) getSchemaFromInterface(v interface{}, allowAdditionalProperties bool) (*openapi3.Schema, error) { + if v == nil { + return &openapi3.Schema{}, nil + } + + reflector := &jsonschema.Reflector{ + DoNotReference: true, + AllowAdditionalProperties: allowAdditionalProperties, + } + + jsonSchema := reflector.Reflect(v) + jsonschema.Version = "" + // Empty definitions. Definitions are not valid in openapi3, which use components. + // In the future, we could add an option to fill the components in openapi spec. + jsonSchema.Definitions = nil + + data, err := jsonSchema.MarshalJSON() + if err != nil { + return nil, err + } + + schema := openapi3.NewSchema() + err = schema.UnmarshalJSON(data) + if err != nil { + return nil, err + } + + return schema, nil +} + +func (r Router) resolveRequestBodySchema(bodySchema *SchemaValue, operation *openapi3.Operation) error { + if bodySchema == nil { + return nil + } + requestBodySchema, err := r.getSchemaFromInterface(bodySchema.Content, bodySchema.AllowAdditionalProperties) + if err != nil { + return err + } + + requestBody := openapi3.NewRequestBody() + switch bodySchema.ContentType { + case "multipart/form-data": + requestBody = requestBody.WithFormDataSchema(requestBodySchema) + case "application/json", "": + requestBody = requestBody.WithJSONSchema(requestBodySchema) + default: + return fmt.Errorf("invalid content-type in request body") + } + + if bodySchema.Description != "" { + requestBody.WithDescription(bodySchema.Description) + } + + operation.RequestBody = &openapi3.RequestBodyRef{ + Value: requestBody, + } + return nil +} + +func (r Router) resolveResponsesSchema(responses map[int]SchemaValue, operation *openapi3.Operation) error { + if responses == nil { + operation.Responses = openapi3.NewResponses() + } + for statusCode, v := range responses { + response := openapi3.NewResponse() + + responseSchema, err := r.getSchemaFromInterface(v.Content, v.AllowAdditionalProperties) + if err != nil { + return err + } + + response = response.WithDescription(v.Description) + response = response.WithJSONSchema(responseSchema) + + operation.AddResponse(statusCode, response) + } + + return nil +} + +func (r Router) resolveParameterSchema(paramType string, paramConfig map[string]SchemaValue, operation *openapi3.Operation) error { + var keys = make([]string, 0, len(paramConfig)) + for k := range paramConfig { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, key := range keys { + v := paramConfig[key] + var param *openapi3.Parameter + switch paramType { + case pathParamsType: + param = openapi3.NewPathParameter(key) + case queryParamType: + param = openapi3.NewQueryParameter(key) + case headerParamType: + param = openapi3.NewHeaderParameter(key) + case cookieParamType: + param = openapi3.NewCookieParameter(key) + default: + return fmt.Errorf("invalid param type") + } + + schema := openapi3.NewSchema() + if v.Content != nil { + var err error + schema, err = r.getSchemaFromInterface(v.Content, v.AllowAdditionalProperties) + if err != nil { + return err + } + } + param = param.WithSchema(schema) + + if v.Description != "" { + param = param.WithDescription(v.Description) + } + + operation.AddParameter(param) + } + + return nil +} diff --git a/route_test.go b/route_test.go new file mode 100644 index 0000000..7b4a031 --- /dev/null +++ b/route_test.go @@ -0,0 +1,723 @@ +package swagger + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/gorilla/mux" + "github.com/stretchr/testify/require" +) + +func TestAddRoute(t *testing.T) { + type User struct { + Name string `json:"name" jsonschema:"title=The user name,required" jsonschema_extras:"example=Jane"` + PhoneNumber int `json:"phone" jsonschema:"title=mobile number of user"` + Groups []string `json:"groups,omitempty" jsonschema:"title=groups of the user,default=users"` + Address string `json:"address" jsonschema:"title=user address"` + } + type Users []User + type errorResponse struct { + Message string `json:"message"` + } + + type Employees struct { + OrganizationName string `json:"organization_name"` + Users Users `json:"users" jsonschema:"selected users"` + } + type FormData struct { + ID string `json:"id,omitempty"` + Address struct { + Street string `json:"street,omitempty"` + City string `json:"city,omitempty"` + } `json:"address,omitempty"` + ProfileImage string `json:"profileImage,omitempty" jsonschema_extras:"format=binary"` + } + + okHandler := func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("OK")) + } + + tests := []struct { + name string + routes func(t *testing.T, router *Router) + fixturesPath string + testPath string + testMethod string + }{ + { + name: "no routes", + routes: func(t *testing.T, router *Router) {}, + fixturesPath: "testdata/empty.json", + }, + { + name: "empty route schema", + routes: func(t *testing.T, router *Router) { + _, err := router.AddRoute(http.MethodPost, "/", okHandler, Schema{}) + require.NoError(t, err) + }, + testPath: "/", + testMethod: http.MethodPost, + fixturesPath: "testdata/empty-route-schema.json", + }, + { + name: "multiple real routes", + routes: func(t *testing.T, router *Router) { + _, err := router.AddRoute(http.MethodPost, "/users", okHandler, Schema{ + RequestBody: &SchemaValue{ + Content: User{}, + }, + Responses: map[int]SchemaValue{ + 201: { + Content: "", + }, + 401: { + Content: &errorResponse{}, + Description: "invalid request", + }, + }, + }) + require.NoError(t, err) + + _, err = router.AddRoute(http.MethodGet, "/users", okHandler, Schema{ + Responses: map[int]SchemaValue{ + 200: { + Content: &Users{}, + }, + }, + }) + require.NoError(t, err) + + _, err = router.AddRoute(http.MethodGet, "/employees", okHandler, Schema{ + Responses: map[int]SchemaValue{ + 200: { + Content: &Employees{}, + }, + }, + }) + require.NoError(t, err) + }, + testPath: "/users", + fixturesPath: "testdata/users_employees.json", + }, + { + name: "multipart request body", + routes: func(t *testing.T, router *Router) { + _, err := router.AddRoute(http.MethodPost, "/files", okHandler, Schema{ + RequestBody: &SchemaValue{ + Content: &FormData{}, + Description: "upload file", + ContentType: "multipart/form-data", + AllowAdditionalProperties: true, + }, + Responses: map[int]SchemaValue{ + 200: {Content: ""}, + }, + }) + require.NoError(t, err) + }, + testPath: "/files", + testMethod: http.MethodPost, + fixturesPath: "testdata/multipart-requestbody.json", + }, + { + name: "schema with params", + routes: func(t *testing.T, router *Router) { + var number = 0 + _, err := router.AddRoute(http.MethodGet, "/users/{userId}", okHandler, Schema{ + PathParams: map[string]SchemaValue{ + "userId": { + Content: number, + Description: "userId is a number above 0", + }, + }, + }) + require.NoError(t, err) + + _, err = router.AddRoute(http.MethodGet, "/cars/{carId}/drivers/{driverId}", okHandler, Schema{ + PathParams: map[string]SchemaValue{ + "carId": { + Content: "", + }, + "driverId": { + Content: "", + }, + }, + }) + require.NoError(t, err) + }, + testPath: "/users/12", + fixturesPath: "testdata/params.json", + }, + { + name: "schema with querystring", + routes: func(t *testing.T, router *Router) { + _, err := router.AddRoute(http.MethodGet, "/projects", okHandler, Schema{ + Querystring: map[string]SchemaValue{ + "projectId": { + Content: "", + Description: "projectId is the project id", + }, + }, + }) + require.NoError(t, err) + }, + testPath: "/projects", + fixturesPath: "testdata/query.json", + }, + { + name: "schema with headers", + routes: func(t *testing.T, router *Router) { + _, err := router.AddRoute(http.MethodGet, "/projects", okHandler, Schema{ + Headers: map[string]SchemaValue{ + "foo": { + Content: "", + Description: "foo description", + }, + "bar": { + Content: "", + }, + }, + }) + require.NoError(t, err) + }, + testPath: "/projects", + fixturesPath: "testdata/headers.json", + }, + { + name: "schema with cookies", + routes: func(t *testing.T, router *Router) { + _, err := router.AddRoute(http.MethodGet, "/projects", okHandler, Schema{ + Cookies: map[string]SchemaValue{ + "debug": { + Content: 0, + Description: "boolean. Set 0 to disable and 1 to enable", + }, + "csrftoken": { + Content: "", + }, + }, + }) + require.NoError(t, err) + }, + testPath: "/projects", + fixturesPath: "testdata/cookies.json", + }, + { + name: "schema defined without content", + routes: func(t *testing.T, router *Router) { + _, err := router.AddRoute(http.MethodPost, "/{id}", okHandler, Schema{ + RequestBody: &SchemaValue{ + Description: "request body without schema", + }, + Responses: map[int]SchemaValue{ + 204: {}, + }, + PathParams: map[string]SchemaValue{ + "id": {}, + }, + Querystring: map[string]SchemaValue{ + "q": {}, + }, + Headers: map[string]SchemaValue{ + "key": {}, + }, + Cookies: map[string]SchemaValue{ + "cookie1": {}, + }, + }) + require.NoError(t, err) + }, + testPath: "/foobar", + testMethod: http.MethodPost, + fixturesPath: "testdata/schema-no-content.json", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + context := context.Background() + r := mux.NewRouter() + + router, err := New(r, Options{ + Context: context, + Openapi: getBaseSwagger(t), + }) + require.NoError(t, err) + require.NotNil(t, router) + + // Add routes to test + test.routes(t, router) + + err = router.GenerateAndExposeSwagger() + require.NoError(t, err) + + if test.testPath != "" { + if test.testMethod == "" { + test.testMethod = http.MethodGet + } + + w := httptest.NewRecorder() + req := httptest.NewRequest(test.testMethod, test.testPath, nil) + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + body := readBody(t, w.Result().Body) + require.Equal(t, "OK", body) + } + + t.Run("and generate swagger documentation in json", func(t *testing.T) { + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, JSONDocumentationPath, nil) + + r.ServeHTTP(w, req) + + require.Equal(t, http.StatusOK, w.Result().StatusCode) + + body := readBody(t, w.Result().Body) + actual, err := ioutil.ReadFile(test.fixturesPath) + require.NoError(t, err) + require.JSONEq(t, string(actual), body, "actual json data: ", string(actual)) + }) + }) + } + + t.Run("", func(t *testing.T) { + + }) +} + +func TestResolveRequestBodySchema(t *testing.T) { + type TestStruct struct { + ID string `json:"id,omitempty"` + } + tests := []struct { + name string + bodySchema *SchemaValue + expectedErr error + expectedJSON string + }{ + { + name: "empty body schema", + expectedErr: nil, + expectedJSON: `{"responses": null}`, + }, + { + name: "schema multipart", + expectedErr: nil, + bodySchema: &SchemaValue{ + Content: &TestStruct{}, + ContentType: "multipart/form-data", + }, + expectedJSON: `{ + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "type":"object", + "additionalProperties":false, + "properties": { + "id": {"type":"string"} + } + } + } + } + }, + "responses": null + }`, + }, + { + name: "content-type application/json", + expectedErr: nil, + bodySchema: &SchemaValue{ + Content: &TestStruct{}, + ContentType: "application/json", + }, + expectedJSON: `{ + "requestBody": { + "content": { + "application/json": { + "schema": { + "type":"object", + "additionalProperties":false, + "properties": { + "id": {"type":"string"} + } + } + } + } + }, + "responses": null + }`, + }, + { + name: "no content-type - default to json", + expectedErr: nil, + bodySchema: &SchemaValue{ + Content: &TestStruct{}, + }, + expectedJSON: `{ + "requestBody": { + "content": { + "application/json": { + "schema": { + "type":"object", + "additionalProperties":false, + "properties": { + "id": {"type":"string"} + } + } + } + } + }, + "responses": null + }`, + }, + { + name: "with description", + expectedErr: nil, + bodySchema: &SchemaValue{ + Content: &TestStruct{}, + Description: "my custom description", + }, + expectedJSON: `{ + "requestBody": { + "description": "my custom description", + "content": { + "application/json": { + "schema": { + "type":"object", + "additionalProperties":false, + "properties": { + "id": {"type":"string"} + } + } + } + } + }, + "responses": null + }`, + }, + // FIXME: this test case exhibits a wrong behavior. It should be supported. + { + name: "content type text/plain", + expectedErr: fmt.Errorf("invalid content-type in request body"), + bodySchema: &SchemaValue{ + Content: &TestStruct{}, + ContentType: "text/plain", + }, + }, + // FIXME: this test case exhibits a wrong behavior. It should be supported. + { + name: "generic content type - it represent all types", + expectedErr: fmt.Errorf("invalid content-type in request body"), + bodySchema: &SchemaValue{ + Content: &TestStruct{}, + ContentType: "*/*", + }, + }, + } + + mux := mux.NewRouter() + router, err := New(mux, Options{ + Openapi: getBaseSwagger(t), + }) + require.NoError(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + operation := openapi3.NewOperation() + + err := router.resolveRequestBodySchema(test.bodySchema, operation) + + if err == nil { + data, _ := operation.MarshalJSON() + jsonData := string(data) + require.JSONEq(t, test.expectedJSON, jsonData, "actual json data: ", jsonData) + require.NoError(t, err) + } + require.Equal(t, test.expectedErr, err) + }) + } +} + +func TestResolveResponsesSchema(t *testing.T) { + type TestStruct struct { + Message string `json:"message,omitempty"` + } + tests := []struct { + name string + responsesSchema map[int]SchemaValue + expectedErr error + expectedJSON string + }{ + { + name: "empty responses schema", + expectedErr: nil, + expectedJSON: `{"responses": {"default":{"description":""}}}`, + }, + { + name: "with 1 status code", + responsesSchema: map[int]SchemaValue{ + 200: { + Content: &TestStruct{}, + }, + }, + expectedErr: nil, + expectedJSON: `{ + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + } + } + }`, + }, + { + name: "with more status codes", + responsesSchema: map[int]SchemaValue{ + 200: { + Content: &TestStruct{}, + }, + 400: { + Content: "", + }, + }, + expectedErr: nil, + expectedJSON: `{ + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "additionalProperties": false + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }`, + }, + { + name: "with custom description", + responsesSchema: map[int]SchemaValue{ + 400: { + Content: "", + Description: "a description", + }, + }, + expectedErr: nil, + expectedJSON: `{ + "responses": { + "400": { + "description": "a description", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + }`, + }, + } + + mux := mux.NewRouter() + router, err := New(mux, Options{ + Openapi: getBaseSwagger(t), + }) + require.NoError(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + operation := openapi3.NewOperation() + operation.Responses = make(openapi3.Responses) + + err := router.resolveResponsesSchema(test.responsesSchema, operation) + + if err == nil { + data, _ := operation.MarshalJSON() + jsonData := string(data) + require.JSONEq(t, test.expectedJSON, jsonData, "actual json data: ", jsonData) + require.NoError(t, err) + } + require.Equal(t, test.expectedErr, err) + }) + } +} + +func TestResolveParametersSchema(t *testing.T) { + type TestStruct struct { + Message string `json:"message,omitempty"` + } + tests := []struct { + name string + paramsSchema map[string]SchemaValue + paramType string + expectedErr error + expectedJSON string + }{ + { + name: "empty responses schema", + paramType: pathParamsType, + expectedJSON: `{"responses": null}`, + }, + { + name: "path param", + paramType: pathParamsType, + paramsSchema: map[string]SchemaValue{ + "foo": { + Content: "", + }, + }, + expectedJSON: `{ + "parameters": [{ + "in": "path", + "name": "foo", + "required": true, + "schema": { + "type": "string" + } + }], + "responses": null + }`, + }, + { + name: "query param", + paramType: queryParamType, + paramsSchema: map[string]SchemaValue{ + "foo": { + Content: "", + }, + }, + expectedJSON: `{ + "parameters": [{ + "in": "query", + "name": "foo", + "schema": { + "type": "string" + } + }], + "responses": null + }`, + }, + { + name: "cookie param", + paramType: cookieParamType, + paramsSchema: map[string]SchemaValue{ + "foo": { + Content: "", + }, + }, + expectedJSON: `{ + "parameters": [{ + "in": "cookie", + "name": "foo", + "schema": { + "type": "string" + } + }], + "responses": null + }`, + }, + { + name: "header param", + paramType: headerParamType, + paramsSchema: map[string]SchemaValue{ + "foo": { + Content: "", + }, + }, + expectedJSON: `{ + "parameters": [{ + "in": "header", + "name": "foo", + "schema": { + "type": "string" + } + }], + "responses": null + }`, + }, + { + name: "wrong param type", + paramType: "wrong", + paramsSchema: map[string]SchemaValue{ + "foo": { + Content: "", + }, + }, + expectedErr: fmt.Errorf("invalid param type"), + }, + } + + mux := mux.NewRouter() + router, err := New(mux, Options{ + Openapi: getBaseSwagger(t), + }) + require.NoError(t, err) + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + operation := openapi3.NewOperation() + + err := router.resolveParameterSchema(test.paramType, test.paramsSchema, operation) + + if err == nil { + data, _ := operation.MarshalJSON() + jsonData := string(data) + require.JSONEq(t, test.expectedJSON, jsonData, "actual json data: ", jsonData) + require.NoError(t, err) + } + require.Equal(t, test.expectedErr, err) + }) + } +} + +func getBaseSwagger(t *testing.T) *openapi3.Swagger { + t.Helper() + + return &openapi3.Swagger{ + Info: &openapi3.Info{ + Title: swaggerOpenapiTitle, + Version: swaggerOpenapiVersion, + }, + } +} diff --git a/testdata/cookies.json b/testdata/cookies.json new file mode 100644 index 0000000..47bb859 --- /dev/null +++ b/testdata/cookies.json @@ -0,0 +1,36 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/projects": { + "get": { + "parameters": [ + { + "in": "cookie", + "name": "csrftoken", + "schema": { + "type": "string" + } + }, + { + "description": "boolean. Set 0 to disable and 1 to enable", + "in": "cookie", + "name": "debug", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + } + } +} diff --git a/testdata/empty-route-schema.json b/testdata/empty-route-schema.json new file mode 100644 index 0000000..2ad2a57 --- /dev/null +++ b/testdata/empty-route-schema.json @@ -0,0 +1,19 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/": { + "post": { + "responses": { + "default": { + "description": "" + } + } + } + } + } +} diff --git a/testdata/empty.json b/testdata/empty.json new file mode 100644 index 0000000..6b479ba --- /dev/null +++ b/testdata/empty.json @@ -0,0 +1,9 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": {} +} diff --git a/testdata/headers.json b/testdata/headers.json new file mode 100644 index 0000000..7904e24 --- /dev/null +++ b/testdata/headers.json @@ -0,0 +1,36 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/projects": { + "get": { + "parameters": [ + { + "in": "header", + "name": "bar", + "schema": { + "type": "string" + } + }, + { + "description": "foo description", + "in": "header", + "name": "foo", + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + } + } +} diff --git a/testdata/multipart-requestbody.json b/testdata/multipart-requestbody.json new file mode 100644 index 0000000..b1524d0 --- /dev/null +++ b/testdata/multipart-requestbody.json @@ -0,0 +1,58 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/files": { + "post": { + "requestBody": { + "description": "upload file", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "additionalProperties": true, + "properties": { + "id": { + "type": "string" + }, + "address": { + "additionalProperties": true, + "type": "object", + "properties": { + "street": { + "type": "string" + }, + "city": { + "type": "string" + } + } + }, + "profileImage": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } +} diff --git a/testdata/params.json b/testdata/params.json new file mode 100644 index 0000000..5b0efe4 --- /dev/null +++ b/testdata/params.json @@ -0,0 +1,57 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/cars/{carId}/drivers/{driverId}": { + "get": { + "parameters": [ + { + "in": "path", + "name": "carId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "driverId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + }, + "/users/{userId}": { + "get": { + "parameters": [ + { + "description": "userId is a number above 0", + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "integer" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + } + } +} diff --git a/testdata/query.json b/testdata/query.json new file mode 100644 index 0000000..223167c --- /dev/null +++ b/testdata/query.json @@ -0,0 +1,29 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/projects": { + "get": { + "parameters": [ + { + "description": "projectId is the project id", + "in": "query", + "name": "projectId", + "schema": { + "type": "string" + } + } + ], + "responses": { + "default": { + "description": "" + } + } + } + } + } +} diff --git a/testdata/schema-no-content.json b/testdata/schema-no-content.json new file mode 100644 index 0000000..9835a68 --- /dev/null +++ b/testdata/schema-no-content.json @@ -0,0 +1,55 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/{id}": { + "post": { + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": {} + }, + { + "in": "query", + "name": "q", + "schema": {} + }, + { + "in": "header", + "name": "key", + "schema": {} + }, + { + "in": "cookie", + "name": "cookie1", + "schema": {} + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "request body without schema" + }, + "responses": { + "204": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "" + } + } + } + } + } +} diff --git a/testdata/users_employees.json b/testdata/users_employees.json new file mode 100644 index 0000000..61e58ee --- /dev/null +++ b/testdata/users_employees.json @@ -0,0 +1,196 @@ +{ + "components": {}, + "info": { + "title": "test swagger title", + "version": "test swagger version" + }, + "openapi": "3.0.0", + "paths": { + "/employees": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "organization_name": { + "type": "string" + }, + "users": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "title": "user address", + "type": "string" + }, + "groups": { + "default": [ + "users" + ], + "items": { + "type": "string" + }, + "title": "groups of the user", + "type": "array" + }, + "name": { + "example": "Jane", + "title": "The user name", + "type": "string" + }, + "phone": { + "title": "mobile number of user", + "type": "integer" + } + }, + "required": [ + "name", + "phone", + "address" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "organization_name", + "users" + ], + "type": "object" + } + } + }, + "description": "" + } + } + } + }, + "/users": { + "get": { + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "additionalProperties": false, + "properties": { + "address": { + "title": "user address", + "type": "string" + }, + "groups": { + "default": [ + "users" + ], + "items": { + "type": "string" + }, + "title": "groups of the user", + "type": "array" + }, + "name": { + "example": "Jane", + "title": "The user name", + "type": "string" + }, + "phone": { + "title": "mobile number of user", + "type": "integer" + } + }, + "required": [ + "name", + "phone", + "address" + ], + "type": "object" + }, + "type": "array" + } + } + }, + "description": "" + } + } + }, + "post": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "address": { + "title": "user address", + "type": "string" + }, + "groups": { + "default": [ + "users" + ], + "items": { + "type": "string" + }, + "title": "groups of the user", + "type": "array" + }, + "name": { + "example": "Jane", + "title": "The user name", + "type": "string" + }, + "phone": { + "title": "mobile number of user", + "type": "integer" + } + }, + "required": [ + "name", + "phone", + "address" + ], + "type": "object" + } + } + } + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + } + }, + "description": "invalid request" + } + } + } + } + } +}