docs: add package documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
1da124a92a
commit
919f7e1fc1
9 changed files with 5288 additions and 0 deletions
391
docs/authentication.md
Normal file
391
docs/authentication.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
|
||||
class ApiKeyAuthTest extends TestCase
|
||||
{
|
||||
public function test_authenticates_with_valid_key(): void
|
||||
{
|
||||
$apiKey = ApiKey::factory()->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)
|
||||
898
docs/building-rest-apis.md
Normal file
898
docs/building-rest-apis.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog;
|
||||
|
||||
use Core\Events\ApiRoutesRegistering;
|
||||
|
||||
class Boot
|
||||
{
|
||||
public static array $listens = [
|
||||
ApiRoutesRegistering::class => '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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PostResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Core\Mod\Api\Concerns\HasApiResponses;
|
||||
use Core\Mod\Api\Resources\PaginatedCollection;
|
||||
use Illuminate\Http\Request;
|
||||
use Mod\Blog\Models\Post;
|
||||
use Mod\Blog\Resources\PostResource;
|
||||
|
||||
class PostController extends Controller
|
||||
{
|
||||
use HasApiResponses;
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$posts = Post::query()
|
||||
->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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Concerns;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
trait FiltersQueries
|
||||
{
|
||||
protected function applyFilters(Builder $query, Request $request): Builder
|
||||
{
|
||||
// Date filters
|
||||
if ($after = $request->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
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
use Mod\Blog\Models\Post;
|
||||
|
||||
class PostApiTest extends TestCase
|
||||
{
|
||||
public function test_lists_posts(): void
|
||||
{
|
||||
$apiKey = ApiKey::factory()->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
|
||||
474
docs/documentation.md
Normal file
474
docs/documentation.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Api\Documentation;
|
||||
|
||||
use Mod\Api\Documentation\Extension;
|
||||
|
||||
class BlogExtension extends Extension
|
||||
{
|
||||
public function apply(array $spec): array
|
||||
{
|
||||
// Add custom tags
|
||||
$spec['tags'][] = [
|
||||
'name' => '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)
|
||||
1129
docs/endpoints-reference.md
Normal file
1129
docs/endpoints-reference.md
Normal file
File diff suppressed because it is too large
Load diff
338
docs/index.md
Normal file
338
docs/index.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog;
|
||||
|
||||
use Core\Events\ApiRoutesRegistering;
|
||||
|
||||
class Boot
|
||||
{
|
||||
public static array $listens = [
|
||||
ApiRoutesRegistering::class => '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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Resources;
|
||||
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class PostResource extends JsonResource
|
||||
{
|
||||
public function toArray($request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Api;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Mod\Api\Models\ApiKey;
|
||||
|
||||
class PostApiTest extends TestCase
|
||||
{
|
||||
public function test_lists_posts(): void
|
||||
{
|
||||
$apiKey = ApiKey::factory()->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)
|
||||
246
docs/rate-limiting.md
Normal file
246
docs/rate-limiting.md
Normal file
|
|
@ -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)
|
||||
548
docs/scopes.md
Normal file
548
docs/scopes.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
|
||||
namespace Mod\Blog\Controllers\Api;
|
||||
|
||||
class PostController
|
||||
{
|
||||
public function store(Request $request)
|
||||
{
|
||||
// Check single scope
|
||||
if (!$request->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
|
||||
<?php
|
||||
|
||||
namespace Mod\Shop\Api;
|
||||
|
||||
use Mod\Api\Contracts\ScopeProvider;
|
||||
|
||||
class ShopScopeProvider implements ScopeProvider
|
||||
{
|
||||
public function scopes(): array
|
||||
{
|
||||
return [
|
||||
'products:read' => '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)
|
||||
765
docs/webhook-integration.md
Normal file
765
docs/webhook-integration.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
/**
|
||||
* Handle incoming webhooks.
|
||||
*/
|
||||
public function handle(Request $request)
|
||||
{
|
||||
// Step 1: Verify the signature
|
||||
if (!$this->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
|
||||
499
docs/webhooks.md
Normal file
499
docs/webhooks.md
Normal file
|
|
@ -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
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Mod\Api\Facades\Webhooks;
|
||||
|
||||
class PostCreationTest extends TestCase
|
||||
{
|
||||
public function test_dispatches_webhook_on_create(): void
|
||||
{
|
||||
Webhooks::fake();
|
||||
|
||||
$post = Post::create(['title' => 'Test']);
|
||||
|
||||
Webhooks::assertDispatched('post.created', function ($event, $payload) {
|
||||
return $payload['id'] === $post->id;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Webhook Consumers
|
||||
|
||||
### Receiving Webhooks (PHP)
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class WebhookController extends Controller
|
||||
{
|
||||
public function handle(Request $request)
|
||||
{
|
||||
// Verify signature
|
||||
if (!$this->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)
|
||||
Loading…
Add table
Reference in a new issue