diff --git a/README.md b/README.md index fe8d5e1..b442972 100644 --- a/README.md +++ b/README.md @@ -452,6 +452,10 @@ func main() { ![Documentation Page](./_examples/basic/screen.png) +## Additional Integrations + +* [`github.com/gorilla/mux`](https://github.com/gorilla/mux), see [example](./gorillamux/example_openapi_collector_test.go). + ## Performance Optimization If top performance is critical for the service or particular endpoints, you can trade diff --git a/_examples/.golangci.yml b/_examples/.golangci.yml index 97710e1..5e23975 100644 --- a/_examples/.golangci.yml +++ b/_examples/.golangci.yml @@ -20,6 +20,11 @@ linters-settings: linters: enable-all: true disable: + - funlen + - exhaustruct + - musttag + - nonamedreturns + - goerr113 - lll - maligned - gochecknoglobals diff --git a/_examples/advanced-generic/_testdata/openapi.json b/_examples/advanced-generic/_testdata/openapi.json index d9bb1f0..7e1081d 100644 --- a/_examples/advanced-generic/_testdata/openapi.json +++ b/_examples/advanced-generic/_testdata/openapi.json @@ -158,12 +158,7 @@ "schema":{"type":"boolean","description":"Invokes internal decoding of compressed data."} } ], - "responses":{ - "200":{ - "description":"OK","headers":{"X-Header":{"style":"simple","schema":{"type":"string"}}}, - "content":{"application/json":{}} - } - }, + "responses":{"200":{"description":"OK","headers":{"X-Header":{"style":"simple","schema":{"type":"string"}}}}}, "x-forbid-unknown-query":true } }, @@ -470,13 +465,9 @@ "style":"simple","description":"Receives req value of X-Foo reduced by 30.", "schema":{"type":"integer","description":"Receives req value of X-Foo reduced by 30."} } - }, - "content":{"application/json":{}} + } }, - "500":{ - "description":"Internal Server Error", - "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}} - } + "500":{"description":"Internal Server Error"} } } }, diff --git a/_examples/advanced-generic/gzip_pass_through.go b/_examples/advanced-generic/gzip_pass_through.go index d2a5c42..f09d3b7 100644 --- a/_examples/advanced-generic/gzip_pass_through.go +++ b/_examples/advanced-generic/gzip_pass_through.go @@ -62,6 +62,7 @@ func directGzip() usecase.Interactor { // Precompute compressed data container. Generally this step should be owned by a caching storage of data. dataFromCache := gzipPassThroughContainer{} + err := dataFromCache.PackJSON(rawData) if err != nil { panic(err) diff --git a/_examples/advanced-generic/gzip_pass_through_test.go b/_examples/advanced-generic/gzip_pass_through_test.go index 7d37fb9..c50bb04 100644 --- a/_examples/advanced-generic/gzip_pass_through_test.go +++ b/_examples/advanced-generic/gzip_pass_through_test.go @@ -20,6 +20,7 @@ func Test_directGzip(t *testing.T) { require.NoError(t, err) req.Header.Set("Accept-Encoding", "gzip") + rw := httptest.NewRecorder() r.ServeHTTP(rw, req) @@ -37,6 +38,7 @@ func Test_noDirectGzip(t *testing.T) { require.NoError(t, err) req.Header.Set("Accept-Encoding", "gzip") + rw := httptest.NewRecorder() r.ServeHTTP(rw, req) diff --git a/_examples/advanced-generic/json_slice_body.go b/_examples/advanced-generic/json_slice_body.go index aa09884..13be45c 100644 --- a/_examples/advanced-generic/json_slice_body.go +++ b/_examples/advanced-generic/json_slice_body.go @@ -9,6 +9,7 @@ import ( "github.com/swaggest/usecase" ) +// JSONSlicePayload is an example non-scalar type without `json` tags. type JSONSlicePayload []int type jsonSliceReq struct { diff --git a/_examples/advanced-generic/output_writer.go b/_examples/advanced-generic/output_writer.go index fb832f3..61f88cc 100644 --- a/_examples/advanced-generic/output_writer.go +++ b/_examples/advanced-generic/output_writer.go @@ -34,6 +34,7 @@ func outputCSVWriter() usecase.Interactor { out.ContentHash = contentHash c := csv.NewWriter(out) + return c.WriteAll([][]string{{"abc", "def", "hij"}, {"klm", "nop", "qrs"}}) }) diff --git a/_examples/advanced-generic/router.go b/_examples/advanced-generic/router.go index 7ec26ff..ba2e29e 100644 --- a/_examples/advanced-generic/router.go +++ b/_examples/advanced-generic/router.go @@ -14,6 +14,7 @@ import ( "github.com/google/uuid" "github.com/rs/cors" "github.com/swaggest/jsonschema-go" + oapi "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/rest/nethttp" @@ -27,12 +28,17 @@ import ( func NewRouter() http.Handler { s := web.DefaultService() - s.OpenAPI.Info.Title = "Advanced Example" - s.OpenAPI.Info.WithDescription("This app showcases a variety of features.") - s.OpenAPI.Info.Version = "v1.2.3" - s.OpenAPICollector.Reflector().InterceptDefName(func(t reflect.Type, defaultDefName string) string { - return strings.ReplaceAll(defaultDefName, "Generic", "") - }) + s.OpenAPISchema().SetTitle("Advanced Example") + s.OpenAPISchema().SetDescription("This app showcases a variety of features.") + s.OpenAPISchema().SetVersion("v1.2.3") + + jsr := s.OpenAPIReflector().JSONSchemaReflector() + + jsr.DefaultOptions = append(jsr.DefaultOptions, jsonschema.InterceptDefName( + func(t reflect.Type, defaultDefName string) string { + return strings.ReplaceAll(defaultDefName, "Generic", "") + }, + )) // Usecase middlewares can be added to web.Service or chirouter.Wrapper. s.Wrap(nethttp.UseCaseMiddlewares(usecase.MiddlewareFunc(func(next usecase.Interactor) usecase.Interactor { @@ -47,7 +53,7 @@ func NewRouter() http.Handler { return usecase.Interact(func(ctx context.Context, input, output interface{}) error { err := next.Interact(ctx, input, output) - if err != nil && err != rest.HTTPCodeAsError(http.StatusNotModified) { + if err != nil && !errors.Is(err, rest.HTTPCodeAsError(http.StatusNotModified)) { log.Printf("usecase %s request (%+v) failed: %v\n", name, input, err) } @@ -56,11 +62,11 @@ func NewRouter() http.Handler { }))) // An example of global schema override to disable additionalProperties for all object schemas. - s.OpenAPICollector.Reflector().DefaultOptions = append(s.OpenAPICollector.Reflector().DefaultOptions, + jsr.DefaultOptions = append(jsr.DefaultOptions, jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (stop bool, err error) { // Allow unknown request headers and skip response. - if oc, ok := openapi3.OperationCtx(params.Context); !params.Processed || !ok || - oc.ProcessingResponse || oc.ProcessingIn == string(rest.ParamInHeader) { + if oc, ok := oapi.OperationCtx(params.Context); !params.Processed || !ok || + oc.IsProcessingResponse() || oc.ProcessingIn() == oapi.InHeader { return false, nil } @@ -79,7 +85,7 @@ func NewRouter() http.Handler { uuidDef.AddType(jsonschema.String) uuidDef.WithFormat("uuid") uuidDef.WithExamples("248df4b7-aa70-47b8-a036-33ac447e668d") - s.OpenAPICollector.Reflector().AddTypeMapping(uuid.UUID{}, uuidDef) + jsr.AddTypeMapping(uuid.UUID{}, uuidDef) // When multiple structures can be returned with the same HTTP status code, it is possible to combine them into a // single schema with such configuration. @@ -130,7 +136,14 @@ func NewRouter() http.Handler { ) // Annotations can be used to alter documentation of operation identified by method and path. - s.OpenAPICollector.Annotate(http.MethodPost, "/validation", func(op *openapi3.Operation) error { + s.OpenAPICollector.AnnotateOperation(http.MethodPost, "/validation", func(oc oapi.OperationContext) error { + o3, ok := oc.(openapi3.OperationExposer) + if !ok { + return nil + } + + op := o3.Operation() + if op.Description != nil { *op.Description = *op.Description + " Custom annotation." } @@ -153,8 +166,8 @@ func NewRouter() http.Handler { s.Post("/json-map-body", jsonMapBody(), // Annotate operation to add post-processing if necessary. - nethttp.AnnotateOperation(func(op *openapi3.Operation) error { - op.WithDescription("Request with JSON object (map) body.") + nethttp.AnnotateOpenAPIOperation(func(oc oapi.OperationContext) error { + oc.SetDescription("Request with JSON object (map) body.") return nil })) @@ -181,7 +194,7 @@ func NewRouter() http.Handler { s.Post("/no-validation", noValidation()) // Type mapping is necessary to pass interface as structure into documentation. - s.OpenAPICollector.Reflector().AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct)) + jsr.AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct)) s.Get("/gzip-pass-through", directGzip()) s.Head("/gzip-pass-through", directGzip()) @@ -197,6 +210,8 @@ func NewRouter() http.Handler { if c, err := r.Cookie("sessid"); err == nil { r = r.WithContext(context.WithValue(r.Context(), "sessionID", c.Value)) } + + handler.ServeHTTP(w, r) }) } diff --git a/_examples/advanced/_testdata/openapi.json b/_examples/advanced/_testdata/openapi.json index e6d9712..d46e050 100644 --- a/_examples/advanced/_testdata/openapi.json +++ b/_examples/advanced/_testdata/openapi.json @@ -140,12 +140,7 @@ "schema":{"type":"boolean","description":"Invokes internal decoding of compressed data."} } ], - "responses":{ - "200":{ - "description":"OK","headers":{"X-Header":{"style":"simple","schema":{"type":"string"}}}, - "content":{"application/dummy+json":{}} - } - }, + "responses":{"200":{"description":"OK","headers":{"X-Header":{"style":"simple","schema":{"type":"string"}}}}}, "x-forbid-unknown-query":true } }, @@ -358,8 +353,7 @@ "style":"simple","description":"Sample response header.", "schema":{"type":"string","description":"Sample response header."} } - }, - "content":{"application/dummy+json":{}} + } } } } diff --git a/_examples/advanced/gzip_pass_through.go b/_examples/advanced/gzip_pass_through.go index 4b597ef..8611277 100644 --- a/_examples/advanced/gzip_pass_through.go +++ b/_examples/advanced/gzip_pass_through.go @@ -60,6 +60,7 @@ func directGzip() usecase.Interactor { // Precompute compressed data container. Generally this step should be owned by a caching storage of data. dataFromCache := gzipPassThroughContainer{} + err := dataFromCache.PackJSON(rawData) if err != nil { panic(err) diff --git a/_examples/advanced/gzip_pass_through_test.go b/_examples/advanced/gzip_pass_through_test.go index f7b4c9a..23bf6b6 100644 --- a/_examples/advanced/gzip_pass_through_test.go +++ b/_examples/advanced/gzip_pass_through_test.go @@ -18,6 +18,7 @@ func Test_directGzip(t *testing.T) { require.NoError(t, err) req.Header.Set("Accept-Encoding", "gzip") + rw := httptest.NewRecorder() r.ServeHTTP(rw, req) @@ -35,6 +36,7 @@ func Test_noDirectGzip(t *testing.T) { require.NoError(t, err) req.Header.Set("Accept-Encoding", "gzip") + rw := httptest.NewRecorder() r.ServeHTTP(rw, req) diff --git a/_examples/advanced/output_writer.go b/_examples/advanced/output_writer.go index 14eb1f2..6481d14 100644 --- a/_examples/advanced/output_writer.go +++ b/_examples/advanced/output_writer.go @@ -32,6 +32,7 @@ func outputCSVWriter() usecase.Interactor { out.Header = "abc" c := csv.NewWriter(out) + return c.WriteAll([][]string{{"abc", "def", "hij"}, {"klm", "nop", "qrs"}}) }) diff --git a/_examples/advanced/router.go b/_examples/advanced/router.go index fd5335b..8584c47 100644 --- a/_examples/advanced/router.go +++ b/_examples/advanced/router.go @@ -8,6 +8,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/swaggest/jsonschema-go" + oapi "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/rest/nethttp" @@ -23,16 +24,17 @@ func NewRouter() http.Handler { s := web.DefaultService() - s.OpenAPI.Info.Title = "Advanced Example" - s.OpenAPI.Info.WithDescription("This app showcases a variety of features.") - s.OpenAPI.Info.Version = "v1.2.3" + s.OpenAPISchema().SetTitle("Advanced Example") + s.OpenAPISchema().SetDescription("This app showcases a variety of features.") + s.OpenAPISchema().SetVersion("v1.2.3") // An example of global schema override to disable additionalProperties for all object schemas. - s.OpenAPICollector.Reflector().DefaultOptions = append(s.OpenAPICollector.Reflector().DefaultOptions, + jsr := s.OpenAPIReflector().JSONSchemaReflector() + jsr.DefaultOptions = append(jsr.DefaultOptions, jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (stop bool, err error) { // Allow unknown request headers and skip response. - if oc, ok := openapi3.OperationCtx(params.Context); !params.Processed || !ok || - oc.ProcessingResponse || oc.ProcessingIn == string(rest.ParamInHeader) { + if oc, ok := oapi.OperationCtx(params.Context); !params.Processed || !ok || + oc.IsProcessingResponse() || oc.ProcessingIn() == oapi.InHeader { return false, nil } @@ -51,7 +53,7 @@ func NewRouter() http.Handler { uuidDef.AddType(jsonschema.String) uuidDef.WithFormat("uuid") uuidDef.WithExamples("248df4b7-aa70-47b8-a036-33ac447e668d") - s.OpenAPICollector.Reflector().AddTypeMapping(uuid.UUID{}, uuidDef) + jsr.AddTypeMapping(uuid.UUID{}, uuidDef) s.OpenAPICollector.CombineErrors = "anyOf" @@ -88,7 +90,14 @@ func NewRouter() http.Handler { ) // Annotations can be used to alter documentation of operation identified by method and path. - s.OpenAPICollector.Annotate(http.MethodPost, "/validation", func(op *openapi3.Operation) error { + s.OpenAPICollector.AnnotateOperation(http.MethodPost, "/validation", func(oc oapi.OperationContext) error { + o3, ok := oc.(openapi3.OperationExposer) + if !ok { + return nil + } + + op := o3.Operation() + if op.Description != nil { *op.Description = *op.Description + " Custom annotation." } @@ -108,8 +117,8 @@ func NewRouter() http.Handler { s.Post("/json-map-body", jsonMapBody(), // Annotate operation to add post-processing if necessary. - nethttp.AnnotateOperation(func(op *openapi3.Operation) error { - op.WithDescription("Request with JSON object (map) body.") + nethttp.AnnotateOpenAPIOperation(func(oc oapi.OperationContext) error { + oc.SetDescription("Request with JSON object (map) body.") return nil })) @@ -134,7 +143,7 @@ func NewRouter() http.Handler { s.Post("/no-validation", noValidation()) // Type mapping is necessary to pass interface as structure into documentation. - s.OpenAPICollector.Reflector().AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct)) + jsr.AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct)) s.Get("/gzip-pass-through", directGzip()) s.Head("/gzip-pass-through", directGzip()) @@ -148,6 +157,8 @@ func NewRouter() http.Handler { if c, err := r.Cookie("sessid"); err == nil { r = r.WithContext(context.WithValue(r.Context(), "sessionID", c.Value)) } + + handler.ServeHTTP(w, r) }) } diff --git a/_examples/basic/main.go b/_examples/basic/main.go index 3f209f1..4136039 100644 --- a/_examples/basic/main.go +++ b/_examples/basic/main.go @@ -19,9 +19,9 @@ func main() { s := web.DefaultService() // Init API documentation schema. - s.OpenAPI.Info.Title = "Basic Example" - s.OpenAPI.Info.WithDescription("This app showcases a trivial REST API.") - s.OpenAPI.Info.Version = "v1.2.3" + s.OpenAPISchema().SetTitle("Basic Example") + s.OpenAPISchema().SetDescription("This app showcases a trivial REST API.") + s.OpenAPISchema().SetVersion("v1.2.3") // Setup middlewares. s.Wrap( diff --git a/_examples/generic/main.go b/_examples/generic/main.go index eb839f6..ab97a90 100644 --- a/_examples/generic/main.go +++ b/_examples/generic/main.go @@ -22,9 +22,9 @@ func main() { s := web.DefaultService() // Init API documentation schema. - s.OpenAPI.Info.Title = "Basic Example" - s.OpenAPI.Info.WithDescription("This app showcases a trivial REST API.") - s.OpenAPI.Info.Version = "v1.2.3" + s.OpenAPISchema().SetTitle("Basic Example") + s.OpenAPISchema().SetDescription("This app showcases a trivial REST API.") + s.OpenAPISchema().SetVersion("v1.2.3") // Setup middlewares. s.Wrap( diff --git a/_examples/go.mod b/_examples/go.mod index 71723ae..71b61b3 100644 --- a/_examples/go.mod +++ b/_examples/go.mod @@ -14,11 +14,11 @@ require ( github.com/rs/cors v1.9.0 github.com/stretchr/testify v1.8.4 github.com/swaggest/assertjson v1.9.0 - github.com/swaggest/jsonschema-go v0.3.52 - github.com/swaggest/openapi-go v0.2.33 - github.com/swaggest/swgui v1.6.4 + github.com/swaggest/jsonschema-go v0.3.55 + github.com/swaggest/openapi-go v0.2.37 + github.com/swaggest/swgui v1.7.2 github.com/swaggest/usecase v1.2.1 - github.com/valyala/fasthttp v1.46.0 + github.com/valyala/fasthttp v1.48.0 ) require ( @@ -33,7 +33,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/iancoleman/orderedmap v0.3.0 // indirect - github.com/klauspost/compress v1.16.5 // indirect + github.com/klauspost/compress v1.16.7 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect @@ -41,7 +41,7 @@ require ( github.com/swaggest/refl v1.2.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/vearutop/dynhist-go v1.1.0 // indirect - github.com/vearutop/statigz v1.3.0 // indirect + github.com/vearutop/statigz v1.4.0 // indirect github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff // indirect github.com/yudai/gojsondiff v1.0.0 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect diff --git a/_examples/go.sum b/_examples/go.sum index c8044d9..3d6f109 100644 --- a/_examples/go.sum +++ b/_examples/go.sum @@ -27,8 +27,8 @@ github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJ github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= -github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= -github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= @@ -55,24 +55,24 @@ github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7 github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= -github.com/swaggest/jsonschema-go v0.3.52 h1:cFk0Ma34MuZvA+JJ8S5WuQMedwxzUa+9+qrGwtoE39U= -github.com/swaggest/jsonschema-go v0.3.52/go.mod h1:sRly7iaIIvbheAqsyvnKU3H7ogWbF0wtUBXyGpRXNm4= -github.com/swaggest/openapi-go v0.2.33 h1:5S9XCSuaex9KkLQVNiH0SYukiTEWCa2z66k7QQ+UUiQ= -github.com/swaggest/openapi-go v0.2.33/go.mod h1:h7dk9ApzrU9yJ7O8TSoW+a1PyT7R+bnYHDw/NpexlYA= +github.com/swaggest/jsonschema-go v0.3.55 h1:xbDQaLw9NxxkL3meYnUnX5f6Hhav2wNAEfb/We53CkM= +github.com/swaggest/jsonschema-go v0.3.55/go.mod h1:5WFFGBBte5JAWAV8gDpNRJ/tlQnb1AHDdf/ghgsVUik= +github.com/swaggest/openapi-go v0.2.37 h1:Fz894zoeV5SNo4mm/tpADDwqQAHws6UWsjZbjrvBgw0= +github.com/swaggest/openapi-go v0.2.37/go.mod h1:iDIKsjFeVItoGOtb1jmLf+ULYg2yP0uqbBJ4OzE0nxM= github.com/swaggest/refl v1.2.0 h1:Qqqhfwi7REXF6/4cwJmj3gQMzl0Q0cYquxTYdD0kvi0= github.com/swaggest/refl v1.2.0/go.mod h1:CkC6g7h1PW33KprTuYRSw8UUOslRUt4lF3oe7tTIgNU= -github.com/swaggest/swgui v1.6.4 h1:9G7HTUMOAu/Y9YF2wDvoq9GXFlV4BH5y8BSOl4MWfl8= -github.com/swaggest/swgui v1.6.4/go.mod h1:xsfGb4NtnBspBXKXtlPdVrvoqzCIZ338Aj3tHNikz2Q= +github.com/swaggest/swgui v1.7.2 h1:N5hMPCQ+bIedVJoQDNjFUn8BqtISQDwaqEa76VkvzLs= +github.com/swaggest/swgui v1.7.2/go.mod h1:gGFKvKH+nmlPVXBc5S1/sUThCi2f+cthHaY2MfsWlAM= github.com/swaggest/usecase v1.2.1 h1:XYVdK9tK2KCPglTflUi7aWBrVwIyb58D5mvGWED7pNs= github.com/swaggest/usecase v1.2.1/go.mod h1:5ccwVsLJ9eQpU4m0AGTM444pdqSPQBiocIwMmdRH9lQ= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.46.0 h1:6ZRhrFg8zBXTRYY6vdzbFhqsBd7FVv123pV2m9V87U4= -github.com/valyala/fasthttp v1.46.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc= +github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= github.com/vearutop/dynhist-go v1.1.0 h1:kufNShpt45I97L2eO3bEYTSPKBSued0prheukJUYRok= github.com/vearutop/dynhist-go v1.1.0/go.mod h1:mjWlwr64oGBtouxh8s/9YOXV73MYP0Q9tfKIAp8/xto= -github.com/vearutop/statigz v1.3.0 h1:RoIbvbMilsT8gXtPflWLcVlce89l5qZP9SHlKhXtEsg= -github.com/vearutop/statigz v1.3.0/go.mod h1:jqlOPvLAdiQktMtYAkyguI3Ee0FA26iXKeEx2pS5l88= +github.com/vearutop/statigz v1.4.0 h1:RQL0KG3j/uyA/PFpHeZ/L6l2ta920/MxlOAIGEOuwmU= +github.com/vearutop/statigz v1.4.0/go.mod h1:LYTolBLiz9oJISwiVKnOQoIwhO1LWX1A7OECawGS8XE= github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff h1:7YqG491bE4vstXRz1lD38rbSgbXnirvROz1lZiOnPO8= github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU= github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA= diff --git a/_examples/mount/main.go b/_examples/mount/main.go index ccfc861..c03b56f 100644 --- a/_examples/mount/main.go +++ b/_examples/mount/main.go @@ -37,7 +37,7 @@ func sum() usecase.Interactor { func main() { service := web.DefaultService() - service.OpenAPI.Info.Title = "Security and Mount Example" + service.OpenAPISchema().SetTitle("Security and Mount Example") apiV1 := web.DefaultService() diff --git a/_examples/task-api/internal/infra/nethttp/router.go b/_examples/task-api/internal/infra/nethttp/router.go index 5a7551e..c6dc11a 100644 --- a/_examples/task-api/internal/infra/nethttp/router.go +++ b/_examples/task-api/internal/infra/nethttp/router.go @@ -6,7 +6,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/openapi-go" "github.com/swaggest/rest" "github.com/swaggest/rest/_examples/task-api/internal/infra/schema" "github.com/swaggest/rest/_examples/task-api/internal/infra/service" @@ -36,8 +36,8 @@ func NewRouter(locator *service.Locator) http.Handler { // Unrestricted access. s.Route("/dev", func(r chi.Router) { - r.Use(nethttp.AnnotateOpenAPI(s.OpenAPICollector, func(op *openapi3.Operation) error { - op.Tags = []string{"Dev Mode"} + r.Use(nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error { + oc.SetTags("Dev Mode") return nil })) @@ -54,8 +54,8 @@ func NewRouter(locator *service.Locator) http.Handler { // Endpoints with admin access. s.Route("/admin", func(r chi.Router) { r.Group(func(r chi.Router) { - r.Use(nethttp.AnnotateOpenAPI(s.OpenAPICollector, func(op *openapi3.Operation) error { - op.Tags = []string{"Admin Mode"} + r.Use(nethttp.OpenAPIAnnotationsMiddleware(s.OpenAPICollector, func(oc openapi.OperationContext) error { + oc.SetTags("Admin Mode") return nil })) diff --git a/_examples/task-api/internal/infra/repository/task.go b/_examples/task-api/internal/infra/repository/task.go index a58bcb3..250d4a7 100644 --- a/_examples/task-api/internal/infra/repository/task.go +++ b/_examples/task-api/internal/infra/repository/task.go @@ -154,8 +154,6 @@ func (tr *Task) Create(ctx context.Context, value task.Value) (task.Entity, erro } // FinishExpired closes expired tasks. -// -// nolint:unused // False positive. func (tr *Task) FinishExpired(_ context.Context) error { tr.mu.Lock() defer tr.mu.Unlock() diff --git a/_examples/task-api/main.go b/_examples/task-api/main.go index b1fcf89..4daf306 100644 --- a/_examples/task-api/main.go +++ b/_examples/task-api/main.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "net/http" + "time" "github.com/kelseyhightower/envconfig" "github.com/swaggest/rest/_examples/task-api/internal/infra" @@ -26,7 +27,10 @@ func main() { l.EnableGracefulShutdown() // Initialize HTTP server. - srv := http.Server{Addr: fmt.Sprintf(":%d", cfg.HTTPPort), Handler: nethttp.NewRouter(l)} + srv := http.Server{ + Addr: fmt.Sprintf(":%d", cfg.HTTPPort), Handler: nethttp.NewRouter(l), + ReadHeaderTimeout: time.Second, + } // Start HTTP server. log.Printf("starting HTTP server at http://localhost:%d/docs\n", cfg.HTTPPort) diff --git a/chirouter/example_test.go b/chirouter/example_test.go new file mode 100644 index 0000000..d969e88 --- /dev/null +++ b/chirouter/example_test.go @@ -0,0 +1,57 @@ +package chirouter_test + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/go-chi/chi/v5" + "github.com/swaggest/rest" + "github.com/swaggest/rest/chirouter" + "github.com/swaggest/rest/request" +) + +func ExamplePathToURLValues() { + // Instantiate decoder factory with gorillamux.PathToURLValues. + // Single factory can be used to create multiple request decoders. + decoderFactory := request.NewDecoderFactory() + decoderFactory.ApplyDefaults = true + decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues) + + // Define request structure for your HTTP handler. + type myRequest struct { + Query1 int `query:"query1"` + Path1 string `path:"path1"` + Path2 int `path:"path2"` + Header1 float64 `header:"X-Header-1"` + FormData1 bool `formData:"formData1"` + FormData2 string `formData:"formData2"` + } + + // Create decoder for that request structure. + dec := decoderFactory.MakeDecoder(http.MethodPost, myRequest{}, nil) + + router := chi.NewRouter() + + // Now in router handler you can decode *http.Request into a Go structure. + router.Handle("/foo/{path1}/bar/{path2}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var in myRequest + + _ = dec.Decode(r, &in, nil) + + fmt.Printf("%+v\n", in) + })) + + // Serving example URL. + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/foo/abc/bar/123?query1=321", + bytes.NewBufferString("formData1=true&formData2=def")) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Header-1", "1.23") + + router.ServeHTTP(w, req) + // Output: + // {Query1:321 Path1:abc Path2:123 Header1:1.23 FormData1:true FormData2:def} +} diff --git a/chirouter/wrapper_test.go b/chirouter/wrapper_test.go index d36ccd4..605e6af 100644 --- a/chirouter/wrapper_test.go +++ b/chirouter/wrapper_test.go @@ -263,7 +263,7 @@ func TestWrapper_Use_StripSlashes(t *testing.T) { func TestWrapper_Mount(t *testing.T) { service := web.DefaultService() - service.OpenAPI.Info.Title = "Security and Mount Example" + service.OpenAPISchema().SetTitle("Security and Mount Example") apiV1 := web.DefaultService() @@ -354,7 +354,7 @@ func TestWrapper_Mount(t *testing.T) { }, "securitySchemes":{"Admin":{"type":"http","scheme":"basic","description":"Admin access"}} } - }`), service.OpenAPI) + }`), service.OpenAPISchema()) } func TestWrapper_With(t *testing.T) { diff --git a/go.mod b/go.mod index 471e9a6..8b36238 100644 --- a/go.mod +++ b/go.mod @@ -8,12 +8,13 @@ require ( github.com/bool64/shared v0.1.5 github.com/cespare/xxhash/v2 v2.2.0 github.com/go-chi/chi/v5 v5.0.10 + github.com/gorilla/mux v1.8.0 github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 github.com/stretchr/testify v1.8.2 github.com/swaggest/assertjson v1.9.0 github.com/swaggest/form/v5 v5.1.1 - github.com/swaggest/jsonschema-go v0.3.52 - github.com/swaggest/openapi-go v0.2.33 + github.com/swaggest/jsonschema-go v0.3.55 + github.com/swaggest/openapi-go v0.2.37 github.com/swaggest/refl v1.2.0 github.com/swaggest/usecase v1.2.1 ) diff --git a/go.sum b/go.sum index 5b4e3de..b526793 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,7 @@ -github.com/bool64/dev v0.2.16/go.mod h1:/csLrm+4oDSsKJRIVS0mrywAonLnYKFG8RvGT7Jh9b8= github.com/bool64/dev v0.2.17/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/dev v0.2.24/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/dev v0.2.25/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/dev v0.2.27/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= -github.com/bool64/dev v0.2.28/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/dev v0.2.29 h1:x+syGyh+0eWtOzQ1ItvLzOGIWyNWnyjXpHIcpF2HvL4= github.com/bool64/dev v0.2.29/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= github.com/bool64/httpmock v0.1.13 h1:3QpRXQ5kwHLW8xnVT8+Ug7VS6RerhdEFV+RWYC61aVo= @@ -31,6 +29,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/iancoleman/orderedmap v0.2.0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= @@ -76,11 +76,10 @@ github.com/swaggest/assertjson v1.9.0 h1:dKu0BfJkIxv/xe//mkCrK5yZbs79jL7OVf9Ija7 github.com/swaggest/assertjson v1.9.0/go.mod h1:b+ZKX2VRiUjxfUIal0HDN85W0nHPAYUbYH5WkkSsFsU= github.com/swaggest/form/v5 v5.1.1 h1:ct6/rOQBGrqWUQ0FUv3vW5sHvTUb31AwTUWj947N6cY= github.com/swaggest/form/v5 v5.1.1/go.mod h1:X1hraaoONee20PMnGNLQpO32f9zbQ0Czfm7iZThuEKg= -github.com/swaggest/jsonschema-go v0.3.52 h1:cFk0Ma34MuZvA+JJ8S5WuQMedwxzUa+9+qrGwtoE39U= -github.com/swaggest/jsonschema-go v0.3.52/go.mod h1:sRly7iaIIvbheAqsyvnKU3H7ogWbF0wtUBXyGpRXNm4= -github.com/swaggest/openapi-go v0.2.33 h1:5S9XCSuaex9KkLQVNiH0SYukiTEWCa2z66k7QQ+UUiQ= -github.com/swaggest/openapi-go v0.2.33/go.mod h1:h7dk9ApzrU9yJ7O8TSoW+a1PyT7R+bnYHDw/NpexlYA= -github.com/swaggest/refl v1.1.0/go.mod h1:g3Qa6ki0A/L2yxiuUpT+cuBURuRaltF5SDQpg1kMZSY= +github.com/swaggest/jsonschema-go v0.3.55 h1:xbDQaLw9NxxkL3meYnUnX5f6Hhav2wNAEfb/We53CkM= +github.com/swaggest/jsonschema-go v0.3.55/go.mod h1:5WFFGBBte5JAWAV8gDpNRJ/tlQnb1AHDdf/ghgsVUik= +github.com/swaggest/openapi-go v0.2.37 h1:Fz894zoeV5SNo4mm/tpADDwqQAHws6UWsjZbjrvBgw0= +github.com/swaggest/openapi-go v0.2.37/go.mod h1:iDIKsjFeVItoGOtb1jmLf+ULYg2yP0uqbBJ4OzE0nxM= github.com/swaggest/refl v1.2.0 h1:Qqqhfwi7REXF6/4cwJmj3gQMzl0Q0cYquxTYdD0kvi0= github.com/swaggest/refl v1.2.0/go.mod h1:CkC6g7h1PW33KprTuYRSw8UUOslRUt4lF3oe7tTIgNU= github.com/swaggest/usecase v1.2.1 h1:XYVdK9tK2KCPglTflUi7aWBrVwIyb58D5mvGWED7pNs= diff --git a/gorillamux/collector.go b/gorillamux/collector.go new file mode 100644 index 0000000..21093f6 --- /dev/null +++ b/gorillamux/collector.go @@ -0,0 +1,137 @@ +package gorillamux + +import ( + "net/http" + + "github.com/gorilla/mux" + oapi "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/rest/nethttp" + "github.com/swaggest/rest/openapi" +) + +// OpenAPICollector is a wrapper for openapi.Collector tailored to walk gorilla/mux router. +type OpenAPICollector struct { + // Collector is an actual OpenAPI collector. + Collector *openapi.Collector + + // DefaultMethods list is used when handler serves all methods. + DefaultMethods []string + + // OperationExtractor allows flexible extraction of OpenAPI information. + OperationExtractor func(h http.Handler) func(oc oapi.OperationContext) error + + // Host filters routes by host, gorilla/mux can serve different handlers at + // same method, paths with different hosts. This can not be expressed with a single + // OpenAPI document. + Host string +} + +// NewOpenAPICollector creates route walker for gorilla/mux, that collects OpenAPI operations. +func NewOpenAPICollector(r oapi.Reflector) *OpenAPICollector { + c := openapi.NewCollector(r) + + return &OpenAPICollector{ + Collector: c, + DefaultMethods: []string{ + http.MethodHead, http.MethodGet, http.MethodPost, + http.MethodPut, http.MethodPatch, http.MethodDelete, + }, + } +} + +// OpenAPIPreparer defines http.Handler with OpenAPI information. +type OpenAPIPreparer interface { + SetupOpenAPIOperation(oc oapi.OperationContext) error +} + +type preparerFunc func(oc oapi.OperationContext) error + +// Walker walks route tree and collects OpenAPI information. +func (dc *OpenAPICollector) Walker(route *mux.Route, _ *mux.Router, _ []*mux.Route) error { + handler := route.GetHandler() + + if handler == nil { + return nil + } + + // Path is critical info, skipping route if there is a problem with path. + path, err := route.GetPathTemplate() + if err != nil && path == "" { + return nil + } + + host, err := route.GetHostTemplate() + if (err == nil && host != dc.Host) || // There is host, but different. + (err != nil && dc.Host != "") { // There is no host, but should be. + return nil + } + + methods, err := route.GetMethods() + if err != nil { + methods = dc.DefaultMethods + } + + var ( + openAPIPreparer OpenAPIPreparer + preparer preparerFunc + ) + + if nethttp.HandlerAs(handler, &openAPIPreparer) { + preparer = openAPIPreparer.SetupOpenAPIOperation + } else if dc.OperationExtractor != nil { + preparer = dc.OperationExtractor(handler) + } + + for _, method := range methods { + if err := dc.Collector.CollectOperation(method, path, dc.collect(method, path, preparer)); err != nil { + return err + } + } + + return nil +} + +func (dc *OpenAPICollector) collect(method, path string, preparer preparerFunc) preparerFunc { + return func(oc oapi.OperationContext) error { + // Do not apply default parameters to not conflict with custom preparer. + if preparer != nil { + return preparer(oc) + } + + // Do not apply default parameters to not conflict with custom annotation. + if dc.Collector.HasAnnotation(method, path) { + return nil + } + + _, _, pathItems, err := oapi.SanitizeMethodPath(method, path) + if err != nil { + return err + } + + if len(pathItems) > 0 { + if o3, ok := oc.(openapi3.OperationExposer); ok { + op := o3.Operation() + + for _, p := range pathItems { + param := openapi3.ParameterOrRef{} + param.WithParameter(openapi3.Parameter{ + Name: p, + In: openapi3.ParameterInPath, + }) + + op.Parameters = append(op.Parameters, param) + } + } + } + + oc.SetDescription("Information about this operation was obtained using only HTTP method and path pattern. " + + "It may be incomplete and/or inaccurate.") + oc.SetTags("Incomplete") + oc.AddRespStructure(nil, func(cu *oapi.ContentUnit) { + cu.ContentType = "text/html" + }) + + return nil + } +} diff --git a/gorillamux/collector_test.go b/gorillamux/collector_test.go new file mode 100644 index 0000000..e6a3154 --- /dev/null +++ b/gorillamux/collector_test.go @@ -0,0 +1,235 @@ +package gorillamux_test + +import ( + "net/http" + "testing" + + "github.com/gorilla/mux" + "github.com/stretchr/testify/assert" + "github.com/swaggest/assertjson" + "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/rest/gorillamux" + "github.com/swaggest/usecase" +) + +type structuredHandler struct { + usecase.Info + usecase.WithInput + usecase.WithOutput +} + +func (s structuredHandler) SetupOpenAPIOperation(oc openapi.OperationContext) error { + oc.AddReqStructure(s.Input) + oc.AddRespStructure(s.Output) + + return nil +} + +func newStructuredHandler(setup func(h *structuredHandler)) structuredHandler { + h := structuredHandler{} + setup(&h) + + return h +} + +func (s structuredHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {} + +func TestOpenAPICollector_Walker(t *testing.T) { + r := mux.NewRouter() + + r.Use(func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + handler.ServeHTTP(w, r) + }) + }) + + r.HandleFunc("/products", nil).Methods(http.MethodGet) + r.HandleFunc("/articles", nil).Methods(http.MethodGet) + r.Handle("/products/{key}", + newStructuredHandler(func(h *structuredHandler) { + h.Input = struct { + Key string `path:"key"` + }{} + h.Output = struct{}{} + })). + Methods(http.MethodGet). + Queries("key", "value") + r.Handle("/articles/{category}/", + newStructuredHandler(func(h *structuredHandler) { + h.Input = struct { + Filter string `query:"filter"` + Category string `path:"category"` + }{} + })). + Methods(http.MethodGet). + Host("{subdomain:[a-z]+}.example.com") + + s := r.Host("www.example.com").Subrouter() + + s.Handle("/articles/{category}/{id:[0-9]+}", newStructuredHandler(func(h *structuredHandler) { + h.Input = struct { + Filter string `query:"filter"` + Category string `path:"category"` + ID string `path:"id"` + }{} + })). + Methods(http.MethodGet). + Headers("X-Requested-With", "XMLHttpRequest") + + r.HandleFunc("/specific", nil).Methods(http.MethodPost) + r.PathPrefix("/").Handler(nil) + + http.Handle("/", r) + + rf := openapi3.NewReflector() + rf.Spec.Info. + WithTitle("Test Server"). + WithVersion("v1.2.3"). + WithDescription("Provides API over HTTP") + + c := gorillamux.NewOpenAPICollector(rf) + + assert.NoError(t, r.Walk(c.Walker)) + + assertjson.EqMarshal(t, `{ + "openapi":"3.0.3", + "info":{ + "title":"Test Server","description":"Provides API over HTTP", + "version":"v1.2.3" + }, + "paths":{ + "/articles":{ + "get":{ + "tags":["Incomplete"], + "description":"Information about this operation was obtained using only HTTP method and path pattern. It may be incomplete and/or inaccurate.", + "responses":{ + "200":{ + "description":"OK", + "content":{"text/html":{"schema":{"type":"string"}}} + } + } + } + }, + "/products":{ + "get":{ + "tags":["Incomplete"], + "description":"Information about this operation was obtained using only HTTP method and path pattern. It may be incomplete and/or inaccurate.", + "responses":{ + "200":{ + "description":"OK", + "content":{"text/html":{"schema":{"type":"string"}}} + } + } + } + }, + "/products/{key}":{ + "get":{ + "parameters":[ + { + "name":"key","in":"path","required":true,"schema":{"type":"string"} + } + ], + "responses":{"200":{"description":"OK"}} + } + }, + "/specific":{ + "post":{ + "tags":["Incomplete"], + "description":"Information about this operation was obtained using only HTTP method and path pattern. It may be incomplete and/or inaccurate.", + "responses":{ + "200":{ + "description":"OK", + "content":{"text/html":{"schema":{"type":"string"}}} + } + } + } + } + } + }`, rf.Spec) + + rf = openapi3.NewReflector() + rf.Spec.Info. + WithTitle("Test Server (www.example.com)"). + WithVersion("v1.2.3"). + WithDescription("Provides API over HTTP") + rf.Spec.WithServers(openapi3.Server{ + URL: "www.example.com", + }) + + c = gorillamux.NewOpenAPICollector(rf) + c.Host = "www.example.com" + + assert.NoError(t, r.Walk(c.Walker)) + + assertjson.EqMarshal(t, `{ + "openapi":"3.0.3", + "info":{ + "title":"Test Server (www.example.com)", + "description":"Provides API over HTTP","version":"v1.2.3" + }, + "servers":[{"url":"www.example.com"}], + "paths":{ + "/articles/{category}/{id}":{ + "get":{ + "parameters":[ + {"name":"filter","in":"query","schema":{"type":"string"}}, + { + "name":"category","in":"path","required":true, + "schema":{"type":"string"} + }, + {"name":"id","in":"path","required":true,"schema":{"type":"string"}} + ], + "responses":{"200":{"description":"OK"}} + } + } + } + }`, rf.Spec) + + rf = openapi3.NewReflector() + rf.Spec.Info. + WithTitle("Test Server ({subdomain}.example.com)"). + WithVersion("v1.2.3"). + WithDescription("Provides API over HTTP") + rf.Spec.WithServers(openapi3.Server{ + URL: "{subdomain}.example.com", + Variables: map[string]openapi3.ServerVariable{ + "subdomain": { + Default: "foo", + }, + }, + }) + + c = gorillamux.NewOpenAPICollector(rf) + c.Host = "{subdomain:[a-z]+}.example.com" + + assert.NoError(t, r.Walk(c.Walker)) + + assertjson.EqMarshal(t, `{ + "openapi":"3.0.3", + "info":{ + "title":"Test Server ({subdomain}.example.com)", + "description":"Provides API over HTTP","version":"v1.2.3" + }, + "servers":[ + { + "url":"{subdomain}.example.com", + "variables":{"subdomain":{"default":"foo"}} + } + ], + "paths":{ + "/articles/{category}/":{ + "get":{ + "parameters":[ + {"name":"filter","in":"query","schema":{"type":"string"}}, + { + "name":"category","in":"path","required":true, + "schema":{"type":"string"} + } + ], + "responses":{"200":{"description":"OK"}} + } + } + } + }`, rf.Spec) +} diff --git a/gorillamux/doc.go b/gorillamux/doc.go new file mode 100644 index 0000000..6f0bedc --- /dev/null +++ b/gorillamux/doc.go @@ -0,0 +1,2 @@ +// Package gorillamux provides OpenAPI docs collector for gorilla/mux web services. +package gorillamux diff --git a/gorillamux/example_openapi_collector_test.go b/gorillamux/example_openapi_collector_test.go new file mode 100644 index 0000000..e4e1d87 --- /dev/null +++ b/gorillamux/example_openapi_collector_test.go @@ -0,0 +1,190 @@ +package gorillamux_test + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + + "github.com/gorilla/mux" + "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/rest" + "github.com/swaggest/rest/gorillamux" + "github.com/swaggest/rest/nethttp" + "github.com/swaggest/rest/request" +) + +// Define request structure for your HTTP handler. +type myRequest struct { + Query1 int `query:"query1"` + Path1 string `path:"path1"` + Path2 int `path:"path2"` + Header1 float64 `header:"X-Header-1"` + FormData1 bool `formData:"formData1"` + FormData2 string `formData:"formData2"` +} + +type myResp struct { + Sum float64 `json:"sum"` + Concat string `json:"concat"` +} + +func newMyHandler() *myHandler { + decoderFactory := request.NewDecoderFactory() + decoderFactory.ApplyDefaults = true + decoderFactory.SetDecoderFunc(rest.ParamInPath, gorillamux.PathToURLValues) + + return &myHandler{ + dec: decoderFactory.MakeDecoder(http.MethodPost, myRequest{}, nil), + } +} + +type myHandler struct { + // Automated request decoding is not required to collect OpenAPI schema, + // but it is good to have to establish a single source of truth and to simplify request reading. + dec nethttp.RequestDecoder +} + +func (m *myHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + var in myRequest + + if err := m.dec.Decode(r, &in, nil); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + + // Serve request. + out := myResp{ + Sum: in.Header1 + float64(in.Path2) + float64(in.Query1), + Concat: in.Path1 + in.FormData2 + strconv.FormatBool(in.FormData1), + } + + j, err := json.Marshal(out) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + + return + } + + _, _ = w.Write(j) +} + +// SetupOpenAPIOperation declares OpenAPI schema for the handler. +func (m *myHandler) SetupOpenAPIOperation(oc openapi.OperationContext) error { + oc.SetTags("My Tag") + oc.SetSummary("My Summary") + oc.SetDescription("This endpoint aggregates request in structured way.") + + oc.AddReqStructure(myRequest{}) + oc.AddRespStructure(myResp{}) + oc.AddRespStructure(nil, openapi.WithContentType("text/html"), openapi.WithHTTPStatus(http.StatusBadRequest)) + oc.AddRespStructure(nil, openapi.WithContentType("text/html"), openapi.WithHTTPStatus(http.StatusInternalServerError)) + + return nil +} + +func ExampleNewOpenAPICollector() { + // Your router does not need special instrumentation. + router := mux.NewRouter() + + // If handler implements gorillamux.OpenAPIPreparer, it will contribute detailed information to OpenAPI document. + router.Handle("/foo/{path1}/bar/{path2}", newMyHandler()).Methods(http.MethodGet) + + // If handler does not implement gorillamux.OpenAPIPreparer, it will be exposed as incomplete. + router.Handle("/uninstrumented-handler/{path-item}", + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})).Methods(http.MethodPost) + + // Setup OpenAPI schema. + refl := openapi3.NewReflector() + refl.SpecSchema().SetTitle("Sample API") + refl.SpecSchema().SetVersion("v1.2.3") + refl.SpecSchema().SetDescription("This is an example.") + + // Walk the router with OpenAPI collector. + c := gorillamux.NewOpenAPICollector(refl) + + _ = router.Walk(c.Walker) + + // Get the resulting schema. + yml, _ := refl.Spec.MarshalYAML() + fmt.Println(string(yml)) + + // Output: + // openapi: 3.0.3 + // info: + // description: This is an example. + // title: Sample API + // version: v1.2.3 + // paths: + // /foo/{path1}/bar/{path2}: + // get: + // description: This endpoint aggregates request in structured way. + // parameters: + // - in: query + // name: query1 + // schema: + // type: integer + // - in: path + // name: path1 + // required: true + // schema: + // type: string + // - in: path + // name: path2 + // required: true + // schema: + // type: integer + // - in: header + // name: X-Header-1 + // schema: + // type: number + // responses: + // "200": + // content: + // application/json: + // schema: + // $ref: '#/components/schemas/GorillamuxTestMyResp' + // description: OK + // "400": + // content: + // text/html: + // schema: + // type: string + // description: Bad Request + // "500": + // content: + // text/html: + // schema: + // type: string + // description: Internal Server Error + // summary: My Summary + // tags: + // - My Tag + // /uninstrumented-handler/{path-item}: + // post: + // description: Information about this operation was obtained using only HTTP method + // and path pattern. It may be incomplete and/or inaccurate. + // parameters: + // - in: path + // name: path-item + // responses: + // "200": + // content: + // text/html: + // schema: + // type: string + // description: OK + // tags: + // - Incomplete + // components: + // schemas: + // GorillamuxTestMyResp: + // properties: + // concat: + // type: string + // sum: + // type: number + // type: object +} diff --git a/gorillamux/example_test.go b/gorillamux/example_test.go new file mode 100644 index 0000000..068be32 --- /dev/null +++ b/gorillamux/example_test.go @@ -0,0 +1,57 @@ +package gorillamux_test + +import ( + "bytes" + "fmt" + "net/http" + "net/http/httptest" + + "github.com/gorilla/mux" + "github.com/swaggest/rest" + "github.com/swaggest/rest/gorillamux" + "github.com/swaggest/rest/request" +) + +func ExamplePathToURLValues() { + // Instantiate decoder factory with gorillamux.PathToURLValues. + // Single factory can be used to create multiple request decoders. + decoderFactory := request.NewDecoderFactory() + decoderFactory.ApplyDefaults = true + decoderFactory.SetDecoderFunc(rest.ParamInPath, gorillamux.PathToURLValues) + + // Define request structure for your HTTP handler. + type myRequest struct { + Query1 int `query:"query1"` + Path1 string `path:"path1"` + Path2 int `path:"path2"` + Header1 float64 `header:"X-Header-1"` + FormData1 bool `formData:"formData1"` + FormData2 string `formData:"formData2"` + } + + // Create decoder for that request structure. + dec := decoderFactory.MakeDecoder(http.MethodPost, myRequest{}, nil) + + router := mux.NewRouter() + + // Now in router handler you can decode *http.Request into a Go structure. + router.Handle("/foo/{path1}/bar/{path2}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var in myRequest + + _ = dec.Decode(r, &in, nil) + + fmt.Printf("%+v\n", in) + })) + + // Serving example URL. + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/foo/abc/bar/123?query1=321", + bytes.NewBufferString("formData1=true&formData2=def")) + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Header-1", "1.23") + + router.ServeHTTP(w, req) + // Output: + // {Query1:321 Path1:abc Path2:123 Header1:1.23 FormData1:true FormData2:def} +} diff --git a/gorillamux/path_decoder.go b/gorillamux/path_decoder.go new file mode 100644 index 0000000..f5396b0 --- /dev/null +++ b/gorillamux/path_decoder.go @@ -0,0 +1,20 @@ +package gorillamux + +import ( + "net/http" + "net/url" + + "github.com/gorilla/mux" +) + +// PathToURLValues is a decoder function for parameters in path. +func PathToURLValues(r *http.Request) (url.Values, error) { //nolint:unparam // Matches request.DecoderFactory.SetDecoderFunc. + muxVars := mux.Vars(r) + res := make(url.Values, len(muxVars)) + + for k, v := range muxVars { + res.Set(k, v) + } + + return res, nil +} diff --git a/jsonschema/validator.go b/jsonschema/validator.go index dc9f945..74e5d1a 100644 --- a/jsonschema/validator.go +++ b/jsonschema/validator.go @@ -167,7 +167,7 @@ func (v *Validator) ValidateJSONBody(jsonBody []byte) error { name := "body" schema, found := v.inNamedSchemas[rest.ParamInBody][name] - if !found { + if !found || schema == nil { return nil } diff --git a/nethttp/example_test.go b/nethttp/example_test.go index 6d8db9f..b6c0fb5 100644 --- a/nethttp/example_test.go +++ b/nethttp/example_test.go @@ -57,7 +57,7 @@ func ExampleSecurityMiddleware() { With(serviceTokenAuth, serviceTokenDoc). // Apply a pair of middlewares: actual security and documentation. Method(http.MethodGet, "/foo", nethttp.NewHandler(u)) - schema, _ := assertjson.MarshalIndentCompact(apiSchema.Reflector().Spec, "", " ", 120) + schema, _ := assertjson.MarshalIndentCompact(apiSchema.SpecSchema(), "", " ", 120) fmt.Println(string(schema)) // Output: diff --git a/nethttp/handler.go b/nethttp/handler.go index a28f129..a9456ca 100644 --- a/nethttp/handler.go +++ b/nethttp/handler.go @@ -23,6 +23,11 @@ func NewHandler(useCase usecase.Interactor, options ...func(h *Handler)) *Handle options: options, } h.HandleErrResponse = h.handleErrResponseDefault + + for _, option := range h.options { + option(h) + } + h.SetUseCase(useCase) return h @@ -37,10 +42,6 @@ func (h *Handler) UseCase() usecase.Interactor { func (h *Handler) SetUseCase(useCase usecase.Interactor) { h.useCase = useCase - for _, option := range h.options { - option(h) - } - h.setupInputBuffer() h.setupOutputBuffer() } diff --git a/nethttp/openapi.go b/nethttp/openapi.go index db9651a..b276ae5 100644 --- a/nethttp/openapi.go +++ b/nethttp/openapi.go @@ -3,6 +3,7 @@ package nethttp import ( "net/http" + oapi "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/rest/openapi" @@ -24,12 +25,11 @@ func OpenAPIMiddleware(s *openapi.Collector) func(http.Handler) http.Handler { return h } - err := s.Collect( + err := s.CollectUseCase( withRoute.RouteMethod(), withRoute.RoutePattern(), handler.UseCase(), handler.HandlerTrait, - handler.OperationAnnotations..., ) if err != nil { panic(err) @@ -105,6 +105,8 @@ func HTTPBearerSecurityMiddleware( } // AnnotateOpenAPI applies OpenAPI annotation to relevant handlers. +// +// Deprecated: use OpenAPIAnnotationsMiddleware. func AnnotateOpenAPI( s *openapi.Collector, setup ...func(op *openapi3.Operation) error, @@ -145,9 +147,33 @@ type MiddlewareConfig struct { ResponseStatus int } +// OpenAPIAnnotationsMiddleware applies OpenAPI annotations to handlers. +func OpenAPIAnnotationsMiddleware( + s *openapi.Collector, + annotations ...func(oc oapi.OperationContext) error, +) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + if IsWrapperChecker(next) { + return next + } + + var withRoute rest.HandlerWithRoute + + if HandlerAs(next, &withRoute) { + s.AnnotateOperation( + withRoute.RouteMethod(), + withRoute.RoutePattern(), + annotations..., + ) + } + + return next + } +} + func securityMiddleware(s *openapi.Collector, name string, cfg MiddlewareConfig) func(http.Handler) http.Handler { - return AnnotateOpenAPI(s, func(op *openapi3.Operation) error { - op.Security = append(op.Security, map[string][]string{name: {}}) + return OpenAPIAnnotationsMiddleware(s, func(oc oapi.OperationContext) error { + oc.AddSecurity(name) if cfg.ResponseStatus == 0 { cfg.ResponseStatus = http.StatusUnauthorized @@ -157,6 +183,10 @@ func securityMiddleware(s *openapi.Collector, name string, cfg MiddlewareConfig) cfg.ResponseStructure = rest.ErrResponse{} } - return s.Reflector().SetJSONResponse(op, cfg.ResponseStructure, cfg.ResponseStatus) + oc.AddRespStructure(cfg.ResponseStructure, func(cu *oapi.ContentUnit) { + cu.HTTPStatus = cfg.ResponseStatus + }) + + return nil }) } diff --git a/nethttp/openapi_test.go b/nethttp/openapi_test.go index 4055ea5..f009181 100644 --- a/nethttp/openapi_test.go +++ b/nethttp/openapi_test.go @@ -67,7 +67,7 @@ func TestOpenAPIMiddleware(t *testing.T) { assert.True(t, nethttp.MiddlewareIsWrapper(w), i) } - sp, err := assertjson.MarshalIndentCompact(c.Reflector().Spec, "", " ", 100) + sp, err := assertjson.MarshalIndentCompact(c.SpecSchema(), "", " ", 100) require.NoError(t, err) assertjson.Equal(t, []byte(`{ diff --git a/nethttp/options.go b/nethttp/options.go index f5892d4..6b0ef77 100644 --- a/nethttp/options.go +++ b/nethttp/options.go @@ -4,6 +4,7 @@ import ( "net/http" "reflect" + "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/refl" "github.com/swaggest/rest" @@ -26,21 +27,39 @@ func OptionsMiddleware(options ...func(h *Handler)) func(h http.Handler) http.Ha } } +// AnnotateOpenAPIOperation allows customization of OpenAPI operation, that is reflected from the Handler. +func AnnotateOpenAPIOperation(annotations ...func(oc openapi.OperationContext) error) func(h *Handler) { + return func(h *Handler) { + h.OpenAPIAnnotations = append(h.OpenAPIAnnotations, annotations...) + } +} + // AnnotateOperation allows customizations of prepared operations. +// +// Deprecated: use AnnotateOpenAPIOperation. func AnnotateOperation(annotations ...func(operation *openapi3.Operation) error) func(h *Handler) { return func(h *Handler) { - h.OperationAnnotations = append(h.OperationAnnotations, annotations...) + for _, a := range annotations { + a := a + + h.OpenAPIAnnotations = append(h.OpenAPIAnnotations, func(oc openapi.OperationContext) error { + if o3, ok := oc.(openapi3.OperationExposer); ok { + return a(o3.Operation()) + } + + return nil + }) + } } } // RequestBodyContent enables string request body with content type (e.g. text/plain). func RequestBodyContent(contentType string) func(h *Handler) { return func(h *Handler) { - mt := openapi3.MediaType{} - mt.SchemaEns().SchemaEns().WithType(openapi3.SchemaTypeString) - - h.OperationAnnotations = append(h.OperationAnnotations, func(op *openapi3.Operation) error { - op.RequestBodyEns().RequestBodyEns().WithContentItem(contentType, mt) + h.OpenAPIAnnotations = append(h.OpenAPIAnnotations, func(oc openapi.OperationContext) error { + oc.AddReqStructure(nil, func(cu *openapi.ContentUnit) { + cu.ContentType = contentType + }) return nil }) diff --git a/nethttp/options_test.go b/nethttp/options_test.go index ed38066..9a3e105 100644 --- a/nethttp/options_test.go +++ b/nethttp/options_test.go @@ -1,23 +1,64 @@ package nethttp_test import ( + "context" + "net/http" "testing" "github.com/stretchr/testify/require" "github.com/swaggest/assertjson" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest/nethttp" + "github.com/swaggest/rest/web" + "github.com/swaggest/usecase" ) func TestRequestBodyContent(t *testing.T) { h := &nethttp.Handler{} - op := openapi3.Operation{} + + r := openapi3.NewReflector() + oc, err := r.NewOperationContext(http.MethodPost, "/") + require.NoError(t, err) nethttp.RequestBodyContent("text/plain")(h) - require.Len(t, h.OperationAnnotations, 1) - require.NoError(t, h.OperationAnnotations[0](&op)) - assertjson.EqualMarshal(t, []byte(`{ - "requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}}}, - "responses":{} - }`), op) + require.Len(t, h.OpenAPIAnnotations, 1) + require.NoError(t, h.OpenAPIAnnotations[0](oc)) + + require.NoError(t, r.AddOperation(oc)) + + assertjson.EqMarshal(t, `{ + "openapi":"3.0.3","info":{"title":"","version":""}, + "paths":{ + "/":{ + "post":{ + "requestBody":{"content":{"text/plain":{"schema":{"type":"string"}}}}, + "responses":{"204":{"description":"No Content"}} + } + } + } + }`, r.SpecSchema()) +} + +func TestRequestBodyContent_webService(t *testing.T) { + s := web.DefaultService() + + u := usecase.NewIOI(new(string), nil, func(ctx context.Context, input, output interface{}) error { + return nil + }) + + s.Post("/text-req-body", u, nethttp.RequestBodyContent("text/csv")) + + assertjson.EqMarshal(t, `{ + "openapi":"3.0.3","info":{"title":"","version":""}, + "paths":{ + "/text-req-body":{ + "post":{ + "summary":"Test Request Body Content _ web Service", + "operationId":"rest/nethttp_test.TestRequestBodyContent_webService", + "requestBody":{"content":{"text/csv":{"schema":{"type":"string"}}}}, + "responses":{"204":{"description":"No Content"}} + } + } + } + }`, s.OpenAPISchema()) } diff --git a/openapi/collector.go b/openapi/collector.go index c4b5dc6..fcc3117 100644 --- a/openapi/collector.go +++ b/openapi/collector.go @@ -4,13 +4,13 @@ package openapi import ( "context" "encoding/json" - "errors" "fmt" "net/http" "strconv" "sync" "github.com/swaggest/jsonschema-go" + "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/usecase" @@ -35,21 +35,57 @@ type Collector struct { // If empty, "application/json" is used. DefaultErrorResponseContentType string - gen *openapi3.Reflector - annotations map[string][]func(*openapi3.Operation) error - operationIDs map[string]bool + gen *openapi3.Reflector + ref openapi.Reflector + + ocAnnotations map[string][]func(oc openapi.OperationContext) error + annotations map[string][]func(*openapi3.Operation) error + operationIDs map[string]bool +} + +// NewCollector creates an instance of OpenAPI Collector. +func NewCollector(r openapi.Reflector) *Collector { + c := &Collector{ + ref: r, + } + + if r3, ok := r.(*openapi3.Reflector); ok { + c.gen = r3 + } + + return c +} + +// SpecSchema returns OpenAPI specification schema. +func (c *Collector) SpecSchema() openapi.SpecSchema { + return c.Refl().SpecSchema() +} + +// Refl returns OpenAPI reflector. +func (c *Collector) Refl() openapi.Reflector { + if c.ref != nil { + return c.ref + } + + return c.Reflector() } // Reflector is an accessor to OpenAPI Reflector instance. func (c *Collector) Reflector() *openapi3.Reflector { + if c.ref != nil && c.gen == nil { + panic(fmt.Sprintf("conflicting OpenAPI reflector supplied: %T", c.ref)) + } + if c.gen == nil { - c.gen = &openapi3.Reflector{} + c.gen = openapi3.NewReflector() } return c.gen } // Annotate adds OpenAPI operation configuration that is applied during collection. +// +// Deprecated: use AnnotateOperation. func (c *Collector) Annotate(method, pattern string, setup ...func(op *openapi3.Operation) error) { c.mu.Lock() defer c.mu.Unlock() @@ -61,12 +97,32 @@ func (c *Collector) Annotate(method, pattern string, setup ...func(op *openapi3. c.annotations[method+pattern] = append(c.annotations[method+pattern], setup...) } -// Collect adds use case handler to documentation. -func (c *Collector) Collect( +// AnnotateOperation adds OpenAPI operation configuration that is applied during collection, +// method can be empty to indicate any method. +func (c *Collector) AnnotateOperation(method, pattern string, setup ...func(oc openapi.OperationContext) error) { + c.mu.Lock() + defer c.mu.Unlock() + + if c.ocAnnotations == nil { + c.ocAnnotations = make(map[string][]func(oc openapi.OperationContext) error) + } + + c.ocAnnotations[method+pattern] = append(c.ocAnnotations[method+pattern], setup...) +} + +// HasAnnotation indicates if there is at least one annotation registered for this operation. +func (c *Collector) HasAnnotation(method, pattern string) bool { + if len(c.ocAnnotations[method+pattern]) > 0 { + return true + } + + return len(c.ocAnnotations[pattern]) > 0 +} + +// CollectOperation prepares and adds OpenAPI operation. +func (c *Collector) CollectOperation( method, pattern string, - u usecase.Interactor, - h rest.HandlerTrait, - annotations ...func(*openapi3.Operation) error, + annotations ...func(oc openapi.OperationContext) error, ) (err error) { c.mu.Lock() defer c.mu.Unlock() @@ -77,31 +133,129 @@ func (c *Collector) Collect( } }() - reflector := c.Reflector() + reflector := c.Refl() - err = reflector.SpecEns().SetupOperation(method, pattern, func(op *openapi3.Operation) error { - oc := openapi3.OperationContext{ - Operation: op, - HTTPMethod: method, - HTTPStatus: h.SuccessStatus, - RespContentType: h.SuccessContentType, - RespHeaderMapping: h.RespHeaderMapping, + oc, err := reflector.NewOperationContext(method, pattern) + if err != nil { + return err + } + + for _, setup := range c.ocAnnotations[pattern] { + if err = setup(oc); err != nil { + return err + } + } + + for _, setup := range c.ocAnnotations[method+pattern] { + if err = setup(oc); err != nil { + return err + } + } + + for _, setup := range annotations { + if err = setup(oc); err != nil { + return err } + } + + return reflector.AddOperation(oc) +} - err = c.setupInput(&oc, u, h) +// CollectUseCase adds use case handler to documentation. +func (c *Collector) CollectUseCase( + method, pattern string, + u usecase.Interactor, + h rest.HandlerTrait, + annotations ...func(oc openapi.OperationContext) error, +) (err error) { + c.mu.Lock() + defer c.mu.Unlock() + + defer func() { if err != nil { - return fmt.Errorf("failed to setup request: %w", err) + err = fmt.Errorf("reflect API schema for %s %s: %w", method, pattern, err) + } + }() + + reflector := c.Refl() + + oc, err := reflector.NewOperationContext(method, pattern) + if err != nil { + return err + } + + c.setupInput(oc, u, h) + c.setupOutput(oc, u, h) + c.processUseCase(oc, u, h) + + an := append([]func(oc openapi.OperationContext) error(nil), c.ocAnnotations[method+pattern]...) + an = append(an, h.OpenAPIAnnotations...) + an = append(an, annotations...) + + for _, setup := range an { + if err = setup(oc); err != nil { + return err + } + } + + if o3, ok := oc.(openapi3.OperationExposer); ok { + op := o3.Operation() + + for _, setup := range c.annotations[method+pattern] { + if err = setup(op); err != nil { + return err + } + } + + //nolint:staticcheck // To be removed with deprecations cleanup. + for _, setup := range h.OperationAnnotations { + if err = setup(op); err != nil { + return err + } } + } - err = c.setupOutput(&oc, u) + return reflector.AddOperation(oc) +} + +// Collect adds use case handler to documentation. +// +// Deprecated: use CollectUseCase. +func (c *Collector) Collect( + method, pattern string, + u usecase.Interactor, + h rest.HandlerTrait, + annotations ...func(*openapi3.Operation) error, +) (err error) { + c.mu.Lock() + defer c.mu.Unlock() + + defer func() { if err != nil { - return fmt.Errorf("failed to setup response: %w", err) + err = fmt.Errorf("reflect API schema for %s %s: %w", method, pattern, err) } + }() + + reflector := c.Refl() - err = c.processUseCase(op, u, h) + oc, err := reflector.NewOperationContext(method, pattern) + if err != nil { + return err + } + + c.setupInput(oc, u, h) + c.setupOutput(oc, u, h) + c.processUseCase(oc, u, h) + + for _, setup := range c.ocAnnotations[method+pattern] { + err = setup(oc) if err != nil { return err } + } + + if o3, ok := oc.(openapi3.OperationExposer); ok { + op := o3.Operation() for _, setup := range c.annotations[method+pattern] { err = setup(op) @@ -116,24 +270,24 @@ func (c *Collector) Collect( return err } } + } - return nil - }) - - return err + return reflector.AddOperation(oc) } -func (c *Collector) setupOutput(oc *openapi3.OperationContext, u usecase.Interactor) error { +func (c *Collector) setupOutput(oc openapi.OperationContext, u usecase.Interactor, h rest.HandlerTrait) { var ( - hasOutput usecase.HasOutputPort - status = http.StatusOK - noContent bool + hasOutput usecase.HasOutputPort + status = http.StatusOK + noContent bool + output interface{} + contentType = h.SuccessContentType ) if usecase.As(u, &hasOutput) { - oc.Output = hasOutput.OutputPort() + output = hasOutput.OutputPort() - if rest.OutputHasNoContent(oc.Output) { + if rest.OutputHasNoContent(output) { status = http.StatusNoContent noContent = true } @@ -142,73 +296,58 @@ func (c *Collector) setupOutput(oc *openapi3.OperationContext, u usecase.Interac noContent = true } - if !noContent && oc.RespContentType == "" { - oc.RespContentType = c.DefaultSuccessResponseContentType + if !noContent && contentType == "" { + contentType = c.DefaultSuccessResponseContentType } - if outputWithStatus, ok := oc.Output.(rest.OutputWithHTTPStatus); ok { - for _, status := range outputWithStatus.ExpectedHTTPStatuses() { - oc.HTTPStatus = status - if err := c.Reflector().SetupResponse(*oc); err != nil { - return err - } - } - } else { - if oc.HTTPStatus == 0 { - oc.HTTPStatus = status - } - err := c.Reflector().SetupResponse(*oc) - if err != nil { - return err - } + if oc.Method() == http.MethodHead { + output = nil } - if oc.HTTPMethod == http.MethodHead { - for code, resp := range oc.Operation.Responses.MapOfResponseOrRefValues { - for contentType, cont := range resp.Response.Content { - cont.Schema = nil - resp.Response.Content[contentType] = cont - } + setupCU := func(cu *openapi.ContentUnit) { + cu.ContentType = contentType + cu.SetFieldMapping(openapi.InHeader, h.RespHeaderMapping) + } - oc.Operation.Responses.MapOfResponseOrRefValues[code] = resp + if outputWithStatus, ok := output.(rest.OutputWithHTTPStatus); ok { + for _, status := range outputWithStatus.ExpectedHTTPStatuses() { + oc.AddRespStructure(output, func(cu *openapi.ContentUnit) { + cu.HTTPStatus = status + setupCU(cu) + }) } + } else { + if h.SuccessStatus != 0 { + status = h.SuccessStatus + } + oc.AddRespStructure(output, func(cu *openapi.ContentUnit) { + cu.HTTPStatus = status + setupCU(cu) + }) } - - return nil } -func (c *Collector) setupInput(oc *openapi3.OperationContext, u usecase.Interactor, h rest.HandlerTrait) error { - var ( - hasInput usecase.HasInputPort - - err error - ) +func (c *Collector) setupInput(oc openapi.OperationContext, u usecase.Interactor, h rest.HandlerTrait) { + var hasInput usecase.HasInputPort if usecase.As(u, &hasInput) { - oc.Input = hasInput.InputPort() - - setRequestMapping(oc, h.ReqMapping) - - err = c.Reflector().SetupRequest(*oc) - if err != nil { - return err - } + oc.AddReqStructure(hasInput.InputPort(), func(cu *openapi.ContentUnit) { + setFieldMapping(cu, h.ReqMapping) + }) } - - return nil } -func setRequestMapping(oc *openapi3.OperationContext, mapping rest.RequestMapping) { +func setFieldMapping(cu *openapi.ContentUnit, mapping rest.RequestMapping) { if mapping != nil { - oc.ReqQueryMapping = mapping[rest.ParamInQuery] - oc.ReqPathMapping = mapping[rest.ParamInPath] - oc.ReqHeaderMapping = mapping[rest.ParamInHeader] - oc.ReqCookieMapping = mapping[rest.ParamInCookie] - oc.ReqFormDataMapping = mapping[rest.ParamInFormData] + cu.SetFieldMapping(openapi.InQuery, mapping[rest.ParamInQuery]) + cu.SetFieldMapping(openapi.InPath, mapping[rest.ParamInPath]) + cu.SetFieldMapping(openapi.InHeader, mapping[rest.ParamInHeader]) + cu.SetFieldMapping(openapi.InCookie, mapping[rest.ParamInCookie]) + cu.SetFieldMapping(openapi.InFormData, mapping[rest.ParamInFormData]) } } -func (c *Collector) processUseCase(op *openapi3.Operation, u usecase.Interactor, h rest.HandlerTrait) error { +func (c *Collector) processUseCase(oc openapi.OperationContext, u usecase.Interactor, h rest.HandlerTrait) { var ( hasName usecase.HasName hasTitle usecase.HasTitle @@ -235,7 +374,7 @@ func (c *Collector) processUseCase(op *openapi3.Operation, u usecase.Interactor, c.operationIDs[idSuf] = true - op.WithID(idSuf) + oc.SetID(idSuf) } } @@ -243,7 +382,7 @@ func (c *Collector) processUseCase(op *openapi3.Operation, u usecase.Interactor, title := hasTitle.Title() if title != "" { - op.WithSummary(hasTitle.Title()) + oc.SetSummary(hasTitle.Title()) } } @@ -251,7 +390,7 @@ func (c *Collector) processUseCase(op *openapi3.Operation, u usecase.Interactor, tags := hasTags.Tags() if len(tags) > 0 { - op.WithTags(hasTags.Tags()...) + oc.SetTags(hasTags.Tags()...) } } @@ -259,31 +398,27 @@ func (c *Collector) processUseCase(op *openapi3.Operation, u usecase.Interactor, desc := hasDescription.Description() if desc != "" { - op.WithDescription(hasDescription.Description()) + oc.SetDescription(hasDescription.Description()) } } if usecase.As(u, &hasDeprecated) && hasDeprecated.IsDeprecated() { - op.WithDeprecated(true) + oc.SetIsDeprecated(true) } - return c.processExpectedErrors(op, u, h) + c.processOCExpectedErrors(oc, u, h) } -func (c *Collector) setJSONResponse(op *openapi3.Operation, output interface{}, statusCode int) error { - oc := openapi3.OperationContext{} - oc.Operation = op - oc.Output = output - oc.HTTPStatus = statusCode - - if output != nil { - oc.RespContentType = c.DefaultErrorResponseContentType - } - - return c.Reflector().SetupResponse(oc) +func (c *Collector) setOCJSONResponse(oc openapi.OperationContext, output interface{}, statusCode int) { + oc.AddRespStructure(output, func(cu *openapi.ContentUnit) { + cu.HTTPStatus = statusCode + if output != nil { + cu.ContentType = c.DefaultErrorResponseContentType + } + }) } -func (c *Collector) processExpectedErrors(op *openapi3.Operation, u usecase.Interactor, h rest.HandlerTrait) error { +func (c *Collector) processOCExpectedErrors(oc openapi.OperationContext, u usecase.Interactor, h rest.HandlerTrait) { var ( errsByCode = map[int][]interface{}{} statusCodes []int @@ -291,7 +426,7 @@ func (c *Collector) processExpectedErrors(op *openapi3.Operation, u usecase.Inte ) if !usecase.As(u, &hasExpectedErrors) { - return nil + return } for _, e := range hasExpectedErrors.ExpectedErrors() { @@ -316,114 +451,36 @@ func (c *Collector) processExpectedErrors(op *openapi3.Operation, u usecase.Inte errsByCode[statusCode] = append(errsByCode[statusCode], errResp) - if err := c.setJSONResponse(op, errResp, statusCode); err != nil { - return err - } + c.setOCJSONResponse(oc, errResp, statusCode) } - return c.combineErrors(op, statusCodes, errsByCode) + c.combineOCErrors(oc, statusCodes, errsByCode) } -func (c *Collector) combineErrors(op *openapi3.Operation, statusCodes []int, errsByCode map[int][]interface{}) error { +func (c *Collector) combineOCErrors(oc openapi.OperationContext, statusCodes []int, errsByCode map[int][]interface{}) { for _, statusCode := range statusCodes { - var ( - errResps = errsByCode[statusCode] - err error - ) + errResps := errsByCode[statusCode] if len(errResps) == 1 || c.CombineErrors == "" { - err = c.setJSONResponse(op, errResps[0], statusCode) + c.setOCJSONResponse(oc, errResps[0], statusCode) } else { switch c.CombineErrors { case "oneOf": - err = c.setJSONResponse(op, jsonschema.OneOf(errResps...), statusCode) + c.setOCJSONResponse(oc, jsonschema.OneOf(errResps...), statusCode) case "anyOf": - err = c.setJSONResponse(op, jsonschema.AnyOf(errResps...), statusCode) + c.setOCJSONResponse(oc, jsonschema.AnyOf(errResps...), statusCode) default: - return errors.New("oneOf/anyOf expected for openapi.Collector.CombineErrors, " + + panic("oneOf/anyOf expected for openapi.Collector.CombineErrors, " + c.CombineErrors + " received") } } - - if err != nil { - return err - } } - - return nil } type unknownFieldsValidator interface { ForbidUnknownParams(in rest.ParamIn, forbidden bool) } -func (c *Collector) provideParametersJSONSchemas(op openapi3.Operation, validator rest.JSONSchemaValidator) error { - if fv, ok := validator.(unknownFieldsValidator); ok { - for _, in := range []rest.ParamIn{rest.ParamInQuery, rest.ParamInCookie, rest.ParamInHeader} { - if op.UnknownParamIsForbidden(openapi3.ParameterIn(in)) { - fv.ForbidUnknownParams(in, true) - } - } - } - - for _, p := range op.Parameters { - pp := p.Parameter - - required := false - if pp.Required != nil && *pp.Required { - required = true - } - - sc := paramSchema(pp) - - if sc == nil { - if validator != nil { - err := validator.AddSchema(rest.ParamIn(pp.In), pp.Name, nil, required) - if err != nil { - return fmt.Errorf("failed to add validation schema for parameter (%s, %s): %w", pp.In, pp.Name, err) - } - } - - continue - } - - schema := sc.ToJSONSchema(c.Reflector().Spec) - - var ( - err error - schemaData []byte - ) - - if !schema.IsTrivial(c.Reflector().ResolveJSONSchemaRef) { - schemaData, err = schema.JSONSchemaBytes() - if err != nil { - return fmt.Errorf("failed to build JSON Schema for parameter (%s, %s)", pp.In, pp.Name) - } - } - - if validator != nil { - err = validator.AddSchema(rest.ParamIn(pp.In), pp.Name, schemaData, required) - if err != nil { - return fmt.Errorf("failed to add validation schema for parameter (%s, %s): %w", pp.In, pp.Name, err) - } - } - } - - return nil -} - -func paramSchema(p *openapi3.Parameter) *openapi3.SchemaOrRef { - sc := p.Schema - - if sc == nil { - if jsc, ok := p.Content["application/json"]; ok { - sc = jsc.Schema - } - } - - return sc -} - // ProvideRequestJSONSchemas provides JSON Schemas for request structure. func (c *Collector) ProvideRequestJSONSchemas( method string, @@ -431,126 +488,26 @@ func (c *Collector) ProvideRequestJSONSchemas( mapping rest.RequestMapping, validator rest.JSONSchemaValidator, ) error { - op := openapi3.Operation{} - oc := openapi3.OperationContext{ - Operation: &op, - HTTPMethod: method, - Input: input, - } - - setRequestMapping(&oc, mapping) - - err := c.Reflector().SetupRequest(oc) - if err != nil { - return err - } - - err = c.provideParametersJSONSchemas(op, validator) - if err != nil { - return err - } - - if op.RequestBody == nil || op.RequestBody.RequestBody == nil { - return nil - } - - for ct, content := range op.RequestBody.RequestBody.Content { - schema := content.Schema.ToJSONSchema(c.Reflector().Spec) - if schema.IsTrivial(c.Reflector().ResolveJSONSchemaRef) { - continue - } - - if ct == "application/json" { - schemaData, err := schema.JSONSchemaBytes() - if err != nil { - return fmt.Errorf("failed to build JSON Schema for request body: %w", err) - } - - err = validator.AddSchema(rest.ParamInBody, "body", schemaData, false) - if err != nil { - return fmt.Errorf("failed to add validation schema for request body: %w", err) - } - } - - if ct == "application/x-www-form-urlencoded" { - if err = provideFormDataSchemas(schema, validator); err != nil { - return err - } - } - } - - return nil -} - -func provideFormDataSchemas(schema jsonschema.SchemaOrBool, validator rest.JSONSchemaValidator) error { - for name, sch := range schema.TypeObject.Properties { - if sch.TypeObject != nil && len(schema.TypeObject.ExtraProperties) > 0 { - cp := *sch.TypeObject - sch.TypeObject = &cp - sch.TypeObject.ExtraProperties = schema.TypeObject.ExtraProperties - } - - sb, err := sch.JSONSchemaBytes() - if err != nil { - return fmt.Errorf("failed to build JSON Schema for form data parameter %q: %w", name, err) - } - - isRequired := false - - for _, req := range schema.TypeObject.Required { - if req == name { - isRequired = true - - break - } - } - - err = validator.AddSchema(rest.ParamInFormData, name, sb, isRequired) - if err != nil { - return fmt.Errorf("failed to add validation schema for request body: %w", err) - } - } - - return nil -} - -func (c *Collector) provideHeaderSchemas(resp *openapi3.Response, validator rest.JSONSchemaValidator) error { - for name, h := range resp.Headers { - if h.Header.Schema == nil { - continue - } - - hh := h.Header - schema := hh.Schema.ToJSONSchema(c.Reflector().Spec) + cu := openapi.ContentUnit{} + cu.Structure = input + setFieldMapping(&cu, mapping) - var ( - err error - schemaData []byte - ) - - if !schema.IsTrivial(c.Reflector().ResolveJSONSchemaRef) { - schemaData, err = schema.JSONSchemaBytes() - if err != nil { - return fmt.Errorf("failed to build JSON Schema for response header (%s)", name) - } - } + r := c.Refl() - required := false - if hh.Required != nil && *hh.Required { - required = true + err := r.WalkRequestJSONSchemas(method, cu, c.jsonSchemaCallback(validator, r), func(oc openapi.OperationContext) { + fv, ok := validator.(unknownFieldsValidator) + if !ok { + return } - if validator != nil { - name = http.CanonicalHeaderKey(name) - - err = validator.AddSchema(rest.ParamInHeader, name, schemaData, required) - if err != nil { - return fmt.Errorf("failed to add validation schema for response header (%s): %w", name, err) + for _, in := range []openapi.In{openapi.InQuery, openapi.InCookie, openapi.InHeader} { + if oc.UnknownParamsAreForbidden(in) { + fv.ForbidUnknownParams(rest.ParamIn(in), true) } } - } + }) - return nil + return err } // ProvideResponseJSONSchemas provides JSON schemas for response structure. @@ -561,58 +518,55 @@ func (c *Collector) ProvideResponseJSONSchemas( headerMapping map[string]string, validator rest.JSONSchemaValidator, ) error { - op := openapi3.Operation{} - oc := openapi3.OperationContext{ - Operation: &op, - HTTPStatus: statusCode, - Output: output, - RespHeaderMapping: headerMapping, - RespContentType: contentType, - } + cu := openapi.ContentUnit{} + cu.Structure = output + cu.SetFieldMapping(openapi.InHeader, headerMapping) + cu.ContentType = contentType + cu.HTTPStatus = statusCode - if oc.RespContentType == "" { - oc.RespContentType = c.DefaultSuccessResponseContentType + if cu.ContentType == "" { + cu.ContentType = c.DefaultSuccessResponseContentType } - if err := c.Reflector().SetupResponse(oc); err != nil { - return err - } + r := c.Refl() + err := r.WalkResponseJSONSchemas(cu, c.jsonSchemaCallback(validator, r), nil) - resp := op.Responses.MapOfResponseOrRefValues[strconv.Itoa(statusCode)].Response - - if err := c.provideHeaderSchemas(resp, validator); err != nil { - return err - } + return err +} - for _, cont := range resp.Content { - if cont.Schema == nil { - continue +func (c *Collector) jsonSchemaCallback(validator rest.JSONSchemaValidator, r openapi.Reflector) openapi.JSONSchemaCallback { + return func(in openapi.In, paramName string, schema *jsonschema.SchemaOrBool, required bool) error { + loc := string(in) + "." + paramName + if loc == "body.body" { + loc = "body" } - schema := cont.Schema.ToJSONSchema(c.Reflector().Spec) + if schema == nil || schema.IsTrivial(r.ResolveJSONSchemaRef) { + if err := validator.AddSchema(rest.ParamIn(in), paramName, nil, required); err != nil { + return fmt.Errorf("add validation schema %s: %w", loc, err) + } - if schema.IsTrivial(c.Reflector().ResolveJSONSchemaRef) { - continue + return nil } schemaData, err := schema.JSONSchemaBytes() if err != nil { - return errors.New("failed to build JSON Schema for response body") + return fmt.Errorf("marshal schema %s: %w", loc, err) } - if err := validator.AddSchema(rest.ParamInBody, "body", schemaData, false); err != nil { - return fmt.Errorf("failed to add validation schema for response body: %w", err) + if err = validator.AddSchema(rest.ParamIn(in), paramName, schemaData, required); err != nil { + return fmt.Errorf("add validation schema %s: %w", loc, err) } - } - return nil + return nil + } } func (c *Collector) ServeHTTP(rw http.ResponseWriter, _ *http.Request) { c.mu.Lock() defer c.mu.Unlock() - document, err := json.MarshalIndent(c.Reflector().Spec, "", " ") + document, err := json.MarshalIndent(c.SpecSchema(), "", " ") if err != nil { http.Error(rw, err.Error(), http.StatusInternalServerError) } diff --git a/openapi/collector_test.go b/openapi/collector_test.go index 6854dc1..12675c0 100644 --- a/openapi/collector_test.go +++ b/openapi/collector_test.go @@ -97,7 +97,7 @@ func TestCollector_Collect(t *testing.T) { ReqValidator: &jsonschema.Validator{}, })) - j, err := json.MarshalIndent(c.Reflector().Spec, "", " ") + j, err := json.MarshalIndent(c.SpecSchema(), "", " ") require.NoError(t, err) rw := httptest.NewRecorder() @@ -223,7 +223,7 @@ func TestCollector_Collect_requestMapping(t *testing.T) { } } } - }`, collector.Reflector().SpecEns()) + }`, collector.SpecSchema()) val := validatorMock{ AddSchemaFunc: func(in rest.ParamIn, name string, schemaData []byte, required bool) error { @@ -321,7 +321,7 @@ func TestCollector_Collect_CombineErrors(t *testing.T) { } } } - }`, collector.Reflector().SpecEns()) + }`, collector.SpecSchema()) } // Output that implements OutputWithHTTPStatus interface. @@ -397,7 +397,7 @@ func TestCollector_Collect_multipleHttpStatuses(t *testing.T) { } } } - }`, c.Reflector().SpecEns()) + }`, c.SpecSchema()) } func TestCollector_Collect_queryObject(t *testing.T) { @@ -465,5 +465,57 @@ func TestCollector_Collect_queryObject(t *testing.T) { "QueryOpenapiTestJsonFilter":{"type":"object","properties":{"foo":{"type":"string"}}} } } - }`, c.Reflector().SpecEns()) + }`, c.SpecSchema()) +} + +func TestCollector_Collect_head_no_response(t *testing.T) { + c := openapi.Collector{} + u := usecase.IOInteractor{} + + type resp struct { + Foo string `json:"foo"` + Bar string `header:"X-Bar"` + } + + u.Output = new(resp) + + require.NoError(t, c.Collect(http.MethodHead, "/foo", u, rest.HandlerTrait{ + ReqValidator: &jsonschema.Validator{}, + })) + + require.NoError(t, c.Collect(http.MethodGet, "/foo", u, rest.HandlerTrait{ + ReqValidator: &jsonschema.Validator{}, + })) + + assertjson.EqMarshal(t, `{ + "openapi":"3.0.3","info":{"title":"","version":""}, + "paths":{ + "/foo":{ + "get":{ + "responses":{ + "200":{ + "description":"OK", + "headers":{"X-Bar":{"style":"simple","schema":{"type":"string"}}}, + "content":{ + "application/json":{"schema":{"$ref":"#/components/schemas/OpenapiTestResp"}} + } + } + } + }, + "head":{ + "responses":{ + "200":{ + "description":"OK", + "headers":{"X-Bar":{"style":"simple","schema":{"type":"string"}}} + } + } + } + } + }, + "components":{ + "schemas":{ + "OpenapiTestResp":{"type":"object","properties":{"foo":{"type":"string"}}} + } + } + }`, c.SpecSchema()) } diff --git a/request/factory.go b/request/factory.go index a669730..34b993d 100644 --- a/request/factory.go +++ b/request/factory.go @@ -11,7 +11,7 @@ import ( "strings" "github.com/swaggest/form/v5" - "github.com/swaggest/openapi-go/openapi3" + "github.com/swaggest/openapi-go" "github.com/swaggest/refl" "github.com/swaggest/rest" "github.com/swaggest/rest/nethttp" @@ -126,7 +126,7 @@ func (df *DecoderFactory) MakeDecoder( method = strings.ToUpper(method) - _, forceRequestBody := input.(openapi3.RequestBodyEnforcer) + _, forceRequestBody := input.(openapi.RequestBodyEnforcer) if method != http.MethodPost && method != http.MethodPut && method != http.MethodPatch && !forceRequestBody { return &d diff --git a/request/file_test.go b/request/file_test.go index 09b776f..5bf68bb 100644 --- a/request/file_test.go +++ b/request/file_test.go @@ -12,6 +12,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/rest/chirouter" "github.com/swaggest/rest/jsonschema" @@ -62,14 +63,14 @@ func TestDecoder_Decode_fileUploadOptional(t *testing.T) { func TestDecoder_Decode_fileUploadTag(t *testing.T) { r := chirouter.NewWrapper(chi.NewRouter()) - apiSchema := openapi.Collector{} + apiSchema := openapi.NewCollector(openapi3.NewReflector()) decoderFactory := request.NewDecoderFactory() - validatorFactory := jsonschema.NewFactory(&apiSchema, &apiSchema) + validatorFactory := jsonschema.NewFactory(apiSchema, apiSchema) decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues) ws := []func(handler http.Handler) http.Handler{ - nethttp.OpenAPIMiddleware(&apiSchema), + nethttp.OpenAPIMiddleware(apiSchema), request.DecoderMiddleware(decoderFactory), request.ValidatorMiddleware(validatorFactory), response.EncoderMiddleware, diff --git a/trait.go b/trait.go index e9c819d..33c28ca 100644 --- a/trait.go +++ b/trait.go @@ -6,6 +6,7 @@ import ( "net/http" "reflect" + "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/refl" "github.com/swaggest/usecase" @@ -39,7 +40,12 @@ type HandlerTrait struct { RespValidator Validator // OperationAnnotations are called after operation setup and before adding operation to documentation. + // + // Deprecated: use OpenAPIAnnotations. OperationAnnotations []func(op *openapi3.Operation) error + + // OpenAPIAnnotations are called after operation setup and before adding operation to documentation. + OpenAPIAnnotations []func(oc openapi.OperationContext) error } // RestHandler is an accessor. diff --git a/web/_testdata/openapi.json b/web/_testdata/openapi.json index 76033e8..6ab353f 100644 --- a/web/_testdata/openapi.json +++ b/web/_testdata/openapi.json @@ -139,7 +139,7 @@ "name":"id","in":"path","required":true,"schema":{"type":"integer"} } ], - "responses":{"200":{"description":"OK","content":{"application/json":{}}}} + "responses":{"200":{"description":"OK"}} } } }, diff --git a/web/service.go b/web/service.go index 4954532..c6f34b6 100644 --- a/web/service.go +++ b/web/service.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + oapi "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/rest/chirouter" @@ -34,13 +35,15 @@ func DefaultService(options ...func(s *Service, initialized bool)) *Service { // Init API documentation schema. if s.OpenAPICollector == nil { - c := &openapi.Collector{} + r := openapi3.NewReflector() + r.Spec = s.OpenAPI + + c := openapi.NewCollector(r) c.DefaultSuccessResponseContentType = response.DefaultSuccessResponseContentType c.DefaultErrorResponseContentType = response.DefaultErrorResponseContentType s.OpenAPICollector = c - s.OpenAPICollector.Reflector().Spec = s.OpenAPI } if s.Wrapper == nil { @@ -83,9 +86,12 @@ type Service struct { *chirouter.Wrapper PanicRecoveryMiddleware func(handler http.Handler) http.Handler // Default is middleware.Recoverer. - OpenAPI *openapi3.Spec - OpenAPICollector *openapi.Collector - DecoderFactory *request.DecoderFactory + + // Deprecated: use openapi.Collector. + OpenAPI *openapi3.Spec + + OpenAPICollector *openapi.Collector + DecoderFactory *request.DecoderFactory // Response validation is not enabled by default for its less justifiable performance impact. // This field is populated so that response.ValidatorMiddleware(s.ResponseValidatorFactory) can be @@ -93,6 +99,18 @@ type Service struct { ResponseValidatorFactory rest.ResponseValidatorFactory } +// OpenAPISchema returns OpenAPI schema. +// +// Returned value can be type asserted to *openapi3.Spec or marshaled. +func (s *Service) OpenAPISchema() oapi.SpecSchema { + return s.OpenAPICollector.SpecSchema() +} + +// OpenAPIReflector returns OpenAPI structure reflector for customizations. +func (s *Service) OpenAPIReflector() oapi.Reflector { + return s.OpenAPICollector.Refl() +} + // Delete adds the route `pattern` that matches a DELETE http method to invoke use case interactor. func (s *Service) Delete(pattern string, uc usecase.Interactor, options ...func(h *nethttp.Handler)) { s.Method(http.MethodDelete, pattern, nethttp.NewHandler(uc, options...)) @@ -137,6 +155,9 @@ func (s *Service) Trace(pattern string, uc usecase.Interactor, options ...func(h // // Swagger UI should be provided by `swgui` handler constructor, you can use one of these functions // +// github.com/swaggest/swgui/v5emb.New +// github.com/swaggest/swgui/v5cdn.New +// github.com/swaggest/swgui/v5.New // github.com/swaggest/swgui/v4emb.New // github.com/swaggest/swgui/v4cdn.New // github.com/swaggest/swgui/v4.New