Repository: swaggest/rest
Pull request #69 (2022-04-10 22:11:20)

Custom error response


Patch:
diff --git a/_examples/advanced-generic/error_response.go b/_examples/advanced-generic/error_response.go
new file mode 100644
index 0000000..e6cd576
--- /dev/null
+++ b/_examples/advanced-generic/error_response.go
@@ -0,0 +1,47 @@
+//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, status.AlreadyExists)
+
+	return u
+}
diff --git a/_examples/advanced-generic/file_multi_upload.go b/_examples/advanced-generic/file_multi_upload.go
index 7cb3a4f..665240f 100644
--- a/_examples/advanced-generic/file_multi_upload.go
+++ b/_examples/advanced-generic/file_multi_upload.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/file_upload.go b/_examples/advanced-generic/file_upload.go
index 3ac85d5..1cbd309 100644
--- a/_examples/advanced-generic/file_upload.go
+++ b/_examples/advanced-generic/file_upload.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/gzip_pass_through.go b/_examples/advanced-generic/gzip_pass_through.go
index aa68bd6..b9af581 100644
--- a/_examples/advanced-generic/gzip_pass_through.go
+++ b/_examples/advanced-generic/gzip_pass_through.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/json_body.go b/_examples/advanced-generic/json_body.go
index 5d9df93..a665181 100644
--- a/_examples/advanced-generic/json_body.go
+++ b/_examples/advanced-generic/json_body.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/json_body_validation.go b/_examples/advanced-generic/json_body_validation.go
index fc38c91..f86e3b4 100644
--- a/_examples/advanced-generic/json_body_validation.go
+++ b/_examples/advanced-generic/json_body_validation.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/json_map_body.go b/_examples/advanced-generic/json_map_body.go
index 2113234..34f37b3 100644
--- a/_examples/advanced-generic/json_map_body.go
+++ b/_examples/advanced-generic/json_map_body.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/json_param.go b/_examples/advanced-generic/json_param.go
index 00c8b9f..4a8b9c5 100644
--- a/_examples/advanced-generic/json_param.go
+++ b/_examples/advanced-generic/json_param.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/json_slice_body.go b/_examples/advanced-generic/json_slice_body.go
index 443d678..579f2d7 100644
--- a/_examples/advanced-generic/json_slice_body.go
+++ b/_examples/advanced-generic/json_slice_body.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/main.go b/_examples/advanced-generic/main.go
index 273b5bb..4677001 100644
--- a/_examples/advanced-generic/main.go
+++ b/_examples/advanced-generic/main.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/no_validation.go b/_examples/advanced-generic/no_validation.go
index 483e49d..846390d 100644
--- a/_examples/advanced-generic/no_validation.go
+++ b/_examples/advanced-generic/no_validation.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/output_headers.go b/_examples/advanced-generic/output_headers.go
index b259cba..592a2ef 100644
--- a/_examples/advanced-generic/output_headers.go
+++ b/_examples/advanced-generic/output_headers.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/output_writer.go b/_examples/advanced-generic/output_writer.go
index 911085f..4fbc981 100644
--- a/_examples/advanced-generic/output_writer.go
+++ b/_examples/advanced-generic/output_writer.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/query_object.go b/_examples/advanced-generic/query_object.go
index a588bf3..5ea264a 100644
--- a/_examples/advanced-generic/query_object.go
+++ b/_examples/advanced-generic/query_object.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/request_response_mapping.go b/_examples/advanced-generic/request_response_mapping.go
index 97adee2..4cab62a 100644
--- a/_examples/advanced-generic/request_response_mapping.go
+++ b/_examples/advanced-generic/request_response_mapping.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/router.go b/_examples/advanced-generic/router.go
index 379e3fe..20b311d 100644
--- a/_examples/advanced-generic/router.go
+++ b/_examples/advanced-generic/router.go
@@ -1,59 +1,60 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
 import (
+	"context"
 	"net/http"
 	"reflect"
 	"strings"
 
-	"github.com/go-chi/chi/v5"
-	"github.com/go-chi/chi/v5/middleware"
 	"github.com/swaggest/openapi-go/openapi3"
 	"github.com/swaggest/rest"
-	"github.com/swaggest/rest/chirouter"
-	"github.com/swaggest/rest/jsonschema"
 	"github.com/swaggest/rest/nethttp"
-	"github.com/swaggest/rest/openapi"
-	"github.com/swaggest/rest/request"
 	"github.com/swaggest/rest/response"
 	"github.com/swaggest/rest/response/gzip"
-	v4 "github.com/swaggest/swgui/v4"
+	"github.com/swaggest/rest/web"
+	swgui "github.com/swaggest/swgui/v4emb"
 )
 
 func NewRouter() http.Handler {
-	apiSchema := &openapi.Collector{}
-	validatorFactory := jsonschema.NewFactory(apiSchema, apiSchema)
-	decoderFactory := request.NewDecoderFactory()
-	decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues)
-
-	apiSchema.Reflector().SpecEns().Info.Title = "Advanced Example"
-	apiSchema.Reflector().SpecEns().Info.WithDescription("This app showcases a variety of features.")
-	apiSchema.Reflector().SpecEns().Info.Version = "v1.2.3"
-	apiSchema.Reflector().InterceptDefName(func(t reflect.Type, defaultDefName string) string {
+	s := web.DefaultService()
+
+	s.OpenAPI.Info.Title = "Advanced Example"
+	s.OpenAPI.Info.WithDescription("This app showcases a variety of features.")
+	s.OpenAPI.Info.Version = "v1.2.3"
+	s.OpenAPICollector.Reflector().InterceptDefName(func(t reflect.Type, defaultDefName string) string {
 		return strings.ReplaceAll(defaultDefName, "Generic", "")
 	})
 
-	r := chirouter.NewWrapper(chi.NewRouter())
-
-	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.
-
+	s.Use(
 		// 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(validatorFactory),
+		response.ValidatorMiddleware(s.ResponseValidatorFactory),
 		gzip.Middleware, // Response compression with support for direct gzip pass through.
+
+		// Example middleware to setup custom error responses.
+		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)
+
+					return code, customErr{
+						Message: er.ErrorText,
+						Details: er.Context,
+					}
+				}
+			}
+
+			return handler
+		},
 	)
 
 	// Annotations can be used to alter documentation of operation identified by method and path.
