Skip to content

Commit b16f749

Browse files
authored
Merge pull request #54 from Fedott/feat/prefix-option
feat: Add path Prefix option to validate
2 parents e58a433 + c5edb3d commit b16f749

File tree

4 files changed

+458
-5
lines changed

4 files changed

+458
-5
lines changed

.github/workflows/ci.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,6 @@ permissions:
77
contents: read
88
jobs:
99
build:
10-
uses: oapi-codegen/actions/.github/workflows/ci.yml@b9f2c274c1c631e648931dbbcc1942c2b2027837 # v0.4.0
10+
uses: oapi-codegen/actions/.github/workflows/ci.yml@6cf35d4f044f2663dae54547ff6d426e565beb48 # v0.6.0
11+
with:
12+
lint_versions: '["1.25"]'
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package gorilla
2+
3+
import (
4+
"context"
5+
_ "embed"
6+
"net/http"
7+
"testing"
8+
9+
middleware "github.com/oapi-codegen/nethttp-middleware"
10+
11+
"github.com/getkin/kin-openapi/openapi3"
12+
"github.com/getkin/kin-openapi/openapi3filter"
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// prefixTestSpec defines a minimal spec with /resource (GET+POST) for prefix testing
18+
const prefixTestSpec = `
19+
openapi: "3.0.0"
20+
info:
21+
version: 1.0.0
22+
title: TestServer
23+
paths:
24+
/resource:
25+
get:
26+
operationId: getResource
27+
parameters:
28+
- name: id
29+
in: query
30+
schema:
31+
type: integer
32+
minimum: 10
33+
maximum: 100
34+
responses:
35+
'200':
36+
description: success
37+
post:
38+
operationId: createResource
39+
responses:
40+
'204':
41+
description: No content
42+
requestBody:
43+
required: true
44+
content:
45+
application/json:
46+
schema:
47+
properties:
48+
name:
49+
type: string
50+
additionalProperties: false
51+
`
52+
53+
func loadPrefixSpec(t *testing.T) *openapi3.T {
54+
t.Helper()
55+
spec, err := openapi3.NewLoader().LoadFromData([]byte(prefixTestSpec))
56+
require.NoError(t, err)
57+
spec.Servers = nil
58+
return spec
59+
}
60+
61+
// setupPrefixHandler creates a mux with a handler at the given handlerPath
62+
// that records whether it was called and what path it saw.
63+
func setupPrefixHandler(t *testing.T, handlerPath string) (*http.ServeMux, *bool, *string) {
64+
t.Helper()
65+
called := new(bool)
66+
observedPath := new(string)
67+
68+
mux := http.NewServeMux()
69+
mux.HandleFunc(handlerPath, func(w http.ResponseWriter, r *http.Request) {
70+
*called = true
71+
*observedPath = r.URL.Path
72+
w.WriteHeader(http.StatusNoContent)
73+
})
74+
return mux, called, observedPath
75+
}
76+
77+
func TestPrefix_ErrorHandler_ValidRequest(t *testing.T) {
78+
spec := loadPrefixSpec(t)
79+
mux, called, observedPath := setupPrefixHandler(t, "/api/v1/resource")
80+
81+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
82+
Prefix: "/api/v1",
83+
})
84+
server := mw(mux)
85+
86+
body := struct {
87+
Name string `json:"name"`
88+
}{Name: "test"}
89+
90+
rec := doPost(t, server, "http://example.com/api/v1/resource", body)
91+
assert.Equal(t, http.StatusNoContent, rec.Code)
92+
assert.True(t, *called, "handler should have been called")
93+
assert.Equal(t, "/api/v1/resource", *observedPath, "handler should see the original path, not the stripped one")
94+
}
95+
96+
func TestPrefix_ErrorHandler_InvalidRequest(t *testing.T) {
97+
spec := loadPrefixSpec(t)
98+
mux, called, _ := setupPrefixHandler(t, "/api/v1/resource")
99+
100+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
101+
Prefix: "/api/v1",
102+
})
103+
server := mw(mux)
104+
105+
// Send a request with out-of-spec query param (id=500, max is 100)
106+
rec := doGet(t, server, "http://example.com/api/v1/resource?id=500")
107+
assert.Equal(t, http.StatusBadRequest, rec.Code)
108+
assert.False(t, *called, "handler should not have been called for invalid request")
109+
}
110+
111+
func TestPrefix_ErrorHandlerWithOpts_ValidRequest(t *testing.T) {
112+
spec := loadPrefixSpec(t)
113+
mux, called, observedPath := setupPrefixHandler(t, "/api/v1/resource")
114+
115+
var errHandlerCalled bool
116+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
117+
Prefix: "/api/v1",
118+
ErrorHandlerWithOpts: func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request, opts middleware.ErrorHandlerOpts) {
119+
errHandlerCalled = true
120+
http.Error(w, err.Error(), opts.StatusCode)
121+
},
122+
})
123+
server := mw(mux)
124+
125+
body := struct {
126+
Name string `json:"name"`
127+
}{Name: "test"}
128+
129+
rec := doPost(t, server, "http://example.com/api/v1/resource", body)
130+
assert.Equal(t, http.StatusNoContent, rec.Code)
131+
assert.True(t, *called, "handler should have been called")
132+
assert.False(t, errHandlerCalled, "error handler should not have been called")
133+
assert.Equal(t, "/api/v1/resource", *observedPath, "handler should see the original path, not the stripped one")
134+
}
135+
136+
func TestPrefix_ErrorHandlerWithOpts_InvalidRequest(t *testing.T) {
137+
spec := loadPrefixSpec(t)
138+
mux, called, _ := setupPrefixHandler(t, "/api/v1/resource")
139+
140+
var errHandlerCalled bool
141+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
142+
Prefix: "/api/v1",
143+
ErrorHandlerWithOpts: func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request, opts middleware.ErrorHandlerOpts) {
144+
errHandlerCalled = true
145+
http.Error(w, err.Error(), opts.StatusCode)
146+
},
147+
})
148+
server := mw(mux)
149+
150+
rec := doGet(t, server, "http://example.com/api/v1/resource?id=500")
151+
assert.Equal(t, http.StatusBadRequest, rec.Code)
152+
assert.False(t, *called, "handler should not have been called")
153+
assert.True(t, errHandlerCalled, "error handler should have been called")
154+
}
155+
156+
func TestPrefix_RequestWithoutPrefix_NotMatched(t *testing.T) {
157+
spec := loadPrefixSpec(t)
158+
mux, called, _ := setupPrefixHandler(t, "/resource")
159+
160+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
161+
Prefix: "/api/v1",
162+
})
163+
server := mw(mux)
164+
165+
// A request to /resource (without the prefix) should not match the
166+
// prefix and should be treated as if no prefix stripping happened.
167+
// Since /resource IS in the spec, this should still validate.
168+
rec := doGet(t, server, "http://example.com/resource")
169+
assert.Equal(t, http.StatusNoContent, rec.Code)
170+
assert.True(t, *called, "handler should have been called for path that doesn't have the prefix")
171+
}
172+
173+
func TestPrefix_PartialSegmentMatch_NotStripped(t *testing.T) {
174+
spec := loadPrefixSpec(t)
175+
176+
// Register handler at the path that would result from incorrect partial stripping
177+
mux := http.NewServeMux()
178+
179+
var resourceV2Called bool
180+
mux.HandleFunc("/api-v2/resource", func(w http.ResponseWriter, r *http.Request) {
181+
resourceV2Called = true
182+
w.WriteHeader(http.StatusOK)
183+
})
184+
185+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
186+
Prefix: "/api",
187+
})
188+
server := mw(mux)
189+
190+
// /api-v2/resource should NOT have "/api" stripped to become "-v2/resource"
191+
// The prefix must match on a path segment boundary.
192+
rec := doGet(t, server, "http://example.com/api-v2/resource")
193+
// The prefix doesn't match on a segment boundary, so no stripping happens.
194+
// /api-v2/resource is not in the spec → 404.
195+
assert.Equal(t, http.StatusNotFound, rec.Code)
196+
assert.False(t, resourceV2Called, "handler should not have been called")
197+
}
198+
199+
func TestPrefix_ExactPrefixOnly_NoTrailingSlash(t *testing.T) {
200+
spec := loadPrefixSpec(t)
201+
mux, called, _ := setupPrefixHandler(t, "/api/resource")
202+
203+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
204+
Prefix: "/api",
205+
})
206+
server := mw(mux)
207+
208+
// /api/resource → strip /api → /resource (which is in the spec)
209+
body := struct {
210+
Name string `json:"name"`
211+
}{Name: "test"}
212+
213+
rec := doPost(t, server, "http://example.com/api/resource", body)
214+
assert.Equal(t, http.StatusNoContent, rec.Code)
215+
assert.True(t, *called, "handler should have been called")
216+
}
217+
218+
func TestPrefix_ErrorHandlerWithOpts_HandlerSeesOriginalPath(t *testing.T) {
219+
spec := loadPrefixSpec(t)
220+
mux, _, observedPath := setupPrefixHandler(t, "/prefix/resource")
221+
222+
mw := middleware.OapiRequestValidatorWithOptions(spec, &middleware.Options{
223+
Prefix: "/prefix",
224+
ErrorHandlerWithOpts: func(ctx context.Context, err error, w http.ResponseWriter, r *http.Request, opts middleware.ErrorHandlerOpts) {
225+
http.Error(w, err.Error(), opts.StatusCode)
226+
},
227+
})
228+
server := mw(mux)
229+
230+
rec := doGet(t, server, "http://example.com/prefix/resource")
231+
assert.Equal(t, http.StatusNoContent, rec.Code)
232+
assert.Equal(t, "/prefix/resource", *observedPath, "downstream handler must see the original un-stripped path")
233+
}
234+
235+
func TestPrefix_WithAuthenticationFunc(t *testing.T) {
236+
spec := loadPrefixSpec(t)
237+
238+
// Add a protected endpoint to the spec for this test
239+
protectedSpec := `
240+
openapi: "3.0.0"
241+
info:
242+
version: 1.0.0
243+
title: TestServer
244+
paths:
245+
/resource:
246+
get:
247+
operationId: getResource
248+
security:
249+
- BearerAuth:
250+
- someScope
251+
responses:
252+
'200':
253+
description: success
254+
components:
255+
securitySchemes:
256+
BearerAuth:
257+
type: http
258+
scheme: bearer
259+
bearerFormat: JWT
260+
`
261+
_ = spec // unused, use protectedSpec instead
262+
pSpec, err := openapi3.NewLoader().LoadFromData([]byte(protectedSpec))
263+
require.NoError(t, err)
264+
pSpec.Servers = nil
265+
266+
mux := http.NewServeMux()
267+
var called bool
268+
mux.HandleFunc("/api/resource", func(w http.ResponseWriter, r *http.Request) {
269+
called = true
270+
w.WriteHeader(http.StatusOK)
271+
})
272+
273+
mw := middleware.OapiRequestValidatorWithOptions(pSpec, &middleware.Options{
274+
Prefix: "/api",
275+
Options: openapi3filter.Options{
276+
AuthenticationFunc: func(ctx context.Context, input *openapi3filter.AuthenticationInput) error {
277+
return nil // always allow
278+
},
279+
},
280+
})
281+
server := mw(mux)
282+
283+
rec := doGet(t, server, "http://example.com/api/resource")
284+
assert.Equal(t, http.StatusOK, rec.Code)
285+
assert.True(t, called, "handler should have been called when auth passes")
286+
}

