From 238a9102c929402e76a3cc80435f3853434b16d1 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:11:37 +0200 Subject: [PATCH 01/12] fix: bind path, query and headers after the body; drop uri fields from body schema The JSON decoder matches keys case-insensitively, so a field tagged uri:"id" without json:"-" could be overwritten by {"id": "..."} in the request body. parseInput now decodes the body first, then binds the path, query and headers, which therefore always win. generateSchema skipped path/query/header fields but not uri ones, so path parameters leaked into the documented request body. uri and body:"raw" fields are now excluded too. Co-Authored-By: Claude Opus 5.5 --- binding_order_test.go | 70 +++++++++++++++++++++++++++++++++++++++++++ common.go | 22 +++++++------- fiberoapi.go | 7 ++++- 3 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 binding_order_test.go diff --git a/binding_order_test.go b/binding_order_test.go new file mode 100644 index 0000000..6371115 --- /dev/null +++ b/binding_order_test.go @@ -0,0 +1,70 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// bindingOrderInput carries path, query and header fields without json:"-", +// so the JSON decoder (which matches keys case-insensitively) can reach them. +type bindingOrderInput struct { + ID string `uri:"id" validate:"required"` + Filter string `query:"filter"` + Tenant string `header:"X-Tenant"` + Name string `json:"name"` +} + +type bindingOrderOutput struct { + ID string `json:"id"` + Filter string `json:"filter"` + Tenant string `json:"tenant"` + Name string `json:"name"` +} + +func TestBindingOrder_BodyCannotOverrideRequestParameters(t *testing.T) { + app := fiber.New() + oapi := New(app) + Put(oapi, "/items/:id", func(c fiber.Ctx, in bindingOrderInput) (bindingOrderOutput, struct{}) { + return bindingOrderOutput(in), struct{}{} + }, OpenAPIOptions{}) + + body := `{"id":"from-body","filter":"from-body","tenant":"from-body","name":"widget"}` + req := httptest.NewRequest("PUT", "/items/from-path?filter=from-query", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Tenant", "from-header") + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, 200, resp.StatusCode) + + raw, _ := io.ReadAll(resp.Body) + var out bindingOrderOutput + require.NoError(t, json.Unmarshal(raw, &out)) + assert.Equal(t, "from-path", out.ID, "the path parameter must win over a body key with the same name") + assert.Equal(t, "from-query", out.Filter, "the query parameter must win over the body") + assert.Equal(t, "from-header", out.Tenant, "the header must win over the body") + assert.Equal(t, "widget", out.Name, "regular body fields are still bound") +} + +func TestBindingOrder_BodySchemaExcludesPathParameters(t *testing.T) { + app := fiber.New() + oapi := New(app) + Put(oapi, "/items/:id", func(c fiber.Ctx, in bindingOrderInput) (bindingOrderOutput, struct{}) { + return bindingOrderOutput(in), struct{}{} + }, OpenAPIOptions{}) + + spec := oapi.GenerateOpenAPISpec() + schema := spec["components"].(map[string]any)["schemas"].(map[string]any)["bindingOrderInput"].(map[string]any) + props := schema["properties"].(map[string]any) + assert.NotContains(t, props, "ID", "uri fields are path parameters, not body properties") + assert.NotContains(t, props, "Filter") + assert.NotContains(t, props, "Tenant") + assert.Contains(t, props, "name") + assert.NotContains(t, schema, "required", "the only required field is the path parameter") +} diff --git a/common.go b/common.go index 464c7e6..84f8bc4 100644 --- a/common.go +++ b/common.go @@ -49,19 +49,11 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope shape := shapeFor[TInput]() - if shape.isStruct { - if err := c.Bind().URI(&input); err != nil { - return input, err - } - if err := c.Bind().Query(&input); err != nil { - return input, err - } - } - // Parse body for POST/PUT/PATCH methods only if there's content. - // Body is parsed before headers so that header values take priority over any - // field that the JSON decoder may have populated (e.g. when a header-bound - // field is also sent in the body without a json:"-" tag). + // The body is parsed first so that path, query and header values take + // priority over any field the JSON decoder may have populated. The decoder + // matches keys case-insensitively, so a field tagged uri:"id" without + // json:"-" would otherwise be overwritten by {"id": "..."} in the body. method := c.Method() if method == "POST" || method == "PUT" || method == "PATCH" { bodyLength := len(c.Body()) @@ -84,6 +76,12 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope } if shape.isStruct { + if err := c.Bind().URI(&input); err != nil { + return input, err + } + if err := c.Bind().Query(&input); err != nil { + return input, err + } if err := c.Bind().Header(&input); err != nil { return input, err } diff --git a/fiberoapi.go b/fiberoapi.go index bef0af3..4ed8d74 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -827,7 +827,12 @@ func generateSchema(t reflect.Type) map[string]interface{} { } // Skip fields that are path, query, or header parameters - they are handled separately - if field.Tag.Get("path") != "" || field.Tag.Get("query") != "" || field.Tag.Get("header") != "" { + if field.Tag.Get("path") != "" || field.Tag.Get("uri") != "" || field.Tag.Get("query") != "" || field.Tag.Get("header") != "" { + continue + } + + // Skip the raw body field - the request body is documented as binary + if field.Tag.Get("body") == "raw" { continue } From b557718472679240e0a56824201e7b1b3f8909c7 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:13:08 +0200 Subject: [PATCH 02/12] feat: keep the cause of authentication errors AuthError gains an Err field and an Unwrap method, so an AuthErrorHandler can use errors.Is / errors.As on the error returned by ValidateToken (expired token, revoked device...) instead of comparing messages. When that cause implements HTTPStatusError, its status replaces the default 401. MultiSchemeAuthMiddleware and SmartAuthMiddleware now go through Config.AuthErrorHandler when one is set; without it they keep their legacy body and status. Co-Authored-By: Claude Opus 5.5 --- auth.go | 36 +++----- auth_error_cause_test.go | 183 +++++++++++++++++++++++++++++++++++++++ auth_schemes.go | 32 +++++++ conditional_auth.go | 19 +++- 4 files changed, 243 insertions(+), 27 deletions(-) create mode 100644 auth_error_cause_test.go diff --git a/auth.go b/auth.go index f8a9423..474f5d8 100644 --- a/auth.go +++ b/auth.go @@ -88,16 +88,22 @@ func RequireResourceAccess(c fiber.Ctx, authService AuthorizationService, resour // BearerTokenMiddleware creates a JWT/Bearer middleware func BearerTokenMiddleware(validator AuthorizationService) fiber.Handler { + return bearerTokenMiddleware(validator, nil) +} + +// bearerTokenMiddleware is BearerTokenMiddleware with an optional Config whose +// error handling it follows (see authFailure). +func bearerTokenMiddleware(validator AuthorizationService, config *Config) fiber.Handler { return func(c fiber.Ctx) error { authHeader := c.Get("Authorization") if authHeader == "" { - return c.Status(401).JSON(fiber.Map{ + return authFailure(c, config, &AuthError{StatusCode: 401, Message: "Authorization header required"}, 401, fiber.Map{ "error": "Authorization header required", }) } if !strings.HasPrefix(authHeader, "Bearer ") { - return c.Status(401).JSON(fiber.Map{ + return authFailure(c, config, &AuthError{StatusCode: 401, Message: "Bearer token required"}, 401, fiber.Map{ "error": "Bearer token required", }) } @@ -105,7 +111,7 @@ func BearerTokenMiddleware(validator AuthorizationService) fiber.Handler { token := strings.TrimPrefix(authHeader, "Bearer ") authCtx, err := validator.ValidateToken(token) if err != nil { - return c.Status(401).JSON(fiber.Map{ + return authFailure(c, config, authErrorFrom(err), 401, fiber.Map{ "error": "Invalid token", "details": err.Error(), }) @@ -156,16 +162,8 @@ func validateAuthorization(c fiber.Ctx, input interface{}, authService Authoriza authCtx, err := validateBearerToken(c, authService) if err != nil { // Preserve typed AuthError (including 5xx) and map ScopeError to 403, - // falling back to 401 only for untyped errors. - var authErr *AuthError - if errors.As(err, &authErr) { - return err - } - var scopeErr *ScopeError - if errors.As(err, &scopeErr) { - return &AuthError{StatusCode: 403, Message: err.Error()} - } - return &AuthError{StatusCode: 401, Message: err.Error()} + // falling back to 401 only for untyped errors. The cause is kept. + return authErrorFrom(err) } c.Locals("auth", authCtx) @@ -205,15 +203,7 @@ func validateAuthorization(c fiber.Ctx, input interface{}, authService Authoriza } // Propagate typed errors (AuthError, ScopeError) without re-wrapping - var existingAuthErr *AuthError - if errors.As(lastErr, &existingAuthErr) { - return lastErr - } - var scopeErr *ScopeError - if errors.As(lastErr, &scopeErr) { - return &AuthError{StatusCode: 403, Message: lastErr.Error()} - } - return &AuthError{StatusCode: 401, Message: lastErr.Error()} + return authErrorFrom(lastErr) } // checkRequiredRoles checks the authenticated user's roles against the required roles. @@ -270,7 +260,7 @@ func validateResourceAccess(c fiber.Ctx, authCtx *AuthContext, input interface{} canAccess, err := authService.CanAccessResource(authCtx, resourceTag, resourceID, actionTag) if err != nil { - return &AuthError{StatusCode: 500, Message: fmt.Sprintf("authorization check failed: %v", err)} + return &AuthError{StatusCode: 500, Message: fmt.Sprintf("authorization check failed: %v", err), Err: err} } if !canAccess { diff --git a/auth_error_cause_test.go b/auth_error_cause_test.go new file mode 100644 index 0000000..669c056 --- /dev/null +++ b/auth_error_cause_test.go @@ -0,0 +1,183 @@ +package fiberoapi + +import ( + "encoding/json" + "errors" + "io" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var errTokenExpired = errors.New("token expired") + +// deviceRevokedError carries data the AuthErrorHandler can extract with +// errors.As, and its own HTTP status. +type deviceRevokedError struct{ DeviceID string } + +func (e *deviceRevokedError) Error() string { return "device " + e.DeviceID + " revoked" } +func (e *deviceRevokedError) HTTPStatus() int { return fiber.StatusForbidden } + +// causeAuthService maps a few well-known tokens onto typed failures. +type causeAuthService struct{} + +func (causeAuthService) ValidateToken(token string) (*AuthContext, error) { + switch token { + case "expired": + return nil, errTokenExpired + case "revoked": + return nil, &deviceRevokedError{DeviceID: "d-42"} + case "ok": + return &AuthContext{UserID: "u-1"}, nil + } + return nil, errors.New("unknown token") +} +func (causeAuthService) HasRole(*AuthContext, string) bool { return false } +func (causeAuthService) HasScope(*AuthContext, string) bool { return false } +func (causeAuthService) CanAccessResource(*AuthContext, string, string, string) (bool, error) { + return false, nil +} +func (causeAuthService) GetUserPermissions(*AuthContext, string, string) (*ResourcePermission, error) { + return nil, nil +} + +// classifyingAuthErrorHandler answers with a stable code derived from the cause. +func classifyingAuthErrorHandler(c fiber.Ctx, err *AuthError) error { + code := "unauthorized" + var revoked *deviceRevokedError + switch { + case errors.Is(err, errTokenExpired): + code = "token_expired" + case errors.As(err, &revoked): + code = "device_revoked:" + revoked.DeviceID + } + return c.Status(err.StatusCode).JSON(fiber.Map{"code": code}) +} + +func doBearer(t *testing.T, app *fiber.App, path, token string) (int, map[string]any) { + t.Helper() + req := httptest.NewRequest("GET", path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := app.Test(req) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + var body map[string]any + _ = json.Unmarshal(raw, &body) + return resp.StatusCode, body +} + +func TestAuthErrorCause_ReachableFromAuthErrorHandler(t *testing.T) { + for _, tc := range []struct { + name string + schemes map[string]SecurityScheme + }{ + {name: "bearer only", schemes: nil}, + {name: "security schemes", schemes: map[string]SecurityScheme{ + "bearerAuth": {Type: "http", Scheme: "bearer"}, + }}, + } { + t.Run(tc.name, func(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: causeAuthService{}, + SecuritySchemes: tc.schemes, + AuthErrorHandler: classifyingAuthErrorHandler, + }) + Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{}) + + status, body := doBearer(t, app, "/me", "expired") + assert.Equal(t, 401, status) + assert.Equal(t, "token_expired", body["code"]) + + status, body = doBearer(t, app, "/me", "revoked") + assert.Equal(t, 403, status, "the status comes from the cause's HTTPStatus()") + assert.Equal(t, "device_revoked:d-42", body["code"]) + + status, _ = doBearer(t, app, "/me", "ok") + assert.Equal(t, 200, status) + }) + } +} + +func TestAuthErrorCause_StatusFromHTTPStatusInDefaultEnvelope(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: causeAuthService{}, + }) + Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{}) + + status, body := doBearer(t, app, "/me", "revoked") + assert.Equal(t, 403, status) + entry := body["errors"].([]any)[0].(map[string]any) + assert.Equal(t, errTypeAuthZ, entry["type"]) + assert.Equal(t, "device d-42 revoked", entry["msg"]) +} + +func TestAuthErrorFrom(t *testing.T) { + authErr := authErrorFrom(errTokenExpired) + assert.Equal(t, 401, authErr.StatusCode) + assert.ErrorIs(t, authErr, errTokenExpired) + + authErr = authErrorFrom(&ScopeError{Scope: "write"}) + assert.Equal(t, 403, authErr.StatusCode) + _, ok := errors.AsType[*ScopeError](authErr) + assert.True(t, ok) + + typed := &AuthError{StatusCode: 500, Message: "misconfigured"} + assert.Same(t, typed, authErrorFrom(typed), "a typed AuthError is returned as-is") +} + +func TestAuthMiddlewares_HonourAuthErrorHandler(t *testing.T) { + multi := Config{ + SecuritySchemes: map[string]SecurityScheme{"bearerAuth": {Type: "http", Scheme: "bearer"}}, + AuthErrorHandler: classifyingAuthErrorHandler, + } + bearerOnly := Config{AuthErrorHandler: classifyingAuthErrorHandler} + + for name, mw := range map[string]fiber.Handler{ + "MultiSchemeAuthMiddleware": MultiSchemeAuthMiddleware(causeAuthService{}, multi), + "SmartAuthMiddleware multi-scheme": SmartAuthMiddleware(causeAuthService{}, multi), + "SmartAuthMiddleware bearer only": SmartAuthMiddleware(causeAuthService{}, bearerOnly), + } { + t.Run(name, func(t *testing.T) { + app := fiber.New() + app.Use(mw) + app.Get("/me", func(c fiber.Ctx) error { return c.SendString("ok") }) + + status, body := doBearer(t, app, "/me", "expired") + assert.Equal(t, 401, status) + assert.Equal(t, "token_expired", body["code"]) + + status, body = doBearer(t, app, "/me", "revoked") + assert.Equal(t, 403, status) + assert.Equal(t, "device_revoked:d-42", body["code"]) + + status, body = doBearer(t, app, "/me", "") + assert.Equal(t, 401, status) + assert.Equal(t, "unauthorized", body["code"]) + }) + } +} + +func TestAuthMiddlewares_LegacyBodyWithoutHandler(t *testing.T) { + app := fiber.New() + app.Use(SmartAuthMiddleware(causeAuthService{}, Config{})) + app.Get("/me", func(c fiber.Ctx) error { return c.SendString("ok") }) + + status, body := doBearer(t, app, "/me", "revoked") + assert.Equal(t, 401, status, "without a handler the legacy status is kept") + assert.Equal(t, "Invalid token", body["error"]) +} diff --git a/auth_schemes.go b/auth_schemes.go index 5467a14..65f5762 100644 --- a/auth_schemes.go +++ b/auth_schemes.go @@ -2,6 +2,7 @@ package fiberoapi import ( "encoding/base64" + "errors" "fmt" "sort" "strings" @@ -221,12 +222,43 @@ func validateWithScheme(c fiber.Ctx, scheme SecurityScheme, authService Authoriz type AuthError struct { StatusCode int Message string + // Err is the underlying cause, typically the error returned by + // ValidateToken (or another scheme validator). It is reachable through + // errors.Is / errors.As, so an AuthErrorHandler can tell an expired token + // from a revoked one without comparing messages. + Err error } func (e *AuthError) Error() string { return e.Message } +// Unwrap returns the underlying cause. +func (e *AuthError) Unwrap() error { + return e.Err +} + +// authErrorFrom converts an error returned while authenticating into an +// AuthError, keeping the original as its cause. A typed AuthError is returned +// as-is. The status defaults to 401, or 403 for a ScopeError; a cause that +// implements HTTPStatusError overrides both. +func authErrorFrom(err error) *AuthError { + if authErr, ok := errors.AsType[*AuthError](err); ok { + return authErr + } + status := fiber.StatusUnauthorized + if _, ok := errors.AsType[*ScopeError](err); ok { + status = fiber.StatusForbidden + } + var withStatus HTTPStatusError + if errors.As(err, &withStatus) { + if s := withStatus.HTTPStatus(); s > 0 { + status = s + } + } + return &AuthError{StatusCode: status, Message: err.Error(), Err: err} +} + // ScopeError represents an authorization failure due to missing scopes (403, not 401). type ScopeError struct { Scope string diff --git a/conditional_auth.go b/conditional_auth.go index 93c05e2..ff011e3 100644 --- a/conditional_auth.go +++ b/conditional_auth.go @@ -32,7 +32,7 @@ func SmartAuthMiddleware(authService AuthorizationService, config Config) fiber. if len(config.SecuritySchemes) > 0 { authMiddleware = MultiSchemeAuthMiddleware(authService, config) } else { - authMiddleware = BearerTokenMiddleware(authService) + authMiddleware = bearerTokenMiddleware(authService, &config) } // Paths to exclude from authentication @@ -66,7 +66,7 @@ func MultiSchemeAuthMiddleware(authService AuthorizationService, config Config) } var authErr *AuthError if errors.As(err, &authErr) && authErr.StatusCode >= 500 { - return c.Status(authErr.StatusCode).JSON(fiber.Map{ + return authFailure(c, &config, authErr, authErr.StatusCode, fiber.Map{ "error": "Server configuration error", "details": authErr.Message, }) @@ -77,7 +77,8 @@ func MultiSchemeAuthMiddleware(authService AuthorizationService, config Config) if lastErr == nil { // No security requirements were configured — this is a server misconfiguration, // not a client authentication failure. - return c.Status(500).JSON(fiber.Map{ + authErr := &AuthError{StatusCode: 500, Message: "no security schemes configured"} + return authFailure(c, &config, authErr, 500, fiber.Map{ "error": "Server configuration error", "details": "no security schemes configured", }) @@ -91,13 +92,23 @@ func MultiSchemeAuthMiddleware(authService AuthorizationService, config Config) errorLabel = "Authorization failed" } - return c.Status(status).JSON(fiber.Map{ + return authFailure(c, &config, authErrorFrom(lastErr), status, fiber.Map{ "error": errorLabel, "details": lastErr.Error(), }) } } +// authFailure lets a middleware built from a Config honour its +// AuthErrorHandler: when one is set it receives the AuthError, otherwise the +// legacy body is written with the legacy status. +func authFailure(c fiber.Ctx, config *Config, authErr *AuthError, status int, body fiber.Map) error { + if config != nil && config.AuthErrorHandler != nil { + return config.AuthErrorHandler(c, authErr) + } + return c.Status(status).JSON(body) +} + // BasicAuthMiddleware creates a standalone middleware for HTTP Basic authentication. // The authService must implement the BasicAuthValidator interface. func BasicAuthMiddleware(validator AuthorizationService) fiber.Handler { From b1aab8b7cd9711c75429e7e99b59081f06682152 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:13:51 +0200 Subject: [PATCH 03/12] feat: add TokenValidatorFunc and ContextTokenValidator TokenValidatorFunc turns a plain func(token) (*AuthContext, error) into an AuthorizationService. Roles and scopes are read from the returned AuthContext; resource permissions fail with ErrResourceAuthorizationNotConfigured, so such a route answers 500 and never grants access by default. A service that needs the request to validate a Bearer token can implement ContextTokenValidator; ValidateTokenCtx is then preferred over ValidateToken, including in BearerTokenMiddleware. Co-Authored-By: Claude Opus 5.5 --- auth.go | 2 +- auth_error_cause_test.go | 13 ++-- auth_schemes.go | 2 +- token_validator.go | 59 ++++++++++++++++++ token_validator_test.go | 130 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 200 insertions(+), 6 deletions(-) create mode 100644 token_validator.go create mode 100644 token_validator_test.go diff --git a/auth.go b/auth.go index 474f5d8..0c1771f 100644 --- a/auth.go +++ b/auth.go @@ -109,7 +109,7 @@ func bearerTokenMiddleware(validator AuthorizationService, config *Config) fiber } token := strings.TrimPrefix(authHeader, "Bearer ") - authCtx, err := validator.ValidateToken(token) + authCtx, err := validateToken(c, validator, token) if err != nil { return authFailure(c, config, authErrorFrom(err), 401, fiber.Map{ "error": "Invalid token", diff --git a/auth_error_cause_test.go b/auth_error_cause_test.go index 669c056..e7ccd4f 100644 --- a/auth_error_cause_test.go +++ b/auth_error_cause_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "io" + "net/http" "net/http/httptest" "testing" @@ -57,13 +58,17 @@ func classifyingAuthErrorHandler(c fiber.Ctx, err *AuthError) error { return c.Status(err.StatusCode).JSON(fiber.Map{"code": code}) } -func doBearer(t *testing.T, app *fiber.App, path, token string) (int, map[string]any) { - t.Helper() - req := httptest.NewRequest("GET", path, nil) +func newBearerRequest(method, path, token string) *http.Request { + req := httptest.NewRequest(method, path, nil) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } - resp, err := app.Test(req) + return req +} + +func doBearer(t *testing.T, app *fiber.App, path, token string) (int, map[string]any) { + t.Helper() + resp, err := app.Test(newBearerRequest("GET", path, token)) require.NoError(t, err) raw, _ := io.ReadAll(resp.Body) var body map[string]any diff --git a/auth_schemes.go b/auth_schemes.go index 65f5762..110bf66 100644 --- a/auth_schemes.go +++ b/auth_schemes.go @@ -68,7 +68,7 @@ func validateBearerToken(c fiber.Ctx, authService AuthorizationService) (*AuthCo } token := strings.TrimPrefix(authHeader, "Bearer ") - return authService.ValidateToken(token) + return validateToken(c, authService, token) } // validateBasicAuth validates Basic Auth credentials from the Authorization header. diff --git a/token_validator.go b/token_validator.go new file mode 100644 index 0000000..ef29c49 --- /dev/null +++ b/token_validator.go @@ -0,0 +1,59 @@ +package fiberoapi + +import ( + "errors" + "slices" + + "github.com/gofiber/fiber/v3" +) + +// ErrResourceAuthorizationNotConfigured is returned by TokenValidatorFunc when +// a route asks for resource permissions: the adapter only validates tokens, so +// such a route is a configuration error (500), never an access granted by default. +var ErrResourceAuthorizationNotConfigured = errors.New("resource authorization is not configured: the AuthService only validates tokens") + +// TokenValidatorFunc adapts a plain token validation function into an +// AuthorizationService, for applications that only need to authenticate. +// +// Roles and scopes are read from the AuthContext the function returns. +// Resource permissions are not supported: a route that needs them answers 500. +type TokenValidatorFunc func(token string) (*AuthContext, error) + +// ValidateToken calls f. +func (f TokenValidatorFunc) ValidateToken(token string) (*AuthContext, error) { return f(token) } + +// HasRole reports whether the AuthContext returned by the validator carries role. +func (TokenValidatorFunc) HasRole(ctx *AuthContext, role string) bool { + return ctx != nil && slices.Contains(ctx.Roles, role) +} + +// HasScope reports whether the AuthContext returned by the validator carries scope. +func (TokenValidatorFunc) HasScope(ctx *AuthContext, scope string) bool { + return ctx != nil && slices.Contains(ctx.Scopes, scope) +} + +// CanAccessResource always fails with ErrResourceAuthorizationNotConfigured. +func (TokenValidatorFunc) CanAccessResource(*AuthContext, string, string, string) (bool, error) { + return false, ErrResourceAuthorizationNotConfigured +} + +// GetUserPermissions always fails with ErrResourceAuthorizationNotConfigured. +func (TokenValidatorFunc) GetUserPermissions(*AuthContext, string, string) (*ResourcePermission, error) { + return nil, ErrResourceAuthorizationNotConfigured +} + +// ContextTokenValidator is an optional interface for an AuthorizationService +// that needs the request to validate a Bearer token (client IP, request +// context, ...). When implemented, it is used instead of ValidateToken. +type ContextTokenValidator interface { + ValidateTokenCtx(c fiber.Ctx, token string) (*AuthContext, error) +} + +// validateToken validates a Bearer token, preferring ValidateTokenCtx when the +// service implements ContextTokenValidator. +func validateToken(c fiber.Ctx, authService AuthorizationService, token string) (*AuthContext, error) { + if ctxValidator, ok := authService.(ContextTokenValidator); ok { + return ctxValidator.ValidateTokenCtx(c, token) + } + return authService.ValidateToken(token) +} diff --git a/token_validator_test.go b/token_validator_test.go new file mode 100644 index 0000000..f05b329 --- /dev/null +++ b/token_validator_test.go @@ -0,0 +1,130 @@ +package fiberoapi + +import ( + "errors" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" +) + +func newTokenValidatorApp(t *testing.T, svc AuthorizationService) (*fiber.App, *OApiApp) { + t.Helper() + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: svc, + }) + return app, oapi +} + +var staticTokens = TokenValidatorFunc(func(token string) (*AuthContext, error) { + switch token { + case "admin": + return &AuthContext{UserID: "u-admin", Roles: []string{"admin"}, Scopes: []string{"write"}}, nil + case "user": + return &AuthContext{UserID: "u-user", Roles: []string{"user"}}, nil + } + return nil, errTokenExpired +}) + +func TestTokenValidatorFunc_AuthenticatesRequests(t *testing.T) { + app, oapi := newTokenValidatorApp(t, staticTokens) + Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + authCtx, err := GetAuthContext(c) + if err != nil { + return fiber.Map{"user": ""}, struct{}{} + } + return fiber.Map{"user": authCtx.UserID}, struct{}{} + }, OpenAPIOptions{}) + + status, body := doBearer(t, app, "/me", "user") + assert.Equal(t, 200, status) + assert.Equal(t, "u-user", body["user"]) + + status, _ = doBearer(t, app, "/me", "nope") + assert.Equal(t, 401, status) +} + +func TestTokenValidatorFunc_RolesComeFromAuthContext(t *testing.T) { + app, oapi := newTokenValidatorApp(t, staticTokens) + Get(oapi, "/admin", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, WithRoles(OpenAPIOptions{}, "admin")) + + status, _ := doBearer(t, app, "/admin", "admin") + assert.Equal(t, 200, status) + status, _ = doBearer(t, app, "/admin", "user") + assert.Equal(t, 403, status) + + assert.True(t, staticTokens.HasScope(&AuthContext{Scopes: []string{"write"}}, "write")) + assert.False(t, staticTokens.HasScope(nil, "write")) +} + +type tokenValidatorDocInput struct { + DocID string `uri:"docId" resource:"document" action:"read"` +} + +func TestTokenValidatorFunc_ResourceRouteIsAConfigurationError(t *testing.T) { + app, oapi := newTokenValidatorApp(t, staticTokens) + Get(oapi, "/documents/:docId", func(c fiber.Ctx, in tokenValidatorDocInput) (fiber.Map, struct{}) { + return fiber.Map{"id": in.DocID}, struct{}{} + }, OpenAPIOptions{}) + + status, _ := doBearer(t, app, "/documents/doc-1", "admin") + assert.Equal(t, 500, status, "a resource check with a token-only validator must never grant access") + + _, err := staticTokens.GetUserPermissions(nil, "document", "doc-1") + assert.ErrorIs(t, err, ErrResourceAuthorizationNotConfigured) +} + +// ctxTokenService rejects every token through ValidateToken; only +// ValidateTokenCtx, which reads the request, can accept one. +type ctxTokenService struct{ causeAuthService } + +func (ctxTokenService) ValidateToken(string) (*AuthContext, error) { + return nil, errors.New("ValidateToken must not be called") +} + +func (ctxTokenService) ValidateTokenCtx(c fiber.Ctx, token string) (*AuthContext, error) { + if token == "ok" && c.Get("X-Device") == "d-1" { + return &AuthContext{UserID: "u-1"}, nil + } + return nil, errTokenExpired +} + +func TestContextTokenValidator_IsPreferred(t *testing.T) { + for name, schemes := range map[string]map[string]SecurityScheme{ + "bearer only": nil, + "security schemes": {"bearerAuth": {Type: "http", Scheme: "bearer"}}, + } { + t.Run(name, func(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: ctxTokenService{}, + SecuritySchemes: schemes, + }) + Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{}) + + req := newBearerRequest("GET", "/me", "ok") + req.Header.Set("X-Device", "d-1") + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + }) + } + + app := fiber.New() + app.Use(BearerTokenMiddleware(ctxTokenService{})) + app.Get("/me", func(c fiber.Ctx) error { return c.SendString("ok") }) + req := newBearerRequest("GET", "/me", "ok") + req.Header.Set("X-Device", "d-1") + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode, "BearerTokenMiddleware prefers ValidateTokenCtx too") +} From e7bbc9008936c53445c02b24d7d0000e954a0afc Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:16:35 +0200 Subject: [PATCH 04/12] feat: add AuthenticateFirst and per-route middlewares Config.AuthenticateFirst places an authentication handler at the head of every protected route: a request without valid credentials is rejected before its body is decoded, and route middlewares can call GetAuthContext. Roles and resource permissions are still checked after the input is parsed. validateAuthorization is split into authenticate and authorize to make this possible; the default flow is unchanged. OpenAPIOptions.Middlewares registers handlers that run before the input is read, after the group middlewares and the authentication handler. Errors raised before the handler go through a single respondError, used by both the authentication handler and the route wrapper. Protected routes now document 401, and 403 when they check roles, scopes or resource permissions. Co-Authored-By: Claude Opus 5.5 --- auth.go | 80 +++++++++---- auth_first_test.go | 232 ++++++++++++++++++++++++++++++++++++++ common.go | 26 +++-- error_response.go | 24 ++++ fiberoapi.go | 157 ++++++++++++++++++++++---- route_middlewares_test.go | 114 +++++++++++++++++++ types.go | 15 +++ 7 files changed, 592 insertions(+), 56 deletions(-) create mode 100644 auth_first_test.go create mode 100644 route_middlewares_test.go diff --git a/auth.go b/auth.go index 0c1771f..8782772 100644 --- a/auth.go +++ b/auth.go @@ -146,36 +146,48 @@ func RoleGuard(validator AuthorizationService, requiredRoles ...string) fiber.Ha } } -// validateAuthorization validates permissions based on configured security schemes. +// validateAuthorization authenticates the request, stores the AuthContext in +// c.Locals("auth"), then checks roles and resource permissions. routeSecurity, +// when non-empty, replaces Config.DefaultSecurity for this route. +func validateAuthorization(c fiber.Ctx, input interface{}, config *Config, routeSecurity []map[string][]string, requiredRoles []string, requireAllRoles bool) error { + authCtx, err := authenticate(c, config, routeSecurity) + if err != nil { + return err + } + if authCtx != nil { + c.Locals("auth", authCtx) + } + return authorize(c, authCtx, input, config.AuthService, requiredRoles, requireAllRoles) +} + +// authenticate validates the credentials of the request against the +// configured security schemes and returns the resulting AuthContext. It does +// not need the request input, so it can run before the body is read. +// It returns (nil, nil) when no AuthService is configured. // When SecuritySchemes is empty, it falls back to Bearer-only validation for backward compatibility. -func validateAuthorization(c fiber.Ctx, input interface{}, authService AuthorizationService, config *Config, requiredRoles []string, requireAllRoles bool) error { +func authenticate(c fiber.Ctx, config *Config, routeSecurity []map[string][]string) (*AuthContext, error) { + authService := config.AuthService if authService == nil { - if len(requiredRoles) > 0 { - return &AuthError{StatusCode: 500, Message: "authorization service not configured"} - } - return nil + return nil, nil } // Backward compatibility: if no SecuritySchemes are configured, // fall back to Bearer-only validation (original behavior). - if config == nil || len(config.SecuritySchemes) == 0 { + if len(config.SecuritySchemes) == 0 { authCtx, err := validateBearerToken(c, authService) if err != nil { // Preserve typed AuthError (including 5xx) and map ScopeError to 403, // falling back to 401 only for untyped errors. The cause is kept. - return authErrorFrom(err) + return nil, authErrorFrom(err) } - c.Locals("auth", authCtx) - - // Check roles before resource access - if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil { - return err - } - return validateResourceAccess(c, authCtx, input, authService) + return authCtx, nil } // Multi-scheme validation path - securityReqs := config.DefaultSecurity + securityReqs := routeSecurity + if len(securityReqs) == 0 { + securityReqs = config.DefaultSecurity + } if len(securityReqs) == 0 { securityReqs = buildDefaultFromSchemes(config.SecuritySchemes) } @@ -187,23 +199,41 @@ func validateAuthorization(c fiber.Ctx, input interface{}, authService Authoriza for _, requirement := range securityReqs { authCtx, err := validateSecurityRequirement(c, requirement, config.SecuritySchemes, authService) if err == nil { - c.Locals("auth", authCtx) - - // Check roles before resource access - if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil { - return err - } - return validateResourceAccess(c, authCtx, input, authService) + return authCtx, nil } var authErr *AuthError if errors.As(err, &authErr) && authErr.StatusCode >= 500 { - return err + return nil, err } lastErr = err } + if lastErr == nil { + return nil, &AuthError{StatusCode: 500, Message: "no security requirements configured"} + } // Propagate typed errors (AuthError, ScopeError) without re-wrapping - return authErrorFrom(lastErr) + return nil, authErrorFrom(lastErr) +} + +// authorize checks the required roles of the route, then the resource +// permissions declared on the input (resource / action tags). It needs the +// parsed input, so it always runs after the request has been read. +func authorize(c fiber.Ctx, authCtx *AuthContext, input interface{}, authService AuthorizationService, requiredRoles []string, requireAllRoles bool) error { + if authService == nil { + if len(requiredRoles) > 0 { + return &AuthError{StatusCode: 500, Message: "authorization service not configured"} + } + return nil + } + if authCtx == nil { + return &AuthError{StatusCode: 401, Message: "authentication required"} + } + + // Check roles before resource access + if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil { + return err + } + return validateResourceAccess(c, authCtx, input, authService) } // checkRequiredRoles checks the authenticated user's roles against the required roles. diff --git a/auth_first_test.go b/auth_first_test.go new file mode 100644 index 0000000..54e281c --- /dev/null +++ b/auth_first_test.go @@ -0,0 +1,232 @@ +package fiberoapi + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type authFirstBody struct { + Name string `json:"name" validate:"required,min=3"` +} + +// newSpyApp returns a Fiber app whose JSON decoder counts its calls, so a +// test can tell whether the request body was read. +func newSpyApp() (*fiber.App, *atomic.Int32) { + var decodes atomic.Int32 + app := fiber.New(fiber.Config{ + JSONDecoder: func(data []byte, v any) error { + decodes.Add(1) + return json.Unmarshal(data, v) + }, + }) + return app, &decodes +} + +func postJSON(t *testing.T, app *fiber.App, path, token, body string) int { + t.Helper() + req := httptest.NewRequest("POST", path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := app.Test(req) + require.NoError(t, err) + return resp.StatusCode +} + +func registerAuthFirstRoute(oapi *OApiApp) { + Post(oapi, "/items", func(c fiber.Ctx, in authFirstBody) (fiber.Map, struct{}) { + return fiber.Map{"name": in.Name}, struct{}{} + }, OpenAPIOptions{}) +} + +func TestAuthenticateFirst_RejectsBeforeReadingTheBody(t *testing.T) { + app, decodes := newSpyApp() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: NewMockAuthService(), + }) + registerAuthFirstRoute(oapi) + + assert.Equal(t, 401, postJSON(t, app, "/items", "", `{"name": 42`)) + assert.Equal(t, 401, postJSON(t, app, "/items", "bad-token", `{"name": "x"}`)) + assert.Zero(t, decodes.Load(), "the body must not be decoded for an unauthenticated request") + + assert.Equal(t, 422, postJSON(t, app, "/items", "valid-token", `{"name": "x"}`)) + assert.Equal(t, 200, postJSON(t, app, "/items", "valid-token", `{"name": "widget"}`)) + assert.Equal(t, int32(2), decodes.Load()) +} + +func TestAuthenticateFirst_DisabledKeepsTheV32Order(t *testing.T) { + cases := []struct { + name string + token, body string + legacy, authFirst int + }{ + {name: "malformed body, no token", body: `{"name": 42`, legacy: 400, authFirst: 401}, + {name: "invalid body, no token", body: `{"name": "x"}`, legacy: 422, authFirst: 401}, + {name: "valid body, no token", body: `{"name": "widget"}`, legacy: 401, authFirst: 401}, + {name: "valid body, valid token", token: "valid-token", body: `{"name": "widget"}`, legacy: 200, authFirst: 200}, + } + for _, authFirst := range []bool{false, true} { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: authFirst, + AuthService: NewMockAuthService(), + }) + registerAuthFirstRoute(oapi) + for _, tc := range cases { + want := tc.legacy + if authFirst { + want = tc.authFirst + } + assert.Equal(t, want, postJSON(t, app, "/items", tc.token, tc.body), "%s (AuthenticateFirst=%v)", tc.name, authFirst) + } + } +} + +func TestAuthenticateFirst_RouteMiddlewareSeesTheAuthContext(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: NewMockAuthService(), + }) + var seen string + Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{Middlewares: []fiber.Handler{func(c fiber.Ctx) error { + authCtx, err := GetAuthContext(c) + if err != nil { + return err + } + seen = authCtx.UserID + return c.Next() + }}}) + + status, _ := doBearer(t, app, "/me", "valid-token") + assert.Equal(t, 200, status) + assert.Equal(t, "user-123", seen) +} + +type authFirstDocInput struct { + DocID string `uri:"docId" resource:"document" action:"delete"` +} + +func TestAuthenticateFirst_RolesAndResourcesStillApply(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: NewMockAuthService(), + }) + Get(oapi, "/admin", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, WithRoles(OpenAPIOptions{}, "admin")) + Delete(oapi, "/documents/:docId", func(c fiber.Ctx, in authFirstDocInput) (fiber.Map, struct{}) { + return fiber.Map{"deleted": in.DocID}, struct{}{} + }, OpenAPIOptions{}) + + status, _ := doBearer(t, app, "/admin", "valid-token") + assert.Equal(t, 403, status) + status, _ = doBearer(t, app, "/admin", "admin-token") + assert.Equal(t, 200, status) + + del := func(token string) int { + resp, err := app.Test(newBearerRequest("DELETE", "/documents/doc-1", token)) + require.NoError(t, err) + return resp.StatusCode + } + assert.Equal(t, 401, del("")) + assert.Equal(t, 403, del("valid-token")) + assert.Equal(t, 200, del("admin-token")) +} + +func TestAuthenticateFirst_DisabledRouteSkipsAuthentication(t *testing.T) { + var validations atomic.Int32 + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: TokenValidatorFunc(func(string) (*AuthContext, error) { + validations.Add(1) + return nil, errTokenExpired + }), + }) + Get(oapi, "/health", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{Security: "disabled"}) + + status, _ := doBearer(t, app, "/health", "whatever") + assert.Equal(t, 200, status, "a public route must not reject an invalid token") + assert.Zero(t, validations.Load()) +} + +func TestAuthenticateFirst_UsesTheErrorHandlers(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: causeAuthService{}, + AuthErrorHandler: classifyingAuthErrorHandler, + }) + Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{}) + + status, body := doBearer(t, app, "/me", "expired") + assert.Equal(t, 401, status) + assert.Equal(t, "token_expired", body["code"]) +} + +func TestSpec_ProtectedRoutesDocumentAuthErrors(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: NewMockAuthService(), + }) + handler := func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { return fiber.Map{}, struct{}{} } + Get(oapi, "/me", handler, OpenAPIOptions{}) + Get(oapi, "/admin", handler, WithRoles(OpenAPIOptions{}, "admin")) + Get(oapi, "/public", handler, OpenAPIOptions{Security: "disabled"}) + Get(oapi, "/documents/:docId", func(c fiber.Ctx, _ authFirstDocInput) (fiber.Map, struct{}) { + return fiber.Map{}, struct{}{} + }, OpenAPIOptions{}) + + spec := oapi.GenerateOpenAPISpec() + responsesOf := func(path string) map[string]any { + return spec["paths"].(map[string]any)[path].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any) + } + + me := responsesOf("/me") + assert.Contains(t, me, "401") + assert.NotContains(t, me, "403") + example := me["401"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["example"].(ErrorEnvelope) + assert.Equal(t, errTypeAuthN, example.Errors[0].Type) + + assert.Contains(t, responsesOf("/admin"), "403") + assert.Contains(t, responsesOf("/documents/{docId}"), "403") + assert.NotContains(t, responsesOf("/public"), "401") + + // Without authorization, nothing is documented. + plain := New(fiber.New()) + Get(plain, "/me", handler, OpenAPIOptions{}) + plainResponses := plain.GenerateOpenAPISpec()["paths"].(map[string]any)["/me"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any) + assert.NotContains(t, plainResponses, "401") +} diff --git a/common.go b/common.go index 84f8bc4..3288d2f 100644 --- a/common.go +++ b/common.go @@ -95,15 +95,18 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope } // Validate authorization if enabled in configuration and not disabled for this route - if app.Config().EnableAuthorization && options != nil { - if securityValue, ok := options.Security.(string); ok && securityValue == "disabled" { - // Skip authorization for this route - } else { - cfg := app.Config() - if routeSecurity, ok := options.Security.([]map[string][]string); ok && len(routeSecurity) > 0 { - cfg.DefaultSecurity = routeSecurity + if app.config.EnableAuthorization && options != nil && !securityDisabled(options) { + if app.config.AuthenticateFirst { + // The request was authenticated before being read (see + // authenticateHandler); only roles and resource permissions, + // which need the input, are left. + authCtx, _ := c.Locals("auth").(*AuthContext) + if err := authorize(c, authCtx, input, app.config.AuthService, options.RequiredRoles, options.RequireAllRoles); err != nil { + return input, err } - if err := validateAuthorization(c, input, cfg.AuthService, &cfg, options.RequiredRoles, options.RequireAllRoles); err != nil { + } else { + routeSecurity, _ := options.Security.([]map[string][]string) + if err := validateAuthorization(c, input, &app.config, routeSecurity, options.RequiredRoles, options.RequireAllRoles); err != nil { return input, err } } @@ -112,6 +115,13 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope return input, nil } +// securityDisabled reports whether the route opted out of authentication +// with Security: "disabled". +func securityDisabled(options *OpenAPIOptions) bool { + securityValue, ok := options.Security.(string) + return ok && securityValue == "disabled" +} + // handleCustomError emits the error instance a handler returned through its // TError, with the status that instance carries. // diff --git a/error_response.go b/error_response.go index 277c7b4..9170880 100644 --- a/error_response.go +++ b/error_response.go @@ -556,6 +556,30 @@ func exampleNotFoundEnvelope() ErrorEnvelope { } } +// Messages used by the 401 / 403 spec examples. +const ( + exampleAuthNMessage = "authentication required" + exampleAuthZMessage = "insufficient permissions" +) + +// exampleAuthEnvelope is the spec example of an authentication (401) or +// authorization (403) failure, shaped as buildEnvelope emits it. +func exampleAuthEnvelope(status int) ErrorEnvelope { + entryType, msg := errTypeAuthN, exampleAuthNMessage + if status == fiber.StatusForbidden { + entryType, msg = errTypeAuthZ, exampleAuthZMessage + } + return ErrorEnvelope{ + Errors: []ValidationErrorEntry{{ + Type: entryType, + Code: status, + Loc: []any{"header", "Authorization"}, + Msg: msg, + }}, + ResponseContext: ResponseContext{ResponseID: "bf0e9029-576b-42e8-84f9-ad0622972f50"}, + } +} + func exampleParseEnvelope() ErrorEnvelope { return ErrorEnvelope{ Errors: []ValidationErrorEntry{{ diff --git a/fiberoapi.go b/fiberoapi.go index 4ed8d74..7e415ec 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -141,6 +141,9 @@ func New(app *fiber.App, config ...Config) *OApiApp { if provided.IncludeInvalidValueInErrors { cfg.IncludeInvalidValueInErrors = true } + if provided.AuthenticateFirst { + cfg.AuthenticateFirst = true + } } oapi := &OApiApp{ @@ -504,6 +507,29 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { }, exampleParseEnvelope)}, } } + // A protected route answers 401 when the credentials are missing or + // invalid, and 403 when it checks roles, scopes or resource + // permissions. + if o.isProtected(op) { + responses["401"] = map[string]interface{}{ + "description": "Authentication required or failed", + "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ + Code: fiber.StatusUnauthorized, + Type: errTypeAuthN, + Message: exampleAuthNMessage, + }, func() ErrorEnvelope { return exampleAuthEnvelope(fiber.StatusUnauthorized) })}, + } + if o.checksPermissions(op) { + responses["403"] = map[string]interface{}{ + "description": "Insufficient permissions", + "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ + Code: fiber.StatusForbidden, + Type: errTypeAuthZ, + Message: exampleAuthZMessage, + }, func() ErrorEnvelope { return exampleAuthEnvelope(fiber.StatusForbidden) })}, + } + } + } // When UseNotFoundHandler() has been installed, every operation can // surface the same shape under 404 — document it. if o.notFoundInstalled { @@ -536,6 +562,54 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { return spec } +// isProtected reports whether the route authenticates its requests, and can +// therefore answer 401. +func (o *OApiApp) isProtected(op OpenAPIOperation) bool { + return o.config.EnableAuthorization && o.config.AuthService != nil && !securityDisabled(&op.Options) +} + +// checksPermissions reports whether a protected route can answer 403: it +// requires roles, scopes, or permissions on a resource of its input. +func (o *OApiApp) checksPermissions(op OpenAPIOperation) bool { + if len(op.Options.RequiredRoles) > 0 || hasResourceField(op.InputType) { + return true + } + if len(o.config.SecuritySchemes) == 0 { + return false // Bearer-only validation ignores scopes + } + requirements, _ := op.Options.Security.([]map[string][]string) + if len(requirements) == 0 { + requirements = o.config.DefaultSecurity + } + for _, requirement := range requirements { + for _, scopes := range requirement { + if len(scopes) > 0 { + return true + } + } + } + return false +} + +// hasResourceField reports whether the input struct declares a resource +// permission check (a string field with a resource tag). +func hasResourceField(t reflect.Type) bool { + if t == nil { + return false + } + t = dereferenceType(t) + if t.Kind() != reflect.Struct { + return false + } + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Tag.Get("resource") != "" && field.Type.Kind() == reflect.String { + return true + } + } + return false +} + // GenerateOpenAPISpecYAML generates the OpenAPI spec in YAML format func (o *OApiApp) GenerateOpenAPISpecYAML() (string, error) { spec := o.GenerateOpenAPISpec() @@ -1106,28 +1180,7 @@ func Method[TInput any, TOutput any, TError any]( fiberHandler := func(c fiber.Ctx) error { input, err := parseInput[TInput](app, c, fullPath, &options) if err != nil { - // Custom handlers, when configured, still take precedence and receive - // the raw error — they may produce any shape they want. - if authErr, ok := errors.AsType[*AuthError](err); ok && app.config.AuthErrorHandler != nil { - return app.config.AuthErrorHandler(c, authErr) - } - if app.config.ValidationErrorHandler != nil { - return app.config.ValidationErrorHandler(c, err) - } - // If the user opted into a unified shape, emit it for parse / auth / - // generic errors. Validation errors keep the rich ErrorEnvelope shape - // regardless — collapsing a multi-field validation failure into a - // single flat struct would lose the per-field info (loc / constraint - // / field) that clients rely on for form-level UX. - if app.config.DefaultErrorShape != nil && !isValidationError(err) { - cat := categorizeError(err) - return c.Status(cat.Code).JSON(materializeError(app.config.DefaultErrorShape, cat)) - } - // Default response: structured envelope, one entry per failing field, - // status code chosen per error category (422 validation, 400 parse, - // 401/403 auth). - envelope, status := buildEnvelope(c, app.config, inputType, err) - return c.Status(status).JSON(envelope) + return app.respondError(c, inputType, err) } output, customErr := handler(c, input) @@ -1150,7 +1203,65 @@ func Method[TInput any, TOutput any, TError any]( return nil } - app.f.Add([]string{m}, fullPath, fiberHandler) + // Route chain: [authenticateHandler] + options.Middlewares + fiberHandler. + // With AuthenticateFirst, credentials are checked before anything reads the + // request, and the route middlewares can call GetAuthContext. + chain := make([]any, 0, len(options.Middlewares)+2) + if app.config.EnableAuthorization && app.config.AuthenticateFirst && !securityDisabled(&options) { + routeSecurity, _ := options.Security.([]map[string][]string) + chain = append(chain, app.authenticateHandler(inputType, routeSecurity)) + } + for _, mw := range options.Middlewares { + if mw != nil { + chain = append(chain, mw) + } + } + chain = append(chain, fiberHandler) + + app.f.Add([]string{m}, fullPath, chain[0], chain[1:]...) +} + +// authenticateHandler builds the handler placed at the head of a protected +// route when Config.AuthenticateFirst is set: it authenticates the request, +// stores the AuthContext in c.Locals("auth") and moves on to the next handler. +func (app *OApiApp) authenticateHandler(inputType reflect.Type, routeSecurity []map[string][]string) fiber.Handler { + return func(c fiber.Ctx) error { + authCtx, err := authenticate(c, &app.config, routeSecurity) + if err != nil { + return app.respondError(c, inputType, err) + } + if authCtx != nil { + c.Locals("auth", authCtx) + } + return c.Next() + } +} + +// respondError writes the response for an error raised before the handler +// runs (authentication, parsing, validation, authorization). +func (app *OApiApp) respondError(c fiber.Ctx, inputType reflect.Type, err error) error { + // Custom handlers, when configured, still take precedence and receive + // the raw error — they may produce any shape they want. + if authErr, ok := errors.AsType[*AuthError](err); ok && app.config.AuthErrorHandler != nil { + return app.config.AuthErrorHandler(c, authErr) + } + if app.config.ValidationErrorHandler != nil { + return app.config.ValidationErrorHandler(c, err) + } + // If the user opted into a unified shape, emit it for parse / auth / + // generic errors. Validation errors keep the rich ErrorEnvelope shape + // regardless — collapsing a multi-field validation failure into a + // single flat struct would lose the per-field info (loc / constraint + // / field) that clients rely on for form-level UX. + if app.config.DefaultErrorShape != nil && !isValidationError(err) { + cat := categorizeError(err) + return c.Status(cat.Code).JSON(materializeError(app.config.DefaultErrorShape, cat)) + } + // Default response: structured envelope, one entry per failing field, + // status code chosen per error category (422 validation, 400 parse, + // 401/403 auth). + envelope, status := buildEnvelope(c, app.config, inputType, err) + return c.Status(status).JSON(envelope) } // Get defines a GET operation for the OpenAPI documentation diff --git a/route_middlewares_test.go b/route_middlewares_test.go new file mode 100644 index 0000000..f7344b7 --- /dev/null +++ b/route_middlewares_test.go @@ -0,0 +1,114 @@ +package fiberoapi + +import ( + "io" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func traceMiddleware(name string, trace *[]string) fiber.Handler { + return func(c fiber.Ctx) error { + *trace = append(*trace, name) + return c.Next() + } +} + +func TestRouteMiddlewares_RunInOrderBeforeTheHandler(t *testing.T) { + var trace []string + app := fiber.New() + oapi := New(app) + v1 := oapi.Group("/v1", traceMiddleware("group", &trace)) + Get(v1, "/items", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + trace = append(trace, "handler") + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{Middlewares: []fiber.Handler{ + traceMiddleware("first", &trace), + nil, // ignored + traceMiddleware("second", &trace), + }}) + Get(v1, "/other", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + trace = append(trace, "other") + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{}) + + resp, err := app.Test(httptest.NewRequest("GET", "/v1/items", nil)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, []string{"group", "first", "second", "handler"}, trace) + + trace = nil + resp, err = app.Test(httptest.NewRequest("GET", "/v1/other", nil)) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, []string{"group", "other"}, trace, "route middlewares only apply to their route") +} + +func TestRouteMiddlewares_CanStopTheChain(t *testing.T) { + handlerCalls := 0 + app, decodes := newSpyApp() + oapi := New(app) + bodyLimit := func(c fiber.Ctx) error { + if len(c.Body()) > 16 { + return c.Status(fiber.StatusRequestEntityTooLarge).SendString("too large") + } + return c.Next() + } + rateLimit := func(c fiber.Ctx) error { + if c.Get("X-Over-Quota") != "" { + return c.SendStatus(fiber.StatusTooManyRequests) + } + return c.Next() + } + Post(oapi, "/items", func(c fiber.Ctx, in authFirstBody) (fiber.Map, struct{}) { + handlerCalls++ + return fiber.Map{"name": in.Name}, struct{}{} + }, OpenAPIOptions{Middlewares: []fiber.Handler{rateLimit, bodyLimit}}) + + req := httptest.NewRequest("POST", "/items", strings.NewReader(`{"name": "a very long name indeed"}`)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 413, resp.StatusCode) + raw, _ := io.ReadAll(resp.Body) + assert.Equal(t, "too large", string(raw)) + + req = httptest.NewRequest("POST", "/items", strings.NewReader(`{"name": "abc"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Over-Quota", "1") + resp, err = app.Test(req) + require.NoError(t, err) + assert.Equal(t, 429, resp.StatusCode) + + assert.Zero(t, handlerCalls) + assert.Zero(t, decodes.Load(), "the input is not read when a middleware answers") + + assert.Equal(t, 200, postJSON(t, app, "/items", "", `{"name": "abc"}`)) + assert.Equal(t, 1, handlerCalls) +} + +func TestRouteMiddlewares_RunAfterAuthenticateFirst(t *testing.T) { + var trace []string + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: NewMockAuthService(), + }) + Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{Middlewares: []fiber.Handler{traceMiddleware("mw", &trace)}}) + + status, _ := doBearer(t, app, "/me", "") + assert.Equal(t, 401, status) + assert.Empty(t, trace, "an unauthenticated request never reaches the route middlewares") + + status, _ = doBearer(t, app, "/me", "valid-token") + assert.Equal(t, 200, status) + assert.Equal(t, []string{"mw"}, trace) +} diff --git a/types.go b/types.go index dc2af72..ce267f9 100644 --- a/types.go +++ b/types.go @@ -93,6 +93,16 @@ type Config struct { DefaultErrorShape any IncludeInvalidValueInErrors bool // Include offending value in default error envelope (default: false — may leak secrets) + + // AuthenticateFirst checks the credentials before anything reads the + // request, in a handler placed at the head of every protected route + // (before OpenAPIOptions.Middlewares). A request without a valid token is + // rejected without its body being decoded, and route middlewares can call + // GetAuthContext. Roles and resource permissions are still checked after + // the input is parsed, since they need it. Only has an effect with + // EnableAuthorization. Default: false (authentication runs after + // validation, as in v3.2). + AuthenticateFirst bool } // OpenAPIOptions represents options for OpenAPI operations @@ -134,6 +144,11 @@ type OpenAPIOptions struct { // generic parameter (which can be `error`, a concrete `*ErrorResponse`, // or any other type) and the library emits it with the matching status. Errors []any `json:"-"` + + // Middlewares run before the request input is read, after the group + // middlewares and, with Config.AuthenticateFirst, after authentication. + // A middleware can answer on its own (429, 413...) to stop the chain. + Middlewares []fiber.Handler `json:"-"` } // OpenAPIOperation represents a registered operation From ed33fe04f19e33dec6601ec244214a351654d586 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:20:02 +0200 Subject: [PATCH 05/12] feat: add RFC 9457 problem details Problem follows RFC 9457, with Code (a stable identifier) and Errors (the per-field entries) as extension members. Any error value can implement ContentTyper to choose the Content-Type it is sent and documented with; Problem answers application/problem+json. With Config.ProblemDetails, every error produced by the library (parse, validation, authentication, 404/405, serialization) is a Problem, and the 400/401/403/404 and validation responses of the spec point to the Problem schema under application/problem+json. ProblemDetails takes precedence over DefaultErrorShape; the custom error handlers keep priority. Config.ValidationStatusCode sets the status of validation errors (default 422), at runtime and in the spec. When it collides with the 400 parse response, both are documented under one key. Config.ProblemCodes renames the Code of a category. Co-Authored-By: Claude Opus 5.5 --- common.go | 16 ++- conditional_auth.go | 10 +- custom_errors.go | 48 ++++++- error_response.go | 45 +++--- fiberoapi.go | 182 +++++++++++++++--------- problem.go | 109 +++++++++++++++ problem_test.go | 244 +++++++++++++++++++++++++++++++++ spec_runtime_alignment_test.go | 49 +++++++ types.go | 18 +++ 9 files changed, 626 insertions(+), 95 deletions(-) create mode 100644 problem.go create mode 100644 problem_test.go diff --git a/common.go b/common.go index 3288d2f..af0dab5 100644 --- a/common.go +++ b/common.go @@ -132,17 +132,29 @@ func securityDisabled(options *OpenAPIOptions) bool { // reflection cannot reach (nested in a slice of per-field errors, say) and // exposes it through the interface was documented under its real status and then // served as 500. Runtime and spec must not disagree about the same instance. -func handleCustomError(c fiber.Ctx, customErr interface{}) error { +// +// The Content-Type is application/json, unless the instance implements +// ContentTyper. +func handleCustomError(c fiber.Ctx, cfg *Config, customErr interface{}) error { // A typed nil pointer carries nothing to serialize, so it cannot say anything // about its own status either. if v := reflect.ValueOf(customErr); v.Kind() == reflect.Ptr && v.IsNil() { + if cfg.ProblemDetails { + return writeProblem(c, cfg.problemFor(errorCategory{Code: 500, Type: errTypeInternal, Message: "Internal server error"}, nil)) + } return c.Status(500).JSON(fiber.Map{"error": "Internal server error"}) } statusCode := extractErrorStatusCode(customErr) // Return the error as JSON - if err := c.Status(statusCode).JSON(customErr); err != nil { + if err := c.Status(statusCode).JSON(customErr, contentTypeOf(customErr)); err != nil { + if cfg.ProblemDetails { + if fallbackErr := writeProblem(c, cfg.problemFor(errorCategory{Code: 500, Type: errTypeSerialization, Message: "Failed to serialize error response"}, nil)); fallbackErr != nil { + return err + } + return nil + } if fallbackErr := c.Status(500).JSON(fiber.Map{"error": "Failed to serialize error response"}); fallbackErr != nil { // Both serializations failed, return original error to Fiber return err diff --git a/conditional_auth.go b/conditional_auth.go index ff011e3..3ed5d98 100644 --- a/conditional_auth.go +++ b/conditional_auth.go @@ -99,13 +99,17 @@ func MultiSchemeAuthMiddleware(authService AuthorizationService, config Config) } } -// authFailure lets a middleware built from a Config honour its -// AuthErrorHandler: when one is set it receives the AuthError, otherwise the -// legacy body is written with the legacy status. +// authFailure lets a middleware built from a Config honour its error +// handling: the AuthErrorHandler receives the AuthError when one is set; with +// ProblemDetails a Problem is written; otherwise the legacy body is written +// with the legacy status. func authFailure(c fiber.Ctx, config *Config, authErr *AuthError, status int, body fiber.Map) error { if config != nil && config.AuthErrorHandler != nil { return config.AuthErrorHandler(c, authErr) } + if config != nil && config.ProblemDetails { + return writeProblem(c, config.problemFor(categorizeError(authErr, config.validationStatus()), nil)) + } return c.Status(status).JSON(body) } diff --git a/custom_errors.go b/custom_errors.go index 2c6184b..9498b31 100644 --- a/custom_errors.go +++ b/custom_errors.go @@ -123,7 +123,7 @@ func buildErrorResponse(errInst any) (statusCode int, response map[string]interf response = map[string]interface{}{ "description": description, "content": map[string]interface{}{ - "application/json": map[string]interface{}{ + contentTypeOf(errInst): map[string]interface{}{ "schema": errorSchemaRef(t), "example": errInst, }, @@ -132,6 +132,44 @@ func buildErrorResponse(errInst any) (statusCode int, response map[string]interf return statusCode, response } +// addErrorResponse documents resp under the status key. When a response is +// already documented there (validation errors moved to 400 by +// Config.ValidationStatusCode, next to parse errors), the two are combined: +// descriptions are joined and differing schemas become a oneOf. +func addErrorResponse(responses map[string]interface{}, key string, resp map[string]interface{}) { + existing, ok := responses[key].(map[string]interface{}) + if !ok { + responses[key] = resp + return + } + content := map[string]interface{}{} + if prev, ok := existing["content"].(map[string]interface{}); ok { + for mediaType, media := range prev { + content[mediaType] = media + } + } + if next, ok := resp["content"].(map[string]interface{}); ok { + for mediaType, media := range next { + prev, ok := content[mediaType].(map[string]interface{}) + if !ok { + content[mediaType] = media + continue + } + nextSchema := media.(map[string]interface{})["schema"] + if reflect.DeepEqual(prev["schema"], nextSchema) { + continue // same shape: keep the first example + } + content[mediaType] = map[string]interface{}{ + "schema": map[string]interface{}{"oneOf": []interface{}{prev["schema"], nextSchema}}, + } + } + } + responses[key] = map[string]interface{}{ + "description": fmt.Sprintf("%v; %v", existing["description"], resp["description"]), + "content": content, + } +} + // statusCodeKey formats a status code as the string key expected by the // OpenAPI responses map. We use the concrete code (e.g. "404") rather than // the "4XX" wildcard so each declared error has its own slot. @@ -173,8 +211,10 @@ func isValidationError(err error) bool { // categorizeError extracts the (code, type, message, details) tuple from any // internal error produced by parseInput. Used by the default error handler to -// build either an ErrorEnvelope or a user-supplied DefaultErrorShape instance. -func categorizeError(err error) errorCategory { +// build a Problem or a user-supplied DefaultErrorShape instance. +// validationStatus is the status of validation errors (see +// Config.ValidationStatusCode). +func categorizeError(err error, validationStatus int) errorCategory { if authErr, ok := errors.AsType[*AuthError](err); ok { t := errTypeAuthN if authErr.StatusCode == fiber.StatusForbidden { @@ -213,7 +253,7 @@ func categorizeError(err error) errorCategory { head = fmt.Sprintf("%s (and %d more)", msgs[0], len(msgs)-1) } return errorCategory{ - Code: statusValidationError, + Code: validationStatus, Type: errTypeValidation, Message: head, Details: strings.Join(msgs, "; "), diff --git a/error_response.go b/error_response.go index 9170880..0168b3e 100644 --- a/error_response.go +++ b/error_response.go @@ -312,7 +312,7 @@ func buildEnvelope(c fiber.Ctx, cfg Config, inputType reflect.Type, err error) ( } entry := ValidationErrorEntry{ Type: errTypeValidation, - Code: statusValidationError, + Code: cfg.validationStatus(), Loc: loc, Field: leaf, Msg: translateValidatorTag(leaf, fe.Tag(), fe.Param()), @@ -323,7 +323,7 @@ func buildEnvelope(c fiber.Ctx, cfg Config, inputType reflect.Type, err error) ( } entries = append(entries, entry) } - return ErrorEnvelope{Errors: entries, ResponseContext: ctx}, statusValidationError + return ErrorEnvelope{Errors: entries, ResponseContext: ctx}, cfg.validationStatus() } // Anything else — generic parse error. @@ -345,14 +345,15 @@ func constraintString(tag, param string) string { return tag + "=" + param } -// exampleEnvelope returns a representative ErrorEnvelope used as the OpenAPI -// example for the 422 response. It is deliberately compact but realistic enough -// to show the shape to consumers reading the spec. -func exampleValidationEnvelope() ErrorEnvelope { +// exampleValidationEnvelope returns a representative ErrorEnvelope used as the +// OpenAPI example for the validation response (422 by default). It is +// deliberately compact but realistic enough to show the shape to consumers +// reading the spec. +func exampleValidationEnvelope(status int) ErrorEnvelope { return ErrorEnvelope{ Errors: []ValidationErrorEntry{{ Type: errTypeValidation, - Code: statusValidationError, + Code: status, Loc: []any{"body", "workspaceId"}, // Routing through translateValidatorTag (the same function the // runtime calls) keeps the spec example wording perfectly aligned @@ -393,7 +394,7 @@ func methodNotAllowedEnvelope(c fiber.Ctx, allowed []string) ErrorEnvelope { path := sanitizePath(c.Path()) return ErrorEnvelope{ Errors: []ValidationErrorEntry{{ - Type: "method_not_allowed", + Type: errTypeMethodNotAllowed, Code: fiber.StatusMethodNotAllowed, Loc: []any{"method"}, Field: method, @@ -423,23 +424,29 @@ func (o *OApiApp) defaultNotFoundHandler() fiber.Handler { // 405: the path exists on another method. if allowed := o.allowedMethodsFor(c.Path()); len(allowed) > 0 { c.Set(fiber.HeaderAllow, strings.Join(allowed, ", ")) + cat := errorCategory{ + Code: fiber.StatusMethodNotAllowed, + Type: errTypeMethodNotAllowed, + Message: fmt.Sprintf("method %s not allowed on %s", c.Method(), sanitizePath(c.Path())), + Details: strings.Join(allowed, ", "), + } + if o.config.ProblemDetails { + return writeProblem(c, o.config.problemFor(cat, nil)) + } if shape != nil { - cat := errorCategory{ - Code: fiber.StatusMethodNotAllowed, - Type: "method_not_allowed", - Message: fmt.Sprintf("method %s not allowed on %s", c.Method(), sanitizePath(c.Path())), - Details: strings.Join(allowed, ", "), - } return c.Status(fiber.StatusMethodNotAllowed).JSON(materializeError(shape, cat)) } return c.Status(fiber.StatusMethodNotAllowed).JSON(methodNotAllowedEnvelope(c, allowed)) } + cat := errorCategory{ + Code: fiber.StatusNotFound, + Type: errTypeNotFound, + Message: fmt.Sprintf("no route matches %s %s", c.Method(), sanitizePath(c.Path())), + } + if o.config.ProblemDetails { + return writeProblem(c, o.config.problemFor(cat, nil)) + } if shape != nil { - cat := errorCategory{ - Code: fiber.StatusNotFound, - Type: errTypeNotFound, - Message: fmt.Sprintf("no route matches %s %s", c.Method(), sanitizePath(c.Path())), - } return c.Status(fiber.StatusNotFound).JSON(materializeError(shape, cat)) } return c.Status(fiber.StatusNotFound).JSON(NotFoundEnvelope(c)) diff --git a/fiberoapi.go b/fiberoapi.go index 7e415ec..6815403 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -54,7 +54,10 @@ func New(app *fiber.App, config ...Config) *OApiApp { provided.ValidationErrorHandler != nil || provided.AuthErrorHandler != nil || provided.NotFoundHandler != nil || - provided.DefaultErrorShape != nil + provided.DefaultErrorShape != nil || + provided.ProblemDetails || + provided.ValidationStatusCode != 0 || + provided.ProblemCodes != nil // Only override boolean defaults if the config appears to be explicitly set if hasExplicitConfig { @@ -83,7 +86,10 @@ func New(app *fiber.App, config ...Config) *OApiApp { provided.ValidationErrorHandler != nil || provided.AuthErrorHandler != nil || provided.NotFoundHandler != nil || - provided.DefaultErrorShape != nil) + provided.DefaultErrorShape != nil || + provided.ProblemDetails || + provided.ValidationStatusCode != 0 || + provided.ProblemCodes != nil) // Only restore defaults if ALL boolean fields are false (suggesting they weren't explicitly set) allBooleansAreFalse := !provided.EnableValidation && !provided.EnableOpenAPIDocs && !provided.EnableAuthorization @@ -144,6 +150,15 @@ func New(app *fiber.App, config ...Config) *OApiApp { if provided.AuthenticateFirst { cfg.AuthenticateFirst = true } + if provided.ProblemDetails { + cfg.ProblemDetails = true + } + if provided.ValidationStatusCode != 0 { + cfg.ValidationStatusCode = provided.ValidationStatusCode + } + if provided.ProblemCodes != nil { + cfg.ProblemCodes = provided.ProblemCodes + } } oapi := &OApiApp{ @@ -279,6 +294,9 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { if _, ok := schemas["ResponseContext"]; !ok { schemas["ResponseContext"] = generateSchema(reflect.TypeOf(ResponseContext{})) } + if o.config.ProblemDetails { + schemas["Problem"] = generateSchema(reflect.TypeOf(Problem{})) + } for _, op := range o.operations { // Skip hidden operations entirely — the route still serves traffic, @@ -449,98 +467,103 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { responses["4XX"] = map[string]interface{}{ "description": "Domain error returned by the handler", "content": map[string]interface{}{ - "application/json": map[string]interface{}{ + contentTypeOfType(op.ErrorType): map[string]interface{}{ "schema": schemaRef, }, }, } } - // Default error responses produced by parseInput. When the user opted - // into a unified shape via Config.DefaultErrorShape, every default entry - // uses that shape (schema + a representative example built via the same - // reflection helpers used at runtime). Otherwise we fall back to the - // built-in ErrorEnvelope. + // Default error responses produced by the library. With + // Config.ProblemDetails they are Problems (application/problem+json). + // When the user opted into a unified shape via Config.DefaultErrorShape, + // every default entry but the validation one uses that shape (schema + a + // representative example built via the same reflection helpers used at + // runtime). Otherwise we fall back to the built-in ErrorEnvelope. shape := o.config.DefaultErrorShape - defaultErrContent := func(cat errorCategory, envExample func() ErrorEnvelope) map[string]interface{} { - if shape != nil { - return map[string]interface{}{ + defaultErrResponse := func(description string, cat errorCategory, envExample func() ErrorEnvelope) map[string]interface{} { + mediaType := fiber.MIMEApplicationJSON + var content map[string]interface{} + switch { + case o.config.ProblemDetails: + var entries []ValidationErrorEntry + if cat.Type == errTypeValidation || cat.Type == errTypeTypeMismatch { + entries = envExample().Errors + } + mediaType = MIMEApplicationProblemJSON + content = map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/Problem"}, + "example": o.config.problemFor(cat, entries), + } + // Validation errors always use ErrorEnvelope so per-field info (loc / + // constraint / field / value) stays first-class for clients building + // form-level UX, even when DefaultErrorShape is set for the other + // error categories. + case shape != nil && cat.Type != errTypeValidation: + content = map[string]interface{}{ "schema": errorSchemaRef(reflect.TypeOf(shape)), "example": materializeError(shape, cat), } + default: + content = map[string]interface{}{ + "schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorEnvelope"}, + "example": envExample(), + } } return map[string]interface{}{ - "schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorEnvelope"}, - "example": envExample(), + "description": description, + "content": map[string]interface{}{mediaType: content}, } } - // All three default envelope responses (422 validation, 400 parse, - // 404 not-found) honour the opt-out so a /health-style endpoint can + // All default responses (validation, 400 parse, 401/403 auth, 404 + // not-found) honour the opt-out so a /health-style endpoint can // document just its 200. if !suppressAllDefaultErrors { - // 422 always uses ErrorEnvelope so per-field info (loc / constraint / - // field / value) stays first-class for clients building form-level UX, - // even when DefaultErrorShape is set for the other error categories. - responses["422"] = map[string]interface{}{ - "description": "Validation error", - "content": map[string]interface{}{ - "application/json": map[string]interface{}{ - "schema": map[string]interface{}{"$ref": "#/components/schemas/ErrorEnvelope"}, - "example": exampleValidationEnvelope(), - }, - }, - } + validationStatus := o.config.validationStatus() + addErrorResponse(responses, statusCodeKey(validationStatus), defaultErrResponse("Validation error", errorCategory{ + Code: validationStatus, + Type: errTypeValidation, + Message: translateValidatorTag("workspaceId", "min", "11"), + }, func() ErrorEnvelope { return exampleValidationEnvelope(validationStatus) })) // Only POST/PUT/PATCH can produce JSON parse / type-mismatch errors. // The 400 covers both syntactically-malformed bodies and well-formed // bodies whose fields have the wrong JSON type — the example uses the // type-mismatch form because it's the most common in practice and the // most useful to show clients. if op.Method == "POST" || op.Method == "PUT" || op.Method == "PATCH" { - responses["400"] = map[string]interface{}{ - "description": "Invalid request body (malformed JSON or wrong field type)", - "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ - Code: 400, - Type: errTypeTypeMismatch, - Message: fmt.Sprintf(typeMismatchMsgFmt, "age", "int", "string"), - Details: "int", - }, exampleParseEnvelope)}, - } + addErrorResponse(responses, "400", defaultErrResponse("Invalid request body (malformed JSON or wrong field type)", errorCategory{ + Code: 400, + Type: errTypeTypeMismatch, + Message: fmt.Sprintf(typeMismatchMsgFmt, "age", "int", "string"), + Details: "int", + }, exampleParseEnvelope)) } // A protected route answers 401 when the credentials are missing or // invalid, and 403 when it checks roles, scopes or resource // permissions. if o.isProtected(op) { - responses["401"] = map[string]interface{}{ - "description": "Authentication required or failed", - "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ - Code: fiber.StatusUnauthorized, - Type: errTypeAuthN, - Message: exampleAuthNMessage, - }, func() ErrorEnvelope { return exampleAuthEnvelope(fiber.StatusUnauthorized) })}, - } + addErrorResponse(responses, "401", defaultErrResponse("Authentication required or failed", errorCategory{ + Code: fiber.StatusUnauthorized, + Type: errTypeAuthN, + Message: exampleAuthNMessage, + }, func() ErrorEnvelope { return exampleAuthEnvelope(fiber.StatusUnauthorized) })) if o.checksPermissions(op) { - responses["403"] = map[string]interface{}{ - "description": "Insufficient permissions", - "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ - Code: fiber.StatusForbidden, - Type: errTypeAuthZ, - Message: exampleAuthZMessage, - }, func() ErrorEnvelope { return exampleAuthEnvelope(fiber.StatusForbidden) })}, - } + addErrorResponse(responses, "403", defaultErrResponse("Insufficient permissions", errorCategory{ + Code: fiber.StatusForbidden, + Type: errTypeAuthZ, + Message: exampleAuthZMessage, + }, func() ErrorEnvelope { return exampleAuthEnvelope(fiber.StatusForbidden) })) } } // When UseNotFoundHandler() has been installed, every operation can // surface the same shape under 404 — document it. if o.notFoundInstalled { - responses["404"] = map[string]interface{}{ - "description": "Route not found", - "content": map[string]interface{}{"application/json": defaultErrContent(errorCategory{ - Code: 404, - Type: errTypeNotFound, - Message: "no route matches GET /users/42", - }, exampleNotFoundEnvelope)}, - } + addErrorResponse(responses, "404", defaultErrResponse("Route not found", errorCategory{ + Code: 404, + Type: errTypeNotFound, + Message: "no route matches GET /users/42", + }, exampleNotFoundEnvelope)) } } // Per-route custom errors declared via OpenAPIOptions.Errors. Each instance @@ -1186,15 +1209,25 @@ func Method[TInput any, TOutput any, TError any]( output, customErr := handler(c, input) if !isZero(customErr) { - return handleCustomError(c, customErr) + return handleCustomError(c, &app.config, customErr) } if err := c.JSON(output); err != nil { - if fallbackErr := c.Status(500).JSON(ErrorResponse{ - Code: 500, - Details: "Failed to serialize response", - Type: "serialization_error", - }); fallbackErr != nil { + var fallbackErr error + if app.config.ProblemDetails { + fallbackErr = writeProblem(c, app.config.problemFor(errorCategory{ + Code: 500, + Type: errTypeSerialization, + Message: "Failed to serialize response", + }, nil)) + } else { + fallbackErr = c.Status(500).JSON(ErrorResponse{ + Code: 500, + Details: "Failed to serialize response", + Type: errTypeSerialization, + }) + } + if fallbackErr != nil { // Both serializations failed, return original error to Fiber return err } @@ -1248,13 +1281,16 @@ func (app *OApiApp) respondError(c fiber.Ctx, inputType reflect.Type, err error) if app.config.ValidationErrorHandler != nil { return app.config.ValidationErrorHandler(c, err) } + if app.config.ProblemDetails { + return writeProblem(c, app.problemForError(c, inputType, err)) + } // If the user opted into a unified shape, emit it for parse / auth / // generic errors. Validation errors keep the rich ErrorEnvelope shape // regardless — collapsing a multi-field validation failure into a // single flat struct would lose the per-field info (loc / constraint // / field) that clients rely on for form-level UX. if app.config.DefaultErrorShape != nil && !isValidationError(err) { - cat := categorizeError(err) + cat := categorizeError(err, app.config.validationStatus()) return c.Status(cat.Code).JSON(materializeError(app.config.DefaultErrorShape, cat)) } // Default response: structured envelope, one entry per failing field, @@ -1264,6 +1300,18 @@ func (app *OApiApp) respondError(c fiber.Ctx, inputType reflect.Type, err error) return c.Status(status).JSON(envelope) } +// problemForError builds the Problem for an error raised before the handler +// runs. Validation and type errors carry their per-field entries. +func (app *OApiApp) problemForError(c fiber.Ctx, inputType reflect.Type, err error) *Problem { + cat := categorizeError(err, app.config.validationStatus()) + var entries []ValidationErrorEntry + if cat.Type == errTypeValidation || cat.Type == errTypeTypeMismatch { + envelope, _ := buildEnvelope(c, app.config, inputType, err) + entries = envelope.Errors + } + return app.config.problemFor(cat, entries) +} + // Get defines a GET operation for the OpenAPI documentation func Get[TInput any, TOutput any, TError any]( router OApiRouter, // Now accepts both *OApiApp and *OApiGroup diff --git a/problem.go b/problem.go new file mode 100644 index 0000000..038c682 --- /dev/null +++ b/problem.go @@ -0,0 +1,109 @@ +package fiberoapi + +import ( + "net/http" + "reflect" + + "github.com/gofiber/fiber/v3" +) + +// MIMEApplicationProblemJSON is the media type of RFC 9457 problem details. +const MIMEApplicationProblemJSON = "application/problem+json" + +// Problem is an error response following RFC 9457 (problem details for HTTP +// APIs). Code is an extension member: a stable, machine-readable identifier. +// Errors is an extension member too, carrying one entry per failing field. +// +// With Config.ProblemDetails, every error produced by the library is a +// Problem, sent and documented as application/problem+json. A handler can +// also return one through its TError. +type Problem struct { + Type string `json:"type,omitempty" yaml:"type,omitempty"` + Title string `json:"title" yaml:"title"` + Status int `json:"status" yaml:"status"` + Detail string `json:"detail,omitempty" yaml:"detail,omitempty"` + Instance string `json:"instance,omitempty" yaml:"instance,omitempty"` + Code string `json:"code,omitempty" yaml:"code,omitempty"` + Errors []ValidationErrorEntry `json:"errors,omitempty" yaml:"errors,omitempty"` +} + +// HTTPStatus returns the status of the problem. +func (p *Problem) HTTPStatus() int { return p.Status } + +// ContentType returns application/problem+json. +func (p *Problem) ContentType() string { return MIMEApplicationProblemJSON } + +// Error returns the title of the problem. +func (p *Problem) Error() string { return p.Title } + +// ContentTyper is an optional interface an error value (TError, an entry of +// OpenAPIOptions.Errors) can implement to choose the Content-Type it is sent +// and documented with. Without it, errors are application/json. +type ContentTyper interface { + ContentType() string +} + +var contentTyperType = reflect.TypeFor[ContentTyper]() + +// Error categories that are not entry types of the ErrorEnvelope. +const ( + errTypeMethodNotAllowed = "method_not_allowed" + errTypeSerialization = "serialization_error" + errTypeInternal = "internal_error" +) + +// problemFor builds the Problem for an error produced by the library. The +// Code is the category, unless Config.ProblemCodes maps it to another value. +func (cfg *Config) problemFor(cat errorCategory, entries []ValidationErrorEntry) *Problem { + code := cat.Type + if custom, ok := cfg.ProblemCodes[cat.Type]; ok && custom != "" { + code = custom + } + title := http.StatusText(cat.Code) + if title == "" { + title = "Error" + } + return &Problem{ + Title: title, + Status: cat.Code, + Detail: cat.Message, + Code: code, + Errors: entries, + } +} + +// writeProblem sends p with its status and Content-Type. +func writeProblem(c fiber.Ctx, p *Problem) error { + return c.Status(p.Status).JSON(p, p.ContentType()) +} + +// validationStatus returns the status of validation errors: ValidationStatusCode, +// or 422 when it is not set. +func (cfg *Config) validationStatus() int { + if cfg.ValidationStatusCode > 0 { + return cfg.ValidationStatusCode + } + return statusValidationError +} + +// contentTypeOf returns the media type an error value is sent with. +func contentTypeOf(v any) string { + if ct, ok := v.(ContentTyper); ok { + if mediaType := ct.ContentType(); mediaType != "" { + return mediaType + } + } + return fiber.MIMEApplicationJSON +} + +// contentTypeOfType returns the media type values of type t are sent with, +// for the spec of a TError that is only known by its type. +func contentTypeOfType(t reflect.Type) string { + if t == nil || t.Kind() == reflect.Interface || !t.Implements(contentTyperType) { + return fiber.MIMEApplicationJSON + } + if t.Kind() == reflect.Ptr { + return contentTypeOf(reflect.New(t.Elem()).Interface()) + } + return contentTypeOf(reflect.Zero(t).Interface()) +} diff --git a/problem_test.go b/problem_test.go new file mode 100644 index 0000000..9580814 --- /dev/null +++ b/problem_test.go @@ -0,0 +1,244 @@ +package fiberoapi + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type problemInput struct { + ID string `uri:"id"` + Name string `json:"name" validate:"required,min=3"` + Age int `json:"age"` +} + +// readProblem sends req and decodes the Problem it gets back, checking the +// RFC 9457 Content-Type on the way. +func readProblem(t *testing.T, app *fiber.App, req *http.Request) (int, Problem) { + t.Helper() + resp, err := app.Test(req) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + require.Equal(t, MIMEApplicationProblemJSON, resp.Header.Get("Content-Type"), "body: %s", raw) + var p Problem + require.NoError(t, json.Unmarshal(raw, &p), "body: %s", raw) + return resp.StatusCode, p +} + +func jsonRequest(method, path, body string) *http.Request { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + return req +} + +func newProblemApp(t *testing.T, cfg Config) (*fiber.App, *OApiApp) { + t.Helper() + app := fiber.New() + oapi := New(app, cfg) + Post(oapi, "/items/:id", func(c fiber.Ctx, in problemInput) (fiber.Map, struct{}) { + return fiber.Map{"id": in.ID}, struct{}{} + }, WithRoles(OpenAPIOptions{}, "admin")) + Get(oapi, "/broken", func(c fiber.Ctx, _ struct{}) (UnserializableOutput, struct{}) { + return UnserializableOutput{Channel: make(chan string)}, struct{}{} + }, OpenAPIOptions{Security: "disabled"}) + oapi.UseNotFoundHandler() + return app, oapi +} + +func problemConfig() Config { + return Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: NewMockAuthService(), + ProblemDetails: true, + } +} + +func TestProblemDetails_EveryLibraryErrorIsAProblem(t *testing.T) { + app, _ := newProblemApp(t, problemConfig()) + authed := func(req *http.Request, token string) *http.Request { + req.Header.Set("Authorization", "Bearer "+token) + return req + } + + status, p := readProblem(t, app, authed(jsonRequest("POST", "/items/1", `{"name": "x"}`), "admin-token")) + assert.Equal(t, 422, status) + assert.Equal(t, Problem{Title: "Unprocessable Entity", Status: 422, Code: errTypeValidation}, Problem{Title: p.Title, Status: p.Status, Code: p.Code}) + require.Len(t, p.Errors, 1) + assert.Equal(t, []any{"body", "name"}, p.Errors[0].Loc) + assert.Equal(t, "min=3", p.Errors[0].Constraint) + assert.NotEmpty(t, p.Detail) + + status, p = readProblem(t, app, authed(jsonRequest("POST", "/items/1", `{"name": "widget", "age": "old"}`), "admin-token")) + assert.Equal(t, 400, status) + assert.Equal(t, errTypeTypeMismatch, p.Code) + require.Len(t, p.Errors, 1) + assert.Equal(t, "age", p.Errors[0].Field) + + status, p = readProblem(t, app, authed(jsonRequest("POST", "/items/1", `{"name": `), "admin-token")) + assert.Equal(t, 400, status) + assert.Equal(t, errTypeParse, p.Code) + assert.Empty(t, p.Errors) + + status, p = readProblem(t, app, jsonRequest("POST", "/items/1", `{"name": "widget"}`)) + assert.Equal(t, 401, status) + assert.Equal(t, errTypeAuthN, p.Code) + assert.Equal(t, "Unauthorized", p.Title) + + status, p = readProblem(t, app, authed(jsonRequest("POST", "/items/1", `{"name": "widget"}`), "valid-token")) + assert.Equal(t, 403, status) + assert.Equal(t, errTypeAuthZ, p.Code) + + status, p = readProblem(t, app, httptest.NewRequest("GET", "/nowhere", nil)) + assert.Equal(t, 404, status) + assert.Equal(t, errTypeNotFound, p.Code) + + status, p = readProblem(t, app, httptest.NewRequest("DELETE", "/items/1", nil)) + assert.Equal(t, 405, status) + assert.Equal(t, errTypeMethodNotAllowed, p.Code) + + status, p = readProblem(t, app, httptest.NewRequest("GET", "/broken", nil)) + assert.Equal(t, 500, status) + assert.Equal(t, errTypeSerialization, p.Code) +} + +func TestProblemDetails_ProblemCodesAndValidationStatus(t *testing.T) { + cfg := problemConfig() + cfg.ValidationStatusCode = 400 + cfg.ProblemCodes = map[string]string{ + errTypeValidation: "invalid_request", + errTypeTypeMismatch: "invalid_request", + errTypeAuthN: "unauthenticated", + } + app, _ := newProblemApp(t, cfg) + + req := jsonRequest("POST", "/items/1", `{"name": "x"}`) + req.Header.Set("Authorization", "Bearer admin-token") + status, p := readProblem(t, app, req) + assert.Equal(t, 400, status) + assert.Equal(t, 400, p.Status) + assert.Equal(t, "invalid_request", p.Code) + assert.Equal(t, 400, p.Errors[0].Code, "entries carry the configured status too") + + status, p = readProblem(t, app, jsonRequest("POST", "/items/1", `{"name": "widget"}`)) + assert.Equal(t, 401, status) + assert.Equal(t, "unauthenticated", p.Code) + + status, p = readProblem(t, app, httptest.NewRequest("GET", "/nowhere", nil)) + assert.Equal(t, 404, status) + assert.Equal(t, errTypeNotFound, p.Code, "unmapped categories keep their default code") +} + +func TestValidationStatusCode_RuntimeMatchesSpec(t *testing.T) { + for _, problems := range []bool{false, true} { + app := fiber.New() + oapi := New(app, Config{EnableValidation: true, ProblemDetails: problems, ValidationStatusCode: 400}) + Post(oapi, "/items/:id", func(c fiber.Ctx, in problemInput) (fiber.Map, struct{}) { + return fiber.Map{"id": in.ID}, struct{}{} + }, OpenAPIOptions{}) + + resp, err := app.Test(jsonRequest("POST", "/items/1", `{"name": "x"}`)) + require.NoError(t, err) + assert.Equal(t, 400, resp.StatusCode) + + responses := operationResponses(oapi, "/items/{id}", "post") + assert.NotContains(t, responses, "422", "ProblemDetails=%v", problems) + badRequest := responses["400"].(map[string]any) + assert.Contains(t, badRequest["description"], "Validation error") + assert.Contains(t, badRequest["description"], "Invalid request body") + mediaType := fiber.MIMEApplicationJSON + if problems { + mediaType = MIMEApplicationProblemJSON + } + assert.Contains(t, badRequest["content"], mediaType, "ProblemDetails=%v", problems) + } +} + +func TestProblemDetails_Spec(t *testing.T) { + _, oapi := newProblemApp(t, problemConfig()) + spec := oapi.GenerateOpenAPISpec() + schemas := spec["components"].(map[string]any)["schemas"].(map[string]any) + assert.Contains(t, schemas, "Problem") + + responses := operationResponses(oapi, "/items/{id}", "post") + for _, code := range []string{"400", "401", "403", "404", "422"} { + require.Contains(t, responses, code) + content := responses[code].(map[string]any)["content"].(map[string]any) + assert.NotContains(t, content, fiber.MIMEApplicationJSON, code) + media := content[MIMEApplicationProblemJSON].(map[string]any) + assert.Equal(t, "#/components/schemas/Problem", media["schema"].(map[string]any)["$ref"], code) + example := media["example"].(*Problem) + assert.Equal(t, code, statusCodeKey(example.Status)) + } + _, err := oapi.GenerateOpenAPISpecYAML() + assert.NoError(t, err) +} + +// conflictError picks its own Content-Type. +type conflictError struct { + Status int `json:"status"` + Title string `json:"title"` +} + +func (e *conflictError) HTTPStatus() int { return e.Status } +func (e *conflictError) ContentType() string { return MIMEApplicationProblemJSON } + +func TestContentTyper_TErrorIsSentAndDocumentedWithItsContentType(t *testing.T) { + app := fiber.New() + oapi := New(app) + Get(oapi, "/conflict", func(c fiber.Ctx, _ struct{}) (fiber.Map, *conflictError) { + return nil, &conflictError{Status: 409, Title: "already exists"} + }, OpenAPIOptions{}) + Get(oapi, "/declared", func(c fiber.Ctx, _ struct{}) (fiber.Map, *Problem) { + return nil, &Problem{Status: 409, Title: "Conflict", Code: "already_exists"} + }, OpenAPIOptions{Errors: []any{&Problem{Status: 409, Title: "Conflict"}}}) + + resp, err := app.Test(httptest.NewRequest("GET", "/conflict", nil)) + require.NoError(t, err) + assert.Equal(t, 409, resp.StatusCode) + assert.Equal(t, MIMEApplicationProblemJSON, resp.Header.Get("Content-Type")) + + status, p := readProblem(t, app, httptest.NewRequest("GET", "/declared", nil)) + assert.Equal(t, 409, status) + assert.Equal(t, "already_exists", p.Code) + + catchAll := operationResponses(oapi, "/conflict", "get")["4XX"].(map[string]any)["content"].(map[string]any) + assert.Contains(t, catchAll, MIMEApplicationProblemJSON) + declared := operationResponses(oapi, "/declared", "get")["409"].(map[string]any)["content"].(map[string]any) + assert.Contains(t, declared, MIMEApplicationProblemJSON) +} + +func TestProblemDetails_AuthMiddlewares(t *testing.T) { + app := fiber.New() + app.Use(SmartAuthMiddleware(NewMockAuthService(), Config{ProblemDetails: true})) + app.Get("/me", func(c fiber.Ctx) error { return c.SendString("ok") }) + + status, p := readProblem(t, app, newBearerRequest("GET", "/me", "bad-token")) + assert.Equal(t, 401, status) + assert.Equal(t, errTypeAuthN, p.Code) +} + +func TestProblemDetails_DisabledKeepsTheEnvelope(t *testing.T) { + app, _ := newProblemApp(t, Config{EnableValidation: true}) + resp, err := app.Test(jsonRequest("POST", "/items/1", `{"name": "x"}`)) + require.NoError(t, err) + assert.Equal(t, 422, resp.StatusCode) + assert.Equal(t, fiber.MIMEApplicationJSONCharsetUTF8, resp.Header.Get("Content-Type")) + raw, _ := io.ReadAll(resp.Body) + var envelope ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &envelope)) + assert.Equal(t, errTypeValidation, envelope.Errors[0].Type) +} + +// operationResponses returns the documented responses of one operation. +func operationResponses(oapi *OApiApp, path, method string) map[string]any { + spec := oapi.GenerateOpenAPISpec() + return spec["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any)["responses"].(map[string]any) +} diff --git a/spec_runtime_alignment_test.go b/spec_runtime_alignment_test.go index a7ec29b..077a5a1 100644 --- a/spec_runtime_alignment_test.go +++ b/spec_runtime_alignment_test.go @@ -3,6 +3,7 @@ package fiberoapi import ( "encoding/json" "io" + "net/http" "net/http/httptest" "strings" "testing" @@ -160,3 +161,51 @@ func TestAlign_DefaultErrorShape_404_TypeMatchesRuntime(t *testing.T) { assert.Equal(t, advertised.Type, runtime.Type, "404 Type must match between spec example and runtime") assert.Equal(t, advertised.Code, runtime.Code) } + +// TestAlign_ProblemDetails_RuntimeMatchesSpecExample checks, for each +// category a route documents, that the runtime Problem has the status, the +// Content-Type and the code the spec advertises. +func TestAlign_ProblemDetails_RuntimeMatchesSpecExample(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: NewMockAuthService(), + ProblemDetails: true, + }) + Post(oapi, "/users/:name", func(c fiber.Ctx, _ alignInput) (alignOutput, struct{}) { + return alignOutput{Message: "ok"}, struct{}{} + }, WithRoles(OpenAPIOptions{OperationID: "createUser"}, "admin")) + oapi.UseNotFoundHandler() + + responses := oapi.GenerateOpenAPISpec()["paths"].(map[string]any)["/users/{name}"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any) + advertised := func(code string) *Problem { + content := responses[code].(map[string]any)["content"].(map[string]any) + return content[MIMEApplicationProblemJSON].(map[string]any)["example"].(*Problem) + } + + withToken := func(req *http.Request, token string) *http.Request { + req.Header.Set("Authorization", "Bearer "+token) + return req + } + cases := map[string]*http.Request{ + "400": withToken(jsonRequest("POST", "/users/alice", `{"age":"not a number"}`), "admin-token"), + "401": jsonRequest("POST", "/users/alice", `{}`), + "403": withToken(jsonRequest("POST", "/users/alice", `{}`), "valid-token"), + "404": httptest.NewRequest("GET", "/missing", nil), + "422": withToken(jsonRequest("POST", "/users/a", `{}`), "admin-token"), + } + for code, req := range cases { + status, runtime := readProblem(t, app, req) + spec := advertised(code) + assert.Equal(t, code, statusCodeKey(status), code) + assert.Equal(t, spec.Status, runtime.Status, code) + assert.Equal(t, spec.Code, runtime.Code, code) + assert.Equal(t, spec.Title, runtime.Title, code) + assert.Equal(t, len(spec.Errors) > 0, len(runtime.Errors) > 0, "%s: per-field entries", code) + } + assert.Equal(t, advertised("400").Detail, func() string { + _, p := readProblem(t, app, withToken(jsonRequest("POST", "/users/alice", `{"age":"not a number"}`), "admin-token")) + return p.Detail + }(), "the type-mismatch message format is the same in the spec and at runtime") +} diff --git a/types.go b/types.go index ce267f9..411697c 100644 --- a/types.go +++ b/types.go @@ -103,6 +103,24 @@ type Config struct { // EnableAuthorization. Default: false (authentication runs after // validation, as in v3.2). AuthenticateFirst bool + + // ProblemDetails makes every error produced by the library (parsing, + // validation, authentication, 404/405, serialization) a Problem (RFC + // 9457), sent and documented as application/problem+json. It takes + // precedence over DefaultErrorShape. ValidationErrorHandler, + // AuthErrorHandler and NotFoundHandler keep priority when set. + ProblemDetails bool + + // ValidationStatusCode is the status of validation errors, at runtime and + // in the spec. Default: 422. + ValidationStatusCode int + + // ProblemCodes replaces the default Code of a Problem, per error category: + // "validation_error", "parse_error", "type_error", "authentication_error", + // "authorization_error", "not_found", "method_not_allowed", + // "serialization_error", "internal_error". The default Code is the + // category itself. + ProblemCodes map[string]string } // OpenAPIOptions represents options for OpenAPI operations From 66dbc44b5628cef93893cbcfba0eb56375b0f187 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:20:59 +0200 Subject: [PATCH 06/12] feat: add SuccessStatus and Responses route options OpenAPIOptions.SuccessStatus sets the status of a successful response (201, 204...), at runtime and in the spec. At runtime a status other than 200 set by the handler still wins, then the HTTPStatus() of the output, then SuccessStatus. A 204 is now sent without a body instead of null, and documented without content. OpenAPIOptions.Responses adds OpenAPI response objects as-is, merged last. Co-Authored-By: Claude Opus 5.5 --- fiberoapi.go | 55 ++++++++++++++++++-- success_status_test.go | 112 +++++++++++++++++++++++++++++++++++++++++ types.go | 12 +++++ 3 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 success_status_test.go diff --git a/fiberoapi.go b/fiberoapi.go index 6815403..9655885 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -406,8 +406,18 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { // Add response schemas responses := make(map[string]interface{}) - // Success response (200) - if op.OutputType != nil { + // Success response (200 unless SuccessStatus says otherwise) + successKey := statusCodeKey(fiber.StatusOK) + successDescription := "Successful response" + if op.Options.SuccessStatus > 0 && op.Options.SuccessStatus != fiber.StatusOK { + successKey = statusCodeKey(op.Options.SuccessStatus) + if text := http.StatusText(op.Options.SuccessStatus); text != "" { + successDescription = text + } + } + if op.Options.SuccessStatus == fiber.StatusNoContent { + responses[successKey] = map[string]interface{}{"description": successDescription} + } else if op.OutputType != nil { outputType := dereferenceType(op.OutputType) var schemaRef map[string]interface{} @@ -421,14 +431,16 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } - responses["200"] = map[string]interface{}{ - "description": "Successful response", + responses[successKey] = map[string]interface{}{ + "description": successDescription, "content": map[string]interface{}{ "application/json": map[string]interface{}{ "schema": schemaRef, }, }, } + } else if op.Options.SuccessStatus > 0 { + responses[successKey] = map[string]interface{}{"description": successDescription} } // A route can opt out of every framework-emitted error response (the 4XX @@ -578,6 +590,11 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { responses[statusCodeKey(code)] = response } + // Extra response objects declared on the route are merged last, as-is. + for key, response := range op.Options.Responses { + responses[key] = response + } + enhancedOptions["responses"] = responses pathItem[strings.ToLower(op.Method)] = enhancedOptions } @@ -1212,6 +1229,12 @@ func Method[TInput any, TOutput any, TError any]( return handleCustomError(c, &app.config, customErr) } + status := successStatus(c, output, options.SuccessStatus) + if status == fiber.StatusNoContent || status == fiber.StatusNotModified { + return c.SendStatus(status) + } + c.Status(status) + if err := c.JSON(output); err != nil { var fallbackErr error if app.config.ProblemDetails { @@ -1254,6 +1277,30 @@ func Method[TInput any, TOutput any, TError any]( app.f.Add([]string{m}, fullPath, chain[0], chain[1:]...) } +// successStatus resolves the status of a successful response: a status other +// than 200 set by the handler, then the HTTPStatus() of the output, then the +// route's SuccessStatus, then 200. +func successStatus(c fiber.Ctx, output any, configured int) int { + if current := c.Response().StatusCode(); current != fiber.StatusOK { + return current + } + if withStatus, ok := output.(HTTPStatusError); ok && !isNilPointer(output) { + if status := withStatus.HTTPStatus(); status > 0 { + return status + } + } + if configured > 0 { + return configured + } + return fiber.StatusOK +} + +// isNilPointer reports whether v is a typed nil pointer. +func isNilPointer(v any) bool { + rv := reflect.ValueOf(v) + return rv.Kind() == reflect.Ptr && rv.IsNil() +} + // authenticateHandler builds the handler placed at the head of a protected // route when Config.AuthenticateFirst is set: it authenticates the request, // stores the AuthContext in c.Locals("auth") and moves on to the next handler. diff --git a/success_status_test.go b/success_status_test.go new file mode 100644 index 0000000..b0a5847 --- /dev/null +++ b/success_status_test.go @@ -0,0 +1,112 @@ +package fiberoapi + +import ( + "io" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type blobResult struct { + ID string `json:"id"` + created bool +} + +// HTTPStatus tells a new blob (201) from one already present (200). +func (r *blobResult) HTTPStatus() int { + if r.created { + return fiber.StatusCreated + } + return fiber.StatusOK +} + +type blobInput struct { + ID string `uri:"id"` +} + +func doRequest(t *testing.T, app *fiber.App, method, path string) (int, string) { + t.Helper() + resp, err := app.Test(httptest.NewRequest(method, path, nil)) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(raw) +} + +func TestSuccessStatus_Runtime(t *testing.T) { + app := fiber.New() + oapi := New(app) + Post(oapi, "/created", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"id": "1"}, struct{}{} + }, OpenAPIOptions{SuccessStatus: 201}) + Post(oapi, "/logout", func(c fiber.Ctx, _ struct{}) (*struct{}, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{SuccessStatus: 204}) + Put(oapi, "/blobs/:id", func(c fiber.Ctx, in blobInput) (*blobResult, struct{}) { + return &blobResult{ID: in.ID, created: in.ID != "existing"}, struct{}{} + }, OpenAPIOptions{SuccessStatus: 201}) + Post(oapi, "/accepted", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + c.Status(fiber.StatusAccepted) + return fiber.Map{"queued": true}, struct{}{} + }, OpenAPIOptions{SuccessStatus: 201}) + Get(oapi, "/plain", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"ok": true}, struct{}{} + }, OpenAPIOptions{}) + + status, body := doRequest(t, app, "POST", "/created") + assert.Equal(t, 201, status) + assert.JSONEq(t, `{"id":"1"}`, body) + + status, body = doRequest(t, app, "POST", "/logout") + assert.Equal(t, 204, status) + assert.Empty(t, body, "a 204 has no body, not null") + + status, _ = doRequest(t, app, "PUT", "/blobs/new") + assert.Equal(t, 201, status, "HTTPStatus() of the output") + status, body = doRequest(t, app, "PUT", "/blobs/existing") + assert.Equal(t, 200, status, "HTTPStatus() of the output wins over SuccessStatus") + assert.JSONEq(t, `{"id":"existing"}`, body) + + status, _ = doRequest(t, app, "POST", "/accepted") + assert.Equal(t, 202, status, "a status set by the handler wins") + + status, _ = doRequest(t, app, "GET", "/plain") + assert.Equal(t, 200, status) +} + +func TestSuccessStatus_Spec(t *testing.T) { + app := fiber.New() + oapi := New(app) + Post(oapi, "/created", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"id": "1"}, struct{}{} + }, OpenAPIOptions{SuccessStatus: 201}) + Post(oapi, "/logout", func(c fiber.Ctx, _ struct{}) (*struct{}, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{SuccessStatus: 204}) + Put(oapi, "/blobs/:id", func(c fiber.Ctx, in blobInput) (*blobResult, struct{}) { + return &blobResult{ID: in.ID}, struct{}{} + }, OpenAPIOptions{ + SuccessStatus: 201, + Responses: map[string]any{ + "200": map[string]any{"description": "Same content already present"}, + "422": map[string]any{"description": "Replaced"}, + }, + }) + + created := operationResponses(oapi, "/created", "post") + assert.NotContains(t, created, "200") + require.Contains(t, created, "201") + assert.Equal(t, "Created", created["201"].(map[string]any)["description"]) + assert.Contains(t, created["201"].(map[string]any), "content") + + logout := operationResponses(oapi, "/logout", "post") + require.Contains(t, logout, "204") + assert.NotContains(t, logout["204"].(map[string]any), "content", "a 204 is documented without content") + + blobs := operationResponses(oapi, "/blobs/{id}", "put") + assert.Contains(t, blobs, "201") + assert.Equal(t, map[string]any{"description": "Same content already present"}, blobs["200"]) + assert.Equal(t, map[string]any{"description": "Replaced"}, blobs["422"], "Responses replace generated entries") +} diff --git a/types.go b/types.go index 411697c..093e7fa 100644 --- a/types.go +++ b/types.go @@ -167,6 +167,18 @@ type OpenAPIOptions struct { // middlewares and, with Config.AuthenticateFirst, after authentication. // A middleware can answer on its own (429, 413...) to stop the chain. Middlewares []fiber.Handler `json:"-"` + + // SuccessStatus is the status of a successful response, at runtime and in + // the spec (e.g. 201, 204). Default: 200. A 204 is sent without a body. + // At runtime a status other than 200 set by the handler wins, then the + // HTTPStatus() of the output (see HTTPStatusError), then SuccessStatus. + SuccessStatus int `json:"-"` + + // Responses adds OpenAPI response objects as-is, keyed by status code + // ("200", "default", ...). They are merged last and replace a generated + // response with the same key — e.g. to document a "200" next to a + // SuccessStatus of 201. + Responses map[string]any `json:"-"` } // OpenAPIOperation represents a registered operation From aa215ca85d9d82294de1c357b9435198dd3b8865 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:22:10 +0200 Subject: [PATCH 07/12] feat: receive raw request bodies with body:"raw" A []byte field tagged body:"raw" receives the request body as-is, whatever its Content-Type, instead of going through Fiber's body binder (which rejects application/octet-stream). The body is copied, since Fiber reuses its buffers, and validation applies to its length. More than one such field, or one that is not a []byte, panics when the route is registered. The spec documents such a body as {type: string, format: binary} under OpenAPIOptions.RequestContentType (application/octet-stream by default); RequestContentType also sets the media type of a JSON body. Co-Authored-By: Claude Opus 5.5 --- common.go | 58 ++++++++++++++++++++- error_response.go | 7 ++- fiberoapi.go | 26 +++++++++- raw_body_test.go | 127 ++++++++++++++++++++++++++++++++++++++++++++++ types.go | 5 ++ 5 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 raw_body_test.go diff --git a/common.go b/common.go index af0dab5..f40c708 100644 --- a/common.go +++ b/common.go @@ -1,6 +1,7 @@ package fiberoapi import ( + "bytes" "fmt" "reflect" "strings" @@ -23,6 +24,9 @@ type inputShape struct { // isStruct is true when the request input is a (possibly pointer-to) struct, // i.e. eligible for URI/Query/Header binding. isStruct bool + // rawBody is the index of the []byte field tagged body:"raw", which + // receives the request body as-is, or -1. + rawBody int } var shapeCache sync.Map // map[reflect.Type]*inputShape @@ -34,8 +38,10 @@ func shapeFor[TInput any]() *inputShape { if cached, ok := shapeCache.Load(t); ok { return cached.(*inputShape) } + rawBody, _ := rawBodyField(t) // an invalid declaration panics in Method s := &inputShape{ isStruct: t != nil && dereferenceType(t).Kind() == reflect.Struct, + rawBody: rawBody, } actual, _ := shapeCache.LoadOrStore(t, s) return actual.(*inputShape) @@ -55,7 +61,14 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope // matches keys case-insensitively, so a field tagged uri:"id" without // json:"-" would otherwise be overwritten by {"id": "..."} in the body. method := c.Method() - if method == "POST" || method == "PUT" || method == "PATCH" { + if (method == "POST" || method == "PUT" || method == "PATCH") && shape.rawBody >= 0 { + // The body is taken as-is, whatever its Content-Type. Fiber reuses its + // buffers, so it is copied. An empty body leaves the field nil, so that + // validate:"required" rejects it. + if body := c.Body(); len(body) > 0 { + setRawBody(&input, shape.rawBody, bytes.Clone(body)) + } + } else if method == "POST" || method == "PUT" || method == "PATCH" { bodyLength := len(c.Body()) contentType := c.Get("Content-Type") @@ -122,6 +135,49 @@ func securityDisabled(options *OpenAPIOptions) bool { return ok && securityValue == "disabled" } +// rawBodyField returns the index of the field tagged body:"raw" in the input +// struct t, or -1 when there is none. The field must be a []byte, and there +// can be only one. +func rawBodyField(t reflect.Type) (int, error) { + if t == nil { + return -1, nil + } + t = dereferenceType(t) + if t.Kind() != reflect.Struct { + return -1, nil + } + index := -1 + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Tag.Get("body") != "raw" { + continue + } + if !field.IsExported() || field.Type != bytesType { + return -1, fmt.Errorf("field %s has body:\"raw\" but is not an exported []byte", field.Name) + } + if index >= 0 { + return -1, fmt.Errorf("fields %s and %s both have body:\"raw\"", t.Field(index).Name, field.Name) + } + index = i + } + return index, nil +} + +var bytesType = reflect.TypeFor[[]byte]() + +// setRawBody stores body in the raw body field of input, allocating the +// struct when the input is a nil pointer. +func setRawBody[TInput any](input *TInput, field int, body []byte) { + v := reflect.ValueOf(input).Elem() + if v.Kind() == reflect.Ptr { + if v.IsNil() { + v.Set(reflect.New(v.Type().Elem())) + } + v = v.Elem() + } + v.Field(field).SetBytes(body) +} + // handleCustomError emits the error instance a handler returned through its // TError, with the status that instance carries. // diff --git a/error_response.go b/error_response.go index 0168b3e..743390c 100644 --- a/error_response.go +++ b/error_response.go @@ -128,6 +128,10 @@ func (r *locResolver) resolve(namespace string) (loc []any, leaf string) { break } if i == 0 { + if field.Tag.Get("body") == "raw" { + // The raw body is the whole body, not a field of it. + return []any{"body"}, "" + } if tag := field.Tag.Get("uri"); tag != "" { loc = append(loc, "path", tag) } else if tag := field.Tag.Get("query"); tag != "" { @@ -318,7 +322,8 @@ func buildEnvelope(c fiber.Ctx, cfg Config, inputType reflect.Type, err error) ( Msg: translateValidatorTag(leaf, fe.Tag(), fe.Param()), Constraint: constraintString(fe.Tag(), fe.Param()), } - if cfg.IncludeInvalidValueInErrors { + // A raw body is never echoed back, however large it is. + if cfg.IncludeInvalidValueInErrors && fe.Type() != bytesType { entry.Value = fe.Value() } entries = append(entries, entry) diff --git a/fiberoapi.go b/fiberoapi.go index 9655885..506649a 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -378,7 +378,22 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { // Add request body schema for POST/PUT methods if op.Method == "POST" || op.Method == "PUT" || op.Method == "PATCH" { - if op.InputType != nil { + if rawBody, _ := rawBodyField(op.InputType); rawBody >= 0 { + // The body is received as-is: documented as binary, under + // RequestContentType (application/octet-stream by default). + mediaType := op.Options.RequestContentType + if mediaType == "" { + mediaType = fiber.MIMEOctetStream + } + enhancedOptions["requestBody"] = map[string]interface{}{ + "required": true, + "content": map[string]interface{}{ + mediaType: map[string]interface{}{ + "schema": map[string]interface{}{"type": "string", "format": "binary"}, + }, + }, + } + } else if op.InputType != nil { inputType := dereferenceType(op.InputType) var schemaRef map[string]interface{} @@ -392,10 +407,14 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } + mediaType := op.Options.RequestContentType + if mediaType == "" { + mediaType = fiber.MIMEApplicationJSON + } enhancedOptions["requestBody"] = map[string]interface{}{ "required": true, "content": map[string]interface{}{ - "application/json": map[string]interface{}{ + mediaType: map[string]interface{}{ "schema": schemaRef, }, }, @@ -1199,6 +1218,9 @@ func Method[TInput any, TOutput any, TError any]( if err := validatePathParams[TInput](fullPath); err != nil { panic(fmt.Sprintf("Path validation failed for %s: %v", fullPath, err)) } + if _, err := rawBodyField(reflect.TypeFor[TInput]()); err != nil { + panic(fmt.Sprintf("Raw body validation failed for %s: %v", fullPath, err)) + } // Register the operation for OpenAPI documentation with type information var inputZero TInput diff --git a/raw_body_test.go b/raw_body_test.go new file mode 100644 index 0000000..a623cc4 --- /dev/null +++ b/raw_body_test.go @@ -0,0 +1,127 @@ +package fiberoapi + +import ( + "bytes" + "crypto/rand" + "encoding/json" + "io" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type putBlobInput struct { + ID string `uri:"id" validate:"required"` + IfNoneMatch string `header:"If-None-Match" validate:"required,eq=*"` + Body []byte `body:"raw" validate:"required,max=2097152"` +} + +func putBlob(t *testing.T, app *fiber.App, path string, body []byte) (int, []byte) { + t.Helper() + req := httptest.NewRequest("PUT", path, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("If-None-Match", "*") + resp, err := app.Test(req) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + return resp.StatusCode, raw +} + +func TestRawBody_ReceivedIntactAndCopied(t *testing.T) { + payload := make([]byte, 1<<20) + _, err := rand.Read(payload) + require.NoError(t, err) + + var received []byte + var aliased bool + app := fiber.New() + oapi := New(app) + Put(oapi, "/blobs/:id", func(c fiber.Ctx, in putBlobInput) (fiber.Map, struct{}) { + received = in.Body + aliased = &in.Body[0] == &c.Body()[0] + return fiber.Map{"id": in.ID, "size": len(in.Body)}, struct{}{} + }, OpenAPIOptions{SuccessStatus: 201}) + + status, body := putBlob(t, app, "/blobs/b-1", payload) + require.Equal(t, 201, status, "%s", body) + assert.JSONEq(t, `{"id":"b-1","size":1048576}`, string(body)) + assert.True(t, bytes.Equal(payload, received), "the body must be received intact") + assert.False(t, aliased, "the field must not share memory with Fiber's buffer") +} + +func TestRawBody_Validation(t *testing.T) { + app := fiber.New() + oapi := New(app) + Put(oapi, "/small/:id", func(c fiber.Ctx, in struct { + ID string `uri:"id"` + Body []byte `body:"raw" validate:"required,max=4"` + }) (fiber.Map, struct{}) { + return fiber.Map{"size": len(in.Body)}, struct{}{} + }, OpenAPIOptions{}) + + status, _ := putBlob(t, app, "/small/1", []byte("1234")) + assert.Equal(t, 200, status) + + status, raw := putBlob(t, app, "/small/1", []byte("12345")) + assert.Equal(t, 422, status) + var envelope ErrorEnvelope + require.NoError(t, json.Unmarshal(raw, &envelope)) + assert.Equal(t, []any{"body"}, envelope.Errors[0].Loc) + assert.Equal(t, "max=4", envelope.Errors[0].Constraint) + + status, raw = putBlob(t, app, "/small/1", nil) + assert.Equal(t, 422, status, "an empty body fails required") + require.NoError(t, json.Unmarshal(raw, &envelope)) + assert.Equal(t, "required", envelope.Errors[0].Constraint) +} + +func TestRawBody_InvalidDeclarationsPanic(t *testing.T) { + assert.PanicsWithValue(t, `Raw body validation failed for /twice: fields A and B both have body:"raw"`, func() { + Post(New(fiber.New()), "/twice", func(c fiber.Ctx, _ struct { + A []byte `body:"raw"` + B []byte `body:"raw"` + }) (fiber.Map, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{}) + }) + assert.Panics(t, func() { + Post(New(fiber.New()), "/string", func(c fiber.Ctx, _ struct { + Body string `body:"raw"` + }) (fiber.Map, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{}) + }) +} + +func TestRawBody_Spec(t *testing.T) { + oapi := New(fiber.New()) + handler := func(c fiber.Ctx, in putBlobInput) (fiber.Map, struct{}) { return nil, struct{}{} } + Put(oapi, "/blobs/:id", handler, OpenAPIOptions{}) + Post(oapi, "/images/:id", handler, OpenAPIOptions{RequestContentType: "image/png"}) + + spec := oapi.GenerateOpenAPISpec() + operation := func(path, method string) map[string]any { + return spec["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any) + } + binary := map[string]any{"type": "string", "format": "binary"} + + blobs := operation("/blobs/{id}", "put") + content := blobs["requestBody"].(map[string]any)["content"].(map[string]any) + assert.Equal(t, map[string]any{fiber.MIMEOctetStream: map[string]any{"schema": binary}}, content) + + images := operation("/images/{id}", "post") + content = images["requestBody"].(map[string]any)["content"].(map[string]any) + assert.Equal(t, map[string]any{"image/png": map[string]any{"schema": binary}}, content) + + var names []string + for _, param := range blobs["parameters"].([]map[string]any) { + names = append(names, param["in"].(string)+":"+param["name"].(string)) + } + assert.ElementsMatch(t, []string{"path:id", "header:If-None-Match"}, names, "uri and header fields stay parameters") + + schema := spec["components"].(map[string]any)["schemas"].(map[string]any)["putBlobInput"].(map[string]any) + assert.Empty(t, schema["properties"], "the raw field is not part of a JSON schema") +} diff --git a/types.go b/types.go index 093e7fa..fe1b3fe 100644 --- a/types.go +++ b/types.go @@ -179,6 +179,11 @@ type OpenAPIOptions struct { // response with the same key — e.g. to document a "200" next to a // SuccessStatus of 201. Responses map[string]any `json:"-"` + + // RequestContentType is the media type the request body is documented + // with. Default: application/octet-stream for an input with a body:"raw" + // field, application/json otherwise. + RequestContentType string `json:"-"` } // OpenAPIOperation represents a registered operation From 71261c691f2d935769f4b8f19b449ca2346817b1 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:24:12 +0200 Subject: [PATCH 08/12] feat: let outputs write their own response (Responder, RawResponse) An output that implements Responder writes its own status, headers and body, instead of being encoded as JSON: binary content, streams or Server-Sent Events. The check is made once, when the route is registered. RawResponse covers a binary body. OpenAPIOptions.ResponseContentType sets the media type of the success response: a binary schema (a string one for text/...) for a Responder, the JSON schema otherwise, in which case the JSON is also sent with that Content-Type. The README gets a complete Server-Sent Events example, with what stays outside the library: compression and WriteTimeout. Co-Authored-By: Claude Opus 5.5 --- README.md | 100 ++++++++++++++++++++++++++++ fiberoapi.go | 40 ++++++++++- responder.go | 54 +++++++++++++++ responder_test.go | 164 ++++++++++++++++++++++++++++++++++++++++++++++ types.go | 9 +++ 5 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 responder.go create mode 100644 responder_test.go diff --git a/README.md b/README.md index 21b6793..789a6bc 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,106 @@ type Input struct { } ``` +## Responses written by the handler (binary, streams, SSE) + +A handler normally returns a value that the library encodes as JSON. When the +output implements `Responder`, the library calls its `Respond` method instead, +and the output writes its own status, headers and body. The success status of +the route (see [Success status](#success-status-201-204-and-extra-responses)) +is set before `Respond` runs, and `Respond` may override it. + +```go +type Responder interface { + Respond(c fiber.Ctx) error +} +``` + +`RawResponse` covers the common case of a binary body: + +```go +fiberoapi.Get(v1, "/blobs/:id", + func(c fiber.Ctx, in GetBlobInput) (*fiberoapi.RawResponse, error) { + data, err := store.Get(in.ID) + if err != nil { + return nil, err + } + return &fiberoapi.RawResponse{ + ContentType: "application/octet-stream", // default + Body: data, + // Status: 0 keeps the success status of the route (200 by default) + }, nil + }, + fiberoapi.OpenAPIOptions{ResponseContentType: "application/octet-stream"}, +) +``` + +In the spec, the success response of a `Responder` route is documented under +`ResponseContentType` (`application/octet-stream` by default) with +`{type: string, format: binary}`, or `{type: string}` for a `text/...` media +type. For a regular JSON output, `ResponseContentType` sets the media type the +JSON is sent and documented with (e.g. `application/vnd.api+json`). + +### Server-Sent Events + +A Server-Sent Events stream is a `Responder` written by the application: + +```go +// EventStream sends the events of one subscription until the client goes +// away or the server ends the subscription. +type EventStream struct { + events <-chan Event + cancel func() +} + +func (s *EventStream) Respond(c fiber.Ctx) error { + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("X-Accel-Buffering", "no") // disable proxy buffering (nginx) + + // The writer runs after the handler has returned: do not use c inside it. + return c.SendStreamWriter(func(w *bufio.Writer) { + defer s.cancel() + ping := time.NewTicker(15 * time.Second) + defer ping.Stop() + for { + select { + case event, ok := <-s.events: + if !ok { + return // the server closes the stream + } + fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", event.ID, event.Type, event.Data) + case <-ping.C: + fmt.Fprint(w, ": ping\n\n") // keeps idle connections open + } + if err := w.Flush(); err != nil { + return // the client is gone + } + } + }) +} + +fiberoapi.Get(v1, "/sync/events", + func(c fiber.Ctx, _ struct{}) (*EventStream, error) { + events, cancel := broker.Subscribe() + return &EventStream{events: events, cancel: cancel}, nil + }, + fiberoapi.OpenAPIOptions{ + Summary: "Change feed", + ResponseContentType: "text/event-stream", + }, +) +``` + +Two settings live outside the library and must be handled by the application: + +- **Compression.** The `compress` middleware buffers the response, so events + would never reach the client. Exclude the stream from it: + `compress.New(compress.Config{Next: func(c fiber.Ctx) bool { return c.Path() == "/v1/sync/events" }})`. +- **`WriteTimeout`.** `fiber.Config.WriteTimeout` bounds the whole response, + stream included. Leave it at 0 or above the longest stream you serve, or end + streams on the server side periodically and let clients reconnect + (`EventSource` does so automatically, sending `Last-Event-ID`). + ## Error responses When the default validation / parse handler runs (i.e. no custom `ValidationErrorHandler` diff --git a/fiberoapi.go b/fiberoapi.go index 506649a..1c73882 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -254,7 +254,8 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { if op.InputType != nil { collectAllTypes(op.InputType, allTypes) } - if op.OutputType != nil { + // A Responder writes its own body: its fields are not a schema. + if op.OutputType != nil && !op.responder { collectAllTypes(op.OutputType, allTypes) } if op.ErrorType != nil && !isEmptyStruct(op.ErrorType) { @@ -436,6 +437,17 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } if op.Options.SuccessStatus == fiber.StatusNoContent { responses[successKey] = map[string]interface{}{"description": successDescription} + } else if op.responder { + mediaType := op.Options.ResponseContentType + if mediaType == "" { + mediaType = fiber.MIMEOctetStream + } + responses[successKey] = map[string]interface{}{ + "description": successDescription, + "content": map[string]interface{}{ + mediaType: map[string]interface{}{"schema": responderSchema(mediaType)}, + }, + } } else if op.OutputType != nil { outputType := dereferenceType(op.OutputType) @@ -450,10 +462,14 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } + mediaType := op.Options.ResponseContentType + if mediaType == "" { + mediaType = fiber.MIMEApplicationJSON + } responses[successKey] = map[string]interface{}{ "description": successDescription, "content": map[string]interface{}{ - "application/json": map[string]interface{}{ + mediaType: map[string]interface{}{ "schema": schemaRef, }, }, @@ -1227,6 +1243,8 @@ func Method[TInput any, TOutput any, TError any]( var outputZero TOutput var errorZero TError + outputIsResponder := reflect.TypeFor[TOutput]().Implements(responderType) + app.operations = append(app.operations, OpenAPIOperation{ Method: m, Path: fullPath, @@ -1234,6 +1252,7 @@ func Method[TInput any, TOutput any, TError any]( InputType: reflect.TypeOf(inputZero), OutputType: reflect.TypeOf(outputZero), ErrorType: reflect.TypeOf(errorZero), + responder: outputIsResponder, }) inputType := reflect.TypeOf(inputZero) @@ -1252,12 +1271,27 @@ func Method[TInput any, TOutput any, TError any]( } status := successStatus(c, output, options.SuccessStatus) + + // An output that writes its own response (binary, stream, SSE). + if outputIsResponder { + if responder, ok := any(output).(Responder); ok && responder != nil && !isNilPointer(responder) { + c.Status(status) + return responder.Respond(c) + } + } + if status == fiber.StatusNoContent || status == fiber.StatusNotModified { return c.SendStatus(status) } c.Status(status) - if err := c.JSON(output); err != nil { + var jsonErr error + if options.ResponseContentType != "" { + jsonErr = c.JSON(output, options.ResponseContentType) + } else { + jsonErr = c.JSON(output) + } + if err := jsonErr; err != nil { var fallbackErr error if app.config.ProblemDetails { fallbackErr = writeProblem(c, app.config.problemFor(errorCategory{ diff --git a/responder.go b/responder.go new file mode 100644 index 0000000..4548a1c --- /dev/null +++ b/responder.go @@ -0,0 +1,54 @@ +package fiberoapi + +import ( + "reflect" + "strings" + + "github.com/gofiber/fiber/v3" +) + +// Responder is an output that writes its own response (status, headers and +// body): binary content, a stream, Server-Sent Events... When a handler +// returns a non-nil Responder, the library calls Respond instead of encoding +// the output as JSON. The success status (see OpenAPIOptions.SuccessStatus) is +// set beforehand, and Respond may override it. +// +// The spec documents the success response under +// OpenAPIOptions.ResponseContentType (application/octet-stream by default). +type Responder interface { + Respond(c fiber.Ctx) error +} + +var responderType = reflect.TypeFor[Responder]() + +// RawResponse is a Responder for a binary body. +type RawResponse struct { + Status int // 0 keeps the success status of the route (200 by default) + ContentType string // default application/octet-stream + Body []byte +} + +// Respond writes the status, the Content-Type and the body. +func (r *RawResponse) Respond(c fiber.Ctx) error { + if r.Status != 0 { + c.Status(r.Status) + } + if status := c.Response().StatusCode(); status == fiber.StatusNoContent || status == fiber.StatusNotModified { + return c.SendStatus(status) + } + contentType := r.ContentType + if contentType == "" { + contentType = fiber.MIMEOctetStream + } + c.Set(fiber.HeaderContentType, contentType) + return c.Send(r.Body) +} + +// responderSchema is the schema of a body written by a Responder: a string +// for a text media type, binary data otherwise. +func responderSchema(mediaType string) map[string]interface{} { + if strings.HasPrefix(mediaType, "text/") { + return map[string]interface{}{"type": "string"} + } + return map[string]interface{}{"type": "string", "format": "binary"} +} diff --git a/responder_test.go b/responder_test.go new file mode 100644 index 0000000..56f5baa --- /dev/null +++ b/responder_test.go @@ -0,0 +1,164 @@ +package fiberoapi + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRawResponse(t *testing.T) { + app := fiber.New() + oapi := New(app) + Get(oapi, "/default", func(c fiber.Ctx, _ struct{}) (*RawResponse, struct{}) { + return &RawResponse{Body: []byte{0x00, 0xff, 0x10}}, struct{}{} + }, OpenAPIOptions{}) + Get(oapi, "/custom", func(c fiber.Ctx, _ struct{}) (*RawResponse, struct{}) { + return &RawResponse{Status: 206, ContentType: "image/png", Body: []byte("png")}, struct{}{} + }, OpenAPIOptions{}) + Post(oapi, "/created", func(c fiber.Ctx, _ struct{}) (*RawResponse, struct{}) { + return &RawResponse{Body: []byte("new")}, struct{}{} + }, OpenAPIOptions{SuccessStatus: 201}) + Get(oapi, "/empty", func(c fiber.Ctx, _ struct{}) (*RawResponse, struct{}) { + return &RawResponse{Status: 204, Body: []byte("ignored")}, struct{}{} + }, OpenAPIOptions{}) + + check := func(method, path string, status int, contentType string, body []byte) { + t.Helper() + resp, err := app.Test(httptest.NewRequest(method, path, nil)) + require.NoError(t, err) + raw, _ := io.ReadAll(resp.Body) + assert.Equal(t, status, resp.StatusCode, path) + if contentType != "" { + assert.Equal(t, contentType, resp.Header.Get("Content-Type"), path) + } + assert.Equal(t, body, raw, path) + } + check("GET", "/default", 200, fiber.MIMEOctetStream, []byte{0x00, 0xff, 0x10}) + check("GET", "/custom", 206, "image/png", []byte("png")) + check("POST", "/created", 201, fiber.MIMEOctetStream, []byte("new")) + check("GET", "/empty", 204, "", []byte{}) +} + +// eventStream is the Server-Sent Events Responder of the README. It sends +// one event, waits until the client asks for the next one, sends it, then +// closes the stream. +type eventStream struct { + next <-chan struct{} +} + +func (s *eventStream) Respond(c fiber.Ctx) error { + c.Set("Content-Type", "text/event-stream") + c.Set("Cache-Control", "no-cache") + c.Set("X-Accel-Buffering", "no") + return c.SendStreamWriter(func(w *bufio.Writer) { + for i := range 2 { + if i > 0 { + select { + case <-s.next: + case <-time.After(5 * time.Second): + return + } + } + fmt.Fprintf(w, "id: %d\nevent: change\ndata: {\"seq\":%d}\n\n", i, i) + if err := w.Flush(); err != nil { + return // client gone + } + } + }) +} + +func TestResponder_ServerSentEventsOnARealListener(t *testing.T) { + next := make(chan struct{}) + app := fiber.New() + oapi := New(app) + Get(oapi, "/events", func(c fiber.Ctx, _ struct{}) (*eventStream, struct{}) { + return &eventStream{next: next}, struct{}{} + }, OpenAPIOptions{ResponseContentType: "text/event-stream"}) + + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { _ = app.Listener(ln, fiber.ListenConfig{DisableStartupMessage: true}) }() + t.Cleanup(func() { _ = app.Shutdown() }) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get("http://" + ln.Addr().String() + "/events") + require.NoError(t, err) + defer resp.Body.Close() + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, "text/event-stream", resp.Header.Get("Content-Type")) + assert.Equal(t, "no-cache", resp.Header.Get("Cache-Control")) + + reader := bufio.NewReader(resp.Body) + readEvent := func() []string { + var lines []string + for { + line, err := reader.ReadString('\n') + require.NoError(t, err) + line = strings.TrimSuffix(line, "\n") + if line == "" { + return lines + } + lines = append(lines, line) + } + } + + // The first event arrives while the stream is still open. + assert.Equal(t, []string{"id: 0", "event: change", `data: {"seq":0}`}, readEvent()) + close(next) + assert.Equal(t, []string{"id: 1", "event: change", `data: {"seq":1}`}, readEvent()) + + // Then the server closes the stream. + _, err = reader.ReadByte() + assert.ErrorIs(t, err, io.EOF) +} + +func TestResponder_Spec(t *testing.T) { + oapi := New(fiber.New()) + Get(oapi, "/blobs/:id", func(c fiber.Ctx, _ blobInput) (*RawResponse, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{}) + Get(oapi, "/events", func(c fiber.Ctx, _ struct{}) (*eventStream, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{ResponseContentType: "text/event-stream"}) + Get(oapi, "/any", func(c fiber.Ctx, _ struct{}) (Responder, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{ResponseContentType: "application/pdf", SuccessStatus: 201}) + + success := func(path, code string) map[string]any { + return operationResponses(oapi, path, "get")[code].(map[string]any)["content"].(map[string]any) + } + assert.Equal(t, map[string]any{fiber.MIMEOctetStream: map[string]any{ + "schema": map[string]any{"type": "string", "format": "binary"}, + }}, success("/blobs/{id}", "200")) + assert.Equal(t, map[string]any{"text/event-stream": map[string]any{ + "schema": map[string]any{"type": "string"}, + }}, success("/events", "200")) + assert.Contains(t, success("/any", "201"), "application/pdf", "an interface output is detected too") + + schemas := oapi.GenerateOpenAPISpec()["components"].(map[string]any)["schemas"].(map[string]any) + assert.NotContains(t, schemas, "RawResponse") + assert.NotContains(t, schemas, "eventStream") +} + +func TestResponseContentType_JSONOutput(t *testing.T) { + app := fiber.New() + oapi := New(app) + Get(oapi, "/doc", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { + return fiber.Map{"data": 1}, struct{}{} + }, OpenAPIOptions{ResponseContentType: "application/vnd.api+json"}) + + resp, err := app.Test(httptest.NewRequest("GET", "/doc", nil)) + require.NoError(t, err) + assert.Equal(t, "application/vnd.api+json", resp.Header.Get("Content-Type")) + assert.Contains(t, operationResponses(oapi, "/doc", "get")["200"].(map[string]any)["content"], "application/vnd.api+json") +} diff --git a/types.go b/types.go index fe1b3fe..816ac4f 100644 --- a/types.go +++ b/types.go @@ -184,6 +184,13 @@ type OpenAPIOptions struct { // with. Default: application/octet-stream for an input with a body:"raw" // field, application/json otherwise. RequestContentType string `json:"-"` + + // ResponseContentType is the media type of the success response. For an + // output that implements Responder, it is documented with a binary schema + // (a string one for text/...), e.g. "application/octet-stream" (the + // default) or "text/event-stream". For a JSON output, the JSON is sent and + // documented under it (default application/json). + ResponseContentType string `json:"-"` } // OpenAPIOperation represents a registered operation @@ -194,6 +201,8 @@ type OpenAPIOperation struct { InputType reflect.Type OutputType reflect.Type ErrorType reflect.Type + + responder bool // the output implements Responder } type OpenAPIParameter struct { From 9068e91440a268c19cfe8ce38c7925fac6d12023 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:25:26 +0200 Subject: [PATCH 09/12] fix: name fields as sent in the detail of validation problems The detail of a validation Problem came from categorizeError, which uses the Go field name ("Name"), while its entries use the name the client sent ("name"). The detail now repeats the first entry. Co-Authored-By: Claude Opus 5.5 --- fiberoapi.go | 8 ++++++++ problem_test.go | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/fiberoapi.go b/fiberoapi.go index 1c73882..6e018f5 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -1412,6 +1412,14 @@ func (app *OApiApp) problemForError(c fiber.Ctx, inputType reflect.Type, err err envelope, _ := buildEnvelope(c, app.config, inputType, err) entries = envelope.Errors } + // The detail repeats the first entry, which names the field as the + // client sent it (JSON / URI / header name) rather than its Go name. + if cat.Type == errTypeValidation && len(entries) > 0 { + cat.Message = entries[0].Msg + if len(entries) > 1 { + cat.Message = fmt.Sprintf("%s (and %d more)", entries[0].Msg, len(entries)-1) + } + } return app.config.problemFor(cat, entries) } diff --git a/problem_test.go b/problem_test.go index 9580814..a87231f 100644 --- a/problem_test.go +++ b/problem_test.go @@ -74,7 +74,7 @@ func TestProblemDetails_EveryLibraryErrorIsAProblem(t *testing.T) { require.Len(t, p.Errors, 1) assert.Equal(t, []any{"body", "name"}, p.Errors[0].Loc) assert.Equal(t, "min=3", p.Errors[0].Constraint) - assert.NotEmpty(t, p.Detail) + assert.Equal(t, "field 'name' must be at least 3", p.Detail, "the detail names the field as the client sent it") status, p = readProblem(t, app, authed(jsonRequest("POST", "/items/1", `{"name": "widget", "age": "old"}`), "admin-token")) assert.Equal(t, 400, status) From b4f66e92d718cff0ed17d9c0af7476acae4de0d5 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:25:26 +0200 Subject: [PATCH 10/12] docs: document the v3.3 features and the two behavior changes One section per feature (AuthenticateFirst, auth error causes, TokenValidatorFunc, problem details, raw bodies, success status, route middlewares), the new Config fields, and a "What's new in v3.3" box that calls out the two fixes that change behavior: path/query/header values now win over the body, and uri fields leave the documented body. Also fixes the default auth error example, which still showed the pre-v3 flat shape, and the minimum Go version (1.27). Co-Authored-By: Claude Opus 5.5 --- README.md | 255 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 243 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 789a6bc..de952d2 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,24 @@ A Go library that extends Fiber to add automatic OpenAPI documentation generatio go get github.com/labbs/fiber-oapi/v3 ``` -> **Upgrading from v1.x?** v3 tracks Fiber v3 and requires Go 1.26+. Breaking changes: +> **What's new in v3.3** +> - [`AuthenticateFirst`](#authenticating-before-reading-the-request-authenticatefirst): check credentials before the body is read. +> - [`AuthError.Err`](#authenticationauthorization-errors): the cause of an authentication failure (`errors.Is` / `errors.As`), and its status through `HTTPStatus()`. +> - [`TokenValidatorFunc` and `ContextTokenValidator`](#token-only-services-tokenvalidatorfunc): an auth service that only validates tokens. +> - [Problem details (RFC 9457)](#problem-details-rfc-9457): `Problem`, `ContentTyper`, `ProblemDetails`, `ValidationStatusCode`, `ProblemCodes`. +> - [Raw request bodies](#raw-request-body-binary): `body:"raw"` and `RequestContentType`. +> - [Responses written by the handler](#responses-written-by-the-handler-binary-streams-sse): `Responder`, `RawResponse`, `ResponseContentType`, Server-Sent Events. +> - [Success status](#success-status-201-204-and-extra-responses): `SuccessStatus` (201, 204 without body) and `Responses`. +> - [Route middlewares](#route-middlewares): `OpenAPIOptions.Middlewares`. +> - Protected routes document their 401, and 403 when they check roles, scopes or resources. +> +> Everything above is opt-in, except **two fixes that change behavior**: +> 1. **Path, query and header values now win over the body.** The body used to be decoded after the path and query, and the JSON decoder matches keys case-insensitively: a field tagged `uri:"id"` without `json:"-"` could be overwritten by `{"id": "..."}` in the body. The body is now decoded first. +> 2. **`uri` fields no longer appear in the documented request body.** They were already documented as path parameters. +> +> Also: `SmartAuthMiddleware` and `MultiSchemeAuthMiddleware` now go through `Config.AuthErrorHandler` when one is set. + +> **Upgrading from v1.x?** v3 tracks Fiber v3 and requires Go 1.27+. Breaking changes: > - Handlers now take `fiber.Ctx` (struct value) instead of `*fiber.Ctx`. > - The path-parameter struct tag is now `uri:` instead of `path:` (Fiber v3 binder convention). Query and header tags are unchanged. > - The default validation/parse error response uses a new `ErrorEnvelope` shape (one entry per failing field, `response_context.response_id` mirrored from `X-Request-Id`) and validation errors are returned as **422 Unprocessable Entity** instead of 400. See [Error responses](#error-responses). @@ -77,6 +94,14 @@ type Config struct { DefaultSecurity []map[string][]string // Default security requirements ValidationErrorHandler ValidationErrorHandler // Custom handler for validation errors AuthErrorHandler AuthErrorHandler // Custom handler for auth errors (401/403/5xx) + NotFoundHandler fiber.Handler // Custom handler for unmatched routes (see UseNotFoundHandler) + DefaultErrorShape any // One shape for library errors (see below) + IncludeInvalidValueInErrors bool // Echo the offending value in error entries (default: false) + + AuthenticateFirst bool // Authenticate before reading the request (default: false) + ProblemDetails bool // Library errors as RFC 9457 problems (default: false) + ValidationStatusCode int // Status of validation errors (default: 422) + ProblemCodes map[string]string // Replace the Code of a problem, per category } ``` @@ -126,12 +151,91 @@ type MyInput struct { } ``` +The body is decoded first, then the path, query and header values are bound: +they always win over a body key with the same name. + Special tags: +- `body:"raw"` — Receive the request body as-is in a `[]byte` field (see [Raw request body](#raw-request-body-binary)) - `openapi:"-"` — Exclude a field from the OpenAPI schema (the field still works in the handler) - `description:"text"` — Add a description to the field in the spec - `resource:"document"` — Mark field as a resource identifier for dynamic authorization - `action:"write"` — Specify the action for resource access checks +### Raw request body (binary) + +Fiber's body binder only knows structured formats (JSON, XML, forms...). To +receive a body as-is — a file, an encrypted package — tag a `[]byte` field with +`body:"raw"`: + +```go +type PutBlobInput struct { + ID string `uri:"id" validate:"required,uuid"` + IfNoneMatch string `header:"If-None-Match" validate:"required,eq=*"` + Body []byte `body:"raw" validate:"required,max=10485760"` // the body, as-is +} + +fiberoapi.Put(v1, "/blobs/:id", ctrl.PutBlob, fiberoapi.OpenAPIOptions{ + RequestContentType: "application/octet-stream", // default for a raw body +}) +``` + +- The body is copied (Fiber reuses its buffers), whatever its `Content-Type`. +- An empty body leaves the field `nil`, so `required` rejects it; `min` / `max` + apply to its length. +- A struct can have only one `body:"raw"` field, and it must be a `[]byte`: + anything else panics when the route is registered. +- The spec documents the body as `{type: string, format: binary}` under + `RequestContentType`. `uri`, `query` and `header` fields stay parameters. + For a JSON input, `RequestContentType` sets the media type of the JSON body. + +The whole body is read before the handler runs: `fiber.Config.BodyLimit` +(4 MiB by default) must be at least the largest body you accept. A route +middleware can enforce a lower limit on one route. + +## Success status (201, 204) and extra responses + +```go +fiberoapi.Post(v1, "/users", ctrl.CreateUser, fiberoapi.OpenAPIOptions{ + SuccessStatus: 201, +}) + +fiberoapi.Post(v1, "/auth/logout", ctrl.Logout, fiberoapi.OpenAPIOptions{ + SuccessStatus: 204, // sent without a body, documented without content +}) + +fiberoapi.Put(v1, "/blobs/:id", ctrl.PutBlob, fiberoapi.OpenAPIOptions{ + SuccessStatus: 201, + Responses: map[string]any{ // OpenAPI response objects, merged as-is + "200": map[string]any{"description": "Same content already present"}, + }, +}) +``` + +At runtime, the status of a successful response is, in order: + +1. a status other than 200 set by the handler with `c.Status(...)`; +2. the `HTTPStatus() int` of the output, when it implements it — e.g. 201 for + a new resource, 200 for an existing one; +3. `SuccessStatus`; +4. 200. + +The spec documents the success response under `SuccessStatus`. `Responses` +is merged last and replaces any generated response with the same key. + +## Route middlewares + +`OpenAPIOptions.Middlewares` runs handlers on one route only, before its input +is read — after the group middlewares and, with +[`AuthenticateFirst`](#authenticating-before-reading-the-request-authenticatefirst), +after authentication. A middleware can answer on its own to stop the chain: + +```go +fiberoapi.Post(v1, "/auth/login", ctrl.Login, fiberoapi.OpenAPIOptions{ + Security: "disabled", + Middlewares: []fiber.Handler{limiter.New(limiter.Config{Max: 5})}, // 429 +}) +``` + ## Groups ```go @@ -299,7 +403,7 @@ Status codes used by the default handler: | Code | Category | Entry `type` | |------|----------|--------------| -| 422 | Failed validation rules | `validation_error` | +| 422 | Failed validation rules (`Config.ValidationStatusCode` to change it) | `validation_error` | | 400 | JSON parse / type mismatch | `type_error`, `parse_error` | | 401 / 403 | Authentication / authorization | `authentication_error`, `authorization_error` | @@ -314,7 +418,8 @@ if you want each entry to carry a `value` field. The OpenAPI spec exposes `ErrorEnvelope` / `ValidationErrorEntry` / `ResponseContext` under `components.schemas` and adds a 422 response with a realistic example to -every operation, plus a 400 example for body-carrying methods. Routes that +every operation, plus a 400 example for body-carrying methods, a 401 for +protected routes, and a 403 for those that check roles, scopes or resources. Routes that declare a non-empty `TError` and no per-status `OpenAPIOptions.Errors` keep their domain shape under the catch-all `4XX` response — as soon as the route declares explicit `Errors` entries the catch-all is suppressed and only the enumerated @@ -369,6 +474,64 @@ If you need a different shape, set `Config.ValidationErrorHandler` / `Config.Aut stays friendly, but `var ute *json.UnmarshalTypeError; errors.As(err, &ute)` still recovers the original). +### Problem details (RFC 9457) + +Set `Config.ProblemDetails` and every error produced by the library — parsing, +validation, authentication, 404/405, serialization — is a `Problem`, sent as +`application/problem+json` and documented as such in the spec: + +```go +oapi := fiberoapi.New(app, fiberoapi.Config{ + EnableValidation: true, + ProblemDetails: true, + ValidationStatusCode: 400, // default 422, at runtime and in the spec + ProblemCodes: map[string]string{ // default: the category itself + "validation_error": "invalid_request", + "parse_error": "invalid_request", + "type_error": "invalid_request", + }, +}) +``` + +```json +{ + "title": "Bad Request", + "status": 400, + "detail": "field 'name' must be at least 3", + "code": "invalid_request", + "errors": [ + {"type": "validation_error", "code": 400, "loc": ["body", "name"], "field": "name", + "msg": "field 'name' must be at least 3", "constraint": "min=3"} + ] +} +``` + +`code` and `errors` are extension members: a stable identifier, and the +per-field entries of validation and type errors. The categories are +`validation_error`, `parse_error`, `type_error`, `authentication_error`, +`authorization_error`, `not_found`, `method_not_allowed`, +`serialization_error` and `internal_error`. + +`ProblemDetails` takes precedence over `DefaultErrorShape`. +`ValidationErrorHandler`, `AuthErrorHandler` and `NotFoundHandler` keep +priority; they can answer with a problem of their own: + +```go +AuthErrorHandler: func(c fiber.Ctx, err *fiberoapi.AuthError) error { + p := &fiberoapi.Problem{Title: "Unauthorized", Status: err.StatusCode, Code: "token_expired"} + return c.Status(p.Status).JSON(p, p.ContentType()) +}, +``` + +Any error value — a `TError`, an entry of `OpenAPIOptions.Errors` — can choose +its `Content-Type` by implementing `ContentTyper`; `Problem` answers +`application/problem+json`. A handler can therefore return a `*Problem` as its +`TError`: + +```go +type ContentTyper interface{ ContentType() string } +``` + ### Unifying all errors under one shape (`DefaultErrorShape`) By default, the library emits its own `ErrorEnvelope` shape for framework-level @@ -412,6 +575,8 @@ per-route override still wins. Result: in your spec every error response references `#/components/schemas/ErrorResponse` **except 422**, which stays on `#/components/schemas/ErrorEnvelope`. +`DefaultErrorShape` is ignored when `ProblemDetails` is set. + Per-route entries declared via `OpenAPIOptions.Errors` still take precedence for their status code — so you can selectively override the default shape on a specific endpoint if you ever need to. @@ -608,6 +773,58 @@ config := fiberoapi.Config{ oapi := fiberoapi.New(app, config) ``` +### Token-only services (`TokenValidatorFunc`) + +An application that only validates tokens does not need to implement the whole +`AuthorizationService`: + +```go +oapi := fiberoapi.New(app, fiberoapi.Config{ + EnableAuthorization: true, + AuthService: fiberoapi.TokenValidatorFunc(func(token string) (*fiberoapi.AuthContext, error) { + return tokens.Validate(token) // tokens.ErrExpired, tokens.ErrDeviceRevoked... + }), +}) +``` + +`HasRole` and `HasScope` read the roles and scopes of the returned +`AuthContext`. Resource permissions are not supported: a route that needs them +answers 500 (`ErrResourceAuthorizationNotConfigured`), never an access granted +by default. + +A service that needs the request to validate a Bearer token (client IP, +request context...) can implement `ContextTokenValidator`; it is then used +instead of `ValidateToken`: + +```go +type ContextTokenValidator interface { + ValidateTokenCtx(c fiber.Ctx, token string) (*fiberoapi.AuthContext, error) +} +``` + +### Authenticating before reading the request (`AuthenticateFirst`) + +By default, a route reads its input (path, query, body, headers), validates +it, and only then checks the credentials: a request without a token has its +body decoded, and a request with an expired token and an invalid body gets a +validation error. With `Config.AuthenticateFirst`, the credentials are checked +first, in a handler placed at the head of every protected route: + +```go +oapi := fiberoapi.New(app, fiberoapi.Config{ + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: authService, +}) +``` + +- A request without valid credentials gets its 401 before anything reads it. +- [Route middlewares](#route-middlewares) run after authentication and can call + `GetAuthContext(c)`. +- Roles and resource permissions are still checked after the input is parsed, + since they need it. +- Routes with `Security: "disabled"` are not authenticated. + ### Public vs Protected Routes ```go @@ -741,18 +958,32 @@ oapi := fiberoapi.New(app, fiberoapi.Config{ }) ``` -Without custom handlers, default error responses are returned: +`AuthError` keeps the error returned by the validator (`ValidateToken`, +`ValidateAPIKey`...) as its cause, so the handler can tell failures apart +without comparing messages: -```json -// 401 - Authentication failure -{"code": 401, "details": "invalid token", "type": "authentication_error"} +```go +AuthErrorHandler: func(c fiber.Ctx, err *fiberoapi.AuthError) error { + code := "unauthorized" + switch { + case errors.Is(err, tokens.ErrExpired): + code = "token_expired" + case errors.Is(err, tokens.ErrDeviceRevoked): + code = "device_revoked" + } + return c.Status(err.StatusCode).JSON(fiber.Map{"code": code}) +}, +``` -// 403 - Authorization failure -{"code": 403, "details": "requires one of: admin, editor", "type": "authorization_error"} +The status is 401 (403 for a missing scope), unless the cause implements +`HTTPStatus() int`, in which case its status is used. -// 400 - Validation failure -{"code": 400, "details": "...", "type": "validation_error"} -``` +`SmartAuthMiddleware` and `MultiSchemeAuthMiddleware` go through +`AuthErrorHandler` too, and answer with a problem when `ProblemDetails` is set. + +Without custom handlers, authentication and authorization failures use the +default [error envelope](#error-responses), with an entry of type +`authentication_error` (401) or `authorization_error` (403). ## Conditional Auth Middleware From 24be1831017cb4d6fa28dce0a4d77996e15a752b Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:25:58 +0200 Subject: [PATCH 11/12] test: check spec/runtime alignment of the v3.3 options together Mirrors a sync server using AuthenticateFirst, ProblemDetails, ValidationStatusCode, raw bodies, RawResponse, SuccessStatus, Responses and route middlewares, and checks that every status and Content-Type returned at runtime is documented in the spec. Co-Authored-By: Claude Opus 5.5 --- spec_runtime_alignment_test.go | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/spec_runtime_alignment_test.go b/spec_runtime_alignment_test.go index 077a5a1..912ecff 100644 --- a/spec_runtime_alignment_test.go +++ b/spec_runtime_alignment_test.go @@ -209,3 +209,87 @@ func TestAlign_ProblemDetails_RuntimeMatchesSpecExample(t *testing.T) { return p.Detail }(), "the type-mismatch message format is the same in the spec and at runtime") } + +// TestAlign_V33Options_RuntimeMatchesSpec mirrors the setup of a sync server +// using every v3.3 option together and checks, route by route, that the +// status and the Content-Type returned at runtime are documented in the spec. +func TestAlign_V33Options_RuntimeMatchesSpec(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthenticateFirst: true, + AuthService: staticTokens, + ProblemDetails: true, + ValidationStatusCode: 400, + ProblemCodes: map[string]string{errTypeValidation: "invalid_request"}, + SecuritySchemes: map[string]SecurityScheme{"bearerAuth": {Type: "http", Scheme: "bearer"}}, + DefaultSecurity: []map[string][]string{{"bearerAuth": {}}}, + }) + v1 := oapi.Group("/v1") + Put(v1, "/blobs/:id", func(c fiber.Ctx, in putBlobInput) (*blobResult, struct{}) { + return &blobResult{ID: in.ID, created: in.ID != "existing"}, struct{}{} + }, OpenAPIOptions{ + SuccessStatus: 201, + Responses: map[string]any{"200": map[string]any{"description": "Same content already present"}}, + Middlewares: []fiber.Handler{func(c fiber.Ctx) error { + if len(c.Body()) > 8 { + return c.SendStatus(fiber.StatusRequestEntityTooLarge) + } + return c.Next() + }}, + }) + Get(v1, "/blobs/:id", func(c fiber.Ctx, in blobInput) (*RawResponse, struct{}) { + return &RawResponse{Body: []byte(in.ID)}, struct{}{} + }, OpenAPIOptions{ResponseContentType: fiber.MIMEOctetStream}) + Post(v1, "/auth/logout", func(c fiber.Ctx, _ struct{}) (*struct{}, struct{}) { + return nil, struct{}{} + }, OpenAPIOptions{SuccessStatus: 204}) + Post(v1, "/auth/login", func(c fiber.Ctx, in authFirstBody) (fiber.Map, struct{}) { + return fiber.Map{"token": "user"}, struct{}{} + }, OpenAPIOptions{Security: "disabled"}) + + spec := oapi.GenerateOpenAPISpec() + documented := func(path, method string, status int, contentType string) { + t.Helper() + operation := spec["paths"].(map[string]any)[path].(map[string]any)[method].(map[string]any) + response, ok := operation["responses"].(map[string]any)[statusCodeKey(status)].(map[string]any) + require.True(t, ok, "%s %s: status %d is not documented", method, path, status) + if contentType == "" { + assert.NotContains(t, response, "content", "%s %s %d", method, path, status) + return + } + mediaType, _, _ := strings.Cut(contentType, ";") + assert.Contains(t, response["content"], mediaType, "%s %s %d", method, path, status) + } + call := func(req *http.Request, token string, wantStatus int) string { + t.Helper() + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + resp, err := app.Test(req) + require.NoError(t, err) + require.Equal(t, wantStatus, resp.StatusCode, "%s %s", req.Method, req.URL.Path) + return resp.Header.Get("Content-Type") + } + blob := func(id, body string) *http.Request { + req := httptest.NewRequest("PUT", "/v1/blobs/"+id, strings.NewReader(body)) + req.Header.Set("Content-Type", fiber.MIMEOctetStream) + req.Header.Set("If-None-Match", "*") + return req + } + + documented("/v1/blobs/{id}", "put", 201, call(blob("new", "data"), "user", 201)) + documented("/v1/blobs/{id}", "put", 200, "") // declared through Responses, without content + call(blob("existing", "data"), "user", 200) + documented("/v1/blobs/{id}", "put", 401, call(blob("new", "data"), "", 401)) + documented("/v1/blobs/{id}", "put", 400, call(blob("new", ""), "user", 400)) + call(blob("new", "far too large"), "user", 413) // the route middleware answers + documented("/v1/blobs/{id}", "get", 200, call(httptest.NewRequest("GET", "/v1/blobs/b-1", nil), "user", 200)) + documented("/v1/auth/logout", "post", 204, call(httptest.NewRequest("POST", "/v1/auth/logout", nil), "user", 204)) + documented("/v1/auth/login", "post", 200, call(jsonRequest("POST", "/v1/auth/login", `{"name":"alice"}`), "", 200)) + documented("/v1/auth/login", "post", 400, call(jsonRequest("POST", "/v1/auth/login", `{"name":"al"}`), "", 400)) + + login := spec["paths"].(map[string]any)["/v1/auth/login"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any) + assert.NotContains(t, login, "401", "a public route documents no authentication error") +} From f0e7fea1803f7ac8e4493f950328756ce16c78c0 Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Mon, 19 Oct 2026 19:32:03 +0200 Subject: [PATCH 12/12] feat!: always authenticate before reading the request Config.AuthenticateFirst is removed: every protected route now checks the credentials in a handler placed at the head of its chain, before the route middlewares and before the input is read. A request that fails authentication gets its 401 without its body being decoded, even when that body is malformed or invalid (it used to get a 400 or 422). This saves the decoding and validation work on rejected requests, and keeps an unauthenticated client from probing the validation rules of a route. Roles and resource permissions are still checked after the input is parsed, since they need it. validateAuthorization, now unused, is gone. BREAKING CHANGE: on a protected route, authentication errors (401) now take precedence over parse (400) and validation (422) errors. Co-Authored-By: Claude Opus 5.5 --- README.md | 42 ++++++++++++------------ auth.go | 18 ++-------- auth_first_test.go | 60 +++++++++++++++------------------- auth_schemes_test.go | 2 +- common.go | 19 ++++------- fiberoapi.go | 16 ++++----- route_middlewares_test.go | 3 +- spec_runtime_alignment_test.go | 1 - types.go | 12 +------ 9 files changed, 65 insertions(+), 108 deletions(-) diff --git a/README.md b/README.md index de952d2..9de2727 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ go get github.com/labbs/fiber-oapi/v3 ``` > **What's new in v3.3** -> - [`AuthenticateFirst`](#authenticating-before-reading-the-request-authenticatefirst): check credentials before the body is read. > - [`AuthError.Err`](#authenticationauthorization-errors): the cause of an authentication failure (`errors.Is` / `errors.As`), and its status through `HTTPStatus()`. > - [`TokenValidatorFunc` and `ContextTokenValidator`](#token-only-services-tokenvalidatorfunc): an auth service that only validates tokens. > - [Problem details (RFC 9457)](#problem-details-rfc-9457): `Problem`, `ContentTyper`, `ProblemDetails`, `ValidationStatusCode`, `ProblemCodes`. @@ -35,9 +34,10 @@ go get github.com/labbs/fiber-oapi/v3 > - [Route middlewares](#route-middlewares): `OpenAPIOptions.Middlewares`. > - Protected routes document their 401, and 403 when they check roles, scopes or resources. > -> Everything above is opt-in, except **two fixes that change behavior**: -> 1. **Path, query and header values now win over the body.** The body used to be decoded after the path and query, and the JSON decoder matches keys case-insensitively: a field tagged `uri:"id"` without `json:"-"` could be overwritten by `{"id": "..."}` in the body. The body is now decoded first. -> 2. **`uri` fields no longer appear in the documented request body.** They were already documented as path parameters. +> Everything above is opt-in, except **three changes of behavior**: +> 1. **[Authentication runs first.](#authentication-runs-first)** A protected route checks the credentials before reading its input: a request that fails authentication gets its 401 without its body being decoded, even if that body is malformed or invalid (it used to get a 400 or 422). +> 2. **Path, query and header values now win over the body.** The body used to be decoded after the path and query, and the JSON decoder matches keys case-insensitively: a field tagged `uri:"id"` without `json:"-"` could be overwritten by `{"id": "..."}` in the body. The body is now decoded first. +> 3. **`uri` fields no longer appear in the documented request body.** They were already documented as path parameters. > > Also: `SmartAuthMiddleware` and `MultiSchemeAuthMiddleware` now go through `Config.AuthErrorHandler` when one is set. @@ -98,7 +98,6 @@ type Config struct { DefaultErrorShape any // One shape for library errors (see below) IncludeInvalidValueInErrors bool // Echo the offending value in error entries (default: false) - AuthenticateFirst bool // Authenticate before reading the request (default: false) ProblemDetails bool // Library errors as RFC 9457 problems (default: false) ValidationStatusCode int // Status of validation errors (default: 422) ProblemCodes map[string]string // Replace the Code of a problem, per category @@ -225,9 +224,9 @@ is merged last and replaces any generated response with the same key. ## Route middlewares `OpenAPIOptions.Middlewares` runs handlers on one route only, before its input -is read — after the group middlewares and, with -[`AuthenticateFirst`](#authenticating-before-reading-the-request-authenticatefirst), -after authentication. A middleware can answer on its own to stop the chain: +is read — after the group middlewares and, on a protected route, after +[authentication](#authentication-runs-first). A middleware can answer on its +own to stop the chain: ```go fiberoapi.Post(v1, "/auth/login", ctrl.Login, fiberoapi.OpenAPIOptions{ @@ -802,23 +801,22 @@ type ContextTokenValidator interface { } ``` -### Authenticating before reading the request (`AuthenticateFirst`) +### Authentication runs first -By default, a route reads its input (path, query, body, headers), validates -it, and only then checks the credentials: a request without a token has its -body decoded, and a request with an expired token and an invalid body gets a -validation error. With `Config.AuthenticateFirst`, the credentials are checked -first, in a handler placed at the head of every protected route: +On a protected route, the credentials are checked before anything reads the +request, in a handler placed at the head of the route: -```go -oapi := fiberoapi.New(app, fiberoapi.Config{ - EnableAuthorization: true, - AuthenticateFirst: true, - AuthService: authService, -}) -``` +1. authentication (401 on failure); +2. the [route middlewares](#route-middlewares); +3. reading the input (path, query, body, headers) and validating it (400, 422); +4. roles and resource permissions (403); +5. the handler. + +Group middlewares (`Group(prefix, handlers...)`, `Use`) run before step 1. -- A request without valid credentials gets its 401 before anything reads it. +- A request without valid credentials gets its 401 before its body is + decoded: no work is spent on a request that will be rejected, and an + unauthenticated client cannot probe the validation rules of the route. - [Route middlewares](#route-middlewares) run after authentication and can call `GetAuthContext(c)`. - Roles and resource permissions are still checked after the input is parsed, diff --git a/auth.go b/auth.go index 8782772..ce80f1e 100644 --- a/auth.go +++ b/auth.go @@ -146,23 +146,11 @@ func RoleGuard(validator AuthorizationService, requiredRoles ...string) fiber.Ha } } -// validateAuthorization authenticates the request, stores the AuthContext in -// c.Locals("auth"), then checks roles and resource permissions. routeSecurity, -// when non-empty, replaces Config.DefaultSecurity for this route. -func validateAuthorization(c fiber.Ctx, input interface{}, config *Config, routeSecurity []map[string][]string, requiredRoles []string, requireAllRoles bool) error { - authCtx, err := authenticate(c, config, routeSecurity) - if err != nil { - return err - } - if authCtx != nil { - c.Locals("auth", authCtx) - } - return authorize(c, authCtx, input, config.AuthService, requiredRoles, requireAllRoles) -} - // authenticate validates the credentials of the request against the // configured security schemes and returns the resulting AuthContext. It does -// not need the request input, so it can run before the body is read. +// not need the request input, so it runs before the body is read, in +// authenticateHandler. routeSecurity, when non-empty, replaces +// Config.DefaultSecurity for the route. // It returns (nil, nil) when no AuthService is configured. // When SecuritySchemes is empty, it falls back to Bearer-only validation for backward compatibility. func authenticate(c fiber.Ctx, config *Config, routeSecurity []map[string][]string) (*AuthContext, error) { diff --git a/auth_first_test.go b/auth_first_test.go index 54e281c..b7dcd81 100644 --- a/auth_first_test.go +++ b/auth_first_test.go @@ -47,12 +47,11 @@ func registerAuthFirstRoute(oapi *OApiApp) { }, OpenAPIOptions{}) } -func TestAuthenticateFirst_RejectsBeforeReadingTheBody(t *testing.T) { +func TestAuthFirst_RejectsBeforeReadingTheBody(t *testing.T) { app, decodes := newSpyApp() oapi := New(app, Config{ EnableValidation: true, EnableAuthorization: true, - AuthenticateFirst: true, AuthService: NewMockAuthService(), }) registerAuthFirstRoute(oapi) @@ -66,42 +65,38 @@ func TestAuthenticateFirst_RejectsBeforeReadingTheBody(t *testing.T) { assert.Equal(t, int32(2), decodes.Load()) } -func TestAuthenticateFirst_DisabledKeepsTheV32Order(t *testing.T) { +func TestAuthFirst_AuthErrorsComeBeforeInputErrors(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: true, + AuthService: NewMockAuthService(), + }) + registerAuthFirstRoute(oapi) + cases := []struct { - name string - token, body string - legacy, authFirst int + name string + token, body string + want int }{ - {name: "malformed body, no token", body: `{"name": 42`, legacy: 400, authFirst: 401}, - {name: "invalid body, no token", body: `{"name": "x"}`, legacy: 422, authFirst: 401}, - {name: "valid body, no token", body: `{"name": "widget"}`, legacy: 401, authFirst: 401}, - {name: "valid body, valid token", token: "valid-token", body: `{"name": "widget"}`, legacy: 200, authFirst: 200}, + {name: "malformed body, no token", body: `{"name": 42`, want: 401}, + {name: "invalid body, no token", body: `{"name": "x"}`, want: 401}, + {name: "valid body, no token", body: `{"name": "widget"}`, want: 401}, + {name: "malformed body, bad token", token: "bad-token", body: `{"name": 42`, want: 401}, + {name: "malformed body, valid token", token: "valid-token", body: `{"name": 42`, want: 400}, + {name: "invalid body, valid token", token: "valid-token", body: `{"name": "x"}`, want: 422}, + {name: "valid body, valid token", token: "valid-token", body: `{"name": "widget"}`, want: 200}, } - for _, authFirst := range []bool{false, true} { - app := fiber.New() - oapi := New(app, Config{ - EnableValidation: true, - EnableAuthorization: true, - AuthenticateFirst: authFirst, - AuthService: NewMockAuthService(), - }) - registerAuthFirstRoute(oapi) - for _, tc := range cases { - want := tc.legacy - if authFirst { - want = tc.authFirst - } - assert.Equal(t, want, postJSON(t, app, "/items", tc.token, tc.body), "%s (AuthenticateFirst=%v)", tc.name, authFirst) - } + for _, tc := range cases { + assert.Equal(t, tc.want, postJSON(t, app, "/items", tc.token, tc.body), tc.name) } } -func TestAuthenticateFirst_RouteMiddlewareSeesTheAuthContext(t *testing.T) { +func TestAuthFirst_RouteMiddlewareSeesTheAuthContext(t *testing.T) { app := fiber.New() oapi := New(app, Config{ EnableValidation: true, EnableAuthorization: true, - AuthenticateFirst: true, AuthService: NewMockAuthService(), }) var seen string @@ -125,12 +120,11 @@ type authFirstDocInput struct { DocID string `uri:"docId" resource:"document" action:"delete"` } -func TestAuthenticateFirst_RolesAndResourcesStillApply(t *testing.T) { +func TestAuthFirst_RolesAndResourcesStillApply(t *testing.T) { app := fiber.New() oapi := New(app, Config{ EnableValidation: true, EnableAuthorization: true, - AuthenticateFirst: true, AuthService: NewMockAuthService(), }) Get(oapi, "/admin", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { @@ -155,13 +149,12 @@ func TestAuthenticateFirst_RolesAndResourcesStillApply(t *testing.T) { assert.Equal(t, 200, del("admin-token")) } -func TestAuthenticateFirst_DisabledRouteSkipsAuthentication(t *testing.T) { +func TestAuthFirst_DisabledRouteSkipsAuthentication(t *testing.T) { var validations atomic.Int32 app := fiber.New() oapi := New(app, Config{ EnableValidation: true, EnableAuthorization: true, - AuthenticateFirst: true, AuthService: TokenValidatorFunc(func(string) (*AuthContext, error) { validations.Add(1) return nil, errTokenExpired @@ -176,12 +169,11 @@ func TestAuthenticateFirst_DisabledRouteSkipsAuthentication(t *testing.T) { assert.Zero(t, validations.Load()) } -func TestAuthenticateFirst_UsesTheErrorHandlers(t *testing.T) { +func TestAuthFirst_UsesTheErrorHandlers(t *testing.T) { app := fiber.New() oapi := New(app, Config{ EnableValidation: true, EnableAuthorization: true, - AuthenticateFirst: true, AuthService: causeAuthService{}, AuthErrorHandler: classifyingAuthErrorHandler, }) diff --git a/auth_schemes_test.go b/auth_schemes_test.go index d1a8790..86e3657 100644 --- a/auth_schemes_test.go +++ b/auth_schemes_test.go @@ -634,7 +634,7 @@ func TestMultiScheme_AllSchemesFail(t *testing.T) { func TestBackwardCompat_ExistingMockAuthService(t *testing.T) { // Existing MockAuthService (which does NOT implement any new interfaces) - // should continue to work with Bearer token via validateAuthorization + // should continue to work with Bearer token via authenticate app := fiber.New() authService := NewMockAuthService() diff --git a/common.go b/common.go index f40c708..f4dd056 100644 --- a/common.go +++ b/common.go @@ -109,19 +109,12 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope // Validate authorization if enabled in configuration and not disabled for this route if app.config.EnableAuthorization && options != nil && !securityDisabled(options) { - if app.config.AuthenticateFirst { - // The request was authenticated before being read (see - // authenticateHandler); only roles and resource permissions, - // which need the input, are left. - authCtx, _ := c.Locals("auth").(*AuthContext) - if err := authorize(c, authCtx, input, app.config.AuthService, options.RequiredRoles, options.RequireAllRoles); err != nil { - return input, err - } - } else { - routeSecurity, _ := options.Security.([]map[string][]string) - if err := validateAuthorization(c, input, &app.config, routeSecurity, options.RequiredRoles, options.RequireAllRoles); err != nil { - return input, err - } + // The request was authenticated before being read (see + // authenticateHandler); only roles and resource permissions, which + // need the input, are left. + authCtx, _ := c.Locals("auth").(*AuthContext) + if err := authorize(c, authCtx, input, app.config.AuthService, options.RequiredRoles, options.RequireAllRoles); err != nil { + return input, err } } diff --git a/fiberoapi.go b/fiberoapi.go index 6e018f5..c97d8bf 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -147,9 +147,6 @@ func New(app *fiber.App, config ...Config) *OApiApp { if provided.IncludeInvalidValueInErrors { cfg.IncludeInvalidValueInErrors = true } - if provided.AuthenticateFirst { - cfg.AuthenticateFirst = true - } if provided.ProblemDetails { cfg.ProblemDetails = true } @@ -1316,10 +1313,11 @@ func Method[TInput any, TOutput any, TError any]( } // Route chain: [authenticateHandler] + options.Middlewares + fiberHandler. - // With AuthenticateFirst, credentials are checked before anything reads the - // request, and the route middlewares can call GetAuthContext. + // On a protected route, credentials are checked before anything reads the + // request: a request that fails authentication never has its body decoded, + // and the route middlewares can call GetAuthContext. chain := make([]any, 0, len(options.Middlewares)+2) - if app.config.EnableAuthorization && app.config.AuthenticateFirst && !securityDisabled(&options) { + if app.config.EnableAuthorization && app.config.AuthService != nil && !securityDisabled(&options) { routeSecurity, _ := options.Security.([]map[string][]string) chain = append(chain, app.authenticateHandler(inputType, routeSecurity)) } @@ -1357,9 +1355,9 @@ func isNilPointer(v any) bool { return rv.Kind() == reflect.Ptr && rv.IsNil() } -// authenticateHandler builds the handler placed at the head of a protected -// route when Config.AuthenticateFirst is set: it authenticates the request, -// stores the AuthContext in c.Locals("auth") and moves on to the next handler. +// authenticateHandler builds the handler placed at the head of every protected +// route: it authenticates the request, stores the AuthContext in +// c.Locals("auth") and moves on to the next handler. func (app *OApiApp) authenticateHandler(inputType reflect.Type, routeSecurity []map[string][]string) fiber.Handler { return func(c fiber.Ctx) error { authCtx, err := authenticate(c, &app.config, routeSecurity) diff --git a/route_middlewares_test.go b/route_middlewares_test.go index f7344b7..146b512 100644 --- a/route_middlewares_test.go +++ b/route_middlewares_test.go @@ -91,13 +91,12 @@ func TestRouteMiddlewares_CanStopTheChain(t *testing.T) { assert.Equal(t, 1, handlerCalls) } -func TestRouteMiddlewares_RunAfterAuthenticateFirst(t *testing.T) { +func TestRouteMiddlewares_RunAfterAuthentication(t *testing.T) { var trace []string app := fiber.New() oapi := New(app, Config{ EnableValidation: true, EnableAuthorization: true, - AuthenticateFirst: true, AuthService: NewMockAuthService(), }) Get(oapi, "/me", func(c fiber.Ctx, _ struct{}) (fiber.Map, struct{}) { diff --git a/spec_runtime_alignment_test.go b/spec_runtime_alignment_test.go index 912ecff..c8827c6 100644 --- a/spec_runtime_alignment_test.go +++ b/spec_runtime_alignment_test.go @@ -218,7 +218,6 @@ func TestAlign_V33Options_RuntimeMatchesSpec(t *testing.T) { oapi := New(app, Config{ EnableValidation: true, EnableAuthorization: true, - AuthenticateFirst: true, AuthService: staticTokens, ProblemDetails: true, ValidationStatusCode: 400, diff --git a/types.go b/types.go index 816ac4f..d5a0133 100644 --- a/types.go +++ b/types.go @@ -94,16 +94,6 @@ type Config struct { IncludeInvalidValueInErrors bool // Include offending value in default error envelope (default: false — may leak secrets) - // AuthenticateFirst checks the credentials before anything reads the - // request, in a handler placed at the head of every protected route - // (before OpenAPIOptions.Middlewares). A request without a valid token is - // rejected without its body being decoded, and route middlewares can call - // GetAuthContext. Roles and resource permissions are still checked after - // the input is parsed, since they need it. Only has an effect with - // EnableAuthorization. Default: false (authentication runs after - // validation, as in v3.2). - AuthenticateFirst bool - // ProblemDetails makes every error produced by the library (parsing, // validation, authentication, 404/405, serialization) a Problem (RFC // 9457), sent and documented as application/problem+json. It takes @@ -164,7 +154,7 @@ type OpenAPIOptions struct { Errors []any `json:"-"` // Middlewares run before the request input is read, after the group - // middlewares and, with Config.AuthenticateFirst, after authentication. + // middlewares and, on a protected route, after authentication. // A middleware can answer on its own (429, 413...) to stop the chain. Middlewares []fiber.Handler `json:"-"`