-	apiSchema.Annotate(http.MethodPost, "/validation", func(op *openapi3.Operation) error {
+	s.OpenAPICollector.Annotate(http.MethodPost, "/validation", func(op *openapi3.Operation) error {
 		if op.Description != nil {
 			*op.Description = *op.Description + " Custom annotation."
 		}
@@ -61,30 +62,30 @@ func NewRouter() http.Handler {
 		return nil
 	})
 
-	r.Method(http.MethodGet, "/query-object", nethttp.NewHandler(queryObject()))
+	s.Get("/query-object", queryObject())
 
-	r.Method(http.MethodPost, "/file-upload", nethttp.NewHandler(fileUploader()))
-	r.Method(http.MethodPost, "/file-multi-upload", nethttp.NewHandler(fileMultiUploader()))
-	r.Method(http.MethodGet, "/json-param/{in-path}", nethttp.NewHandler(jsonParam()))
-	r.Method(http.MethodPost, "/json-body/{in-path}", nethttp.NewHandler(jsonBody(),
-		nethttp.SuccessStatus(http.StatusCreated)))
-	r.Method(http.MethodPost, "/json-body-validation/{in-path}", nethttp.NewHandler(jsonBodyValidation()))
-	r.Method(http.MethodPost, "/json-slice-body", nethttp.NewHandler(jsonSliceBody()))
+	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-validation/{in-path}", jsonBodyValidation())
+	s.Post("/json-slice-body", jsonSliceBody())
 
-	r.Method(http.MethodPost, "/json-map-body", nethttp.NewHandler(jsonMapBody(),
-		// Annotate operation to add post processing if necessary.
+	s.Post("/json-map-body", jsonMapBody(),
+		// Annotate operation to add post-processing if necessary.
 		nethttp.AnnotateOperation(func(op *openapi3.Operation) error {
 			op.WithDescription("Request with JSON object (map) body.")
 
 			return nil
-		})))
+		}))
 
-	r.Method(http.MethodGet, "/output-headers", nethttp.NewHandler(outputHeaders()))
-	r.Method(http.MethodHead, "/output-headers", nethttp.NewHandler(outputHeaders()))
-	r.Method(http.MethodGet, "/output-csv-writer", nethttp.NewHandler(outputCSVWriter(),
-		nethttp.SuccessfulResponseContentType("text/csv; charset=utf-8")))
+	s.Get("/output-headers", outputHeaders())
+	s.Head("/output-headers", outputHeaders())
+	s.Get("/output-csv-writer", outputCSVWriter(),
+		nethttp.SuccessfulResponseContentType("text/csv; charset=utf-8"))
 
-	r.Method(http.MethodPost, "/req-resp-mapping", nethttp.NewHandler(reqRespMapping(),
+	s.Post("/req-resp-mapping", reqRespMapping(),
 		nethttp.RequestMapping(new(struct {
 			Val1 string `header:"X-Header"`
 			Val2 int    `formData:"val2"`
@@ -93,20 +94,20 @@ func NewRouter() http.Handler {
 			Val1 string `header:"X-Value-1"`
 			Val2 int    `header:"X-Value-2"`
 		})),
-	))
+	)
 
-	r.Method(http.MethodPost, "/validation", nethttp.NewHandler(validation()))
-	r.Method(http.MethodPost, "/no-validation", nethttp.NewHandler(validation()))
+	s.Post("/validation", validation())
+	s.Post("/no-validation", noValidation())
 
 	// Type mapping is necessary to pass interface as structure into documentation.
-	apiSchema.Reflector().AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct))
-	r.Method(http.MethodGet, "/gzip-pass-through", nethttp.NewHandler(directGzip()))
-	r.Method(http.MethodHead, "/gzip-pass-through", nethttp.NewHandler(directGzip()))
+	s.OpenAPICollector.Reflector().AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct))
+	s.Get("/gzip-pass-through", directGzip())
+	s.Head("/gzip-pass-through", directGzip())
+
+	s.Get("/error-response", errorResponse())
 
 	// Swagger UI endpoint at /docs.
-	r.Method(http.MethodGet, "/docs/openapi.json", apiSchema)
-	r.Mount("/docs", v4.NewHandler(apiSchema.Reflector().Spec.Info.Title,
-		"/docs/openapi.json", "/docs"))
+	s.Docs("/docs", swgui.New)
 
-	return r
+	return s
 }
