Repository: swaggest/rest (go)

Issue:
Missing security operation
I'm trying to add a security operation to a subset of routes. However, the `security` op isn't set on the paths. 

```
	s.Route("/github", func(r chi.Router) {
		r.Group(func(r chi.Router) {
			r.Use(sessionMiddleware, nethttp.SecurityMiddleware(s.OpenAPICollector, "User", openapi3.SecurityScheme{
				APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{
					In:   "cookie",
					Name: appcfg.CookieName,
				},
			}))
			r.Method(http.MethodGet, "/connect", nethttp.NewHandler(controllers.Connect(appcfg.GitHubAppSlug), nethttp.SuccessStatus(http.StatusTemporaryRedirect)))
			r.Method(http.MethodGet, "/install", nethttp.NewHandler(controllers.Install(orgManager, clientCreator), nethttp.SuccessfulResponseContentType("text/html")))
		})
	})
```

Output of json (no security :()

```
    "/github/connect": {
      "get": {
        "summary": "Connect",
        "description": "Connect to GitHub",
        "operationId": "function/controllers.Connect",
        "responses": {
          "307": {
            "description": "Temporary Redirect",
            "headers": {
              "Location": {
                "style": "simple",
                "description": "Redirect to GitHub",
                "schema": {
                  "type": "string",
                  "description": "Redirect to GitHub"
                }
              }
            }
          },
          "400": {
            "description": "Bad Request",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RestErrResponse"
                }
              }
            }
          }
        }
      }
    },
```

Pull request description:
Return wrappers support in Use and With (filtered) to fix sub-routers
Fixes #96.

Separation of `Wrap` and `Use`/`With` introduced in #76 led to an opposite limitation: wrappers that are configured in sub-routers have no effect as they are passed to the original `chi` router that does not know how to deal with them.

This PR implements a hacky compromise, that some middlewares in `Use`/`With` can be considered handler wrappers, but for that they need to exhibit particular indicative behavior.

Then wrappers would go to wrappers (not going to chi), and middlewares would go to chi (not going to wrappers).


Patch:
diff --git a/_examples/advanced-generic/dummy.go b/_examples/advanced-generic/dummy.go
new file mode 100644
index 0000000..521364b
--- /dev/null
+++ b/_examples/advanced-generic/dummy.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"context"
+
+	"github.com/swaggest/usecase"
+)
+
+func dummy() usecase.Interactor {
+	return usecase.NewInteractor(func(ctx context.Context, input struct{}, output *struct{}) error {
+		return nil
+	})
+}
diff --git a/_examples/advanced-generic/router.go b/_examples/advanced-generic/router.go
index bb46a3a..6b8ba2c 100644
--- a/_examples/advanced-generic/router.go
+++ b/_examples/advanced-generic/router.go
@@ -9,6 +9,7 @@ import (
 	"reflect"
 	"strings"
 
+	"github.com/go-chi/chi/v5"
 	"github.com/google/uuid"
 	"github.com/rs/cors"
 	"github.com/swaggest/jsonschema-go"
@@ -166,6 +167,37 @@ func NewRouter() http.Handler {
 
 	s.Get("/error-response", errorResponse())
 
+	// 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))
+			}
+		})
+	}
+
+	sessDoc := nethttp.SecurityMiddleware(s.OpenAPICollector, "User", openapi3.SecurityScheme{
+		APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{
+			In:   "cookie",
+			Name: "sessid",
+		},
+	})
+
+	// 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)
 
diff --git a/_examples/advanced/dummy.go b/_examples/advanced/dummy.go
new file mode 100644
index 0000000..da56516
--- /dev/null
+++ b/_examples/advanced/dummy.go
@@ -0,0 +1,13 @@
+package main
+
+import (
+	"context"
+
+	"github.com/swaggest/usecase"
+)
+
+func dummy() usecase.Interactor {
+	return usecase.NewIOI(nil, nil, func(ctx context.Context, input, output interface{}) error {
+		return nil
+	})
+}
diff --git a/_examples/advanced/router.go b/_examples/advanced/router.go
index 0d59d5d..c282689 100644
--- a/_examples/advanced/router.go
+++ b/_examples/advanced/router.go
@@ -6,6 +6,7 @@ import (
 	"net/http"
 	"reflect"
 
+	"github.com/go-chi/chi/v5"
 	"github.com/google/uuid"
 	"github.com/swaggest/jsonschema-go"
 	"github.com/swaggest/openapi-go/openapi3"
@@ -141,6 +142,37 @@ func NewRouter() http.Handler {
 
 	s.Get("/error-response", errorResponse())
 
+	// 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))
+			}
+		})
+	}
+
+	sessDoc := nethttp.SecurityMiddleware(s.OpenAPICollector, "User", openapi3.SecurityScheme{
+		APIKeySecurityScheme: &openapi3.APIKeySecurityScheme{
+			In:   "cookie",
+			Name: "sessid",
+		},
+	})
+
+	// 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)
 
