From 82f352f4db33e31fd1ef0dbb216e9712a4a4196d Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Tue, 8 Aug 2023 21:21:24 +0200 Subject: [PATCH] Add OpenAPI 3.1 support (#166) --- ADVANCED.md | 98 +++ README.md | 155 ++-- .../_testdata/openapi.json | 817 ++++++++++++++++++ _examples/advanced-generic-openapi31/dummy.go | 16 + .../error_response.go | 57 ++ .../file_multi_upload.go | 78 ++ .../advanced-generic-openapi31/file_upload.go | 82 ++ _examples/advanced-generic-openapi31/form.go | 35 + .../gzip_pass_through.go | 92 ++ .../gzip_pass_through_test.go | 154 ++++ .../html_response.go | 70 ++ .../html_response_test.go | 58 ++ .../advanced-generic-openapi31/json_body.go | 47 + .../json_body_manual.go | 87 ++ .../json_body_manual_test.go | 30 + .../json_body_test.go | 30 + .../json_body_validation.go | 45 + .../json_body_validation_test.go | 30 + .../json_map_body.go | 43 + .../advanced-generic-openapi31/json_param.go | 47 + .../json_slice_body.go | 44 + _examples/advanced-generic-openapi31/main.go | 15 + .../no_validation.go | 41 + .../output_headers.go | 45 + .../output_headers_test.go | 136 +++ .../output_writer.go | 47 + .../query_object.go | 45 + .../query_object_test.go | 82 ++ .../request_response_mapping.go | 35 + .../request_response_mapping_test.go | 59 ++ .../request_text_body.go | 69 ++ .../advanced-generic-openapi31/router.go | 238 +++++ .../advanced-generic-openapi31/router_test.go | 37 + .../advanced-generic-openapi31/validation.go | 41 + .../validation_test.go | 48 + .../advanced-generic/_testdata/openapi.json | 2 +- _examples/advanced-generic/router.go | 12 +- _examples/advanced/_testdata/openapi.json | 2 +- _examples/advanced/router.go | 10 +- _examples/basic/main.go | 3 +- _examples/generic/main.go | 3 +- _examples/gingonic/main.go | 25 +- _examples/go.mod | 14 +- _examples/go.sum | 10 +- _examples/mount/main.go | 5 +- .../task-api/internal/infra/nethttp/router.go | 3 +- .../task-api/internal/infra/schema/openapi.go | 11 +- chirouter/wrapper_test.go | 5 +- go.mod | 4 +- go.sum | 8 +- gorillamux/collector.go | 24 +- gorillamux/example_openapi_collector_test.go | 3 + nethttp/example_test.go | 29 +- nethttp/openapi.go | 65 +- nethttp/openapi_test.go | 6 +- nethttp/options_test.go | 2 +- openapi/collector_test.go | 18 +- request/factory.go | 2 +- request/file_test.go | 2 +- web/example_test.go | 9 +- web/service.go | 48 +- web/service_test.go | 25 +- 62 files changed, 3122 insertions(+), 281 deletions(-) create mode 100644 ADVANCED.md create mode 100644 _examples/advanced-generic-openapi31/_testdata/openapi.json create mode 100644 _examples/advanced-generic-openapi31/dummy.go create mode 100644 _examples/advanced-generic-openapi31/error_response.go create mode 100644 _examples/advanced-generic-openapi31/file_multi_upload.go create mode 100644 _examples/advanced-generic-openapi31/file_upload.go create mode 100644 _examples/advanced-generic-openapi31/form.go create mode 100644 _examples/advanced-generic-openapi31/gzip_pass_through.go create mode 100644 _examples/advanced-generic-openapi31/gzip_pass_through_test.go create mode 100644 _examples/advanced-generic-openapi31/html_response.go create mode 100644 _examples/advanced-generic-openapi31/html_response_test.go create mode 100644 _examples/advanced-generic-openapi31/json_body.go create mode 100644 _examples/advanced-generic-openapi31/json_body_manual.go create mode 100644 _examples/advanced-generic-openapi31/json_body_manual_test.go create mode 100644 _examples/advanced-generic-openapi31/json_body_test.go create mode 100644 _examples/advanced-generic-openapi31/json_body_validation.go create mode 100644 _examples/advanced-generic-openapi31/json_body_validation_test.go create mode 100644 _examples/advanced-generic-openapi31/json_map_body.go create mode 100644 _examples/advanced-generic-openapi31/json_param.go create mode 100644 _examples/advanced-generic-openapi31/json_slice_body.go create mode 100644 _examples/advanced-generic-openapi31/main.go create mode 100644 _examples/advanced-generic-openapi31/no_validation.go create mode 100644 _examples/advanced-generic-openapi31/output_headers.go create mode 100644 _examples/advanced-generic-openapi31/output_headers_test.go create mode 100644 _examples/advanced-generic-openapi31/output_writer.go create mode 100644 _examples/advanced-generic-openapi31/query_object.go create mode 100644 _examples/advanced-generic-openapi31/query_object_test.go create mode 100644 _examples/advanced-generic-openapi31/request_response_mapping.go create mode 100644 _examples/advanced-generic-openapi31/request_response_mapping_test.go create mode 100644 _examples/advanced-generic-openapi31/request_text_body.go create mode 100644 _examples/advanced-generic-openapi31/router.go create mode 100644 _examples/advanced-generic-openapi31/router_test.go create mode 100644 _examples/advanced-generic-openapi31/validation.go create mode 100644 _examples/advanced-generic-openapi31/validation_test.go diff --git a/ADVANCED.md b/ADVANCED.md new file mode 100644 index 0000000..c190f2e --- /dev/null +++ b/ADVANCED.md @@ -0,0 +1,98 @@ +# Advanced Usage and Fine-tuning + +In most cases you would not need to touch these APIs directly, and instead you may find `web.Service` sufficient. + +If that's not the case, this document covers internal components. + +## Creating Modular Use Case Interactor + +For modularity particular use case interactor instance can be assembled by embedding relevant traits in a struct, +for example you can skip adding `usecase.WithInput` if your use case does not imply any input. + +```go +// Create use case interactor. +u := struct { + usecase.Info + usecase.Interactor + usecase.WithInput + usecase.WithOutput +}{} + +// Describe use case interactor. +u.SetTitle("Greeter") +u.SetDescription("Greeter greets you.") +u.Input = new(helloInput) +u.Output = new(helloOutput) +u.Interactor = usecase.Interact(func(ctx context.Context, input, output interface{}) error { + // Do something about input to prepare output. + return nil +}) +``` + +## Adding use case to router + +```go +// Add use case handler to router. +r.Method(http.MethodGet, "/hello/{name}", nethttp.NewHandler(u)) +``` + +## API Schema Collector + +OpenAPI schema should be initialized with general information about REST API. + +It uses [type-safe mapping](https://github.com/swaggest/openapi-go) for the configuration, +so any IDE will help with available fields. + +```go +// Init API documentation schema. +apiSchema := openapi.NewCollector(openapi31.NewReflector()) +apiSchema.SpecSchema().SetTitle("Basic Example") +apiSchema.SpecSchema().SetDescription("This app showcases a trivial REST API.") +apiSchema.SpecSchema().SetVersion("v1.2.3") +``` + +## Router Setup + +REST router is based on [`github.com/go-chi/chi`](https://github.com/go-chi/chi), wrapper allows unwrapping instrumented +handler in middleware. + +These middlewares are required: +* `nethttp.OpenAPIMiddleware(apiSchema)`, +* `request.DecoderMiddleware(decoderFactory)`, +* `response.EncoderMiddleware`. + +Optionally you can add more middlewares with some performance impact: +* `request.ValidatorMiddleware(validatorFactory)` (request validation, recommended) +* `response.ValidatorMiddleware(validatorFactory)` +* `gzip.Middleware` + +You can also add any other 3rd party middlewares compatible with `net/http` at your discretion. + +```go +// Setup request decoder and validator. +validatorFactory := jsonschema.NewFactory(apiSchema, apiSchema) +decoderFactory := request.NewDecoderFactory() +decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues) + +// Create router. +r := chirouter.NewWrapper(chi.NewRouter()) + +// Setup middlewares. +r.Use( + middleware.Recoverer, // Panic recovery. + nethttp.OpenAPIMiddleware(apiSchema), // Documentation collector. + request.DecoderMiddleware(decoderFactory), // Request decoder setup. + request.ValidatorMiddleware(validatorFactory), // Request validator setup. + response.EncoderMiddleware, // Response encoder setup. + gzip.Middleware, // Response compression with support for direct gzip pass through. +) +``` + +Register Swagger UI to serve documentation at `/docs`. + +```go +// Swagger UI endpoint at /docs. +r.Method(http.MethodGet, "/docs/openapi.json", apiSchema) +r.Mount("/docs", v3cdn.NewHandler(apiSchema.Reflector().Spec.Info.Title, + "/docs/openapi.json", "/docs")) +``` diff --git a/README.md b/README.md index b442972..571187a 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ to build REST services. * Modular flexible structure. * HTTP [request mapping](#request-decoder) into Go value based on field tags. * Decoupled business logic with Clean Architecture use cases. -* Automatic type-safe OpenAPI 3 documentation with [`github.com/swaggest/openapi-go`](https://github.com/swaggest/openapi-go). +* Automatic type-safe OpenAPI 3.0/3.1 documentation with [`github.com/swaggest/openapi-go`](https://github.com/swaggest/openapi-go). * Single source of truth for the documentation and endpoint interface. * Automatic request/response JSON schema validation with [`github.com/santhosh-tekuri/jsonschema`](https://github.com/santhosh-tekuri/jsonschema). * Dynamic gzip compression and fast pass through mode. @@ -200,29 +200,6 @@ u := usecase.NewInteractor(func(ctx context.Context, input helloInput, output *h }) ``` -For modularity particular use case interactor instance can be assembled by embedding relevant traits in a struct, -for example you can skip adding `usecase.WithInput` if your use case does not imply any input. - -```go -// Create use case interactor. -u := struct { - usecase.Info - usecase.Interactor - usecase.WithInput - usecase.WithOutput -}{} - -// Describe use case interactor. -u.SetTitle("Greeter") -u.SetDescription("Greeter greets you.") -u.Input = new(helloInput) -u.Output = new(helloOutput) -u.Interactor = usecase.Interact(func(ctx context.Context, input, output interface{}) error { - // Do something about input to prepare output. - return nil -}) -``` - ### Initializing Web Service [Web Service](https://pkg.go.dev/github.com/swaggest/rest/web#DefaultService) is an instrumented facade in front of @@ -230,12 +207,12 @@ router, it simplifies configuration and provides more compact API to add use cas ```go // Service initializes router with required middlewares. -service := web.DefaultService() +service := web.NewService(openapi31.NewReflector()) // It allows OpenAPI configuration. -service.OpenAPI.Info.Title = "Albums API" -service.OpenAPI.Info.WithDescription("This service provides API to manage albums.") -service.OpenAPI.Info.Version = "v1.0.0" +service.OpenAPISchema().SetTitle("Albums API") +service.OpenAPISchema().SetDescription("This service provides API to manage albums.") +service.OpenAPISchema().SetVersion("v1.0.0") // Additional middlewares can be added. service.Use( @@ -258,76 +235,11 @@ if err := http.ListenAndServe("localhost:8080", service); err != nil { Usually, `web.Service` API is sufficient, but if it is not, router can be configured manually, please check the documentation below. -### Adding use case to router - -```go -// Add use case handler to router. -r.Method(http.MethodGet, "/hello/{name}", nethttp.NewHandler(u)) -``` - -## API Schema Collector - -OpenAPI schema should be initialized with general information about REST API. - -It uses [type-safe mapping](https://github.com/swaggest/openapi-go) for the configuration, -so any IDE will help with available fields. - -```go -// Init API documentation schema. -apiSchema := &openapi.Collector{} -apiSchema.Reflector().SpecEns().Info.Title = "Basic Example" -apiSchema.Reflector().SpecEns().Info.WithDescription("This app showcases a trivial REST API.") -apiSchema.Reflector().SpecEns().Info.Version = "v1.2.3" -``` - -## Router Setup - -REST router is based on [`github.com/go-chi/chi`](https://github.com/go-chi/chi), wrapper allows unwrapping instrumented -handler in middleware. - -These middlewares are required: -* `nethttp.OpenAPIMiddleware(apiSchema)`, -* `request.DecoderMiddleware(decoderFactory)`, -* `response.EncoderMiddleware`. - -Optionally you can add more middlewares with some performance impact: -* `request.ValidatorMiddleware(validatorFactory)` (request validation, recommended) -* `response.ValidatorMiddleware(validatorFactory)` -* `gzip.Middleware` - -You can also add any other 3rd party middlewares compatible with `net/http` at your discretion. - -```go -// Setup request decoder and validator. -validatorFactory := jsonschema.NewFactory(apiSchema, apiSchema) -decoderFactory := request.NewDecoderFactory() -decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues) - -// Create router. -r := chirouter.NewWrapper(chi.NewRouter()) - -// Setup middlewares. -r.Use( - middleware.Recoverer, // Panic recovery. - nethttp.OpenAPIMiddleware(apiSchema), // Documentation collector. - request.DecoderMiddleware(decoderFactory), // Request decoder setup. - request.ValidatorMiddleware(validatorFactory), // Request validator setup. - response.EncoderMiddleware, // Response encoder setup. - gzip.Middleware, // Response compression with support for direct gzip pass through. -) -``` - -Register Swagger UI to serve documentation at `/docs`. - -```go -// Swagger UI endpoint at /docs. -r.Method(http.MethodGet, "/docs/openapi.json", apiSchema) -r.Mount("/docs", v3cdn.NewHandler(apiSchema.Reflector().Spec.Info.Title, - "/docs/openapi.json", "/docs")) -``` ## Security Setup +Example with HTTP Basic Auth. + ```go // Prepare middleware with suitable security schema. // It will perform actual security check for every relevant request. @@ -340,12 +252,46 @@ adminSecuritySchema := nethttp.HTTPBasicSecurityMiddleware(apiSchema, "Admin", " // Endpoints with admin access. r.Route("/admin", func(r chi.Router) { r.Group(func(r chi.Router) { - r.Wrap(adminAuth, adminSecuritySchema) // Add both middlewares to routing group to enforce and document security. + r.Use(adminAuth, adminSecuritySchema) // Add both middlewares to routing group to enforce and document security. r.Method(http.MethodPut, "/hello/{name}", nethttp.NewHandler(u)) }) }) ``` +Example with cookie. + +```go +// Security middlewares. +// - sessMW is the actual request-level processor, +// - sessDoc is a handler-level wrapper to expose docs. +sessMW := func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("sessid"); err == nil { + r = r.WithContext(context.WithValue(r.Context(), "sessionID", c.Value)) + } + + handler.ServeHTTP(w, r) + }) +} + +sessDoc := nethttp.APIKeySecurityMiddleware(s.OpenAPICollector, "User", + "sessid", oapi.InCookie, "Session cookie.") + +// Security schema is configured for a single top-level route. +s.With(sessMW, sessDoc).Method(http.MethodGet, "/root-with-session", nethttp.NewHandler(dummy())) + +// Security schema is configured on a sub-router. +s.Route("/deeper-with-session", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(sessMW, sessDoc) + + r.Method(http.MethodGet, "/one", nethttp.NewHandler(dummy())) + r.Method(http.MethodGet, "/two", nethttp.NewHandler(dummy())) + }) +}) + +``` + See [example](./_examples/task-api/internal/infra/nethttp/router.go). ## Handler Setup @@ -372,20 +318,21 @@ import ( "net/http" "time" + "github.com/swaggest/openapi-go/openapi31" "github.com/swaggest/rest/response/gzip" "github.com/swaggest/rest/web" - swgui "github.com/swaggest/swgui/v4emb" + swgui "github.com/swaggest/swgui/v5emb" "github.com/swaggest/usecase" "github.com/swaggest/usecase/status" ) func main() { - s := web.DefaultService() + s := web.NewService(openapi31.NewReflector()) // 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( @@ -443,7 +390,7 @@ func main() { // Start server. log.Println("http://localhost:8011/docs") - if err := http.ListenAndServe(":8011", s); err != nil { + if err := http.ListenAndServe("localhost:8011", s); err != nil { log.Fatal(err) } } @@ -484,3 +431,7 @@ Before version `1.0.0`, breaking changes are tagged with `MINOR` bump, features After version `1.0.0`, breaking changes are tagged with `MAJOR` bump. Breaking changes are described in [UPGRADE.md](./UPGRADE.md). + +## Advanced Usage + +[Advanced Usage](./ADVANCED.md) \ No newline at end of file diff --git a/_examples/advanced-generic-openapi31/_testdata/openapi.json b/_examples/advanced-generic-openapi31/_testdata/openapi.json new file mode 100644 index 0000000..b539ff3 --- /dev/null +++ b/_examples/advanced-generic-openapi31/_testdata/openapi.json @@ -0,0 +1,817 @@ +{ + "openapi":"3.1.0", + "info":{"title":"Advanced Example","description":"This app showcases a variety of features.","version":"v1.2.3"}, + "paths":{ + "/deeper-with-session/one":{ + "get":{ + "tags":["Other"],"summary":"Dummy","operationId":"_examples/advanced-generic-openapi31.dummy2", + "responses":{ + "204":{"description":"No Content"}, + "401":{ + "description":"Unauthorized", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}} + } + }, + "security":[{"User":[]}] + } + }, + "/deeper-with-session/two":{ + "get":{ + "tags":["Other"],"summary":"Dummy","operationId":"_examples/advanced-generic-openapi31.dummy3", + "responses":{ + "204":{"description":"No Content"}, + "401":{ + "description":"Unauthorized", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}} + } + }, + "security":[{"User":[]}] + } + }, + "/error-response":{ + "get":{ + "tags":["Response"],"summary":"Declare Expected Errors", + "description":"This use case demonstrates documentation of expected errors.", + "operationId":"_examples/advanced-generic-openapi31.errorResponse", + "parameters":[ + { + "name":"type","in":"query","required":true, + "schema":{"enum":["ok","invalid_argument","conflict"],"type":"string"} + } + ], + "responses":{ + "200":{ + "description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOkResp"}}} + }, + "400":{ + "description":"Bad Request", + "content":{ + "application/json":{ + "schema":{ + "anyOf":[ + {"$ref":"#/components/schemas/AdvancedCustomErr"}, + {"$ref":"#/components/schemas/AdvancedAnotherErr"} + ] + } + } + } + }, + "409":{ + "description":"Conflict", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}} + }, + "412":{ + "description":"Precondition Failed", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}} + } + }, + "x-forbid-unknown-query":true + } + }, + "/file-multi-upload":{ + "post":{ + "tags":["Request"],"summary":"Files Uploads With 'multipart/form-data'", + "operationId":"_examples/advanced-generic-openapi31.fileMultiUploader", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","type":"integer"} + } + ], + "requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataAdvancedUploadType2"}}}}, + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInfoType2"}}} + } + }, + "x-forbid-unknown-query":true + } + }, + "/file-upload":{ + "post":{ + "tags":["Request"],"summary":"File Upload With 'multipart/form-data'", + "operationId":"_examples/advanced-generic-openapi31.fileUploader", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","type":"integer"} + } + ], + "requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/FormDataAdvancedUpload"}}}}, + "responses":{ + "200":{"description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInfo"}}}} + }, + "x-forbid-unknown-query":true + } + }, + "/form":{ + "post":{ + "tags":["Request"],"summary":"Request With Form", + "description":"The `form` field tag acts as `query` and `formData`, with priority on `formData`.\n\nIt is decoded with `http.Request.Form` values.", + "operationId":"_examples/advanced-generic-openapi31.form", + "parameters":[ + {"name":"id","in":"query","schema":{"type":"integer"}}, + {"name":"name","in":"query","schema":{"type":"string"}} + ], + "requestBody":{ + "content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/FormDataAdvancedForm"}}} + }, + "responses":{ + "200":{ + "description":"OK","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutput"}}} + } + }, + "x-forbid-unknown-query":true + } + }, + "/gzip-pass-through":{ + "get":{ + "tags":["Response"],"summary":"Direct Gzip","operationId":"_examples/advanced-generic-openapi31.directGzip", + "parameters":[ + { + "name":"plainStruct","in":"query","description":"Output plain structure instead of gzip container.", + "schema":{"description":"Output plain structure instead of gzip container.","type":"boolean"} + }, + { + "name":"countItems","in":"query","description":"Invokes internal decoding of compressed data.", + "schema":{"description":"Invokes internal decoding of compressed data.","type":"boolean"} + } + ], + "responses":{ + "200":{ + "description":"OK","headers":{"X-Header":{"style":"simple","schema":{"type":"string"}}}, + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedGzipPassThroughStruct"}}} + } + }, + "x-forbid-unknown-query":true + }, + "head":{ + "tags":["Response"],"summary":"Direct Gzip","operationId":"_examples/advanced-generic-openapi31.directGzip2", + "parameters":[ + { + "name":"plainStruct","in":"query","description":"Output plain structure instead of gzip container.", + "schema":{"description":"Output plain structure instead of gzip container.","type":"boolean"} + }, + { + "name":"countItems","in":"query","description":"Invokes internal decoding of compressed data.", + "schema":{"description":"Invokes internal decoding of compressed data.","type":"boolean"} + } + ], + "responses":{"200":{"description":"OK","headers":{"X-Header":{"style":"simple","schema":{"type":"string"}}}}}, + "x-forbid-unknown-query":true + } + }, + "/html-response/{id}":{ + "get":{ + "tags":["Response"],"summary":"Request With HTML Response", + "description":"Request with templated HTML response.", + "operationId":"_examples/advanced-generic-openapi31.htmlResponse", + "parameters":[ + {"name":"filter","in":"query","schema":{"type":"string"}}, + {"name":"id","in":"path","required":true,"schema":{"type":"integer"}}, + {"name":"X-Header","in":"header","schema":{"type":"boolean"}} + ], + "responses":{ + "200":{ + "description":"OK","headers":{"X-Anti-Header":{"style":"simple","schema":{"type":"boolean"}}}, + "content":{"text/html":{"schema":{"type":"string"}}} + } + }, + "x-forbid-unknown-path":true,"x-forbid-unknown-query":true + } + }, + "/json-body-manual/{in-path}":{ + "post":{ + "tags":["Request"],"summary":"Request With JSON Body and manual decoder", + "description":"Request with JSON body and query/header/path params, response with JSON body and data from request.", + "operationId":"_examples/advanced-generic-openapi31.jsonBodyManual", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","format":"date","type":"string"} + }, + { + "name":"in-path","in":"path","description":"Simple scalar value in path","required":true, + "schema":{"description":"Simple scalar value in path","type":"string"} + }, + { + "name":"X-Header","in":"header","description":"Simple scalar value in header.", + "schema":{"description":"Simple scalar value in header.","type":"string"} + } + ], + "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInputWithJSONType3"}}}}, + "responses":{ + "201":{ + "description":"Created", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputWithJSONType3"}}} + } + }, + "x-forbid-unknown-path":true,"x-forbid-unknown-query":true + } + }, + "/json-body-validation/{in-path}":{ + "post":{ + "tags":["Request","Response","Validation"],"summary":"Request With JSON Body and non-trivial validation", + "description":"Request with JSON body and query/header/path params, response with JSON body and data from request.", + "operationId":"_examples/advanced-generic-openapi31.jsonBodyValidation", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","minimum":100,"type":"integer"} + }, + { + "name":"in-path","in":"path","description":"Simple scalar value in path","required":true, + "schema":{"description":"Simple scalar value in path","minLength":3,"type":"string"} + }, + { + "name":"X-Header","in":"header","description":"Simple scalar value in header.", + "schema":{"description":"Simple scalar value in header.","minLength":3,"type":"string"} + } + ], + "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInputWithJSONType4"}}}}, + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputWithJSONType4"}}} + } + }, + "x-forbid-unknown-path":true,"x-forbid-unknown-query":true + } + }, + "/json-body/{in-path}":{ + "post":{ + "tags":["Request"],"summary":"Request With JSON Body", + "description":"Request with JSON body and query/header/path params, response with JSON body and data from request.", + "operationId":"_examples/advanced-generic-openapi31.jsonBody", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","format":"date","type":"string"} + }, + { + "name":"in-path","in":"path","description":"Simple scalar value in path","required":true, + "schema":{"description":"Simple scalar value in path","type":"string"} + }, + { + "name":"X-Header","in":"header","description":"Simple scalar value in header.", + "schema":{"description":"Simple scalar value in header.","type":"string"} + } + ], + "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInputWithJSONType2"}}}}, + "responses":{ + "201":{ + "description":"Created", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputWithJSONType2"}}} + } + }, + "x-forbid-unknown-path":true,"x-forbid-unknown-query":true + } + }, + "/json-map-body":{ + "post":{ + "tags":["Request"],"summary":"Request With JSON Map In Body", + "description":"Request with JSON object (map) body.", + "operationId":"_examples/advanced-generic-openapi31.jsonMapBody", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","type":"integer"} + }, + { + "name":"X-Header","in":"header","description":"Simple scalar value in header.", + "schema":{"description":"Simple scalar value in header.","type":"string"} + } + ], + "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedJsonMapReq"}}}}, + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedJsonOutputType2"}}} + } + }, + "x-forbid-unknown-query":true + } + }, + "/json-param/{in-path}":{ + "get":{ + "tags":["Request"],"summary":"Request With JSON Query Parameter", + "description":"Request with JSON body and query/header/path params, response with JSON body and data from request.", + "operationId":"_examples/advanced-generic-openapi31.jsonParam", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","type":"integer"} + }, + { + "name":"identity","in":"query","description":"JSON value in query", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryAdvancedJSONPayload"}}} + }, + { + "name":"in-path","in":"path","description":"Simple scalar value in path","required":true, + "schema":{"description":"Simple scalar value in path","type":"string"} + }, + { + "name":"in_cookie","in":"cookie","description":"UUID in cookie.", + "schema":{"$ref":"#/components/schemas/CookieUuidUUID","description":"UUID in cookie."} + }, + { + "name":"X-Header","in":"header","description":"Simple scalar value in header.", + "schema":{"description":"Simple scalar value in header.","type":"string"} + } + ], + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputWithJSON"}}} + } + }, + "x-forbid-unknown-cookie":true,"x-forbid-unknown-path":true,"x-forbid-unknown-query":true + } + }, + "/json-slice-body":{ + "post":{ + "tags":["Request"],"summary":"Request With JSON Array In Body", + "operationId":"_examples/advanced-generic-openapi31.jsonSliceBody", + "parameters":[ + { + "name":"in_query","in":"query","description":"Simple scalar value in query.", + "schema":{"description":"Simple scalar value in query.","type":"integer"} + }, + { + "name":"X-Header","in":"header","description":"Simple scalar value in header.", + "schema":{"description":"Simple scalar value in header.","type":"string"} + } + ], + "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedJsonSliceReq"}}}}, + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedJsonOutput"}}} + } + }, + "x-forbid-unknown-query":true + } + }, + "/no-validation":{ + "post":{ + "tags":["Request","Response"],"summary":"No Validation","description":"Input/Output without validation.", + "operationId":"_examples/advanced-generic-openapi31.noValidation", + "parameters":[ + {"name":"q","in":"query","schema":{"type":"boolean"}}, + {"name":"X-Input","in":"header","schema":{"type":"integer"}} + ], + "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInputPortType3"}}}}, + "responses":{ + "200":{ + "description":"OK", + "headers":{ + "X-Output":{"style":"simple","schema":{"type":"integer"}}, + "X-Query":{"style":"simple","schema":{"type":"boolean"}} + }, + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputPortType3"}}} + } + }, + "x-forbid-unknown-query":true + } + }, + "/output-csv-writer":{ + "get":{ + "tags":["Response"],"summary":"Output With Stream Writer","description":"Output with stream writer.", + "operationId":"_examples/advanced-generic-openapi31.outputCSVWriter", + "parameters":[ + { + "name":"If-None-Match","in":"header","description":"Content hash.", + "schema":{"description":"Content hash.","type":"string"} + } + ], + "responses":{ + "200":{ + "description":"OK", + "headers":{ + "ETag":{ + "style":"simple","description":"Content hash.","schema":{"description":"Content hash.","type":"string"} + }, + "X-Header":{ + "style":"simple","description":"Sample response header.", + "schema":{"description":"Sample response header.","type":"string"} + } + }, + "content":{"text/csv":{"schema":{"type":"string"}}} + }, + "304":{"description":"Not Modified"}, + "500":{ + "description":"Internal Server Error", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}} + } + } + } + }, + "/output-headers":{ + "get":{ + "tags":["Response"],"summary":"Output With Headers","description":"Output with headers.", + "operationId":"_examples/advanced-generic-openapi31.outputHeaders", + "parameters":[ + { + "name":"X-foO","in":"header","description":"Reduced by 20 in response.","required":true, + "schema":{"description":"Reduced by 20 in response.","minimum":10,"type":"integer"} + } + ], + "responses":{ + "200":{ + "description":"OK", + "headers":{ + "X-foO":{ + "style":"simple","description":"Reduced by 20 in response.","required":true, + "schema":{"description":"Reduced by 20 in response.","minimum":10,"type":"integer"} + }, + "x-HeAdEr":{ + "style":"simple","description":"Sample response header.", + "schema":{"description":"Sample response header.","type":"string"} + }, + "x-omit-empty":{ + "style":"simple","description":"Receives req value of X-Foo reduced by 30.", + "schema":{"description":"Receives req value of X-Foo reduced by 30.","type":"integer"} + } + }, + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedHeaderOutput"}}} + }, + "500":{ + "description":"Internal Server Error", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}} + } + } + }, + "head":{ + "tags":["Response"],"summary":"Output With Headers","description":"Output with headers.", + "operationId":"_examples/advanced-generic-openapi31.outputHeaders2", + "parameters":[ + { + "name":"X-foO","in":"header","description":"Reduced by 20 in response.","required":true, + "schema":{"description":"Reduced by 20 in response.","minimum":10,"type":"integer"} + } + ], + "responses":{ + "200":{ + "description":"OK", + "headers":{ + "X-foO":{ + "style":"simple","description":"Reduced by 20 in response.","required":true, + "schema":{"description":"Reduced by 20 in response.","minimum":10,"type":"integer"} + }, + "x-HeAdEr":{ + "style":"simple","description":"Sample response header.", + "schema":{"description":"Sample response header.","type":"string"} + }, + "x-omit-empty":{ + "style":"simple","description":"Receives req value of X-Foo reduced by 30.", + "schema":{"description":"Receives req value of X-Foo reduced by 30.","type":"integer"} + } + } + }, + "500":{"description":"Internal Server Error"} + } + } + }, + "/query-object":{ + "get":{ + "tags":["Request"],"summary":"Request With Object As Query Parameter", + "operationId":"_examples/advanced-generic-openapi31.queryObject", + "parameters":[ + { + "name":"in_query","in":"query","description":"Object value in query.", + "schema":{"additionalProperties":{"type":"number"},"description":"Object value in query.","type":["object","null"]}, + "style":"deepObject","explode":true + }, + { + "name":"json_filter","in":"query","description":"JSON object value in query.", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/QueryAdvancedJsonFilter"}}} + }, + { + "name":"deep_object_filter","in":"query","description":"Deep object value in query params.", + "schema":{ + "$ref":"#/components/schemas/QueryAdvancedDeepObjectFilter", + "description":"Deep object value in query params." + }, + "style":"deepObject","explode":true + } + ], + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputQueryObject"}}} + } + }, + "x-forbid-unknown-query":true + } + }, + "/req-resp-mapping":{ + "post":{ + "tags":["Request","Response"],"summary":"Request Response Mapping", + "description":"This use case has transport concerns fully decoupled with external req/resp mapping.", + "operationId":"reqRespMapping", + "parameters":[ + { + "name":"X-Header","in":"header","description":"Simple scalar value with sample validation.","required":true, + "schema":{"description":"Simple scalar value with sample validation.","minLength":3,"type":"string"} + } + ], + "requestBody":{ + "content":{"application/x-www-form-urlencoded":{"schema":{"$ref":"#/components/schemas/FormDataAdvancedInputPort"}}} + }, + "responses":{ + "204":{ + "description":"No Content", + "headers":{ + "X-Value-1":{ + "style":"simple","description":"Simple scalar value with sample validation.","required":true, + "schema":{"description":"Simple scalar value with sample validation.","minLength":3,"type":"string"} + }, + "X-Value-2":{ + "style":"simple","description":"Simple scalar value with sample validation.","required":true, + "schema":{"description":"Simple scalar value with sample validation.","minimum":3,"type":"integer"} + } + } + } + } + } + }, + "/root-with-session":{ + "get":{ + "tags":["Other"],"summary":"Dummy","operationId":"_examples/advanced-generic-openapi31.dummy", + "responses":{ + "204":{"description":"No Content"}, + "401":{ + "description":"Unauthorized", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}} + } + }, + "security":[{"User":[]}] + } + }, + "/text-req-body-ptr/{path}":{ + "post":{ + "tags":["Request"],"summary":"Request With Text Body (ptr input)", + "description":"This usecase allows direct access to original `*http.Request` while keeping automated decoding of parameters.", + "operationId":"_examples/advanced-generic-openapi31.textReqBodyPtr", + "parameters":[ + {"name":"query","in":"query","schema":{"type":"integer"}}, + {"name":"path","in":"path","required":true,"schema":{"type":"string"}} + ], + "requestBody":{"content":{"text/csv":{"schema":{"type":"string"}}}}, + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputType3"}}} + } + }, + "x-forbid-unknown-path":true,"x-forbid-unknown-query":true + } + }, + "/text-req-body/{path}":{ + "post":{ + "tags":["Request"],"summary":"Request With Text Body", + "description":"This usecase allows direct access to original `*http.Request` while keeping automated decoding of parameters.", + "operationId":"_examples/advanced-generic-openapi31.textReqBody", + "parameters":[ + {"name":"query","in":"query","schema":{"type":"integer"}}, + {"name":"path","in":"path","required":true,"schema":{"type":"string"}} + ], + "requestBody":{"content":{"text/csv":{"schema":{"type":"string"}}}}, + "responses":{ + "200":{ + "description":"OK", + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputType2"}}} + } + }, + "x-forbid-unknown-path":true,"x-forbid-unknown-query":true + } + }, + "/validation":{ + "post":{ + "tags":["Request","Response","Validation"],"summary":"Validation", + "description":"Input/Output with validation. Custom annotation.", + "operationId":"_examples/advanced-generic-openapi31.validation", + "parameters":[ + { + "name":"q","in":"query", + "description":"This parameter will bypass explicit validation as it does not have constraints.", + "schema":{ + "description":"This parameter will bypass explicit validation as it does not have constraints.", + "type":"boolean" + } + }, + { + "name":"X-Input","in":"header","description":"Request minimum: 10, response maximum: 20.", + "schema":{"description":"Request minimum: 10, response maximum: 20.","minimum":10,"type":"integer"} + } + ], + "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInputPortType2"}}}}, + "responses":{ + "200":{ + "description":"OK", + "headers":{ + "X-Output":{"style":"simple","schema":{"maximum":20,"type":"integer"}}, + "X-Query":{ + "style":"simple","description":"This header bypasses validation as it does not have constraints.", + "schema":{"description":"This header bypasses validation as it does not have constraints.","type":"boolean"} + } + }, + "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputPortType2"}}} + } + }, + "x-forbid-unknown-query":true + } + } + }, + "components":{ + "schemas":{ + "AdvancedAnotherErr":{"properties":{"foo":{"type":"integer"}},"type":"object"}, + "AdvancedCustomErr":{"properties":{"details":{"additionalProperties":{},"type":"object"},"msg":{"type":"string"}},"type":"object"}, + "AdvancedDeepObjectFilter":{ + "properties":{"bar":{"minLength":3,"type":"string"},"baz":{"minLength":3,"type":["null","string"]}}, + "type":"object" + }, + "AdvancedGzipPassThroughStruct":{ + "properties":{"id":{"type":"integer"},"text":{"items":{"type":"string"},"type":["array","null"]}}, + "type":"object" + }, + "AdvancedHeaderOutput":{"properties":{"inBody":{"deprecated":true,"type":"string"}},"type":"object"}, + "AdvancedInfo":{ + "properties":{ + "filename":{"type":"string"},"header":{"$ref":"#/components/schemas/TextprotoMIMEHeader"}, + "inQuery":{"type":"integer"},"peek1":{"type":"string"},"peek2":{"type":"string"},"simple":{"type":"string"}, + "size":{"type":"integer"} + }, + "type":"object" + }, + "AdvancedInfoType2":{ + "properties":{ + "filenames":{"items":{"type":"string"},"type":["array","null"]}, + "headers":{"items":{"$ref":"#/components/schemas/TextprotoMIMEHeader"},"type":["array","null"]}, + "inQuery":{"type":"integer"},"peeks1":{"items":{"type":"string"},"type":["array","null"]}, + "peeks2":{"items":{"type":"string"},"type":["array","null"]},"simple":{"type":"string"}, + "sizes":{"items":{"type":"integer"},"type":["array","null"]} + }, + "type":"object" + }, + "AdvancedInputPortType2":{ + "additionalProperties":false, + "properties":{ + "data":{ + "additionalProperties":false, + "properties":{"value":{"description":"Request minLength: 3, response maxLength: 7","minLength":3,"type":"string"}}, + "type":"object" + } + }, + "required":["data"],"type":"object" + }, + "AdvancedInputPortType3":{ + "additionalProperties":false, + "properties":{"data":{"additionalProperties":false,"properties":{"value":{"type":"string"}},"type":"object"}}, + "type":"object" + }, + "AdvancedInputWithJSONType2":{ + "additionalProperties":false, + "properties":{ + "id":{"type":"integer"},"name":{"type":"string"}, + "namedStruct":{"$ref":"#/components/schemas/AdvancedJSONPayloadType2","deprecated":true} + }, + "type":"object" + }, + "AdvancedInputWithJSONType3":{ + "additionalProperties":false, + "properties":{ + "id":{"type":"integer"},"name":{"type":"string"}, + "namedStruct":{"$ref":"#/components/schemas/AdvancedJSONPayloadType3","deprecated":true} + }, + "type":"object" + }, + "AdvancedInputWithJSONType4":{ + "additionalProperties":false, + "properties":{"id":{"minimum":100,"type":"integer"},"name":{"minLength":3,"type":"string"}},"type":"object" + }, + "AdvancedJSONMapPayload":{"additionalProperties":{"type":"number"},"type":"object"}, + "AdvancedJSONPayloadType2":{"additionalProperties":false,"properties":{"id":{"type":"integer"},"name":{"type":"string"}},"type":"object"}, + "AdvancedJSONPayloadType3":{"additionalProperties":false,"properties":{"id":{"type":"integer"},"name":{"type":"string"}},"type":"object"}, + "AdvancedJSONSlicePayload":{"items":{"type":"integer"},"type":["array","null"]}, + "AdvancedJsonFilter":{"properties":{"foo":{"maxLength":5,"type":"string"}},"type":"object"}, + "AdvancedJsonMapReq":{"additionalProperties":{"type":"number"},"type":"object"}, + "AdvancedJsonOutput":{ + "properties":{ + "data":{"$ref":"#/components/schemas/AdvancedJSONSlicePayload"},"inHeader":{"type":"string"}, + "inQuery":{"type":"integer"} + }, + "type":"object" + }, + "AdvancedJsonOutputType2":{ + "properties":{ + "data":{"$ref":"#/components/schemas/AdvancedJSONMapPayload"},"inHeader":{"type":"string"}, + "inQuery":{"type":"integer"} + }, + "type":"object" + }, + "AdvancedJsonSliceReq":{"items":{"type":"integer"},"type":"array"}, + "AdvancedOkResp":{"properties":{"status":{"type":"string"}},"type":"object"}, + "AdvancedOutput":{"properties":{"id":{"type":"integer"},"name":{"type":"string"}},"type":"object"}, + "AdvancedOutputPortType2":{ + "properties":{"data":{"properties":{"value":{"maxLength":7,"type":"string"}},"type":"object"}}, + "required":["data"],"type":"object" + }, + "AdvancedOutputPortType3":{"properties":{"data":{"properties":{"value":{"type":"string"}},"type":"object"}},"type":"object"}, + "AdvancedOutputQueryObject":{ + "properties":{ + "deepObjectFilter":{"$ref":"#/components/schemas/AdvancedDeepObjectFilter"}, + "inQuery":{"additionalProperties":{"type":"number"},"type":["object","null"]}, + "jsonFilter":{"$ref":"#/components/schemas/AdvancedJsonFilter"} + }, + "type":"object" + }, + "AdvancedOutputType2":{"properties":{"path":{"type":"string"},"query":{"type":"integer"},"text":{"type":"string"}},"type":"object"}, + "AdvancedOutputType3":{"properties":{"path":{"type":"string"},"query":{"type":"integer"},"text":{"type":"string"}},"type":"object"}, + "AdvancedOutputWithJSON":{ + "properties":{ + "id":{"type":"integer"},"inHeader":{"type":"string"},"inPath":{"type":"string"},"inQuery":{"type":"integer"}, + "name":{"type":"string"} + }, + "type":"object" + }, + "AdvancedOutputWithJSONType2":{ + "properties":{ + "id":{"type":"integer"},"inHeader":{"type":"string"},"inPath":{"type":"string"}, + "inQuery":{"deprecated":true,"format":"date","type":"string"},"name":{"type":"string"} + }, + "type":"object" + }, + "AdvancedOutputWithJSONType3":{ + "properties":{ + "id":{"type":"integer"},"inHeader":{"type":"string"},"inPath":{"type":"string"}, + "inQuery":{"deprecated":true,"format":"date","type":"string"},"name":{"type":"string"} + }, + "type":"object" + }, + "AdvancedOutputWithJSONType4":{ + "properties":{ + "id":{"minimum":100,"type":"integer"},"inHeader":{"minLength":3,"type":"string"}, + "inPath":{"minLength":3,"type":"string"},"inQuery":{"minimum":3,"type":"integer"}, + "name":{"minLength":3,"type":"string"} + }, + "type":"object" + }, + "CookieUuidUUID":{"examples":["248df4b7-aa70-47b8-a036-33ac447e668d"],"format":"uuid","type":"string"}, + "FormDataAdvancedForm":{"additionalProperties":false,"properties":{"id":{"type":"integer"},"name":{"type":"string"}},"type":"object"}, + "FormDataAdvancedInputPort":{ + "additionalProperties":false, + "properties":{"val2":{"description":"Simple scalar value with sample validation.","minimum":3,"type":"integer"}}, + "required":["val2"],"type":"object" + }, + "FormDataAdvancedUpload":{ + "additionalProperties":false, + "properties":{ + "simple":{"description":"Simple scalar value in body.","type":"string"}, + "upload1":{ + "$ref":"#/components/schemas/FormDataMultipartFileHeader", + "description":"Upload with *multipart.FileHeader." + }, + "upload2":{"$ref":"#/components/schemas/FormDataMultipartFile","description":"Upload with multipart.File."} + }, + "type":"object" + }, + "FormDataAdvancedUploadType2":{ + "additionalProperties":false, + "properties":{ + "simple":{"description":"Simple scalar value in body.","type":"string"}, + "uploads1":{ + "description":"Uploads with *multipart.FileHeader.", + "items":{"$ref":"#/components/schemas/FormDataMultipartFileHeader"},"type":["array","null"] + }, + "uploads2":{ + "description":"Uploads with multipart.File.","items":{"$ref":"#/components/schemas/FormDataMultipartFile"}, + "type":["array","null"] + } + }, + "type":"object" + }, + "FormDataMultipartFile":{"format":"binary","type":["null","string"]}, + "FormDataMultipartFileHeader":{"format":"binary","type":["null","string"]}, + "QueryAdvancedDeepObjectFilter":{ + "additionalProperties":false, + "properties":{"bar":{"minLength":3,"type":"string"},"baz":{"minLength":3,"type":["null","string"]}}, + "type":"object" + }, + "QueryAdvancedJSONPayload":{"additionalProperties":false,"properties":{"id":{"type":"integer"},"name":{"type":"string"}},"type":"object"}, + "QueryAdvancedJsonFilter":{"additionalProperties":false,"properties":{"foo":{"maxLength":5,"type":"string"}},"type":"object"}, + "RestErrResponse":{ + "properties":{ + "code":{"description":"Application-specific error code.","type":"integer"}, + "context":{"additionalProperties":{},"description":"Application context.","type":"object"}, + "error":{"description":"Error message.","type":"string"}, + "status":{"description":"Status text.","type":"string"} + }, + "type":"object" + }, + "TextprotoMIMEHeader":{"additionalProperties":{"items":{"type":"string"},"type":"array"},"type":"object"} + }, + "securitySchemes":{"User":{"description":"Session cookie.","type":"apiKey","name":"sessid","in":"cookie"}} + } +} diff --git a/_examples/advanced-generic-openapi31/dummy.go b/_examples/advanced-generic-openapi31/dummy.go new file mode 100644 index 0000000..f9d2db5 --- /dev/null +++ b/_examples/advanced-generic-openapi31/dummy.go @@ -0,0 +1,16 @@ +package main + +import ( + "context" + + "github.com/swaggest/usecase" +) + +func dummy() usecase.Interactor { + u := usecase.NewInteractor(func(ctx context.Context, input struct{}, output *struct{}) error { + return nil + }) + u.SetTags("Other") + + return u +} diff --git a/_examples/advanced-generic-openapi31/error_response.go b/_examples/advanced-generic-openapi31/error_response.go new file mode 100644 index 0000000..2fa2352 --- /dev/null +++ b/_examples/advanced-generic-openapi31/error_response.go @@ -0,0 +1,57 @@ +//go:build go1.18 + +package main + +import ( + "context" + "errors" + + "github.com/bool64/ctxd" + "github.com/swaggest/usecase" + "github.com/swaggest/usecase/status" +) + +type customErr struct { + Message string `json:"msg"` + Details map[string]interface{} `json:"details,omitempty"` +} + +func errorResponse() usecase.Interactor { + type errType struct { + Type string `query:"type" enum:"ok,invalid_argument,conflict" required:"true"` + } + + type okResp struct { + Status string `json:"status"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in errType, out *okResp) (err error) { + switch in.Type { + case "ok": + out.Status = "ok" + case "invalid_argument": + return status.Wrap(errors.New("bad value for foo"), status.InvalidArgument) + case "conflict": + return status.Wrap(ctxd.NewError(ctx, "conflict", "foo", "bar"), + status.AlreadyExists) + } + + return nil + }) + + u.SetTitle("Declare Expected Errors") + u.SetDescription("This use case demonstrates documentation of expected errors.") + u.SetExpectedErrors(status.InvalidArgument, anotherErr{}, status.FailedPrecondition, status.AlreadyExists) + u.SetTags("Response") + + return u +} + +// anotherErr is another custom error. +type anotherErr struct { + Foo int `json:"foo"` +} + +func (anotherErr) Error() string { + return "foo happened" +} diff --git a/_examples/advanced-generic-openapi31/file_multi_upload.go b/_examples/advanced-generic-openapi31/file_multi_upload.go new file mode 100644 index 0000000..006d5c0 --- /dev/null +++ b/_examples/advanced-generic-openapi31/file_multi_upload.go @@ -0,0 +1,78 @@ +//go:build go1.18 + +package main + +import ( + "context" + "mime/multipart" + "net/textproto" + + "github.com/swaggest/usecase" +) + +func fileMultiUploader() usecase.Interactor { + type upload struct { + Simple string `formData:"simple" description:"Simple scalar value in body."` + Query int `query:"in_query" description:"Simple scalar value in query."` + Uploads1 []*multipart.FileHeader `formData:"uploads1" description:"Uploads with *multipart.FileHeader."` + Uploads2 []multipart.File `formData:"uploads2" description:"Uploads with multipart.File."` + } + + type info struct { + Filenames []string `json:"filenames"` + Headers []textproto.MIMEHeader `json:"headers"` + Sizes []int64 `json:"sizes"` + Upload1Peeks []string `json:"peeks1"` + Upload2Peeks []string `json:"peeks2"` + Simple string `json:"simple"` + Query int `json:"inQuery"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in upload, out *info) (err error) { + out.Query = in.Query + out.Simple = in.Simple + for _, o := range in.Uploads1 { + out.Filenames = append(out.Filenames, o.Filename) + out.Headers = append(out.Headers, o.Header) + out.Sizes = append(out.Sizes, o.Size) + + f, err := o.Open() + if err != nil { + return err + } + p := make([]byte, 100) + _, err = f.Read(p) + if err != nil { + return err + } + + out.Upload1Peeks = append(out.Upload1Peeks, string(p)) + + err = f.Close() + if err != nil { + return err + } + } + + for _, o := range in.Uploads2 { + p := make([]byte, 100) + _, err = o.Read(p) + if err != nil { + return err + } + + out.Upload2Peeks = append(out.Upload2Peeks, string(p)) + err = o.Close() + if err != nil { + return err + } + } + + return nil + }) + + u.SetTitle("Files Uploads With 'multipart/form-data'") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/file_upload.go b/_examples/advanced-generic-openapi31/file_upload.go new file mode 100644 index 0000000..be820a2 --- /dev/null +++ b/_examples/advanced-generic-openapi31/file_upload.go @@ -0,0 +1,82 @@ +//go:build go1.18 + +package main + +import ( + "context" + "mime/multipart" + "net/textproto" + + "github.com/swaggest/usecase" +) + +func fileUploader() usecase.Interactor { + type upload struct { + Simple string `formData:"simple" description:"Simple scalar value in body."` + Query int `query:"in_query" description:"Simple scalar value in query."` + Upload1 *multipart.FileHeader `formData:"upload1" description:"Upload with *multipart.FileHeader."` + Upload2 multipart.File `formData:"upload2" description:"Upload with multipart.File."` + } + + type info struct { + Filename string `json:"filename"` + Header textproto.MIMEHeader `json:"header"` + Size int64 `json:"size"` + Upload1Peek string `json:"peek1"` + Upload2Peek string `json:"peek2"` + Simple string `json:"simple"` + Query int `json:"inQuery"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in upload, out *info) (err error) { + out.Query = in.Query + out.Simple = in.Simple + if in.Upload1 == nil { + return nil + } + + out.Filename = in.Upload1.Filename + out.Header = in.Upload1.Header + out.Size = in.Upload1.Size + + f, err := in.Upload1.Open() + if err != nil { + return err + } + + defer func() { + clErr := f.Close() + if clErr != nil && err == nil { + err = clErr + } + + clErr = in.Upload2.Close() + if clErr != nil && err == nil { + err = clErr + } + }() + + p := make([]byte, 100) + _, err = f.Read(p) + if err != nil { + return err + } + + out.Upload1Peek = string(p) + + p = make([]byte, 100) + _, err = in.Upload2.Read(p) + if err != nil { + return err + } + + out.Upload2Peek = string(p) + + return nil + }) + + u.SetTitle("File Upload With 'multipart/form-data'") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/form.go b/_examples/advanced-generic-openapi31/form.go new file mode 100644 index 0000000..ffdfa28 --- /dev/null +++ b/_examples/advanced-generic-openapi31/form.go @@ -0,0 +1,35 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/usecase" +) + +func form() usecase.Interactor { + type form struct { + ID int `form:"id"` + Name string `form:"name"` + } + + type output struct { + ID int `json:"id"` + Name string `json:"name"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in form, out *output) error { + out.ID = in.ID + out.Name = in.Name + + return nil + }) + + u.SetTitle("Request With Form") + u.SetDescription("The `form` field tag acts as `query` and `formData`, with priority on `formData`.\n\n" + + "It is decoded with `http.Request.Form` values.") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/gzip_pass_through.go b/_examples/advanced-generic-openapi31/gzip_pass_through.go new file mode 100644 index 0000000..f09d3b7 --- /dev/null +++ b/_examples/advanced-generic-openapi31/gzip_pass_through.go @@ -0,0 +1,92 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/rest/gzip" + "github.com/swaggest/usecase" +) + +type gzipPassThroughInput struct { + PlainStruct bool `query:"plainStruct" description:"Output plain structure instead of gzip container."` + CountItems bool `query:"countItems" description:"Invokes internal decoding of compressed data."` +} + +// gzipPassThroughOutput defers data to an accessor function instead of using struct directly. +// This is necessary to allow containers that can data in binary wire-friendly format. +type gzipPassThroughOutput interface { + // Data should be accessed though an accessor to allow container interface. + gzipPassThroughStruct() gzipPassThroughStruct +} + +// gzipPassThroughStruct represents the actual structure that is held in the container +// and implements gzipPassThroughOutput to be directly useful in output. +type gzipPassThroughStruct struct { + Header string `header:"X-Header" json:"-"` + ID int `json:"id"` + Text []string `json:"text"` +} + +func (d gzipPassThroughStruct) gzipPassThroughStruct() gzipPassThroughStruct { + return d +} + +// gzipPassThroughContainer is wrapping gzip.JSONContainer and implements gzipPassThroughOutput. +type gzipPassThroughContainer struct { + Header string `header:"X-Header" json:"-"` + gzip.JSONContainer +} + +func (dc gzipPassThroughContainer) gzipPassThroughStruct() gzipPassThroughStruct { + var p gzipPassThroughStruct + + err := dc.UnpackJSON(&p) + if err != nil { + panic(err) + } + + return p +} + +func directGzip() usecase.Interactor { + // Prepare moderately big JSON, resulting JSON payload is ~67KB. + rawData := gzipPassThroughStruct{ + ID: 123, + } + for i := 0; i < 400; i++ { + rawData.Text = append(rawData.Text, "Quis autem vel eum iure reprehenderit, qui in ea voluptate velit esse, "+ + "quam nihil molestiae consequatur, vel illum, qui dolorem eum fugiat, quo voluptas nulla pariatur?") + } + + // 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) + } + + u := usecase.NewInteractor(func(ctx context.Context, in gzipPassThroughInput, out *gzipPassThroughOutput) error { + if in.PlainStruct { + o := rawData + o.Header = "cba" + *out = o + } else { + o := dataFromCache + o.Header = "abc" + *out = o + } + + // Imitating an internal read operation on data in container. + if in.CountItems { + _ = len((*out).gzipPassThroughStruct().Text) + } + + return nil + }) + u.SetTags("Response") + + return u +} diff --git a/_examples/advanced-generic-openapi31/gzip_pass_through_test.go b/_examples/advanced-generic-openapi31/gzip_pass_through_test.go new file mode 100644 index 0000000..c50bb04 --- /dev/null +++ b/_examples/advanced-generic-openapi31/gzip_pass_through_test.go @@ -0,0 +1,154 @@ +//go:build go1.18 + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" +) + +func Test_directGzip(t *testing.T) { + r := NewRouter() + + req, err := http.NewRequest(http.MethodGet, "/gzip-pass-through", nil) + require.NoError(t, err) + + req.Header.Set("Accept-Encoding", "gzip") + + rw := httptest.NewRecorder() + + r.ServeHTTP(rw, req) + assert.Equal(t, http.StatusOK, rw.Code) + assert.Equal(t, "330epditz19z", rw.Header().Get("Etag")) + assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding")) + assert.Equal(t, "abc", rw.Header().Get("X-Header")) + assert.Less(t, len(rw.Body.Bytes()), 500) +} + +func Test_noDirectGzip(t *testing.T) { + r := NewRouter() + + req, err := http.NewRequest(http.MethodGet, "/gzip-pass-through?plainStruct=1", nil) + require.NoError(t, err) + + req.Header.Set("Accept-Encoding", "gzip") + + rw := httptest.NewRecorder() + + r.ServeHTTP(rw, req) + assert.Equal(t, http.StatusOK, rw.Code) + assert.Equal(t, "", rw.Header().Get("Etag")) // No ETag for dynamic compression. + assert.Equal(t, "gzip", rw.Header().Get("Content-Encoding")) + assert.Equal(t, "cba", rw.Header().Get("X-Header")) + assert.Less(t, len(rw.Body.Bytes()), 1000) // Worse compression for better speed. +} + +func Test_directGzip_perf(t *testing.T) { + res := testing.Benchmark(Benchmark_directGzip) + + if httptestbench.RaceDetectorEnabled { + assert.Less(t, res.Extra["B:rcvd/op"], 700.0) + assert.Less(t, res.Extra["B:sent/op"], 104.0) + assert.Less(t, res.AllocsPerOp(), int64(60)) + assert.Less(t, res.AllocedBytesPerOp(), int64(8500)) + } else { + assert.Less(t, res.Extra["B:rcvd/op"], 700.0) + assert.Less(t, res.Extra["B:sent/op"], 104.0) + assert.Less(t, res.AllocsPerOp(), int64(45)) + assert.Less(t, res.AllocedBytesPerOp(), int64(4100)) + } +} + +// Direct gzip enabled. +// Benchmark_directGzip-4 48037 24474 ns/op 624 B:rcvd/op 103 B:sent/op 40860 rps 3499 B/op 36 allocs/op. +// Benchmark_directGzip-4 45792 26102 ns/op 624 B:rcvd/op 103 B:sent/op 38278 rps 3063 B/op 33 allocs/op. +func Benchmark_directGzip(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.Set("Accept-Encoding", "gzip") + req.SetRequestURI(srv.URL + "/gzip-pass-through") + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} + +// Direct gzip enabled. +// Benchmark_directGzipHead-4 43804 26481 ns/op 168 B:rcvd/op 104 B:sent/op 37730 rps 3507 B/op 36 allocs/op. +// Benchmark_directGzipHead-4 45580 32286 ns/op 168 B:rcvd/op 104 B:sent/op 30963 rps 3093 B/op 33 allocs/op. +func Benchmark_directGzipHead(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodHead) + req.Header.Set("Accept-Encoding", "gzip") + req.SetRequestURI(srv.URL + "/gzip-pass-through") + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} + +// Direct gzip disabled, payload is marshaled and compressed for every request. +// Benchmark_noDirectGzip-4 8031 136836 ns/op 1029 B:rcvd/op 117 B:sent/op 7308 rps 5382 B/op 41 allocs/op. +// Benchmark_noDirectGzip-4 7587 143294 ns/op 1029 B:rcvd/op 117 B:sent/op 6974 rps 4619 B/op 38 allocs/op. +// Benchmark_noDirectGzip-4 7825 157317 ns/op 1029 B:rcvd/op 117 B:sent/op 6357 rps 4655 B/op 40 allocs/op. +func Benchmark_noDirectGzip(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.Set("Accept-Encoding", "gzip") + req.SetRequestURI(srv.URL + "/gzip-pass-through?plainStruct=1") + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} + +// Direct gzip enabled, payload is unmarshaled and decompressed for every request in usecase body. +// Unmarshaling large JSON payloads can be much more expensive than explicitly creating them from Go values. +// Benchmark_directGzip_decode-4 2018 499755 ns/op 624 B:rcvd/op 116 B:sent/op 2001 rps 403967 B/op 496 allocs/op. +// Benchmark_directGzip_decode-4 2085 526586 ns/op 624 B:rcvd/op 116 B:sent/op 1899 rps 403600 B/op 493 allocs/op. +func Benchmark_directGzip_decode(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.Set("Accept-Encoding", "gzip") + req.SetRequestURI(srv.URL + "/gzip-pass-through?countItems=1") + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} + +// Direct gzip disabled. +// Benchmark_noDirectGzip_decode-4 7603 142173 ns/op 1029 B:rcvd/op 130 B:sent/op 7034 rps 5122 B/op 43 allocs/op. +// Benchmark_noDirectGzip_decode-4 5836 198000 ns/op 1029 B:rcvd/op 130 B:sent/op 5051 rps 5371 B/op 42 allocs/op. +func Benchmark_noDirectGzip_decode(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.Set("Accept-Encoding", "gzip") + req.SetRequestURI(srv.URL + "/gzip-pass-through?plainStruct=1&countItems=1") + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} diff --git a/_examples/advanced-generic-openapi31/html_response.go b/_examples/advanced-generic-openapi31/html_response.go new file mode 100644 index 0000000..bdf6242 --- /dev/null +++ b/_examples/advanced-generic-openapi31/html_response.go @@ -0,0 +1,70 @@ +//go:build go1.18 + +package main + +import ( + "context" + "html/template" + "io" + + "github.com/swaggest/usecase" +) + +type htmlResponseOutput struct { + ID int + Filter string + Title string + Items []string + AntiHeader bool `header:"X-Anti-Header"` + + writer io.Writer +} + +func (o *htmlResponseOutput) SetWriter(w io.Writer) { + o.writer = w +} + +func (o *htmlResponseOutput) Render(tmpl *template.Template) error { + return tmpl.Execute(o.writer, o) +} + +func htmlResponse() usecase.Interactor { + type htmlResponseInput struct { + ID int `path:"id"` + Filter string `query:"filter"` + Header bool `header:"X-Header"` + } + + const tpl = ` + + + + {{.Title}} + + + Next {{.Title}}
+ {{range .Items}}
{{ . }}
{{else}}
no rows
{{end}} + +` + + tmpl, err := template.New("htmlResponse").Parse(tpl) + if err != nil { + panic(err) + } + + u := usecase.NewInteractor(func(ctx context.Context, in htmlResponseInput, out *htmlResponseOutput) (err error) { + out.AntiHeader = !in.Header + out.Filter = in.Filter + out.ID = in.ID + 1 + out.Title = "Foo" + out.Items = []string{"foo", "bar", "baz"} + + return out.Render(tmpl) + }) + + u.SetTitle("Request With HTML Response") + u.SetDescription("Request with templated HTML response.") + u.SetTags("Response") + + return u +} diff --git a/_examples/advanced-generic-openapi31/html_response_test.go b/_examples/advanced-generic-openapi31/html_response_test.go new file mode 100644 index 0000000..dd65ebe --- /dev/null +++ b/_examples/advanced-generic-openapi31/html_response_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" +) + +func Test_htmlResponse(t *testing.T) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/html-response/123?filter=feel") + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, http.StatusOK) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + assert.Equal(t, "true", resp.Header.Get("X-Anti-Header")) + assert.Equal(t, "text/html", resp.Header.Get("Content-Type")) + assert.Equal(t, ` + + + + Foo + + + Next Foo
+
foo
bar
baz
+ +`, string(body), string(body)) +} + +// Benchmark_htmlResponse-12 89209 12348 ns/op 0.3801 50%:ms 1.119 90%:ms 2.553 99%:ms 3.877 99.9%:ms 370.0 B:rcvd/op 108.0 B:sent/op 80973 rps 8279 B/op 144 allocs/op. +func Benchmark_htmlResponse(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.SetRequestURI(srv.URL + "/html-response/123?filter=feel") + req.Header.Set("X-Header", "true") + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} diff --git a/_examples/advanced-generic-openapi31/json_body.go b/_examples/advanced-generic-openapi31/json_body.go new file mode 100644 index 0000000..3e923dc --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_body.go @@ -0,0 +1,47 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/jsonschema-go" + "github.com/swaggest/usecase" +) + +func jsonBody() usecase.Interactor { + type JSONPayload struct { + ID int `json:"id"` + Name string `json:"name"` + } + + type inputWithJSON struct { + Header string `header:"X-Header" description:"Simple scalar value in header."` + Query jsonschema.Date `query:"in_query" description:"Simple scalar value in query."` + Path string `path:"in-path" description:"Simple scalar value in path"` + NamedStruct JSONPayload `json:"namedStruct" deprecated:"true"` + JSONPayload + } + + type outputWithJSON struct { + Header string `json:"inHeader"` + Query jsonschema.Date `json:"inQuery" deprecated:"true"` + Path string `json:"inPath"` + JSONPayload + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { + out.Query = in.Query + out.Header = in.Header + out.Path = in.Path + out.JSONPayload = in.JSONPayload + + return nil + }) + + u.SetTitle("Request With JSON Body") + u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/json_body_manual.go b/_examples/advanced-generic-openapi31/json_body_manual.go new file mode 100644 index 0000000..c16dd9f --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_body_manual.go @@ -0,0 +1,87 @@ +//go:build go1.18 + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/swaggest/jsonschema-go" + "github.com/swaggest/rest/request" + "github.com/swaggest/usecase" +) + +func jsonBodyManual() usecase.Interactor { + type outputWithJSON struct { + Header string `json:"inHeader"` + Query jsonschema.Date `json:"inQuery" deprecated:"true"` + Path string `json:"inPath"` + JSONPayload + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { + out.Query = in.Query + out.Header = in.Header + out.Path = in.Path + out.JSONPayload = in.JSONPayload + + return nil + }) + + u.SetTitle("Request With JSON Body and manual decoder") + u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") + u.SetTags("Request") + + return u +} + +type JSONPayload struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type inputWithJSON struct { + Header string `header:"X-Header" description:"Simple scalar value in header."` + Query jsonschema.Date `query:"in_query" description:"Simple scalar value in query."` + Path string `path:"in-path" description:"Simple scalar value in path"` + NamedStruct JSONPayload `json:"namedStruct" deprecated:"true"` + JSONPayload +} + +var _ request.Loader = &inputWithJSON{} + +func (i *inputWithJSON) LoadFromHTTPRequest(r *http.Request) (err error) { + defer func() { + if err := r.Body.Close(); err != nil { + log.Printf("failed to close request body: %s", err.Error()) + } + }() + + b, err := io.ReadAll(r.Body) + if err != nil { + return fmt.Errorf("failed to read request body: %w", err) + } + + if err = json.Unmarshal(b, i); err != nil { + return fmt.Errorf("failed to unmarshal request body: %w", err) + } + + i.Header = r.Header.Get("X-Header") + if err := i.Query.UnmarshalText([]byte(r.URL.Query().Get("in_query"))); err != nil { + return fmt.Errorf("failed to decode in_query %q: %w", r.URL.Query().Get("in_query"), err) + } + + if routeCtx := chi.RouteContext(r.Context()); routeCtx != nil { + i.Path = routeCtx.URLParam("in-path") + } else { + return errors.New("missing path params in context") + } + + return nil +} diff --git a/_examples/advanced-generic-openapi31/json_body_manual_test.go b/_examples/advanced-generic-openapi31/json_body_manual_test.go new file mode 100644 index 0000000..96f952f --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_body_manual_test.go @@ -0,0 +1,30 @@ +//go:build go1.18 + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/valyala/fasthttp" +) + +// Benchmark_jsonBodyManual-12 125672 8542 ns/op 208.0 B:rcvd/op 195.0 B:sent/op 117048 rps 4523 B/op 49 allocs/op. +func Benchmark_jsonBodyManual(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(srv.URL + "/json-body-manual/abc?in_query=2006-01-02") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Header", "def") + req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusCreated + }) +} diff --git a/_examples/advanced-generic-openapi31/json_body_test.go b/_examples/advanced-generic-openapi31/json_body_test.go new file mode 100644 index 0000000..55e9f0b --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_body_test.go @@ -0,0 +1,30 @@ +//go:build go1.18 + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/valyala/fasthttp" +) + +// Benchmark_jsonBody-12 96762 12042 ns/op 208.0 B:rcvd/op 188.0 B:sent/op 83033 rps 10312 B/op 100 allocs/op. +func Benchmark_jsonBody(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(srv.URL + "/json-body/abc?in_query=2006-01-02") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Header", "def") + req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusCreated + }) +} diff --git a/_examples/advanced-generic-openapi31/json_body_validation.go b/_examples/advanced-generic-openapi31/json_body_validation.go new file mode 100644 index 0000000..44dcd7c --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_body_validation.go @@ -0,0 +1,45 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/usecase" +) + +func jsonBodyValidation() usecase.Interactor { + type JSONPayload struct { + ID int `json:"id" minimum:"100"` + Name string `json:"name" minLength:"3"` + } + + type inputWithJSON struct { + Header string `header:"X-Header" description:"Simple scalar value in header." minLength:"3"` + Query int `query:"in_query" description:"Simple scalar value in query." minimum:"100"` + Path string `path:"in-path" description:"Simple scalar value in path" minLength:"3"` + JSONPayload + } + + type outputWithJSON struct { + Header string `json:"inHeader" minLength:"3"` + Query int `json:"inQuery" minimum:"3"` + Path string `json:"inPath" minLength:"3"` + JSONPayload + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { + out.Query = in.Query + out.Header = in.Header + out.Path = in.Path + out.JSONPayload = in.JSONPayload + + return nil + }) + + u.SetTitle("Request With JSON Body and non-trivial validation") + u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") + u.SetTags("Request", "Response", "Validation") + + return u +} diff --git a/_examples/advanced-generic-openapi31/json_body_validation_test.go b/_examples/advanced-generic-openapi31/json_body_validation_test.go new file mode 100644 index 0000000..69e7548 --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_body_validation_test.go @@ -0,0 +1,30 @@ +//go:build go1.18 + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/valyala/fasthttp" +) + +// Benchmark_jsonBodyValidation-4 19126 60170 ns/op 194 B:rcvd/op 192 B:sent/op 16620 rps 18363 B/op 144 allocs/op. +func Benchmark_jsonBodyValidation(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(srv.URL + "/json-body-validation/abc?in_query=123") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Header", "def") + req.SetBody([]byte(`{"id":321,"name":"Jane"}`)) + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} diff --git a/_examples/advanced-generic-openapi31/json_map_body.go b/_examples/advanced-generic-openapi31/json_map_body.go new file mode 100644 index 0000000..fdac060 --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_map_body.go @@ -0,0 +1,43 @@ +//go:build go1.18 + +package main + +import ( + "context" + "encoding/json" + + "github.com/swaggest/usecase" +) + +type JSONMapPayload map[string]float64 + +type jsonMapReq struct { + Header string `header:"X-Header" description:"Simple scalar value in header."` + Query int `query:"in_query" description:"Simple scalar value in query."` + JSONMapPayload +} + +func (j *jsonMapReq) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &j.JSONMapPayload) +} + +func jsonMapBody() usecase.Interactor { + type jsonOutput struct { + Header string `json:"inHeader"` + Query int `json:"inQuery"` + Data JSONMapPayload `json:"data"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in jsonMapReq, out *jsonOutput) (err error) { + out.Query = in.Query + out.Header = in.Header + out.Data = in.JSONMapPayload + + return nil + }) + + u.SetTitle("Request With JSON Map In Body") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/json_param.go b/_examples/advanced-generic-openapi31/json_param.go new file mode 100644 index 0000000..9a58205 --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_param.go @@ -0,0 +1,47 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/google/uuid" + "github.com/swaggest/usecase" +) + +func jsonParam() usecase.Interactor { + type JSONPayload struct { + ID int `json:"id"` + Name string `json:"name"` + } + + type inputWithJSON struct { + Header string `header:"X-Header" description:"Simple scalar value in header."` + Query int `query:"in_query" description:"Simple scalar value in query."` + Path string `path:"in-path" description:"Simple scalar value in path"` + Cookie uuid.UUID `cookie:"in_cookie" description:"UUID in cookie."` + Identity JSONPayload `query:"identity" description:"JSON value in query"` + } + + type outputWithJSON struct { + Header string `json:"inHeader"` + Query int `json:"inQuery"` + Path string `json:"inPath"` + JSONPayload + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputWithJSON, out *outputWithJSON) (err error) { + out.Query = in.Query + out.Header = in.Header + out.Path = in.Path + out.JSONPayload = in.Identity + + return nil + }) + + u.SetTitle("Request With JSON Query Parameter") + u.SetDescription("Request with JSON body and query/header/path params, response with JSON body and data from request.") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/json_slice_body.go b/_examples/advanced-generic-openapi31/json_slice_body.go new file mode 100644 index 0000000..13be45c --- /dev/null +++ b/_examples/advanced-generic-openapi31/json_slice_body.go @@ -0,0 +1,44 @@ +//go:build go1.18 + +package main + +import ( + "context" + "encoding/json" + + "github.com/swaggest/usecase" +) + +// JSONSlicePayload is an example non-scalar type without `json` tags. +type JSONSlicePayload []int + +type jsonSliceReq struct { + Header string `header:"X-Header" description:"Simple scalar value in header."` + Query int `query:"in_query" description:"Simple scalar value in query."` + JSONSlicePayload +} + +func (j *jsonSliceReq) UnmarshalJSON(data []byte) error { + return json.Unmarshal(data, &j.JSONSlicePayload) +} + +func jsonSliceBody() usecase.Interactor { + type jsonOutput struct { + Header string `json:"inHeader"` + Query int `json:"inQuery"` + Data JSONSlicePayload `json:"data"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in jsonSliceReq, out *jsonOutput) (err error) { + out.Query = in.Query + out.Header = in.Header + out.Data = in.JSONSlicePayload + + return nil + }) + + u.SetTitle("Request With JSON Array In Body") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/main.go b/_examples/advanced-generic-openapi31/main.go new file mode 100644 index 0000000..3112630 --- /dev/null +++ b/_examples/advanced-generic-openapi31/main.go @@ -0,0 +1,15 @@ +//go:build go1.18 + +package main + +import ( + "log" + "net/http" +) + +func main() { + log.Println("http://localhost:8012/docs") + if err := http.ListenAndServe("localhost:8012", NewRouter()); err != nil { + log.Fatal(err) + } +} diff --git a/_examples/advanced-generic-openapi31/no_validation.go b/_examples/advanced-generic-openapi31/no_validation.go new file mode 100644 index 0000000..8cc1b25 --- /dev/null +++ b/_examples/advanced-generic-openapi31/no_validation.go @@ -0,0 +1,41 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/usecase" +) + +func noValidation() usecase.Interactor { + type inputPort struct { + Header int `header:"X-Input"` + Query bool `query:"q"` + Data struct { + Value string `json:"value"` + } `json:"data"` + } + + type outputPort struct { + Header int `header:"X-Output" json:"-"` + AnotherHeader bool `header:"X-Query" json:"-"` + Data struct { + Value string `json:"value"` + } `json:"data"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputPort, out *outputPort) (err error) { + out.Header = in.Header + out.AnotherHeader = in.Query + out.Data.Value = in.Data.Value + + return nil + }) + + u.SetTitle("No Validation") + u.SetDescription("Input/Output without validation.") + u.SetTags("Request", "Response") + + return u +} diff --git a/_examples/advanced-generic-openapi31/output_headers.go b/_examples/advanced-generic-openapi31/output_headers.go new file mode 100644 index 0000000..41f2227 --- /dev/null +++ b/_examples/advanced-generic-openapi31/output_headers.go @@ -0,0 +1,45 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/usecase" + "github.com/swaggest/usecase/status" +) + +func outputHeaders() usecase.Interactor { + type EmbeddedHeaders struct { + Foo int `header:"X-foO,omitempty" json:"-" minimum:"10" required:"true" description:"Reduced by 20 in response."` + } + + type headerOutput struct { + EmbeddedHeaders + Header string `header:"x-HeAdEr" json:"-" description:"Sample response header."` + OmitEmpty int `header:"x-omit-empty,omitempty" json:"-" description:"Receives req value of X-Foo reduced by 30."` + InBody string `json:"inBody" deprecated:"true"` + Cookie int `cookie:"coo,httponly,path:/foo" json:"-"` + } + + type headerInput struct { + EmbeddedHeaders + } + + u := usecase.NewInteractor(func(ctx context.Context, in headerInput, out *headerOutput) (err error) { + out.Header = "abc" + out.InBody = "def" + out.Cookie = 123 + out.Foo = in.Foo - 20 + out.OmitEmpty = in.Foo - 30 + + return nil + }) + + u.SetTitle("Output With Headers") + u.SetDescription("Output with headers.") + u.SetTags("Response") + u.SetExpectedErrors(status.Internal) + + return u +} diff --git a/_examples/advanced-generic-openapi31/output_headers_test.go b/_examples/advanced-generic-openapi31/output_headers_test.go new file mode 100644 index 0000000..929135f --- /dev/null +++ b/_examples/advanced-generic-openapi31/output_headers_test.go @@ -0,0 +1,136 @@ +//go:build go1.18 + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/swaggest/assertjson" + "github.com/valyala/fasthttp" +) + +// Benchmark_outputHeaders-4 41424 27054 ns/op 154 B:rcvd/op 77.0 B:sent/op 36963 rps 3641 B/op 35 allocs/op. +func Benchmark_outputHeaders(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodGet) + req.SetRequestURI(srv.URL + "/output-headers") + req.Header.Set("X-Foo", "40") + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} + +func Test_outputHeaders(t *testing.T) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil) + require.NoError(t, err) + + req.Header.Set("x-FoO", "40") + + resp, err := http.DefaultTransport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, http.StatusOK) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + assert.Equal(t, "abc", resp.Header.Get("X-Header")) + assert.Equal(t, "20", resp.Header.Get("X-Foo")) + assert.Equal(t, "10", resp.Header.Get("X-Omit-Empty")) + assert.Equal(t, []string{"coo=123; HttpOnly"}, resp.Header.Values("Set-Cookie")) + assertjson.Equal(t, []byte(`{"inBody":"def"}`), body) +} + +func Test_outputHeaders_invalidReq(t *testing.T) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil) + require.NoError(t, err) + + req.Header.Set("x-FoO", "5") + + resp, err := http.DefaultTransport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, http.StatusBadRequest) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + assertjson.Equal(t, + []byte(`{"msg":"invalid argument: validation failed","details":{"header:X-Foo":["#: must be >= 10/1 but found 5"]}}`), + body, string(body)) +} + +func Test_outputHeaders_invalidResp(t *testing.T) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil) + require.NoError(t, err) + + req.Header.Set("x-FoO", "15") + + resp, err := http.DefaultTransport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, http.StatusInternalServerError) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + assertjson.Equal(t, + []byte(`{"msg":"internal: bad response: validation failed","details":{"header:X-Foo":["#: must be >= 10/1 but found -5"]}}`), + body, string(body)) +} + +func Test_outputHeaders_omitempty(t *testing.T) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/output-headers", nil) + require.NoError(t, err) + + req.Header.Set("x-FoO", "30") + + resp, err := http.DefaultTransport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, http.StatusOK) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + + assert.Equal(t, "abc", resp.Header.Get("X-Header")) + assert.Equal(t, "10", resp.Header.Get("X-Foo")) + assert.Equal(t, []string(nil), resp.Header.Values("X-Omit-Empty")) + assert.Equal(t, []string{"coo=123; HttpOnly"}, resp.Header.Values("Set-Cookie")) + assertjson.Equal(t, []byte(`{"inBody":"def"}`), body) +} diff --git a/_examples/advanced-generic-openapi31/output_writer.go b/_examples/advanced-generic-openapi31/output_writer.go new file mode 100644 index 0000000..61f88cc --- /dev/null +++ b/_examples/advanced-generic-openapi31/output_writer.go @@ -0,0 +1,47 @@ +//go:build go1.18 + +package main + +import ( + "context" + "encoding/csv" + "net/http" + + "github.com/swaggest/rest" + "github.com/swaggest/usecase" + "github.com/swaggest/usecase/status" +) + +func outputCSVWriter() usecase.Interactor { + type writerOutput struct { + Header string `header:"X-Header" description:"Sample response header."` + ContentHash string `header:"ETag" description:"Content hash."` + usecase.OutputWithEmbeddedWriter + } + + type writerInput struct { + ContentHash string `header:"If-None-Match" description:"Content hash."` + } + + u := usecase.NewInteractor(func(ctx context.Context, in writerInput, out *writerOutput) (err error) { + contentHash := "abc123" // Pretending this is an actual content hash. + + if in.ContentHash == contentHash { + return rest.HTTPCodeAsError(http.StatusNotModified) + } + + out.Header = "abc" + out.ContentHash = contentHash + + c := csv.NewWriter(out) + + return c.WriteAll([][]string{{"abc", "def", "hij"}, {"klm", "nop", "qrs"}}) + }) + + u.SetTitle("Output With Stream Writer") + u.SetDescription("Output with stream writer.") + u.SetExpectedErrors(status.Internal, rest.HTTPCodeAsError(http.StatusNotModified)) + u.SetTags("Response") + + return u +} diff --git a/_examples/advanced-generic-openapi31/query_object.go b/_examples/advanced-generic-openapi31/query_object.go new file mode 100644 index 0000000..148f404 --- /dev/null +++ b/_examples/advanced-generic-openapi31/query_object.go @@ -0,0 +1,45 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/usecase" +) + +func queryObject() usecase.Interactor { + type jsonFilter struct { + Foo string `json:"foo" maxLength:"5"` + } + + type deepObjectFilter struct { + Bar string `json:"bar" query:"bar" minLength:"3"` + Baz *string `json:"baz,omitempty" query:"baz" minLength:"3"` + } + + type inputQueryObject struct { + Query map[int]float64 `query:"in_query" description:"Object value in query."` + JSONFilter jsonFilter `query:"json_filter" description:"JSON object value in query."` + DeepObjectFilter deepObjectFilter `query:"deep_object_filter" description:"Deep object value in query params."` + } + + type outputQueryObject struct { + Query map[int]float64 `json:"inQuery"` + JSONFilter jsonFilter `json:"jsonFilter"` + DeepObjectFilter deepObjectFilter `json:"deepObjectFilter"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputQueryObject, out *outputQueryObject) (err error) { + out.Query = in.Query + out.JSONFilter = in.JSONFilter + out.DeepObjectFilter = in.DeepObjectFilter + + return nil + }) + + u.SetTitle("Request With Object As Query Parameter") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/query_object_test.go b/_examples/advanced-generic-openapi31/query_object_test.go new file mode 100644 index 0000000..cf95747 --- /dev/null +++ b/_examples/advanced-generic-openapi31/query_object_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/swaggest/assertjson" +) + +func Test_queryObject(t *testing.T) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + for _, tc := range []struct { + name string + url string + code int + resp string + }{ + { + name: "validation_failed_deep_object", + url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_filter={"foo":"strin"}&deep_object_filter[bar]=sd`, + code: http.StatusBadRequest, + resp: `{ + "msg":"invalid argument: validation failed", + "details":{"query:deep_object_filter":["#/bar: length must be \u003e= 3, but got 2"]} + }`, + }, + { + name: "validation_failed_deep_object_2", + url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_filter={"foo":"strin"}&deep_object_filter[bar]=asd&deep_object_filter[baz]=sd`, + code: http.StatusBadRequest, + resp: `{ + "msg":"invalid argument: validation failed", + "details":{"query:deep_object_filter":["#/baz: length must be \u003e= 3, but got 2"]} + }`, + }, + { + name: "validation_failed_json", + url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_filter={"foo":"string"}&deep_object_filter[bar]=asd`, + code: http.StatusBadRequest, + resp: `{ + "msg":"invalid argument: validation failed", + "details":{"query:json_filter":["#/foo: length must be \u003c= 5, but got 6"]} + }`, + }, + { + name: "ok", + url: `/query-object?in_query[1]=0&in_query[2]=0&in_query[3]=0&json_filter={"foo":"strin"}&deep_object_filter[bar]=asd`, + code: http.StatusOK, + resp: `{ + "inQuery":{"1":0,"2":0,"3":0},"jsonFilter":{"foo":"strin"}, + "deepObjectFilter":{"bar":"asd"} + }`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest( + http.MethodGet, + srv.URL+tc.url, + nil, + ) + require.NoError(t, err) + + resp, err := http.DefaultTransport.RoundTrip(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + assertjson.EqMarshal(t, tc.resp, json.RawMessage(body)) + assert.Equal(t, tc.code, resp.StatusCode) + }) + } +} diff --git a/_examples/advanced-generic-openapi31/request_response_mapping.go b/_examples/advanced-generic-openapi31/request_response_mapping.go new file mode 100644 index 0000000..39a5d7d --- /dev/null +++ b/_examples/advanced-generic-openapi31/request_response_mapping.go @@ -0,0 +1,35 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/usecase" +) + +func reqRespMapping() usecase.Interactor { + type inputPort struct { + Val1 string `description:"Simple scalar value with sample validation." required:"true" minLength:"3"` + Val2 int `description:"Simple scalar value with sample validation." required:"true" minimum:"3"` + } + + type outputPort struct { + Val1 string `json:"-" description:"Simple scalar value with sample validation." required:"true" minLength:"3"` + Val2 int `json:"-" description:"Simple scalar value with sample validation." required:"true" minimum:"3"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputPort, out *outputPort) (err error) { + out.Val1 = in.Val1 + out.Val2 = in.Val2 + + return nil + }) + + u.SetTitle("Request Response Mapping") + u.SetName("reqRespMapping") + u.SetDescription("This use case has transport concerns fully decoupled with external req/resp mapping.") + u.SetTags("Request", "Response") + + return u +} diff --git a/_examples/advanced-generic-openapi31/request_response_mapping_test.go b/_examples/advanced-generic-openapi31/request_response_mapping_test.go new file mode 100644 index 0000000..0c85192 --- /dev/null +++ b/_examples/advanced-generic-openapi31/request_response_mapping_test.go @@ -0,0 +1,59 @@ +//go:build go1.18 + +package main + +import ( + "bytes" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/valyala/fasthttp" +) + +func Test_requestResponseMapping(t *testing.T) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + req, err := http.NewRequest(http.MethodPost, srv.URL+"/req-resp-mapping", + bytes.NewReader([]byte(`val2=3`))) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Header", "abc") + + resp, err := http.DefaultTransport.RoundTrip(req) + require.NoError(t, err) + + assert.Equal(t, http.StatusNoContent, resp.StatusCode) + + body, err := ioutil.ReadAll(resp.Body) + assert.NoError(t, err) + assert.NoError(t, resp.Body.Close()) + assert.Equal(t, "", string(body)) + + assert.Equal(t, "abc", resp.Header.Get("X-Value-1")) + assert.Equal(t, "3", resp.Header.Get("X-Value-2")) +} + +func Benchmark_requestResponseMapping(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(srv.URL + "/req-resp-mapping") + req.Header.Set("X-Header", "abc") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBody([]byte(`val2=3`)) + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusNoContent + }) +} diff --git a/_examples/advanced-generic-openapi31/request_text_body.go b/_examples/advanced-generic-openapi31/request_text_body.go new file mode 100644 index 0000000..38b8240 --- /dev/null +++ b/_examples/advanced-generic-openapi31/request_text_body.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "io" + "net/http" + + "github.com/swaggest/usecase" +) + +type textReqBodyInput struct { + Path string `path:"path"` + Query int `query:"query"` + text []byte + err error +} + +func (c *textReqBodyInput) SetRequest(r *http.Request) { + c.text, c.err = io.ReadAll(r.Body) + clErr := r.Body.Close() + + if c.err == nil { + c.err = clErr + } +} + +func textReqBody() usecase.Interactor { + type output struct { + Path string `json:"path"` + Query int `json:"query"` + Text string `json:"text"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in textReqBodyInput, out *output) (err error) { + out.Text = string(in.text) + out.Path = in.Path + out.Query = in.Query + + return nil + }) + + u.SetTitle("Request With Text Body") + u.SetDescription("This usecase allows direct access to original `*http.Request` while keeping automated decoding of parameters.") + u.SetTags("Request") + + return u +} + +func textReqBodyPtr() usecase.Interactor { + type output struct { + Path string `json:"path"` + Query int `json:"query"` + Text string `json:"text"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in *textReqBodyInput, out *output) (err error) { + out.Text = string(in.text) + out.Path = in.Path + out.Query = in.Query + + return nil + }) + + u.SetTitle("Request With Text Body (ptr input)") + u.SetDescription("This usecase allows direct access to original `*http.Request` while keeping automated decoding of parameters.") + u.SetTags("Request") + + return u +} diff --git a/_examples/advanced-generic-openapi31/router.go b/_examples/advanced-generic-openapi31/router.go new file mode 100644 index 0000000..0a1e7fc --- /dev/null +++ b/_examples/advanced-generic-openapi31/router.go @@ -0,0 +1,238 @@ +//go:build go1.18 + +package main + +import ( + "context" + "errors" + "log" + "net/http" + "reflect" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/rs/cors" + "github.com/swaggest/jsonschema-go" + oapi "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi31" + "github.com/swaggest/rest" + "github.com/swaggest/rest/nethttp" + "github.com/swaggest/rest/response" + "github.com/swaggest/rest/response/gzip" + "github.com/swaggest/rest/web" + swgui "github.com/swaggest/swgui/v5emb" + "github.com/swaggest/usecase" +) + +func NewRouter() http.Handler { + s := web.NewService(openapi31.NewReflector()) + + 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, "GenericOpenapi31", "") + }, + )) + + // Usecase middlewares can be added to web.Service or chirouter.Wrapper. + s.Wrap(nethttp.UseCaseMiddlewares(usecase.MiddlewareFunc(func(next usecase.Interactor) usecase.Interactor { + var ( + hasName usecase.HasName + name = "unknown" + ) + + if usecase.As(next, &hasName) { + name = hasName.Name() + } + + return usecase.Interact(func(ctx context.Context, input, output interface{}) error { + err := next.Interact(ctx, input, output) + if err != nil && !errors.Is(err, rest.HTTPCodeAsError(http.StatusNotModified)) { + log.Printf("usecase %s request (%+v) failed: %v\n", name, input, err) + } + + return err + }) + }))) + + // An example of global schema override to disable additionalProperties for all object schemas. + 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 := oapi.OperationCtx(params.Context); !params.Processed || !ok || + oc.IsProcessingResponse() || oc.ProcessingIn() == oapi.InHeader { + return false, nil + } + + schema := params.Schema + + if schema.HasType(jsonschema.Object) && len(schema.Properties) > 0 && schema.AdditionalProperties == nil { + schema.AdditionalProperties = (&jsonschema.SchemaOrBool{}).WithTypeBoolean(false) + } + + return false, nil + }), + ) + + // Create custom schema mapping for 3rd party type. + uuidDef := jsonschema.Schema{} + uuidDef.AddType(jsonschema.String) + uuidDef.WithFormat("uuid") + uuidDef.WithExamples("248df4b7-aa70-47b8-a036-33ac447e668d") + 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. + s.OpenAPICollector.CombineErrors = "anyOf" + + s.Wrap( + // Example middleware to set up custom error responses and disable response validation for particular handlers. + func(handler http.Handler) http.Handler { + var h *nethttp.Handler + if nethttp.HandlerAs(handler, &h) { + h.MakeErrResp = func(ctx context.Context, err error) (int, interface{}) { + code, er := rest.Err(err) + + var ae anotherErr + + if errors.As(err, &ae) { + return http.StatusBadRequest, ae + } + + return code, customErr{ + Message: er.ErrorText, + Details: er.Context, + } + } + + var hr rest.HandlerWithRoute + if h.RespValidator != nil && + nethttp.HandlerAs(handler, &hr) { + if hr.RoutePattern() == "/json-body-manual/{in-path}" || hr.RoutePattern() == "/json-body/{in-path}" { + h.RespValidator = nil + } + } + } + + return handler + }, + + // Example middleware to set up CORS headers. + // See https://pkg.go.dev/github.com/rs/cors for more details. + cors.AllowAll().Handler, + + // Response validator setup. + // + // It might be a good idea to disable this middleware in production to save performance, + // but keep it enabled in dev/test/staging environments to catch logical issues. + response.ValidatorMiddleware(s.ResponseValidatorFactory), + gzip.Middleware, // Response compression with support for direct gzip pass through. + ) + + // Annotations can be used to alter documentation of operation identified by method and path. + s.OpenAPICollector.AnnotateOperation(http.MethodPost, "/validation", func(oc oapi.OperationContext) error { + o3, ok := oc.(openapi31.OperationExposer) + if !ok { + return nil + } + + op := o3.Operation() + + if op.Description != nil { + *op.Description = *op.Description + " Custom annotation." + } + + return nil + }) + + s.Get("/query-object", queryObject()) + s.Post("/form", form()) + + s.Post("/file-upload", fileUploader()) + s.Post("/file-multi-upload", fileMultiUploader()) + s.Get("/json-param/{in-path}", jsonParam()) + s.Post("/json-body/{in-path}", jsonBody(), + nethttp.SuccessStatus(http.StatusCreated)) + s.Post("/json-body-manual/{in-path}", jsonBodyManual(), + nethttp.SuccessStatus(http.StatusCreated)) + s.Post("/json-body-validation/{in-path}", jsonBodyValidation()) + s.Post("/json-slice-body", jsonSliceBody()) + + s.Post("/json-map-body", jsonMapBody(), + // Annotate operation to add post-processing if necessary. + nethttp.AnnotateOpenAPIOperation(func(oc oapi.OperationContext) error { + oc.SetDescription("Request with JSON object (map) body.") + + return nil + })) + + s.Get("/html-response/{id}", htmlResponse(), nethttp.SuccessfulResponseContentType("text/html")) + + s.Get("/output-headers", outputHeaders()) + s.Head("/output-headers", outputHeaders()) + s.Get("/output-csv-writer", outputCSVWriter(), + nethttp.SuccessfulResponseContentType("text/csv; charset=utf-8")) + + s.Post("/req-resp-mapping", reqRespMapping(), + nethttp.RequestMapping(new(struct { + Val1 string `header:"X-Header"` + Val2 int `formData:"val2"` + })), + nethttp.ResponseHeaderMapping(new(struct { + Val1 string `header:"X-Value-1"` + Val2 int `header:"X-Value-2"` + })), + ) + + s.Post("/validation", validation()) + s.Post("/no-validation", noValidation()) + + // Type mapping is necessary to pass interface as structure into documentation. + jsr.AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct)) + s.Get("/gzip-pass-through", directGzip()) + s.Head("/gzip-pass-through", directGzip()) + + s.Get("/error-response", errorResponse()) + s.Post("/text-req-body/{path}", textReqBody(), nethttp.RequestBodyContent("text/csv")) + s.Post("/text-req-body-ptr/{path}", textReqBodyPtr(), nethttp.RequestBodyContent("text/csv")) + + // Security middlewares. + // - sessMW is the actual request-level processor, + // - sessDoc is a handler-level wrapper to expose docs. + sessMW := func(handler http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("sessid"); err == nil { + r = r.WithContext(context.WithValue(r.Context(), "sessionID", c.Value)) + } + + handler.ServeHTTP(w, r) + }) + } + + sessDoc := nethttp.APIKeySecurityMiddleware(s.OpenAPICollector, "User", + "sessid", oapi.InCookie, "Session cookie.") + + // Security schema is configured for a single top-level route. + s.With(sessMW, sessDoc).Method(http.MethodGet, "/root-with-session", nethttp.NewHandler(dummy())) + + // Security schema is configured on a sub-router. + s.Route("/deeper-with-session", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(sessMW, sessDoc) + + r.Method(http.MethodGet, "/one", nethttp.NewHandler(dummy())) + r.Method(http.MethodGet, "/two", nethttp.NewHandler(dummy())) + }) + }) + + // Swagger UI endpoint at /docs. + s.Docs("/docs", swgui.New) + + return s +} diff --git a/_examples/advanced-generic-openapi31/router_test.go b/_examples/advanced-generic-openapi31/router_test.go new file mode 100644 index 0000000..a581145 --- /dev/null +++ b/_examples/advanced-generic-openapi31/router_test.go @@ -0,0 +1,37 @@ +//go:build go1.18 + +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/swaggest/assertjson" +) + +func TestNewRouter(t *testing.T) { + r := NewRouter() + + req, err := http.NewRequest(http.MethodGet, "/docs/openapi.json", nil) + require.NoError(t, err) + + rw := httptest.NewRecorder() + + r.ServeHTTP(rw, req) + assert.Equal(t, http.StatusOK, rw.Code) + + actualSchema, err := assertjson.MarshalIndentCompact(json.RawMessage(rw.Body.Bytes()), "", " ", 120) + require.NoError(t, err) + + expectedSchema, err := os.ReadFile("_testdata/openapi.json") + require.NoError(t, err) + + if !assertjson.Equal(t, expectedSchema, rw.Body.Bytes(), string(actualSchema)) { + require.NoError(t, os.WriteFile("_testdata/openapi_last_run.json", actualSchema, 0o600)) + } +} diff --git a/_examples/advanced-generic-openapi31/validation.go b/_examples/advanced-generic-openapi31/validation.go new file mode 100644 index 0000000..f4e66c5 --- /dev/null +++ b/_examples/advanced-generic-openapi31/validation.go @@ -0,0 +1,41 @@ +//go:build go1.18 + +package main + +import ( + "context" + + "github.com/swaggest/usecase" +) + +func validation() usecase.Interactor { + type inputPort struct { + Header int `header:"X-Input" minimum:"10" description:"Request minimum: 10, response maximum: 20."` + Query bool `query:"q" description:"This parameter will bypass explicit validation as it does not have constraints."` + Data struct { + Value string `json:"value" minLength:"3" description:"Request minLength: 3, response maxLength: 7"` + } `json:"data" required:"true"` + } + + type outputPort struct { + Header int `header:"X-Output" json:"-" maximum:"20"` + AnotherHeader bool `header:"X-Query" json:"-" description:"This header bypasses validation as it does not have constraints."` + Data struct { + Value string `json:"value" maxLength:"7"` + } `json:"data" required:"true"` + } + + u := usecase.NewInteractor(func(ctx context.Context, in inputPort, out *outputPort) (err error) { + out.Header = in.Header + out.AnotherHeader = in.Query + out.Data.Value = in.Data.Value + + return nil + }) + + u.SetTitle("Validation") + u.SetDescription("Input/Output with validation.") + u.SetTags("Request", "Response", "Validation") + + return u +} diff --git a/_examples/advanced-generic-openapi31/validation_test.go b/_examples/advanced-generic-openapi31/validation_test.go new file mode 100644 index 0000000..227ea0f --- /dev/null +++ b/_examples/advanced-generic-openapi31/validation_test.go @@ -0,0 +1,48 @@ +//go:build go1.18 + +package main + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/bool64/httptestbench" + "github.com/valyala/fasthttp" +) + +// Benchmark_validation-4 18979 53012 ns/op 197 B:rcvd/op 170 B:sent/op 18861 rps 14817 B/op 131 allocs/op. +// Benchmark_validation-4 17665 58243 ns/op 177 B:rcvd/op 170 B:sent/op 17161 rps 16349 B/op 132 allocs/op. +func Benchmark_validation(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(srv.URL + "/validation?q=true") + req.Header.Set("X-Input", "12") + req.Header.Set("Content-Type", "application/json") + req.SetBody([]byte(`{"data":{"value":"abc"}}`)) + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} + +func Benchmark_noValidation(b *testing.B) { + r := NewRouter() + + srv := httptest.NewServer(r) + defer srv.Close() + + httptestbench.RoundTrip(b, 50, func(i int, req *fasthttp.Request) { + req.Header.SetMethod(http.MethodPost) + req.SetRequestURI(srv.URL + "/no-validation?q=true") + req.Header.Set("X-Input", "12") + req.Header.Set("Content-Type", "application/json") + req.SetBody([]byte(`{"data":{"value":"abc"}}`)) + }, func(i int, resp *fasthttp.Response) bool { + return resp.StatusCode() == http.StatusOK + }) +} diff --git a/_examples/advanced-generic/_testdata/openapi.json b/_examples/advanced-generic/_testdata/openapi.json index 7e1081d..b40ed84 100644 --- a/_examples/advanced-generic/_testdata/openapi.json +++ b/_examples/advanced-generic/_testdata/openapi.json @@ -802,6 +802,6 @@ }, "TextprotoMIMEHeader":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}} }, - "securitySchemes":{"User":{"type":"apiKey","name":"sessid","in":"cookie"}} + "securitySchemes":{"User":{"type":"apiKey","name":"sessid","in":"cookie","description":"Session cookie."}} } } diff --git a/_examples/advanced-generic/router.go b/_examples/advanced-generic/router.go index ba2e29e..87d93b4 100644 --- a/_examples/advanced-generic/router.go +++ b/_examples/advanced-generic/router.go @@ -21,12 +21,12 @@ import ( "github.com/swaggest/rest/response" "github.com/swaggest/rest/response/gzip" "github.com/swaggest/rest/web" - swgui "github.com/swaggest/swgui/v4emb" + swgui "github.com/swaggest/swgui/v5emb" "github.com/swaggest/usecase" ) func NewRouter() http.Handler { - s := web.DefaultService() + s := web.NewService(openapi3.NewReflector()) s.OpenAPISchema().SetTitle("Advanced Example") s.OpenAPISchema().SetDescription("This app showcases a variety of features.") @@ -215,12 +215,8 @@ func NewRouter() http.Handler { }) } - sessDoc := nethttp.SecurityMiddleware(s.OpenAPICollector, "User", openapi3.SecurityScheme{ - APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{ - In: "cookie", - Name: "sessid", - }, - }) + sessDoc := nethttp.AuthMiddleware(s.OpenAPICollector, "User") + s.OpenAPISchema().SetAPIKeySecurity("User", "sessid", oapi.InCookie, "Session cookie.") // Security schema is configured for a single top-level route. s.With(sessMW, sessDoc).Method(http.MethodGet, "/root-with-session", nethttp.NewHandler(dummy())) diff --git a/_examples/advanced/_testdata/openapi.json b/_examples/advanced/_testdata/openapi.json index d46e050..5bcc7e4 100644 --- a/_examples/advanced/_testdata/openapi.json +++ b/_examples/advanced/_testdata/openapi.json @@ -606,6 +606,6 @@ }, "TextprotoMIMEHeader":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}}} }, - "securitySchemes":{"User":{"type":"apiKey","name":"sessid","in":"cookie"}} + "securitySchemes":{"User":{"type":"apiKey","name":"sessid","in":"cookie","description":"Session cookie."}} } } diff --git a/_examples/advanced/router.go b/_examples/advanced/router.go index 8584c47..5bc8e19 100644 --- a/_examples/advanced/router.go +++ b/_examples/advanced/router.go @@ -22,7 +22,7 @@ func NewRouter() http.Handler { response.DefaultErrorResponseContentType = "application/problem+json" response.DefaultSuccessResponseContentType = "application/dummy+json" - s := web.DefaultService() + s := web.NewService(openapi3.NewReflector()) s.OpenAPISchema().SetTitle("Advanced Example") s.OpenAPISchema().SetDescription("This app showcases a variety of features.") @@ -162,12 +162,8 @@ func NewRouter() http.Handler { }) } - sessDoc := nethttp.SecurityMiddleware(s.OpenAPICollector, "User", openapi3.SecurityScheme{ - APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{ - In: "cookie", - Name: "sessid", - }, - }) + sessDoc := nethttp.APIKeySecurityMiddleware(s.OpenAPICollector, "User", + "sessid", oapi.InCookie, "Session cookie.") // Security schema is configured for a single top-level route. s.With(sessMW, sessDoc).Method(http.MethodGet, "/root-with-session", nethttp.NewHandler(dummy())) diff --git a/_examples/basic/main.go b/_examples/basic/main.go index 4136039..4521103 100644 --- a/_examples/basic/main.go +++ b/_examples/basic/main.go @@ -8,6 +8,7 @@ import ( "net/http" "time" + "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest/response/gzip" "github.com/swaggest/rest/web" swgui "github.com/swaggest/swgui/v5emb" @@ -16,7 +17,7 @@ import ( ) func main() { - s := web.DefaultService() + s := web.NewService(openapi3.NewReflector()) // Init API documentation schema. s.OpenAPISchema().SetTitle("Basic Example") diff --git a/_examples/generic/main.go b/_examples/generic/main.go index ab97a90..1c79c91 100644 --- a/_examples/generic/main.go +++ b/_examples/generic/main.go @@ -11,6 +11,7 @@ import ( "net/http" "time" + "github.com/swaggest/openapi-go/openapi31" "github.com/swaggest/rest/response/gzip" "github.com/swaggest/rest/web" swgui "github.com/swaggest/swgui/v5emb" @@ -19,7 +20,7 @@ import ( ) func main() { - s := web.DefaultService() + s := web.NewService(openapi31.NewReflector()) // Init API documentation schema. s.OpenAPISchema().SetTitle("Basic Example") diff --git a/_examples/gingonic/main.go b/_examples/gingonic/main.go index 3096802..a5355c2 100644 --- a/_examples/gingonic/main.go +++ b/_examples/gingonic/main.go @@ -5,9 +5,11 @@ import ( "log" "net/http" "os" + "reflect" "regexp" "github.com/gin-gonic/gin" + "github.com/swaggest/jsonschema-go" "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" ) @@ -54,19 +56,16 @@ func OpenAPICollect(refl openapi.Reflector, routes gin.RoutesInfo) error { } 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) - } + req := jsonschema.Struct{} + for _, p := range pathItems { + req.Fields = append(req.Fields, jsonschema.Field{ + Name: "F" + p, + Tag: reflect.StructTag(`path:"` + p + `"`), + Value: "", + }) } + + oc.AddReqStructure(req) } oc.SetDescription("Information about this operation was obtained using only HTTP method and path pattern. " + @@ -118,7 +117,7 @@ func main() { y, _ := refl.Spec.MarshalYAML() - os.WriteFile("openapi.yaml", y, 0600) + os.WriteFile("openapi.yaml", y, 0o600) fmt.Println(string(y)) router.Run("localhost:8080") diff --git a/_examples/go.mod b/_examples/go.mod index 5e0c583..8f9ea40 100644 --- a/_examples/go.mod +++ b/_examples/go.mod @@ -9,25 +9,21 @@ require ( github.com/bool64/dev v0.2.29 github.com/bool64/httpmock v0.1.13 github.com/bool64/httptestbench v0.1.4 + github.com/gin-gonic/gin v1.9.1 github.com/go-chi/chi/v5 v5.0.10 + github.com/google/uuid v1.3.0 github.com/kelseyhightower/envconfig v1.4.0 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.55 - github.com/swaggest/openapi-go v0.2.37 + github.com/swaggest/jsonschema-go v0.3.57 + github.com/swaggest/openapi-go v0.2.38 + github.com/swaggest/rest v0.0.0-00010101000000-000000000000 github.com/swaggest/swgui v1.7.2 github.com/swaggest/usecase v1.2.1 github.com/valyala/fasthttp v1.48.0 ) -require ( - github.com/gin-gonic/gin v1.9.1 - github.com/google/uuid v1.3.0 - github.com/gorilla/mux v1.8.0 - github.com/swaggest/rest v0.0.0-00010101000000-000000000000 -) - require ( github.com/andybalholm/brotli v1.0.5 // indirect github.com/bool64/shared v0.1.5 // indirect diff --git a/_examples/go.sum b/_examples/go.sum index 35e2184..ab45a43 100644 --- a/_examples/go.sum +++ b/_examples/go.sum @@ -48,8 +48,6 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -106,10 +104,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.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/jsonschema-go v0.3.57 h1:n6D/2K9557Yqn/NohXoszmjuN0Lp5n0DyHlVLRZXbOM= +github.com/swaggest/jsonschema-go v0.3.57/go.mod h1:5WFFGBBte5JAWAV8gDpNRJ/tlQnb1AHDdf/ghgsVUik= +github.com/swaggest/openapi-go v0.2.38 h1:umFRZ2wg75eDAofQCMLfXf8eWBLuQNfU9jOjGxwFdng= +github.com/swaggest/openapi-go v0.2.38/go.mod h1:g+AfRIkPCHdhqfW8zOD1Sk3PwLhxpWW8SNWHXrmA08c= 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.7.2 h1:N5hMPCQ+bIedVJoQDNjFUn8BqtISQDwaqEa76VkvzLs= diff --git a/_examples/mount/main.go b/_examples/mount/main.go index c03b56f..ed38729 100644 --- a/_examples/mount/main.go +++ b/_examples/mount/main.go @@ -7,6 +7,7 @@ import ( "net/http" "github.com/go-chi/chi/v5/middleware" + "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest/nethttp" "github.com/swaggest/rest/web" swgui "github.com/swaggest/swgui/v5emb" @@ -36,10 +37,10 @@ func sum() usecase.Interactor { } func main() { - service := web.DefaultService() + service := web.NewService(openapi3.NewReflector()) service.OpenAPISchema().SetTitle("Security and Mount Example") - apiV1 := web.DefaultService() + apiV1 := web.NewService(service.OpenAPIReflector()) apiV1.Wrap( middleware.BasicAuth("Admin Access", map[string]string{"admin": "admin"}), diff --git a/_examples/task-api/internal/infra/nethttp/router.go b/_examples/task-api/internal/infra/nethttp/router.go index c6dc11a..70d5a8a 100644 --- a/_examples/task-api/internal/infra/nethttp/router.go +++ b/_examples/task-api/internal/infra/nethttp/router.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/swaggest/openapi-go" + "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/rest/_examples/task-api/internal/infra/schema" "github.com/swaggest/rest/_examples/task-api/internal/infra/service" @@ -18,7 +19,7 @@ import ( // NewRouter creates HTTP router. func NewRouter(locator *service.Locator) http.Handler { - s := web.DefaultService() + s := web.NewService(openapi3.NewReflector()) schema.SetupOpenAPICollector(s.OpenAPICollector) diff --git a/_examples/task-api/internal/infra/schema/openapi.go b/_examples/task-api/internal/infra/schema/openapi.go index 733693d..694c1ac 100644 --- a/_examples/task-api/internal/infra/schema/openapi.go +++ b/_examples/task-api/internal/infra/schema/openapi.go @@ -2,17 +2,12 @@ package schema import ( - "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest/openapi" ) // SetupOpenAPICollector sets up API documentation collector. func SetupOpenAPICollector(apiSchema *openapi.Collector) { - serviceInfo := openapi3.Info{} - serviceInfo. - WithTitle("Tasks Service"). - WithDescription("This example service manages tasks."). - WithVersion("1.2.3") - - apiSchema.Reflector().SpecEns().WithInfo(serviceInfo) + apiSchema.SpecSchema().SetTitle("Tasks Service") + apiSchema.SpecSchema().SetDescription("This example service manages tasks.") + apiSchema.SpecSchema().SetVersion("1.2.3") } diff --git a/chirouter/wrapper_test.go b/chirouter/wrapper_test.go index 605e6af..c23a545 100644 --- a/chirouter/wrapper_test.go +++ b/chirouter/wrapper_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/swaggest/assertjson" + "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest" "github.com/swaggest/rest/chirouter" "github.com/swaggest/rest/nethttp" @@ -262,10 +263,10 @@ func TestWrapper_Use_StripSlashes(t *testing.T) { } func TestWrapper_Mount(t *testing.T) { - service := web.DefaultService() + service := web.NewService(openapi3.NewReflector()) service.OpenAPISchema().SetTitle("Security and Mount Example") - apiV1 := web.DefaultService() + apiV1 := web.NewService(openapi3.NewReflector()) apiV1.Wrap( middleware.BasicAuth("Admin Access", map[string]string{"admin": "admin"}), diff --git a/go.mod b/go.mod index 8b36238..713a233 100644 --- a/go.mod +++ b/go.mod @@ -13,8 +13,8 @@ require ( 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.55 - github.com/swaggest/openapi-go v0.2.37 + github.com/swaggest/jsonschema-go v0.3.57 + github.com/swaggest/openapi-go v0.2.38 github.com/swaggest/refl v1.2.0 github.com/swaggest/usecase v1.2.1 ) diff --git a/go.sum b/go.sum index b526793..a8336a8 100644 --- a/go.sum +++ b/go.sum @@ -76,10 +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.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/jsonschema-go v0.3.57 h1:n6D/2K9557Yqn/NohXoszmjuN0Lp5n0DyHlVLRZXbOM= +github.com/swaggest/jsonschema-go v0.3.57/go.mod h1:5WFFGBBte5JAWAV8gDpNRJ/tlQnb1AHDdf/ghgsVUik= +github.com/swaggest/openapi-go v0.2.38 h1:umFRZ2wg75eDAofQCMLfXf8eWBLuQNfU9jOjGxwFdng= +github.com/swaggest/openapi-go v0.2.38/go.mod h1:g+AfRIkPCHdhqfW8zOD1Sk3PwLhxpWW8SNWHXrmA08c= 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 index 21093f6..d72509c 100644 --- a/gorillamux/collector.go +++ b/gorillamux/collector.go @@ -2,10 +2,11 @@ package gorillamux import ( "net/http" + "reflect" "github.com/gorilla/mux" + "github.com/swaggest/jsonschema-go" oapi "github.com/swaggest/openapi-go" - "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest/nethttp" "github.com/swaggest/rest/openapi" ) @@ -110,19 +111,16 @@ func (dc *OpenAPICollector) collect(method, path string, preparer preparerFunc) } 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) - } + req := jsonschema.Struct{} + for _, p := range pathItems { + req.Fields = append(req.Fields, jsonschema.Field{ + Name: "F" + p, + Tag: reflect.StructTag(`path:"` + p + `"`), + Value: "", + }) } + + oc.AddReqStructure(req) } oc.SetDescription("Information about this operation was obtained using only HTTP method and path pattern. " + diff --git a/gorillamux/example_openapi_collector_test.go b/gorillamux/example_openapi_collector_test.go index e4e1d87..65c6e90 100644 --- a/gorillamux/example_openapi_collector_test.go +++ b/gorillamux/example_openapi_collector_test.go @@ -169,6 +169,9 @@ func ExampleNewOpenAPICollector() { // parameters: // - in: path // name: path-item + // required: true + // schema: + // type: string // responses: // "200": // content: diff --git a/nethttp/example_test.go b/nethttp/example_test.go index b6c0fb5..57b9ed6 100644 --- a/nethttp/example_test.go +++ b/nethttp/example_test.go @@ -5,26 +5,17 @@ import ( "fmt" "net/http" - "github.com/go-chi/chi/v5" "github.com/swaggest/assertjson" + oapi "github.com/swaggest/openapi-go" "github.com/swaggest/openapi-go/openapi3" - "github.com/swaggest/rest/chirouter" "github.com/swaggest/rest/nethttp" - "github.com/swaggest/rest/openapi" + "github.com/swaggest/rest/web" "github.com/swaggest/usecase" ) func ExampleSecurityMiddleware() { // Create router. - r := chirouter.NewWrapper(chi.NewRouter()) - - // Init API documentation schema. - apiSchema := &openapi.Collector{} - - // Setup middlewares (non-documentation middlewares omitted for brevity). - r.Wrap( - nethttp.OpenAPIMiddleware(apiSchema), // Documentation collector. - ) + s := web.NewService(openapi3.NewReflector()) // Configure an actual security middleware. serviceTokenAuth := func(h http.Handler) http.Handler { @@ -40,12 +31,8 @@ func ExampleSecurityMiddleware() { } // Configure documentation middleware to describe actual security middleware. - serviceTokenDoc := nethttp.SecurityMiddleware(apiSchema, "serviceToken", openapi3.SecurityScheme{ - APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{ - Name: "Authorization", - In: openapi3.APIKeySecuritySchemeInHeader, - }, - }) + serviceTokenDoc := nethttp.APIKeySecurityMiddleware(s.OpenAPICollector, + "serviceToken", "Authorization", oapi.InHeader, "Service token.") u := usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error { // Do something. @@ -53,11 +40,11 @@ func ExampleSecurityMiddleware() { }) // Add use case handler to router with security middleware. - r. + s. With(serviceTokenAuth, serviceTokenDoc). // Apply a pair of middlewares: actual security and documentation. Method(http.MethodGet, "/foo", nethttp.NewHandler(u)) - schema, _ := assertjson.MarshalIndentCompact(apiSchema.SpecSchema(), "", " ", 120) + schema, _ := assertjson.MarshalIndentCompact(s.OpenAPISchema(), "", " ", 120) fmt.Println(string(schema)) // Output: @@ -89,7 +76,7 @@ func ExampleSecurityMiddleware() { // } // } // }, - // "securitySchemes":{"serviceToken":{"type":"apiKey","name":"Authorization","in":"header"}} + // "securitySchemes":{"serviceToken":{"type":"apiKey","name":"Authorization","in":"header","description":"Service token."}} // } // } } diff --git a/nethttp/openapi.go b/nethttp/openapi.go index b276ae5..abc7ad6 100644 --- a/nethttp/openapi.go +++ b/nethttp/openapi.go @@ -39,7 +39,24 @@ func OpenAPIMiddleware(s *openapi.Collector) func(http.Handler) http.Handler { } } +// AuthMiddleware creates middleware to expose security scheme. +func AuthMiddleware( + c *openapi.Collector, + name string, + options ...func(*MiddlewareConfig), +) func(http.Handler) http.Handler { + cfg := MiddlewareConfig{} + + for _, o := range options { + o(&cfg) + } + + return securityMiddleware(c, name, cfg) +} + // SecurityMiddleware creates middleware to expose security scheme. +// +// Deprecated: use AuthMiddleware. func SecurityMiddleware( c *openapi.Collector, name string, @@ -62,23 +79,26 @@ func SecurityMiddleware( return securityMiddleware(c, name, cfg) } -// HTTPBasicSecurityMiddleware creates middleware to expose Basic Security schema. -func HTTPBasicSecurityMiddleware( +// APIKeySecurityMiddleware creates middleware to expose API Key security schema. +func APIKeySecurityMiddleware( c *openapi.Collector, - name, description string, + name string, fieldName string, fieldIn oapi.In, description string, options ...func(*MiddlewareConfig), ) func(http.Handler) http.Handler { - hss := openapi3.HTTPSecurityScheme{} + c.SpecSchema().SetAPIKeySecurity(name, fieldName, fieldIn, description) - hss.WithScheme("basic") + return AuthMiddleware(c, name, options...) +} - if description != "" { - hss.WithDescription(description) - } +// HTTPBasicSecurityMiddleware creates middleware to expose HTTP Basic security schema. +func HTTPBasicSecurityMiddleware( + c *openapi.Collector, + name, description string, + options ...func(*MiddlewareConfig), +) func(http.Handler) http.Handler { + c.SpecSchema().SetHTTPBasicSecurity(name, description) - return SecurityMiddleware(c, name, openapi3.SecurityScheme{ - HTTPSecurityScheme: &hss, - }, options...) + return AuthMiddleware(c, name, options...) } // HTTPBearerSecurityMiddleware creates middleware to expose HTTP Bearer security schema. @@ -87,21 +107,9 @@ func HTTPBearerSecurityMiddleware( name, description, bearerFormat string, options ...func(*MiddlewareConfig), ) func(http.Handler) http.Handler { - hss := openapi3.HTTPSecurityScheme{} - - hss.WithScheme("bearer") + c.SpecSchema().SetHTTPBearerTokenSecurity(name, bearerFormat, description) - if bearerFormat != "" { - hss.WithBearerFormat(bearerFormat) - } - - if description != "" { - hss.WithDescription(description) - } - - return SecurityMiddleware(c, name, openapi3.SecurityScheme{ - HTTPSecurityScheme: &hss, - }, options...) + return AuthMiddleware(c, name, options...) } // AnnotateOpenAPI applies OpenAPI annotation to relevant handlers. @@ -160,9 +168,12 @@ func OpenAPIAnnotationsMiddleware( var withRoute rest.HandlerWithRoute if HandlerAs(next, &withRoute) { + method := withRoute.RouteMethod() + pattern := withRoute.RoutePattern() + s.AnnotateOperation( - withRoute.RouteMethod(), - withRoute.RoutePattern(), + method, + pattern, annotations..., ) } diff --git a/nethttp/openapi_test.go b/nethttp/openapi_test.go index f009181..f514bf3 100644 --- a/nethttp/openapi_test.go +++ b/nethttp/openapi_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/swaggest/assertjson" - "github.com/swaggest/openapi-go/openapi3" + oapi "github.com/swaggest/openapi-go" "github.com/swaggest/rest/nethttp" "github.com/swaggest/rest/openapi" "github.com/swaggest/usecase" @@ -42,8 +42,8 @@ func TestOpenAPIMiddleware(t *testing.T) { nethttp.ResponseHeaderMapping(new(struct { Header int `header:"X-Hd"` })), - nethttp.AnnotateOperation(func(op *openapi3.Operation) error { - op.WithDescription("Hello!") + nethttp.AnnotateOpenAPIOperation(func(oc oapi.OperationContext) error { + oc.SetDescription("Hello!") return nil }), diff --git a/nethttp/options_test.go b/nethttp/options_test.go index 9a3e105..b9eea72 100644 --- a/nethttp/options_test.go +++ b/nethttp/options_test.go @@ -40,7 +40,7 @@ func TestRequestBodyContent(t *testing.T) { } func TestRequestBodyContent_webService(t *testing.T) { - s := web.DefaultService() + s := web.NewService(openapi3.NewReflector()) u := usecase.NewIOI(new(string), nil, func(ctx context.Context, input, output interface{}) error { return nil diff --git a/openapi/collector_test.go b/openapi/collector_test.go index 12675c0..2ea83f3 100644 --- a/openapi/collector_test.go +++ b/openapi/collector_test.go @@ -89,11 +89,11 @@ func TestCollector_Collect(t *testing.T) { ) u.SetTags("Tasks") - require.NoError(t, c.Collect(http.MethodPost, "/foo", u, rest.HandlerTrait{ + require.NoError(t, c.CollectUseCase(http.MethodPost, "/foo", u, rest.HandlerTrait{ ReqValidator: &jsonschema.Validator{}, })) - require.NoError(t, c.Collect(http.MethodGet, "/foo", nil, rest.HandlerTrait{ + require.NoError(t, c.CollectUseCase(http.MethodGet, "/foo", nil, rest.HandlerTrait{ ReqValidator: &jsonschema.Validator{}, })) @@ -144,8 +144,8 @@ func TestCollector_Collect_requestMapping(t *testing.T) { collector := openapi.Collector{} - require.NoError(t, collector.Collect(http.MethodPost, "/test/{in-path}", u, h)) - require.NoError(t, collector.Collect(http.MethodPut, "/test/{in-path}", u, h)) + require.NoError(t, collector.CollectUseCase(http.MethodPost, "/test/{in-path}", u, h)) + require.NoError(t, collector.CollectUseCase(http.MethodPut, "/test/{in-path}", u, h)) assertjson.EqMarshal(t, `{ "openapi":"3.0.3","info":{"title":"","version":""}, @@ -265,7 +265,7 @@ func TestCollector_Collect_CombineErrors(t *testing.T) { collector := openapi.Collector{} collector.CombineErrors = "oneOf" - require.NoError(t, collector.Collect(http.MethodPost, "/test", u, h)) + require.NoError(t, collector.CollectUseCase(http.MethodPost, "/test", u, h)) assertjson.EqMarshal(t, `{ "openapi":"3.0.3","info":{"title":"","version":""}, @@ -345,7 +345,7 @@ func TestCollector_Collect_multipleHttpStatuses(t *testing.T) { u.Input = new(struct{}) u.Output = new(outputWithHTTPStatuses) - require.NoError(t, c.Collect(http.MethodPost, "/foo", u, rest.HandlerTrait{ + require.NoError(t, c.CollectUseCase(http.MethodPost, "/foo", u, rest.HandlerTrait{ ReqValidator: &jsonschema.Validator{}, })) @@ -420,7 +420,7 @@ func TestCollector_Collect_queryObject(t *testing.T) { u.Input = new(inputQueryObject) - require.NoError(t, c.Collect(http.MethodGet, "/foo", u, rest.HandlerTrait{ + require.NoError(t, c.CollectUseCase(http.MethodGet, "/foo", u, rest.HandlerTrait{ ReqValidator: &jsonschema.Validator{}, })) @@ -479,11 +479,11 @@ func TestCollector_Collect_head_no_response(t *testing.T) { u.Output = new(resp) - require.NoError(t, c.Collect(http.MethodHead, "/foo", u, rest.HandlerTrait{ + require.NoError(t, c.CollectUseCase(http.MethodHead, "/foo", u, rest.HandlerTrait{ ReqValidator: &jsonschema.Validator{}, })) - require.NoError(t, c.Collect(http.MethodGet, "/foo", u, rest.HandlerTrait{ + require.NoError(t, c.CollectUseCase(http.MethodGet, "/foo", u, rest.HandlerTrait{ ReqValidator: &jsonschema.Validator{}, })) diff --git a/request/factory.go b/request/factory.go index 34b993d..c859424 100644 --- a/request/factory.go +++ b/request/factory.go @@ -92,7 +92,7 @@ func (df *DecoderFactory) SetDecoderFunc(tagName rest.ParamIn, d func(r *http.Re // MakeDecoder creates request.RequestDecoder for a http method and request structure. // // Input is checked for `json`, `file` tags only for methods with body semantics (POST, PUT, PATCH) or -// if input implements openapi3.RequestBodyEnforcer. +// if input implements openapi.RequestBodyEnforcer. // // CustomMapping can be nil, otherwise it is used instead of field tags to match decoded fields with struct. func (df *DecoderFactory) MakeDecoder( diff --git a/request/file_test.go b/request/file_test.go index 5bf68bb..315aed3 100644 --- a/request/file_test.go +++ b/request/file_test.go @@ -41,7 +41,7 @@ func TestDecoder_Decode_fileUploadOptional(t *testing.T) { return nil }) - s := web.DefaultService() + s := web.NewService(openapi3.NewReflector()) s.Post("/", u) b := bytes.NewBuffer(nil) diff --git a/web/example_test.go b/web/example_test.go index b102801..4f5f2d9 100644 --- a/web/example_test.go +++ b/web/example_test.go @@ -6,6 +6,7 @@ import ( "net/http" "github.com/go-chi/chi/v5/middleware" + "github.com/swaggest/openapi-go/openapi3" "github.com/swaggest/rest/nethttp" "github.com/swaggest/rest/web" "github.com/swaggest/usecase" @@ -33,12 +34,12 @@ func postAlbums() usecase.Interactor { func ExampleDefaultService() { // Service initializes router with required middlewares. - service := web.DefaultService() + service := web.NewService(openapi3.NewReflector()) // It allows OpenAPI configuration. - service.OpenAPI.Info.Title = "Albums API" - service.OpenAPI.Info.WithDescription("This service provides API to manage albums.") - service.OpenAPI.Info.Version = "v1.0.0" + service.OpenAPISchema().SetTitle("Albums API") + service.OpenAPISchema().SetDescription("This service provides API to manage albums.") + service.OpenAPISchema().SetVersion("v1.0.0") // Additional middlewares can be added. service.Use( diff --git a/web/service.go b/web/service.go index c6f34b6..d9d2930 100644 --- a/web/service.go +++ b/web/service.go @@ -19,26 +19,17 @@ import ( "github.com/swaggest/usecase" ) -// DefaultService initializes router and other basic components of web service. -// -// Provided functional options are invoked twice, before and after initialization. -func DefaultService(options ...func(s *Service, initialized bool)) *Service { +// NewService initializes router and other basic components of web service. +func NewService(refl oapi.Reflector, options ...func(s *Service)) *Service { s := Service{} for _, option := range options { - option(&s, false) - } - - if s.OpenAPI == nil { - s.OpenAPI = &openapi3.Spec{Openapi: "3.0.3"} + option(&s) } // Init API documentation schema. if s.OpenAPICollector == nil { - r := openapi3.NewReflector() - r.Spec = s.OpenAPI - - c := openapi.NewCollector(r) + c := openapi.NewCollector(refl) c.DefaultSuccessResponseContentType = response.DefaultSuccessResponseContentType c.DefaultErrorResponseContentType = response.DefaultErrorResponseContentType @@ -74,11 +65,30 @@ func DefaultService(options ...func(s *Service, initialized bool)) *Service { response.EncoderMiddleware, // Response encoder setup. ) - for _, option := range options { - option(&s, true) + return &s +} + +// DefaultService initializes router and other basic components of web service. +// +// Provided functional options are invoked twice, before and after initialization. +// +// Deprecated: use NewService. +func DefaultService(options ...func(s *Service, initialized bool)) *Service { + s := NewService(openapi3.NewReflector(), func(s *Service) { + for _, o := range options { + o(s, false) + } + }) + + if r3, ok := s.OpenAPIReflector().(*openapi3.Reflector); ok && s.OpenAPI == nil { + s.OpenAPI = r3.Spec } - return &s + for _, o := range options { + o(s, true) + } + + return s } // Service keeps instrumented router and documentation collector. @@ -87,7 +97,7 @@ type Service struct { PanicRecoveryMiddleware func(handler http.Handler) http.Handler // Default is middleware.Recoverer. - // Deprecated: use openapi.Collector. + // Deprecated: use OpenAPISchema(). OpenAPI *openapi3.Spec OpenAPICollector *openapi.Collector @@ -101,7 +111,7 @@ type Service struct { // OpenAPISchema returns OpenAPI schema. // -// Returned value can be type asserted to *openapi3.Spec or marshaled. +// Returned value can be type asserted to *openapi3.Spec, *openapi31.Spec or marshaled. func (s *Service) OpenAPISchema() oapi.SpecSchema { return s.OpenAPICollector.SpecSchema() } @@ -169,5 +179,5 @@ func (s *Service) Trace(pattern string, uc usecase.Interactor, options ...func(h func (s *Service) Docs(pattern string, swgui func(title, schemaURL, basePath string) http.Handler) { pattern = strings.TrimRight(pattern, "/") s.Method(http.MethodGet, pattern+"/openapi.json", s.OpenAPICollector) - s.Mount(pattern, swgui(s.OpenAPI.Info.Title, pattern+"/openapi.json", pattern)) + s.Mount(pattern, swgui(s.OpenAPISchema().Title(), pattern+"/openapi.json", pattern)) } diff --git a/web/service_test.go b/web/service_test.go index d8ad725..6b57a9c 100644 --- a/web/service_test.go +++ b/web/service_test.go @@ -2,7 +2,6 @@ package web_test import ( "context" - "fmt" "io/ioutil" "net/http" "net/http/httptest" @@ -11,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "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" @@ -33,18 +33,15 @@ func albumByID() usecase.Interactor { func TestDefaultService(t *testing.T) { var l []string - service := web.DefaultService( - func(s *web.Service, initialized bool) { - l = append(l, fmt.Sprintf("one:%v", initialized)) - }, - func(s *web.Service, initialized bool) { - l = append(l, fmt.Sprintf("two:%v", initialized)) - }, + service := web.NewService( + openapi3.NewReflector(), + func(s *web.Service) { l = append(l, "one") }, + func(s *web.Service) { l = append(l, "two") }, ) - service.OpenAPI.Info.Title = "Albums API" - service.OpenAPI.Info.WithDescription("This service provides API to manage albums.") - service.OpenAPI.Info.Version = "v1.0.0" + service.OpenAPISchema().SetTitle("Albums API") + service.OpenAPISchema().SetDescription("This service provides API to manage albums.") + service.OpenAPISchema().SetVersion("v1.0.0") service.Delete("/albums/{id}", albumByID()) service.Head("/albums/{id}", albumByID()) @@ -65,11 +62,11 @@ func TestDefaultService(t *testing.T) { service.ServeHTTP(rw, r) assert.Equal(t, http.StatusOK, rw.Code) - assertjson.EqualMarshal(t, rw.Body.Bytes(), service.OpenAPI) + assertjson.EqualMarshal(t, rw.Body.Bytes(), service.OpenAPISchema()) expected, err := ioutil.ReadFile("_testdata/openapi.json") require.NoError(t, err) - assertjson.EqualMarshal(t, expected, service.OpenAPI) + assertjson.EqualMarshal(t, expected, service.OpenAPISchema()) - assert.Equal(t, []string{"one:false", "two:false", "one:true", "two:true"}, l) + assert.Equal(t, []string{"one", "two"}, l) }