diff --git a/_examples/advanced-generic/validation.go b/_examples/advanced-generic/validation.go
index 249ab5e..065571b 100644
--- a/_examples/advanced-generic/validation.go
+++ b/_examples/advanced-generic/validation.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced/error_response.go b/_examples/advanced/error_response.go
new file mode 100644
index 0000000..8f5fff5
--- /dev/null
+++ b/_examples/advanced/error_response.go
@@ -0,0 +1,50 @@
+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.NewIOI(new(errType), new(okResp), func(ctx context.Context, input, output interface{}) (err error) {
+		var (
+			in  = input.(*errType)
+			out = output.(*okResp)
+		)
+
+		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, status.AlreadyExists)
+
+	return u
+}
diff --git a/_examples/advanced/router.go b/_examples/advanced/router.go
index e28500c..75b5170 100644
--- a/_examples/advanced/router.go
+++ b/_examples/advanced/router.go
@@ -1,51 +1,53 @@
 package main
 
 import (
+	"context"
 	"net/http"
 
-	"github.com/go-chi/chi/v5"
-	"github.com/go-chi/chi/v5/middleware"
 	"github.com/swaggest/openapi-go/openapi3"
 	"github.com/swaggest/rest"
-	"github.com/swaggest/rest/chirouter"
-	"github.com/swaggest/rest/jsonschema"
 	"github.com/swaggest/rest/nethttp"
-	"github.com/swaggest/rest/openapi"
-	"github.com/swaggest/rest/request"
 	"github.com/swaggest/rest/response"
 	"github.com/swaggest/rest/response/gzip"
-	v4 "github.com/swaggest/swgui/v4"
+	"github.com/swaggest/rest/web"
+	swgui "github.com/swaggest/swgui/v4emb"
 )
 
 func NewRouter() http.Handler {
-	apiSchema := &openapi.Collector{}
-	validatorFactory := jsonschema.NewFactory(apiSchema, apiSchema)
-	decoderFactory := request.NewDecoderFactory()
-	decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues)
+	s := web.DefaultService()
 
-	apiSchema.Reflector().SpecEns().Info.Title = "Advanced Example"
-	apiSchema.Reflector().SpecEns().Info.WithDescription("This app showcases a variety of features.")
-	apiSchema.Reflector().SpecEns().Info.Version = "v1.2.3"
-
-	r := chirouter.NewWrapper(chi.NewRouter())
-
-	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.
+	s.OpenAPI.Info.Title = "Advanced Example"
+	s.OpenAPI.Info.WithDescription("This app showcases a variety of features.")
+	s.OpenAPI.Info.Version = "v1.2.3"
 
+	s.Use(
 		// 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(validatorFactory),
+		response.ValidatorMiddleware(s.ResponseValidatorFactory),
 		gzip.Middleware, // Response compression with support for direct gzip pass through.
+
+		// Example middleware to setup custom error responses.
+		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)
+
+					return code, customErr{
+						Message: er.ErrorText,
+						Details: er.Context,
+					}
+				}
+			}
+
+			return handler
+		},
 	)
 
 	// Annotations can be used to alter documentation of operation identified by method and path.
-	apiSchema.Annotate(http.MethodPost, "/validation", func(op *openapi3.Operation) error {
+	s.OpenAPICollector.Annotate(http.MethodPost, "/validation", func(op *openapi3.Operation) error {
 		if op.Description != nil {
 			*op.Description = *op.Description + " Custom annotation."
 		}
@@ -53,30 +55,30 @@ func NewRouter() http.Handler {
 		return nil
 	})
 
-	r.Method(http.MethodGet, "/query-object", nethttp.NewHandler(queryObject()))
+	s.Get("/query-object", queryObject())
 
-	r.Method(http.MethodPost, "/file-upload", nethttp.NewHandler(fileUploader()))
-	r.Method(http.MethodPost, "/file-multi-upload", nethttp.NewHandler(fileMultiUploader()))
-	r.Method(http.MethodGet, "/json-param/{in-path}", nethttp.NewHandler(jsonParam()))
-	r.Method(http.MethodPost, "/json-body/{in-path}", nethttp.NewHandler(jsonBody(),
-		nethttp.SuccessStatus(http.StatusCreated)))
-	r.Method(http.MethodPost, "/json-body-validation/{in-path}", nethttp.NewHandler(jsonBodyValidation()))
-	r.Method(http.MethodPost, "/json-slice-body", nethttp.NewHandler(jsonSliceBody()))
+	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-validation/{in-path}", jsonBodyValidation())
+	s.Post("/json-slice-body", jsonSliceBody())
 
-	r.Method(http.MethodPost, "/json-map-body", nethttp.NewHandler(jsonMapBody(),
-		// Annotate operation to add post processing if necessary.
+	s.Post("/json-map-body", jsonMapBody(),
+		// Annotate operation to add post-processing if necessary.
 		nethttp.AnnotateOperation(func(op *openapi3.Operation) error {
 			op.WithDescription("Request with JSON object (map) body.")
 
 			return nil
-		})))
+		}))
 
-	r.Method(http.MethodGet, "/output-headers", nethttp.NewHandler(outputHeaders()))
-	r.Method(http.MethodHead, "/output-headers", nethttp.NewHandler(outputHeaders()))
-	r.Method(http.MethodGet, "/output-csv-writer", nethttp.NewHandler(outputCSVWriter(),
-		nethttp.SuccessfulResponseContentType("text/csv; charset=utf-8")))
+	s.Get("/output-headers", outputHeaders())
+	s.Head("/output-headers", outputHeaders())
+	s.Get("/output-csv-writer", outputCSVWriter(),
+		nethttp.SuccessfulResponseContentType("text/csv; charset=utf-8"))
 
-	r.Method(http.MethodPost, "/req-resp-mapping", nethttp.NewHandler(reqRespMapping(),
+	s.Post("/req-resp-mapping", reqRespMapping(),
 		nethttp.RequestMapping(new(struct {
 			Val1 string `header:"X-Header"`
 			Val2 int    `formData:"val2"`
@@ -85,20 +87,20 @@ func NewRouter() http.Handler {
 			Val1 string `header:"X-Value-1"`
 			Val2 int    `header:"X-Value-2"`
 		})),
-	))
+	)
 
-	r.Method(http.MethodPost, "/validation", nethttp.NewHandler(validation()))
-	r.Method(http.MethodPost, "/no-validation", nethttp.NewHandler(noValidation()))
+	s.Post("/validation", validation())
+	s.Post("/no-validation", noValidation())
 
 	// Type mapping is necessary to pass interface as structure into documentation.
-	apiSchema.Reflector().AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct))
-	r.Method(http.MethodGet, "/gzip-pass-through", nethttp.NewHandler(directGzip()))
-	r.Method(http.MethodHead, "/gzip-pass-through", nethttp.NewHandler(directGzip()))
+	s.OpenAPICollector.Reflector().AddTypeMapping(new(gzipPassThroughOutput), new(gzipPassThroughStruct))
+	s.Get("/gzip-pass-through", directGzip())
+	s.Head("/gzip-pass-through", directGzip())
+
+	s.Get("/error-response", errorResponse())
 
 	// Swagger UI endpoint at /docs.