diff --git a/_examples/go.mod b/_examples/go.mod
index bb05b74..cedc1fd 100644
--- a/_examples/go.mod
+++ b/_examples/go.mod
@@ -14,8 +14,8 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/stretchr/testify v1.8.1
 	github.com/swaggest/assertjson v1.7.0
-	github.com/swaggest/jsonschema-go v0.3.40
-	github.com/swaggest/openapi-go v0.2.24
+	github.com/swaggest/jsonschema-go v0.3.41
+	github.com/swaggest/openapi-go v0.2.25
 	github.com/swaggest/swgui v1.5.1
 	github.com/swaggest/usecase v1.2.0
 	github.com/valyala/fasthttp v1.41.0
diff --git a/_examples/go.sum b/_examples/go.sum
index 1359005..8d40fed 100644
--- a/_examples/go.sum
+++ b/_examples/go.sum
@@ -62,10 +62,10 @@ github.com/swaggest/assertjson v1.7.0 h1:SKw5Rn0LQs6UvmGrIdaKQbMR1R3ncXm5KNon+QJ
 github.com/swaggest/assertjson v1.7.0/go.mod h1:vxMJMehbSVJd+dDWFCKv3QRZKNTpy/ktZKTz9LOEDng=
 github.com/swaggest/form/v5 v5.0.1 h1:YQH0REX7iMKhtoVPWXREZgbt50VYXNCKK61psnD8Fgo=
 github.com/swaggest/form/v5 v5.0.1/go.mod h1:vdnaSTze7cxVKhWiCabrfm1YeLwWLpb9P941Gxv4FnA=
-github.com/swaggest/jsonschema-go v0.3.40 h1:9EqQ9RvtdW69xfYODmyEKWOSZ12x5eiK+wGD2EVh/L4=
-github.com/swaggest/jsonschema-go v0.3.40/go.mod h1:ipIOmoFP64QyRUgyPyU/P9tayq2m2TlvUhyZHrhe3S4=
-github.com/swaggest/openapi-go v0.2.24 h1:Y+IyBMOKUmiSQw6EZs0JhsAjrTo7z/c4ZGy/qQbpHTs=
-github.com/swaggest/openapi-go v0.2.24/go.mod h1:6wX+vfa/g9NW5TXeez3AbqRAphq5PQe9iSqGmOJoplE=
+github.com/swaggest/jsonschema-go v0.3.41 h1:YsrBk5TW+S7/jTudvo1jWUJTECAIBdfk4iM7O9kzbnU=
+github.com/swaggest/jsonschema-go v0.3.41/go.mod h1:yt5lcjdqywYtNnzkFgYnyfvMwlH2XHEDitKw749AV1w=
+github.com/swaggest/openapi-go v0.2.25 h1:CFTbqMxjhD5MMtN6izHkcQuJZoCxQLsN7Zi0zvYTsF8=
+github.com/swaggest/openapi-go v0.2.25/go.mod h1:99jn0HbbECG97hkP6X8BA+TBS4+EBlMuko4zkPAz1BA=
 github.com/swaggest/refl v1.1.0 h1:a+9a75Kv6ciMozPjVbOfcVTEQe81t2R3emvaD9oGQGc=
 github.com/swaggest/refl v1.1.0/go.mod h1:g3Qa6ki0A/L2yxiuUpT+cuBURuRaltF5SDQpg1kMZSY=
 github.com/swaggest/swgui v1.5.1 h1:CXJ2l9n1nEz2AJXl/FHDGD5ZtlKnTRJb2p86njBfXfo=