oapi_validate.go

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,9 @@ type Options struct {
100100
SilenceServersWarning bool
101101
// DoNotValidateServers ensures that there is no Host validation performed (see `SilenceServersWarning` and https://github.com/deepmap/oapi-codegen/issues/882 for more details)
102102
DoNotValidateServers bool
103+
// Prefix allows (optionally) trimming a prefix from the API path.
104+
// This may be useful if your API is routed to an internal path that is different from the OpenAPI specification.
105+
Prefix string
103106
}
104107

105108
// OapiRequestValidator Creates the middleware to validate that incoming requests match the given OpenAPI 3.x spec, with a default set of configuration.
@@ -153,10 +156,53 @@ func performRequestValidationForErrorHandler(next http.Handler, w http.ResponseW
153156
errorHandler(w, err.Error(), statusCode)
154157
}
155158

159+
func makeRequestForValidation(r *http.Request, options *Options) *http.Request {
160+
if options == nil || options.Prefix == "" {
161+
return r
162+
}
163+
164+
// Only strip the prefix when it matches on a path segment boundary:
165+
// the path must equal the prefix exactly, or the character immediately
166+
// after the prefix must be '/'.
167+
if !hasPathPrefix(r.URL.Path, options.Prefix) {
168+
return r
169+
}
170+
171+
r = r.Clone(r.Context())
172+
173+
r.RequestURI = stripPrefix(r.RequestURI, options.Prefix)
174+
r.URL.Path = stripPrefix(r.URL.Path, options.Prefix)
175+
if r.URL.RawPath != "" {
176+
r.URL.RawPath = stripPrefix(r.URL.RawPath, options.Prefix)
177+
}
178+
179+
return r
180+
}
181+
182+
// hasPathPrefix reports whether path starts with prefix on a segment boundary.
183+
func hasPathPrefix(path, prefix string) bool {
184+
if !strings.HasPrefix(path, prefix) {
185+
return false
186+
}
187+
// The prefix matches if the path equals the prefix exactly, or
188+
// the next character is a '/'.
189+
return len(path) == len(prefix) || path[len(prefix)] == '/'
190+
}
191+
192+
// stripPrefix removes prefix from s and returns the result.
193+
// If s does not start with prefix it is returned unchanged.
194+
func stripPrefix(s, prefix string) string {
195+
return strings.TrimPrefix(s, prefix)
196+
}
197+
156198
// Note that this is an inline-and-modified version of `validateRequest`, with a simplified control flow and providing full access to the `error` for the `ErrorHandlerWithOpts` function.
157199
func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.ResponseWriter, r *http.Request, router routers.Router, options *Options) {
200+
// Build a (possibly prefix-stripped) request for validation, but keep
201+
// the original so the downstream handler sees the un-modified path.
202+
validationReq := makeRequestForValidation(r, options)
203+
158204
// Find route
159-
route, pathParams, err := router.FindRoute(r)
205+
route, pathParams, err := router.FindRoute(validationReq)
160206
if err != nil {
161207
errOpts := ErrorHandlerOpts{
162208
// MatchedRoute will be nil, as we've not matched a route we know about
@@ -177,7 +223,7 @@ func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.R
177223

178224
// Validate request
179225
requestValidationInput := &openapi3filter.RequestValidationInput{
180-
Request: r,
226+
Request: validationReq,
181227
PathParams: pathParams,
182228
Route: route,
183229
}
@@ -186,9 +232,9 @@ func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.R
186232
requestValidationInput.Options = &options.Options
187233
}
188234

189-
err = openapi3filter.ValidateRequest(r.Context(), requestValidationInput)
235+
err = openapi3filter.ValidateRequest(validationReq.Context(), requestValidationInput)
190236
if err == nil {
191-
// it's a valid request, so serve it
237+
// it's a valid request, so serve it with the original request
192238
next.ServeHTTP(w, r)
193239
return
194240
}
@@ -220,6 +266,7 @@ func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.R
220266
// validateRequest is called from the middleware above and actually does the work
221267
// of validating a request.
222268
func validateRequest(r *http.Request, router routers.Router, options *Options) (int, error) {
269+
r = makeRequestForValidation(r, options)
223270

224271
// Find route
225272
route, pathParams, err := router.FindRoute(r)

0 commit comments

Comments
 (0)