-	r.Method(http.MethodGet, "/docs/openapi.json", apiSchema)
-	r.Mount("/docs", v4.NewHandler(apiSchema.Reflector().Spec.Info.Title,
-		"/docs/openapi.json", "/docs"))
+	s.Docs("/docs", swgui.New)
 
-	return r
+	return s
 }
diff --git a/_examples/basic/main.go b/_examples/basic/main.go
index 63eb04c..240fa7f 100644
--- a/_examples/basic/main.go
+++ b/_examples/basic/main.go
@@ -10,7 +10,7 @@ import (
 
 	"github.com/swaggest/rest/response/gzip"
 	"github.com/swaggest/rest/web"
-	swgui "github.com/swaggest/swgui/v4"
+	swgui "github.com/swaggest/swgui/v4emb"
 	"github.com/swaggest/usecase"
 	"github.com/swaggest/usecase/status"
 )
diff --git a/_examples/generic/main.go b/_examples/generic/main.go
index 2e6a535..0b3e9b2 100644
--- a/_examples/generic/main.go
+++ b/_examples/generic/main.go
@@ -13,7 +13,7 @@ import (
 
 	"github.com/swaggest/rest/response/gzip"
 	"github.com/swaggest/rest/web"
-	swgui "github.com/swaggest/swgui/v4"
+	swgui "github.com/swaggest/swgui/v4emb"
 	"github.com/swaggest/usecase"
 	"github.com/swaggest/usecase/status"
 )