diff --git a/chirouter/wrapper.go b/chirouter/wrapper.go
index 7db2d94..257fb52 100644
--- a/chirouter/wrapper.go
+++ b/chirouter/wrapper.go
@@ -53,14 +53,34 @@ func (r *Wrapper) Wrap(wraps ...func(handler http.Handler) http.Handler) {
 
 // Use appends one of more middlewares onto the Router stack.
 func (r *Wrapper) Use(middlewares ...func(http.Handler) http.Handler) {
-	r.Router.Use(middlewares...)
-	r.middlewares = append(r.middlewares, middlewares...)
+	var mws []func(http.Handler) http.Handler
+
+	for _, mw := range middlewares {
+		if nethttp.MiddlewareIsWrapper(mw) {
+			r.wraps = append(r.wraps, mw)
+		} else {
+			mws = append(mws, mw)
+		}
+	}
+
+	r.Router.Use(mws...)
+	r.middlewares = append(r.middlewares, mws...)
 }
 
 // With adds inline middlewares for an endpoint handler.
 func (r Wrapper) With(middlewares ...func(http.Handler) http.Handler) chi.Router {
-	c := r.copy(r.Router.With(middlewares...), "")
-	c.middlewares = append(c.middlewares, middlewares...)
+	var mws, ws []func(http.Handler) http.Handler
+
+	for _, mw := range middlewares {
+		if nethttp.MiddlewareIsWrapper(mw) {
+			ws = append(ws, mw)
+		} else {
+			mws = append(mws, mw)
+		}
+	}
+
+	c := r.copy(r.Router.With(mws...), "")
+	c.wraps = append(c.wraps, ws...)
 
 	return c
 }
diff --git a/go.mod b/go.mod
index efce0eb..30e6e36 100644
--- a/go.mod
+++ b/go.mod
@@ -12,8 +12,8 @@ require (
 	github.com/stretchr/testify v1.8.1
 	github.com/swaggest/assertjson v1.7.0
 	github.com/swaggest/form/v5 v5.0.1
-	github.com/swaggest/jsonschema-go v0.3.40
-	github.com/swaggest/openapi-go v0.2.24
+	github.com/swaggest/jsonschema-go v0.3.41
+	github.com/swaggest/openapi-go v0.2.25
 	github.com/swaggest/refl v1.1.0
 	github.com/swaggest/usecase v1.2.0
 )
diff --git a/go.sum b/go.sum
index caed6b0..a7a2668 100644
--- a/go.sum
+++ b/go.sum
@@ -3,8 +3,8 @@ github.com/bool64/dev v0.2.5/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zR
 github.com/bool64/dev v0.2.10/go.mod h1:/csLrm+4oDSsKJRIVS0mrywAonLnYKFG8RvGT7Jh9b8=
 github.com/bool64/dev v0.2.16/go.mod h1:/csLrm+4oDSsKJRIVS0mrywAonLnYKFG8RvGT7Jh9b8=
 github.com/bool64/dev v0.2.17/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
-github.com/bool64/dev v0.2.19/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
 github.com/bool64/dev v0.2.20/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
+github.com/bool64/dev v0.2.21/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
 github.com/bool64/dev v0.2.22 h1:YJFKBRKplkt+0Emq/5Xk1Z5QRmMNzc1UOJkR3rxJksA=
 github.com/bool64/dev v0.2.22/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg=
 github.com/bool64/httpmock v0.1.6 h1:v9+AmoSabaHuR7abjnhGwGxsaNljtFAYtluF0p4Q3ng=
@@ -76,10 +76,10 @@ github.com/swaggest/assertjson v1.7.0 h1:SKw5Rn0LQs6UvmGrIdaKQbMR1R3ncXm5KNon+QJ
 github.com/swaggest/assertjson v1.7.0/go.mod h1:vxMJMehbSVJd+dDWFCKv3QRZKNTpy/ktZKTz9LOEDng=
 github.com/swaggest/form/v5 v5.0.1 h1:YQH0REX7iMKhtoVPWXREZgbt50VYXNCKK61psnD8Fgo=
 github.com/swaggest/form/v5 v5.0.1/go.mod h1:vdnaSTze7cxVKhWiCabrfm1YeLwWLpb9P941Gxv4FnA=
-github.com/swaggest/jsonschema-go v0.3.40 h1:9EqQ9RvtdW69xfYODmyEKWOSZ12x5eiK+wGD2EVh/L4=
-github.com/swaggest/jsonschema-go v0.3.40/go.mod h1:ipIOmoFP64QyRUgyPyU/P9tayq2m2TlvUhyZHrhe3S4=
-github.com/swaggest/openapi-go v0.2.24 h1:Y+IyBMOKUmiSQw6EZs0JhsAjrTo7z/c4ZGy/qQbpHTs=
-github.com/swaggest/openapi-go v0.2.24/go.mod h1:6wX+vfa/g9NW5TXeez3AbqRAphq5PQe9iSqGmOJoplE=
+github.com/swaggest/jsonschema-go v0.3.41 h1:YsrBk5TW+S7/jTudvo1jWUJTECAIBdfk4iM7O9kzbnU=
+github.com/swaggest/jsonschema-go v0.3.41/go.mod h1:yt5lcjdqywYtNnzkFgYnyfvMwlH2XHEDitKw749AV1w=
+github.com/swaggest/openapi-go v0.2.25 h1:CFTbqMxjhD5MMtN6izHkcQuJZoCxQLsN7Zi0zvYTsF8=
+github.com/swaggest/openapi-go v0.2.25/go.mod h1:99jn0HbbECG97hkP6X8BA+TBS4+EBlMuko4zkPAz1BA=
 github.com/swaggest/refl v1.1.0 h1:a+9a75Kv6ciMozPjVbOfcVTEQe81t2R3emvaD9oGQGc=
 github.com/swaggest/refl v1.1.0/go.mod h1:g3Qa6ki0A/L2yxiuUpT+cuBURuRaltF5SDQpg1kMZSY=
 github.com/swaggest/usecase v1.2.0 h1:cHVFqxIbHfyTXp02JmWXk+ZADaSa87UZP+b3qL5Nz90=
diff --git a/nethttp/handler.go b/nethttp/handler.go
index ae85745..41af5ef 100644
--- a/nethttp/handler.go
+++ b/nethttp/handler.go
@@ -229,6 +229,10 @@ func (h handlerWithRoute) RoutePattern() string {
 // HandlerWithRouteMiddleware wraps handler with routing information.
 func HandlerWithRouteMiddleware(method, pathPattern string) func(http.Handler) http.Handler {
 	return func(handler http.Handler) http.Handler {
+		if IsWrapperChecker(handler) {
+			return handler
+		}
+
 		return handlerWithRoute{
 			Handler:     handler,
 			pathPattern: pathPattern,
diff --git a/nethttp/openapi.go b/nethttp/openapi.go
index 8ef8f18..db9651a 100644
--- a/nethttp/openapi.go
+++ b/nethttp/openapi.go
@@ -11,6 +11,10 @@ import (
 // OpenAPIMiddleware reads info and adds validation to handler.
 func OpenAPIMiddleware(s *openapi.Collector) func(http.Handler) http.Handler {
 	return func(h http.Handler) http.Handler {
+		if IsWrapperChecker(h) {
+			return h
+		}
+
 		var (
 			withRoute rest.HandlerWithRoute
 			handler   *Handler
@@ -106,6 +110,10 @@ func AnnotateOpenAPI(
 	setup ...func(op *openapi3.Operation) error,
 ) func(http.Handler) http.Handler {
 	return func(next http.Handler) http.Handler {
+		if IsWrapperChecker(next) {
+			return next
+		}
+
 		var withRoute rest.HandlerWithRoute
 
 		if HandlerAs(next, &withRoute) {
diff --git a/nethttp/usecase.go b/nethttp/usecase.go
index 2999cb0..e3d1433 100644
--- a/nethttp/usecase.go
+++ b/nethttp/usecase.go
@@ -10,6 +10,10 @@ import (
 // UseCaseMiddlewares applies use case middlewares to Handler.
 func UseCaseMiddlewares(mw ...usecase.Middleware) func(http.Handler) http.Handler {
 	return func(handler http.Handler) http.Handler {
+		if IsWrapperChecker(handler) {
+			return handler
+		}
+
 		var uh *Handler
 		if !HandlerAs(handler, &uh) {
 			return handler
diff --git a/nethttp/wrapper.go b/nethttp/wrapper.go
new file mode 100644
index 0000000..1466fd2
--- /dev/null
+++ b/nethttp/wrapper.go
@@ -0,0 +1,32 @@
+package nethttp
+
+import "net/http"
+
+type wrapperChecker struct {
+	found bool
+}
+
+func (*wrapperChecker) ServeHTTP(w http.ResponseWriter, r *http.Request) {}
+
+// IsWrapperChecker is a hack to mark middleware as a handler wrapper.
+// See chirouter.Wrapper Wrap() documentation for more details on the difference.
+//
+// Wrappers should invoke the check and do early return if it succeeds.
+func IsWrapperChecker(h http.Handler) bool {
+	if wm, ok := h.(*wrapperChecker); ok {
+		wm.found = true
+
+		return true
+	}
+
+	return false
+}
+
+// MiddlewareIsWrapper is a hack to detect whether middleware is a handler wrapper.
+// See chirouter.Wrapper Wrap() documentation for more details on the difference.
+func MiddlewareIsWrapper(mw func(h http.Handler) http.Handler) bool {
+	wm := &wrapperChecker{}
+	mw(wm)
+
+	return wm.found
+}
diff --git a/request/middleware.go b/request/middleware.go
index 1df668a..5c82407 100644
--- a/request/middleware.go
+++ b/request/middleware.go
@@ -19,6 +19,10 @@ type requestMapping interface {
 // DecoderMiddleware sets up request decoder in suitable handlers.
 func DecoderMiddleware(factory DecoderMaker) func(http.Handler) http.Handler {
 	return func(handler http.Handler) http.Handler {
+		if nethttp.IsWrapperChecker(handler) {
+			return handler
+		}
+
 		var (
 			withRoute          rest.HandlerWithRoute
 			withUseCase        rest.HandlerWithUseCase
@@ -57,6 +61,10 @@ type withRestHandler interface {
 // ValidatorMiddleware sets up request validator in suitable handlers.
 func ValidatorMiddleware(factory rest.RequestValidatorFactory) func(http.Handler) http.Handler {
 	return func(handler http.Handler) http.Handler {
+		if nethttp.IsWrapperChecker(handler) {
+			return handler
+		}
+
 		var (
 			withRoute        rest.HandlerWithRoute
 			withUseCase      rest.HandlerWithUseCase
diff --git a/response/middleware.go b/response/middleware.go
index 792da1d..3fb0918 100644
--- a/response/middleware.go
+++ b/response/middleware.go
@@ -14,6 +14,10 @@ type responseEncoderSetter interface {
 
 // EncoderMiddleware instruments qualifying http.Handler with Encoder.
 func EncoderMiddleware(handler http.Handler) http.Handler {
+	if nethttp.IsWrapperChecker(handler) {
+		return handler
+	}
+
 	var (
 		withUseCase        rest.HandlerWithUseCase
 		setResponseEncoder responseEncoderSetter
diff --git a/response/validator.go b/response/validator.go
index 3e5a274..f109447 100644
--- a/response/validator.go
+++ b/response/validator.go
@@ -15,6 +15,10 @@ type withRestHandler interface {
 // ValidatorMiddleware sets up response validator in suitable handlers.
 func ValidatorMiddleware(factory rest.ResponseValidatorFactory) func(http.Handler) http.Handler {
 	return func(handler http.Handler) http.Handler {
+		if nethttp.IsWrapperChecker(handler) {
+			return handler
+		}
+
 		var (
 			withUseCase       rest.HandlerWithUseCase
 			handlerTrait      withRestHandler


Test patch:
diff --git a/_examples/advanced-generic/_testdata/openapi.json b/_examples/advanced-generic/_testdata/openapi.json
index 61b39d6..632c073 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":{
+    "/deeper-with-session/one":{
+      "get":{
+        "summary":"Dummy","operationId":"_examples/advanced-generic.dummy2",
+        "responses":{
+          "204":{"description":"No Content"},
+          "401":{
+            "description":"Unauthorized",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+          }
+        },
+        "security":[{"User":[]}]
+      }
+    },
+    "/deeper-with-session/two":{
+      "get":{
+        "summary":"Dummy","operationId":"_examples/advanced-generic.dummy3",
+        "responses":{
+          "204":{"description":"No Content"},
+          "401":{
+            "description":"Unauthorized",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+          }
+        },
+        "security":[{"User":[]}]
+      }
+    },
     "/error-response":{
       "get":{
         "summary":"Declare Expected Errors",
@@ -427,6 +453,19 @@
         }
       }
     },
+    "/root-with-session":{
+      "get":{
+        "summary":"Dummy","operationId":"_examples/advanced-generic.dummy",
+        "responses":{
+          "204":{"description":"No Content"},
+          "401":{
+            "description":"Unauthorized",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+          }
+        },
+        "security":[{"User":[]}]
+      }
+    },
     "/validation":{
       "post":{
         "summary":"Validation","description":"Input/Output with validation. Custom annotation.",
@@ -617,8 +656,18 @@
       },
       "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},
       "UuidUUID":{"type":"string","format":"uuid","example":"248df4b7-aa70-47b8-a036-33ac447e668d"}
-    }
+    },
+    "securitySchemes":{"User":{"type":"apiKey","name":"sessid","in":"cookie"}}
   }
 }
diff --git a/_examples/advanced/_testdata/openapi.json b/_examples/advanced/_testdata/openapi.json
index 8ace187..4b5f155 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":{
+    "/deeper-with-session/one":{
+      "get":{
+        "summary":"Dummy","operationId":"_examples/advanced.dummy2",
+        "responses":{
+          "204":{"description":"No Content"},
+          "401":{
+            "description":"Unauthorized",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+          }
+        },
+        "security":[{"User":[]}]
+      }
+    },
+    "/deeper-with-session/two":{
+      "get":{
+        "summary":"Dummy","operationId":"_examples/advanced.dummy3",
+        "responses":{
+          "204":{"description":"No Content"},
+          "401":{
+            "description":"Unauthorized",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+          }
+        },
+        "security":[{"User":[]}]
+      }
+    },
     "/error-response":{
       "get":{
         "summary":"Declare Expected Errors",
@@ -380,6 +406,19 @@
         }
       }
     },
+    "/root-with-session":{
+      "get":{
+        "summary":"Dummy","operationId":"_examples/advanced.dummy",
+        "responses":{
+          "204":{"description":"No Content"},
+          "401":{
+            "description":"Unauthorized",
+            "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+          }
+        },
+        "security":[{"User":[]}]
+      }
+    },
     "/validation":{
       "post":{
         "summary":"Validation","description":"Input/Output with validation. Custom annotation.",
@@ -554,8 +593,18 @@
       },
       "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},
       "UuidUUID":{"type":"string","format":"uuid","example":"248df4b7-aa70-47b8-a036-33ac447e668d"}
-    }
+    },
+    "securitySchemes":{"User":{"type":"apiKey","name":"sessid","in":"cookie"}}
   }
 }
diff --git a/chirouter/wrapper_test.go b/chirouter/wrapper_test.go
index 3b3fe45..d36ccd4 100644
--- a/chirouter/wrapper_test.go
+++ b/chirouter/wrapper_test.go
@@ -124,7 +124,7 @@ func TestNewWrapper(t *testing.T) {
 	}
 
 	assert.Equal(t, 14, handlersCnt)
-	assert.Equal(t, 20, totalCnt)
+	assert.Equal(t, 21, totalCnt)
 }
 
 func TestWrapper_Use_precedence(t *testing.T) {
@@ -356,3 +356,36 @@ func TestWrapper_Mount(t *testing.T) {
 	  }
 	}`), service.OpenAPI)
 }
+
+func TestWrapper_With(t *testing.T) {
+	wrapperCalled := 0
+	wrapperFound := 0
+	wrapper := func(handler http.Handler) http.Handler {
+		if nethttp.IsWrapperChecker(handler) {
+			wrapperFound++
+
+			return handler
+		}
+
+		wrapperCalled++
+
+		return handler
+	}
+
+	notWrapperCalled := 0
+	notWrapper := func(handler http.Handler) http.Handler {
+		notWrapperCalled++
+
+		return handler
+	}
+
+	cw := chirouter.NewWrapper(chi.NewRouter())
+
+	cw.Use(wrapper, notWrapper)
+	cw.With(wrapper, notWrapper).Method(http.MethodGet, "/",
+		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
+
+	assert.Equal(t, 2, wrapperCalled)
+	assert.Equal(t, 2, wrapperFound)
+	assert.Equal(t, 5, notWrapperCalled) // 2 wrapper checks, 2 chi chains, 1 capture handler.
+}
diff --git a/nethttp/example_test.go b/nethttp/example_test.go
index 531eb0f..6d8db9f 100644
--- a/nethttp/example_test.go
+++ b/nethttp/example_test.go
@@ -2,9 +2,11 @@ package nethttp_test
 
 import (
 	"context"
+	"fmt"
 	"net/http"
 
 	"github.com/go-chi/chi/v5"
+	"github.com/swaggest/assertjson"
 	"github.com/swaggest/openapi-go/openapi3"
 	"github.com/swaggest/rest/chirouter"
 	"github.com/swaggest/rest/nethttp"
@@ -19,7 +21,7 @@ func ExampleSecurityMiddleware() {
 	// Init API documentation schema.
 	apiSchema := &openapi.Collector{}
 
-	// Setup middlewares (non-documentary middlewares omitted for brevity).
+	// Setup middlewares (non-documentation middlewares omitted for brevity).
 	r.Wrap(
 		nethttp.OpenAPIMiddleware(apiSchema), // Documentation collector.
 	)
@@ -54,4 +56,40 @@ func ExampleSecurityMiddleware() {
 	r.
 		With(serviceTokenAuth, serviceTokenDoc). // Apply a pair of middlewares: actual security and documentation.
 		Method(http.MethodGet, "/foo", nethttp.NewHandler(u))
+
+	schema, _ := assertjson.MarshalIndentCompact(apiSchema.Reflector().Spec, "", " ", 120)
+	fmt.Println(string(schema))
+
+	// Output:
+	// {
+	//  "openapi":"3.0.3","info":{"title":"","version":""},
+	//  "paths":{
+	//   "/foo":{
+	//    "get":{
+	//     "summary":"Example Security Middleware","operationId":"rest/nethttp_test.ExampleSecurityMiddleware",
+	//     "responses":{
+	//      "204":{"description":"No Content"},
+	//      "401":{
+	//       "description":"Unauthorized",
+	//       "content":{"application/json":{"schema":{"$ref":"#/components/schemas/RestErrResponse"}}}
+	//      }
+	//     },
+	//     "security":[{"serviceToken":[]}]
+	//    }
+	//   }
+	//  },
+	//  "components":{
+	//   "schemas":{
+	//    "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."}
+	//     }
+	//    }
+	//   },
+	//   "securitySchemes":{"serviceToken":{"type":"apiKey","name":"Authorization","in":"header"}}
+	//  }
+	// }
 }
diff --git a/nethttp/handler_test.go b/nethttp/handler_test.go
index 415f167..6e0d224 100644
--- a/nethttp/handler_test.go
+++ b/nethttp/handler_test.go
@@ -86,13 +86,16 @@ func TestHandler_ServeHTTP(t *testing.T) {
 	assert.Equal(t, u, h.UseCase())
 
 	umwCalled := false
-	hh := nethttp.UseCaseMiddlewares(usecase.MiddlewareFunc(func(next usecase.Interactor) usecase.Interactor {
+	w := nethttp.UseCaseMiddlewares(usecase.MiddlewareFunc(func(next usecase.Interactor) usecase.Interactor {
 		return usecase.Interact(func(ctx context.Context, input, output interface{}) error {
 			umwCalled = true
 
 			return next.Interact(ctx, input, output)
 		})
-	}))(h)
+	}))
+	hh := w(h)
+
+	assert.True(t, nethttp.MiddlewareIsWrapper(w))
 
 	rw := httptest.NewRecorder()
 	hh.ServeHTTP(rw, req)
@@ -283,11 +286,17 @@ func TestHandler_ServeHTTP_customMapping(t *testing.T) {
 		rest.ParamInQuery: map[string]string{"ID": "ident"},
 	}
 
-	h := nethttp.WrapHandler(uh,
+	ws := []func(handler http.Handler) http.Handler{
 		request.DecoderMiddleware(request.NewDecoderFactory()),
 		nethttp.HandlerWithRouteMiddleware(http.MethodGet, "/test"),
 		response.EncoderMiddleware,
-	)
+	}
+
+	h := nethttp.WrapHandler(uh, ws...)
+
+	for i, w := range ws {
+		assert.True(t, nethttp.MiddlewareIsWrapper(w), i)
+	}
 
 	req, err := http.NewRequest(http.MethodGet, "/test?ident=123", nil)
 	require.NoError(t, err)
diff --git a/nethttp/openapi_test.go b/nethttp/openapi_test.go
index 243456a..4055ea5 100644
--- a/nethttp/openapi_test.go
+++ b/nethttp/openapi_test.go
@@ -51,7 +51,7 @@ func TestOpenAPIMiddleware(t *testing.T) {
 
 	c := openapi.Collector{}
 
-	_ = nethttp.WrapHandler(uh,
+	ws := []func(handler http.Handler) http.Handler{
 		nethttp.OpenAPIMiddleware(&c),
 		nethttp.HTTPBasicSecurityMiddleware(&c, "admin", "Admin Area."),
 		nethttp.HTTPBearerSecurityMiddleware(&c, "api", "API Security.", "JWT",
@@ -59,7 +59,13 @@ func TestOpenAPIMiddleware(t *testing.T) {
 				Error string `json:"error"`
 			}), http.StatusForbidden)),
 		nethttp.HandlerWithRouteMiddleware(http.MethodGet, "/test"),
-	)
+	}
+
+	_ = nethttp.WrapHandler(uh, ws...)
+
+	for i, w := range ws {
+		assert.True(t, nethttp.MiddlewareIsWrapper(w), i)
+	}
 
 	sp, err := assertjson.MarshalIndentCompact(c.Reflector().Spec, "", " ", 100)
 	require.NoError(t, err)
diff --git a/nethttp/wrapper_test.go b/nethttp/wrapper_test.go
new file mode 100644
index 0000000..cc549cd
--- /dev/null
+++ b/nethttp/wrapper_test.go
@@ -0,0 +1,26 @@
+package nethttp_test
+
+import (
+	"net/http"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/swaggest/rest/nethttp"
+)
+
+func TestMiddlewareIsWrapper(t *testing.T) {
+	wrapper := func(handler http.Handler) http.Handler {
+		if nethttp.IsWrapperChecker(handler) {
+			return handler
+		}
+
+		return handler
+	}
+
+	notWrapper := func(handler http.Handler) http.Handler {
+		return handler
+	}
+
+	assert.True(t, nethttp.MiddlewareIsWrapper(wrapper))
+	assert.False(t, nethttp.MiddlewareIsWrapper(notWrapper))
+}
diff --git a/request/file_test.go b/request/file_test.go
index 035101e..10c7cfb 100644
--- a/request/file_test.go
+++ b/request/file_test.go
@@ -41,12 +41,18 @@ func TestMapper_Decode_fileUploadTag(t *testing.T) {
 
 	decoderFactory.SetDecoderFunc(rest.ParamInPath, chirouter.PathToURLValues)
 
-	r.Wrap(
+	ws := []func(handler http.Handler) http.Handler{
 		nethttp.OpenAPIMiddleware(&apiSchema),
 		request.DecoderMiddleware(decoderFactory),
 		request.ValidatorMiddleware(validatorFactory),
 		response.EncoderMiddleware,
-	)
+	}
+
+	r.Wrap(ws...)
+
+	for i, w := range ws {
+		assert.True(t, nethttp.MiddlewareIsWrapper(w), i)
+	}
 
 	u := struct {
 		usecase.Interactor
diff --git a/response/middleware_test.go b/response/middleware_test.go
index 7da317a..bb68ded 100644
--- a/response/middleware_test.go
+++ b/response/middleware_test.go
@@ -38,8 +38,10 @@ func TestEncoderMiddleware(t *testing.T) {
 	r, err := http.NewRequest(http.MethodGet, "/", nil)
 	require.NoError(t, err)
 
-	response.EncoderMiddleware(h).ServeHTTP(w, r)
+	em := response.EncoderMiddleware
+	em(h).ServeHTTP(w, r)
 	assert.Equal(t, http.StatusOK, w.Code)
 	assert.Equal(t, `{"items":["one","two","three"]}`+"\n", w.Body.String())
 	assert.Equal(t, "Jane", w.Header().Get("X-Name"))
+	assert.True(t, nethttp.MiddlewareIsWrapper(em))
 }
diff --git a/response/validator_test.go b/response/validator_test.go
index 8a56f3b..0fdf48d 100644
--- a/response/validator_test.go
+++ b/response/validator_test.go
@@ -47,6 +47,8 @@ func TestValidatorMiddleware(t *testing.T) {
 	validatorFactory := jsonschema.NewFactory(apiSchema, apiSchema)
 	wh := nethttp.WrapHandler(h, response.EncoderMiddleware, response.ValidatorMiddleware(validatorFactory))
 
+	assert.True(t, nethttp.MiddlewareIsWrapper(response.ValidatorMiddleware(validatorFactory)))
+
 	w := httptest.NewRecorder()
 	r, err := http.NewRequest(http.MethodGet, "/", nil)
 	require.NoError(t, err)