php-framework/docs/packages/api.md

12 KiB

API Package

The API package provides secure REST API functionality with OpenAPI documentation, rate limiting, webhook delivery, and scope-based authorization.

Installation

composer require host-uk/core-api

Features

OpenAPI Documentation

Automatically generated API documentation with Swagger/Scalar/ReDoc interfaces:

<?php

namespace Mod\Blog\Controllers\Api;

use Mod\Blog\Models\Post;
use Core\Api\Documentation\Attributes\ApiTag;
use Core\Api\Documentation\Attributes\ApiParameter;
use Core\Api\Documentation\Attributes\ApiResponse;

#[ApiTag('Posts', 'Blog post management')]
class PostController
{
    #[ApiResponse(200, 'Success', Post::class)]
    #[ApiResponse(404, 'Post not found')]
    public function show(Post $post)
    {
        return response()->json($post);
    }

    #[ApiParameter('title', 'string', 'Post title', required: true)]
    #[ApiParameter('content', 'string', 'Post content', required: true)]
    #[ApiResponse(201, 'Post created', Post::class)]
    public function store(Request $request)
    {
        $post = Post::create($request->validated());

        return response()->json($post, 201);
    }
}

Access documentation:

  • Scalar UI: https://your-app.test/api/docs
  • Swagger UI: https://your-app.test/api/docs/swagger
  • ReDoc: https://your-app.test/api/docs/redoc
  • OpenAPI JSON: https://your-app.test/api/docs/openapi.json

Secure API Keys

Bcrypt-hashed API keys with rotation support:

use Mod\Api\Models\ApiKey;

// Create API key
$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_...

// Verify key
if ($apiKey->verify($plaintext)) {
    // Valid key
}

// Rotate key
$newKey = $apiKey->rotate();

Rate Limiting

Tier-based rate limiting with workspace isolation:

// config/core-api.php
'rate_limits' => [
    'tiers' => [
        'free' => [
            'requests' => 1000,
            'window' => 60, // minutes
        ],
        'pro' => [
            'requests' => 10000,
            'window' => 60,
        ],
        'enterprise' => [
            'requests' => null, // unlimited
        ],
    ],
],

Rate limit headers are automatically added:

X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9995
X-RateLimit-Reset: 1640995200

Scope Enforcement

Fine-grained API access control:

// Define scopes in API key
$apiKey = ApiKey::create([
    'scopes' => ['posts:read', 'posts:write', 'categories:read'],
]);

// Protect routes with scopes
Route::middleware(['api', 'auth:sanctum', 'scope:posts:write'])
    ->post('/posts', [PostController::class, 'store']);

// Check scopes in controller
if (! $request->user()->tokenCan('posts:delete')) {
    abort(403, 'Insufficient permissions');
}

Available scopes:

// config/core-api.php
'scopes' => [
    'available' => [
        'posts:read',
        'posts:write',
        'posts:delete',
        'categories:read',
        'categories:write',
        'analytics:read',
        'webhooks:manage',
    ],
],

Webhook Delivery

Reliable webhook delivery with retry logic and signature verification:

use Mod\Api\Models\WebhookEndpoint;
use Mod\Api\Services\WebhookService;

// Register webhook endpoint
$endpoint = WebhookEndpoint::create([
    'url' => 'https://customer.com/webhooks',
    'events' => ['post.created', 'post.updated'],
    'secret' => Str::random(32),
]);

// Dispatch webhook
$webhook = app(WebhookService::class);

$webhook->dispatch('post.created', [
    'id' => $post->id,
    'title' => $post->title,
    'published_at' => $post->published_at,
], $endpoint);

Webhook Signature Verification

Webhooks are signed with HMAC-SHA256:

// Receiving webhooks (customer side)
$signature = $request->header('X-Webhook-Signature');
$timestamp = $request->header('X-Webhook-Timestamp');
$payload = $request->getContent();

$expected = hash_hmac(
    'sha256',
    $timestamp . '.' . $payload,
    $webhookSecret
);

if (! hash_equals($expected, $signature)) {
    abort(401, 'Invalid signature');
}

// Check timestamp to prevent replay attacks
if (abs(time() - $timestamp) > 300) {
    abort(401, 'Request too old');
}

Core PHP provides a helper service:

use Mod\Api\Services\WebhookSignature;

$verifier = app(WebhookSignature::class);

if (! $verifier->verify($request, $webhookSecret)) {
    abort(401, 'Invalid signature');
}

Usage Alerts

Monitor API usage and alert on high usage:

// config/core-api.php
'usage_alerts' => [
    'enabled' => true,
    'thresholds' => [
        'warning' => 80, // % of limit
        'critical' => 95,
    ],
],

Check usage alerts:

php artisan api:check-usage-alerts

Notifications sent when usage exceeds thresholds:

use Mod\Api\Notifications\HighApiUsageNotification;

// Sent automatically to workspace owners
Mail::to($workspace->owner)
    ->send(new HighApiUsageNotification($workspace, $usage));

API Routes

Define API routes in your module:

// Mod/Blog/Routes/api.php
<?php

use Illuminate\Support\Facades\Route;
use Mod\Blog\Controllers\Api\PostController;

Route::prefix('v1')->group(function () {
    // Public endpoints
    Route::get('posts', [PostController::class, 'index']);
    Route::get('posts/{post}', [PostController::class, 'show']);

    // Protected endpoints
    Route::middleware('auth:sanctum')->group(function () {
        Route::post('posts', [PostController::class, 'store'])
            ->middleware('scope:posts:write');

        Route::put('posts/{post}', [PostController::class, 'update'])
            ->middleware('scope:posts:write');

        Route::delete('posts/{post}', [PostController::class, 'destroy'])
            ->middleware('scope:posts:delete');
    });
});

