A Go library that extends Fiber to add automatic OpenAPI documentation generation with built-in validation, authentication, and role-based access control.
- Complete HTTP methods (GET, POST, PUT, PATCH, DELETE, HEAD) with automatic validation
- Group support with OpenAPI methods available on both app and groups
- Unified API with interface-based approach for seamless app/group usage
- Powerful validation via
github.com/go-playground/validator/v10 - Multiple authentication schemes: Bearer, Basic Auth, API Key, AWS SigV4
- Declarative role-based access control with OR/AND semantics
- Custom error handlers for validation and authentication errors
- Per-route security overrides and public routes
- Type safety with Go generics
- OpenAPI 3.0 documentation in JSON and YAML formats
- Redoc documentation UI for modern, responsive API documentation
- OpenAPI extensions (
x-required-roles,x-required-roles-mode) - Conditional auth middleware for flexible authentication strategies
go get github.com/labbs/fiber-oapi/v3Upgrading from v1.x? v3 tracks Fiber v3 and requires Go 1.26+. Breaking changes:
- Handlers now take
fiber.Ctx(struct value) instead of*fiber.Ctx.- The path-parameter struct tag is now
uri:instead ofpath:(Fiber v3 binder convention). Query and header tags are unchanged.- The default validation/parse error response uses a new
ErrorEnvelopeshape (one entry per failing field,response_context.response_idmirrored fromX-Request-Id) and validation errors are returned as 422 Unprocessable Entity instead of 400. See Error responses.
package main
import (
"github.com/gofiber/fiber/v3"
fiberoapi "github.com/labbs/fiber-oapi/v3"
)
func main() {
app := fiber.New()
oapi := fiberoapi.New(app)
fiberoapi.Get(oapi, "/hello/:name",
func(c fiber.Ctx, input struct {
Name string `uri:"name" validate:"required,min=2"`
}) (fiber.Map, *fiberoapi.ErrorResponse) {
return fiber.Map{"message": "Hello " + input.Name}, nil
},
fiberoapi.OpenAPIOptions{
Summary: "Say hello",
Tags: []string{"greeting"},
})
// Docs at /docs, spec at /openapi.json and /openapi.yaml
app.Listen(":3000")
}type Config struct {
EnableValidation bool // Enable input validation (default: true)
EnableOpenAPIDocs bool // Enable automatic docs setup (default: true)
EnableAuthorization bool // Enable auth validation (default: false)
OpenAPIDocsPath string // Path for docs UI (default: "/docs")
OpenAPIJSONPath string // Path for JSON spec (default: "/openapi.json")
OpenAPIYamlPath string // Path for YAML spec (default: "/openapi.yaml")
OpenAPITitle string // Spec title (default: "Fiber OpenAPI")
OpenAPIDescription string // Spec description (default: "API documentation generated by fiber-oapi")
OpenAPIVersion string // Spec version (default: "1.0.0")
AuthService AuthorizationService // Service for handling auth
SecuritySchemes map[string]SecurityScheme // OpenAPI security schemes
DefaultSecurity []map[string][]string // Default security requirements
ValidationErrorHandler ValidationErrorHandler // Custom handler for validation errors
AuthErrorHandler AuthErrorHandler // Custom handler for auth errors (401/403/5xx)
}Default config when none is provided:
- Validation: enabled
- Documentation: enabled
- Authorization: disabled
- Docs path:
/docs - JSON spec path:
/openapi.json - YAML spec path:
/openapi.yaml - Spec title:
Fiber OpenAPI - Spec description:
API documentation generated by fiber-oapi - Spec version:
1.0.0
Override the info block of the generated spec via Config:
fiberoapi.New(app, fiberoapi.Config{
OpenAPITitle: "My Service",
OpenAPIDescription: "Public REST API for the My Service platform",
OpenAPIVersion: "2024.10.1",
})All methods work with both the main app and groups:
fiberoapi.Get(router, path, handler, options)
fiberoapi.Post(router, path, handler, options)
fiberoapi.Put(router, path, handler, options)
fiberoapi.Patch(router, path, handler, options)
fiberoapi.Delete(router, path, handler, options)
fiberoapi.Head(router, path, handler, options)
fiberoapi.Method(method, router, path, handler, options) // Custom HTTP methodtype MyInput struct {
ID string `uri:"id" validate:"required"` // Path parameter
Filter string `query:"filter" validate:"omitempty"` // Query parameter
Auth string `header:"Authorization"` // Header parameter
Title string `json:"title" validate:"required,min=1"` // JSON body field
}Special tags:
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 specresource:"document"— Mark field as a resource identifier for dynamic authorizationaction:"write"— Specify the action for resource access checks
app := fiber.New()
oapi := fiberoapi.New(app)
v1 := fiberoapi.Group(oapi, "/api/v1")
users := fiberoapi.Group(v1, "/users")
// OpenAPI methods on groups
fiberoapi.Get(users, "/:id", getUser, options) // Registers as GET /api/v1/users/{id}
fiberoapi.Post(users, "/", createUser, options)
// Standard Fiber middleware still works
v1.Use(authMiddleware)Uses validator/v10. Common tags:
type Input struct {
Name string `json:"name" validate:"required,min=3,max=50"`
Email string `json:"email" validate:"required,email"`
Age int `json:"age" validate:"min=13,max=120"`
Role string `json:"role" validate:"oneof=admin user guest"`
Tags []string `json:"tags" validate:"dive,min=1"`
}When the default validation / parse handler runs (i.e. no custom ValidationErrorHandler
is configured), errors are returned as a structured envelope with one entry per
failing field:
{
"errors": [
{
"type": "validation_error",
"code": 422,
"loc": ["body", "workspaceId"],
"field": "workspaceId",
"msg": "field 'workspaceId' must be at least 11",
"constraint": "min=11"
},
{
"type": "validation_error",
"code": 422,
"loc": ["body", "nested", "slug"],
"field": "slug",
"msg": "field 'slug' must be at least 2",
"constraint": "min=2"
}
],
"response_context": {
"response_id": "bf0e9029-576b-42e8-84f9-ad0622972f50"
}
}Status codes used by the default handler:
| Code | Category | Entry type |
|---|---|---|
| 422 | Failed validation rules | validation_error |
| 400 | JSON parse / type mismatch | type_error, parse_error |
| 401 / 403 | Authentication / authorization | authentication_error, authorization_error |
response_context.response_id mirrors the X-Request-Id request header when
present (no UUID is generated by the lib — pair with a requestid middleware
if you want one). The loc array starts with the request source (body, path,
query, header) followed by the field path using JSON / URI / header tag names.
By default the offending value is omitted to avoid leaking secrets (e.g. a
password failing min=8 validation). Opt in via Config.IncludeInvalidValueInErrors: true
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
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
status codes appear in the spec.
Some routes — internal admin endpoints, debug handlers, in-progress features —
serve traffic but shouldn't appear in the published spec. Set
OpenAPIOptions.Hidden = true:
fiberoapi.Get(oapi, "/admin/debug/:id",
controller.Debug,
fiberoapi.OpenAPIOptions{
OperationID: "internalDebug",
Hidden: true, // ← serves traffic, absent from /openapi.json
},
)The path entry is omitted and any type only referenced by hidden routes is
skipped from components.schemas (so reading the spec doesn't leak the shape
of internal endpoints). Types used by both hidden and visible routes still
surface — the visible route needs them.
Some endpoints — health checks, readiness probes, anything with no body or no
error contract — don't need the framework's 400 / 422 / 404 / 4XX in the spec.
Pass an explicit empty Errors slice to suppress every default and document
only the 200 success path:
fiberoapi.Get(oapi, "/health",
func(c fiber.Ctx, _ struct{}) (HealthStatus, struct{}) {
return HealthStatus{Status: "ok"}, struct{}{}
},
fiberoapi.OpenAPIOptions{
Summary: "Liveness probe",
Errors: []any{}, // ← explicit "no errors for this route"
},
)The distinction matters: Errors: nil (the zero value) keeps the defaults,
Errors: []any{} (an explicitly empty slice) suppresses them. A slice that
contains only nil entries is treated as nil for back-compat.
If you need a different shape, set Config.ValidationErrorHandler / Config.AuthErrorHandler
— they receive the raw error (JSON type mismatches are wrapped so err.Error()
stays friendly, but var ute *json.UnmarshalTypeError; errors.As(err, &ute) still recovers
the original).
By default, the library emits its own ErrorEnvelope shape for framework-level
errors (parse, validation, auth, route-miss) while your custom errors use
whatever struct you declared. That mismatch shows up in tools like Redoc /
Stoplight as two distinct schemas per endpoint.
Set Config.DefaultErrorShape to a (zero) instance of your error type and the
library uses that shape everywhere — both runtime responses and spec entries:
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
Type string `json:"type"`
}
oapi := fiberoapi.New(app, fiberoapi.Config{
DefaultErrorShape: &ErrorResponse{}, // empty template — fields are filled per error
})For each library-emitted error, the matching fields on your shape are populated via reflection (case-sensitive, applied only if present and settable):
| Field | Source |
|---|---|
StatusCode or Code |
HTTP status code (400, 401, 403, 404, 405) |
Message, Description, or Msg |
one-line human-readable summary |
Type |
discriminator (type_error, parse_error, authentication_error, authorization_error, not_found, method_not_allowed) |
Details |
joined extra context (allowed methods for 405, …) |
One exception: the 422 validation response keeps the rich ErrorEnvelope
shape (one entry per failing field with loc / constraint / field /
value). Collapsing a multi-field validation failure into a single flat struct
would lose the structured info that form-level UX needs. If you really want a
flat 422, declare your own entry at status 422 in OpenAPIOptions.Errors — the
per-route override still wins.
Result: in your spec every error response references #/components/schemas/ErrorResponse
except 422, which stays on #/components/schemas/ErrorEnvelope.
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.
For handler-emitted errors (conflict, not-found, precondition-failed, …),
declare them via OpenAPIOptions.Errors. Each entry is an instance of any
struct describing one error case — the library inspects it to populate the
generated OpenAPI spec and the handler returns the same instance at runtime.
// One shared type in your app — all custom errors funnel through it so the
// spec has a single component schema and clients see one consistent shape.
type ErrorResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Details string `json:"details,omitempty"`
Type string `json:"type"`
}
func (e *ErrorResponse) Error() string { return e.Message } // optional
func UserAlreadyExists(name string) *ErrorResponse {
return &ErrorResponse{Code: 409, Message: fmt.Sprintf("user %q already exists", name), Type: "Conflict"}
}
func UserNotFound(name string) *ErrorResponse {
return &ErrorResponse{Code: 404, Message: fmt.Sprintf("user %q not found", name), Type: "NotFound"}
}
fiberoapi.Post(oapi, "/users/:name",
func(c fiber.Ctx, in CreateUserInput) (CreateUserOutput, error) {
if in.Name == "admin" {
return CreateUserOutput{}, UserAlreadyExists(in.Name)
}
return CreateUserOutput{Message: "ok"}, nil
},
fiberoapi.OpenAPIOptions{
Errors: []any{
UserAlreadyExists("admin"), // becomes the 409 example in the spec
UserNotFound("ghost"), // becomes the 404 example in the spec
},
},
)How each entry maps to the spec:
| Source | Extracted via |
|---|---|
| Status code | HTTPStatus() int method, else StatusCode / Code int field, else 500 |
| Description | Description() string method, else Message / Description / Msg string field, else HTTP reason phrase |
| Schema | reflect.TypeOf(entry) — named types use $ref to components.schemas so multiple entries with the same type stay deduplicated |
| Example | the entry value itself, JSON-marshalled |
The handler's return type can be error (when the entry implements error),
*ErrorResponse, or any other concrete type — the library uses reflection,
not a type assertion, to read the status code.
Multiple entries at the same status code: the last one wins (typical when
mixing Errors with the auto-emitted default envelopes). Use this to
deliberately override the default 404 (route-miss) envelope with your own
domain-404 shape for routes that report "resource not found".
The same envelope is produced for unmatched routes when you opt in via
oapi.UseNotFoundHandler(). Call it after registering every route — under
the hood it installs a catch-all app.Use(handler) middleware in Fiber, which
is matched in registration order, so it must come last to avoid swallowing real
routes.
app := fiber.New()
oapi := fiberoapi.New(app)
fiberoapi.Get(oapi, "/users/:id", getUser, opts)
fiberoapi.Post(oapi, "/users", createUser, opts)
// ... every other route ...
oapi.UseNotFoundHandler() // ← last
app.Listen(":3000")Response shape:
{
"errors": [
{
"type": "not_found",
"code": 404,
"loc": ["path"],
"field": "/users/42",
"msg": "no route matches GET /users/42"
}
],
"response_context": { "response_id": "bf0e9029-..." }
}The default handler does more than just emit the 404 envelope:
- HEAD requests get a bodyless 404 (HTTP-conformant).
- OPTIONS requests fall through (
c.Next()) so downstream CORS middleware can answer preflights. - When the path is registered under another HTTP method, the response is 405
with an
Allowheader listing the supported methods and the envelope's entrytypeset tomethod_not_allowed. - The
X-Request-Idheader is sanitised before being echoed (max 128 bytes, charset[A-Za-z0-9._\-:]) — invalid values are dropped to neutralise log-injection vectors. - The echoed path is capped at ~1 KiB and validated as UTF-8.
Calling UseNotFoundHandler() more than once on the same OApiApp is a no-op
after the first install. Once installed, the generated OpenAPI spec also lists
a 404 response on every operation (referencing ErrorEnvelope) so the contract
is documented for clients.
Override the handler entirely via Config.NotFoundHandler. Your handler
receives a raw fiber.Ctx and owns the full response — call
fiberoapi.NotFoundEnvelope(c) to reuse the library's shape from inside it.
For users managing their own fiber.Config, fiberoapi.DefaultNotFoundHandler()
returns a no-op-405 version of the catch-all you can install yourself.
| Scheme | Config | Validator Interface |
|---|---|---|
| Bearer Token | Type: "http", Scheme: "bearer" |
AuthorizationService (built-in) |
| HTTP Basic | Type: "http", Scheme: "basic" |
BasicAuthValidator |
| API Key | Type: "apiKey", In: "header"/"query"/"cookie" |
APIKeyValidator |
| AWS SigV4 | Type: "http", Scheme: "AWS4-HMAC-SHA256" |
AWSSignatureValidator |
Implement AuthorizationService (required) and any additional validator interfaces:
type MyAuthService struct{}
// Required: AuthorizationService
func (s *MyAuthService) ValidateToken(token string) (*fiberoapi.AuthContext, error) { ... }
func (s *MyAuthService) HasRole(ctx *fiberoapi.AuthContext, role string) bool { ... }
func (s *MyAuthService) HasScope(ctx *fiberoapi.AuthContext, scope string) bool { ... }
func (s *MyAuthService) CanAccessResource(ctx *fiberoapi.AuthContext, resourceType, resourceID, action string) (bool, error) { ... }
func (s *MyAuthService) GetUserPermissions(ctx *fiberoapi.AuthContext, resourceType, resourceID string) (*fiberoapi.ResourcePermission, error) { ... }
// Optional: BasicAuthValidator
func (s *MyAuthService) ValidateBasicAuth(username, password string) (*fiberoapi.AuthContext, error) { ... }
// Optional: APIKeyValidator
func (s *MyAuthService) ValidateAPIKey(key, location, paramName string) (*fiberoapi.AuthContext, error) { ... }
// Optional: AWSSignatureValidator
func (s *MyAuthService) ValidateAWSSignature(params *fiberoapi.AWSSignatureParams) (*fiberoapi.AuthContext, error) { ... }Configure multiple security schemes (OR semantics between them):
config := fiberoapi.Config{
EnableAuthorization: true,
AuthService: &MyAuthService{},
SecuritySchemes: map[string]fiberoapi.SecurityScheme{
"bearerAuth": {
Type: "http",
Scheme: "bearer",
BearerFormat: "JWT",
Description: "JWT Bearer token",
},
"basicAuth": {
Type: "http",
Scheme: "basic",
Description: "HTTP Basic authentication",
},
"apiKeyAuth": {
Type: "apiKey",
In: "header",
Name: "X-API-Key",
Description: "API Key via header",
},
},
// Any of these schemes can authenticate a request (OR semantics)
DefaultSecurity: []map[string][]string{
{"bearerAuth": {}},
{"basicAuth": {}},
{"apiKeyAuth": {}},
},
}
oapi := fiberoapi.New(app, config)// Public route — no authentication
fiberoapi.Get(oapi, "/health", handler,
fiberoapi.OpenAPIOptions{
Summary: "Health check",
Security: "disabled",
})
// Protected route — uses default security
fiberoapi.Get(oapi, "/profile", handler,
fiberoapi.OpenAPIOptions{
Summary: "Get profile",
})
// Per-route security override
fiberoapi.Get(oapi, "/admin", handler,
fiberoapi.WithSecurity(
fiberoapi.OpenAPIOptions{Summary: "Admin endpoint"},
[]map[string][]string{{"bearerAuth": {}}}, // Only bearer, not API key
))Roles are checked automatically before your handler runs. No manual checks needed.
// OR semantics: user needs at least ONE of the listed roles
fiberoapi.Get(oapi, "/documents/:id", handler,
fiberoapi.WithRoles(
fiberoapi.OpenAPIOptions{Summary: "Get document", Tags: []string{"documents"}},
"admin", "editor", // admin OR editor can access
))
// AND semantics: user needs ALL of the listed roles
fiberoapi.Delete(oapi, "/documents/:id", handler,
fiberoapi.WithAllRoles(
fiberoapi.OpenAPIOptions{Summary: "Delete document", Tags: []string{"documents"}},
"admin", "moderator", // must be admin AND moderator
))
// Inline via OpenAPIOptions
fiberoapi.Get(oapi, "/settings", handler,
fiberoapi.OpenAPIOptions{
Summary: "Settings",
RequiredRoles: []string{"admin", "superadmin"},
RequireAllRoles: false, // OR semantics (default)
})Roles appear in the OpenAPI spec as extensions:
{
"x-required-roles": ["admin", "editor"],
"x-required-roles-mode": "any"
}// RequiredPermissions are documented in the OpenAPI spec description
fiberoapi.Put(oapi, "/documents/:id", handler,
fiberoapi.OpenAPIOptions{
Summary: "Update document",
RequiredRoles: []string{"editor"},
RequiredPermissions: []string{"document:write"},
})
// Resource-based access via struct tags
type UpdateDocInput struct {
DocumentID string `uri:"documentId" validate:"required" resource:"document" action:"write"`
Title string `json:"title" validate:"required"`
}
// Dynamic resource access check in handler
fiberoapi.RequireResourceAccess(c, authService, "document", docID, "delete")Access the authenticated user in handlers:
fiberoapi.Get(oapi, "/me", func(c fiber.Ctx, input struct{}) (fiber.Map, *fiberoapi.ErrorResponse) {
authCtx, err := fiberoapi.GetAuthContext(c)
if err != nil {
return nil, &fiberoapi.ErrorResponse{Code: 401, Details: "Not authenticated"}
}
return fiber.Map{
"user_id": authCtx.UserID,
"roles": authCtx.Roles,
"scopes": authCtx.Scopes,
"claims": authCtx.Claims,
}, nil
}, fiberoapi.OpenAPIOptions{Summary: "Current user"})oapi := fiberoapi.New(app, fiberoapi.Config{
ValidationErrorHandler: func(c fiber.Ctx, err error) error {
return c.Status(400).JSON(fiber.Map{
"success": false,
"error": err.Error(),
})
},
})oapi := fiberoapi.New(app, fiberoapi.Config{
EnableAuthorization: true,
AuthService: authService,
AuthErrorHandler: func(c fiber.Ctx, err *fiberoapi.AuthError) error {
// err.StatusCode: 401, 403, or 5xx
// err.Message: human-readable error message
return c.Status(err.StatusCode).JSON(fiber.Map{
"error": err.Message,
"status": err.StatusCode,
})
},
})Without custom handlers, default error responses are returned:
// 401 - Authentication failure
{"code": 401, "details": "invalid token", "type": "authentication_error"}
// 403 - Authorization failure
{"code": 403, "details": "requires one of: admin, editor", "type": "authorization_error"}
// 400 - Validation failure
{"code": 400, "details": "...", "type": "validation_error"}Standalone middleware functions for use outside the declarative route system:
// Smart middleware that auto-detects security schemes and excludes doc routes
app.Use(fiberoapi.SmartAuthMiddleware(authService, config))
// Skip auth for specific paths
app.Use(fiberoapi.ConditionalAuthMiddleware(
fiberoapi.BearerTokenMiddleware(authService),
"/health", "/docs", "/openapi.json",
))
// Individual scheme middleware
app.Use(fiberoapi.BearerTokenMiddleware(authService))
app.Use(fiberoapi.BasicAuthMiddleware(authService))
app.Use(fiberoapi.APIKeyMiddleware(authService, scheme))
app.Use(fiberoapi.AWSSignatureMiddleware(authService))
// Role guard middleware
app.Use(fiberoapi.RoleGuard(authService, "admin"))opts := fiberoapi.OpenAPIOptions{Summary: "My endpoint"}
// Security
opts = fiberoapi.WithSecurity(opts, []map[string][]string{{"bearerAuth": {}}})
opts = fiberoapi.WithSecurityDisabled(opts)
// Roles
opts = fiberoapi.WithRoles(opts, "admin", "editor") // OR semantics
opts = fiberoapi.WithAllRoles(opts, "admin", "moderator") // AND semantics
// Documentation
opts = fiberoapi.WithPermissions(opts, "document:read", "document:write")
opts = fiberoapi.WithResourceType(opts, "document")The spec is available in both JSON and YAML:
// Automatic endpoints
// GET /openapi.json
// GET /openapi.yaml
// GET /docs (Redoc UI)
// Programmatic access
spec := oapi.GenerateOpenAPISpec() // map[string]interface{}
yamlSpec, err := oapi.GenerateOpenAPISpecYAML() // stringoapi.SetupDocs(fiberoapi.DocConfig{
Title: "My API",
Description: "My API description",
Version: "2.0.0",
DocsPath: "/documentation",
JSONPath: "/api-spec.json",
})# Run all tests
go test -v ./...
# Run the auth example
go run _examples/auth/main.go
# Visit http://localhost:3002/docsTesting with authentication:
# Bearer token
curl -H "Authorization: Bearer admin-token" http://localhost:3002/me
# Basic auth
curl --user admin:admin-pass http://localhost:3002/me
# API key
curl -H "X-API-Key: my-secret-api-key" http://localhost:3002/documents/doc-1
# Public endpoint
curl http://localhost:3002/healthSee _examples/auth/main.go for a full working example with:
- Multiple security schemes (Bearer, Basic, API Key, AWS SigV4)
- Declarative role-based access control
- Custom auth error handler
- Public and protected routes
- Resource-level authorization
- OpenAPI documentation with security schemes