diff --git a/_examples/go.mod b/_examples/go.mod
index 2604591..48c31d2 100644
--- a/_examples/go.mod
+++ b/_examples/go.mod
@@ -5,6 +5,7 @@ go 1.18
 replace github.com/swaggest/rest => ../
 
 require (
+	github.com/bool64/ctxd v1.1.2
 	github.com/bool64/dev v0.2.10
 	github.com/bool64/httpmock v0.1.1
 	github.com/bool64/httptestbench v0.1.3
@@ -14,7 +15,7 @@ require (
 	github.com/swaggest/assertjson v1.6.8
 	github.com/swaggest/jsonschema-go v0.3.30
 	github.com/swaggest/openapi-go v0.2.15
-	github.com/swaggest/swgui v1.4.4
+	github.com/swaggest/swgui v1.4.5
 	github.com/swaggest/usecase v1.1.2
 	github.com/valyala/fasthttp v1.33.0
 )
@@ -31,15 +32,13 @@ require (
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/santhosh-tekuri/jsonschema/v3 v3.1.0 // indirect
 	github.com/sergi/go-diff v1.2.0 // indirect
-	github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0 // indirect
 	github.com/swaggest/form/v5 v5.0.1 // indirect
 	github.com/swaggest/refl v1.0.1 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
+	github.com/vearutop/statigz v1.1.5 // indirect
 	github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff // indirect
 	github.com/yudai/gojsondiff v1.0.0 // indirect
 	github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
-	golang.org/x/net v0.0.0-20220111093109-d55c255bac03 // indirect
-	golang.org/x/text v0.3.7 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
 )
diff --git a/_examples/go.sum b/_examples/go.sum
index 531db44..61943fc 100644
--- a/_examples/go.sum
+++ b/_examples/go.sum
@@ -1,5 +1,8 @@
+github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
 github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
 github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
+github.com/bool64/ctxd v1.1.2 h1:6J05q1vTG0op2O0kP+LosCd9wJ0qJybcvdE+bPjUJZE=
+github.com/bool64/ctxd v1.1.2/go.mod h1:5rwLykeZBJHK27q5sSfhfKig6076270V+3iiXbP+HFY=
 github.com/bool64/dev v0.1.25/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
 github.com/bool64/dev v0.1.35/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
 github.com/bool64/dev v0.1.41/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU=
@@ -71,8 +74,6 @@ github.com/santhosh-tekuri/jsonschema/v3 v3.1.0/go.mod h1:8kzK2TC0k0YjOForaAHdNE
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
 github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
 github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0 h1:mj/nMDAwTBiaCqMEs4cYCqF7pO6Np7vhy1D1wcQGz+E=
-github.com/shurcooL/httpgzip v0.0.0-20190720172056-320755c1c1b0/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
 github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
@@ -87,8 +88,8 @@ github.com/swaggest/openapi-go v0.2.15 h1:Cp3c0IacJlDEvYYCBzVm3M51bINyI2XCH5RAwu
 github.com/swaggest/openapi-go v0.2.15/go.mod h1:3Xd0go1dJkxlSlBHpVV3h76TccououBojV98hhXn6eQ=
 github.com/swaggest/refl v1.0.1 h1:YQHb7Ic6EMpdUpxQmTWmf/O4IWN6iIErxJNWA7LwyyM=
 github.com/swaggest/refl v1.0.1/go.mod h1:dnx+n9YaI0o+FH+OR2tJZWLABBVIPs9qc4VY9UdrhLE=
-github.com/swaggest/swgui v1.4.4 h1:dpL947QgBHxEuyijgMkXXX+3O7bXZzI2E9FTDT1TJgk=
-github.com/swaggest/swgui v1.4.4/go.mod h1:ttADCRUE5Qed8yVI20naCXNDnWA206+vm/490GsLVCs=
+github.com/swaggest/swgui v1.4.5 h1:9kw3Lt+4qNdDwnRgpNSsx3s3BjoW/Inhbn7/38DX6Qk=
+github.com/swaggest/swgui v1.4.5/go.mod h1:qfAwdL49ikzxahZrLrPJYAruNuL1Nt6do8jtC9luAlI=
 github.com/swaggest/usecase v1.1.2 h1:2LfuSyjYtPtpHnxqPwV87/eunbhGBC5HKdRp8/fINBk=
 github.com/swaggest/usecase v1.1.2/go.mod h1:abZWuMFYujaeLDODqRySJZpWD/ugsnE3Wj9K6jUeCjo=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
@@ -96,6 +97,8 @@ github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyC
 github.com/valyala/fasthttp v1.33.0 h1:mHBKd98J5NcXuBddgjvim1i3kWzlng1SzLhrnBOU9g8=
 github.com/valyala/fasthttp v1.33.0/go.mod h1:KJRK/MXx0J+yd0c5hlR+s1tIHD72sniU8ZJjl97LIw4=
 github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/vearutop/statigz v1.1.5 h1:qWvRgXFsseWVTFCkIvwHQPpaLNf9WI0+dDJE7I9432o=
+github.com/vearutop/statigz v1.1.5/go.mod h1:czAv7iXgPv/s+xsgXpVEhhD0NSOQ4wZPgmM/n7LANDI=
 github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff h1:7YqG491bE4vstXRz1lD38rbSgbXnirvROz1lZiOnPO8=
 github.com/yosuke-furukawa/json5 v0.1.2-0.20201207051438-cf7bb3f354ff/go.mod h1:sw49aWDqNdRJ6DYUtIQiaA3xyj2IL9tjeNYmX2ixwcU=
 github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
@@ -147,7 +150,6 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/_examples/task-api/internal/infra/nethttp/router.go b/_examples/task-api/internal/infra/nethttp/router.go
index 59db387..0dfd437 100644
--- a/_examples/task-api/internal/infra/nethttp/router.go
+++ b/_examples/task-api/internal/infra/nethttp/router.go
@@ -17,7 +17,7 @@ import (
 	"github.com/swaggest/rest/nethttp"
 	"github.com/swaggest/rest/request"
 	"github.com/swaggest/rest/response"
-	"github.com/swaggest/swgui/v3cdn"
+	swgui "github.com/swaggest/swgui/v4emb"
 )
 
 // NewRouter creates HTTP router.
@@ -91,7 +91,7 @@ func NewRouter(locator *service.Locator) http.Handler {
 
 	// Swagger UI endpoint at /docs.
 	r.Method(http.MethodGet, "/docs/openapi.json", apiSchema)
-	r.Mount("/docs", v3cdn.NewHandler(apiSchema.Reflector().Spec.Info.Title,
+	r.Mount("/docs", swgui.NewHandler(apiSchema.Reflector().Spec.Info.Title,
 		"/docs/openapi.json", "/docs"))
 
 	r.Mount("/debug", middleware.Profiler())
diff --git a/trait.go b/trait.go
index b5fb9c6..1c04671 100644
--- a/trait.go
+++ b/trait.go
@@ -20,7 +20,8 @@ type HandlerTrait struct {
 	// SuccessContentType is a Content-Type of successful response, default application/json.
 	SuccessContentType string
 
-	// MakeErrResp overrides error response builder instead of default Err.
+	// MakeErrResp overrides error response builder instead of default Err,
+	// returned values are HTTP status code and error structure to be marshaled.
 	MakeErrResp func(ctx context.Context, err error) (int, interface{})
 
 	// ReqMapping controls request decoding into use case input.
diff --git a/web/service.go b/web/service.go
index 550dc65..36316d2 100644
--- a/web/service.go
+++ b/web/service.go
@@ -41,10 +41,11 @@ func DefaultService() *Service {
 	)
 
 	s := Service{
-		OpenAPI:          apiSchema.Reflector().SpecEns(),
-		OpenAPICollector: apiSchema,
-		Wrapper:          router,
-		DecoderFactory:   decoderFactory,
+		OpenAPI:                  apiSchema.Reflector().SpecEns(),
+		OpenAPICollector:         apiSchema,
+		Wrapper:                  router,
+		DecoderFactory:           decoderFactory,
+		ResponseValidatorFactory: validatorFactory,
 	}
 
 	return &s
@@ -54,9 +55,10 @@ func DefaultService() *Service {
 type Service struct {
 	*chirouter.Wrapper
 
-	OpenAPI          *openapi3.Spec
-	OpenAPICollector *openapi.Collector
-	DecoderFactory   *request.DecoderFactory
+	OpenAPI                  *openapi3.Spec
+	OpenAPICollector         *openapi.Collector
+	DecoderFactory           *request.DecoderFactory
+	ResponseValidatorFactory rest.ResponseValidatorFactory
 }
 
 // Delete adds the route `pattern` that matches a DELETE http method to invoke use case interactor.
@@ -71,7 +73,7 @@ func (s *Service) Get(pattern string, uc usecase.Interactor, options ...func(h *
 
 // Head adds the route `pattern` that matches a HEAD http method to invoke use case interactor.
 func (s *Service) Head(pattern string, uc usecase.Interactor, options ...func(h *nethttp.Handler)) {
-	s.Method(http.MethodGet, pattern, nethttp.NewHandler(uc, options...))
+	s.Method(http.MethodHead, pattern, nethttp.NewHandler(uc, options...))
 }
 
 // Options adds the route `pattern` that matches a OPTIONS http method to invoke use case interactor.


Test patch:
diff --git a/.github/workflows/test-examples.yml b/.github/workflows/test-examples.yml
index b5a8a49..358958e 100644
--- a/.github/workflows/test-examples.yml
+++ b/.github/workflows/test-examples.yml
@@ -11,7 +11,7 @@ jobs:
   test:
     strategy:
       matrix:
-        go-version: [ 1.17.x, tip ]
+        go-version: [ 1.17.x, 1.18.x ]
     runs-on: ubuntu-latest
     steps:
       - name: Install Go stable
diff --git a/_examples/advanced-generic/_testdata/openapi.json b/_examples/advanced-generic/_testdata/openapi.json
index 3540386..c8f795e 100644
--- a/_examples/advanced-generic/_testdata/openapi.json
+++ b/_examples/advanced-generic/_testdata/openapi.json
@@ -2,6 +2,32 @@
   "openapi":"3.0.3",
   "info":{"title":"Advanced Example","description":"This app showcases a variety of features.","version":"v1.2.3"},
   "paths":{
+    "/error-response":{
+      "get":{
+        "summary":"Declare Expected Errors",
+        "description":"This use case demonstrates documentation of expected errors.",
+        "operationId":"_examples/advanced-generic.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":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+          },
+          "409":{
+            "description":"Conflict",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+          }
+        }
+      }
+    },
     "/file-multi-upload":{
       "post":{
         "summary":"Files Uploads With 'multipart/form-data'","description":"",
@@ -212,34 +238,21 @@
     },
     "/no-validation":{
       "post":{
-        "summary":"Validation","description":"Input/Output with validation.",
-        "operationId":"_examples/advanced-generic.validation",
+        "summary":"No Validation","description":"Input/Output without validation.",
+        "operationId":"_examples/advanced-generic.noValidation",
         "parameters":[
-          {
-            "name":"q","in":"query",
-            "description":"This parameter will bypass explicit validation as it does not have constraints.",
-            "schema":{
-              "type":"boolean",
-              "description":"This parameter will bypass explicit validation as it does not have constraints."
-            }
-          },
-          {
-            "name":"X-Input","in":"header","description":"Request minimum: 10, response maximum: 20.",
-            "schema":{"minimum":10,"type":"integer","description":"Request minimum: 10, response maximum: 20."}
-          }
+          {"name":"q","in":"query","schema":{"type":"boolean"}},
+          {"name":"X-Input","in":"header","schema":{"type":"integer"}}
         ],
-        "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInputPortType2"}}}},
+        "requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedInputPortType3"}}}},
         "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":{"type":"boolean","description":"This header bypasses validation as it does not have constraints."}
-              }
+              "X-Output":{"style":"simple","schema":{"type":"integer"}},
+              "X-Query":{"style":"simple","schema":{"type":"boolean"}}
             },
