diff --git a/README.md b/README.md index dfb7c0f..8f5f22b 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,22 @@ # Fiber OpenAPI -A Go library that extends Fiber to add automatic OpenAPI documentation generation with built-in validation and group support. +A Go library that extends Fiber to add automatic OpenAPI documentation generation with built-in validation, authentication, and role-based access control. ## Features -- ✅ **Complete HTTP methods** (GET, POST, PUT, DELETE) 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` -- ✅ **Authentication & Authorization** with JWT/Bearer token support and role-based access control -- ✅ **OpenAPI Security** documentation with automatic security scheme generation -- ✅ **Type safety** with Go generics -- ✅ **Custom error handling** -- ✅ **OpenAPI documentation generation** with automatic schema generation -- ✅ **Redoc documentation UI** for modern, responsive API documentation -- ✅ **Support for path, query, and body parameters** -- ✅ **Automatic documentation setup** with configurable paths +- **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 ## Installation @@ -25,8 +26,6 @@ go get github.com/labbs/fiber-oapi ## Quick Start -### Basic Usage with Default Configuration - ```go package main @@ -37,951 +36,430 @@ import ( func main() { app := fiber.New() - - // Create OApi app with default configuration - // Documentation will be available at /documentation (Redoc UI) and /openapi.json oapi := fiberoapi.New(app) - // Your routes here... + fiberoapi.Get(oapi, "/hello/:name", + func(c *fiber.Ctx, input struct { + Name string `path:"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"}, + }) - oapi.Listen(":3000") + // Docs at /docs, spec at /openapi.json and /openapi.yaml + app.Listen(":3000") } ``` -### Using Groups +## Configuration ```go -func main() { - app := fiber.New() - oapi := fiberoapi.New(app) - - // Create groups with OpenAPI support - v1 := fiberoapi.Group(oapi, "/api/v1") - v2 := fiberoapi.Group(oapi, "/api/v2") - - // Nested groups - users := fiberoapi.Group(v1, "/users") - admin := fiberoapi.Group(v1, "/admin") - - // Routes work the same on app, groups, and nested groups - fiberoapi.Get(oapi, "/health", handler, options) // On app - fiberoapi.Get(v1, "/status", handler, options) // On group - fiberoapi.Post(users, "/", handler, options) // On nested group - - oapi.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") + 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) } ``` -### Custom Configuration +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` -```go -func main() { - app := fiber.New() - - // Custom configuration - config := fiberoapi.Config{ - EnableValidation: true, // Enable input validation - EnableOpenAPIDocs: true, // Enable automatic docs setup - EnableAuthorization: false, // Enable authentication (default: false) - OpenAPIDocsPath: "/docs", // Custom docs path (default: /docs) - OpenAPIJSONPath: "/openapi.json", // Custom spec path (default: /openapi.json) - OpenAPIYamlPath: "/openapi.yaml", // Custom spec path (default: /openapi.yaml) - } - oapi := fiberoapi.New(app, config) +## HTTP Methods - // Your routes here... +All methods work with both the main app and groups: - oapi.Listen(":3000") -} +```go +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 method ``` -## Usage Examples - -### GET with path parameters and validation +## Parameter Types ```go -type GetInput struct { - Name string `path:"name" validate:"required,min=2"` -} - -type GetOutput struct { - Message string `json:"message"` -} - -type GetError struct { - Code int `json:"code"` - Message string `json:"message"` +type MyInput struct { + ID string `path:"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 } - -// Works on app -fiberoapi.Get(oapi, "/greeting/:name", - func(c *fiber.Ctx, input GetInput) (GetOutput, GetError) { - return GetOutput{Message: "Hello " + input.Name}, GetError{} - }, - fiberoapi.OpenAPIOptions{ - OperationID: "get-greeting", - Tags: []string{"greeting"}, - Summary: "Get a personalized greeting", - }) - -// Works on groups too -v1 := fiberoapi.Group(oapi, "/api/v1") -fiberoapi.Get(v1, "/greeting/:name", handler, options) ``` -### POST with JSON body and validation +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 spec +- `resource:"document"` — Mark field as a resource identifier for dynamic authorization +- `action:"write"` — Specify the action for resource access checks + +## Groups ```go -type CreateUserInput struct { - Username string `json:"username" validate:"required,min=3,max=20,alphanum"` - Email string `json:"email" validate:"required,email"` - Age int `json:"age" validate:"required,min=13,max=120"` -} +app := fiber.New() +oapi := fiberoapi.New(app) -type CreateUserOutput struct { - ID string `json:"id"` - Message string `json:"message"` -} +v1 := fiberoapi.Group(oapi, "/api/v1") +users := fiberoapi.Group(v1, "/users") -type CreateUserError struct { - Code int `json:"code"` - Message string `json:"message"` -} +// OpenAPI methods on groups +fiberoapi.Get(users, "/:id", getUser, options) // Registers as GET /api/v1/users/{id} +fiberoapi.Post(users, "/", createUser, options) -fiberoapi.Post(oapi, "/users", - func(c *fiber.Ctx, input CreateUserInput) (CreateUserOutput, CreateUserError) { - if input.Username == "admin" { - return CreateUserOutput{}, CreateUserError{ - Code: 403, - Message: "Username 'admin' is reserved", - } - } - - return CreateUserOutput{ - ID: "user_" + input.Username, - Message: "User created successfully", - }, CreateUserError{} - }, - fiberoapi.OpenAPIOptions{ - OperationID: "create-user", - Tags: []string{"users"}, - Summary: "Create a new user", - }) +// Standard Fiber middleware still works +v1.Use(authMiddleware) ``` -### PUT with path parameters and JSON body - -```go -type UpdateUserInput struct { - ID string `path:"id" validate:"required"` - Username string `json:"username" validate:"omitempty,min=3,max=20,alphanum"` - Email string `json:"email" validate:"omitempty,email"` - Age int `json:"age" validate:"omitempty,min=13,max=120"` -} - -type UpdateUserOutput struct { - ID string `json:"id"` - Message string `json:"message"` - Updated bool `json:"updated"` -} - -fiberoapi.Put(oapi, "/users/:id", - func(c *fiber.Ctx, input UpdateUserInput) (UpdateUserOutput, CreateUserError) { - if input.ID == "notfound" { - return UpdateUserOutput{}, CreateUserError{ - Code: 404, - Message: "User not found", - } - } - - return UpdateUserOutput{ - ID: input.ID, - Message: "User updated successfully", - Updated: true, - }, CreateUserError{} - }, - fiberoapi.OpenAPIOptions{ - OperationID: "update-user", - Tags: []string{"users"}, - Summary: "Update an existing user", - }) -``` +## Validation -### DELETE with path parameters +Uses `validator/v10`. Common tags: ```go -type DeleteUserInput struct { - ID string `path:"id" validate:"required"` -} - -type DeleteUserOutput struct { - ID string `json:"id"` - Message string `json:"message"` - Deleted bool `json:"deleted"` +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"` } - -fiberoapi.Delete(oapi, "/users/:id", - func(c *fiber.Ctx, input DeleteUserInput) (DeleteUserOutput, CreateUserError) { - if input.ID == "protected" { - return DeleteUserOutput{}, CreateUserError{ - Code: 403, - Message: "User is protected and cannot be deleted", - } - } - - return DeleteUserOutput{ - ID: input.ID, - Message: "User deleted successfully", - Deleted: true, - }, CreateUserError{} - }, - fiberoapi.OpenAPIOptions{ - OperationID: "delete-user", - Tags: []string{"users"}, - Summary: "Delete a user", - }) ``` ## Authentication & Authorization -Fiber-oapi provides comprehensive authentication and authorization support with JWT/Bearer tokens, role-based access control, and automatic OpenAPI security documentation. +### Supported Security Schemes -### Basic Authentication Setup +| 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` | -First, implement the `AuthorizationService` interface: - -```go -package main +### Setup -import ( - "fmt" - "time" - fiberoapi "github.com/labbs/fiber-oapi" - "github.com/gofiber/fiber/v2" -) +Implement `AuthorizationService` (required) and any additional validator interfaces: -// Implement the AuthorizationService interface +```go type MyAuthService struct{} -func (s *MyAuthService) ValidateToken(token string) (*fiberoapi.AuthContext, error) { - // Validate your JWT token here - switch token { - case "admin-token": - return &fiberoapi.AuthContext{ - UserID: "admin-123", - Roles: []string{"admin", "user"}, - Scopes: []string{"read", "write", "delete"}, - Claims: map[string]interface{}{ - "sub": "admin-123", - "exp": time.Now().Add(time.Hour).Unix(), - }, - }, nil - case "user-token": - return &fiberoapi.AuthContext{ - UserID: "user-789", - Roles: []string{"user"}, - Scopes: []string{"read", "write"}, - Claims: map[string]interface{}{ - "sub": "user-789", - "exp": time.Now().Add(time.Hour).Unix(), - }, - }, nil - default: - return nil, fmt.Errorf("invalid token") - } -} +// 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) { ... } -func (s *MyAuthService) HasRole(ctx *fiberoapi.AuthContext, role string) bool { - for _, r := range ctx.Roles { - if r == role { - return true - } - } - return false -} +// Optional: BasicAuthValidator +func (s *MyAuthService) ValidateBasicAuth(username, password string) (*fiberoapi.AuthContext, error) { ... } -func (s *MyAuthService) HasScope(ctx *fiberoapi.AuthContext, scope string) bool { - for _, sc := range ctx.Scopes { - if sc == scope { - return true - } - } - return false -} +// Optional: APIKeyValidator +func (s *MyAuthService) ValidateAPIKey(key, location, paramName string) (*fiberoapi.AuthContext, error) { ... } -func (s *MyAuthService) CanAccessResource(ctx *fiberoapi.AuthContext, resourceType, resourceID, action string) (bool, error) { - // Admins can do everything - if s.HasRole(ctx, "admin") { - return true, nil - } - - // Custom resource-based logic - if resourceType == "document" && action == "delete" { - return false, nil // Only admins can delete - } - - return s.HasScope(ctx, action), nil -} +// Optional: AWSSignatureValidator +func (s *MyAuthService) ValidateAWSSignature(params *fiberoapi.AWSSignatureParams) (*fiberoapi.AuthContext, error) { ... } +``` -func (s *MyAuthService) GetUserPermissions(ctx *fiberoapi.AuthContext, resourceType, resourceID string) (*fiberoapi.ResourcePermission, error) { - actions := []string{} - if s.HasScope(ctx, "read") { - actions = append(actions, "read") - } - if s.HasScope(ctx, "write") { - actions = append(actions, "write") - } - if s.HasRole(ctx, "admin") { - actions = append(actions, "delete") - } - - return &fiberoapi.ResourcePermission{ - ResourceType: resourceType, - ResourceID: resourceID, - Actions: actions, - }, nil -} +Configure multiple security schemes (OR semantics between them): -func main() { - app := fiber.New() - authService := &MyAuthService{} - - // Configure with authentication - config := fiberoapi.Config{ - EnableValidation: true, - EnableOpenAPIDocs: true, - EnableAuthorization: true, - AuthService: authService, - SecuritySchemes: map[string]fiberoapi.SecurityScheme{ - "bearerAuth": { - Type: "http", - Scheme: "bearer", - BearerFormat: "JWT", - Description: "JWT Bearer token", - }, +```go +config := fiberoapi.Config{ + EnableAuthorization: true, + AuthService: &MyAuthService{}, + SecuritySchemes: map[string]fiberoapi.SecurityScheme{ + "bearerAuth": { + Type: "http", + Scheme: "bearer", + BearerFormat: "JWT", + Description: "JWT Bearer token", }, - DefaultSecurity: []map[string][]string{ - {"bearerAuth": {}}, + "basicAuth": { + Type: "http", + Scheme: "basic", + Description: "HTTP Basic authentication", }, - } - - oapi := fiberoapi.New(app, config) - - // Your authenticated routes here... - - oapi.Listen(":3000") + "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 vs Protected Routes ```go -// Public route (no authentication required) -fiberoapi.Get(oapi, "/health", - func(c *fiber.Ctx, input struct{}) (map[string]string, *fiberoapi.ErrorResponse) { - return map[string]string{"status": "ok"}, nil - }, +// Public route — no authentication +fiberoapi.Get(oapi, "/health", handler, fiberoapi.OpenAPIOptions{ Summary: "Health check", - Security: "disabled", // Explicitly disable auth for this route + Security: "disabled", }) -// Protected route (authentication required by default) -fiberoapi.Get(oapi, "/profile", - func(c *fiber.Ctx, input struct{}) (UserProfile, *fiberoapi.ErrorResponse) { - // Get authenticated user context - authCtx, err := fiberoapi.GetAuthContext(c) - if err != nil { - return UserProfile{}, &fiberoapi.ErrorResponse{ - Code: 401, - Details: "Authentication required", - Type: "auth_error", - } - } - - return UserProfile{ - UserID: authCtx.UserID, - Roles: authCtx.Roles, - }, nil - }, +// Protected route — uses default security +fiberoapi.Get(oapi, "/profile", handler, fiberoapi.OpenAPIOptions{ - Summary: "Get user profile", - Tags: []string{"user"}, + Summary: "Get profile", }) -``` - -### Role-Based Access Control -```go -type DocumentRequest struct { - DocumentID string `path:"documentId" validate:"required"` -} - -type DocumentResponse struct { - ID string `json:"id"` - Title string `json:"title"` - Content string `json:"content"` -} - -// Route requiring specific role -fiberoapi.Get(oapi, "/documents/:documentId", - func(c *fiber.Ctx, input DocumentRequest) (DocumentResponse, *fiberoapi.ErrorResponse) { - authCtx, _ := fiberoapi.GetAuthContext(c) - - // Check if user has required role - if !authService.HasRole(authCtx, "user") { - return DocumentResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "User role required", - Type: "authorization_error", - } - } - - // Check if user has required scope - if !authService.HasScope(authCtx, "read") { - return DocumentResponse{}, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Read permission required", - Type: "authorization_error", - } - } - - return DocumentResponse{ - ID: input.DocumentID, - Title: "Document Title", - Content: "Document content", - }, nil - }, - fiberoapi.OpenAPIOptions{ - Summary: "Get document", - Description: "Requires 'user' role and 'read' scope", - Tags: []string{"documents"}, - }) - -// Admin-only route -fiberoapi.Delete(oapi, "/documents/:documentId", - func(c *fiber.Ctx, input DocumentRequest) (map[string]bool, *fiberoapi.ErrorResponse) { - authCtx, _ := fiberoapi.GetAuthContext(c) - - // Only admins can delete - if !authService.HasRole(authCtx, "admin") { - return nil, &fiberoapi.ErrorResponse{ - Code: 403, - Details: "Admin role required", - Type: "authorization_error", - } - } - - return map[string]bool{"deleted": true}, nil - }, - fiberoapi.OpenAPIOptions{ - Summary: "Delete document", - Description: "Admin only - requires 'admin' role", - Tags: []string{"documents", "admin"}, - }) +// 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 + )) ``` -### Security Helpers +### Declarative Role-Based Access Control -Use the helper functions to simplify security configuration: +Roles are checked automatically before your handler runs. No manual checks needed. ```go -// Add security to existing options -options := fiberoapi.OpenAPIOptions{Summary: "Protected endpoint"} -options = fiberoapi.WithSecurity(options, map[string][]string{ - "bearerAuth": {}, -}) - -// Disable security for specific route -options = fiberoapi.WithSecurityDisabled(options) - -// Add required permissions for documentation -options = fiberoapi.WithPermissions(options, "document:read", "workspace:admin") - -// Set resource type for documentation -options = fiberoapi.WithResourceType(options, "document") -``` - -### Authentication Context - -Access the authenticated user's information in your handlers: - -```go -fiberoapi.Post(oapi, "/posts", - func(c *fiber.Ctx, input CreatePostInput) (PostResponse, *fiberoapi.ErrorResponse) { - // Get authenticated user context - authCtx, err := fiberoapi.GetAuthContext(c) - if err != nil { - return PostResponse{}, &fiberoapi.ErrorResponse{ - Code: 401, - Details: "Authentication required", - Type: "auth_error", - } - } - - // Use auth context - fmt.Printf("User %s with roles %v creating post\n", authCtx.UserID, authCtx.Roles) - - // Access JWT claims if needed - if exp, ok := authCtx.Claims["exp"].(int64); ok { - if time.Now().Unix() > exp { - return PostResponse{}, &fiberoapi.ErrorResponse{ - Code: 401, - Details: "Token expired", - Type: "auth_error", - } - } - } - - return PostResponse{ - ID: "post-123", - Author: authCtx.UserID, - Title: input.Title, - }, nil - }, +// 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: "Create a post", - Tags: []string{"posts"}, + Summary: "Settings", + RequiredRoles: []string{"admin", "superadmin"}, + RequireAllRoles: false, // OR semantics (default) }) ``` -### OpenAPI Security Documentation - -When authentication is enabled, the OpenAPI specification automatically includes: - -- **Security Schemes**: JWT Bearer token configuration -- **Security Requirements**: Applied to protected endpoints -- **Security Overrides**: Public endpoints marked with `security: []` +Roles appear in the OpenAPI spec as extensions: ```json { - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT", - "description": "JWT Bearer token" - } - } - }, - "security": [ - {"bearerAuth": []} - ], - "paths": { - "/health": { - "get": { - "security": [], - "summary": "Health check" - } - }, - "/profile": { - "get": { - "security": [{"bearerAuth": []}], - "summary": "Get user profile" - } - } - } + "x-required-roles": ["admin", "editor"], + "x-required-roles-mode": "any" } ``` -### Testing with Authentication - -```bash -# Public endpoint -curl http://localhost:3000/health - -# Protected endpoint -curl -H "Authorization: Bearer your-jwt-token" http://localhost:3000/profile - -# Admin endpoint -curl -H "Authorization: Bearer admin-token" -X DELETE http://localhost:3000/documents/123 -``` - -### Error Responses +### Permissions and Resource Access -Authentication errors are automatically formatted: +```go +// 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"}, + }) -```json -{ - "code": 401, - "details": "Invalid token", - "type": "auth_error" +// Resource-based access via struct tags +type UpdateDocInput struct { + DocumentID string `path:"documentId" validate:"required" resource:"document" action:"write"` + Title string `json:"title" validate:"required"` } -``` -```json -{ - "code": 403, - "details": "Admin role required", - "type": "authorization_error" -} +// Dynamic resource access check in handler +fiberoapi.RequireResourceAccess(c, authService, "document", docID, "delete") ``` -### Complete Authentication Example +### Authentication Context -See the complete working example in `_examples/auth/main.go` which demonstrates: -- Multiple user types with different roles and scopes -- Role-based access control -- Scope-based permissions -- Resource-level authorization -- OpenAPI security documentation -- Public and protected endpoints +Access the authenticated user in handlers: ```go -// Clone the repository and run the auth example -go run _examples/auth/main.go - -// Visit http://localhost:3002/docs to see the documentation -// Test with different tokens: admin-token, user-token, readonly-token -``` +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"}) ``` -## Configuration +## Custom Error Handlers -The library supports flexible configuration through the `Config` struct: +### Validation Errors ```go -type Config struct { - EnableValidation bool // Enable/disable input validation (default: true) - EnableOpenAPIDocs bool // Enable automatic docs setup (default: true) - EnableAuthorization bool // Enable authentication/authorization (default: false) - OpenAPIDocsPath string // Path for documentation UI (default: "/docs") - OpenAPIJSONPath string // Path for OpenAPI JSON spec (default: "/openapi.json") - AuthService AuthorizationService // Service for handling auth - SecuritySchemes map[string]SecurityScheme // OpenAPI security schemes - DefaultSecurity []map[string][]string // Default security requirements -} +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(), + }) + }, +}) ``` -### Default Configuration - -If no configuration is provided, the library uses these defaults: -- Validation: **enabled** -- Documentation: **enabled** -- Authorization: **disabled** -- Docs path: `/docs` -- JSON spec path: `/openapi.json` - -### Disabling Features +### Authentication/Authorization Errors ```go -// Disable documentation but keep validation -config := fiberoapi.Config{ - EnableValidation: true, - EnableOpenAPIDocs: false, -} - -// Or disable validation but keep docs -config := fiberoapi.Config{ - EnableValidation: false, - EnableOpenAPIDocs: true, - OpenAPIDocsPath: "/api-docs", - OpenAPIJSONPath: "/openapi.json", -} - -// Enable authentication with custom paths -config := fiberoapi.Config{ - EnableValidation: true, - EnableOpenAPIDocs: true, +oapi := fiberoapi.New(app, fiberoapi.Config{ EnableAuthorization: true, - AuthService: &MyAuthService{}, - OpenAPIDocsPath: "/documentation", - OpenAPIJSONPath: "/openapi.json", -} -``` - -## Validation - -This library uses `validator/v10` for validation. You can use all supported validation tags: - -- `required` - Required field -- `min=3,max=20` - Min/max length -- `email` - Valid email format -- `alphanum` - Alphanumeric characters only -- `uuid4` - UUID version 4 -- `url` - Valid URL -- `oneof=admin user guest` - Value from a list -- `dive` - Validation for slice elements -- `gtfield=MinPrice` - Greater than another field - -## Supported Parameter Types - -- **Path parameters**: `path:"paramName"` (GET, POST, PUT, DELETE) -- **Query parameters**: `query:"paramName"` (GET, DELETE) -- **JSON body**: `json:"fieldName"` (POST, PUT) - -## Supported HTTP Methods - -All methods work with both the main app and groups through the unified API: - -- **GET**: `fiberoapi.Get()` - Retrieve resources with path/query parameters -- **POST**: `fiberoapi.Post()` - Create resources with JSON body + optional path parameters -- **PUT**: `fiberoapi.Put()` - Update resources with path parameters + JSON body -- **DELETE**: `fiberoapi.Delete()` - Delete resources with path parameters + optional query parameters - -### Legacy Method Names (Still Supported) - -For backward compatibility, the old method names are still available: -- `fiberoapi.GetOApi()` -- `fiberoapi.PostOApi()` -- `fiberoapi.PutOApi()` -- `fiberoapi.DeleteOApi()` - -## Groups - -Fiber-oapi provides full support for Fiber groups while maintaining access to OpenAPI methods: - -```go -// Create the main app -app := fiber.New() -oapi := fiberoapi.New(app) - -// Create groups - they have access to all Fiber Router methods AND OpenAPI methods -v1 := fiberoapi.Group(oapi, "/api/v1") -v2 := fiberoapi.Group(oapi, "/api/v2") - -// Nested groups work too -users := fiberoapi.Group(v1, "/users") -admin := fiberoapi.Group(v1, "/admin") - -// Use OpenAPI methods on any router (app or group) -fiberoapi.Get(oapi, "/health", healthHandler, options) // Main app -fiberoapi.Get(v1, "/status", statusHandler, options) // Group -fiberoapi.Post(users, "/", createUserHandler, options) // Nested group -fiberoapi.Put(users, "/:id", updateUserHandler, options) // Nested group - -// Use standard Fiber Router methods on groups (inherited via embedding) -v1.Use("/protected", authMiddleware) // Middleware -admin.Get("/stats", func(c *fiber.Ctx) error { // Regular Fiber handler - return c.JSON(fiber.Map{"stats": "data"}) + 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, + }) + }, }) - -// For static files, use the main Fiber app -app.Static("/files", "./uploads") // Static files via main app - -// Groups preserve full path context for OpenAPI documentation -// fiberoapi.Get(users, "/:id", ...) registers as GET /api/v1/users/{id} ``` -### Group Features - -- **Fiber Router compatibility**: Groups embed `fiber.Router` so standard Router methods work (Use, Get, Post, etc.) -- **OpenAPI method support**: Use `fiberoapi.Get()`, `fiberoapi.Post()`, etc. on groups -- **Nested groups**: Create groups within groups with proper path handling -- **Path prefix handling**: OpenAPI paths are automatically constructed with full prefixes -- **Unified API**: Same function names work on both app and groups through interface polymorphism +Without custom handlers, default error responses are returned: -**Note**: For features like static file serving, use the main Fiber app: `app.Static("/path", "./dir")` - -## Error Handling +```json +// 401 - Authentication failure +{"code": 401, "details": "invalid token", "type": "authentication_error"} -Validation errors are automatically formatted and returned with HTTP status 400: +// 403 - Authorization failure +{"code": 403, "details": "requires one of: admin, editor", "type": "authorization_error"} -```json -{ - "error": "Validation failed", - "details": "Key: 'CreateUserInput.Username' Error:Field validation for 'Username' failed on the 'min' tag" -} +// 400 - Validation failure +{"code": 400, "details": "...", "type": "validation_error"} ``` -Custom errors use the `StatusCode` from your error struct. +## Conditional Auth Middleware -## Testing +Standalone middleware functions for use outside the declarative route system: -Run tests: - -```bash -go test -v +```go +// 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")) ``` -## Complete Example with Groups +## Security Helpers ```go -package main +opts := fiberoapi.OpenAPIOptions{Summary: "My endpoint"} -import ( - "github.com/gofiber/fiber/v2" - fiberoapi "github.com/labbs/fiber-oapi" -) +// Security +opts = fiberoapi.WithSecurity(opts, []map[string][]string{{"bearerAuth": {}}}) +opts = fiberoapi.WithSecurityDisabled(opts) -type UserInput struct { - ID int `path:"id" validate:"required,min=1"` -} - -type UserOutput struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type UserError struct { - Message string `json:"message"` -} - -func main() { - app := fiber.New() - oapi := fiberoapi.New(app) +// Roles +opts = fiberoapi.WithRoles(opts, "admin", "editor") // OR semantics +opts = fiberoapi.WithAllRoles(opts, "admin", "moderator") // AND semantics - // Global routes - fiberoapi.Get(oapi, "/health", func(c *fiber.Ctx, input struct{}) (map[string]string, struct{}) { - return map[string]string{"status": "ok"}, struct{}{} - }, fiberoapi.OpenAPIOptions{ - Summary: "Health check", - Tags: []string{"health"}, - }) - - // API v1 group - v1 := fiberoapi.Group(oapi, "/api/v1") - - fiberoapi.Get(v1, "/users/:id", func(c *fiber.Ctx, input UserInput) (UserOutput, UserError) { - return UserOutput{ID: input.ID, Name: "User " + string(rune(input.ID))}, UserError{} - }, fiberoapi.OpenAPIOptions{ - Summary: "Get user by ID", - Tags: []string{"users"}, - }) - - fiberoapi.Post(v1, "/users", func(c *fiber.Ctx, input UserOutput) (UserOutput, UserError) { - return UserOutput{ID: 99, Name: input.Name}, UserError{} - }, fiberoapi.OpenAPIOptions{ - Summary: "Create a new user", - Tags: []string{"users"}, - }) - - // API v2 with nested groups - v2 := fiberoapi.Group(oapi, "/api/v2") - usersV2 := fiberoapi.Group(v2, "/users") +// Documentation +opts = fiberoapi.WithPermissions(opts, "document:read", "document:write") +opts = fiberoapi.WithResourceType(opts, "document") +``` - fiberoapi.Get(usersV2, "/:id", func(c *fiber.Ctx, input UserInput) (UserOutput, UserError) { - return UserOutput{ID: input.ID, Name: "User v2 " + string(rune(input.ID))}, UserError{} - }, fiberoapi.OpenAPIOptions{ - Summary: "Get user by ID (v2)", - Tags: []string{"users", "v2"}, - }) +## OpenAPI Spec Generation - // Mix with standard Fiber methods - v1.Use("/admin", func(c *fiber.Ctx) error { - return c.Next() // Auth middleware - }) +The spec is available in both JSON and YAML: - app.Listen(":3000") - // Visit http://localhost:3000/docs to see the Redoc documentation -} +```go +// Automatic endpoints +// GET /openapi.json +// GET /openapi.yaml +// GET /docs (Redoc UI) + +// Programmatic access +spec := oapi.GenerateOpenAPISpec() // map[string]interface{} +yamlSpec, err := oapi.GenerateOpenAPISpecYAML() // string ``` -## Advanced Usage - -### Custom Documentation Configuration +### Custom Documentation ```go -config := fiberoapi.DocConfig{ +oapi.SetupDocs(fiberoapi.DocConfig{ Title: "My API", Description: "My API description", Version: "2.0.0", DocsPath: "/documentation", JSONPath: "/api-spec.json", -} -oapi.SetupDocs(config) // Optional - docs are auto-configured by default -``` - -### OApiRouter Interface - -The library uses an `OApiRouter` interface that allows the same functions to work seamlessly with both apps and groups: - -```go -// This interface is implemented by both *OApiApp and *OApiGroup -type OApiRouter interface { - GetApp() *OApiApp - GetPrefix() string -} - -// So these functions work with both: -func Get[T any, U any, E any](router OApiRouter, path string, handler HandlerFunc[T, U, E], options OpenAPIOptions) -func Post[T any, U any, E any](router OApiRouter, path string, handler HandlerFunc[T, U, E], options OpenAPIOptions) -func Put[T any, U any, E any](router OApiRouter, path string, handler HandlerFunc[T, U, E], options OpenAPIOptions) -func Delete[T any, U any, E any](router OApiRouter, path string, handler HandlerFunc[T, U, E], options OpenAPIOptions) -func Group(router OApiRouter, prefix string, handlers ...fiber.Handler) *OApiGroup -``` - -## Documentation - -When `EnableOpenAPIDocs` is set to `true` (default), the library automatically sets up: - -- **Redoc UI**: Modern, responsive documentation interface available at the configured docs path (default: `/docs`) -- **OpenAPI JSON**: Complete OpenAPI 3.0 specification available at the configured JSON path (default: `/openapi.json`) -- **Automatic Schema Generation**: Input and output types are automatically converted to OpenAPI schemas -- **Components Section**: All schemas are properly organized in the `components/schemas` section - -No manual setup required! Just visit `http://localhost:3000/docs` to see your API documentation with Redoc. - -### Redoc vs Swagger UI - -This library uses **Redoc** for documentation UI instead of Swagger UI because: -- **Better performance** with large APIs -- **Responsive design** that works great on mobile -- **Clean, modern interface** -- **Better OpenAPI 3.0 support** -- **No JavaScript framework dependencies** - -### OpenAPI Schema Generation - -The library automatically generates OpenAPI 3.0 schemas from your Go types: - -```go -// This struct automatically becomes an OpenAPI schema -type User struct { - ID string `json:"id"` - Username string `json:"username" validate:"required,min=3"` - Email string `json:"email" validate:"required,email"` - Age int `json:"age" validate:"min=13,max=120"` -} +}) ``` -Generated OpenAPI spec will include: -- Complete path definitions with parameters -- Request/response schemas -- Validation rules as schema constraints -- Proper HTTP status codes -- Operation IDs, tags, and descriptions - -## Migration from v1 - -If you're migrating from a previous version, here are the key changes: - -### 1. New Unified API (Recommended) +## Testing -```go -fiberoapi.Get(oapi, "/users/:id", handler, options) // Works on app -fiberoapi.Post(oapi, "/users", handler, options) // Works on app +```bash +# Run all tests +go test -v ./... -// And seamlessly on groups -v1 := fiberoapi.Group(oapi, "/api/v1") -fiberoapi.Get(v1, "/users/:id", handler, options) // Works on groups -fiberoapi.Post(v1, "/users", handler, options) // Works on groups +# Run the auth example +go run _examples/auth/main.go +# Visit http://localhost:3002/docs ``` -### 2. Group Support +Testing with authentication: -```go -// New group functionality -v1 := fiberoapi.Group(oapi, "/api/v1") -users := fiberoapi.Group(v1, "/users") +```bash +# Bearer token +curl -H "Authorization: Bearer admin-token" http://localhost:3002/me -// All OpenAPI methods work on groups -fiberoapi.Get(users, "/:id", getUserHandler, options) -fiberoapi.Post(users, "/", createUserHandler, options) +# Basic auth +curl --user admin:admin-pass http://localhost:3002/me -// Standard Fiber Router methods work too (inherited via embedding) -users.Use(authMiddleware) // Middleware -users.Get("/legacy", func(c *fiber.Ctx) error { // Regular Fiber handler - return c.SendString("legacy endpoint") -}) +# API key +curl -H "X-API-Key: my-secret-api-key" http://localhost:3002/documents/doc-1 -// For static files, use the main Fiber app -app.Static("/avatars", "./uploads") // Static files via main app +# Public endpoint +curl http://localhost:3002/health ``` -### 3. Documentation UI +## Complete Example -- **Changed from Swagger UI to Redoc** for better performance and modern UI -- Same paths: `/docs` for UI, `/openapi.json` for spec -- No code changes required for existing documentation setup \ No newline at end of file +See `_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 diff --git a/auth.go b/auth.go index f443744..32ae888 100644 --- a/auth.go +++ b/auth.go @@ -142,7 +142,7 @@ func RoleGuard(validator AuthorizationService, requiredRoles ...string) fiber.Ha // validateAuthorization validates permissions based on configured security schemes. // 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) error { +func validateAuthorization(c *fiber.Ctx, input interface{}, authService AuthorizationService, config *Config, requiredRoles []string, requireAllRoles bool) error { if authService == nil { if len(requiredRoles) > 0 { return &AuthError{StatusCode: 500, Message: "authorization service not configured"} @@ -170,7 +170,7 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz c.Locals("auth", authCtx) // Check roles before resource access - if err := checkRequiredRoles(authCtx, authService, requiredRoles); err != nil { + if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil { return err } return validateResourceAccess(c, authCtx, input, authService) @@ -192,7 +192,7 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz c.Locals("auth", authCtx) // Check roles before resource access - if err := checkRequiredRoles(authCtx, authService, requiredRoles); err != nil { + if err := checkRequiredRoles(authCtx, authService, requiredRoles, requireAllRoles); err != nil { return err } return validateResourceAccess(c, authCtx, input, authService) @@ -216,15 +216,27 @@ func validateAuthorization(c *fiber.Ctx, input interface{}, authService Authoriz return &AuthError{StatusCode: 401, Message: lastErr.Error()} } -// checkRequiredRoles checks that the authenticated user has all required roles. -// Called inside validateAuthorization after auth context is established, before resource access checks. -func checkRequiredRoles(authCtx *AuthContext, authService AuthorizationService, requiredRoles []string) error { +// checkRequiredRoles checks the authenticated user's roles against the required roles. +// By default (requireAll=false), the user needs at least one of the roles (OR semantics). +// When requireAll=true, the user must have all listed roles (AND semantics). +func checkRequiredRoles(authCtx *AuthContext, authService AuthorizationService, requiredRoles []string, requireAll bool) error { + if len(requiredRoles) == 0 { + return nil + } + if requireAll { + for _, role := range requiredRoles { + if !authService.HasRole(authCtx, role) { + return &AuthError{StatusCode: 403, Message: fmt.Sprintf("required role missing: %s", role)} + } + } + return nil + } for _, role := range requiredRoles { - if !authService.HasRole(authCtx, role) { - return &AuthError{StatusCode: 403, Message: fmt.Sprintf("required role missing: %s", role)} + if authService.HasRole(authCtx, role) { + return nil } } - return nil + return &AuthError{StatusCode: 403, Message: fmt.Sprintf("requires one of: %s", strings.Join(requiredRoles, ", "))} } // validateResourceAccess validates resource access based on tags diff --git a/auth_helpers.go b/auth_helpers.go index 34944b0..a561d57 100644 --- a/auth_helpers.go +++ b/auth_helpers.go @@ -12,12 +12,19 @@ func WithSecurityDisabled(options OpenAPIOptions) OpenAPIOptions { return options } -// WithRoles adds required roles to a route (checked automatically during auth) +// WithRoles adds required roles to a route with OR semantics (user needs at least one) func WithRoles(options OpenAPIOptions, roles ...string) OpenAPIOptions { options.RequiredRoles = append(options.RequiredRoles, roles...) return options } +// WithAllRoles adds required roles to a route with AND semantics (user needs all of them) +func WithAllRoles(options OpenAPIOptions, roles ...string) OpenAPIOptions { + options.RequiredRoles = append(options.RequiredRoles, roles...) + options.RequireAllRoles = true + return options +} + // WithPermissions adds required permissions for documentation func WithPermissions(options OpenAPIOptions, permissions ...string) OpenAPIOptions { options.RequiredPermissions = append(options.RequiredPermissions, permissions...) diff --git a/auth_test.go b/auth_test.go index 4520527..7e2432c 100644 --- a/auth_test.go +++ b/auth_test.go @@ -38,6 +38,15 @@ func NewMockAuthService() *MockAuthService { "exp": time.Now().Add(time.Hour).Unix(), }, }, + "editor-token": { + UserID: "editor-321", + Roles: []string{"editor", "user"}, + Scopes: []string{"read", "write", "share"}, + Claims: map[string]interface{}{ + "sub": "editor-321", + "exp": time.Now().Add(time.Hour).Unix(), + }, + }, "readonly-token": { UserID: "readonly-789", Roles: []string{"user"}, @@ -593,10 +602,10 @@ func TestRequiredRoles(t *testing.T) { return fiber.Map{"ok": true}, nil }, WithRoles(OpenAPIOptions{Summary: "User profile"}, "user")) - // Route requiring multiple roles (AND semantics): "admin" AND "user" + // Route requiring multiple roles (OR semantics): "admin" OR "editor" Get(oapi, "/admin/settings", func(c *fiber.Ctx, input struct{}) (fiber.Map, *ErrorResponse) { return fiber.Map{"ok": true}, nil - }, WithRoles(OpenAPIOptions{Summary: "Admin settings"}, "admin", "user")) + }, WithRoles(OpenAPIOptions{Summary: "Admin settings"}, "admin", "editor")) // Route with no required roles Get(oapi, "/public/info", func(c *fiber.Ctx, input struct{}) (fiber.Map, *ErrorResponse) { @@ -668,11 +677,26 @@ func TestRequiredRoles(t *testing.T) { if len(roles) != 1 || roles[0] != "admin" { t.Errorf("Expected [admin], got %v", roles) } + mode, ok := getOp["x-required-roles-mode"].(string) + if !ok { + t.Fatal("Expected x-required-roles-mode in spec") + } + if mode != "any" { + t.Errorf("Expected mode 'any', got %s", mode) + } + + // Check OR route has mode "any" + settingsPath := paths["/admin/settings"].(map[string]interface{}) + settingsOp := settingsPath["get"].(map[string]interface{}) + settingsMode := settingsOp["x-required-roles-mode"].(string) + if settingsMode != "any" { + t.Errorf("Expected mode 'any' for /admin/settings, got %s", settingsMode) + } }) - // Multi-role AND semantics tests - // admin-token has roles ["admin", "user"] -> should pass - t.Run("multi-role: token with all roles accepted", func(t *testing.T) { + // Multi-role OR semantics tests + // admin-token has roles ["admin", "user"] -> has "admin", should pass + t.Run("multi-role: token with one matching role accepted", func(t *testing.T) { req := httptest.NewRequest("GET", "/admin/settings", nil) req.Header.Set("Authorization", "Bearer admin-token") resp, _ := app.Test(req) @@ -681,8 +705,18 @@ func TestRequiredRoles(t *testing.T) { } }) - // valid-token has roles ["user"] -> missing "admin", should be rejected - t.Run("multi-role: token missing one role rejected", func(t *testing.T) { + // editor-token has roles ["editor", "user"] -> has "editor" (second in RequiredRoles), should pass + t.Run("multi-role: token matching second role accepted", func(t *testing.T) { + req := httptest.NewRequest("GET", "/admin/settings", nil) + req.Header.Set("Authorization", "Bearer editor-token") + resp, _ := app.Test(req) + if resp.StatusCode != 200 { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + // valid-token has roles ["user"] -> has neither "admin" nor "editor", should be rejected + t.Run("multi-role: token with no matching role rejected", func(t *testing.T) { req := httptest.NewRequest("GET", "/admin/settings", nil) req.Header.Set("Authorization", "Bearer valid-token") resp, _ := app.Test(req) @@ -768,6 +802,9 @@ func TestWithRolesHelper(t *testing.T) { if opts.RequiredRoles[0] != "admin" || opts.RequiredRoles[1] != "editor" { t.Errorf("Expected [admin, editor], got %v", opts.RequiredRoles) } + if opts.RequireAllRoles { + t.Error("WithRoles should not set RequireAllRoles") + } // Chaining opts = WithRoles(opts, "superadmin") @@ -775,3 +812,72 @@ func TestWithRolesHelper(t *testing.T) { t.Fatalf("Expected 3 roles after chaining, got %d", len(opts.RequiredRoles)) } } + +// TestWithAllRolesHelper tests the WithAllRoles helper function +func TestWithAllRolesHelper(t *testing.T) { + opts := WithAllRoles(OpenAPIOptions{Summary: "test"}, "admin", "editor") + if len(opts.RequiredRoles) != 2 { + t.Fatalf("Expected 2 roles, got %d", len(opts.RequiredRoles)) + } + if !opts.RequireAllRoles { + t.Error("WithAllRoles should set RequireAllRoles to true") + } +} + +// TestRequiredRoles_ANDSemantics tests RequireAllRoles=true (AND semantics) +func TestRequiredRoles_ANDSemantics(t *testing.T) { + mockAuth := NewMockAuthService() + + app := fiber.New() + oapi := New(app, Config{ + EnableOpenAPIDocs: true, + EnableValidation: true, + EnableAuthorization: true, + AuthService: mockAuth, + SecuritySchemes: map[string]SecurityScheme{ + "bearerAuth": {Type: "http", Scheme: "bearer", BearerFormat: "JWT"}, + }, + DefaultSecurity: []map[string][]string{ + {"bearerAuth": {}}, + }, + }) + + // Route requiring ALL of "admin" AND "user" (AND semantics) + Get(oapi, "/strict", func(c *fiber.Ctx, input struct{}) (fiber.Map, *ErrorResponse) { + return fiber.Map{"ok": true}, nil + }, WithAllRoles(OpenAPIOptions{Summary: "Strict route"}, "admin", "user")) + + // admin-token has roles ["admin", "user"] -> has both, should pass + t.Run("token with all roles accepted", func(t *testing.T) { + req := httptest.NewRequest("GET", "/strict", nil) + req.Header.Set("Authorization", "Bearer admin-token") + resp, _ := app.Test(req) + if resp.StatusCode != 200 { + t.Errorf("Expected 200, got %d", resp.StatusCode) + } + }) + + // valid-token has roles ["user"] -> missing "admin", should be rejected + t.Run("token missing one role rejected", func(t *testing.T) { + req := httptest.NewRequest("GET", "/strict", nil) + req.Header.Set("Authorization", "Bearer valid-token") + resp, _ := app.Test(req) + if resp.StatusCode != 403 { + t.Errorf("Expected 403, got %d", resp.StatusCode) + } + }) + + t.Run("x-required-roles-mode is 'all' in OpenAPI spec", func(t *testing.T) { + spec := oapi.GenerateOpenAPISpec() + paths := spec["paths"].(map[string]interface{}) + strictPath := paths["/strict"].(map[string]interface{}) + getOp := strictPath["get"].(map[string]interface{}) + mode, ok := getOp["x-required-roles-mode"].(string) + if !ok { + t.Fatal("Expected x-required-roles-mode in spec") + } + if mode != "all" { + t.Errorf("Expected mode 'all', got %s", mode) + } + }) +} diff --git a/common.go b/common.go index cf7c423..32efcf1 100644 --- a/common.go +++ b/common.go @@ -123,7 +123,7 @@ func parseInput[TInput any](app *OApiApp, c *fiber.Ctx, path string, options *Op if routeSecurity, ok := options.Security.([]map[string][]string); ok && len(routeSecurity) > 0 { cfg.DefaultSecurity = routeSecurity } - err = validateAuthorization(c, input, cfg.AuthService, &cfg, options.RequiredRoles) + err = validateAuthorization(c, input, cfg.AuthService, &cfg, options.RequiredRoles, options.RequireAllRoles) if err != nil { return input, err } diff --git a/fiberoapi.go b/fiberoapi.go index a7319bb..daf4991 100644 --- a/fiberoapi.go +++ b/fiberoapi.go @@ -259,6 +259,11 @@ func (o *OApiApp) GenerateOpenAPISpec() map[string]interface{} { // Add required roles as OpenAPI extension if len(op.Options.RequiredRoles) > 0 { enhancedOptions["x-required-roles"] = op.Options.RequiredRoles + if op.Options.RequireAllRoles { + enhancedOptions["x-required-roles-mode"] = "all" + } else { + enhancedOptions["x-required-roles-mode"] = "any" + } } // Add descriptions for required permissions diff --git a/types.go b/types.go index 8e28d49..c9917ce 100644 --- a/types.go +++ b/types.go @@ -84,7 +84,8 @@ type OpenAPIOptions struct { Description string `json:"description,omitempty"` Parameters []map[string]any `json:"parameters,omitempty"` Security any `json:"security,omitempty"` // Can be []map[string][]string or "disabled" - RequiredRoles []string `json:"-"` // Roles required to access this route (checked automatically) + RequiredRoles []string `json:"-"` // Roles required to access this route (OR semantics by default) + RequireAllRoles bool `json:"-"` // If true, all RequiredRoles must match (AND semantics) RequiredPermissions []string `json:"-"` // Ex: ["document:read", "workspace:admin"] ResourceType string `json:"-"` // Type de ressource concernée }