Register in Boot.php:

public function onApiRoutes(ApiRoutesRegistering $event): void
{
    $event->routes(fn () => require __DIR__.'/Routes/api.php');
}

API Resources

Transform models for API responses:

<?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:full'),
                $this->content
            ),
            'published_at' => $this->published_at?->toIso8601String(),
            'category' => new CategoryResource($this->whenLoaded('category')),
            'author' => new UserResource($this->whenLoaded('author')),
            'links' => [
                'self' => route('api.posts.show', $this),
                'category' => route('api.categories.show', $this->category_id),
            ],
        ];
    }
}

Use in controllers:

public function index()
{
    $posts = Post::with('category', 'author')->paginate(20);

    return PostResource::collection($posts);
}

public function show(Post $post)
{
    return new PostResource($post->load('category', 'author'));
}

Error Handling

Standardized error responses:

{
  "message": "The given data was invalid.",
  "errors": {
    "title": ["The title field is required."],
    "content": ["The content field is required."]
  }
}

Custom error responses:

return response()->json([
    'message' => 'Post not found',
    'error_code' => 'POST_NOT_FOUND',
], 404);

Pagination

Laravel's pagination is automatically formatted:

{
  "data": [
    { "id": 1, "title": "Post 1" },
    { "id": 2, "title": "Post 2" }
  ],
  "links": {
    "first": "https://api.example.com/posts?page=1",
    "last": "https://api.example.com/posts?page=10",
    "prev": null,
    "next": "https://api.example.com/posts?page=2"
  },
  "meta": {
    "current_page": 1,
    "from": 1,
    "last_page": 10,
    "per_page": 20,
    "to": 20,
    "total": 200
  }
}

Testing

Feature Tests

<?php

namespace Tests\Feature\Api;

use Tests\TestCase;
use Mod\Blog\Models\Post;
use Mod\Api\Models\ApiKey;

class PostApiTest extends TestCase
{
    public function test_can_list_posts(): void
    {
        Post::factory()->count(3)->create();

        $response = $this->getJson('/api/v1/posts');

        $response->assertStatus(200)
            ->assertJsonCount(3, 'data');
    }

    public function test_requires_authentication_to_create_post(): void
    {
        $response = $this->postJson('/api/v1/posts', [
            'title' => 'Test Post',
            'content' => 'Test content',
        ]);

        $response->assertStatus(401);
    }

    public function test_can_create_post_with_valid_api_key(): void
    {
        $apiKey = ApiKey::factory()
            ->withScopes(['posts:write'])
            ->create();

        $response = $this->withHeaders([
            'Authorization' => 'Bearer ' . $apiKey->plaintext_key,
        ])->postJson('/api/v1/posts', [
            'title' => 'Test Post',
            'content' => 'Test content',
        ]);

        $response->assertStatus(201)
            ->assertJsonStructure(['data' => ['id', 'title']]);
    }

    public function test_enforces_rate_limits(): void
    {
        $apiKey = ApiKey::factory()
            ->tier('free')
            ->create();

        // Make requests up to limit
        for ($i = 0; $i < 1001; $i++) {
            $response = $this->withHeaders([
                'Authorization' => 'Bearer ' . $apiKey->plaintext_key,
            ])->getJson('/api/v1/posts');
        }

        $response->assertStatus(429); // Too Many Requests
    }
}

Configuration

// config/core-api.php
return [
    'rate_limits' => [
        'tiers' => [
            'free' => ['requests' => 1000, 'window' => 60],
            'pro' => ['requests' => 10000, 'window' => 60],
            'enterprise' => ['requests' => null],
        ],
        'headers_enabled' => true,
    ],

    'api_keys' => [
        'hash_algorithm' => 'bcrypt',
        'rotation_grace_period' => 86400, // 24 hours
        'prefix' => 'sk_',
    ],

    'webhooks' => [
        'signature_algorithm' => 'sha256',
        'max_retries' => 3,
        'retry_delay' => 60,
        'timeout' => 10,
        'verify_ssl' => true,
    ],

    'documentation' => [
        'enabled' => true,
        'require_auth' => false,
        'title' => 'API Documentation',
        'default_ui' => 'scalar',
    ],

    'scopes' => [
        'enforce' => true,
        'available' => [
            'posts:read',
            'posts:write',
            'posts:delete',
        ],
    ],
];

Artisan Commands

# Check usage alerts
php artisan api:check-usage-alerts

# Rotate API key
php artisan api:rotate-key {key-id}

# Generate API documentation
php artisan api:generate-docs

# Test webhook delivery
php artisan api:test-webhook {endpoint-id}

Best Practices

1. Use API Resources

// ✅ Good - consistent formatting
return PostResource::collection($posts);

// ❌ Bad - raw data
return response()->json($posts);

2. Version Your API

// ✅ Good - versioned routes
Route::prefix('v1')->group(/*...*/);
Route::prefix('v2')->group(/*...*/);

// ❌ Bad - no versioning
Route::prefix('api')->group(/*...*/);

3. Use Scopes for Authorization

// ✅ Good - granular scopes
Route::middleware('scope:posts:write')->post('/posts', /*...*/);

// ❌ Bad - no scope checking
Route::middleware('auth:sanctum')->post('/posts', /*...*/);

4. Validate Webhook Signatures

// ✅ Good - verify signatures
if (! WebhookSignature::verify($request, $secret)) {
    abort(401);
}

// ❌ Bad - no verification
// Process webhook without checking signature

Changelog

See CHANGELOG.md

License

EUPL-1.2

Learn More