diff --git a/README.md b/README.md index 21b6793..9de2727 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** +> - [`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 **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. + +> **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,13 @@ 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) + + 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 +150,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, 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{ + Security: "disabled", + Middlewares: []fiber.Handler{limiter.New(limiter.Config{Max: 5})}, // 429 +}) +``` + ## Groups ```go @@ -163,6 +266,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` @@ -199,7 +402,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` | @@ -214,7 +417,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 @@ -269,6 +473,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 @@ -312,6 +574,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. @@ -508,6 +772,57 @@ 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) +} +``` + +### Authentication runs first + +On a protected route, the credentials are checked before anything reads the +request, in a handler placed at the head of the route: + +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 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, + since they need it. +- Routes with `Security: "disabled"` are not authenticated. + ### Public vs Protected Routes ```go @@ -641,18 +956,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 diff --git a/auth.go b/auth.go index f8a9423..ce80f1e 100644 --- a/auth.go +++ b/auth.go @@ -88,24 +88,30 @@ 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", }) } token := strings.TrimPrefix(authHeader, "Bearer ") - authCtx, err := validator.ValidateToken(token) + authCtx, err := validateToken(c, validator, 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(), }) @@ -140,44 +146,36 @@ func RoleGuard(validator AuthorizationService, requiredRoles ...string) fiber.Ha } } -// validateAuthorization validates permissions based on configured security schemes. +// 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 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 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. - 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()} - } - c.Locals("auth", authCtx) - - // Check roles before resource access - if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil { - return err + // falling back to 401 only for untyped errors. The cause is kept. + return nil, authErrorFrom(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) } @@ -189,31 +187,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 - var existingAuthErr *AuthError - if errors.As(lastErr, &existingAuthErr) { - return 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"} } - var scopeErr *ScopeError - if errors.As(lastErr, &scopeErr) { - return &AuthError{StatusCode: 403, Message: lastErr.Error()} + + // Check roles before resource access + if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil { + return err } - return &AuthError{StatusCode: 401, Message: lastErr.Error()} + return validateResourceAccess(c, authCtx, input, authService) } // checkRequiredRoles checks the authenticated user's roles against the required roles. @@ -270,7 +278,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..e7ccd4f --- /dev/null +++ b/auth_error_cause_test.go @@ -0,0 +1,188 @@ +package fiberoapi + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "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 newBearerRequest(method, path, token string) *http.Request { + req := httptest.NewRequest(method, path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + 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 + _ = 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_first_test.go b/auth_first_test.go new file mode 100644 index 0000000..b7dcd81 --- /dev/null +++ b/auth_first_test.go @@ -0,0 +1,224 @@ +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 TestAuthFirst_RejectsBeforeReadingTheBody(t *testing.T) { + app, decodes := newSpyApp() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: 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 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 + want int + }{ + {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 _, tc := range cases { + assert.Equal(t, tc.want, postJSON(t, app, "/items", tc.token, tc.body), tc.name) + } +} + +func TestAuthFirst_RouteMiddlewareSeesTheAuthContext(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: 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 TestAuthFirst_RolesAndResourcesStillApply(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: 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 TestAuthFirst_DisabledRouteSkipsAuthentication(t *testing.T) { + var validations atomic.Int32 + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: 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 TestAuthFirst_UsesTheErrorHandlers(t *testing.T) { + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: 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/auth_schemes.go b/auth_schemes.go index 5467a14..110bf66 100644 --- a/auth_schemes.go +++ b/auth_schemes.go @@ -2,6 +2,7 @@ package fiberoapi import ( "encoding/base64" + "errors" "fmt" "sort" "strings" @@ -67,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. @@ -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/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/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..f4dd056 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) @@ -49,21 +55,20 @@ 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" { + 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") @@ -84,6 +89,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 } @@ -97,23 +108,69 @@ 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 err := validateAuthorization(c, input, cfg.AuthService, &cfg, options.RequiredRoles, options.RequireAllRoles); err != nil { - return input, err - } + if app.config.EnableAuthorization && options != nil && !securityDisabled(options) { + // 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 } } 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" +} + +// 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. // @@ -124,17 +181,29 @@ func parseInput[TInput any](app *OApiApp, c fiber.Ctx, path string, options *Ope // 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 93c05e2..3ed5d98 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,27 @@ 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 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) +} + // BasicAuthMiddleware creates a standalone middleware for HTTP Basic authentication. // The authService must implement the BasicAuthValidator interface. func BasicAuthMiddleware(validator AuthorizationService) fiber.Handler { 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 277c7b4..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 != "" { @@ -312,18 +316,19 @@ 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()), 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) } - return ErrorEnvelope{Errors: entries, ResponseContext: ctx}, statusValidationError + return ErrorEnvelope{Errors: entries, ResponseContext: ctx}, cfg.validationStatus() } // Anything else — generic parse error. @@ -345,14 +350,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 +399,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 +429,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)) @@ -556,6 +568,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 bef0af3..c97d8bf 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 @@ -141,6 +147,15 @@ func New(app *fiber.App, config ...Config) *OApiApp { if provided.IncludeInvalidValueInErrors { cfg.IncludeInvalidValueInErrors = 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{ @@ -236,7 +251,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) { @@ -276,6 +292,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, @@ -357,7 +376,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{} @@ -371,10 +405,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, }, }, @@ -385,8 +423,29 @@ 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.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) var schemaRef map[string]interface{} @@ -400,14 +459,20 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { } } - responses["200"] = map[string]interface{}{ - "description": "Successful response", + 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, }, }, } + } 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 @@ -446,75 +511,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) { + 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) { + 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 @@ -529,6 +622,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 } @@ -536,6 +634,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() @@ -827,7 +973,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 } @@ -1080,12 +1231,17 @@ 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 var outputZero TOutput var errorZero TError + outputIsResponder := reflect.TypeFor[TOutput]().Implements(responderType) + app.operations = append(app.operations, OpenAPIOperation{ Method: m, Path: fullPath, @@ -1093,6 +1249,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) @@ -1101,42 +1258,52 @@ 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) 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 { + 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) + + 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{ + 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 } @@ -1145,7 +1312,113 @@ func Method[TInput any, TOutput any, TError any]( return nil } - app.f.Add([]string{m}, fullPath, fiberHandler) + // Route chain: [authenticateHandler] + options.Middlewares + fiberHandler. + // 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.AuthService != nil && !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:]...) +} + +// 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 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) + 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 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, app.config.validationStatus()) + 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) +} + +// 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 + } + // 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) } // Get defines a GET operation for the OpenAPI documentation 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..a87231f --- /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.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) + 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/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/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/route_middlewares_test.go b/route_middlewares_test.go new file mode 100644 index 0000000..146b512 --- /dev/null +++ b/route_middlewares_test.go @@ -0,0 +1,113 @@ +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_RunAfterAuthentication(t *testing.T) { + var trace []string + app := fiber.New() + oapi := New(app, Config{ + EnableValidation: true, + EnableAuthorization: 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/spec_runtime_alignment_test.go b/spec_runtime_alignment_test.go index a7ec29b..c8827c6 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,134 @@ 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") +} + +// 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, + 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") +} 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/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") +} diff --git a/types.go b/types.go index dc2af72..d5a0133 100644 --- a/types.go +++ b/types.go @@ -93,6 +93,24 @@ type Config struct { DefaultErrorShape any IncludeInvalidValueInErrors bool // Include offending value in default error envelope (default: false — may leak secrets) + + // 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 @@ -134,6 +152,35 @@ 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, on a protected route, 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:"-"` + + // 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:"-"` + + // 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 @@ -144,6 +191,8 @@ type OpenAPIOperation struct { InputType reflect.Type OutputType reflect.Type ErrorType reflect.Type + + responder bool // the output implements Responder } type OpenAPIParameter struct {