diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..3fe97ce --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,391 @@ +# API Authentication + +The API package provides secure authentication with bcrypt-hashed API keys, scope-based permissions, and automatic key rotation. + +## API Key Management + +### Creating Keys + +```php +use Mod\Api\Models\ApiKey; + +$apiKey = ApiKey::create([ + 'name' => 'Mobile App Production', + 'workspace_id' => $workspace->id, + 'scopes' => ['posts:read', 'posts:write', 'categories:read'], + 'rate_limit_tier' => 'pro', + 'expires_at' => now()->addYear(), +]); + +// Get plaintext key (only available once!) +$plaintext = $apiKey->plaintext_key; +// Returns: sk_live_abc123def456... +``` + +**Key Format:** `{prefix}_{environment}_{random}` +- Prefix: `sk` (secret key) +- Environment: `live` or `test` +- Random: 32-character string + +### Secure Storage + +Keys are hashed with bcrypt before storage: + +```php +// Never stored in plaintext +$hash = bcrypt($plaintext); + +// Stored in database +$apiKey->key_hash = $hash; + +// Verification +if (Hash::check($providedKey, $apiKey->key_hash)) { + // Valid key +} +``` + +### Key Rotation + +Rotate keys with a grace period: + +```php +$newKey = $apiKey->rotate([ + 'grace_period_hours' => 24, +]); + +// Returns new ApiKey with: +// - New plaintext key +// - Same scopes and settings +// - Old key marked for deletion after grace period +``` + +During the grace period, both keys work. After 24 hours, the old key is automatically deleted. + +## Using API Keys + +### Authorization Header + +```bash +curl -H "Authorization: Bearer sk_live_abc123..." \ + https://api.example.com/v1/posts +``` + +### Basic Auth + +```bash +curl -u sk_live_abc123: \ + https://api.example.com/v1/posts +``` + +### PHP Example + +```php +use GuzzleHttp\Client; + +$client = new Client([ + 'base_uri' => 'https://api.example.com', + 'headers' => [ + 'Authorization' => "Bearer {$apiKey}", + 'Accept' => 'application/json', + ], +]); + +$response = $client->get('/v1/posts'); +``` + +### JavaScript Example + +```javascript +const response = await fetch('https://api.example.com/v1/posts', { + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Accept': 'application/json' + } +}); +``` + +## Scopes & Permissions + +### Defining Scopes + +```php +$apiKey = ApiKey::create([ + 'scopes' => [ + 'posts:read', // Read posts + 'posts:write', // Create/update posts + 'posts:delete', // Delete posts + 'categories:read', // Read categories + ], +]); +``` + +### Common Scopes + +| Scope | Description | +|-------|-------------| +| `{resource}:read` | Read access | +| `{resource}:write` | Create and update | +| `{resource}:delete` | Delete access | +| `{resource}:*` | All permissions for resource | +| `*` | Full access (use sparingly!) | + +### Wildcard Scopes + +```php +// All post permissions +'scopes' => ['posts:*'] + +// Read access to all resources +'scopes' => ['*:read'] + +// Full access (admin only!) +'scopes' => ['*'] +``` + +### Scope Enforcement + +Protect routes with scope middleware: + +```php +Route::middleware('scope:posts:write') + ->post('/posts', [PostController::class, 'store']); + +Route::middleware('scope:posts:delete') + ->delete('/posts/{id}', [PostController::class, 'destroy']); +``` + +### Check Scopes in Controllers + +```php +public function store(Request $request) +{ + if (!$request->user()->tokenCan('posts:write')) { + return response()->json([ + 'error' => 'Insufficient permissions', + 'required_scope' => 'posts:write', + ], 403); + } + + return Post::create($request->validated()); +} +``` + +## Rate Limiting + +Keys are rate-limited based on tier: + +```php +// config/api.php +'rate_limits' => [ + 'free' => ['requests' => 1000, 'per' => 'hour'], + 'pro' => ['requests' => 10000, 'per' => 'hour'], + 'business' => ['requests' => 50000, 'per' => 'hour'], + 'enterprise' => ['requests' => null], // Unlimited +], +``` + +Rate limit headers included in responses: + +``` +X-RateLimit-Limit: 10000 +X-RateLimit-Remaining: 9847 +X-RateLimit-Reset: 1640995200 +``` + +[Learn more about Rate Limiting →](/packages/api/rate-limiting) + +## Key Expiration + +### Set Expiration + +```php +$apiKey = ApiKey::create([ + 'expires_at' => now()->addMonths(6), +]); +``` + +### Check Expiration + +```php +if ($apiKey->isExpired()) { + return response()->json(['error' => 'API key expired'], 401); +} +``` + +### Auto-Cleanup + +Expired keys are automatically cleaned up: + +```bash +php artisan api:prune-expired-keys +``` + +## Environment-Specific Keys + +### Test Keys + +```php +$testKey = ApiKey::create([ + 'name' => 'Development Key', + 'environment' => 'test', +]); + +// Key prefix: sk_test_... +``` + +Test keys: +- Don't affect production data +- Higher rate limits +- Clearly marked in UI +- Easy to identify and delete + +### Live Keys + +```php +$liveKey = ApiKey::create([ + 'environment' => 'live', +]); + +// Key prefix: sk_live_... +``` + +## Middleware + +### API Authentication + +```php +Route::middleware('auth:api')->group(function () { + // Protected routes +}); +``` + +### Scope Enforcement + +```php +use Mod\Api\Middleware\EnforceApiScope; + +Route::middleware([EnforceApiScope::class.':posts:write']) + ->post('/posts', [PostController::class, 'store']); +``` + +### Rate Limiting + +```php +use Mod\Api\Middleware\RateLimitApi; + +Route::middleware(RateLimitApi::class)->group(function () { + // Rate-limited routes +}); +``` + +## Security Best Practices + +### 1. Minimum Required Scopes + +```php +// ✅ Good - specific scopes +'scopes' => ['posts:read', 'categories:read'] + +// ❌ Bad - excessive permissions +'scopes' => ['*'] +``` + +### 2. Rotate Regularly + +```php +// Rotate every 90 days +if ($apiKey->created_at->diffInDays() > 90) { + $newKey = $apiKey->rotate(); + // Notify user of new key +} +``` + +### 3. Use Separate Keys Per Client + +```php +// ✅ Good - separate keys +ApiKey::create(['name' => 'iOS App']); +ApiKey::create(['name' => 'Android App']); +ApiKey::create(['name' => 'Web App']); + +// ❌ Bad - shared key +ApiKey::create(['name' => 'All Mobile Apps']); +``` + +### 4. Set Expiration + +```php +// ✅ Good - temporary access +'expires_at' => now()->addMonths(6) + +// ❌ Bad - never expires +'expires_at' => null +``` + +### 5. Monitor Usage + +```php +$usage = ApiKey::find($id)->usage() + ->whereBetween('created_at', [now()->subDays(7), now()]) + ->count(); + +if ($usage > $threshold) { + // Alert admin +} +``` + +## Testing + +```php +create([ + 'scopes' => ['posts:read'], + ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$apiKey->plaintext_key}", + ])->getJson('/api/v1/posts'); + + $response->assertOk(); + } + + public function test_rejects_invalid_key(): void + { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer invalid_key', + ])->getJson('/api/v1/posts'); + + $response->assertUnauthorized(); + } + + public function test_enforces_scopes(): void + { + $apiKey = ApiKey::factory()->create([ + 'scopes' => ['posts:read'], // No write permission + ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$apiKey->plaintext_key}", + ])->postJson('/api/v1/posts', ['title' => 'Test']); + + $response->assertForbidden(); + } +} +``` + +## Learn More + +- [Rate Limiting →](/packages/api/rate-limiting) +- [Scopes →](/packages/api/scopes) +- [Webhooks →](/packages/api/webhooks) +- [API Reference →](/api/authentication) diff --git a/docs/building-rest-apis.md b/docs/building-rest-apis.md new file mode 100644 index 0000000..8eb52ea --- /dev/null +++ b/docs/building-rest-apis.md @@ -0,0 +1,898 @@ +# Building REST APIs + +This guide covers how to build production-ready REST APIs using the core-api package. You'll learn to create resources, implement pagination, add filtering and sorting, and secure endpoints with authentication. + +## Quick Start + +Register API routes by listening to the `ApiRoutesRegistering` event: + +```php + 'onApiRoutes', + ]; + + public function onApiRoutes(ApiRoutesRegistering $event): void + { + $event->routes(function () { + Route::apiResource('posts', Api\PostController::class); + }); + } +} +``` + +## Creating Resources + +### API Resources + +Transform Eloquent models into consistent JSON responses using Laravel's API Resources: + +```php + $this->id, + 'type' => 'post', + 'attributes' => [ + 'title' => $this->title, + 'slug' => $this->slug, + 'excerpt' => $this->excerpt, + 'content' => $this->when( + $request->user()?->tokenCan('posts:read-content'), + $this->content + ), + 'status' => $this->status, + 'published_at' => $this->published_at?->toIso8601String(), + ], + 'relationships' => [ + 'author' => $this->whenLoaded('author', fn () => [ + 'id' => $this->author->id, + 'name' => $this->author->name, + ]), + 'categories' => $this->whenLoaded('categories', fn () => + $this->categories->map(fn ($cat) => [ + 'id' => $cat->id, + 'name' => $cat->name, + ]) + ), + ], + 'meta' => [ + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ], + ]; + } +} +``` + +### Resource Controllers + +Build controllers that use the `HasApiResponses` trait for consistent error handling: + +```php +with(['author', 'categories']) + ->paginate($request->input('per_page', 25)); + + return new PaginatedCollection($posts, PostResource::class); + } + + public function show(Post $post) + { + $post->load(['author', 'categories']); + + return new PostResource($post); + } + + public function store(Request $request) + { + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'content' => 'required|string', + 'status' => 'in:draft,published', + ]); + + $post = Post::create($validated); + + return $this->createdResponse( + new PostResource($post), + 'Post created successfully.' + ); + } + + public function update(Request $request, Post $post) + { + $validated = $request->validate([ + 'title' => 'string|max:255', + 'content' => 'string', + 'status' => 'in:draft,published', + ]); + + $post->update($validated); + + return new PostResource($post); + } + + public function destroy(Post $post) + { + $post->delete(); + + return response()->json(null, 204); + } +} +``` + +## Pagination + +### Using PaginatedCollection + +The `PaginatedCollection` class provides standardized pagination metadata: + +```php +use Core\Mod\Api\Resources\PaginatedCollection; + +public function index(Request $request) +{ + $posts = Post::paginate( + $request->input('per_page', config('api.pagination.default_per_page', 25)) + ); + + return new PaginatedCollection($posts, PostResource::class); +} +``` + +### Response Format + +Paginated responses include comprehensive metadata: + +```json +{ + "data": [ + {"id": 1, "type": "post", "attributes": {...}}, + {"id": 2, "type": "post", "attributes": {...}} + ], + "meta": { + "current_page": 1, + "from": 1, + "last_page": 10, + "per_page": 25, + "to": 25, + "total": 250 + }, + "links": { + "first": "https://api.example.com/v1/posts?page=1", + "last": "https://api.example.com/v1/posts?page=10", + "prev": null, + "next": "https://api.example.com/v1/posts?page=2" + } +} +``` + +### Pagination Best Practices + +**1. Limit Maximum Page Size** + +```php +public function index(Request $request) +{ + $perPage = min( + $request->input('per_page', 25), + config('api.pagination.max_per_page', 100) + ); + + return new PaginatedCollection( + Post::paginate($perPage), + PostResource::class + ); +} +``` + +**2. Use Cursor Pagination for Large Datasets** + +```php +public function index(Request $request) +{ + $posts = Post::orderBy('id') + ->cursorPaginate($request->input('per_page', 25)); + + return PostResource::collection($posts); +} +``` + +**3. Include Total Count Conditionally** + +For very large tables, counting can be expensive: + +```php +public function index(Request $request) +{ + $query = Post::query(); + + // Only count if explicitly requested + if ($request->boolean('include_total')) { + return new PaginatedCollection( + $query->paginate($request->input('per_page', 25)), + PostResource::class + ); + } + + // Use simple pagination (no total count) + return PostResource::collection( + $query->simplePaginate($request->input('per_page', 25)) + ); +} +``` + +## Filtering + +### Query Parameter Filters + +Implement flexible filtering with query parameters: + +```php +public function index(Request $request) +{ + $query = Post::query(); + + // Status filter + if ($status = $request->input('status')) { + $query->where('status', $status); + } + + // Date range filters + if ($after = $request->input('created_after')) { + $query->where('created_at', '>=', $after); + } + + if ($before = $request->input('created_before')) { + $query->where('created_at', '<=', $before); + } + + // Author filter + if ($authorId = $request->input('author_id')) { + $query->where('author_id', $authorId); + } + + // Full-text search + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search) { + $q->where('title', 'like', "%{$search}%") + ->orWhere('content', 'like', "%{$search}%"); + }); + } + + return new PaginatedCollection( + $query->paginate($request->input('per_page', 25)), + PostResource::class + ); +} +``` + +### Filter Validation + +Validate filter parameters to prevent errors: + +```php +public function index(Request $request) +{ + $request->validate([ + 'status' => 'in:draft,published,archived', + 'created_after' => 'date|before_or_equal:created_before', + 'created_before' => 'date', + 'author_id' => 'integer|exists:users,id', + 'per_page' => 'integer|min:1|max:100', + ]); + + // Apply filters... +} +``` + +### Reusable Filter Traits + +Create a trait for common filtering patterns: + +```php +input('created_after')) { + $query->where('created_at', '>=', $after); + } + + if ($before = $request->input('created_before')) { + $query->where('created_at', '<=', $before); + } + + if ($updatedAfter = $request->input('updated_after')) { + $query->where('updated_at', '>=', $updatedAfter); + } + + // Status filter (if model has status) + if ($status = $request->input('status')) { + $query->where('status', $status); + } + + return $query; + } +} +``` + +## Sorting + +### Sort Parameter + +Implement sorting with a `sort` query parameter: + +```php +public function index(Request $request) +{ + $query = Post::query(); + + // Parse sort parameter: -created_at,title + $sortFields = $this->parseSortFields( + $request->input('sort', '-created_at') + ); + + foreach ($sortFields as $field => $direction) { + $query->orderBy($field, $direction); + } + + return new PaginatedCollection( + $query->paginate($request->input('per_page', 25)), + PostResource::class + ); +} + +protected function parseSortFields(string $sort): array +{ + $allowedFields = ['id', 'title', 'created_at', 'updated_at', 'published_at']; + $fields = []; + + foreach (explode(',', $sort) as $field) { + $direction = 'asc'; + + if (str_starts_with($field, '-')) { + $direction = 'desc'; + $field = substr($field, 1); + } + + if (in_array($field, $allowedFields)) { + $fields[$field] = $direction; + } + } + + return $fields ?: ['created_at' => 'desc']; +} +``` + +### Sort Validation + +Validate sort fields against an allowlist: + +```php +public function index(Request $request) +{ + $request->validate([ + 'sort' => [ + 'string', + 'regex:/^-?(id|title|created_at|updated_at)(,-?(id|title|created_at|updated_at))*$/', + ], + ]); + + // Apply sorting... +} +``` + +## Authentication + +### Protecting Routes + +Use the `auth:api` middleware to protect endpoints: + +```php +// In your Boot class +$event->routes(function () { + // Public routes (no authentication) + Route::get('/posts', [PostController::class, 'index']); + Route::get('/posts/{post}', [PostController::class, 'show']); + + // Protected routes (require authentication) + Route::middleware('auth:api')->group(function () { + Route::post('/posts', [PostController::class, 'store']); + Route::put('/posts/{post}', [PostController::class, 'update']); + Route::delete('/posts/{post}', [PostController::class, 'destroy']); + }); +}); +``` + +### Scope-Based Authorization + +Enforce API key scopes on routes: + +```php +Route::middleware(['auth:api', 'scope:posts:write']) + ->post('/posts', [PostController::class, 'store']); + +Route::middleware(['auth:api', 'scope:posts:delete']) + ->delete('/posts/{post}', [PostController::class, 'destroy']); +``` + +### Checking Scopes in Controllers + +Verify scopes programmatically for fine-grained control: + +```php +public function update(Request $request, Post $post) +{ + // Check if user can update posts + if (!$request->user()->tokenCan('posts:write')) { + return $this->accessDeniedResponse('Insufficient permissions to update posts.'); + } + + // Check if user can publish (requires elevated scope) + if ($request->input('status') === 'published') { + if (!$request->user()->tokenCan('posts:publish')) { + return $this->accessDeniedResponse('Insufficient permissions to publish posts.'); + } + } + + $post->update($request->validated()); + + return new PostResource($post); +} +``` + +### API Key Authentication Examples + +**PHP with Guzzle:** + +```php +use GuzzleHttp\Client; + +$client = new Client([ + 'base_uri' => 'https://api.example.com/v1/', + 'headers' => [ + 'Authorization' => 'Bearer ' . $apiKey, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], +]); + +// List posts +$response = $client->get('posts', [ + 'query' => [ + 'status' => 'published', + 'per_page' => 50, + 'sort' => '-published_at', + ], +]); + +$posts = json_decode($response->getBody(), true); + +// Create a post +$response = $client->post('posts', [ + 'json' => [ + 'title' => 'New Post', + 'content' => 'Post content here...', + 'status' => 'draft', + ], +]); + +$newPost = json_decode($response->getBody(), true); +``` + +**JavaScript with Fetch:** + +```javascript +const API_KEY = 'sk_live_abc123...'; +const BASE_URL = 'https://api.example.com/v1'; + +async function listPosts(params = {}) { + const query = new URLSearchParams(params).toString(); + + const response = await fetch(`${BASE_URL}/posts?${query}`, { + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Accept': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`API error: ${response.status}`); + } + + return response.json(); +} + +async function createPost(data) { + const response = await fetch(`${BASE_URL}/posts`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to create post'); + } + + return response.json(); +} + +// Usage +const posts = await listPosts({ status: 'published', per_page: 25 }); +const newPost = await createPost({ title: 'Hello', content: 'World' }); +``` + +**Python with Requests:** + +```python +import requests + +API_KEY = 'sk_live_abc123...' +BASE_URL = 'https://api.example.com/v1' + +headers = { + 'Authorization': f'Bearer {API_KEY}', + 'Accept': 'application/json', + 'Content-Type': 'application/json', +} + +# List posts +response = requests.get( + f'{BASE_URL}/posts', + headers=headers, + params={ + 'status': 'published', + 'per_page': 50, + 'sort': '-published_at', + } +) +response.raise_for_status() +posts = response.json() + +# Create a post +response = requests.post( + f'{BASE_URL}/posts', + headers=headers, + json={ + 'title': 'New Post', + 'content': 'Post content here...', + 'status': 'draft', + } +) +response.raise_for_status() +new_post = response.json() +``` + +## OpenAPI Documentation + +### Document Endpoints + +Use attributes to auto-generate OpenAPI documentation: + +```php +use Core\Mod\Api\Documentation\Attributes\ApiTag; +use Core\Mod\Api\Documentation\Attributes\ApiParameter; +use Core\Mod\Api\Documentation\Attributes\ApiResponse; +use Core\Mod\Api\Documentation\Attributes\ApiSecurity; + +#[ApiTag('Posts', 'Blog post management')] +#[ApiSecurity('api_key')] +class PostController extends Controller +{ + #[ApiParameter('page', 'query', 'integer', 'Page number', example: 1)] + #[ApiParameter('per_page', 'query', 'integer', 'Items per page', example: 25)] + #[ApiParameter('status', 'query', 'string', 'Filter by status', enum: ['draft', 'published'])] + #[ApiParameter('sort', 'query', 'string', 'Sort fields (prefix with - for desc)', example: '-created_at')] + #[ApiResponse(200, PostResource::class, 'List of posts', paginated: true)] + public function index(Request $request) + { + // ... + } + + #[ApiParameter('id', 'path', 'integer', 'Post ID', required: true)] + #[ApiResponse(200, PostResource::class, 'Post details')] + #[ApiResponse(404, null, 'Post not found')] + public function show(Post $post) + { + // ... + } + + #[ApiResponse(201, PostResource::class, 'Post created')] + #[ApiResponse(422, null, 'Validation error')] + public function store(Request $request) + { + // ... + } +} +``` + +## Error Handling + +### Consistent Error Responses + +Use the `HasApiResponses` trait for consistent errors: + +```php +use Core\Mod\Api\Concerns\HasApiResponses; + +class PostController extends Controller +{ + use HasApiResponses; + + public function show($id) + { + $post = Post::find($id); + + if (!$post) { + return $this->notFoundResponse('Post'); + } + + return new PostResource($post); + } + + public function store(Request $request) + { + // Check entitlement limits + if (!$this->canCreatePost($request->user())) { + return $this->limitReachedResponse( + 'posts', + 'You have reached your post limit. Please upgrade your plan.' + ); + } + + // Validation errors are handled automatically by Laravel + $validated = $request->validate([...]); + + // ... + } +} +``` + +### Error Response Format + +All errors follow a consistent format: + +```json +{ + "error": "not_found", + "message": "Post not found." +} +``` + +```json +{ + "error": "validation_failed", + "message": "The given data was invalid.", + "errors": { + "title": ["The title field is required."], + "content": ["The content must be at least 100 characters."] + } +} +``` + +```json +{ + "error": "feature_limit_reached", + "message": "You have reached your post limit.", + "feature": "posts", + "upgrade_url": "https://example.com/upgrade" +} +``` + +## Best Practices + +### 1. Use API Resources + +Always transform models through resources: + +```php +// Good - consistent response format +return new PostResource($post); + +// Bad - exposes database schema +return response()->json($post); +``` + +### 2. Validate All Input + +```php +public function store(Request $request) +{ + $validated = $request->validate([ + 'title' => 'required|string|max:255', + 'content' => 'required|string|min:100', + 'status' => 'in:draft,published', + 'published_at' => 'nullable|date|after:now', + ]); + + // Use validated data only + $post = Post::create($validated); +} +``` + +### 3. Eager Load Relationships + +```php +// Good - single query with eager loading +$posts = Post::with(['author', 'categories'])->paginate(); + +// Bad - N+1 queries +$posts = Post::paginate(); +foreach ($posts as $post) { + echo $post->author->name; // Additional query per post +} +``` + +### 4. Use Route Model Binding + +```php +// Good - automatic 404 if not found +public function show(Post $post) +{ + return new PostResource($post); +} + +// Unnecessary - route model binding handles this +public function show($id) +{ + $post = Post::findOrFail($id); + return new PostResource($post); +} +``` + +### 5. Scope Data by Workspace + +```php +public function index(Request $request) +{ + $workspaceId = $request->user()->currentWorkspaceId(); + + $posts = Post::where('workspace_id', $workspaceId) + ->paginate(); + + return new PaginatedCollection($posts, PostResource::class); +} +``` + +## Testing + +### Feature Tests + +```php +create([ + 'scopes' => ['posts:read'], + ]); + + Post::factory()->count(5)->create(); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$apiKey->plaintext_key}", + ])->getJson('/api/v1/posts'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'type', 'attributes'], + ], + 'meta' => ['current_page', 'total'], + 'links', + ]); + } + + public function test_filters_posts_by_status(): void + { + $apiKey = ApiKey::factory()->create(['scopes' => ['posts:read']]); + + Post::factory()->create(['status' => 'draft']); + Post::factory()->create(['status' => 'published']); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$apiKey->plaintext_key}", + ])->getJson('/api/v1/posts?status=published'); + + $response->assertOk() + ->assertJsonCount(1, 'data'); + } + + public function test_creates_post_with_valid_scope(): void + { + $apiKey = ApiKey::factory()->create([ + 'scopes' => ['posts:write'], + ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$apiKey->plaintext_key}", + ])->postJson('/api/v1/posts', [ + 'title' => 'Test Post', + 'content' => 'Test content...', + ]); + + $response->assertCreated() + ->assertJsonPath('data.attributes.title', 'Test Post'); + } + + public function test_rejects_create_without_scope(): void + { + $apiKey = ApiKey::factory()->create([ + 'scopes' => ['posts:read'], // No write scope + ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$apiKey->plaintext_key}", + ])->postJson('/api/v1/posts', [ + 'title' => 'Test Post', + 'content' => 'Test content...', + ]); + + $response->assertForbidden(); + } +} +``` + +## Learn More + +- [Authentication](/packages/api/authentication) - API key management +- [Rate Limiting](/packages/api/rate-limiting) - Tier-based rate limits +- [Scopes](/packages/api/scopes) - Permission system +- [Webhooks](/packages/api/webhooks) - Event notifications +- [OpenAPI Documentation](/packages/api/documentation) - Auto-generated docs diff --git a/docs/documentation.md b/docs/documentation.md new file mode 100644 index 0000000..61bec4c --- /dev/null +++ b/docs/documentation.md @@ -0,0 +1,474 @@ +# API Documentation + +Automatically generate OpenAPI 3.0 documentation with Swagger UI, Scalar, and ReDoc viewers. + +## Overview + +The API package automatically generates OpenAPI documentation from your routes, controllers, and doc blocks. + +**Features:** +- Automatic route discovery +- OpenAPI 3.0 spec generation +- Multiple documentation viewers +- Security scheme documentation +- Request/response examples +- Interactive API explorer + +## Accessing Documentation + +### Available Endpoints + +``` +/api/docs - Swagger UI (default) +/api/docs/scalar - Scalar viewer +/api/docs/redoc - ReDoc viewer +/api/docs/openapi - Raw OpenAPI JSON +``` + +### Protection + +Documentation is protected in production: + +```php +// config/api.php +return [ + 'documentation' => [ + 'enabled' => env('API_DOCS_ENABLED', !app()->isProduction()), + 'middleware' => ['auth', 'can:view-api-docs'], + ], +]; +``` + +## Attributes + +### Hiding Endpoints + +```php +use Mod\Api\Documentation\Attributes\ApiHidden; + +#[ApiHidden] +class InternalController +{ + // Entire controller hidden from docs +} + +class PostController +{ + #[ApiHidden] + public function internalMethod() + { + // Single method hidden + } +} +``` + +### Tagging Endpoints + +```php +use Mod\Api\Documentation\Attributes\ApiTag; + +#[ApiTag('Blog Posts')] +class PostController +{ + // All methods tagged with "Blog Posts" +} +``` + +### Documenting Parameters + +```php +use Mod\Api\Documentation\Attributes\ApiParameter; + +class PostController +{ + #[ApiParameter( + name: 'status', + in: 'query', + description: 'Filter by post status', + required: false, + schema: ['type' => 'string', 'enum' => ['draft', 'published', 'archived']] + )] + #[ApiParameter( + name: 'category', + in: 'query', + description: 'Filter by category ID', + schema: ['type' => 'integer'] + )] + public function index(Request $request) + { + // GET /posts?status=published&category=5 + } +} +``` + +### Documenting Responses + +```php +use Mod\Api\Documentation\Attributes\ApiResponse; + +class PostController +{ + #[ApiResponse( + status: 200, + description: 'Post created successfully', + content: [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string'], + 'status' => ['type' => 'string'], + ], + ], + ], + ] + )] + #[ApiResponse( + status: 422, + description: 'Validation error' + )] + public function store(Request $request) + { + // ... + } +} +``` + +### Security Requirements + +```php +use Mod\Api\Documentation\Attributes\ApiSecurity; + +#[ApiSecurity(['apiKey' => []])] +class PostController +{ + // Requires API key authentication +} + +#[ApiSecurity(['bearerAuth' => ['posts:write']])] +public function store(Request $request) +{ + // Requires Bearer token with posts:write scope +} +``` + +## Configuration + +```php +// config/api.php +return [ + 'documentation' => [ + 'enabled' => true, + + 'info' => [ + 'title' => 'Core PHP Framework API', + 'description' => 'REST API for Core PHP Framework', + 'version' => '1.0.0', + 'contact' => [ + 'name' => 'API Support', + 'email' => 'api@example.com', + 'url' => 'https://example.com/support', + ], + ], + + 'servers' => [ + [ + 'url' => 'https://api.example.com', + 'description' => 'Production', + ], + [ + 'url' => 'https://staging.example.com', + 'description' => 'Staging', + ], + ], + + 'security_schemes' => [ + 'apiKey' => [ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'API Key', + 'description' => 'API key authentication. Format: `Bearer sk_live_...`', + ], + ], + + 'viewers' => [ + 'swagger' => true, + 'scalar' => true, + 'redoc' => true, + ], + ], +]; +``` + +## Extensions + +### Custom Extensions + +```php + 'Blog Posts', + 'description' => 'Operations for managing blog posts', + ]; + + // Add custom security requirements + $spec['paths']['/posts']['post']['security'][] = [ + 'apiKey' => [], + ]; + + return $spec; + } +} +``` + +**Register Extension:** + +```php +use Core\Events\ApiRoutesRegistering; + +public function onApiRoutes(ApiRoutesRegistering $event): void +{ + $event->documentationExtension(new BlogExtension()); +} +``` + +### Built-in Extensions + +**Rate Limit Extension:** + +```php +use Mod\Api\Documentation\Extensions\RateLimitExtension; + +// Automatically documents rate limits in responses +// Adds X-RateLimit-* headers to all endpoints +``` + +**Workspace Header Extension:** + +```php +use Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension; + +// Documents X-Workspace-ID header requirement +// Adds to all workspace-scoped endpoints +``` + +## Common Examples + +### Pagination + +```php +use Mod\Api\Documentation\Examples\CommonExamples; + +#[ApiResponse( + status: 200, + description: 'Paginated list of posts', + content: CommonExamples::paginatedResponse('posts', [ + 'id' => 1, + 'title' => 'Example Post', + 'status' => 'published', + ]) +)] +public function index(Request $request) +{ + return PostResource::collection( + Post::paginate(20) + ); +} +``` + +**Generates:** + +```json +{ + "data": [ + { + "id": 1, + "title": "Example Post", + "status": "published" + } + ], + "links": { + "first": "...", + "last": "...", + "prev": null, + "next": "..." + }, + "meta": { + "current_page": 1, + "total": 100 + } +} +``` + +### Error Responses + +```php +#[ApiResponse( + status: 404, + description: 'Post not found', + content: CommonExamples::errorResponse('Post not found', 'resource_not_found') +)] +public function show(Post $post) +{ + return new PostResource($post); +} +``` + +## Module Discovery + +The documentation system automatically discovers API routes from all modules: + +```php +// Mod\Blog\Boot +public function onApiRoutes(ApiRoutesRegistering $event): void +{ + $event->routes(function () { + Route::get('/posts', [PostController::class, 'index']); + // Automatically included in docs + }); +} +``` + +**Discovery Process:** +1. Scan all registered API routes +2. Extract controller methods +3. Parse doc blocks and attributes +4. Generate OpenAPI spec +5. Cache for performance + +## Viewers + +### Swagger UI + +Interactive API explorer with "Try it out" functionality. + +**Access:** `/api/docs` + +**Features:** +- Test endpoints directly +- View request/response examples +- OAuth/API key authentication +- Model schemas + +### Scalar + +Modern, clean documentation viewer. + +**Access:** `/api/docs/scalar` + +**Features:** +- Beautiful UI +- Dark mode +- Code examples in multiple languages +- Interactive examples + +### ReDoc + +Professional documentation with three-panel layout. + +**Access:** `/api/docs/redoc` + +**Features:** +- Search functionality +- Menu navigation +- Responsive design +- Printable + +## Best Practices + +### 1. Document All Public Endpoints + +```php +// ✅ Good - documented +#[ApiTag('Posts')] +#[ApiResponse(200, 'Success')] +#[ApiResponse(422, 'Validation error')] +public function store(Request $request) + +// ❌ Bad - undocumented +public function store(Request $request) +``` + +### 2. Provide Examples + +```php +// ✅ Good - request example +#[ApiParameter( + name: 'status', + example: 'published' +)] + +// ❌ Bad - no example +#[ApiParameter(name: 'status')] +``` + +### 3. Hide Internal Endpoints + +```php +// ✅ Good - hidden +#[ApiHidden] +public function internal() + +// ❌ Bad - exposed in docs +public function internal() +``` + +### 4. Group Related Endpoints + +```php +// ✅ Good - tagged +#[ApiTag('Blog Posts')] +class PostController + +// ❌ Bad - ungrouped +class PostController +``` + +## Testing + +```php +use Tests\TestCase; + +class DocumentationTest extends TestCase +{ + public function test_generates_openapi_spec(): void + { + $response = $this->getJson('/api/docs/openapi'); + + $response->assertStatus(200); + $response->assertJsonStructure([ + 'openapi', + 'info', + 'paths', + 'components', + ]); + } + + public function test_includes_blog_endpoints(): void + { + $response = $this->getJson('/api/docs/openapi'); + + $spec = $response->json(); + + $this->assertArrayHasKey('/posts', $spec['paths']); + $this->assertArrayHasKey('/posts/{id}', $spec['paths']); + } +} +``` + +## Learn More + +- [Authentication →](/packages/api/authentication) +- [Scopes →](/packages/api/scopes) +- [API Reference →](/api/endpoints) diff --git a/docs/endpoints-reference.md b/docs/endpoints-reference.md new file mode 100644 index 0000000..8534bd4 --- /dev/null +++ b/docs/endpoints-reference.md @@ -0,0 +1,1129 @@ +# API Endpoints Reference + +Complete reference for all core-api endpoints. All endpoints follow RESTful conventions with consistent authentication, pagination, filtering, and error handling. + +## Base URL + +``` +https://your-domain.com/api/v1 +``` + +## Authentication + +All authenticated endpoints require an API key in the Authorization header: + +```http +Authorization: Bearer sk_live_abc123def456... +``` + +See [Authentication](/packages/api/authentication) for details on creating and managing API keys. + +## Common Headers + +### Request Headers + +| Header | Required | Description | +|--------|----------|-------------| +| `Authorization` | Yes* | API key (Bearer token) | +| `Accept` | No | Should be `application/json` | +| `Content-Type` | For POST/PUT | Should be `application/json` | +| `X-Workspace-ID` | Sometimes | Workspace context for multi-tenant endpoints | +| `Idempotency-Key` | No | UUID for safe retries on POST/PUT/DELETE | + +*Required for authenticated endpoints + +### Response Headers + +| Header | Description | +|--------|-------------| +| `X-RateLimit-Limit` | Maximum requests allowed in window | +| `X-RateLimit-Remaining` | Requests remaining in current window | +| `X-RateLimit-Reset` | Unix timestamp when limit resets | +| `X-Request-ID` | Unique request identifier for debugging | + +## Common Parameters + +### Pagination + +All list endpoints support pagination: + +| Parameter | Type | Default | Max | Description | +|-----------|------|---------|-----|-------------| +| `page` | integer | 1 | - | Page number | +| `per_page` | integer | 25 | 100 | Items per page | + +**Response format:** + +```json +{ + "data": [...], + "meta": { + "current_page": 1, + "from": 1, + "last_page": 10, + "per_page": 25, + "to": 25, + "total": 250 + }, + "links": { + "first": "https://api.example.com/v1/resource?page=1", + "last": "https://api.example.com/v1/resource?page=10", + "prev": null, + "next": "https://api.example.com/v1/resource?page=2" + } +} +``` + +### Filtering + +Filter list results with query parameters: + +| Parameter | Type | Description | +|-----------|------|-------------| +| `status` | string | Filter by status (varies by resource) | +| `created_after` | ISO 8601 date | Filter by creation date | +| `created_before` | ISO 8601 date | Filter by creation date | +| `updated_after` | ISO 8601 date | Filter by update date | +| `updated_before` | ISO 8601 date | Filter by update date | +| `search` | string | Full-text search (if supported) | + +### Sorting + +Sort results using the `sort` parameter: + +```http +GET /api/v1/resources?sort=-created_at,name +``` + +- Prefix with `-` for descending order +- Default is ascending order +- Comma-separate multiple fields + +### Field Selection + +Request specific fields only: + +```http +GET /api/v1/resources?fields=id,name,created_at +``` + +### Includes + +Eager-load related resources: + +```http +GET /api/v1/resources?include=owner,tags +``` + +--- + +## Workspaces + +### List Workspaces + +```http +GET /api/v1/workspaces +``` + +**Required scope:** `workspaces:read` + +**Query parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `page` | integer | Page number | +| `per_page` | integer | Items per page | + +**Response:** `200 OK` + +```json +{ + "data": [ + { + "id": 1, + "name": "Acme Corporation", + "slug": "acme-corp", + "tier": "business", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-15T10:30:00Z" + } + ], + "meta": {...}, + "links": {...} +} +``` + +### Get Workspace + +```http +GET /api/v1/workspaces/{id} +``` + +**Required scope:** `workspaces:read` + +**Path parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `id` | integer | Yes | Workspace ID | + +**Response:** `200 OK` + +```json +{ + "data": { + "id": 1, + "name": "Acme Corporation", + "slug": "acme-corp", + "tier": "business", + "settings": { + "timezone": "UTC", + "locale": "en_GB" + }, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-15T10:30:00Z" + } +} +``` + +**Error responses:** + +| Status | Code | Description | +|--------|------|-------------| +| 404 | `not_found` | Workspace not found | + +### Create Workspace + +```http +POST /api/v1/workspaces +``` + +**Required scope:** `workspaces:write` + +**Request body:** + +```json +{ + "name": "New Workspace", + "slug": "new-workspace", + "tier": "pro" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Workspace name (max 255 chars) | +| `slug` | string | No | URL-friendly identifier (auto-generated if not provided) | +| `tier` | string | No | Subscription tier (default: free) | + +**Response:** `201 Created` + +```json +{ + "message": "Workspace created successfully.", + "data": { + "id": 2, + "name": "New Workspace", + "slug": "new-workspace", + "tier": "pro", + "created_at": "2026-01-15T10:30:00Z" + } +} +``` + +**Error responses:** + +| Status | Code | Description | +|--------|------|-------------| +| 422 | `validation_failed` | Invalid input data | + +### Update Workspace + +```http +PATCH /api/v1/workspaces/{id} +``` + +**Required scope:** `workspaces:write` + +**Request body:** + +```json +{ + "name": "Updated Name", + "settings": { + "timezone": "Europe/London" + } +} +``` + +**Response:** `200 OK` + +### Delete Workspace + +```http +DELETE /api/v1/workspaces/{id} +``` + +**Required scope:** `workspaces:delete` + +**Response:** `204 No Content` + +--- + +## API Keys + +### List API Keys + +```http +GET /api/v1/api-keys +``` + +**Required scope:** `api-keys:read` + +**Response:** `200 OK` + +```json +{ + "data": [ + { + "id": 1, + "name": "Production API Key", + "prefix": "sk_live_abc", + "scopes": ["posts:read", "posts:write"], + "rate_limit_tier": "pro", + "last_used_at": "2026-01-15T10:30:00Z", + "expires_at": null, + "created_at": "2026-01-01T00:00:00Z" + } + ] +} +``` + +Note: The full API key is never returned after creation. + +### Get API Key + +```http +GET /api/v1/api-keys/{id} +``` + +**Required scope:** `api-keys:read` + +**Response:** `200 OK` + +```json +{ + "data": { + "id": 1, + "name": "Production API Key", + "prefix": "sk_live_abc", + "scopes": ["posts:read", "posts:write"], + "rate_limit_tier": "pro", + "last_used_at": "2026-01-15T10:30:00Z", + "expires_at": null, + "created_at": "2026-01-01T00:00:00Z" + } +} +``` + +### Create API Key + +```http +POST /api/v1/api-keys +``` + +**Required scope:** `api-keys:write` + +**Request body:** + +```json +{ + "name": "Mobile App Key", + "scopes": ["posts:read", "users:read"], + "rate_limit_tier": "pro", + "expires_at": "2027-01-01T00:00:00Z" +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Key name (max 255 chars) | +| `scopes` | array | No | Permission scopes (default: read, write) | +| `rate_limit_tier` | string | No | Rate limit tier (default: from workspace) | +| `expires_at` | ISO 8601 | No | Expiration date (null = never) | + +**Response:** `201 Created` + +```json +{ + "message": "API key created successfully.", + "data": { + "id": 2, + "name": "Mobile App Key", + "key": "sk_live_abc123def456ghi789...", + "scopes": ["posts:read", "users:read"], + "rate_limit_tier": "pro", + "expires_at": "2027-01-01T00:00:00Z", + "created_at": "2026-01-15T10:30:00Z" + } +} +``` + +**Important:** The `key` field is only returned once during creation. Store it securely. + +### Rotate API Key + +```http +POST /api/v1/api-keys/{id}/rotate +``` + +**Required scope:** `api-keys:write` + +**Request body:** + +```json +{ + "grace_period_hours": 24 +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `grace_period_hours` | integer | No | Hours both keys work (default: 24) | + +**Response:** `200 OK` + +```json +{ + "message": "API key rotated successfully.", + "data": { + "id": 3, + "name": "Mobile App Key", + "key": "sk_live_new123key456...", + "scopes": ["posts:read", "users:read"], + "grace_period_ends_at": "2026-01-16T10:30:00Z" + } +} +``` + +### Revoke API Key + +```http +DELETE /api/v1/api-keys/{id} +``` + +**Required scope:** `api-keys:delete` + +**Response:** `204 No Content` + +--- + +## Webhooks + +### List Webhook Endpoints + +```http +GET /api/v1/webhooks +``` + +**Required scope:** `webhooks:read` + +**Response:** `200 OK` + +```json +{ + "data": [ + { + "id": 1, + "url": "https://your-app.com/webhooks", + "events": ["post.created", "post.updated"], + "is_active": true, + "success_count": 150, + "failure_count": 2, + "last_delivery_at": "2026-01-15T10:30:00Z", + "created_at": "2026-01-01T00:00:00Z" + } + ] +} +``` + +### Get Webhook Endpoint + +```http +GET /api/v1/webhooks/{id} +``` + +**Required scope:** `webhooks:read` + +**Response:** `200 OK` + +```json +{ + "data": { + "id": 1, + "url": "https://your-app.com/webhooks", + "events": ["post.created", "post.updated"], + "is_active": true, + "success_count": 150, + "failure_count": 2, + "consecutive_failures": 0, + "last_delivery_at": "2026-01-15T10:30:00Z", + "created_at": "2026-01-01T00:00:00Z" + } +} +``` + +### Create Webhook Endpoint + +```http +POST /api/v1/webhooks +``` + +**Required scope:** `webhooks:write` + +**Request body:** + +```json +{ + "url": "https://your-app.com/webhooks", + "events": ["post.created", "post.updated", "post.deleted"], + "secret": "whsec_abc123def456..." +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `url` | string | Yes | Webhook endpoint URL (HTTPS required) | +| `events` | array | Yes | Events to subscribe to | +| `secret` | string | No | Signing secret (auto-generated if not provided) | + +**Response:** `201 Created` + +```json +{ + "message": "Webhook endpoint created successfully.", + "data": { + "id": 2, + "url": "https://your-app.com/webhooks", + "events": ["post.created", "post.updated", "post.deleted"], + "secret": "whsec_abc123def456...", + "is_active": true, + "created_at": "2026-01-15T10:30:00Z" + } +} +``` + +**Important:** The `secret` is only returned during creation. Store it securely. + +### Update Webhook Endpoint + +```http +PATCH /api/v1/webhooks/{id} +``` + +**Required scope:** `webhooks:write` + +**Request body:** + +```json +{ + "url": "https://new-url.com/webhooks", + "events": ["post.*"], + "is_active": true +} +``` + +**Response:** `200 OK` + +### Delete Webhook Endpoint + +```http +DELETE /api/v1/webhooks/{id} +``` + +**Required scope:** `webhooks:delete` + +**Response:** `204 No Content` + +### Test Webhook Endpoint + +```http +POST /api/v1/webhooks/{id}/test +``` + +**Required scope:** `webhooks:write` + +Sends a test event to the webhook endpoint. + +**Response:** `200 OK` + +```json +{ + "success": true, + "status_code": 200, + "response_time_ms": 145, + "response_body": "{\"received\": true}" +} +``` + +**Error response (delivery failed):** + +```json +{ + "success": false, + "status_code": 500, + "error": "Connection timeout", + "response_time_ms": 30000 +} +``` + +### List Webhook Deliveries + +```http +GET /api/v1/webhooks/{id}/deliveries +``` + +**Required scope:** `webhooks:read` + +**Query parameters:** + +| Parameter | Type | Description | +|-----------|------|-------------| +| `status` | string | Filter by status: `pending`, `success`, `failed`, `retrying` | +| `page` | integer | Page number | +| `per_page` | integer | Items per page | + +**Response:** `200 OK` + +```json +{ + "data": [ + { + "id": 1, + "event_id": "evt_abc123def456", + "event_type": "post.created", + "status": "success", + "response_code": 200, + "attempt": 1, + "delivered_at": "2026-01-15T10:30:00Z", + "created_at": "2026-01-15T10:30:00Z" + }, + { + "id": 2, + "event_id": "evt_xyz789", + "event_type": "post.updated", + "status": "retrying", + "response_code": 500, + "attempt": 2, + "next_retry_at": "2026-01-15T10:35:00Z", + "created_at": "2026-01-15T10:30:00Z" + } + ], + "meta": {...}, + "links": {...} +} +``` + +### Retry Webhook Delivery + +```http +POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry +``` + +**Required scope:** `webhooks:write` + +Manually retry a failed delivery. + +**Response:** `200 OK` + +```json +{ + "message": "Delivery queued for retry.", + "data": { + "id": 2, + "status": "pending", + "attempt": 3 + } +} +``` + +**Error responses:** + +| Status | Code | Description | +|--------|------|-------------| +| 400 | `cannot_retry` | Delivery already succeeded or max retries reached | + +--- + +## Entitlements + +### Check Feature Access + +```http +GET /api/v1/entitlements/check +``` + +**Required scope:** `entitlements:read` + +**Query parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `feature` | string | Yes | Feature key to check | +| `quantity` | integer | No | Amount to check (default: 1) | + +**Response:** `200 OK` + +```json +{ + "allowed": true, + "feature": "posts", + "current_usage": 45, + "limit": 100, + "available": 55 +} +``` + +**Response (limit exceeded):** + +```json +{ + "allowed": false, + "feature": "posts", + "reason": "LIMIT_EXCEEDED", + "message": "Post limit exceeded. Used: 100, Limit: 100", + "current_usage": 100, + "limit": 100, + "available": 0, + "upgrade_url": "https://example.com/upgrade" +} +``` + +### Record Usage + +```http +POST /api/v1/entitlements/usage +``` + +**Required scope:** `entitlements:write` + +**Request body:** + +```json +{ + "feature": "api_calls", + "quantity": 1, + "metadata": { + "endpoint": "/api/v1/posts" + } +} +``` + +**Response:** `200 OK` + +```json +{ + "recorded": true, + "feature": "api_calls", + "current_usage": 5001, + "limit": 10000 +} +``` + +### Get Usage Summary + +```http +GET /api/v1/entitlements/summary +``` + +**Required scope:** `entitlements:read` + +Returns usage summary for the authenticated user's workspace. + +**Response:** `200 OK` + +```json +{ + "data": { + "workspace_id": 1, + "tier": "pro", + "entitlements": { + "posts": { + "used": 45, + "limit": 1000, + "available": 955, + "percentage": 4.5 + }, + "api_calls": { + "used": 5001, + "limit": 10000, + "available": 4999, + "percentage": 50.01, + "reset_at": "2026-02-01T00:00:00Z" + }, + "storage": { + "used": 1073741824, + "limit": 5368709120, + "available": 4294967296, + "percentage": 20, + "unit": "bytes" + } + } + } +} +``` + +--- + +## SEO Reports + +### Submit SEO Report + +```http +POST /api/v1/seo/report +``` + +**Required scope:** `seo:write` + +**Request body:** + +```json +{ + "url": "https://example.com/page", + "scores": { + "performance": 85, + "accessibility": 92, + "best_practices": 88, + "seo": 95 + }, + "issues": [ + { + "type": "missing_alt", + "severity": "warning", + "element": "img.hero-image" + } + ] +} +``` + +**Response:** `201 Created` + +### Get SEO Issues + +```http +GET /api/v1/seo/issues/{workspace_id} +``` + +**Required scope:** `seo:read` + +**Response:** `200 OK` + +```json +{ + "data": [ + { + "id": 1, + "url": "https://example.com/page", + "issue_type": "missing_alt", + "severity": "warning", + "details": {...}, + "created_at": "2026-01-15T10:30:00Z" + } + ] +} +``` + +--- + +## Pixel Tracking + +### Get Pixel Configuration + +```http +GET /api/v1/pixel/config +``` + +**Authentication:** Not required + +Returns tracking pixel configuration for the current domain. + +**Response:** `200 OK` + +```json +{ + "enabled": true, + "features": { + "pageviews": true, + "events": true, + "sessions": true + }, + "sample_rate": 1.0 +} +``` + +### Track Event + +```http +POST /api/v1/pixel/track +``` + +**Authentication:** Not required + +**Rate limit:** 300 requests per minute + +**Request body:** + +```json +{ + "event": "pageview", + "url": "https://example.com/page", + "referrer": "https://google.com", + "user_agent": "Mozilla/5.0...", + "properties": { + "title": "Page Title" + } +} +``` + +**Response:** `200 OK` + +```json +{ + "tracked": true +} +``` + +--- + +## MCP (Model Context Protocol) + +### List MCP Servers + +```http +GET /api/v1/mcp/servers +``` + +**Required scope:** `mcp:read` + +**Response:** `200 OK` + +```json +{ + "data": [ + { + "id": "filesystem", + "name": "Filesystem Server", + "description": "File and directory operations", + "tools": ["read_file", "write_file", "list_directory"] + } + ] +} +``` + +### Get MCP Server + +```http +GET /api/v1/mcp/servers/{id} +``` + +**Required scope:** `mcp:read` + +### List Server Tools + +```http +GET /api/v1/mcp/servers/{id}/tools +``` + +**Required scope:** `mcp:read` + +**Response:** `200 OK` + +```json +{ + "data": [ + { + "name": "read_file", + "description": "Read contents of a file", + "parameters": { + "path": { + "type": "string", + "description": "File path to read", + "required": true + } + } + } + ] +} +``` + +### Call MCP Tool + +```http +POST /api/v1/mcp/tools/call +``` + +**Required scope:** `mcp:write` + +**Request body:** + +```json +{ + "server": "filesystem", + "tool": "read_file", + "arguments": { + "path": "/path/to/file.txt" + } +} +``` + +**Response:** `200 OK` + +```json +{ + "result": { + "content": "File contents here...", + "size": 1234 + } +} +``` + +### Get MCP Resource + +```http +GET /api/v1/mcp/resources/{uri} +``` + +**Required scope:** `mcp:read` + +The `uri` can include slashes and will be URL-decoded. + +--- + +## Error Responses + +All errors follow a consistent format: + +### Validation Error (422) + +```json +{ + "error": "validation_failed", + "message": "The given data was invalid.", + "errors": { + "name": ["The name field is required."], + "email": ["The email must be a valid email address."] + } +} +``` + +### Not Found (404) + +```json +{ + "error": "not_found", + "message": "Resource not found." +} +``` + +### Unauthorized (401) + +```json +{ + "error": "unauthorized", + "message": "Invalid or missing API key." +} +``` + +### Forbidden (403) + +```json +{ + "error": "access_denied", + "message": "Insufficient permissions. Required scope: posts:write" +} +``` + +### Feature Limit Reached (403) + +```json +{ + "error": "feature_limit_reached", + "message": "You have reached your limit for this feature.", + "feature": "posts", + "upgrade_url": "https://example.com/upgrade" +} +``` + +### Rate Limited (429) + +```json +{ + "error": "rate_limit_exceeded", + "message": "Too many requests. Please retry after 60 seconds.", + "retry_after": 60, + "limit": 1000, + "remaining": 0, + "reset_at": "2026-01-15T11:00:00Z" +} +``` + +### Server Error (500) + +```json +{ + "error": "server_error", + "message": "An unexpected error occurred.", + "request_id": "req_abc123def456" +} +``` + +--- + +## Rate Limits + +Rate limits vary by tier: + +| Tier | Requests/Minute | Burst Allowance | +|------|-----------------|-----------------| +| Free | 60 | None | +| Starter | 1,000 | 20% | +| Pro | 5,000 | 30% | +| Agency | 20,000 | 50% | +| Enterprise | 100,000 | 100% | + +Rate limit headers are included in every response: + +```http +X-RateLimit-Limit: 5000 +X-RateLimit-Remaining: 4892 +X-RateLimit-Reset: 1705312260 +``` + +See [Rate Limiting](/packages/api/rate-limiting) for details. + +--- + +## Idempotency + +For safe retries on POST, PUT, and DELETE requests, include an idempotency key: + +```http +POST /api/v1/posts +Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000 +``` + +If the same idempotency key is used within 24 hours: +- Same status code and response body returned +- No duplicate resource created +- Safe to retry failed requests + +--- + +## Learn More + +- [Building REST APIs](/packages/api/building-rest-apis) - Tutorial for creating API endpoints +- [Authentication](/packages/api/authentication) - API key management +- [Webhooks](/packages/api/webhooks) - Event notifications +- [Webhook Integration](/packages/api/webhook-integration) - Consumer guide +- [Rate Limiting](/packages/api/rate-limiting) - Understanding rate limits +- [Scopes](/packages/api/scopes) - Permission system diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..03ff405 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,338 @@ +# API Package + +The API package provides a complete REST API with secure authentication, rate limiting, webhooks, and OpenAPI documentation. + +## Installation + +```bash +composer require host-uk/core-api +``` + +## Quick Start + +```php + 'onApiRoutes', + ]; + + public function onApiRoutes(ApiRoutesRegistering $event): void + { + $event->routes(function () { + Route::get('/posts', [Api\PostController::class, 'index']); + Route::post('/posts', [Api\PostController::class, 'store']); + Route::get('/posts/{id}', [Api\PostController::class, 'show']); + }); + } +} +``` + +## Key Features + +### Authentication & Security + +- **[API Keys](/packages/api/authentication)** - Secure API key management with bcrypt hashing +- **[Scopes](/packages/api/scopes)** - Fine-grained permission system +- **[Rate Limiting](/packages/api/rate-limiting)** - Tier-based rate limits with Redis backend +- **[Key Rotation](/packages/api/authentication#rotation)** - Secure key rotation with grace periods + +### Webhooks + +- **[Webhook Endpoints](/packages/api/webhooks)** - Event-driven notifications +- **[Signatures](/packages/api/webhooks#signatures)** - HMAC-SHA256 signature verification +- **[Delivery Tracking](/packages/api/webhooks#delivery)** - Retry logic and delivery history + +### Documentation + +- **[OpenAPI Spec](/packages/api/openapi)** - Auto-generated OpenAPI 3.0 documentation +- **[Interactive Docs](/packages/api/documentation)** - Swagger UI, Scalar, and ReDoc interfaces +- **[Code Examples](/packages/api/documentation#examples)** - Multi-language code snippets + +### Monitoring + +- **[Usage Analytics](/packages/api/analytics)** - Track API usage and quota +- **[Usage Alerts](/packages/api/alerts)** - Automated high-usage notifications +- **[Request Logging](/packages/api/logging)** - Comprehensive request/response logging + +## Authentication + +### Creating API Keys + +```php +use Mod\Api\Models\ApiKey; + +$apiKey = ApiKey::create([ + 'name' => 'Mobile App', + 'workspace_id' => $workspace->id, + 'scopes' => ['posts:read', 'posts:write'], + 'rate_limit_tier' => 'pro', +]); + +// Get plaintext key (only shown once!) +$plaintext = $apiKey->plaintext_key; // sk_live_abc123... +``` + +### Using API Keys + +```bash +curl -H "Authorization: Bearer sk_live_abc123..." \ + https://api.example.com/v1/posts +``` + +[Learn more about Authentication →](/packages/api/authentication) + +## Rate Limiting + +Tier-based rate limits with automatic enforcement: + +```php +// config/api.php +'rate_limits' => [ + 'free' => ['requests' => 1000, 'per' => 'hour'], + 'pro' => ['requests' => 10000, 'per' => 'hour'], + 'business' => ['requests' => 50000, 'per' => 'hour'], + 'enterprise' => ['requests' => null], // Unlimited +], +``` + +Rate limit headers included in every response: + +``` +X-RateLimit-Limit: 10000 +X-RateLimit-Remaining: 9847 +X-RateLimit-Reset: 1640995200 +``` + +[Learn more about Rate Limiting →](/packages/api/rate-limiting) + +## Webhooks + +### Creating Webhooks + +```php +use Mod\Api\Models\WebhookEndpoint; + +$webhook = WebhookEndpoint::create([ + 'url' => 'https://your-app.com/webhooks', + 'events' => ['post.created', 'post.updated'], + 'secret' => 'whsec_abc123...', + 'workspace_id' => $workspace->id, +]); +``` + +### Dispatching Events + +```php +use Mod\Api\Services\WebhookService; + +$service = app(WebhookService::class); + +$service->dispatch('post.created', [ + 'id' => $post->id, + 'title' => $post->title, + 'url' => route('posts.show', $post), +]); +``` + +### Verifying Signatures + +```php +use Mod\Api\Services\WebhookSignature; + +$signature = WebhookSignature::verify( + payload: $request->getContent(), + signature: $request->header('X-Webhook-Signature'), + secret: $webhook->secret +); + +if (!$signature) { + abort(401, 'Invalid signature'); +} +``` + +[Learn more about Webhooks →](/packages/api/webhooks) + +## OpenAPI Documentation + +Auto-generate OpenAPI documentation with attributes: + +```php +use Mod\Api\Documentation\Attributes\ApiTag; +use Mod\Api\Documentation\Attributes\ApiParameter; +use Mod\Api\Documentation\Attributes\ApiResponse; + +#[ApiTag('Posts')] +class PostController extends Controller +{ + #[ApiParameter(name: 'page', in: 'query', type: 'integer')] + #[ApiParameter(name: 'per_page', in: 'query', type: 'integer')] + #[ApiResponse(status: 200, description: 'List of posts')] + public function index(Request $request) + { + return PostResource::collection( + Post::paginate($request->input('per_page', 15)) + ); + } +} +``` + +View documentation at: +- `/api/docs` - Swagger UI +- `/api/docs/scalar` - Scalar interface +- `/api/docs/redoc` - ReDoc interface + +[Learn more about Documentation →](/packages/api/documentation) + +## API Resources + +Transform models to JSON: + +```php + $this->id, + 'title' => $this->title, + 'slug' => $this->slug, + 'excerpt' => $this->excerpt, + 'content' => $this->when( + $request->user()->tokenCan('posts:read-content'), + $this->content + ), + 'status' => $this->status, + 'published_at' => $this->published_at?->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + ]; + } +} +``` + +## Configuration + +```php +// config/api.php +return [ + 'prefix' => 'api/v1', + 'middleware' => ['api'], + + 'rate_limits' => [ + 'free' => ['requests' => 1000, 'per' => 'hour'], + 'pro' => ['requests' => 10000, 'per' => 'hour'], + 'business' => ['requests' => 50000, 'per' => 'hour'], + 'enterprise' => ['requests' => null], + ], + + 'api_keys' => [ + 'hash_algo' => 'bcrypt', + 'prefix' => 'sk', + 'length' => 32, + ], + + 'webhooks' => [ + 'max_retries' => 3, + 'retry_delay' => 60, // seconds + 'signature_algo' => 'sha256', + ], + + 'documentation' => [ + 'enabled' => true, + 'middleware' => ['web', 'auth'], + 'title' => 'API Documentation', + ], +]; +``` + +## Best Practices + +### 1. Use API Resources + +```php +// ✅ Good - API resource +return PostResource::collection($posts); + +// ❌ Bad - raw model data +return $posts->toArray(); +``` + +### 2. Implement Scopes + +```php +// ✅ Good - scope protection +Route::middleware('scope:posts:write') + ->post('/posts', [PostController::class, 'store']); +``` + +### 3. Verify Webhook Signatures + +```php +// ✅ Good - verify signature +if (!WebhookSignature::verify($payload, $signature, $secret)) { + abort(401); +} +``` + +### 4. Use Rate Limit Middleware + +```php +// ✅ Good - rate limited +Route::middleware('api.rate-limit') + ->group(function () { + // API routes + }); +``` + +## Testing + +```php +create([ + 'scopes' => ['posts:read'], + ]); + + $response = $this->withHeaders([ + 'Authorization' => "Bearer {$apiKey->plaintext_key}", + ])->getJson('/api/v1/posts'); + + $response->assertOk() + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'title', 'slug'], + ], + ]); + } +} +``` + +## Learn More + +- [Authentication →](/packages/api/authentication) +- [Rate Limiting →](/packages/api/rate-limiting) +- [Webhooks →](/packages/api/webhooks) +- [OpenAPI Docs →](/packages/api/documentation) +- [API Reference →](/api/endpoints) diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md new file mode 100644 index 0000000..4e28438 --- /dev/null +++ b/docs/rate-limiting.md @@ -0,0 +1,246 @@ +# Rate Limiting + +The API package provides tier-based rate limiting with Redis backend, custom limits per endpoint, and automatic enforcement. + +## Overview + +Rate limiting: +- Prevents API abuse +- Ensures fair usage +- Protects server resources +- Enforces tier limits + +## Tier-Based Limits + +Configure limits per tier: + +```php +// config/api.php +'rate_limits' => [ + 'free' => [ + 'requests' => 1000, + 'per' => 'hour', + ], + 'pro' => [ + 'requests' => 10000, + 'per' => 'hour', + ], + 'business' => [ + 'requests' => 50000, + 'per' => 'hour', + ], + 'enterprise' => [ + 'requests' => null, // Unlimited + ], +], +``` + +## Response Headers + +Every response includes rate limit headers: + +``` +X-RateLimit-Limit: 10000 +X-RateLimit-Remaining: 9847 +X-RateLimit-Reset: 1640995200 +``` + +## Applying Rate Limits + +### Global Rate Limiting + +```php +// Apply to all API routes +Route::middleware('api.rate-limit')->group(function () { + Route::get('/posts', [PostController::class, 'index']); + Route::post('/posts', [PostController::class, 'store']); +}); +``` + +### Per-Endpoint Limits + +```php +// Custom limit for specific endpoint +Route::get('/search', [SearchController::class, 'index']) + ->middleware('throttle:60,1'); // 60 per minute +``` + +### Named Rate Limiters + +```php +// app/Providers/RouteServiceProvider.php +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; + +RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); +}); + +// Apply in routes +Route::middleware('throttle:api')->group(function () { + // Routes +}); +``` + +## Custom Rate Limiting + +### Based on API Key Tier + +```php +use Mod\Api\Services\RateLimitService; + +$rateLimitService = app(RateLimitService::class); + +$result = $rateLimitService->attempt($apiKey); + +if ($result->exceeded()) { + return response()->json([ + 'error' => 'Rate limit exceeded', + 'retry_after' => $result->retryAfter(), + ], 429); +} +``` + +### Dynamic Limits + +```php +RateLimiter::for('api', function (Request $request) { + $apiKey = $request->user()->currentApiKey(); + + return match ($apiKey->rate_limit_tier) { + 'free' => Limit::perHour(1000), + 'pro' => Limit::perHour(10000), + 'business' => Limit::perHour(50000), + 'enterprise' => Limit::none(), + }; +}); +``` + +## Rate Limit Responses + +### 429 Too Many Requests + +```json +{ + "message": "Too many requests", + "error_code": "RATE_LIMIT_EXCEEDED", + "retry_after": 3600, + "limit": 10000, + "remaining": 0, + "reset_at": "2024-01-15T12:00:00Z" +} +``` + +### Retry-After Header + +``` +HTTP/1.1 429 Too Many Requests +Retry-After: 3600 +X-RateLimit-Limit: 10000 +X-RateLimit-Remaining: 0 +X-RateLimit-Reset: 1640995200 +``` + +## Monitoring + +### Check Current Usage + +```php +use Mod\Api\Services\RateLimitService; + +$service = app(RateLimitService::class); + +$usage = $service->getCurrentUsage($apiKey); + +echo "Used: {$usage->used} / {$usage->limit}"; +echo "Remaining: {$usage->remaining}"; +echo "Resets at: {$usage->reset_at}"; +``` + +### Usage Analytics + +```php +$apiKey = ApiKey::find($id); + +$stats = $apiKey->usage() + ->whereBetween('created_at', [now()->subDays(7), now()]) + ->selectRaw('DATE(created_at) as date, COUNT(*) as count') + ->groupBy('date') + ->get(); +``` + +## Best Practices + +### 1. Handle 429 Gracefully + +```javascript +// ✅ Good - retry with backoff +async function apiRequest(url, retries = 3) { + for (let i = 0; i < retries; i++) { + const response = await fetch(url); + + if (response.status === 429) { + const retryAfter = parseInt(response.headers.get('Retry-After')); + await sleep(retryAfter * 1000); + continue; + } + + return response; + } +} +``` + +### 2. Respect Rate Limit Headers + +```javascript +// ✅ Good - check remaining requests +const remaining = parseInt(response.headers.get('X-RateLimit-Remaining')); + +if (remaining < 10) { + console.warn('Approaching rate limit'); +} +``` + +### 3. Implement Exponential Backoff + +```javascript +// ✅ Good - exponential backoff +async function fetchWithBackoff(url, maxRetries = 5) { + for (let i = 0; i < maxRetries; i++) { + const response = await fetch(url); + + if (response.status !== 429) { + return response; + } + + const delay = Math.min(1000 * Math.pow(2, i), 30000); + await sleep(delay); + } +} +``` + +### 4. Use Caching + +```javascript +// ✅ Good - cache responses +const cache = new Map(); + +async function fetchPost(id) { + const cached = cache.get(id); + if (cached && Date.now() - cached.timestamp < 60000) { + return cached.data; + } + + const response = await fetch(`/api/v1/posts/${id}`); + const data = await response.json(); + + cache.set(id, {data, timestamp: Date.now()}); + return data; +} +``` + +## Learn More + +- [API Authentication →](/packages/api/authentication) +- [Error Handling →](/api/errors) +- [API Reference →](/api/endpoints#rate-limiting) diff --git a/docs/scopes.md b/docs/scopes.md new file mode 100644 index 0000000..c9a272c --- /dev/null +++ b/docs/scopes.md @@ -0,0 +1,548 @@ +# API Scopes + +Fine-grained permission control for API keys using OAuth-style scopes. + +## Scope Format + +Scopes follow the format: `resource:action` + +**Examples:** +- `posts:read` - Read blog posts +- `posts:write` - Create and update posts +- `posts:delete` - Delete posts +- `users:*` - All user operations +- `*:read` - Read access to all resources +- `*` - Full access (use sparingly!) + +## Available Scopes + +### Content Management + +| Scope | Description | +|-------|-------------| +| `posts:read` | View published posts | +| `posts:write` | Create and update posts | +| `posts:delete` | Delete posts | +| `posts:publish` | Publish posts | +| `pages:read` | View static pages | +| `pages:write` | Create and update pages | +| `pages:delete` | Delete pages | +| `categories:read` | View categories | +| `categories:write` | Manage categories | +| `tags:read` | View tags | +| `tags:write` | Manage tags | + +### User Management + +| Scope | Description | +|-------|-------------| +| `users:read` | View user profiles | +| `users:write` | Update user profiles | +| `users:delete` | Delete users | +| `users:roles` | Manage user roles | +| `users:permissions` | Manage user permissions | + +### Analytics + +| Scope | Description | +|-------|-------------| +| `analytics:read` | View analytics data | +| `analytics:export` | Export analytics | +| `metrics:read` | View system metrics | + +### Webhooks + +| Scope | Description | +|-------|-------------| +| `webhooks:read` | View webhook endpoints | +| `webhooks:write` | Create and update webhooks | +| `webhooks:delete` | Delete webhooks | +| `webhooks:manage` | Full webhook management | + +### API Keys + +| Scope | Description | +|-------|-------------| +| `keys:read` | View API keys | +| `keys:write` | Create API keys | +| `keys:delete` | Delete API keys | +| `keys:manage` | Full key management | + +### Workspace Management + +| Scope | Description | +|-------|-------------| +| `workspace:read` | View workspace details | +| `workspace:write` | Update workspace settings | +| `workspace:members` | Manage workspace members | +| `workspace:billing` | Access billing information | + +### Admin Operations + +| Scope | Description | +|-------|-------------| +| `admin:users` | Admin user management | +| `admin:workspaces` | Admin workspace management | +| `admin:system` | System administration | +| `admin:*` | Full admin access | + +## Assigning Scopes + +### API Key Creation + +```php +use Mod\Api\Models\ApiKey; + +$apiKey = ApiKey::create([ + 'name' => 'Mobile App', + 'workspace_id' => $workspace->id, + 'scopes' => [ + 'posts:read', + 'posts:write', + 'categories:read', + ], +]); +``` + +### Sanctum Tokens + +```php +$user = User::find(1); + +$token = $user->createToken('mobile-app', [ + 'posts:read', + 'posts:write', + 'analytics:read', +])->plainTextToken; +``` + +## Scope Enforcement + +### Route Protection + +```php +use Mod\Api\Middleware\EnforceApiScope; + +// Single scope +Route::middleware(['auth:sanctum', 'scope:posts:write']) + ->post('/posts', [PostController::class, 'store']); + +// Multiple scopes (all required) +Route::middleware(['auth:sanctum', 'scopes:posts:write,categories:read']) + ->post('/posts', [PostController::class, 'store']); + +// Any scope (at least one required) +Route::middleware(['auth:sanctum', 'scope-any:posts:write,pages:write']) + ->post('/content', [ContentController::class, 'store']); +``` + +### Controller Checks + +```php +user()->tokenCan('posts:write')) { + abort(403, 'Insufficient permissions'); + } + + // Check multiple scopes + if (!$request->user()->tokenCan('posts:write') || + !$request->user()->tokenCan('categories:read')) { + abort(403); + } + + // Proceed with creation + $post = Post::create($request->validated()); + + return new PostResource($post); + } + + public function publish(Post $post) + { + // Require specific scope for sensitive action + if (!request()->user()->tokenCan('posts:publish')) { + abort(403, 'Publishing requires posts:publish scope'); + } + + $post->publish(); + + return new PostResource($post); + } +} +``` + +## Wildcard Scopes + +### Resource Wildcards + +Grant all permissions for a resource: + +```php +$apiKey->scopes = [ + 'posts:*', // All post operations + 'categories:*', // All category operations +]; +``` + +**Equivalent to:** + +```php +$apiKey->scopes = [ + 'posts:read', + 'posts:write', + 'posts:delete', + 'posts:publish', + 'categories:read', + 'categories:write', + 'categories:delete', +]; +``` + +### Action Wildcards + +Grant read-only access to everything: + +```php +$apiKey->scopes = [ + '*:read', // Read access to all resources +]; +``` + +### Full Access + +```php +$apiKey->scopes = ['*']; // Full access (dangerous!) +``` + +::: warning +Only use `*` scope for admin integrations. Always prefer specific scopes. +::: + +## Scope Validation + +### Custom Scopes + +Define custom scopes for your modules: + +```php + 'View products', + 'products:write' => 'Create and update products', + 'products:delete' => 'Delete products', + 'orders:read' => 'View orders', + 'orders:write' => 'Process orders', + 'orders:refund' => 'Issue refunds', + ]; + } +} +``` + +**Register Provider:** + +```php +use Core\Events\ApiRoutesRegistering; +use Mod\Shop\Api\ShopScopeProvider; + +public function onApiRoutes(ApiRoutesRegistering $event): void +{ + $event->scopes(new ShopScopeProvider()); +} +``` + +### Scope Groups + +Group related scopes: + +```php +// config/api.php +return [ + 'scope_groups' => [ + 'content_admin' => [ + 'posts:*', + 'pages:*', + 'categories:*', + 'tags:*', + ], + 'analytics_viewer' => [ + 'analytics:read', + 'metrics:read', + ], + 'webhook_manager' => [ + 'webhooks:*', + ], + ], +]; +``` + +**Usage:** + +```php +// Assign group instead of individual scopes +$apiKey->scopes = config('api.scope_groups.content_admin'); +``` + +## Checking Scopes + +### Token Abilities + +```php +// Check if token has scope +if ($request->user()->tokenCan('posts:write')) { + // Has permission +} + +// Check multiple scopes (all required) +if ($request->user()->tokenCan('posts:write') && + $request->user()->tokenCan('posts:publish')) { + // Has both permissions +} + +// Get all token abilities +$abilities = $request->user()->currentAccessToken()->abilities; +``` + +### Scope Middleware + +```php +// Require single scope +Route::middleware('scope:posts:write')->post('/posts', ...); + +// Require all scopes +Route::middleware('scopes:posts:write,categories:read')->post('/posts', ...); + +// Require any scope (OR logic) +Route::middleware('scope-any:posts:write,pages:write')->post('/content', ...); +``` + +### API Key Scopes + +```php +use Mod\Api\Models\ApiKey; + +$apiKey = ApiKey::findByKey($providedKey); + +// Check scope +if ($apiKey->hasScope('posts:write')) { + // Has permission +} + +// Check multiple scopes +if ($apiKey->hasAllScopes(['posts:write', 'categories:read'])) { + // Has all permissions +} + +// Check any scope +if ($apiKey->hasAnyScope(['posts:write', 'pages:write'])) { + // Has at least one permission +} +``` + +## Scope Inheritance + +### Hierarchical Scopes + +Higher-level scopes include lower-level scopes: + +``` +admin:* includes: + ├─ admin:users + ├─ admin:workspaces + └─ admin:system + +workspace:* includes: + ├─ workspace:read + ├─ workspace:write + ├─ workspace:members + └─ workspace:billing +``` + +**Implementation:** + +```php +public function hasScope(string $scope): bool +{ + // Exact match + if (in_array($scope, $this->scopes)) { + return true; + } + + // Check wildcards + [$resource, $action] = explode(':', $scope); + + // Resource wildcard (e.g., posts:*) + if (in_array("{$resource}:*", $this->scopes)) { + return true; + } + + // Action wildcard (e.g., *:read) + if (in_array("*:{$action}", $this->scopes)) { + return true; + } + + // Full wildcard + return in_array('*', $this->scopes); +} +``` + +## Error Responses + +### Insufficient Scope + +```json +{ + "message": "Insufficient scope", + "required_scope": "posts:write", + "provided_scopes": ["posts:read"], + "error_code": "insufficient_scope" +} +``` + +**HTTP Status:** 403 Forbidden + +### Missing Scope + +```json +{ + "message": "This action requires the 'posts:publish' scope", + "required_scope": "posts:publish", + "error_code": "scope_required" +} +``` + +## Best Practices + +### 1. Principle of Least Privilege + +```php +// ✅ Good - minimal scopes +$apiKey->scopes = [ + 'posts:read', + 'categories:read', +]; + +// ❌ Bad - excessive permissions +$apiKey->scopes = ['*']; +``` + +### 2. Use Specific Scopes + +```php +// ✅ Good - specific actions +$apiKey->scopes = [ + 'posts:read', + 'posts:write', +]; + +// ❌ Bad - overly broad +$apiKey->scopes = ['posts:*']; +``` + +### 3. Document Required Scopes + +```php +/** + * Publish a blog post. + * + * Required scopes: + * - posts:write (to modify post) + * - posts:publish (to change status) + * + * @requires posts:write + * @requires posts:publish + */ +public function publish(Post $post) +{ + // ... +} +``` + +### 4. Validate Early + +```php +// ✅ Good - check at route level +Route::middleware('scope:posts:write') + ->post('/posts', [PostController::class, 'store']); + +// ❌ Bad - check late in controller +public function store(Request $request) +{ + $validated = $request->validate([...]); // Wasted work + + if (!$request->user()->tokenCan('posts:write')) { + abort(403); + } +} +``` + +## Testing Scopes + +```php +use Tests\TestCase; +use Laravel\Sanctum\Sanctum; + +class ScopeTest extends TestCase +{ + public function test_requires_write_scope(): void + { + $user = User::factory()->create(); + + // Token without write scope + Sanctum::actingAs($user, ['posts:read']); + + $response = $this->postJson('/api/v1/posts', [ + 'title' => 'Test Post', + ]); + + $response->assertStatus(403); + } + + public function test_allows_with_correct_scope(): void + { + $user = User::factory()->create(); + + // Token with write scope + Sanctum::actingAs($user, ['posts:write']); + + $response = $this->postJson('/api/v1/posts', [ + 'title' => 'Test Post', + 'content' => 'Content', + ]); + + $response->assertStatus(201); + } + + public function test_wildcard_scope_grants_access(): void + { + $user = User::factory()->create(); + + Sanctum::actingAs($user, ['posts:*']); + + $this->postJson('/api/v1/posts', [...])->assertStatus(201); + $this->putJson('/api/v1/posts/1', [...])->assertStatus(200); + $this->deleteJson('/api/v1/posts/1')->assertStatus(204); + } +} +``` + +## Learn More + +- [Authentication →](/packages/api/authentication) +- [Rate Limiting →](/packages/api/rate-limiting) +- [API Reference →](/api/authentication) diff --git a/docs/webhook-integration.md b/docs/webhook-integration.md new file mode 100644 index 0000000..f659c40 --- /dev/null +++ b/docs/webhook-integration.md @@ -0,0 +1,765 @@ +# Webhook Integration Guide + +This guide explains how to receive and process webhooks from the core-api package. Learn to verify signatures, handle retries, and implement reliable webhook consumers. + +## Overview + +Webhooks provide real-time notifications when events occur in the system. Instead of polling the API, your application receives HTTP POST requests with event data. + +**Key Features:** +- HMAC-SHA256 signature verification +- Automatic retries with exponential backoff +- Timestamp validation for replay protection +- Delivery tracking and manual retry + +## Webhook Payload Format + +All webhooks follow a consistent format: + +```json +{ + "id": "evt_abc123def456789", + "type": "post.created", + "created_at": "2026-01-15T10:30:00Z", + "data": { + "id": 123, + "title": "New Blog Post", + "status": "published", + "author_id": 42 + }, + "workspace_id": 456 +} +``` + +**Fields:** +- `id` - Unique event identifier (use for idempotency) +- `type` - Event type (e.g., `post.created`, `user.updated`) +- `created_at` - ISO 8601 timestamp when the event occurred +- `data` - Event-specific payload +- `workspace_id` - Workspace that generated the event + +## Webhook Headers + +Every webhook request includes these headers: + +| Header | Description | Example | +|--------|-------------|---------| +| `Content-Type` | Always `application/json` | `application/json` | +| `X-Webhook-Id` | Unique event ID | `evt_abc123def456` | +| `X-Webhook-Event` | Event type | `post.created` | +| `X-Webhook-Timestamp` | Unix timestamp | `1705312200` | +| `X-Webhook-Signature` | HMAC-SHA256 signature | `a1b2c3d4e5f6...` | + +## Signature Verification + +**Always verify webhook signatures** to ensure requests are authentic and unmodified. + +### Signature Algorithm + +The signature is computed as: + +``` +signature = HMAC-SHA256(timestamp + "." + payload, secret) +``` + +Where: +- `timestamp` is the value of `X-Webhook-Timestamp` header +- `payload` is the raw request body (JSON string) +- `secret` is your webhook signing secret + +### Verification Steps + +1. Get the signature and timestamp from headers +2. Get the raw request body (do not parse JSON first) +3. Compute expected signature: `HMAC-SHA256(timestamp + "." + body, secret)` +4. Compare signatures using timing-safe comparison +5. Verify timestamp is within 5 minutes of current time + +## Code Examples + +### PHP (Laravel) + +```php +verifySignature($request)) { + Log::warning('Invalid webhook signature', [ + 'ip' => $request->ip(), + ]); + return response()->json(['error' => 'Invalid signature'], 401); + } + + // Step 2: Verify timestamp (replay protection) + if (!$this->verifyTimestamp($request)) { + Log::warning('Webhook timestamp too old'); + return response()->json(['error' => 'Timestamp expired'], 401); + } + + // Step 3: Check for duplicate events (idempotency) + $eventId = $request->input('id'); + if ($this->isDuplicate($eventId)) { + // Already processed - return success to stop retries + return response()->json(['received' => true]); + } + + // Step 4: Process the event + try { + $this->processEvent( + $request->input('type'), + $request->input('data') + ); + + // Mark event as processed + $this->markProcessed($eventId); + + return response()->json(['received' => true]); + + } catch (\Exception $e) { + Log::error('Webhook processing failed', [ + 'event_id' => $eventId, + 'error' => $e->getMessage(), + ]); + + // Return 500 to trigger retry + return response()->json(['error' => 'Processing failed'], 500); + } + } + + /** + * Verify the HMAC-SHA256 signature. + */ + protected function verifySignature(Request $request): bool + { + $signature = $request->header('X-Webhook-Signature'); + $timestamp = $request->header('X-Webhook-Timestamp'); + $payload = $request->getContent(); + $secret = config('services.webhooks.secret'); + + if (!$signature || !$timestamp) { + return false; + } + + // Compute expected signature + $signedPayload = $timestamp . '.' . $payload; + $expectedSignature = hash_hmac('sha256', $signedPayload, $secret); + + // Use timing-safe comparison + return hash_equals($expectedSignature, $signature); + } + + /** + * Verify timestamp is within tolerance (5 minutes). + */ + protected function verifyTimestamp(Request $request): bool + { + $timestamp = (int) $request->header('X-Webhook-Timestamp'); + $tolerance = 300; // 5 minutes + + return abs(time() - $timestamp) <= $tolerance; + } + + /** + * Check if event was already processed. + */ + protected function isDuplicate(string $eventId): bool + { + return cache()->has("webhook:processed:{$eventId}"); + } + + /** + * Mark event as processed (cache for 24 hours). + */ + protected function markProcessed(string $eventId): void + { + cache()->put("webhook:processed:{$eventId}", true, now()->addDay()); + } + + /** + * Process the webhook event. + */ + protected function processEvent(string $type, array $data): void + { + match ($type) { + 'post.created' => $this->handlePostCreated($data), + 'post.updated' => $this->handlePostUpdated($data), + 'post.deleted' => $this->handlePostDeleted($data), + 'user.created' => $this->handleUserCreated($data), + default => Log::info("Unhandled webhook type: {$type}"), + }; + } + + protected function handlePostCreated(array $data): void + { + // Sync to your database, trigger notifications, etc. + Log::info('Post created', $data); + } + + protected function handlePostUpdated(array $data): void + { + Log::info('Post updated', $data); + } + + protected function handlePostDeleted(array $data): void + { + Log::info('Post deleted', $data); + } + + protected function handleUserCreated(array $data): void + { + Log::info('User created', $data); + } +} +``` + +**Route registration:** + +```php +// routes/api.php +Route::post('/webhooks', [WebhookController::class, 'handle']) + ->middleware('throttle:100,1'); // Rate limit webhook endpoint +``` + +### JavaScript (Node.js/Express) + +```javascript +const express = require('express'); +const crypto = require('crypto'); +const app = express(); + +// Important: Use raw body for signature verification +app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { + const signature = req.headers['x-webhook-signature']; + const timestamp = req.headers['x-webhook-timestamp']; + const payload = req.body; // Raw buffer + const secret = process.env.WEBHOOK_SECRET; + + // Step 1: Verify signature + if (!verifySignature(payload, signature, timestamp, secret)) { + console.warn('Invalid webhook signature'); + return res.status(401).json({ error: 'Invalid signature' }); + } + + // Step 2: Verify timestamp + if (!verifyTimestamp(timestamp)) { + console.warn('Webhook timestamp too old'); + return res.status(401).json({ error: 'Timestamp expired' }); + } + + // Step 3: Parse the event + let event; + try { + event = JSON.parse(payload.toString()); + } catch (e) { + return res.status(400).json({ error: 'Invalid JSON' }); + } + + // Step 4: Check for duplicates + if (await isDuplicate(event.id)) { + return res.json({ received: true }); + } + + // Step 5: Process the event + try { + await processEvent(event.type, event.data); + await markProcessed(event.id); + res.json({ received: true }); + } catch (e) { + console.error('Webhook processing failed:', e); + res.status(500).json({ error: 'Processing failed' }); + } +}); + +function verifySignature(payload, signature, timestamp, secret) { + if (!signature || !timestamp) return false; + + const signedPayload = timestamp + '.' + payload.toString(); + const expectedSignature = crypto + .createHmac('sha256', secret) + .update(signedPayload) + .digest('hex'); + + // Timing-safe comparison + try { + return crypto.timingSafeEqual( + Buffer.from(signature), + Buffer.from(expectedSignature) + ); + } catch { + return false; + } +} + +function verifyTimestamp(timestamp) { + const tolerance = 300; // 5 minutes + const now = Math.floor(Date.now() / 1000); + return Math.abs(now - parseInt(timestamp)) <= tolerance; +} + +// Redis-based duplicate detection +const Redis = require('ioredis'); +const redis = new Redis(); + +async function isDuplicate(eventId) { + return await redis.exists(`webhook:processed:${eventId}`); +} + +async function markProcessed(eventId) { + await redis.set(`webhook:processed:${eventId}`, '1', 'EX', 86400); +} + +async function processEvent(type, data) { + switch (type) { + case 'post.created': + console.log('Post created:', data); + break; + case 'post.updated': + console.log('Post updated:', data); + break; + case 'post.deleted': + console.log('Post deleted:', data); + break; + default: + console.log(`Unhandled event type: ${type}`); + } +} + +app.listen(3000); +``` + +### Python (Flask) + +```python +import hmac +import hashlib +import time +import json +from functools import wraps +from flask import Flask, request, jsonify +import redis + +app = Flask(__name__) +cache = redis.Redis() + +WEBHOOK_SECRET = 'your_webhook_secret' +TIMESTAMP_TOLERANCE = 300 # 5 minutes + +def verify_webhook(f): + """Decorator to verify webhook signatures.""" + @wraps(f) + def decorated(*args, **kwargs): + signature = request.headers.get('X-Webhook-Signature') + timestamp = request.headers.get('X-Webhook-Timestamp') + payload = request.get_data() + + # Verify signature + if not verify_signature(payload, signature, timestamp): + return jsonify({'error': 'Invalid signature'}), 401 + + # Verify timestamp + if not verify_timestamp(timestamp): + return jsonify({'error': 'Timestamp expired'}), 401 + + return f(*args, **kwargs) + return decorated + + +def verify_signature(payload: bytes, signature: str, timestamp: str) -> bool: + """Verify the HMAC-SHA256 signature.""" + if not signature or not timestamp: + return False + + signed_payload = f"{timestamp}.{payload.decode('utf-8')}" + expected_signature = hmac.new( + WEBHOOK_SECRET.encode('utf-8'), + signed_payload.encode('utf-8'), + hashlib.sha256 + ).hexdigest() + + # Timing-safe comparison + return hmac.compare_digest(expected_signature, signature) + + +def verify_timestamp(timestamp: str) -> bool: + """Verify timestamp is within tolerance.""" + try: + ts = int(timestamp) + return abs(time.time() - ts) <= TIMESTAMP_TOLERANCE + except (ValueError, TypeError): + return False + + +def is_duplicate(event_id: str) -> bool: + """Check if event was already processed.""" + return cache.exists(f"webhook:processed:{event_id}") + + +def mark_processed(event_id: str) -> None: + """Mark event as processed (24 hour TTL).""" + cache.setex(f"webhook:processed:{event_id}", 86400, "1") + + +@app.route('/webhooks', methods=['POST']) +@verify_webhook +def handle_webhook(): + event = request.get_json() + event_id = event.get('id') + event_type = event.get('type') + data = event.get('data') + + # Check for duplicates + if is_duplicate(event_id): + return jsonify({'received': True}) + + # Process the event + try: + process_event(event_type, data) + mark_processed(event_id) + return jsonify({'received': True}) + except Exception as e: + app.logger.error(f"Webhook processing failed: {e}") + return jsonify({'error': 'Processing failed'}), 500 + + +def process_event(event_type: str, data: dict) -> None: + """Process webhook event based on type.""" + handlers = { + 'post.created': handle_post_created, + 'post.updated': handle_post_updated, + 'post.deleted': handle_post_deleted, + 'user.created': handle_user_created, + } + + handler = handlers.get(event_type) + if handler: + handler(data) + else: + app.logger.info(f"Unhandled event type: {event_type}") + + +def handle_post_created(data: dict) -> None: + app.logger.info(f"Post created: {data}") + + +def handle_post_updated(data: dict) -> None: + app.logger.info(f"Post updated: {data}") + + +def handle_post_deleted(data: dict) -> None: + app.logger.info(f"Post deleted: {data}") + + +def handle_user_created(data: dict) -> None: + app.logger.info(f"User created: {data}") + + +if __name__ == '__main__': + app.run(port=3000) +``` + +## Retry Handling + +### Retry Schedule + +Failed webhook deliveries are automatically retried with exponential backoff: + +| Attempt | Delay | Total Time | +|---------|-------|------------| +| 1 | Immediate | 0 | +| 2 | 1 minute | 1 minute | +| 3 | 5 minutes | 6 minutes | +| 4 | 30 minutes | 36 minutes | +| 5 | 2 hours | 2h 36m | +| 6 (final) | 24 hours | 26h 36m | + +After 6 failed attempts, the delivery is marked as permanently failed. + +### Triggering Retries + +A delivery is retried when your endpoint returns: +- **5xx status codes** (server errors) +- **Connection timeouts** (30 second default) +- **Connection refused/failed** + +A delivery is **not** retried when: +- **2xx status codes** (success) +- **4xx status codes** (client errors - your endpoint rejected it) + +### Best Practices for Reliability + +**1. Return 200 Quickly** + +Process webhooks asynchronously to avoid timeouts: + +```php +public function handle(Request $request) +{ + // Verify signature first + if (!$this->verifySignature($request)) { + return response()->json(['error' => 'Invalid signature'], 401); + } + + // Queue for async processing + ProcessWebhook::dispatch($request->all()); + + // Return immediately + return response()->json(['received' => true]); +} +``` + +**2. Handle Duplicates** + +Webhooks may be delivered more than once. Always check the event ID: + +```php +public function handle(Request $request) +{ + $eventId = $request->input('id'); + + // Atomic check-and-set + if (!Cache::add("webhook:{$eventId}", true, now()->addDay())) { + // Already processed + return response()->json(['received' => true]); + } + + // Process the event... +} +``` + +**3. Return 4xx for Permanent Failures** + +If your endpoint cannot process an event (invalid data, etc.), return 4xx to stop retries: + +```php +public function handle(Request $request) +{ + $eventType = $request->input('type'); + + // Unknown event type - don't retry + if (!in_array($eventType, $this->supportedEvents)) { + return response()->json(['error' => 'Unknown event type'], 400); + } + + // Process... +} +``` + +## Event Types + +### Common Events + +| Event | Description | +|-------|-------------| +| `{resource}.created` | Resource was created | +| `{resource}.updated` | Resource was updated | +| `{resource}.deleted` | Resource was deleted | +| `{resource}.published` | Resource was published | +| `{resource}.archived` | Resource was archived | + +### Wildcard Subscriptions + +Subscribe to all events for a resource: + +```php +$webhook = WebhookEndpoint::create([ + 'url' => 'https://your-app.com/webhooks', + 'events' => ['post.*'], // All post events + 'secret' => 'whsec_' . Str::random(32), +]); +``` + +Subscribe to all events: + +```php +$webhook = WebhookEndpoint::create([ + 'url' => 'https://your-app.com/webhooks', + 'events' => ['*'], // All events + 'secret' => 'whsec_' . Str::random(32), +]); +``` + +### High-Volume Events + +Some events are high-volume and opt-in only: + +- `link.clicked` - Link click tracking +- `qrcode.scanned` - QR code scan tracking + +These must be explicitly included in the `events` array. + +## Testing Webhooks + +### Test Endpoint + +Use the test endpoint to verify your webhook handler: + +```bash +curl -X POST https://api.example.com/v1/webhooks/{webhook_id}/test \ + -H "Authorization: Bearer sk_live_abc123" +``` + +This sends a test event to your endpoint. + +### Local Development + +For local development, use a tunnel service: + +**ngrok:** +```bash +ngrok http 3000 +# Use the https URL as your webhook endpoint +``` + +**Cloudflare Tunnel:** +```bash +cloudflared tunnel --url http://localhost:3000 +``` + +### Mock Verification + +Test signature verification in isolation: + +```php +// tests/Feature/WebhookTest.php +public function test_verifies_valid_signature(): void +{ + $payload = json_encode([ + 'id' => 'evt_test123', + 'type' => 'post.created', + 'data' => ['id' => 1, 'title' => 'Test'], + ]); + + $timestamp = time(); + $secret = 'test_secret'; + $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); + + config(['services.webhooks.secret' => $secret]); + + $response = $this->postJson('/webhooks', json_decode($payload, true), [ + 'X-Webhook-Signature' => $signature, + 'X-Webhook-Timestamp' => $timestamp, + 'Content-Type' => 'application/json', + ]); + + $response->assertOk(); +} + +public function test_rejects_invalid_signature(): void +{ + $response = $this->postJson('/webhooks', [ + 'id' => 'evt_test123', + 'type' => 'post.created', + ], [ + 'X-Webhook-Signature' => 'invalid', + 'X-Webhook-Timestamp' => time(), + ]); + + $response->assertUnauthorized(); +} +``` + +## Troubleshooting + +### Signature Verification Fails + +**Common causes:** + +1. **Parsed JSON instead of raw body** + ```php + // Wrong - body has been modified + $payload = json_encode($request->all()); + + // Correct - raw body + $payload = $request->getContent(); + ``` + +2. **Different secrets** + - Check the secret matches exactly + - Ensure no extra whitespace + +3. **Encoding issues** + ```php + // Ensure UTF-8 encoding + $payload = $request->getContent(); + $signedPayload = $timestamp . '.' . $payload; + ``` + +### Deliveries Not Arriving + +1. **Check endpoint URL** - Must be publicly accessible (not localhost) +2. **Check SSL certificate** - Must be valid and not expired +3. **Check firewall rules** - Allow incoming HTTPS from webhook IPs +4. **Check webhook is active** - Endpoints can be disabled after failures + +### Timeouts + +The default timeout is 30 seconds. If processing takes longer: + +```php +// Queue long-running tasks +public function handle(Request $request) +{ + // Quick signature check + if (!$this->verifySignature($request)) { + return response()->json(['error' => 'Invalid signature'], 401); + } + + // Queue for async processing + ProcessWebhook::dispatch($request->all()); + + // Return immediately + return response()->json(['received' => true]); +} +``` + +## Security Considerations + +### Always Verify Signatures + +Never skip signature verification, even in development: + +```php +// DON'T DO THIS +if (app()->environment('local')) { + return; // Skip verification +} +``` + +### Use HTTPS + +Webhook endpoints must use HTTPS to protect: +- The webhook secret in transit +- Sensitive payload data + +### Protect Your Secret + +- Store in environment variables, not code +- Rotate secrets periodically +- Use different secrets per environment + +### Rate Limit Your Endpoint + +Protect against abuse: + +```php +Route::post('/webhooks', [WebhookController::class, 'handle']) + ->middleware('throttle:100,1'); // 100 requests per minute +``` + +## Learn More + +- [Webhooks Overview](/packages/api/webhooks) - Creating webhook endpoints +- [Authentication](/packages/api/authentication) - API key management +- [Rate Limiting](/packages/api/rate-limiting) - Understanding rate limits diff --git a/docs/webhooks.md b/docs/webhooks.md new file mode 100644 index 0000000..03852f6 --- /dev/null +++ b/docs/webhooks.md @@ -0,0 +1,499 @@ +# Webhooks + +The API package provides event-driven webhooks with HMAC-SHA256 signatures, automatic retries, and delivery tracking. + +## Overview + +Webhooks allow your application to: +- Send real-time notifications to external systems +- Trigger workflows in other applications +- Sync data across platforms +- Build integrations without polling + +## Creating Webhooks + +### Basic Webhook + +```php +use Mod\Api\Models\WebhookEndpoint; + +$webhook = WebhookEndpoint::create([ + 'url' => 'https://your-app.com/webhooks', + 'events' => ['post.created', 'post.updated'], + 'secret' => 'whsec_'.Str::random(32), + 'workspace_id' => $workspace->id, + 'is_active' => true, +]); +``` + +### With Filters + +```php +$webhook = WebhookEndpoint::create([ + 'url' => 'https://your-app.com/webhooks/posts', + 'events' => ['post.*'], // All post events + 'filters' => [ + 'status' => 'published', // Only published posts + ], +]); +``` + +## Dispatching Events + +### Manual Dispatch + +```php +use Mod\Api\Services\WebhookService; + +$webhookService = app(WebhookService::class); + +$webhookService->dispatch('post.created', [ + 'id' => $post->id, + 'title' => $post->title, + 'url' => route('posts.show', $post), + 'published_at' => $post->published_at, +]); +``` + +### From Model Events + +```php +use Mod\Api\Services\WebhookService; + +class Post extends Model +{ + protected static function booted(): void + { + static::created(function (Post $post) { + app(WebhookService::class)->dispatch('post.created', [ + 'id' => $post->id, + 'title' => $post->title, + ]); + }); + + static::updated(function (Post $post) { + app(WebhookService::class)->dispatch('post.updated', [ + 'id' => $post->id, + 'title' => $post->title, + ]); + }); + } +} +``` + +### From Actions + +```php +use Mod\Blog\Actions\CreatePost; +use Mod\Api\Services\WebhookService; + +class CreatePost +{ + use Action; + + public function handle(array $data): Post + { + $post = Post::create($data); + + // Dispatch webhook + app(WebhookService::class)->dispatch('post.created', [ + 'post' => $post->only(['id', 'title', 'slug']), + ]); + + return $post; + } +} +``` + +## Webhook Payload + +### Standard Format + +```json +{ + "id": "evt_abc123def456", + "type": "post.created", + "created_at": "2024-01-15T10:30:00Z", + "data": { + "object": { + "id": 123, + "title": "My Blog Post", + "url": "https://example.com/posts/my-blog-post" + } + }, + "workspace_id": 456 +} +``` + +### Custom Payload + +```php +$webhookService->dispatch('post.published', [ + 'post_id' => $post->id, + 'title' => $post->title, + 'author' => [ + 'id' => $post->author->id, + 'name' => $post->author->name, + ], + 'metadata' => [ + 'published_at' => $post->published_at, + 'word_count' => str_word_count($post->content), + ], +]); +``` + +## Webhook Signatures + +All webhook requests include HMAC-SHA256 signatures: + +### Request Headers + +``` +X-Webhook-Signature: sha256=abc123def456... +X-Webhook-Timestamp: 1640995200 +X-Webhook-ID: evt_abc123 +``` + +### Verifying Signatures + +```php +use Mod\Api\Services\WebhookSignature; + +public function handle(Request $request) +{ + $payload = $request->getContent(); + $signature = $request->header('X-Webhook-Signature'); + $secret = $webhook->secret; + + if (!WebhookSignature::verify($payload, $signature, $secret)) { + abort(401, 'Invalid signature'); + } + + // Process webhook... +} +``` + +### Manual Verification + +```php +$expectedSignature = 'sha256=' . hash_hmac( + 'sha256', + $payload, + $secret +); + +if (!hash_equals($expectedSignature, $providedSignature)) { + abort(401); +} +``` + +## Webhook Delivery + +### Automatic Retries + +Failed deliveries are automatically retried: + +```php +// config/api.php +'webhooks' => [ + 'max_retries' => 3, + 'retry_delay' => 60, // seconds + 'timeout' => 10, +], +``` + +Retry schedule: +1. Immediate delivery +2. After 1 minute +3. After 5 minutes +4. After 30 minutes + +### Delivery Status + +```php +$deliveries = $webhook->deliveries() + ->latest() + ->limit(10) + ->get(); + +foreach ($deliveries as $delivery) { + echo $delivery->status; // success, failed, pending + echo $delivery->status_code; // HTTP status code + echo $delivery->attempts; // Number of attempts + echo $delivery->response_body; // Response from endpoint +} +``` + +### Manual Retry + +```php +use Mod\Api\Models\WebhookDelivery; + +$delivery = WebhookDelivery::find($id); + +if ($delivery->isFailed()) { + $delivery->retry(); +} +``` + +## Webhook Events + +### Common Events + +| Event | Description | +|-------|-------------| +| `{resource}.created` | Resource created | +| `{resource}.updated` | Resource updated | +| `{resource}.deleted` | Resource deleted | +| `{resource}.published` | Resource published | +| `{resource}.archived` | Resource archived | + +### Wildcards + +```php +// All post events +'events' => ['post.*'] + +// All events +'events' => ['*'] + +// Specific events +'events' => ['post.created', 'post.published'] +``` + +## Testing Webhooks + +### Test Endpoint + +```php +use Mod\Api\Models\WebhookEndpoint; + +$webhook = WebhookEndpoint::find($id); + +$result = $webhook->test([ + 'test' => true, + 'message' => 'This is a test webhook', +]); + +if ($result['success']) { + echo "Test successful! Status: {$result['status_code']}"; +} else { + echo "Test failed: {$result['error']}"; +} +``` + +### Mock Webhooks in Tests + +```php + 'Test']); + + Webhooks::assertDispatched('post.created', function ($event, $payload) { + return $payload['id'] === $post->id; + }); + } +} +``` + +## Webhook Consumers + +### Receiving Webhooks (PHP) + +```php +verifySignature($request)) { + abort(401, 'Invalid signature'); + } + + $event = $request->input('type'); + $data = $request->input('data'); + + match ($event) { + 'post.created' => $this->handlePostCreated($data), + 'post.updated' => $this->handlePostUpdated($data), + default => null, + }; + + return response()->json(['received' => true]); + } + + protected function verifySignature(Request $request): bool + { + $payload = $request->getContent(); + $signature = $request->header('X-Webhook-Signature'); + $secret = config('webhooks.secret'); + + $expected = 'sha256=' . hash_hmac('sha256', $payload, $secret); + + return hash_equals($expected, $signature); + } +} +``` + +### Receiving Webhooks (JavaScript/Node.js) + +```javascript +const express = require('express'); +const crypto = require('crypto'); + +app.post('/webhooks', express.raw({type: 'application/json'}), (req, res) => { + const payload = req.body; + const signature = req.headers['x-webhook-signature']; + const secret = process.env.WEBHOOK_SECRET; + + // Verify signature + const expected = 'sha256=' + crypto + .createHmac('sha256', secret) + .update(payload) + .digest('hex'); + + if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) { + return res.status(401).send('Invalid signature'); + } + + const event = JSON.parse(payload); + + switch (event.type) { + case 'post.created': + handlePostCreated(event.data); + break; + case 'post.updated': + handlePostUpdated(event.data); + break; + } + + res.json({received: true}); +}); +``` + +## Webhook Management UI + +### List Webhooks + +```php +$webhooks = WebhookEndpoint::where('workspace_id', $workspace->id)->get(); +``` + +### Enable/Disable + +```php +$webhook->update(['is_active' => false]); // Disable +$webhook->update(['is_active' => true]); // Enable +``` + +### View Deliveries + +```php +$deliveries = $webhook->deliveries() + ->with('webhookEndpoint') + ->latest() + ->paginate(50); +``` + +## Best Practices + +### 1. Verify Signatures + +```php +// ✅ Good - always verify +if (!WebhookSignature::verify($payload, $signature, $secret)) { + abort(401); +} +``` + +### 2. Return 200 Quickly + +```php +// ✅ Good - queue long-running tasks +public function handle(Request $request) +{ + // Verify signature + if (!$this->verifySignature($request)) { + abort(401); + } + + // Queue processing + ProcessWebhook::dispatch($request->all()); + + return response()->json(['received' => true]); +} +``` + +### 3. Handle Idempotency + +```php +// ✅ Good - check for duplicate events +public function handle(Request $request) +{ + $eventId = $request->input('id'); + + if (ProcessedWebhook::where('event_id', $eventId)->exists()) { + return response()->json(['received' => true]); // Already processed + } + + // Process webhook... + + ProcessedWebhook::create(['event_id' => $eventId]); +} +``` + +### 4. Use Webhook Secrets + +```php +// ✅ Good - secure secret +'secret' => 'whsec_' . Str::random(32) + +// ❌ Bad - weak secret +'secret' => 'password123' +``` + +## Troubleshooting + +### Webhook Not Firing + +1. Check if webhook is active: `$webhook->is_active` +2. Verify event name matches: `'post.created'` not `'posts.created'` +3. Check workspace context is set +4. Review event filters + +### Delivery Failures + +1. Check endpoint URL is reachable +2. Verify SSL certificate is valid +3. Check firewall/IP whitelist +4. Review timeout settings + +### Signature Verification Fails + +1. Ensure using raw request body (not parsed JSON) +2. Check secret matches on both sides +3. Verify using same hashing algorithm (SHA-256) +4. Check for whitespace/encoding issues + +## Learn More + +- [API Authentication →](/packages/api/authentication) +- [Webhook Security →](/api/authentication#webhook-signatures) +- [API Reference →](/api/endpoints#webhook-endpoints)