-            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputPortType2"}}}
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedOutputPortType3"}}}
           }
         }
       }
@@ -261,7 +274,7 @@
           },
           "500":{
             "description":"Internal Server Error",
-            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
           }
         }
       }
@@ -386,6 +399,7 @@
   },
   "components":{
     "schemas":{
+      "AdvancedCustomErr":{"type":"object","properties":{"details":{"type":"object","additionalProperties":{}},"msg":{"type":"string"}}},
       "AdvancedGzipPassThroughStruct":{
         "type":"object",
         "properties":{"id":{"type":"integer"},"text":{"type":"array","items":{"type":"string"},"nullable":true}}
@@ -418,6 +432,7 @@
           }
         }
       },
+      "AdvancedInputPortType3":{"type":"object","properties":{"data":{"type":"object","properties":{"value":{"type":"string"}}}}},
       "AdvancedInputWithJSONType2":{
         "type":"object",
         "properties":{
@@ -446,10 +461,12 @@
         }
       },
       "AdvancedJsonSliceReq":{"type":"array","items":{"type":"integer"}},
+      "AdvancedOkResp":{"type":"object","properties":{"status":{"type":"string"}}},
       "AdvancedOutputPortType2":{
         "required":["data"],"type":"object",
         "properties":{"data":{"type":"object","properties":{"value":{"maxLength":7,"type":"string"}}}}
       },
+      "AdvancedOutputPortType3":{"type":"object","properties":{"data":{"type":"object","properties":{"value":{"type":"string"}}}}},
       "AdvancedOutputQueryObject":{
         "type":"object",
         "properties":{"inQuery":{"type":"object","additionalProperties":{"type":"number"},"nullable":true}}
@@ -504,15 +521,6 @@
       },
       "FormDataMultipartFile":{"type":"string","format":"binary","nullable":true},
       "FormDataMultipartFileHeader":{"type":"string","format":"binary","nullable":true},
-      "RestErrResponse":{
-        "type":"object",
-        "properties":{
-          "code":{"type":"integer","description":"Application-specific error code."},
-          "context":{"type":"object","additionalProperties":{},"description":"Application context."},
-          "error":{"type":"string","description":"Error message."},
-          "status":{"type":"string","description":"Status text."}
-        }
-      },
       "TextprotoMIMEHeader":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"nullable":true}
     }
   }
