Skip to content

Commit 85c9214

Browse files
mromaszewiczclaude
andcommitted
Merge upstream/main into pr/66
Resolve conflicts between feat/skipper and the Prefix feature from main. Both features are independent and retained in full. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2 parents ff1f0eb + b16f749 commit 85c9214

4 files changed

Lines changed: 457 additions & 6 deletions

File tree

.github/workflows/ci.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,6 @@ permissions:
77
contents: read
88
jobs:
99
build:
10-
uses: oapi-codegen/actions/.github/workflows/ci.yml@a4ae25b391bf6689acc6983e1e801237d8d515fc # v0.3.0
10+
uses: oapi-codegen/actions/.github/workflows/ci.yml@6cf35d4f044f2663dae54547ff6d426e565beb48 # v0.6.0
1111
with:
12-
excluding_versions: '["1.20", "1.21"]'
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
@@ -107,6 +107,9 @@ type Options struct {
107107
SilenceServersWarning bool
108108
// DoNotValidateServers ensures that there is no Host validation performed (see `SilenceServersWarning` and https://github.com/deepmap/oapi-codegen/issues/882 for more details)
109109
DoNotValidateServers bool
110+
// Prefix allows (optionally) trimming a prefix from the API path.
111+
// This may be useful if your API is routed to an internal path that is different from the OpenAPI specification.
112+
Prefix string
110113

111114
// Skipper allows writing a function that runs before any middleware and determines whether the given request should skip any validation middleware
112115
Skipper Skipper
@@ -188,10 +191,53 @@ func performRequestValidationForErrorHandler(next http.Handler, w http.ResponseW
188191
errorHandler(w, err.Error(), statusCode)
189192
}
190193

194+
func makeRequestForValidation(r *http.Request, options *Options) *http.Request {
195+
if options == nil || options.Prefix == "" {
196+
return r
197+
}
198+
199+
// Only strip the prefix when it matches on a path segment boundary:
200+
// the path must equal the prefix exactly, or the character immediately
201+
// after the prefix must be '/'.
202+
if !hasPathPrefix(r.URL.Path, options.Prefix) {
203+
return r
204+
}
205+
206+
r = r.Clone(r.Context())
207+
208+
r.RequestURI = stripPrefix(r.RequestURI, options.Prefix)
209+
r.URL.Path = stripPrefix(r.URL.Path, options.Prefix)
210+
if r.URL.RawPath != "" {
211+
r.URL.RawPath = stripPrefix(r.URL.RawPath, options.Prefix)
212+
}
213+
214+
return r
215+
}
216+
217+
// hasPathPrefix reports whether path starts with prefix on a segment boundary.
218+
func hasPathPrefix(path, prefix string) bool {
219+
if !strings.HasPrefix(path, prefix) {
220+
return false
221+
}
222+
// The prefix matches if the path equals the prefix exactly, or
223+
// the next character is a '/'.
224+
return len(path) == len(prefix) || path[len(prefix)] == '/'
225+
}
226+
227+
// stripPrefix removes prefix from s and returns the result.
228+
// If s does not start with prefix it is returned unchanged.
229+
func stripPrefix(s, prefix string) string {
230+
return strings.TrimPrefix(s, prefix)
231+
}
232+
191233
// 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.
192234
func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.ResponseWriter, r *http.Request, router routers.Router, options *Options) {
235+
// Build a (possibly prefix-stripped) request for validation, but keep
236+
// the original so the downstream handler sees the un-modified path.
237+
validationReq := makeRequestForValidation(r, options)
238+
193239
// Find route
194-
route, pathParams, err := router.FindRoute(r)
240+
route, pathParams, err := router.FindRoute(validationReq)
195241
if err != nil {
196242
errOpts := ErrorHandlerOpts{
197243
// MatchedRoute will be nil, as we've not matched a route we know about
@@ -212,7 +258,7 @@ func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.R
212258

213259
// Validate request
214260
requestValidationInput := &openapi3filter.RequestValidationInput{
215-
Request: r,
261+
Request: validationReq,
216262
PathParams: pathParams,
217263
Route: route,
218264
}
@@ -221,9 +267,9 @@ func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.R
221267
requestValidationInput.Options = &options.Options
222268
}
223269

224-
err = openapi3filter.ValidateRequest(r.Context(), requestValidationInput)
270+
err = openapi3filter.ValidateRequest(validationReq.Context(), requestValidationInput)
225271
if err == nil {
226-
// it's a valid request, so serve it
272+
// it's a valid request, so serve it with the original request
227273
next.ServeHTTP(w, r)
228274
return
229275
}
@@ -255,6 +301,7 @@ func performRequestValidationForErrorHandlerWithOpts(next http.Handler, w http.R
255301
// validateRequest is called from the middleware above and actually does the work
256302
// of validating a request.
257303
func validateRequest(r *http.Request, router routers.Router, options *Options) (int, error) {
304+
r = makeRequestForValidation(r, options)
258305

259306
// Find route
260307
route, pathParams, err := router.FindRoute(r)

0 commit comments

Comments
 (0)