diff --git a/_examples/advanced-generic/gzip_pass_through_test.go b/_examples/advanced-generic/gzip_pass_through_test.go
index fe78ee4..9522bdf 100644
--- a/_examples/advanced-generic/gzip_pass_through_test.go
+++ b/_examples/advanced-generic/gzip_pass_through_test.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/json_body_test.go b/_examples/advanced-generic/json_body_test.go
index 4cba893..5de002e 100644
--- a/_examples/advanced-generic/json_body_test.go
+++ b/_examples/advanced-generic/json_body_test.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/json_body_validation_test.go b/_examples/advanced-generic/json_body_validation_test.go
index a2cb0dc..69e7548 100644
--- a/_examples/advanced-generic/json_body_validation_test.go
+++ b/_examples/advanced-generic/json_body_validation_test.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/output_headers_test.go b/_examples/advanced-generic/output_headers_test.go
index 836fd70..1736619 100644
--- a/_examples/advanced-generic/output_headers_test.go
+++ b/_examples/advanced-generic/output_headers_test.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/request_response_mapping_test.go b/_examples/advanced-generic/request_response_mapping_test.go
index 64fdca2..d5990b5 100644
--- a/_examples/advanced-generic/request_response_mapping_test.go
+++ b/_examples/advanced-generic/request_response_mapping_test.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/router_test.go b/_examples/advanced-generic/router_test.go
index ab6c574..55ae603 100644
--- a/_examples/advanced-generic/router_test.go
+++ b/_examples/advanced-generic/router_test.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced-generic/validation_test.go b/_examples/advanced-generic/validation_test.go
index 67d6285..227ea0f 100644
--- a/_examples/advanced-generic/validation_test.go
+++ b/_examples/advanced-generic/validation_test.go
@@ -1,5 +1,4 @@
 //go:build go1.18
-// +build go1.18
 
 package main
 
diff --git a/_examples/advanced/_testdata/openapi.json b/_examples/advanced/_testdata/openapi.json
index 3c89a83..a617605 100644
--- a/_examples/advanced/_testdata/openapi.json
+++ b/_examples/advanced/_testdata/openapi.json
@@ -2,6 +2,32 @@
   "openapi":"3.0.3",
   "info":{"title":"Advanced Example","description":"This app showcases a variety of features.","version":"v1.2.3"},
   "paths":{
+    "/error-response":{
+      "get":{
+        "summary":"Declare Expected Errors",
+        "description":"This use case demonstrates documentation of expected errors.",
+        "operationId":"_examples/advanced.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":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+          },
+          "409":{
+            "description":"Conflict",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
+          }
+        }
+      }
+    },
     "/file-multi-upload":{
       "post":{
         "summary":"Files Uploads With 'multipart/form-data'","description":"",
@@ -246,7 +272,7 @@
           },
           "500":{
             "description":"Internal Server Error",
-            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdvancedCustomErr"}}}
           }
         }
       }
@@ -369,6 +395,7 @@
   },
   "components":{
     "schemas":{
+      "AdvancedCustomErr":{"type":"object","properties":{"details":{"type":"object","additionalProperties":{}},"msg":{"type":"string"}}},
       "AdvancedGzipPassThroughStruct":{
         "type":"object",
         "properties":{"id":{"type":"integer"},"text":{"type":"array","items":{"type":"string"},"nullable":true}}
@@ -430,6 +457,7 @@
         }
       },
       "AdvancedJsonSliceReq":{"type":"array","items":{"type":"integer"},"nullable":true},
+      "AdvancedOkResp":{"type":"object","properties":{"status":{"type":"string"}}},
       "AdvancedOutputPortType2":{
         "required":["data"],"type":"object",
         "properties":{"data":{"type":"object","properties":{"value":{"maxLength":7,"type":"string"}}}}
@@ -489,16 +517,7 @@
       },
       "FormDataMultipartFile":{"type":"string","format":"binary","nullable":true},
       "FormDataMultipartFileHeader":{"type":"string","format":"binary","nullable":true},
-      "RestErrResponse":{
-        "type":"object",
-        "properties":{
-          "code":{"type":"integer","description":"Application-specific error code."},
-          "context":{"type":"object","additionalProperties":{},"description":"Application context."},
-          "error":{"type":"string","description":"Error message."},
-          "status":{"type":"string","description":"Status text."}
-        }
-      },
       "TextprotoMIMEHeader":{"type":"object","additionalProperties":{"type":"array","items":{"type":"string"}},"nullable":true}
     }
   }
-}
\ No newline at end of file
+}
diff --git a/web/_testdata/openapi.json b/web/_testdata/openapi.json
new file mode 100644
index 0000000..0398d42
--- /dev/null
+++ b/web/_testdata/openapi.json
@@ -0,0 +1,157 @@
+{
+  "openapi":"3.0.3",
+  "info":{
+    "title":"Albums API",
+    "description":"This service provides API to manage albums.",
+    "version":"v1.0.0"
+  },
+  "paths":{
+    "/albums":{
+      "options":{
+        "tags":["Album"],"summary":"Post Albums","description":"",
+        "operationId":"rest/web_test.postAlbums",
+        "parameters":[{"name":"locale","in":"query","schema":{"type":"string"}}],
+        "requestBody":{
+          "content":{
+            "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+          }
+        },
+        "responses":{
+          "201":{
+            "description":"Created",
+            "content":{
+              "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+            }
+          }
+        }
+      },
+      "patch":{
+        "tags":["Album"],"summary":"Post Albums","description":"",
+        "operationId":"rest/web_test.postAlbums",
+        "parameters":[{"name":"locale","in":"query","schema":{"type":"string"}}],
+        "requestBody":{
+          "content":{
+            "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+          }
+        },
+        "responses":{
+          "201":{
+            "description":"Created",
+            "content":{
+              "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+            }
+          }
+        }
+      },
+      "post":{
+        "tags":["Album"],"summary":"Post Albums","description":"",
+        "operationId":"rest/web_test.postAlbums",
+        "parameters":[{"name":"locale","in":"query","schema":{"type":"string"}}],
+        "requestBody":{
+          "content":{
+            "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+          }
+        },
+        "responses":{
+          "201":{
+            "description":"Created",
+            "content":{
+              "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+            }
+          }
+        }
+      },
+      "put":{
+        "tags":["Album"],"summary":"Post Albums","description":"",
+        "operationId":"rest/web_test.postAlbums",
+        "parameters":[{"name":"locale","in":"query","schema":{"type":"string"}}],
+        "requestBody":{
+          "content":{
+            "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+          }
+        },
+        "responses":{
+          "201":{
+            "description":"Created",
+            "content":{
+              "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+            }
+          }
+        }
+      },
+      "trace":{
+        "tags":["Album"],"summary":"Post Albums","description":"",
+        "operationId":"rest/web_test.postAlbums",
+        "parameters":[{"name":"locale","in":"query","schema":{"type":"string"}}],
+        "responses":{
+          "201":{
+            "description":"Created",
+            "content":{
+              "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+            }
+          }
+        }
+      }
+    },
+    "/albums/{id}":{
+      "delete":{
+        "tags":["Album"],"summary":"Album By ID","description":"",
+        "operationId":"rest/web_test.albumByID",
+        "parameters":[
+          {"name":"locale","in":"query","schema":{"type":"string"}},
+          {
+            "name":"id","in":"path","required":true,"schema":{"type":"integer"}
+          }
+        ],
+        "responses":{
+          "200":{
+            "description":"OK",
+            "content":{
+              "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+            }
+          }
+        }
+      },
+      "get":{
+        "tags":["Album"],"summary":"Album By ID","description":"",
+        "operationId":"rest/web_test.albumByID",
+        "parameters":[
+          {"name":"locale","in":"query","schema":{"type":"string"}},
+          {
+            "name":"id","in":"path","required":true,"schema":{"type":"integer"}
+          }
+        ],
+        "responses":{
+          "200":{
+            "description":"OK",
+            "content":{
+              "application/json":{"schema":{"$ref":"#/components/schemas/WebTestAlbum"}}
+            }
+          }
+        }
+      },
+      "head":{
+        "tags":["Album"],"summary":"Album By ID","description":"",
+        "operationId":"rest/web_test.albumByID",
+        "parameters":[
+          {"name":"locale","in":"query","schema":{"type":"string"}},
+          {
+            "name":"id","in":"path","required":true,"schema":{"type":"integer"}
+          }
+        ],
+        "responses":{"200":{"description":"OK","content":{"application/json":{}}}}
+      }
+    }
+  },
+  "components":{
+    "schemas":{
+      "WebTestAlbum":{
+        "type":"object",
+        "properties":{
+          "artist":{"type":"string"},"id":{"type":"integer"},
+          "price":{"type":"number"},"title":{"type":"string"}
+        }
+      }
+    }
+  }
+}
diff --git a/web/example_test.go b/web/example_test.go
index e4d00bc..9fc65ec 100644
--- a/web/example_test.go
+++ b/web/example_test.go
@@ -16,6 +16,7 @@ type album struct {
 	Title  string  `json:"title"`
 	Artist string  `json:"artist"`
 	Price  float64 `json:"price"`
+	Locale string  `query:"locale"`
 }
 
 func postAlbums() usecase.Interactor {
diff --git a/web/service_test.go b/web/service_test.go
index a7c7abb..3342ca4 100644
--- a/web/service_test.go
+++ b/web/service_test.go
@@ -1,6 +1,8 @@
 package web_test
 
 import (
+	"context"
+	"io/ioutil"
 	"net/http"
 	"net/http/httptest"
 	"testing"
@@ -10,8 +12,23 @@ import (
 	"github.com/swaggest/assertjson"
 	"github.com/swaggest/rest/nethttp"
 	"github.com/swaggest/rest/web"
+	"github.com/swaggest/usecase"
 )
 
+type albumID struct {
+	ID     int    `path:"id"`
+	Locale string `query:"locale"`
+}
+
+func albumByID() usecase.Interactor {
+	u := usecase.NewIOI(new(albumID), new(album), func(ctx context.Context, input, output interface{}) error {
+		return nil
+	})
+	u.SetTags("Album")
+
+	return u
+}
+
 func TestDefaultService(t *testing.T) {
 	service := web.DefaultService()
 
@@ -19,9 +36,9 @@ func TestDefaultService(t *testing.T) {
 	service.OpenAPI.Info.WithDescription("This service provides API to manage albums.")
 	service.OpenAPI.Info.Version = "v1.0.0"
 
-	service.Delete("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated))
-	service.Head("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated))
-	service.Get("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated))
+	service.Delete("/albums/{id}", albumByID())
+	service.Head("/albums/{id}", albumByID())
+	service.Get("/albums/{id}", albumByID())
 	service.Post("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated))
 	service.Patch("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated))
 	service.Put("/albums", postAlbums(), nethttp.SuccessStatus(http.StatusCreated))
@@ -39,4 +56,8 @@ func TestDefaultService(t *testing.T) {
 
 	assert.Equal(t, http.StatusOK, rw.Code)
 	assertjson.EqualMarshal(t, rw.Body.Bytes(), service.OpenAPI)
+
+	expected, err := ioutil.ReadFile("_testdata/openapi.json")
+	require.NoError(t, err)
+	assertjson.EqualMarshal(t, expected, service.OpenAPI)
 }