monorepo sepration
This commit is contained in:
parent
3265159fdc
commit
931974645b
92 changed files with 16220 additions and 173 deletions
241
README.md
241
README.md
|
|
@ -1,138 +1,155 @@
|
||||||
# Core PHP Framework Project
|
# Core API Package
|
||||||
|
|
||||||
[](https://github.com/host-uk/core-template/actions/workflows/ci.yml)
|
REST API infrastructure with OpenAPI documentation, rate limiting, webhook signing, and secure API key management.
|
||||||
[](https://codecov.io/gh/host-uk/core-template)
|
|
||||||
[](https://packagist.org/packages/host-uk/core-template)
|
|
||||||
[](https://laravel.com)
|
|
||||||
[](LICENSE)
|
|
||||||
|
|
||||||
A modular monolith Laravel application built with Core PHP Framework.
|
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- **Core Framework** - Event-driven module system with lazy loading
|
|
||||||
- **Admin Panel** - Livewire-powered admin interface with Flux UI
|
|
||||||
- **REST API** - Scoped API keys, rate limiting, webhooks, OpenAPI docs
|
|
||||||
- **MCP Tools** - Model Context Protocol for AI agent integration
|
|
||||||
|
|
||||||
## Requirements
|
|
||||||
|
|
||||||
- PHP 8.2+
|
|
||||||
- Composer 2.x
|
|
||||||
- SQLite (default) or MySQL/PostgreSQL
|
|
||||||
- Node.js 18+ (for frontend assets)
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Clone or create from template
|
composer require host-uk/core-api
|
||||||
git clone https://github.com/host-uk/core-template.git my-project
|
|
||||||
cd my-project
|
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
composer install
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# Configure environment
|
|
||||||
cp .env.example .env
|
|
||||||
php artisan key:generate
|
|
||||||
|
|
||||||
# Set up database
|
|
||||||
touch database/database.sqlite
|
|
||||||
php artisan migrate
|
|
||||||
|
|
||||||
# Start development server
|
|
||||||
php artisan serve
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Visit: http://localhost:8000
|
## Features
|
||||||
|
|
||||||
## Project Structure
|
### OpenAPI/Swagger Documentation
|
||||||
|
Auto-generated API documentation with multiple UI options:
|
||||||
```
|
|
||||||
app/
|
|
||||||
├── Console/ # Artisan commands
|
|
||||||
├── Http/ # Controllers & Middleware
|
|
||||||
├── Models/ # Eloquent models
|
|
||||||
├── Mod/ # Your custom modules
|
|
||||||
└── Providers/ # Service providers
|
|
||||||
|
|
||||||
config/
|
|
||||||
└── core.php # Core framework configuration
|
|
||||||
|
|
||||||
routes/
|
|
||||||
├── web.php # Public web routes
|
|
||||||
├── api.php # REST API routes
|
|
||||||
└── console.php # Artisan commands
|
|
||||||
```
|
|
||||||
|
|
||||||
## Creating Modules
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new module with all features
|
|
||||||
php artisan make:mod Blog --all
|
|
||||||
|
|
||||||
# Create module with specific features
|
|
||||||
php artisan make:mod Shop --web --api --admin
|
|
||||||
```
|
|
||||||
|
|
||||||
Modules follow the event-driven pattern:
|
|
||||||
|
|
||||||
```php
|
```php
|
||||||
<?php
|
use Core\Mod\Api\Documentation\Attributes\{ApiTag, ApiResponse};
|
||||||
|
|
||||||
namespace App\Mod\Blog;
|
#[ApiTag('Products')]
|
||||||
|
#[ApiResponse(200, ProductResource::class)]
|
||||||
use Core\Events\WebRoutesRegistering;
|
class ProductController extends Controller
|
||||||
use Core\Events\ApiRoutesRegistering;
|
|
||||||
use Core\Events\AdminPanelBooting;
|
|
||||||
|
|
||||||
class Boot
|
|
||||||
{
|
{
|
||||||
public static array $listens = [
|
public function index()
|
||||||
WebRoutesRegistering::class => 'onWebRoutes',
|
{
|
||||||
ApiRoutesRegistering::class => 'onApiRoutes',
|
return ProductResource::collection(Product::paginate());
|
||||||
AdminPanelBooting::class => 'onAdminPanel',
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Access documentation:**
|
||||||
|
- `GET /api/docs` - Scalar UI (default)
|
||||||
|
- `GET /api/docs/swagger` - Swagger UI
|
||||||
|
- `GET /api/docs/redoc` - ReDoc
|
||||||
|
- `GET /api/docs/openapi.json` - OpenAPI spec
|
||||||
|
|
||||||
|
### Secure API Keys
|
||||||
|
Bcrypt hashing with backward compatibility:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Api\Models\ApiKey;
|
||||||
|
|
||||||
|
$key = ApiKey::create([
|
||||||
|
'name' => 'Production API',
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'scopes' => ['read', 'write'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Returns the plain key (shown only once)
|
||||||
|
$plainKey = $key->getPlainKey();
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Bcrypt hashing for new keys
|
||||||
|
- Legacy SHA-256 support
|
||||||
|
- Key rotation with grace periods
|
||||||
|
- Scope-based permissions
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
Granular rate limiting per endpoint:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimit;
|
||||||
|
|
||||||
|
#[RateLimit(limit: 100, window: 60, burst: 1.2)]
|
||||||
|
class ProductController extends Controller
|
||||||
|
{
|
||||||
|
// Limited to 100 requests per 60 seconds
|
||||||
|
// With 20% burst allowance
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Per-endpoint limits
|
||||||
|
- Workspace isolation
|
||||||
|
- Tier-based limits
|
||||||
|
- Standard headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
||||||
|
|
||||||
|
### Webhook Signing
|
||||||
|
HMAC-SHA256 signatures for outbound webhooks:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Api\Models\WebhookEndpoint;
|
||||||
|
|
||||||
|
$endpoint = WebhookEndpoint::create([
|
||||||
|
'url' => 'https://example.com/webhooks',
|
||||||
|
'events' => ['order.created', 'order.updated'],
|
||||||
|
'secret' => WebhookEndpoint::generateSecret(),
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
```php
|
||||||
|
$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
|
||||||
|
hash_equals($signature, $request->header('X-Webhook-Signature'));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scope Enforcement
|
||||||
|
Fine-grained API permissions:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Core\Mod\Api\Middleware\EnforceApiScope;
|
||||||
|
|
||||||
|
Route::middleware(['api', EnforceApiScope::class.':write'])
|
||||||
|
->post('/products', [ProductController::class, 'store']);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
```php
|
||||||
|
// config/api.php (after php artisan vendor:publish --tag=api-config)
|
||||||
|
|
||||||
|
return [
|
||||||
|
'rate_limits' => [
|
||||||
|
'default' => 60,
|
||||||
|
'tiers' => [
|
||||||
|
'free' => 100,
|
||||||
|
'pro' => 1000,
|
||||||
|
'enterprise' => 10000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'docs' => [
|
||||||
|
'enabled' => env('API_DOCS_ENABLED', true),
|
||||||
|
'require_auth' => env('API_DOCS_REQUIRE_AUTH', false),
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
public function onWebRoutes(WebRoutesRegistering $event): void
|
|
||||||
{
|
|
||||||
$event->routes(fn() => require __DIR__.'/Routes/web.php');
|
|
||||||
$event->views('blog', __DIR__.'/Views');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Core Packages
|
## API Guides
|
||||||
|
|
||||||
| Package | Description |
|
The package includes comprehensive guides:
|
||||||
|---------|-------------|
|
|
||||||
| `host-uk/core` | Core framework components |
|
|
||||||
| `host-uk/core-admin` | Admin panel & Livewire modals |
|
|
||||||
| `host-uk/core-api` | REST API with scopes & webhooks |
|
|
||||||
| `host-uk/core-mcp` | Model Context Protocol tools |
|
|
||||||
|
|
||||||
## Flux Pro (Optional)
|
- **Authentication** - API key creation and usage
|
||||||
|
- **Quick Start** - Getting started in 5 minutes
|
||||||
|
- **Rate Limiting** - Understanding limits and tiers
|
||||||
|
- **Webhooks** - Setting up and verifying webhooks
|
||||||
|
- **Errors** - Error codes and handling
|
||||||
|
|
||||||
This template uses the free Flux UI components. If you have a Flux Pro license:
|
Access at: `/api/guides`
|
||||||
|
|
||||||
```bash
|
## Requirements
|
||||||
# Configure authentication
|
|
||||||
composer config http-basic.composer.fluxui.dev your-email your-license-key
|
|
||||||
|
|
||||||
# Add the repository
|
- PHP 8.2+
|
||||||
composer config repositories.flux-pro composer https://composer.fluxui.dev
|
- Laravel 11+ or 12+
|
||||||
|
|
||||||
# Install Flux Pro
|
## Changelog
|
||||||
composer require livewire/flux-pro
|
|
||||||
```
|
|
||||||
|
|
||||||
## Documentation
|
See [changelog/2026/jan/features.md](changelog/2026/jan/features.md) for recent changes.
|
||||||
|
|
||||||
- [Core PHP Framework](https://github.com/host-uk/core-php)
|
## Security
|
||||||
- [Getting Started Guide](https://host-uk.github.io/core-php/guide/)
|
|
||||||
- [Architecture](https://host-uk.github.io/core-php/architecture/)
|
See [changelog/2026/jan/security.md](changelog/2026/jan/security.md) for security updates.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
EUPL-1.2 (European Union Public Licence)
|
EUPL-1.2 - See [LICENSE](../../LICENSE) for details.
|
||||||
|
|
|
||||||
246
TODO.md
Normal file
246
TODO.md
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
# Core-API TODO
|
||||||
|
|
||||||
|
## Testing & Quality Assurance
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
- [ ] **Test Coverage: API Key Security** - Test bcrypt hashing and rotation
|
||||||
|
- [ ] Test API key creation with bcrypt hashing
|
||||||
|
- [ ] Test API key authentication
|
||||||
|
- [ ] Test key rotation with grace period
|
||||||
|
- [ ] Test key revocation
|
||||||
|
- [ ] Test scoped key access
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
|
||||||
|
- [ ] **Test Coverage: Webhook System** - Test delivery and signatures
|
||||||
|
- [ ] Test webhook endpoint registration
|
||||||
|
- [ ] Test HMAC-SHA256 signature generation
|
||||||
|
- [ ] Test signature verification
|
||||||
|
- [ ] Test webhook delivery retry logic
|
||||||
|
- [ ] Test exponential backoff
|
||||||
|
- [ ] Test delivery status tracking
|
||||||
|
- **Estimated effort:** 4-5 hours
|
||||||
|
|
||||||
|
- [ ] **Test Coverage: Rate Limiting** - Test tier-based limits
|
||||||
|
- [ ] Test per-tier rate limits
|
||||||
|
- [ ] Test rate limit headers
|
||||||
|
- [ ] Test quota exceeded responses
|
||||||
|
- [ ] Test workspace-scoped limits
|
||||||
|
- [ ] Test burst allowance
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
|
||||||
|
- [ ] **Test Coverage: Scope Enforcement** - Test permission system
|
||||||
|
- [ ] Test EnforceApiScope middleware
|
||||||
|
- [ ] Test wildcard scopes (posts:*, *:read)
|
||||||
|
- [ ] Test scope inheritance
|
||||||
|
- [ ] Test scope validation errors
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
- [ ] **Test Coverage: OpenAPI Documentation** - Test spec generation
|
||||||
|
- [ ] Test OpenApiBuilder with controller scanning
|
||||||
|
- [ ] Test #[ApiParameter] attribute parsing
|
||||||
|
- [ ] Test #[ApiResponse] rendering
|
||||||
|
- [ ] Test #[ApiSecurity] requirements
|
||||||
|
- [ ] Test #[ApiHidden] filtering
|
||||||
|
- [ ] Test extension system
|
||||||
|
- **Estimated effort:** 4-5 hours
|
||||||
|
|
||||||
|
- [ ] **Test Coverage: Usage Alerts** - Test quota monitoring
|
||||||
|
- [ ] Test CheckApiUsageAlerts command
|
||||||
|
- [ ] Test HighApiUsageNotification delivery
|
||||||
|
- [ ] Test usage alert thresholds
|
||||||
|
- [ ] Test alert history tracking
|
||||||
|
- **Estimated effort:** 2-3 hours
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
- [ ] **Test Coverage: Webhook Payload Validation** - Test request validation
|
||||||
|
- [ ] Test payload size limits
|
||||||
|
- [ ] Test content-type validation
|
||||||
|
- [ ] Test malformed JSON handling
|
||||||
|
- **Estimated effort:** 2-3 hours
|
||||||
|
|
||||||
|
## Features & Enhancements
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
- [ ] **Feature: API Versioning** - Support multiple API versions
|
||||||
|
- [ ] Implement version routing (v1, v2)
|
||||||
|
- [ ] Add version deprecation warnings
|
||||||
|
- [ ] Support version-specific transformers
|
||||||
|
- [ ] Document migration between versions
|
||||||
|
- [ ] Test backward compatibility
|
||||||
|
- **Estimated effort:** 6-8 hours
|
||||||
|
- **Files:** `src/Mod/Api/Versioning/`
|
||||||
|
|
||||||
|
- [ ] **Feature: GraphQL API** - Alternative to REST
|
||||||
|
- [ ] Implement GraphQL schema generation
|
||||||
|
- [ ] Add query resolver system
|
||||||
|
- [ ] Support mutations
|
||||||
|
- [ ] Add introspection
|
||||||
|
- [ ] Test complex nested queries
|
||||||
|
- **Estimated effort:** 12-16 hours
|
||||||
|
- **Files:** `src/Mod/Api/GraphQL/`
|
||||||
|
|
||||||
|
- [ ] **Feature: Batch Operations** - Bulk API requests
|
||||||
|
- [ ] Support batched requests
|
||||||
|
- [ ] Implement atomic batch transactions
|
||||||
|
- [ ] Add batch size limits
|
||||||
|
- [ ] Test error handling in batches
|
||||||
|
- **Estimated effort:** 4-6 hours
|
||||||
|
- **Files:** `src/Mod/Api/Batch/`
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
- [ ] **Enhancement: Webhook Transformers** - Custom payload formatting
|
||||||
|
- [ ] Create transformer interface
|
||||||
|
- [ ] Support per-endpoint transformers
|
||||||
|
- [ ] Add JSON-LD format support
|
||||||
|
- [ ] Test with complex data structures
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
- **Files:** `src/Mod/Api/Webhooks/Transformers/`
|
||||||
|
|
||||||
|
- [ ] **Enhancement: API Analytics** - Detailed usage metrics
|
||||||
|
- [ ] Track API calls per endpoint
|
||||||
|
- [ ] Monitor response times
|
||||||
|
- [ ] Track error rates
|
||||||
|
- [ ] Create admin dashboard
|
||||||
|
- [ ] Add export to CSV
|
||||||
|
- **Estimated effort:** 5-6 hours
|
||||||
|
- **Files:** `src/Mod/Api/Analytics/`
|
||||||
|
|
||||||
|
- [ ] **Enhancement: Request Throttling Strategies** - Advanced rate limiting
|
||||||
|
- [ ] Implement sliding window algorithm
|
||||||
|
- [ ] Add burst allowance
|
||||||
|
- [ ] Support custom throttle strategies
|
||||||
|
- [ ] Add per-endpoint rate limits
|
||||||
|
- **Estimated effort:** 4-5 hours
|
||||||
|
- **Files:** `src/Mod/Api/RateLimit/Strategies/`
|
||||||
|
|
||||||
|
### Low Priority
|
||||||
|
|
||||||
|
- [ ] **Enhancement: API Client SDK Generator** - Auto-generate SDKs
|
||||||
|
- [ ] Generate PHP SDK from OpenAPI
|
||||||
|
- [ ] Generate JavaScript SDK
|
||||||
|
- [ ] Generate Python SDK
|
||||||
|
- [ ] Add usage examples
|
||||||
|
- **Estimated effort:** 8-10 hours
|
||||||
|
- **Files:** `src/Mod/Api/Sdk/`
|
||||||
|
|
||||||
|
- [ ] **Enhancement: Webhook Retry Dashboard** - Visual delivery monitoring
|
||||||
|
- [ ] Create delivery status dashboard
|
||||||
|
- [ ] Add manual retry button
|
||||||
|
- [ ] Show delivery timeline
|
||||||
|
- [ ] Export delivery logs
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
- **Files:** `src/Website/Api/Components/`
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
### High Priority
|
||||||
|
|
||||||
|
- [ ] **Security: API Key IP Whitelisting** - Restrict key usage
|
||||||
|
- [ ] Add allowed_ips column to api_keys
|
||||||
|
- [ ] Validate request IP against whitelist
|
||||||
|
- [ ] Test with IPv4 and IPv6
|
||||||
|
- [ ] Add CIDR notation support
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
|
||||||
|
- [ ] **Security: Request Signing** - Prevent replay attacks
|
||||||
|
- [ ] Implement timestamp validation
|
||||||
|
- [ ] Add nonce tracking
|
||||||
|
- [ ] Support custom signing algorithms
|
||||||
|
- [ ] Test with clock skew
|
||||||
|
- **Estimated effort:** 4-5 hours
|
||||||
|
|
||||||
|
### Medium Priority
|
||||||
|
|
||||||
|
- [ ] **Security: Webhook Mutual TLS** - Secure webhook delivery
|
||||||
|
- [ ] Add client certificate support
|
||||||
|
- [ ] Implement certificate validation
|
||||||
|
- [ ] Test with self-signed certs
|
||||||
|
- **Estimated effort:** 4-5 hours
|
||||||
|
|
||||||
|
- [ ] **Audit: API Permission Model** - Review scope granularity
|
||||||
|
- [ ] Audit all API scopes
|
||||||
|
- [ ] Ensure least-privilege defaults
|
||||||
|
- [ ] Document scope requirements
|
||||||
|
- [ ] Test scope escalation attempts
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [x] **Guide: Building REST APIs** - Complete tutorial
|
||||||
|
- [x] Document resource creation
|
||||||
|
- [x] Show pagination best practices
|
||||||
|
- [x] Explain filtering and sorting
|
||||||
|
- [x] Add authentication examples
|
||||||
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/api/building-rest-apis.md`
|
||||||
|
|
||||||
|
- [x] **Guide: Webhook Integration** - For API consumers
|
||||||
|
- [x] Document signature verification
|
||||||
|
- [x] Show retry handling
|
||||||
|
- [x] Explain event types
|
||||||
|
- [x] Add code examples (PHP, JS, Python)
|
||||||
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/api/webhook-integration.md`
|
||||||
|
|
||||||
|
- [x] **API Reference: All Endpoints** - Complete OpenAPI spec
|
||||||
|
- [x] Document all request parameters
|
||||||
|
- [x] Add response examples
|
||||||
|
- [x] Show error responses
|
||||||
|
- [x] Include authentication notes
|
||||||
|
- **Completed:** January 2026
|
||||||
|
- **File:** `docs/packages/api/endpoints-reference.md`
|
||||||
|
|
||||||
|
## Code Quality
|
||||||
|
|
||||||
|
- [ ] **Refactor: Extract Rate Limiter** - Reusable rate limiting
|
||||||
|
- [ ] Create standalone RateLimiter service
|
||||||
|
- [ ] Support multiple backends (Redis, DB, memory)
|
||||||
|
- [ ] Add configurable strategies
|
||||||
|
- [ ] Test with high concurrency
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
|
||||||
|
- [ ] **Refactor: Webhook Queue Priority** - Prioritize critical webhooks
|
||||||
|
- [ ] Add priority field to webhooks
|
||||||
|
- [ ] Implement priority queue
|
||||||
|
- [ ] Test delivery order
|
||||||
|
- **Estimated effort:** 2-3 hours
|
||||||
|
|
||||||
|
- [ ] **PHPStan: Fix Level 5 Errors** - Improve type safety
|
||||||
|
- [ ] Fix array shape types in resources
|
||||||
|
- [ ] Add missing return types
|
||||||
|
- [ ] Fix property type declarations
|
||||||
|
- **Estimated effort:** 2-3 hours
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- [ ] **Optimization: Response Caching** - Cache GET requests
|
||||||
|
- [ ] Implement HTTP cache headers
|
||||||
|
- [ ] Add ETag support
|
||||||
|
- [ ] Support cache invalidation
|
||||||
|
- [ ] Test with CDN
|
||||||
|
- **Estimated effort:** 3-4 hours
|
||||||
|
|
||||||
|
- [ ] **Optimization: Database Query Reduction** - Eager load relationships
|
||||||
|
- [ ] Audit N+1 queries in resources
|
||||||
|
- [ ] Add eager loading
|
||||||
|
- [ ] Benchmark before/after
|
||||||
|
- **Estimated effort:** 2-3 hours
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completed (January 2026)
|
||||||
|
|
||||||
|
- [x] **API Key Hashing** - Bcrypt hashing for all API keys
|
||||||
|
- [x] **Webhook Signatures** - HMAC-SHA256 signature verification
|
||||||
|
- [x] **Scope System** - Fine-grained API permissions
|
||||||
|
- [x] **Rate Limiting** - Tier-based rate limits with usage alerts
|
||||||
|
- [x] **OpenAPI Documentation** - Auto-generated API docs with Swagger/Scalar/ReDoc
|
||||||
|
- [x] **Documentation** - Complete API package documentation
|
||||||
|
|
||||||
|
*See `changelog/2026/jan/` for completed features.*
|
||||||
122
changelog/2026/jan/features.md
Normal file
122
changelog/2026/jan/features.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# Core-API - January 2026
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### Webhook Signing (Outbound)
|
||||||
|
|
||||||
|
HMAC-SHA256 signatures with timestamp for replay attack protection.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `Services/WebhookSignature.php` - Sign/verify service
|
||||||
|
- `Models/WebhookEndpoint.php` - Signature methods
|
||||||
|
- `Models/WebhookDelivery.php` - Headers in payload
|
||||||
|
|
||||||
|
**Headers:**
|
||||||
|
| Header | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `X-Webhook-Signature` | HMAC-SHA256 (64 hex chars) |
|
||||||
|
| `X-Webhook-Timestamp` | Unix timestamp |
|
||||||
|
| `X-Webhook-Event` | Event type |
|
||||||
|
| `X-Webhook-Id` | Unique delivery ID |
|
||||||
|
|
||||||
|
**Verification:**
|
||||||
|
```php
|
||||||
|
$signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret);
|
||||||
|
hash_equals($signature, $headerSignature);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### API Key Security
|
||||||
|
|
||||||
|
Secure bcrypt hashing with backward compatibility for legacy SHA-256 keys.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `Models/ApiKey.php` - Secure hashing, rotation, grace periods
|
||||||
|
- `Migrations/2026_01_27_*` - Added hash_algorithm column
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- New keys use `Hash::make()` (bcrypt)
|
||||||
|
- Legacy keys continue working
|
||||||
|
- Key rotation with grace periods
|
||||||
|
- Scopes: `legacyHash()`, `secureHash()`, `inGracePeriod()`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
Granular rate limiting with sliding window algorithm.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `RateLimit/RateLimitService.php` - Sliding window service
|
||||||
|
- `RateLimit/RateLimitResult.php` - Result DTO
|
||||||
|
- `RateLimit/RateLimit.php` - PHP 8 attribute
|
||||||
|
- `Middleware/RateLimitApi.php` - Enhanced middleware
|
||||||
|
- `Exceptions/RateLimitExceededException.php`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Per-endpoint limits via `#[RateLimit]` attribute or config
|
||||||
|
- Per-workspace isolation
|
||||||
|
- Tier-based limits (free/starter/pro/agency/enterprise)
|
||||||
|
- Burst allowance (e.g., 20% over limit)
|
||||||
|
- Headers: `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```php
|
||||||
|
#[RateLimit(limit: 100, window: 60, burst: 1.2)]
|
||||||
|
public function index() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### OpenAPI/Swagger Documentation
|
||||||
|
|
||||||
|
Auto-generated API documentation with multiple UI options.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `Documentation/OpenApiBuilder.php` - Spec generator
|
||||||
|
- `Documentation/DocumentationController.php` - Routes
|
||||||
|
- `Documentation/Attributes/` - ApiTag, ApiResponse, ApiSecurity, ApiParameter, ApiHidden
|
||||||
|
- `Documentation/Extensions/` - WorkspaceHeader, RateLimit, ApiKeyAuth
|
||||||
|
- `Documentation/Views/` - Swagger, Scalar, ReDoc
|
||||||
|
|
||||||
|
**Routes:**
|
||||||
|
| Route | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `GET /api/docs` | Default UI (Scalar) |
|
||||||
|
| `GET /api/docs/swagger` | Swagger UI |
|
||||||
|
| `GET /api/docs/scalar` | Scalar API Reference |
|
||||||
|
| `GET /api/docs/redoc` | ReDoc |
|
||||||
|
| `GET /api/docs/openapi.json` | OpenAPI spec (JSON) |
|
||||||
|
| `GET /api/docs/openapi.yaml` | OpenAPI spec (YAML) |
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```php
|
||||||
|
#[ApiTag('Users')]
|
||||||
|
#[ApiResponse(200, UserResource::class)]
|
||||||
|
#[ApiParameter('page', 'query', 'integer')]
|
||||||
|
public function index() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Config:** `API_DOCS_ENABLED`, `API_DOCS_TITLE`, `API_DOCS_REQUIRE_AUTH`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Documentation Genericization
|
||||||
|
|
||||||
|
Removed vendor-specific branding from API documentation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- `Website/Api/View/Blade/guides/authentication.blade.php`
|
||||||
|
- `Website/Api/View/Blade/guides/errors.blade.php`
|
||||||
|
- `Website/Api/View/Blade/guides/index.blade.php`
|
||||||
|
- `Website/Api/View/Blade/guides/qrcodes.blade.php`
|
||||||
|
- `Website/Api/View/Blade/guides/quickstart.blade.php`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Replaced "Host UK API" with generic "API"
|
||||||
|
- Removed specific domain references (lt.hn)
|
||||||
|
- Replaced sign-up URLs with generic account requirements
|
||||||
|
- Made example URLs vendor-neutral
|
||||||
|
|
||||||
|
**Impact:** Framework documentation is now vendor-agnostic and suitable for open-source distribution.
|
||||||
|
|
@ -1,76 +1,22 @@
|
||||||
{
|
{
|
||||||
"name": "host-uk/core-template",
|
"name": "host-uk/core-api",
|
||||||
"type": "project",
|
"description": "REST API module for Core PHP framework",
|
||||||
"description": "Core PHP Framework - Project Template",
|
"keywords": ["laravel", "api", "rest", "json"],
|
||||||
"keywords": ["laravel", "core-php", "modular", "framework", "template"],
|
|
||||||
"license": "EUPL-1.2",
|
"license": "EUPL-1.2",
|
||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"laravel/framework": "^12.0",
|
"host-uk/core": "@dev",
|
||||||
"laravel/tinker": "^2.10",
|
"symfony/yaml": "^7.0"
|
||||||
"livewire/flux": "^2.0",
|
|
||||||
"livewire/livewire": "^3.0",
|
|
||||||
"host-uk/core": "dev-main",
|
|
||||||
"host-uk/core-admin": "dev-main",
|
|
||||||
"host-uk/core-api": "dev-main",
|
|
||||||
"host-uk/core-mcp": "dev-main"
|
|
||||||
},
|
|
||||||
"require-dev": {
|
|
||||||
"fakerphp/faker": "^1.23",
|
|
||||||
"laravel/pail": "^1.2",
|
|
||||||
"laravel/pint": "^1.18",
|
|
||||||
"laravel/sail": "^1.41",
|
|
||||||
"mockery/mockery": "^1.6",
|
|
||||||
"nunomaduro/collision": "^8.6",
|
|
||||||
"pestphp/pest": "^3.0",
|
|
||||||
"pestphp/pest-plugin-laravel": "^3.0"
|
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"Core\\Mod\\Api\\": "src/Mod/Api/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Core\\Website\\Api\\": "src/Website/Api/"
|
||||||
"Database\\Seeders\\": "database/seeders/"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload-dev": {
|
|
||||||
"psr-4": {
|
|
||||||
"Tests\\": "tests/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"repositories": [
|
|
||||||
{
|
|
||||||
"type": "vcs",
|
|
||||||
"url": "https://github.com/host-uk/core-php.git"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"scripts": {
|
|
||||||
"post-autoload-dump": [
|
|
||||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
|
||||||
"@php artisan package:discover --ansi"
|
|
||||||
],
|
|
||||||
"post-update-cmd": [
|
|
||||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
|
||||||
],
|
|
||||||
"post-root-package-install": [
|
|
||||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
|
||||||
],
|
|
||||||
"post-create-project-cmd": [
|
|
||||||
"@php artisan key:generate --ansi",
|
|
||||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
|
||||||
"@php artisan migrate --graceful --ansi"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"dont-discover": []
|
"providers": []
|
||||||
}
|
|
||||||
},
|
|
||||||
"config": {
|
|
||||||
"optimize-autoloader": true,
|
|
||||||
"preferred-install": "dist",
|
|
||||||
"sort-packages": true,
|
|
||||||
"allow-plugins": {
|
|
||||||
"php-http/discovery": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
|
|
|
||||||
98
src/Mod/Api/Boot.php
Normal file
98
src/Mod/Api/Boot.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api;
|
||||||
|
|
||||||
|
use Core\Events\ApiRoutesRegistering;
|
||||||
|
use Core\Events\ConsoleBooting;
|
||||||
|
use Core\Mod\Api\Documentation\DocumentationServiceProvider;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimitService;
|
||||||
|
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Module Boot.
|
||||||
|
*
|
||||||
|
* This module provides shared API controllers and middleware.
|
||||||
|
* Routes are registered centrally in routes/api.php rather than
|
||||||
|
* per-module, as API endpoints span multiple service modules.
|
||||||
|
*/
|
||||||
|
class Boot extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The module name.
|
||||||
|
*/
|
||||||
|
protected string $moduleName = 'api';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events this module listens to for lazy loading.
|
||||||
|
*
|
||||||
|
* @var array<class-string, string>
|
||||||
|
*/
|
||||||
|
public static array $listens = [
|
||||||
|
ApiRoutesRegistering::class => 'onApiRoutes',
|
||||||
|
ConsoleBooting::class => 'onConsole',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
$this->mergeConfigFrom(
|
||||||
|
__DIR__.'/config.php',
|
||||||
|
$this->moduleName
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register RateLimitService as a singleton
|
||||||
|
$this->app->singleton(RateLimitService::class, function ($app) {
|
||||||
|
return new RateLimitService($app->make(CacheRepository::class));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register API Documentation provider
|
||||||
|
$this->app->register(DocumentationServiceProvider::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->loadMigrationsFrom(__DIR__.'/Migrations');
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Event-driven handlers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
public function onApiRoutes(ApiRoutesRegistering $event): void
|
||||||
|
{
|
||||||
|
// Middleware aliases registered via event
|
||||||
|
$event->middleware('api.auth', Middleware\AuthenticateApiKey::class);
|
||||||
|
$event->middleware('api.scope', Middleware\CheckApiScope::class);
|
||||||
|
$event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class);
|
||||||
|
$event->middleware('api.rate', Middleware\RateLimitApi::class);
|
||||||
|
$event->middleware('auth.api', Middleware\AuthenticateApiKey::class);
|
||||||
|
|
||||||
|
// Core API routes (SEO, Pixel, Entitlements, MCP)
|
||||||
|
if (file_exists(__DIR__.'/Routes/api.php')) {
|
||||||
|
$event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onConsole(ConsoleBooting $event): void
|
||||||
|
{
|
||||||
|
// Register middleware aliases for CLI context (artisan route:list etc)
|
||||||
|
$event->middleware('api.auth', Middleware\AuthenticateApiKey::class);
|
||||||
|
$event->middleware('api.scope', Middleware\CheckApiScope::class);
|
||||||
|
$event->middleware('api.scope.enforce', Middleware\EnforceApiScope::class);
|
||||||
|
$event->middleware('api.rate', Middleware\RateLimitApi::class);
|
||||||
|
$event->middleware('auth.api', Middleware\AuthenticateApiKey::class);
|
||||||
|
|
||||||
|
// Register console commands
|
||||||
|
$event->command(Console\Commands\CleanupExpiredGracePeriods::class);
|
||||||
|
$event->command(Console\Commands\CheckApiUsageAlerts::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
92
src/Mod/Api/Concerns/HasApiResponses.php
Normal file
92
src/Mod/Api/Concerns/HasApiResponses.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standardised API response helpers.
|
||||||
|
*
|
||||||
|
* Provides consistent error response format across all API endpoints.
|
||||||
|
*/
|
||||||
|
trait HasApiResponses
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Return a no workspace response.
|
||||||
|
*/
|
||||||
|
protected function noWorkspaceResponse(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'no_workspace',
|
||||||
|
'message' => 'No workspace found. Please select a workspace first.',
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a resource not found response.
|
||||||
|
*/
|
||||||
|
protected function notFoundResponse(string $resource = 'Resource'): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'not_found',
|
||||||
|
'message' => "{$resource} not found.",
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a feature limit reached response.
|
||||||
|
*/
|
||||||
|
protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'feature_limit_reached',
|
||||||
|
'message' => $message ?? 'You have reached your limit for this feature.',
|
||||||
|
'feature' => $feature,
|
||||||
|
'upgrade_url' => route('hub.usage'),
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an access denied response.
|
||||||
|
*/
|
||||||
|
protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'access_denied',
|
||||||
|
'message' => $message,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a success response with message.
|
||||||
|
*/
|
||||||
|
protected function successResponse(string $message, array $data = []): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(array_merge([
|
||||||
|
'message' => $message,
|
||||||
|
], $data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a created response.
|
||||||
|
*/
|
||||||
|
protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'message' => $message,
|
||||||
|
'data' => $resource,
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a validation error response.
|
||||||
|
*/
|
||||||
|
protected function validationErrorResponse(array $errors): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'validation_failed',
|
||||||
|
'message' => 'The given data was invalid.',
|
||||||
|
'errors' => $errors,
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/Mod/Api/Concerns/HasApiTokens.php
Normal file
76
src/Mod/Api/Concerns/HasApiTokens.php
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Concerns;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\UserToken;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trait for models that can have API tokens.
|
||||||
|
*
|
||||||
|
* Provides methods to create and manage personal access tokens
|
||||||
|
* for API authentication.
|
||||||
|
*/
|
||||||
|
trait HasApiTokens
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get all API tokens for this user.
|
||||||
|
*
|
||||||
|
* @return HasMany<UserToken>
|
||||||
|
*/
|
||||||
|
public function tokens(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(UserToken::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new personal access token for the user.
|
||||||
|
*
|
||||||
|
* @param string $name Human-readable name for the token
|
||||||
|
* @param \DateTimeInterface|null $expiresAt Optional expiration date
|
||||||
|
* @return array{token: string, model: UserToken} Plain-text token and model instance
|
||||||
|
*/
|
||||||
|
public function createToken(string $name, ?\DateTimeInterface $expiresAt = null): array
|
||||||
|
{
|
||||||
|
// Generate a random 40-character token
|
||||||
|
$plainTextToken = Str::random(40);
|
||||||
|
|
||||||
|
// Hash it for storage
|
||||||
|
$hashedToken = hash('sha256', $plainTextToken);
|
||||||
|
|
||||||
|
// Create the token record
|
||||||
|
$token = $this->tokens()->create([
|
||||||
|
'name' => $name,
|
||||||
|
'token' => $hashedToken,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'token' => $plainTextToken,
|
||||||
|
'model' => $token,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke all tokens for this user.
|
||||||
|
*
|
||||||
|
* @return int Number of tokens deleted
|
||||||
|
*/
|
||||||
|
public function revokeAllTokens(): int
|
||||||
|
{
|
||||||
|
return $this->tokens()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke a specific token by its ID.
|
||||||
|
*
|
||||||
|
* @return bool True if the token was deleted
|
||||||
|
*/
|
||||||
|
public function revokeToken(int $tokenId): bool
|
||||||
|
{
|
||||||
|
return (bool) $this->tokens()->where('id', $tokenId)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/Mod/Api/Concerns/ResolvesWorkspace.php
Normal file
84
src/Mod/Api/Concerns/ResolvesWorkspace.php
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Concerns;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve workspace from request context.
|
||||||
|
*
|
||||||
|
* Supports both API key authentication (workspace from key) and
|
||||||
|
* session authentication (workspace from user default).
|
||||||
|
*/
|
||||||
|
trait ResolvesWorkspace
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get the workspace from request context.
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. API key workspace (set by AuthenticateApiKey middleware)
|
||||||
|
* 2. Explicit workspace_id parameter
|
||||||
|
* 3. User's default workspace
|
||||||
|
*/
|
||||||
|
protected function resolveWorkspace(Request $request): ?Workspace
|
||||||
|
{
|
||||||
|
// API key auth provides workspace directly
|
||||||
|
$workspace = $request->attributes->get('workspace');
|
||||||
|
if ($workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for explicit workspace_id
|
||||||
|
$workspaceId = $request->attributes->get('workspace_id')
|
||||||
|
?? $request->input('workspace_id')
|
||||||
|
?? $request->header('X-Workspace-Id');
|
||||||
|
|
||||||
|
if ($workspaceId) {
|
||||||
|
return $this->findWorkspaceForUser($request, (int) $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to user's default workspace
|
||||||
|
$user = $request->user();
|
||||||
|
if ($user instanceof User) {
|
||||||
|
return $user->defaultHostWorkspace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a workspace by ID that the user has access to.
|
||||||
|
*/
|
||||||
|
protected function findWorkspaceForUser(Request $request, int $workspaceId): ?Workspace
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user->workspaces()
|
||||||
|
->where('workspaces.id', $workspaceId)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the authentication type.
|
||||||
|
*/
|
||||||
|
protected function getAuthType(Request $request): string
|
||||||
|
{
|
||||||
|
return $request->attributes->get('auth_type', 'session');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if authenticated via API key.
|
||||||
|
*/
|
||||||
|
protected function isApiKeyAuth(Request $request): bool
|
||||||
|
{
|
||||||
|
return $this->getAuthType($request) === 'api_key';
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php
Normal file
291
src/Mod/Api/Console/Commands/CheckApiUsageAlerts.php
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Console\Commands;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Models\ApiKey;
|
||||||
|
use Core\Mod\Api\Notifications\HighApiUsageNotification;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimitService;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check API usage levels and send alerts when approaching limits.
|
||||||
|
*
|
||||||
|
* Notifies workspace owners when:
|
||||||
|
* - 80% of rate limit is used (warning)
|
||||||
|
* - 95% of rate limit is used (critical)
|
||||||
|
*
|
||||||
|
* Uses cache to prevent duplicate notifications within a cooldown period.
|
||||||
|
*/
|
||||||
|
class CheckApiUsageAlerts extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cache key prefix for notification cooldowns.
|
||||||
|
*/
|
||||||
|
protected const CACHE_PREFIX = 'api_usage_alert:';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default hours between notifications of the same level.
|
||||||
|
*/
|
||||||
|
protected const DEFAULT_COOLDOWN_HOURS = 6;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*/
|
||||||
|
protected $signature = 'api:check-usage-alerts
|
||||||
|
{--dry-run : Show what alerts would be sent without sending}
|
||||||
|
{--workspace= : Check a specific workspace by ID}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*/
|
||||||
|
protected $description = 'Check API usage levels and send alerts when approaching rate limits';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alert thresholds (percentage of limit).
|
||||||
|
* Loaded from config in constructor.
|
||||||
|
*/
|
||||||
|
protected array $thresholds = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cooldown hours between notifications.
|
||||||
|
*/
|
||||||
|
protected int $cooldownHours;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(RateLimitService $rateLimitService): int
|
||||||
|
{
|
||||||
|
// Check if alerts are enabled
|
||||||
|
if (! config('api.alerts.enabled', true)) {
|
||||||
|
$this->info('API usage alerts are disabled.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load thresholds from config (sorted by severity, critical first)
|
||||||
|
$this->thresholds = config('api.alerts.thresholds', [
|
||||||
|
'critical' => 95,
|
||||||
|
'warning' => 80,
|
||||||
|
]);
|
||||||
|
arsort($this->thresholds);
|
||||||
|
|
||||||
|
$this->cooldownHours = config('api.alerts.cooldown_hours', self::DEFAULT_COOLDOWN_HOURS);
|
||||||
|
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
$specificWorkspace = $this->option('workspace');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN MODE - No notifications will be sent');
|
||||||
|
$this->newLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get workspaces with active API keys
|
||||||
|
$query = Workspace::whereHas('apiKeys', function ($q) {
|
||||||
|
$q->active();
|
||||||
|
});
|
||||||
|
|
||||||
|
if ($specificWorkspace) {
|
||||||
|
$query->where('id', $specificWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaces = $query->get();
|
||||||
|
|
||||||
|
if ($workspaces->isEmpty()) {
|
||||||
|
$this->info('No workspaces with active API keys found.');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$alertsSent = 0;
|
||||||
|
$alertsSkipped = 0;
|
||||||
|
|
||||||
|
foreach ($workspaces as $workspace) {
|
||||||
|
$result = $this->checkWorkspaceUsage($workspace, $rateLimitService, $dryRun);
|
||||||
|
$alertsSent += $result['sent'];
|
||||||
|
$alertsSkipped += $result['skipped'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->newLine();
|
||||||
|
$this->info("Alerts sent: {$alertsSent}");
|
||||||
|
$this->info("Alerts skipped (cooldown): {$alertsSkipped}");
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check usage for a workspace and send alerts if needed.
|
||||||
|
*
|
||||||
|
* @return array{sent: int, skipped: int}
|
||||||
|
*/
|
||||||
|
protected function checkWorkspaceUsage(
|
||||||
|
Workspace $workspace,
|
||||||
|
RateLimitService $rateLimitService,
|
||||||
|
bool $dryRun
|
||||||
|
): array {
|
||||||
|
$sent = 0;
|
||||||
|
$skipped = 0;
|
||||||
|
|
||||||
|
// Get rate limit config for this workspace's tier
|
||||||
|
$tier = $this->getWorkspaceTier($workspace);
|
||||||
|
$limitConfig = $this->getTierLimitConfig($tier);
|
||||||
|
|
||||||
|
if (! $limitConfig) {
|
||||||
|
return ['sent' => 0, 'skipped' => 0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check usage for each active API key
|
||||||
|
$apiKeys = $workspace->apiKeys()->active()->get();
|
||||||
|
|
||||||
|
foreach ($apiKeys as $apiKey) {
|
||||||
|
$key = $rateLimitService->buildApiKeyKey($apiKey->id);
|
||||||
|
$attempts = $rateLimitService->attempts($key, $limitConfig['window']);
|
||||||
|
$limit = (int) floor($limitConfig['limit'] * ($limitConfig['burst'] ?? 1.0));
|
||||||
|
|
||||||
|
if ($limit === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$percentage = ($attempts / $limit) * 100;
|
||||||
|
|
||||||
|
// Check thresholds (critical first, then warning)
|
||||||
|
foreach ($this->thresholds as $level => $threshold) {
|
||||||
|
if ($percentage >= $threshold) {
|
||||||
|
$cacheKey = $this->getCacheKey($workspace->id, $apiKey->id, $level);
|
||||||
|
|
||||||
|
if (Cache::has($cacheKey)) {
|
||||||
|
$this->line(" [SKIP] {$workspace->name} - Key {$apiKey->prefix}: {$level} (cooldown)");
|
||||||
|
$skipped++;
|
||||||
|
|
||||||
|
break; // Don't check lower thresholds
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->line(" [ALERT] {$workspace->name} - Key {$apiKey->prefix}: {$level} ({$percentage}%)");
|
||||||
|
|
||||||
|
if (! $dryRun) {
|
||||||
|
$this->sendAlert($workspace, $apiKey, $level, $attempts, $limit, $limitConfig);
|
||||||
|
Cache::put($cacheKey, true, now()->addHours($this->cooldownHours));
|
||||||
|
}
|
||||||
|
|
||||||
|
$sent++;
|
||||||
|
|
||||||
|
break; // Only send one alert per key (highest severity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['sent' => $sent, 'skipped' => $skipped];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send alert notification to workspace owner.
|
||||||
|
*/
|
||||||
|
protected function sendAlert(
|
||||||
|
Workspace $workspace,
|
||||||
|
ApiKey $apiKey,
|
||||||
|
string $level,
|
||||||
|
int $currentUsage,
|
||||||
|
int $limit,
|
||||||
|
array $limitConfig
|
||||||
|
): void {
|
||||||
|
$owner = $workspace->owner();
|
||||||
|
|
||||||
|
if (! $owner) {
|
||||||
|
$this->warn(" No owner found for workspace {$workspace->name}");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$period = $this->formatPeriod($limitConfig['window']);
|
||||||
|
|
||||||
|
$owner->notify(new HighApiUsageNotification(
|
||||||
|
workspace: $workspace,
|
||||||
|
level: $level,
|
||||||
|
currentUsage: $currentUsage,
|
||||||
|
limit: $limit,
|
||||||
|
period: $period,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workspace tier for rate limiting.
|
||||||
|
*/
|
||||||
|
protected function getWorkspaceTier(Workspace $workspace): string
|
||||||
|
{
|
||||||
|
// Check for active package
|
||||||
|
$package = $workspace->workspacePackages()
|
||||||
|
->active()
|
||||||
|
->with('package')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $package?->package?->slug ?? 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit config for a tier.
|
||||||
|
*
|
||||||
|
* @return array{limit: int, window: int, burst: float}|null
|
||||||
|
*/
|
||||||
|
protected function getTierLimitConfig(string $tier): ?array
|
||||||
|
{
|
||||||
|
$config = config("api.rate_limits.tiers.{$tier}");
|
||||||
|
|
||||||
|
if (! $config) {
|
||||||
|
$config = config('api.rate_limits.tiers.free');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $config) {
|
||||||
|
$config = config('api.rate_limits.authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $config['limit'] ?? $config['requests'] ?? 60,
|
||||||
|
'window' => $config['window'] ?? (($config['per_minutes'] ?? 1) * 60),
|
||||||
|
'burst' => $config['burst'] ?? 1.0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format window period for display.
|
||||||
|
*/
|
||||||
|
protected function formatPeriod(int $seconds): string
|
||||||
|
{
|
||||||
|
if ($seconds < 60) {
|
||||||
|
return "{$seconds} seconds";
|
||||||
|
}
|
||||||
|
|
||||||
|
$minutes = $seconds / 60;
|
||||||
|
|
||||||
|
if ($minutes === 1.0) {
|
||||||
|
return 'minute';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($minutes < 60) {
|
||||||
|
return "{$minutes} minutes";
|
||||||
|
}
|
||||||
|
|
||||||
|
$hours = $minutes / 60;
|
||||||
|
|
||||||
|
if ($hours === 1.0) {
|
||||||
|
return 'hour';
|
||||||
|
}
|
||||||
|
|
||||||
|
return "{$hours} hours";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache key for notification cooldown.
|
||||||
|
*/
|
||||||
|
protected function getCacheKey(int $workspaceId, int $apiKeyId, string $level): string
|
||||||
|
{
|
||||||
|
return self::CACHE_PREFIX."{$workspaceId}:{$apiKeyId}:{$level}";
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php
Normal file
67
src/Mod/Api/Console/Commands/CleanupExpiredGracePeriods.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Mod\Api\Services\ApiKeyService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up API keys with expired grace periods.
|
||||||
|
*
|
||||||
|
* When an API key is rotated, the old key enters a grace period where
|
||||||
|
* both keys are valid. This command revokes keys whose grace period
|
||||||
|
* has ended.
|
||||||
|
*/
|
||||||
|
class CleanupExpiredGracePeriods extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*/
|
||||||
|
protected $signature = 'api:cleanup-grace-periods
|
||||||
|
{--dry-run : Show what would be revoked without actually revoking}';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*/
|
||||||
|
protected $description = 'Revoke API keys with expired grace periods after rotation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle(ApiKeyService $service): int
|
||||||
|
{
|
||||||
|
$dryRun = $this->option('dry-run');
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
$this->warn('DRY RUN MODE - No keys will be revoked');
|
||||||
|
$this->newLine();
|
||||||
|
|
||||||
|
// Count keys that would be cleaned up
|
||||||
|
$count = \Mod\Api\Models\ApiKey::gracePeriodExpired()
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->info('No API keys with expired grace periods found.');
|
||||||
|
} else {
|
||||||
|
$this->info("Would revoke {$count} API key(s) with expired grace periods.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info('Cleaning up API keys with expired grace periods...');
|
||||||
|
|
||||||
|
$count = $service->cleanupExpiredGracePeriods();
|
||||||
|
|
||||||
|
if ($count === 0) {
|
||||||
|
$this->info('No API keys with expired grace periods found.');
|
||||||
|
} else {
|
||||||
|
$this->info("Revoked {$count} API key(s) with expired grace periods.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
625
src/Mod/Api/Controllers/McpApiController.php
Normal file
625
src/Mod/Api/Controllers/McpApiController.php
Normal file
|
|
@ -0,0 +1,625 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Controllers;
|
||||||
|
|
||||||
|
use Core\Front\Controller;
|
||||||
|
use Core\Mod\Api\Models\ApiKey;
|
||||||
|
use Core\Mod\Mcp\Models\McpApiRequest;
|
||||||
|
use Core\Mod\Mcp\Models\McpToolCall;
|
||||||
|
use Core\Mod\Mcp\Models\McpToolVersion;
|
||||||
|
use Core\Mod\Mcp\Services\McpWebhookDispatcher;
|
||||||
|
use Core\Mod\Mcp\Services\ToolVersionService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP HTTP API Controller.
|
||||||
|
*
|
||||||
|
* Provides HTTP bridge to MCP servers for external integrations.
|
||||||
|
*/
|
||||||
|
class McpApiController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* List all available MCP servers.
|
||||||
|
*
|
||||||
|
* GET /api/v1/mcp/servers
|
||||||
|
*/
|
||||||
|
public function servers(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$registry = $this->loadRegistry();
|
||||||
|
|
||||||
|
$servers = collect($registry['servers'] ?? [])
|
||||||
|
->map(fn ($ref) => $this->loadServerSummary($ref['id']))
|
||||||
|
->filter()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'servers' => $servers,
|
||||||
|
'count' => $servers->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get server details with tools and resources.
|
||||||
|
*
|
||||||
|
* GET /api/v1/mcp/servers/{id}
|
||||||
|
*/
|
||||||
|
public function server(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$server = $this->loadServerFull($id);
|
||||||
|
|
||||||
|
if (! $server) {
|
||||||
|
return response()->json(['error' => 'Server not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($server);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List tools for a specific server.
|
||||||
|
*
|
||||||
|
* GET /api/v1/mcp/servers/{id}/tools
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* - include_versions: bool - include version info for each tool
|
||||||
|
*/
|
||||||
|
public function tools(Request $request, string $id): JsonResponse
|
||||||
|
{
|
||||||
|
$server = $this->loadServerFull($id);
|
||||||
|
|
||||||
|
if (! $server) {
|
||||||
|
return response()->json(['error' => 'Server not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tools = $server['tools'] ?? [];
|
||||||
|
$includeVersions = $request->boolean('include_versions', false);
|
||||||
|
|
||||||
|
// Optionally enrich tools with version information
|
||||||
|
if ($includeVersions) {
|
||||||
|
$versionService = app(ToolVersionService::class);
|
||||||
|
$tools = collect($tools)->map(function ($tool) use ($id, $versionService) {
|
||||||
|
$toolName = $tool['name'] ?? '';
|
||||||
|
$latestVersion = $versionService->getLatestVersion($id, $toolName);
|
||||||
|
|
||||||
|
$tool['versioning'] = [
|
||||||
|
'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
||||||
|
'is_versioned' => $latestVersion !== null,
|
||||||
|
'deprecated' => $latestVersion?->is_deprecated ?? false,
|
||||||
|
];
|
||||||
|
|
||||||
|
// If version exists, use its schema (may differ from YAML)
|
||||||
|
if ($latestVersion?->input_schema) {
|
||||||
|
$tool['inputSchema'] = $latestVersion->input_schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tool;
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'server' => $id,
|
||||||
|
'tools' => $tools,
|
||||||
|
'count' => count($tools),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a tool on an MCP server.
|
||||||
|
*
|
||||||
|
* POST /api/v1/mcp/tools/call
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* - server: string (required)
|
||||||
|
* - tool: string (required)
|
||||||
|
* - arguments: array (optional)
|
||||||
|
* - version: string (optional) - semver version to use, defaults to latest
|
||||||
|
*/
|
||||||
|
public function callTool(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'server' => 'required|string|max:64',
|
||||||
|
'tool' => 'required|string|max:128',
|
||||||
|
'arguments' => 'nullable|array',
|
||||||
|
'version' => 'nullable|string|max:32',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$server = $this->loadServerFull($validated['server']);
|
||||||
|
if (! $server) {
|
||||||
|
return response()->json(['error' => 'Server not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tool exists in server definition
|
||||||
|
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
|
||||||
|
if (! $toolDef) {
|
||||||
|
return response()->json(['error' => 'Tool not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version resolution
|
||||||
|
$versionService = app(ToolVersionService::class);
|
||||||
|
$versionResult = $versionService->resolveVersion(
|
||||||
|
$validated['server'],
|
||||||
|
$validated['tool'],
|
||||||
|
$validated['version'] ?? null
|
||||||
|
);
|
||||||
|
|
||||||
|
// If version was requested but is sunset, block the call
|
||||||
|
if ($versionResult['error']) {
|
||||||
|
$error = $versionResult['error'];
|
||||||
|
|
||||||
|
// Sunset versions return 410 Gone
|
||||||
|
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => $error['message'] ?? 'Version error',
|
||||||
|
'error_code' => $error['code'] ?? 'VERSION_ERROR',
|
||||||
|
'server' => $validated['server'],
|
||||||
|
'tool' => $validated['tool'],
|
||||||
|
'requested_version' => $validated['version'] ?? null,
|
||||||
|
'latest_version' => $error['latest_version'] ?? null,
|
||||||
|
'migration_notes' => $error['migration_notes'] ?? null,
|
||||||
|
], $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var McpToolVersion|null $toolVersion */
|
||||||
|
$toolVersion = $versionResult['version'];
|
||||||
|
$deprecationWarning = $versionResult['warning'];
|
||||||
|
|
||||||
|
// Use versioned schema if available for validation
|
||||||
|
$schemaForValidation = $toolVersion?->input_schema ?? $toolDef['inputSchema'] ?? null;
|
||||||
|
if ($schemaForValidation) {
|
||||||
|
$validationErrors = $this->validateToolArguments(
|
||||||
|
['inputSchema' => $schemaForValidation],
|
||||||
|
$validated['arguments'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! empty($validationErrors)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'error' => 'Validation failed',
|
||||||
|
'error_code' => 'VALIDATION_ERROR',
|
||||||
|
'validation_errors' => $validationErrors,
|
||||||
|
'server' => $validated['server'],
|
||||||
|
'tool' => $validated['tool'],
|
||||||
|
'version' => $toolVersion?->version ?? 'unversioned',
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get API key for logging
|
||||||
|
$apiKey = $request->attributes->get('api_key');
|
||||||
|
$workspace = $apiKey?->workspace;
|
||||||
|
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Execute the tool via artisan command
|
||||||
|
$result = $this->executeToolViaArtisan(
|
||||||
|
$validated['server'],
|
||||||
|
$validated['tool'],
|
||||||
|
$validated['arguments'] ?? []
|
||||||
|
);
|
||||||
|
|
||||||
|
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
// Log the call
|
||||||
|
$this->logToolCall($apiKey, $validated, $result, $durationMs, true);
|
||||||
|
|
||||||
|
// Dispatch webhooks
|
||||||
|
$this->dispatchWebhook($apiKey, $validated, true, $durationMs);
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'success' => true,
|
||||||
|
'server' => $validated['server'],
|
||||||
|
'tool' => $validated['tool'],
|
||||||
|
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
||||||
|
'result' => $result,
|
||||||
|
'duration_ms' => $durationMs,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Include deprecation warning if applicable
|
||||||
|
if ($deprecationWarning) {
|
||||||
|
$response['_warnings'] = [$deprecationWarning];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log full request for debugging/replay
|
||||||
|
$this->logApiRequest($request, $validated, 200, $response, $durationMs, $apiKey);
|
||||||
|
|
||||||
|
// Build response with deprecation headers if needed
|
||||||
|
$jsonResponse = response()->json($response);
|
||||||
|
|
||||||
|
if ($deprecationWarning) {
|
||||||
|
$jsonResponse->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
|
||||||
|
if (isset($deprecationWarning['sunset_at'])) {
|
||||||
|
$jsonResponse->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
|
||||||
|
}
|
||||||
|
if (isset($deprecationWarning['latest_version'])) {
|
||||||
|
$jsonResponse->header('X-MCP-Latest-Version', $deprecationWarning['latest_version']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $jsonResponse;
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
$this->logToolCall($apiKey, $validated, null, $durationMs, false, $e->getMessage());
|
||||||
|
|
||||||
|
// Dispatch webhooks (even on failure)
|
||||||
|
$this->dispatchWebhook($apiKey, $validated, false, $durationMs, $e->getMessage());
|
||||||
|
|
||||||
|
$response = [
|
||||||
|
'success' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'server' => $validated['server'],
|
||||||
|
'tool' => $validated['tool'],
|
||||||
|
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Log full request for debugging/replay
|
||||||
|
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
|
||||||
|
|
||||||
|
return response()->json($response, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate tool arguments against a JSON schema.
|
||||||
|
*
|
||||||
|
* @return array<string> Validation error messages
|
||||||
|
*/
|
||||||
|
protected function validateToolArguments(array $toolDef, array $arguments): array
|
||||||
|
{
|
||||||
|
$inputSchema = $toolDef['inputSchema'] ?? null;
|
||||||
|
|
||||||
|
if (! $inputSchema || ! is_array($inputSchema)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$errors = [];
|
||||||
|
$properties = $inputSchema['properties'] ?? [];
|
||||||
|
$required = $inputSchema['required'] ?? [];
|
||||||
|
|
||||||
|
// Check required properties
|
||||||
|
foreach ($required as $requiredProp) {
|
||||||
|
if (! array_key_exists($requiredProp, $arguments)) {
|
||||||
|
$errors[] = "Missing required argument: {$requiredProp}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type validation for provided arguments
|
||||||
|
foreach ($arguments as $key => $value) {
|
||||||
|
if (! isset($properties[$key])) {
|
||||||
|
if (($inputSchema['additionalProperties'] ?? true) === false) {
|
||||||
|
$errors[] = "Unknown argument: {$key}";
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$propSchema = $properties[$key];
|
||||||
|
$expectedType = $propSchema['type'] ?? null;
|
||||||
|
|
||||||
|
if ($expectedType && ! $this->validateType($value, $expectedType)) {
|
||||||
|
$errors[] = "Argument '{$key}' must be of type {$expectedType}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate enum values
|
||||||
|
if (isset($propSchema['enum']) && ! in_array($value, $propSchema['enum'], true)) {
|
||||||
|
$allowedValues = implode(', ', $propSchema['enum']);
|
||||||
|
$errors[] = "Argument '{$key}' must be one of: {$allowedValues}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a value against a JSON Schema type.
|
||||||
|
*/
|
||||||
|
protected function validateType(mixed $value, string $type): bool
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'string' => is_string($value),
|
||||||
|
'integer' => is_int($value) || (is_numeric($value) && floor((float) $value) == $value),
|
||||||
|
'number' => is_numeric($value),
|
||||||
|
'boolean' => is_bool($value),
|
||||||
|
'array' => is_array($value) && array_is_list($value),
|
||||||
|
'object' => is_array($value) && ! array_is_list($value),
|
||||||
|
'null' => is_null($value),
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get version history for a specific tool.
|
||||||
|
*
|
||||||
|
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions
|
||||||
|
*/
|
||||||
|
public function toolVersions(Request $request, string $server, string $tool): JsonResponse
|
||||||
|
{
|
||||||
|
$serverConfig = $this->loadServerFull($server);
|
||||||
|
if (! $serverConfig) {
|
||||||
|
return response()->json(['error' => 'Server not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tool exists in server definition
|
||||||
|
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
|
||||||
|
if (! $toolDef) {
|
||||||
|
return response()->json(['error' => 'Tool not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$versionService = app(ToolVersionService::class);
|
||||||
|
$versions = $versionService->getVersionHistory($server, $tool);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'server' => $server,
|
||||||
|
'tool' => $tool,
|
||||||
|
'versions' => $versions->map(fn (McpToolVersion $v) => $v->toApiArray())->values(),
|
||||||
|
'count' => $versions->count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific version of a tool.
|
||||||
|
*
|
||||||
|
* GET /api/v1/mcp/servers/{server}/tools/{tool}/versions/{version}
|
||||||
|
*/
|
||||||
|
public function toolVersion(Request $request, string $server, string $tool, string $version): JsonResponse
|
||||||
|
{
|
||||||
|
$versionService = app(ToolVersionService::class);
|
||||||
|
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
|
||||||
|
|
||||||
|
if (! $toolVersion) {
|
||||||
|
return response()->json(['error' => 'Version not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = response()->json($toolVersion->toApiArray());
|
||||||
|
|
||||||
|
// Add deprecation headers if applicable
|
||||||
|
if ($deprecationWarning = $toolVersion->getDeprecationWarning()) {
|
||||||
|
$response->header('X-MCP-Deprecation-Warning', $deprecationWarning['message'] ?? 'Version deprecated');
|
||||||
|
if (isset($deprecationWarning['sunset_at'])) {
|
||||||
|
$response->header('X-MCP-Sunset-At', $deprecationWarning['sunset_at']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a resource from an MCP server.
|
||||||
|
*
|
||||||
|
* GET /api/v1/mcp/resources/{uri}
|
||||||
|
*/
|
||||||
|
public function resource(Request $request, string $uri): JsonResponse
|
||||||
|
{
|
||||||
|
// Parse URI format: server://resource/path
|
||||||
|
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
|
||||||
|
return response()->json(['error' => 'Invalid resource URI format'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$serverId = $matches[1];
|
||||||
|
$resourcePath = $matches[2];
|
||||||
|
|
||||||
|
$server = $this->loadServerFull($serverId);
|
||||||
|
if (! $server) {
|
||||||
|
return response()->json(['error' => 'Server not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'uri' => $uri,
|
||||||
|
'content' => $result,
|
||||||
|
]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'uri' => $uri,
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute tool via artisan MCP server command.
|
||||||
|
*/
|
||||||
|
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
|
||||||
|
{
|
||||||
|
$commandMap = [
|
||||||
|
'hosthub-agent' => 'mcp:agent-server',
|
||||||
|
'socialhost' => 'mcp:socialhost-server',
|
||||||
|
'biohost' => 'mcp:biohost-server',
|
||||||
|
'commerce' => 'mcp:commerce-server',
|
||||||
|
'supporthost' => 'mcp:support-server',
|
||||||
|
'upstream' => 'mcp:upstream-server',
|
||||||
|
];
|
||||||
|
|
||||||
|
$command = $commandMap[$server] ?? null;
|
||||||
|
if (! $command) {
|
||||||
|
throw new \RuntimeException("Unknown server: {$server}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build MCP request
|
||||||
|
$mcpRequest = [
|
||||||
|
'jsonrpc' => '2.0',
|
||||||
|
'id' => uniqid(),
|
||||||
|
'method' => 'tools/call',
|
||||||
|
'params' => [
|
||||||
|
'name' => $tool,
|
||||||
|
'arguments' => $arguments,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Execute via process
|
||||||
|
$process = proc_open(
|
||||||
|
['php', 'artisan', $command],
|
||||||
|
[
|
||||||
|
0 => ['pipe', 'r'],
|
||||||
|
1 => ['pipe', 'w'],
|
||||||
|
2 => ['pipe', 'w'],
|
||||||
|
],
|
||||||
|
$pipes,
|
||||||
|
base_path()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! is_resource($process)) {
|
||||||
|
throw new \RuntimeException('Failed to start MCP server process');
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite($pipes[0], json_encode($mcpRequest)."\n");
|
||||||
|
fclose($pipes[0]);
|
||||||
|
|
||||||
|
$output = stream_get_contents($pipes[1]);
|
||||||
|
fclose($pipes[1]);
|
||||||
|
fclose($pipes[2]);
|
||||||
|
|
||||||
|
proc_close($process);
|
||||||
|
|
||||||
|
$response = json_decode($output, true);
|
||||||
|
|
||||||
|
if (isset($response['error'])) {
|
||||||
|
throw new \RuntimeException($response['error']['message'] ?? 'Tool execution failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response['result'] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read resource via artisan MCP server command.
|
||||||
|
*/
|
||||||
|
protected function readResourceViaArtisan(string $server, string $path): mixed
|
||||||
|
{
|
||||||
|
// Similar to executeToolViaArtisan but with resources/read method
|
||||||
|
// Simplified for now - can expand later
|
||||||
|
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log full API request for debugging and replay.
|
||||||
|
*/
|
||||||
|
protected function logApiRequest(
|
||||||
|
Request $request,
|
||||||
|
array $validated,
|
||||||
|
int $status,
|
||||||
|
array $response,
|
||||||
|
int $durationMs,
|
||||||
|
?ApiKey $apiKey,
|
||||||
|
?string $error = null
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
McpApiRequest::log(
|
||||||
|
method: $request->method(),
|
||||||
|
path: '/tools/call',
|
||||||
|
requestBody: $validated,
|
||||||
|
responseStatus: $status,
|
||||||
|
responseBody: $response,
|
||||||
|
durationMs: $durationMs,
|
||||||
|
workspaceId: $apiKey?->workspace_id,
|
||||||
|
apiKeyId: $apiKey?->id,
|
||||||
|
serverId: $validated['server'],
|
||||||
|
toolName: $validated['tool'],
|
||||||
|
errorMessage: $error,
|
||||||
|
ipAddress: $request->ip(),
|
||||||
|
headers: $request->headers->all()
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Don't let logging failures affect API response
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch webhook for tool execution.
|
||||||
|
*/
|
||||||
|
protected function dispatchWebhook(
|
||||||
|
?ApiKey $apiKey,
|
||||||
|
array $request,
|
||||||
|
bool $success,
|
||||||
|
int $durationMs,
|
||||||
|
?string $error = null
|
||||||
|
): void {
|
||||||
|
if (! $apiKey?->workspace_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dispatcher = new McpWebhookDispatcher;
|
||||||
|
$dispatcher->dispatchToolExecuted(
|
||||||
|
workspaceId: $apiKey->workspace_id,
|
||||||
|
serverId: $request['server'],
|
||||||
|
toolName: $request['tool'],
|
||||||
|
arguments: $request['arguments'] ?? [],
|
||||||
|
success: $success,
|
||||||
|
durationMs: $durationMs,
|
||||||
|
errorMessage: $error
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Don't let webhook failures affect API response
|
||||||
|
report($e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log tool call for analytics.
|
||||||
|
*/
|
||||||
|
protected function logToolCall(
|
||||||
|
?ApiKey $apiKey,
|
||||||
|
array $request,
|
||||||
|
mixed $result,
|
||||||
|
int $durationMs,
|
||||||
|
bool $success,
|
||||||
|
?string $error = null
|
||||||
|
): void {
|
||||||
|
McpToolCall::log(
|
||||||
|
serverId: $request['server'],
|
||||||
|
toolName: $request['tool'],
|
||||||
|
params: $request['arguments'] ?? [],
|
||||||
|
success: $success,
|
||||||
|
durationMs: $durationMs,
|
||||||
|
errorMessage: $error,
|
||||||
|
workspaceId: $apiKey?->workspace_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry loading methods (shared with McpRegistryController)
|
||||||
|
|
||||||
|
protected function loadRegistry(): array
|
||||||
|
{
|
||||||
|
return Cache::remember('mcp:registry', 600, function () {
|
||||||
|
$path = resource_path('mcp/registry.yaml');
|
||||||
|
|
||||||
|
return file_exists($path) ? Yaml::parseFile($path) : ['servers' => []];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadServerFull(string $id): ?array
|
||||||
|
{
|
||||||
|
return Cache::remember("mcp:server:{$id}", 600, function () use ($id) {
|
||||||
|
$path = resource_path("mcp/servers/{$id}.yaml");
|
||||||
|
|
||||||
|
return file_exists($path) ? Yaml::parseFile($path) : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function loadServerSummary(string $id): ?array
|
||||||
|
{
|
||||||
|
$server = $this->loadServerFull($id);
|
||||||
|
if (! $server) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $server['id'],
|
||||||
|
'name' => $server['name'],
|
||||||
|
'tagline' => $server['tagline'] ?? '',
|
||||||
|
'status' => $server['status'] ?? 'available',
|
||||||
|
'tool_count' => count($server['tools'] ?? []),
|
||||||
|
'resource_count' => count($server['resources'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/Mod/Api/Database/Factories/ApiKeyFactory.php
Normal file
253
src/Mod/Api/Database/Factories/ApiKeyFactory.php
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for generating ApiKey test instances.
|
||||||
|
*
|
||||||
|
* By default, creates keys with secure bcrypt hashing.
|
||||||
|
* Use legacyHash() to create keys with SHA-256 for migration testing.
|
||||||
|
*
|
||||||
|
* @extends Factory<ApiKey>
|
||||||
|
*/
|
||||||
|
class ApiKeyFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name of the factory's corresponding model.
|
||||||
|
*
|
||||||
|
* @var class-string<ApiKey>
|
||||||
|
*/
|
||||||
|
protected $model = ApiKey::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store the plain key for testing.
|
||||||
|
*/
|
||||||
|
private ?string $plainKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* Creates keys with secure bcrypt hashing by default.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$plainKey = Str::random(48);
|
||||||
|
$prefix = 'hk_'.Str::random(8);
|
||||||
|
$this->plainKey = "{$prefix}_{$plainKey}";
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workspace_id' => Workspace::factory(),
|
||||||
|
'user_id' => User::factory(),
|
||||||
|
'name' => fake()->words(2, true).' API Key',
|
||||||
|
'key' => Hash::make($plainKey),
|
||||||
|
'hash_algorithm' => ApiKey::HASH_BCRYPT,
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'scopes' => [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||||
|
'server_scopes' => null,
|
||||||
|
'last_used_at' => null,
|
||||||
|
'expires_at' => null,
|
||||||
|
'grace_period_ends_at' => null,
|
||||||
|
'rotated_from_id' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the plain key after creation.
|
||||||
|
* Must be called immediately after create() to get the plain key.
|
||||||
|
*/
|
||||||
|
public function getPlainKey(): ?string
|
||||||
|
{
|
||||||
|
return $this->plainKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a key with specific known credentials for testing.
|
||||||
|
*
|
||||||
|
* This method uses ApiKey::generate() which creates secure bcrypt keys.
|
||||||
|
*
|
||||||
|
* @return array{api_key: ApiKey, plain_key: string}
|
||||||
|
*/
|
||||||
|
public static function createWithPlainKey(
|
||||||
|
?Workspace $workspace = null,
|
||||||
|
?User $user = null,
|
||||||
|
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||||
|
?\DateTimeInterface $expiresAt = null
|
||||||
|
): array {
|
||||||
|
$workspace ??= Workspace::factory()->create();
|
||||||
|
$user ??= User::factory()->create();
|
||||||
|
|
||||||
|
return ApiKey::generate(
|
||||||
|
$workspace->id,
|
||||||
|
$user->id,
|
||||||
|
fake()->words(2, true).' API Key',
|
||||||
|
$scopes,
|
||||||
|
$expiresAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a key with legacy SHA-256 hashing for migration testing.
|
||||||
|
*
|
||||||
|
* @return array{api_key: ApiKey, plain_key: string}
|
||||||
|
*/
|
||||||
|
public static function createLegacyKey(
|
||||||
|
?Workspace $workspace = null,
|
||||||
|
?User $user = null,
|
||||||
|
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||||
|
?\DateTimeInterface $expiresAt = null
|
||||||
|
): array {
|
||||||
|
$workspace ??= Workspace::factory()->create();
|
||||||
|
$user ??= User::factory()->create();
|
||||||
|
|
||||||
|
$plainKey = Str::random(48);
|
||||||
|
$prefix = 'hk_'.Str::random(8);
|
||||||
|
|
||||||
|
$apiKey = ApiKey::create([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'name' => fake()->words(2, true).' API Key',
|
||||||
|
'key' => hash('sha256', $plainKey),
|
||||||
|
'hash_algorithm' => ApiKey::HASH_SHA256,
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'scopes' => $scopes,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
'plain_key' => "{$prefix}_{$plainKey}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create key with legacy SHA-256 hashing (for migration testing).
|
||||||
|
*/
|
||||||
|
public function legacyHash(): static
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) {
|
||||||
|
// Extract the plain key from the stored state
|
||||||
|
$parts = explode('_', $this->plainKey ?? '', 3);
|
||||||
|
$plainKey = $parts[2] ?? Str::random(48);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'key' => hash('sha256', $plainKey),
|
||||||
|
'hash_algorithm' => ApiKey::HASH_SHA256,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the key has been used recently.
|
||||||
|
*/
|
||||||
|
public function used(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'last_used_at' => now()->subMinutes(fake()->numberBetween(1, 60)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the key expires in the future.
|
||||||
|
*
|
||||||
|
* @param int $days Number of days until expiration
|
||||||
|
*/
|
||||||
|
public function expiresIn(int $days = 30): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'expires_at' => now()->addDays($days),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Indicate that the key has expired.
|
||||||
|
*/
|
||||||
|
public function expired(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'expires_at' => now()->subDays(1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set specific scopes.
|
||||||
|
*
|
||||||
|
* @param array<string> $scopes
|
||||||
|
*/
|
||||||
|
public function withScopes(array $scopes): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'scopes' => $scopes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set read-only scope.
|
||||||
|
*/
|
||||||
|
public function readOnly(): static
|
||||||
|
{
|
||||||
|
return $this->withScopes([ApiKey::SCOPE_READ]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set all scopes (read, write, delete).
|
||||||
|
*/
|
||||||
|
public function fullAccess(): static
|
||||||
|
{
|
||||||
|
return $this->withScopes(ApiKey::ALL_SCOPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set specific server scopes.
|
||||||
|
*
|
||||||
|
* @param array<string>|null $servers
|
||||||
|
*/
|
||||||
|
public function withServerScopes(?array $servers): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'server_scopes' => $servers,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a revoked (soft-deleted) key.
|
||||||
|
*/
|
||||||
|
public function revoked(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'deleted_at' => now()->subDay(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a key in a rotation grace period.
|
||||||
|
*
|
||||||
|
* @param int $hoursRemaining Hours until grace period ends
|
||||||
|
*/
|
||||||
|
public function inGracePeriod(int $hoursRemaining = 12): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'grace_period_ends_at' => now()->addHours($hoursRemaining),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a key with an expired grace period.
|
||||||
|
*/
|
||||||
|
public function gracePeriodExpired(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'grace_period_ends_at' => now()->subHours(1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/Mod/Api/Documentation/Attributes/ApiHidden.php
Normal file
41
src/Mod/Api/Documentation/Attributes/ApiHidden.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Hidden attribute for excluding endpoints from documentation.
|
||||||
|
*
|
||||||
|
* Apply to controller classes or methods to hide them from the generated
|
||||||
|
* OpenAPI documentation.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* // Hide entire controller
|
||||||
|
* #[ApiHidden]
|
||||||
|
* class InternalController extends Controller {}
|
||||||
|
*
|
||||||
|
* // Hide specific method
|
||||||
|
* class UserController extends Controller
|
||||||
|
* {
|
||||||
|
* #[ApiHidden]
|
||||||
|
* public function internalMethod() {}
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Hide with reason (for code documentation)
|
||||||
|
* #[ApiHidden('Internal use only')]
|
||||||
|
* public function debug() {}
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
|
||||||
|
readonly class ApiHidden
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $reason Optional reason for hiding (documentation only)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?string $reason = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
101
src/Mod/Api/Documentation/Attributes/ApiParameter.php
Normal file
101
src/Mod/Api/Documentation/Attributes/ApiParameter.php
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Parameter attribute for documenting endpoint parameters.
|
||||||
|
*
|
||||||
|
* Apply to controller methods to document query parameters, path parameters,
|
||||||
|
* or header parameters in OpenAPI documentation.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* #[ApiParameter('page', 'query', 'integer', 'Page number', required: false, example: 1)]
|
||||||
|
* #[ApiParameter('per_page', 'query', 'integer', 'Items per page', required: false, example: 25)]
|
||||||
|
* #[ApiParameter('filter[status]', 'query', 'string', 'Filter by status', enum: ['active', 'inactive'])]
|
||||||
|
* public function index(Request $request)
|
||||||
|
* {
|
||||||
|
* // ...
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Document header parameters
|
||||||
|
* #[ApiParameter('X-Custom-Header', 'header', 'string', 'Custom header value')]
|
||||||
|
* public function withHeader() {}
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
||||||
|
readonly class ApiParameter
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $name Parameter name
|
||||||
|
* @param string $in Parameter location: 'query', 'path', 'header', 'cookie'
|
||||||
|
* @param string $type Data type: 'string', 'integer', 'boolean', 'number', 'array'
|
||||||
|
* @param string|null $description Parameter description
|
||||||
|
* @param bool $required Whether parameter is required
|
||||||
|
* @param mixed $example Example value
|
||||||
|
* @param mixed $default Default value
|
||||||
|
* @param array|null $enum Allowed values (for enumerated parameters)
|
||||||
|
* @param string|null $format Format hint (e.g., 'date', 'email', 'uuid')
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public string $in = 'query',
|
||||||
|
public string $type = 'string',
|
||||||
|
public ?string $description = null,
|
||||||
|
public bool $required = false,
|
||||||
|
public mixed $example = null,
|
||||||
|
public mixed $default = null,
|
||||||
|
public ?array $enum = null,
|
||||||
|
public ?string $format = null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to OpenAPI parameter schema.
|
||||||
|
*/
|
||||||
|
public function toSchema(): array
|
||||||
|
{
|
||||||
|
$schema = [
|
||||||
|
'type' => $this->type,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->format !== null) {
|
||||||
|
$schema['format'] = $this->format;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->enum !== null) {
|
||||||
|
$schema['enum'] = $this->enum;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->default !== null) {
|
||||||
|
$schema['default'] = $this->default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->example !== null) {
|
||||||
|
$schema['example'] = $this->example;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert to full OpenAPI parameter object.
|
||||||
|
*/
|
||||||
|
public function toOpenApi(): array
|
||||||
|
{
|
||||||
|
$param = [
|
||||||
|
'name' => $this->name,
|
||||||
|
'in' => $this->in,
|
||||||
|
'required' => $this->required || $this->in === 'path',
|
||||||
|
'schema' => $this->toSchema(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->description !== null) {
|
||||||
|
$param['description'] = $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $param;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/Mod/Api/Documentation/Attributes/ApiResponse.php
Normal file
80
src/Mod/Api/Documentation/Attributes/ApiResponse.php
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Response attribute for documenting endpoint responses.
|
||||||
|
*
|
||||||
|
* Apply to controller methods to document possible responses in OpenAPI.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* #[ApiResponse(200, UserResource::class, 'User retrieved successfully')]
|
||||||
|
* #[ApiResponse(404, null, 'User not found')]
|
||||||
|
* #[ApiResponse(422, null, 'Validation failed')]
|
||||||
|
* public function show(User $user)
|
||||||
|
* {
|
||||||
|
* return new UserResource($user);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // For paginated responses
|
||||||
|
* #[ApiResponse(200, UserResource::class, 'Users list', paginated: true)]
|
||||||
|
* public function index()
|
||||||
|
* {
|
||||||
|
* return UserResource::collection(User::paginate());
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
||||||
|
readonly class ApiResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $status HTTP status code
|
||||||
|
* @param string|null $resource Resource class for response body (null for no body)
|
||||||
|
* @param string|null $description Description of the response
|
||||||
|
* @param bool $paginated Whether this is a paginated collection response
|
||||||
|
* @param array<string> $headers Additional response headers to document
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $status,
|
||||||
|
public ?string $resource = null,
|
||||||
|
public ?string $description = null,
|
||||||
|
public bool $paginated = false,
|
||||||
|
public array $headers = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the description or generate from status code.
|
||||||
|
*/
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
if ($this->description !== null) {
|
||||||
|
return $this->description;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($this->status) {
|
||||||
|
200 => 'Successful response',
|
||||||
|
201 => 'Resource created',
|
||||||
|
202 => 'Request accepted',
|
||||||
|
204 => 'No content',
|
||||||
|
301 => 'Moved permanently',
|
||||||
|
302 => 'Found (redirect)',
|
||||||
|
304 => 'Not modified',
|
||||||
|
400 => 'Bad request',
|
||||||
|
401 => 'Unauthorized',
|
||||||
|
403 => 'Forbidden',
|
||||||
|
404 => 'Not found',
|
||||||
|
405 => 'Method not allowed',
|
||||||
|
409 => 'Conflict',
|
||||||
|
422 => 'Validation error',
|
||||||
|
429 => 'Too many requests',
|
||||||
|
500 => 'Internal server error',
|
||||||
|
502 => 'Bad gateway',
|
||||||
|
503 => 'Service unavailable',
|
||||||
|
default => 'Response',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/Mod/Api/Documentation/Attributes/ApiSecurity.php
Normal file
51
src/Mod/Api/Documentation/Attributes/ApiSecurity.php
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Security attribute for documenting authentication requirements.
|
||||||
|
*
|
||||||
|
* Apply to controller classes or methods to specify authentication requirements.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* // Require API key authentication
|
||||||
|
* #[ApiSecurity('apiKey')]
|
||||||
|
* class ProtectedController extends Controller {}
|
||||||
|
*
|
||||||
|
* // Require bearer token
|
||||||
|
* #[ApiSecurity('bearer')]
|
||||||
|
* public function profile() {}
|
||||||
|
*
|
||||||
|
* // Require specific scopes
|
||||||
|
* #[ApiSecurity('apiKey', scopes: ['read', 'write'])]
|
||||||
|
* public function update() {}
|
||||||
|
*
|
||||||
|
* // Mark endpoint as public (no auth required)
|
||||||
|
* #[ApiSecurity(null)]
|
||||||
|
* public function publicEndpoint() {}
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
|
||||||
|
readonly class ApiSecurity
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string|null $scheme Security scheme name (null for no auth)
|
||||||
|
* @param array<string> $scopes Required OAuth2 scopes (if applicable)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public ?string $scheme,
|
||||||
|
public array $scopes = [],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this marks the endpoint as public.
|
||||||
|
*/
|
||||||
|
public function isPublic(): bool
|
||||||
|
{
|
||||||
|
return $this->scheme === null;
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/Mod/Api/Documentation/Attributes/ApiTag.php
Normal file
38
src/Mod/Api/Documentation/Attributes/ApiTag.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Attributes;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Tag attribute for grouping endpoints in documentation.
|
||||||
|
*
|
||||||
|
* Apply to controller classes to group their endpoints under a specific tag
|
||||||
|
* in the OpenAPI documentation.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* #[ApiTag('Users', 'User management endpoints')]
|
||||||
|
* class UserController extends Controller
|
||||||
|
* {
|
||||||
|
* // All methods will be tagged with 'Users'
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Or use on specific methods to override class-level tag
|
||||||
|
* #[ApiTag('Admin')]
|
||||||
|
* public function adminOnly() {}
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
|
||||||
|
readonly class ApiTag
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $name The tag name displayed in documentation
|
||||||
|
* @param string|null $description Optional description of the tag
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $name,
|
||||||
|
public ?string $description = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
128
src/Mod/Api/Documentation/DocumentationController.php
Normal file
128
src/Mod/Api/Documentation/DocumentationController.php
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Symfony\Component\Yaml\Yaml;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Documentation Controller.
|
||||||
|
*
|
||||||
|
* Serves OpenAPI documentation in multiple formats and provides
|
||||||
|
* interactive documentation UIs (Swagger, Scalar, ReDoc).
|
||||||
|
*/
|
||||||
|
class DocumentationController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected OpenApiBuilder $builder,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show the main documentation page.
|
||||||
|
*
|
||||||
|
* Redirects to the configured default UI.
|
||||||
|
*/
|
||||||
|
public function index(Request $request): View
|
||||||
|
{
|
||||||
|
$defaultUi = config('api-docs.ui.default', 'scalar');
|
||||||
|
|
||||||
|
return match ($defaultUi) {
|
||||||
|
'swagger' => $this->swagger($request),
|
||||||
|
'redoc' => $this->redoc($request),
|
||||||
|
default => $this->scalar($request),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show Swagger UI.
|
||||||
|
*/
|
||||||
|
public function swagger(Request $request): View
|
||||||
|
{
|
||||||
|
$config = config('api-docs.ui.swagger', []);
|
||||||
|
|
||||||
|
return view('api-docs::swagger', [
|
||||||
|
'specUrl' => route('api.docs.openapi.json'),
|
||||||
|
'config' => $config,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show Scalar API Reference.
|
||||||
|
*/
|
||||||
|
public function scalar(Request $request): View
|
||||||
|
{
|
||||||
|
$config = config('api-docs.ui.scalar', []);
|
||||||
|
|
||||||
|
return view('api-docs::scalar', [
|
||||||
|
'specUrl' => route('api.docs.openapi.json'),
|
||||||
|
'config' => $config,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show ReDoc documentation.
|
||||||
|
*/
|
||||||
|
public function redoc(Request $request): View
|
||||||
|
{
|
||||||
|
return view('api-docs::redoc', [
|
||||||
|
'specUrl' => route('api.docs.openapi.json'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OpenAPI specification as JSON.
|
||||||
|
*/
|
||||||
|
public function openApiJson(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$spec = $this->builder->build();
|
||||||
|
|
||||||
|
return response()->json($spec)
|
||||||
|
->header('Cache-Control', $this->getCacheControl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get OpenAPI specification as YAML.
|
||||||
|
*/
|
||||||
|
public function openApiYaml(Request $request): Response
|
||||||
|
{
|
||||||
|
$spec = $this->builder->build();
|
||||||
|
|
||||||
|
// Convert to YAML
|
||||||
|
$yaml = Yaml::dump($spec, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||||
|
|
||||||
|
return response($yaml)
|
||||||
|
->header('Content-Type', 'application/x-yaml')
|
||||||
|
->header('Cache-Control', $this->getCacheControl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the documentation cache.
|
||||||
|
*/
|
||||||
|
public function clearCache(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$this->builder->clearCache();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => 'Documentation cache cleared successfully.',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cache control header value.
|
||||||
|
*/
|
||||||
|
protected function getCacheControl(): string
|
||||||
|
{
|
||||||
|
if (app()->environment('local', 'testing')) {
|
||||||
|
return 'no-cache, no-store, must-revalidate';
|
||||||
|
}
|
||||||
|
|
||||||
|
$ttl = config('api-docs.cache.ttl', 3600);
|
||||||
|
|
||||||
|
return "public, max-age={$ttl}";
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/Mod/Api/Documentation/DocumentationServiceProvider.php
Normal file
87
src/Mod/Api/Documentation/DocumentationServiceProvider.php
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\Middleware\ProtectDocumentation;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Documentation Service Provider.
|
||||||
|
*
|
||||||
|
* Registers documentation routes, views, configuration, and services.
|
||||||
|
*/
|
||||||
|
class DocumentationServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Register any application services.
|
||||||
|
*/
|
||||||
|
public function register(): void
|
||||||
|
{
|
||||||
|
// Merge configuration
|
||||||
|
$this->mergeConfigFrom(
|
||||||
|
__DIR__.'/config.php',
|
||||||
|
'api-docs'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Register OpenApiBuilder as singleton
|
||||||
|
$this->app->singleton(OpenApiBuilder::class, function ($app) {
|
||||||
|
return new OpenApiBuilder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
// Skip route registration during console commands (except route:list)
|
||||||
|
if ($this->shouldRegisterRoutes()) {
|
||||||
|
$this->registerRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register views
|
||||||
|
$this->loadViewsFrom(__DIR__.'/Views', 'api-docs');
|
||||||
|
|
||||||
|
// Publish configuration
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
$this->publishes([
|
||||||
|
__DIR__.'/config.php' => config_path('api-docs.php'),
|
||||||
|
], 'api-docs-config');
|
||||||
|
|
||||||
|
$this->publishes([
|
||||||
|
__DIR__.'/Views' => resource_path('views/vendor/api-docs'),
|
||||||
|
], 'api-docs-views');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if routes should be registered.
|
||||||
|
*/
|
||||||
|
protected function shouldRegisterRoutes(): bool
|
||||||
|
{
|
||||||
|
// Always register if not in console
|
||||||
|
if (! $this->app->runningInConsole()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register for artisan route:list command
|
||||||
|
$command = $_SERVER['argv'][1] ?? null;
|
||||||
|
|
||||||
|
return $command === 'route:list' || $command === 'route:cache';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register documentation routes.
|
||||||
|
*/
|
||||||
|
protected function registerRoutes(): void
|
||||||
|
{
|
||||||
|
$path = config('api-docs.path', '/api/docs');
|
||||||
|
|
||||||
|
Route::middleware(['web', ProtectDocumentation::class])
|
||||||
|
->prefix($path)
|
||||||
|
->group(__DIR__.'/Routes/docs.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
278
src/Mod/Api/Documentation/Examples/CommonExamples.php
Normal file
278
src/Mod/Api/Documentation/Examples/CommonExamples.php
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Examples;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common API Examples.
|
||||||
|
*
|
||||||
|
* Provides example requests and responses for documentation.
|
||||||
|
*/
|
||||||
|
class CommonExamples
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Get example for pagination parameters.
|
||||||
|
*/
|
||||||
|
public static function paginationParams(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'page' => [
|
||||||
|
'name' => 'page',
|
||||||
|
'in' => 'query',
|
||||||
|
'description' => 'Page number for pagination',
|
||||||
|
'required' => false,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'minimum' => 1,
|
||||||
|
'default' => 1,
|
||||||
|
'example' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'per_page' => [
|
||||||
|
'name' => 'per_page',
|
||||||
|
'in' => 'query',
|
||||||
|
'description' => 'Number of items per page',
|
||||||
|
'required' => false,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'minimum' => 1,
|
||||||
|
'maximum' => 100,
|
||||||
|
'default' => 25,
|
||||||
|
'example' => 25,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example for sorting parameters.
|
||||||
|
*/
|
||||||
|
public static function sortingParams(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'sort' => [
|
||||||
|
'name' => 'sort',
|
||||||
|
'in' => 'query',
|
||||||
|
'description' => 'Field to sort by (prefix with - for descending)',
|
||||||
|
'required' => false,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'example' => '-created_at',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example for filtering parameters.
|
||||||
|
*/
|
||||||
|
public static function filteringParams(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'filter' => [
|
||||||
|
'name' => 'filter',
|
||||||
|
'in' => 'query',
|
||||||
|
'description' => 'Filter parameters in the format filter[field]=value',
|
||||||
|
'required' => false,
|
||||||
|
'style' => 'deepObject',
|
||||||
|
'explode' => true,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'additionalProperties' => [
|
||||||
|
'type' => 'string',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'example' => [
|
||||||
|
'status' => 'active',
|
||||||
|
'created_at[gte]' => '2024-01-01',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example paginated response.
|
||||||
|
*/
|
||||||
|
public static function paginatedResponse(string $dataExample = '[]'): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'data' => json_decode($dataExample, true) ?? [],
|
||||||
|
'links' => [
|
||||||
|
'first' => 'https://api.example.com/resource?page=1',
|
||||||
|
'last' => 'https://api.example.com/resource?page=10',
|
||||||
|
'prev' => null,
|
||||||
|
'next' => 'https://api.example.com/resource?page=2',
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => 1,
|
||||||
|
'from' => 1,
|
||||||
|
'last_page' => 10,
|
||||||
|
'per_page' => 25,
|
||||||
|
'to' => 25,
|
||||||
|
'total' => 250,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example error response.
|
||||||
|
*/
|
||||||
|
public static function errorResponse(int $status, string $message, ?array $errors = null): array
|
||||||
|
{
|
||||||
|
$response = ['message' => $message];
|
||||||
|
|
||||||
|
if ($errors !== null) {
|
||||||
|
$response['errors'] = $errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example validation error response.
|
||||||
|
*/
|
||||||
|
public static function validationErrorResponse(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'message' => 'The given data was invalid.',
|
||||||
|
'errors' => [
|
||||||
|
'email' => [
|
||||||
|
'The email field is required.',
|
||||||
|
],
|
||||||
|
'name' => [
|
||||||
|
'The name field must be at least 2 characters.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example rate limit headers.
|
||||||
|
*/
|
||||||
|
public static function rateLimitHeaders(int $limit = 1000, int $remaining = 999): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'X-RateLimit-Limit' => (string) $limit,
|
||||||
|
'X-RateLimit-Remaining' => (string) $remaining,
|
||||||
|
'X-RateLimit-Reset' => (string) (time() + 60),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example authentication headers.
|
||||||
|
*/
|
||||||
|
public static function authHeaders(string $type = 'api_key'): array
|
||||||
|
{
|
||||||
|
return match ($type) {
|
||||||
|
'api_key' => [
|
||||||
|
'X-API-Key' => 'hk_1234567890abcdefghijklmnop',
|
||||||
|
],
|
||||||
|
'bearer' => [
|
||||||
|
'Authorization' => 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',
|
||||||
|
],
|
||||||
|
default => [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example workspace header.
|
||||||
|
*/
|
||||||
|
public static function workspaceHeader(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'X-Workspace-ID' => '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example CURL request.
|
||||||
|
*/
|
||||||
|
public static function curlExample(
|
||||||
|
string $method,
|
||||||
|
string $endpoint,
|
||||||
|
?array $body = null,
|
||||||
|
array $headers = []
|
||||||
|
): string {
|
||||||
|
$curl = "curl -X {$method} \\\n";
|
||||||
|
$curl .= " 'https://api.example.com{$endpoint}' \\\n";
|
||||||
|
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
$curl .= " -H '{$name}: {$value}' \\\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body !== null) {
|
||||||
|
$curl .= " -H 'Content-Type: application/json' \\\n";
|
||||||
|
$curl .= " -d '".json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."'";
|
||||||
|
}
|
||||||
|
|
||||||
|
return rtrim($curl, " \\\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example JavaScript fetch request.
|
||||||
|
*/
|
||||||
|
public static function fetchExample(
|
||||||
|
string $method,
|
||||||
|
string $endpoint,
|
||||||
|
?array $body = null,
|
||||||
|
array $headers = []
|
||||||
|
): string {
|
||||||
|
$allHeaders = array_merge([
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
], $headers);
|
||||||
|
|
||||||
|
$options = [
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'headers' => $allHeaders,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($body !== null) {
|
||||||
|
$options['body'] = 'JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT).')';
|
||||||
|
}
|
||||||
|
|
||||||
|
$code = "const response = await fetch('https://api.example.com{$endpoint}', {\n";
|
||||||
|
$code .= " method: '{$options['method']}',\n";
|
||||||
|
$code .= ' headers: '.json_encode($allHeaders, JSON_PRETTY_PRINT).",\n";
|
||||||
|
|
||||||
|
if ($body !== null) {
|
||||||
|
$code .= ' body: JSON.stringify('.json_encode($body, JSON_PRETTY_PRINT)."),\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$code .= "});\n\n";
|
||||||
|
$code .= 'const data = await response.json();';
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get example PHP request.
|
||||||
|
*/
|
||||||
|
public static function phpExample(
|
||||||
|
string $method,
|
||||||
|
string $endpoint,
|
||||||
|
?array $body = null,
|
||||||
|
array $headers = []
|
||||||
|
): string {
|
||||||
|
$code = "<?php\n\n";
|
||||||
|
$code .= "\$client = new \\GuzzleHttp\\Client();\n\n";
|
||||||
|
$code .= "\$response = \$client->request('{$method}', 'https://api.example.com{$endpoint}', [\n";
|
||||||
|
|
||||||
|
if (! empty($headers)) {
|
||||||
|
$code .= " 'headers' => [\n";
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
$code .= " '{$name}' => '{$value}',\n";
|
||||||
|
}
|
||||||
|
$code .= " ],\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body !== null) {
|
||||||
|
$code .= " 'json' => ".var_export($body, true).",\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$code .= "]);\n\n";
|
||||||
|
$code .= '$data = json_decode($response->getBody(), true);';
|
||||||
|
|
||||||
|
return $code;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/Mod/Api/Documentation/Extension.php
Normal file
40
src/Mod/Api/Documentation/Extension.php
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation;
|
||||||
|
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAPI Extension Interface.
|
||||||
|
*
|
||||||
|
* Extensions allow customizing the OpenAPI specification generation
|
||||||
|
* by modifying the spec or individual operations.
|
||||||
|
*/
|
||||||
|
interface Extension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extend the complete OpenAPI specification.
|
||||||
|
*
|
||||||
|
* Called after the spec is built but before it's cached or returned.
|
||||||
|
*
|
||||||
|
* @param array $spec The OpenAPI specification array
|
||||||
|
* @param array $config Documentation configuration
|
||||||
|
* @return array Modified specification
|
||||||
|
*/
|
||||||
|
public function extend(array $spec, array $config): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend an individual operation.
|
||||||
|
*
|
||||||
|
* Called for each route operation during path building.
|
||||||
|
*
|
||||||
|
* @param array $operation The operation array
|
||||||
|
* @param Route $route The Laravel route
|
||||||
|
* @param string $method HTTP method (lowercase)
|
||||||
|
* @param array $config Documentation configuration
|
||||||
|
* @return array Modified operation
|
||||||
|
*/
|
||||||
|
public function extendOperation(array $operation, Route $route, string $method, array $config): array;
|
||||||
|
}
|
||||||
234
src/Mod/Api/Documentation/Extensions/ApiKeyAuthExtension.php
Normal file
234
src/Mod/Api/Documentation/Extensions/ApiKeyAuthExtension.php
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Extensions;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\Extension;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key Authentication Extension.
|
||||||
|
*
|
||||||
|
* Enhances API key authentication documentation with examples
|
||||||
|
* and detailed instructions.
|
||||||
|
*/
|
||||||
|
class ApiKeyAuthExtension implements Extension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extend the complete OpenAPI specification.
|
||||||
|
*/
|
||||||
|
public function extend(array $spec, array $config): array
|
||||||
|
{
|
||||||
|
$apiKeyConfig = $config['auth']['api_key'] ?? [];
|
||||||
|
|
||||||
|
if (! ($apiKeyConfig['enabled'] ?? true)) {
|
||||||
|
return $spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enhance API key security scheme description
|
||||||
|
if (isset($spec['components']['securitySchemes']['apiKeyAuth'])) {
|
||||||
|
$spec['components']['securitySchemes']['apiKeyAuth']['description'] = $this->buildApiKeyDescription($apiKeyConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add authentication guide to info.description
|
||||||
|
$authGuide = $this->buildAuthenticationGuide($config);
|
||||||
|
if (! empty($authGuide)) {
|
||||||
|
$spec['info']['description'] = ($spec['info']['description'] ?? '')."\n\n".$authGuide;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add example schemas for authentication-related responses
|
||||||
|
$spec['components']['schemas']['UnauthorizedError'] = [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'message' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'example' => 'Unauthenticated.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$spec['components']['schemas']['ForbiddenError'] = [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'message' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'example' => 'This action is unauthorized.',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add common auth error responses to components
|
||||||
|
$spec['components']['responses']['Unauthorized'] = [
|
||||||
|
'description' => 'Authentication required or invalid credentials',
|
||||||
|
'content' => [
|
||||||
|
'application/json' => [
|
||||||
|
'schema' => [
|
||||||
|
'$ref' => '#/components/schemas/UnauthorizedError',
|
||||||
|
],
|
||||||
|
'examples' => [
|
||||||
|
'missing_key' => [
|
||||||
|
'summary' => 'Missing API Key',
|
||||||
|
'value' => ['message' => 'API key is required.'],
|
||||||
|
],
|
||||||
|
'invalid_key' => [
|
||||||
|
'summary' => 'Invalid API Key',
|
||||||
|
'value' => ['message' => 'Invalid API key.'],
|
||||||
|
],
|
||||||
|
'expired_key' => [
|
||||||
|
'summary' => 'Expired API Key',
|
||||||
|
'value' => ['message' => 'API key has expired.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$spec['components']['responses']['Forbidden'] = [
|
||||||
|
'description' => 'Insufficient permissions for this action',
|
||||||
|
'content' => [
|
||||||
|
'application/json' => [
|
||||||
|
'schema' => [
|
||||||
|
'$ref' => '#/components/schemas/ForbiddenError',
|
||||||
|
],
|
||||||
|
'examples' => [
|
||||||
|
'insufficient_scope' => [
|
||||||
|
'summary' => 'Missing Required Scope',
|
||||||
|
'value' => ['message' => 'API key lacks required scope: write'],
|
||||||
|
],
|
||||||
|
'workspace_access' => [
|
||||||
|
'summary' => 'Workspace Access Denied',
|
||||||
|
'value' => ['message' => 'API key does not have access to this workspace.'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend an individual operation.
|
||||||
|
*/
|
||||||
|
public function extendOperation(array $operation, Route $route, string $method, array $config): array
|
||||||
|
{
|
||||||
|
// Add 401/403 responses to authenticated endpoints
|
||||||
|
if (! empty($operation['security'])) {
|
||||||
|
$hasApiKeyAuth = false;
|
||||||
|
foreach ($operation['security'] as $security) {
|
||||||
|
if (isset($security['apiKeyAuth'])) {
|
||||||
|
$hasApiKeyAuth = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($hasApiKeyAuth) {
|
||||||
|
// Add 401 response if not present
|
||||||
|
if (! isset($operation['responses']['401'])) {
|
||||||
|
$operation['responses']['401'] = [
|
||||||
|
'$ref' => '#/components/responses/Unauthorized',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 403 response if not present
|
||||||
|
if (! isset($operation['responses']['403'])) {
|
||||||
|
$operation['responses']['403'] = [
|
||||||
|
'$ref' => '#/components/responses/Forbidden',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build detailed API key description.
|
||||||
|
*/
|
||||||
|
protected function buildApiKeyDescription(array $config): string
|
||||||
|
{
|
||||||
|
$headerName = $config['name'] ?? 'X-API-Key';
|
||||||
|
$baseDescription = $config['description'] ?? 'API key for authentication.';
|
||||||
|
|
||||||
|
return <<<MARKDOWN
|
||||||
|
$baseDescription
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Include your API key in the `$headerName` header:
|
||||||
|
|
||||||
|
```
|
||||||
|
$headerName: your_api_key_here
|
||||||
|
```
|
||||||
|
|
||||||
|
## Key Format
|
||||||
|
|
||||||
|
API keys follow the format: `hk_xxxxxxxxxxxxxxxx`
|
||||||
|
|
||||||
|
- Prefix `hk_` identifies it as a Host UK API key
|
||||||
|
- Keys are 32+ characters long
|
||||||
|
- Keys should be kept secret and never committed to version control
|
||||||
|
|
||||||
|
## Scopes
|
||||||
|
|
||||||
|
API keys can be created with specific scopes:
|
||||||
|
|
||||||
|
- `read` - Read access to resources
|
||||||
|
- `write` - Create and update resources
|
||||||
|
- `delete` - Delete resources
|
||||||
|
|
||||||
|
## Key Management
|
||||||
|
|
||||||
|
- Create and manage API keys in your workspace settings
|
||||||
|
- Keys can be revoked at any time
|
||||||
|
- Set expiration dates for temporary access
|
||||||
|
- Monitor usage via the API dashboard
|
||||||
|
MARKDOWN;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build authentication guide for API description.
|
||||||
|
*/
|
||||||
|
protected function buildAuthenticationGuide(array $config): string
|
||||||
|
{
|
||||||
|
$apiKeyConfig = $config['auth']['api_key'] ?? [];
|
||||||
|
$bearerConfig = $config['auth']['bearer'] ?? [];
|
||||||
|
|
||||||
|
$sections = [];
|
||||||
|
|
||||||
|
$sections[] = '## Authentication';
|
||||||
|
$sections[] = '';
|
||||||
|
$sections[] = 'This API supports multiple authentication methods:';
|
||||||
|
$sections[] = '';
|
||||||
|
|
||||||
|
if ($apiKeyConfig['enabled'] ?? true) {
|
||||||
|
$headerName = $apiKeyConfig['name'] ?? 'X-API-Key';
|
||||||
|
$sections[] = '### API Key Authentication';
|
||||||
|
$sections[] = '';
|
||||||
|
$sections[] = "For server-to-server integration, use API key authentication via the `$headerName` header.";
|
||||||
|
$sections[] = '';
|
||||||
|
$sections[] = '```http';
|
||||||
|
$sections[] = 'GET /api/endpoint HTTP/1.1';
|
||||||
|
$sections[] = 'Host: api.example.com';
|
||||||
|
$sections[] = "$headerName: hk_your_api_key_here";
|
||||||
|
$sections[] = '```';
|
||||||
|
$sections[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($bearerConfig['enabled'] ?? true) {
|
||||||
|
$sections[] = '### Bearer Token Authentication';
|
||||||
|
$sections[] = '';
|
||||||
|
$sections[] = 'For user-authenticated requests (SPAs, mobile apps), use bearer token authentication.';
|
||||||
|
$sections[] = '';
|
||||||
|
$sections[] = '```http';
|
||||||
|
$sections[] = 'GET /api/endpoint HTTP/1.1';
|
||||||
|
$sections[] = 'Host: api.example.com';
|
||||||
|
$sections[] = 'Authorization: Bearer your_token_here';
|
||||||
|
$sections[] = '```';
|
||||||
|
$sections[] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $sections);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/Mod/Api/Documentation/Extensions/RateLimitExtension.php
Normal file
228
src/Mod/Api/Documentation/Extensions/RateLimitExtension.php
Normal file
|
|
@ -0,0 +1,228 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Extensions;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\Extension;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimit;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate Limit Extension.
|
||||||
|
*
|
||||||
|
* Documents rate limit headers in API responses and extracts rate limit
|
||||||
|
* information from the #[RateLimit] attribute.
|
||||||
|
*/
|
||||||
|
class RateLimitExtension implements Extension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extend the complete OpenAPI specification.
|
||||||
|
*/
|
||||||
|
public function extend(array $spec, array $config): array
|
||||||
|
{
|
||||||
|
$rateLimitConfig = $config['rate_limits'] ?? [];
|
||||||
|
|
||||||
|
if (! ($rateLimitConfig['enabled'] ?? true)) {
|
||||||
|
return $spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rate limit headers to components
|
||||||
|
$headers = $rateLimitConfig['headers'] ?? [
|
||||||
|
'X-RateLimit-Limit' => 'Maximum number of requests allowed per window',
|
||||||
|
'X-RateLimit-Remaining' => 'Number of requests remaining in the current window',
|
||||||
|
'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets',
|
||||||
|
];
|
||||||
|
|
||||||
|
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
|
||||||
|
|
||||||
|
foreach ($headers as $name => $description) {
|
||||||
|
$headerKey = str_replace(['-', ' '], '', strtolower($name));
|
||||||
|
$spec['components']['headers'][$headerKey] = [
|
||||||
|
'description' => $description,
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 429 response schema to components
|
||||||
|
$spec['components']['responses']['RateLimitExceeded'] = [
|
||||||
|
'description' => 'Rate limit exceeded',
|
||||||
|
'headers' => [
|
||||||
|
'X-RateLimit-Limit' => [
|
||||||
|
'$ref' => '#/components/headers/xratelimitlimit',
|
||||||
|
],
|
||||||
|
'X-RateLimit-Remaining' => [
|
||||||
|
'$ref' => '#/components/headers/xratelimitremaining',
|
||||||
|
],
|
||||||
|
'X-RateLimit-Reset' => [
|
||||||
|
'$ref' => '#/components/headers/xratelimitreset',
|
||||||
|
],
|
||||||
|
'Retry-After' => [
|
||||||
|
'description' => 'Seconds to wait before retrying',
|
||||||
|
'schema' => ['type' => 'integer'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'content' => [
|
||||||
|
'application/json' => [
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'message' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'example' => 'Too Many Requests',
|
||||||
|
],
|
||||||
|
'retry_after' => [
|
||||||
|
'type' => 'integer',
|
||||||
|
'description' => 'Seconds until rate limit resets',
|
||||||
|
'example' => 30,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
return $spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend an individual operation.
|
||||||
|
*/
|
||||||
|
public function extendOperation(array $operation, Route $route, string $method, array $config): array
|
||||||
|
{
|
||||||
|
$rateLimitConfig = $config['rate_limits'] ?? [];
|
||||||
|
|
||||||
|
if (! ($rateLimitConfig['enabled'] ?? true)) {
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if route has rate limiting middleware
|
||||||
|
if (! $this->hasRateLimiting($route)) {
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rate limit headers to successful responses
|
||||||
|
foreach ($operation['responses'] as $status => &$response) {
|
||||||
|
if ((int) $status >= 200 && (int) $status < 300) {
|
||||||
|
$response['headers'] = $response['headers'] ?? [];
|
||||||
|
$response['headers']['X-RateLimit-Limit'] = [
|
||||||
|
'$ref' => '#/components/headers/xratelimitlimit',
|
||||||
|
];
|
||||||
|
$response['headers']['X-RateLimit-Remaining'] = [
|
||||||
|
'$ref' => '#/components/headers/xratelimitremaining',
|
||||||
|
];
|
||||||
|
$response['headers']['X-RateLimit-Reset'] = [
|
||||||
|
'$ref' => '#/components/headers/xratelimitreset',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add 429 response
|
||||||
|
$operation['responses']['429'] = [
|
||||||
|
'$ref' => '#/components/responses/RateLimitExceeded',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Extract rate limit from attribute and add to description
|
||||||
|
$rateLimit = $this->extractRateLimit($route);
|
||||||
|
if ($rateLimit !== null) {
|
||||||
|
$limitInfo = sprintf(
|
||||||
|
'**Rate Limit:** %d requests per %d seconds',
|
||||||
|
$rateLimit['limit'],
|
||||||
|
$rateLimit['window']
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($rateLimit['burst'] > 1.0) {
|
||||||
|
$limitInfo .= sprintf(' (%.0f%% burst allowed)', ($rateLimit['burst'] - 1) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation['description'] = isset($operation['description'])
|
||||||
|
? $operation['description']."\n\n".$limitInfo
|
||||||
|
: $limitInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if route has rate limiting.
|
||||||
|
*/
|
||||||
|
protected function hasRateLimiting(Route $route): bool
|
||||||
|
{
|
||||||
|
$middleware = $route->middleware();
|
||||||
|
|
||||||
|
foreach ($middleware as $m) {
|
||||||
|
if (str_contains($m, 'throttle') ||
|
||||||
|
str_contains($m, 'rate') ||
|
||||||
|
str_contains($m, 'api.rate') ||
|
||||||
|
str_contains($m, 'RateLimit')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check for RateLimit attribute on controller
|
||||||
|
$controller = $route->getController();
|
||||||
|
if ($controller !== null) {
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
if (! empty($reflection->getAttributes(RateLimit::class))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$action = $route->getActionMethod();
|
||||||
|
if ($reflection->hasMethod($action)) {
|
||||||
|
$method = $reflection->getMethod($action);
|
||||||
|
if (! empty($method->getAttributes(RateLimit::class))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract rate limit configuration from route.
|
||||||
|
*/
|
||||||
|
protected function extractRateLimit(Route $route): ?array
|
||||||
|
{
|
||||||
|
$controller = $route->getController();
|
||||||
|
|
||||||
|
if ($controller === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
$action = $route->getActionMethod();
|
||||||
|
|
||||||
|
// Check method first
|
||||||
|
if ($reflection->hasMethod($action)) {
|
||||||
|
$method = $reflection->getMethod($action);
|
||||||
|
$attrs = $method->getAttributes(RateLimit::class);
|
||||||
|
if (! empty($attrs)) {
|
||||||
|
$rateLimit = $attrs[0]->newInstance();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $rateLimit->limit,
|
||||||
|
'window' => $rateLimit->window,
|
||||||
|
'burst' => $rateLimit->burst,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check class
|
||||||
|
$attrs = $reflection->getAttributes(RateLimit::class);
|
||||||
|
if (! empty($attrs)) {
|
||||||
|
$rateLimit = $attrs[0]->newInstance();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $rateLimit->limit,
|
||||||
|
'window' => $rateLimit->window,
|
||||||
|
'burst' => $rateLimit->burst,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Extensions;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\Extension;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace Header Extension.
|
||||||
|
*
|
||||||
|
* Adds documentation for the X-Workspace-ID header used in multi-tenant
|
||||||
|
* API operations.
|
||||||
|
*/
|
||||||
|
class WorkspaceHeaderExtension implements Extension
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Extend the complete OpenAPI specification.
|
||||||
|
*/
|
||||||
|
public function extend(array $spec, array $config): array
|
||||||
|
{
|
||||||
|
// Add workspace header parameter to components
|
||||||
|
$workspaceConfig = $config['workspace'] ?? [];
|
||||||
|
|
||||||
|
if (! empty($workspaceConfig)) {
|
||||||
|
$spec['components']['parameters']['workspaceId'] = [
|
||||||
|
'name' => $workspaceConfig['header_name'] ?? 'X-Workspace-ID',
|
||||||
|
'in' => 'header',
|
||||||
|
'required' => $workspaceConfig['required'] ?? false,
|
||||||
|
'description' => $workspaceConfig['description'] ?? 'Workspace identifier for multi-tenant operations',
|
||||||
|
'schema' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'format' => 'uuid',
|
||||||
|
'example' => '550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend an individual operation.
|
||||||
|
*/
|
||||||
|
public function extendOperation(array $operation, Route $route, string $method, array $config): array
|
||||||
|
{
|
||||||
|
// Check if route requires workspace context
|
||||||
|
if (! $this->requiresWorkspace($route)) {
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceConfig = $config['workspace'] ?? [];
|
||||||
|
$headerName = $workspaceConfig['header_name'] ?? 'X-Workspace-ID';
|
||||||
|
|
||||||
|
// Add workspace header parameter reference
|
||||||
|
$operation['parameters'] = $operation['parameters'] ?? [];
|
||||||
|
|
||||||
|
// Check if already added
|
||||||
|
foreach ($operation['parameters'] as $param) {
|
||||||
|
if (isset($param['name']) && $param['name'] === $headerName) {
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add as reference to component
|
||||||
|
$operation['parameters'][] = [
|
||||||
|
'$ref' => '#/components/parameters/workspaceId',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if route requires workspace context.
|
||||||
|
*/
|
||||||
|
protected function requiresWorkspace(Route $route): bool
|
||||||
|
{
|
||||||
|
$middleware = $route->middleware();
|
||||||
|
|
||||||
|
// Check for workspace-related middleware
|
||||||
|
foreach ($middleware as $m) {
|
||||||
|
if (str_contains($m, 'workspace') ||
|
||||||
|
str_contains($m, 'api.auth') ||
|
||||||
|
str_contains($m, 'auth.api')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check route name patterns that typically need workspace
|
||||||
|
$name = $route->getName() ?? '';
|
||||||
|
$workspaceRoutes = [
|
||||||
|
'api.key.',
|
||||||
|
'api.bio.',
|
||||||
|
'api.blocks.',
|
||||||
|
'api.shortlinks.',
|
||||||
|
'api.qr.',
|
||||||
|
'api.workspaces.',
|
||||||
|
'api.webhooks.',
|
||||||
|
'api.content.',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($workspaceRoutes as $pattern) {
|
||||||
|
if (str_starts_with($name, $pattern)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Protect Documentation Middleware.
|
||||||
|
*
|
||||||
|
* Controls access to API documentation based on environment,
|
||||||
|
* authentication, and IP whitelist.
|
||||||
|
*/
|
||||||
|
class ProtectDocumentation
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Check if documentation is enabled
|
||||||
|
if (! config('api-docs.enabled', true)) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = config('api-docs.access', []);
|
||||||
|
|
||||||
|
// Check if public access is allowed in current environment
|
||||||
|
$publicEnvironments = $config['public_environments'] ?? ['local', 'testing', 'staging'];
|
||||||
|
if (in_array(app()->environment(), $publicEnvironments, true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check IP whitelist
|
||||||
|
$ipWhitelist = $config['ip_whitelist'] ?? [];
|
||||||
|
if (! empty($ipWhitelist)) {
|
||||||
|
$clientIp = $request->ip();
|
||||||
|
if (! in_array($clientIp, $ipWhitelist, true)) {
|
||||||
|
abort(403, 'Access denied.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if authentication is required
|
||||||
|
if ($config['require_auth'] ?? false) {
|
||||||
|
if (! $request->user()) {
|
||||||
|
return redirect()->route('login');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check allowed roles
|
||||||
|
$allowedRoles = $config['allowed_roles'] ?? [];
|
||||||
|
if (! empty($allowedRoles)) {
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Check if user has any of the allowed roles
|
||||||
|
$hasRole = false;
|
||||||
|
foreach ($allowedRoles as $role) {
|
||||||
|
if (method_exists($user, 'hasRole') && $user->hasRole($role)) {
|
||||||
|
$hasRole = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $hasRole) {
|
||||||
|
abort(403, 'Insufficient permissions to view documentation.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/Mod/Api/Documentation/ModuleDiscovery.php
Normal file
209
src/Mod/Api/Documentation/ModuleDiscovery.php
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiTag;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module Discovery Service.
|
||||||
|
*
|
||||||
|
* Discovers API routes from modules and groups them by tag/module
|
||||||
|
* for organized documentation.
|
||||||
|
*/
|
||||||
|
class ModuleDiscovery
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Discovered modules with their routes.
|
||||||
|
*
|
||||||
|
* @var array<string, array>
|
||||||
|
*/
|
||||||
|
protected array $modules = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discover all API modules and their routes.
|
||||||
|
*
|
||||||
|
* @return array<string, array>
|
||||||
|
*/
|
||||||
|
public function discover(): array
|
||||||
|
{
|
||||||
|
$this->modules = [];
|
||||||
|
|
||||||
|
foreach (Route::getRoutes() as $route) {
|
||||||
|
if (! $this->isApiRoute($route)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$module = $this->identifyModule($route);
|
||||||
|
$this->addRouteToModule($module, $route);
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($this->modules);
|
||||||
|
|
||||||
|
return $this->modules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get modules grouped by tag.
|
||||||
|
*
|
||||||
|
* @return array<string, array>
|
||||||
|
*/
|
||||||
|
public function getModulesByTag(): array
|
||||||
|
{
|
||||||
|
$byTag = [];
|
||||||
|
|
||||||
|
foreach ($this->discover() as $module => $data) {
|
||||||
|
$tag = $data['tag'] ?? $module;
|
||||||
|
$byTag[$tag] = $byTag[$tag] ?? [
|
||||||
|
'name' => $tag,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
'routes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$byTag[$tag]['routes'] = array_merge(
|
||||||
|
$byTag[$tag]['routes'],
|
||||||
|
$data['routes']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byTag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a summary of discovered modules.
|
||||||
|
*/
|
||||||
|
public function getSummary(): array
|
||||||
|
{
|
||||||
|
$modules = $this->discover();
|
||||||
|
|
||||||
|
return array_map(function ($data) {
|
||||||
|
return [
|
||||||
|
'tag' => $data['tag'],
|
||||||
|
'description' => $data['description'],
|
||||||
|
'route_count' => count($data['routes']),
|
||||||
|
'endpoints' => array_map(function ($route) {
|
||||||
|
return [
|
||||||
|
'method' => $route['method'],
|
||||||
|
'uri' => $route['uri'],
|
||||||
|
'name' => $route['name'],
|
||||||
|
];
|
||||||
|
}, $data['routes']),
|
||||||
|
];
|
||||||
|
}, $modules);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if route is an API route.
|
||||||
|
*/
|
||||||
|
protected function isApiRoute($route): bool
|
||||||
|
{
|
||||||
|
$uri = $route->uri();
|
||||||
|
|
||||||
|
return str_starts_with($uri, 'api/') || $uri === 'api';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Identify which module a route belongs to.
|
||||||
|
*/
|
||||||
|
protected function identifyModule($route): string
|
||||||
|
{
|
||||||
|
$controller = $route->getController();
|
||||||
|
|
||||||
|
if ($controller !== null) {
|
||||||
|
// Check for ApiTag attribute
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
$tagAttrs = $reflection->getAttributes(ApiTag::class);
|
||||||
|
|
||||||
|
if (! empty($tagAttrs)) {
|
||||||
|
return $tagAttrs[0]->newInstance()->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer from namespace
|
||||||
|
$namespace = $reflection->getNamespaceName();
|
||||||
|
|
||||||
|
// Extract module name from namespace patterns
|
||||||
|
if (preg_match('/(?:Mod|Module|Http\\\\Controllers)\\\\([^\\\\]+)/', $namespace, $matches)) {
|
||||||
|
return $matches[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer from route URI
|
||||||
|
return $this->inferModuleFromUri($route->uri());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer module name from URI.
|
||||||
|
*/
|
||||||
|
protected function inferModuleFromUri(string $uri): string
|
||||||
|
{
|
||||||
|
// Remove api/ prefix
|
||||||
|
$path = preg_replace('#^api/#', '', $uri);
|
||||||
|
|
||||||
|
// Get first segment
|
||||||
|
$parts = explode('/', $path);
|
||||||
|
$segment = $parts[0] ?? 'general';
|
||||||
|
|
||||||
|
// Map common segments to module names
|
||||||
|
$mapping = [
|
||||||
|
'bio' => 'Bio',
|
||||||
|
'blocks' => 'Bio',
|
||||||
|
'shortlinks' => 'Bio',
|
||||||
|
'qr' => 'Bio',
|
||||||
|
'commerce' => 'Commerce',
|
||||||
|
'provisioning' => 'Commerce',
|
||||||
|
'workspaces' => 'Tenant',
|
||||||
|
'analytics' => 'Analytics',
|
||||||
|
'social' => 'Social',
|
||||||
|
'notify' => 'Notifications',
|
||||||
|
'support' => 'Support',
|
||||||
|
'pixel' => 'Pixel',
|
||||||
|
'seo' => 'SEO',
|
||||||
|
'mcp' => 'MCP',
|
||||||
|
'content' => 'Content',
|
||||||
|
'trust' => 'Trust',
|
||||||
|
'webhooks' => 'Webhooks',
|
||||||
|
'entitlements' => 'Entitlements',
|
||||||
|
];
|
||||||
|
|
||||||
|
return $mapping[$segment] ?? ucfirst($segment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a route to a module.
|
||||||
|
*/
|
||||||
|
protected function addRouteToModule(string $module, $route): void
|
||||||
|
{
|
||||||
|
if (! isset($this->modules[$module])) {
|
||||||
|
$this->modules[$module] = [
|
||||||
|
'tag' => $module,
|
||||||
|
'description' => $this->getModuleDescription($module),
|
||||||
|
'routes' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$this->modules[$module]['routes'][] = [
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'uri' => '/'.$route->uri(),
|
||||||
|
'name' => $route->getName(),
|
||||||
|
'action' => $route->getActionMethod(),
|
||||||
|
'middleware' => $route->middleware(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get module description from config.
|
||||||
|
*/
|
||||||
|
protected function getModuleDescription(string $module): ?string
|
||||||
|
{
|
||||||
|
$tags = config('api-docs.tags', []);
|
||||||
|
|
||||||
|
return $tags[$module]['description'] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
819
src/Mod/Api/Documentation/OpenApiBuilder.php
Normal file
819
src/Mod/Api/Documentation/OpenApiBuilder.php
Normal file
|
|
@ -0,0 +1,819 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Documentation;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiHidden;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiParameter;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiResponse;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiTag;
|
||||||
|
use Core\Mod\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
||||||
|
use Core\Mod\Api\Documentation\Extensions\RateLimitExtension;
|
||||||
|
use Core\Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use ReflectionAttribute;
|
||||||
|
use ReflectionClass;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced OpenAPI Specification Builder.
|
||||||
|
*
|
||||||
|
* Builds comprehensive OpenAPI 3.1 specification from Laravel routes,
|
||||||
|
* with support for custom attributes, module discovery, and extensions.
|
||||||
|
*/
|
||||||
|
class OpenApiBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Registered extensions.
|
||||||
|
*
|
||||||
|
* @var array<Extension>
|
||||||
|
*/
|
||||||
|
protected array $extensions = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Discovered tags from modules.
|
||||||
|
*
|
||||||
|
* @var array<string, array>
|
||||||
|
*/
|
||||||
|
protected array $discoveredTags = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new builder instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->registerDefaultExtensions();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register default extensions.
|
||||||
|
*/
|
||||||
|
protected function registerDefaultExtensions(): void
|
||||||
|
{
|
||||||
|
$this->extensions = [
|
||||||
|
new WorkspaceHeaderExtension,
|
||||||
|
new RateLimitExtension,
|
||||||
|
new ApiKeyAuthExtension,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a custom extension.
|
||||||
|
*/
|
||||||
|
public function addExtension(Extension $extension): static
|
||||||
|
{
|
||||||
|
$this->extensions[] = $extension;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the complete OpenAPI specification.
|
||||||
|
*/
|
||||||
|
public function build(): array
|
||||||
|
{
|
||||||
|
$config = config('api-docs', []);
|
||||||
|
|
||||||
|
if ($this->shouldCache($config)) {
|
||||||
|
$cacheKey = $config['cache']['key'] ?? 'api-docs:openapi';
|
||||||
|
$cacheTtl = $config['cache']['ttl'] ?? 3600;
|
||||||
|
|
||||||
|
return Cache::remember($cacheKey, $cacheTtl, fn () => $this->buildSpec($config));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->buildSpec($config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cached specification.
|
||||||
|
*/
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
$cacheKey = config('api-docs.cache.key', 'api-docs:openapi');
|
||||||
|
Cache::forget($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if caching should be enabled.
|
||||||
|
*/
|
||||||
|
protected function shouldCache(array $config): bool
|
||||||
|
{
|
||||||
|
if (! ($config['cache']['enabled'] ?? true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disabledEnvs = $config['cache']['disabled_environments'] ?? ['local', 'testing'];
|
||||||
|
|
||||||
|
return ! in_array(app()->environment(), $disabledEnvs, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full OpenAPI specification.
|
||||||
|
*/
|
||||||
|
protected function buildSpec(array $config): array
|
||||||
|
{
|
||||||
|
$spec = [
|
||||||
|
'openapi' => '3.1.0',
|
||||||
|
'info' => $this->buildInfo($config),
|
||||||
|
'servers' => $this->buildServers($config),
|
||||||
|
'tags' => [],
|
||||||
|
'paths' => [],
|
||||||
|
'components' => $this->buildComponents($config),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Build paths and collect tags
|
||||||
|
$spec['paths'] = $this->buildPaths($config);
|
||||||
|
$spec['tags'] = $this->buildTags($config);
|
||||||
|
|
||||||
|
// Apply extensions to spec
|
||||||
|
foreach ($this->extensions as $extension) {
|
||||||
|
$spec = $extension->extend($spec, $config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build API info section.
|
||||||
|
*/
|
||||||
|
protected function buildInfo(array $config): array
|
||||||
|
{
|
||||||
|
$info = $config['info'] ?? [];
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'title' => $info['title'] ?? config('app.name', 'API').' API',
|
||||||
|
'version' => $info['version'] ?? config('api.version', '1.0.0'),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! empty($info['description'])) {
|
||||||
|
$result['description'] = $info['description'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($info['contact'])) {
|
||||||
|
$contact = array_filter($info['contact']);
|
||||||
|
if (! empty($contact)) {
|
||||||
|
$result['contact'] = $contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($info['license']['name'])) {
|
||||||
|
$result['license'] = array_filter($info['license']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build servers section.
|
||||||
|
*/
|
||||||
|
protected function buildServers(array $config): array
|
||||||
|
{
|
||||||
|
$servers = $config['servers'] ?? [];
|
||||||
|
|
||||||
|
if (empty($servers)) {
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'url' => config('app.url', 'http://localhost'),
|
||||||
|
'description' => 'Current Environment',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(fn ($server) => array_filter($server), $servers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build tags section from discovered modules and config.
|
||||||
|
*/
|
||||||
|
protected function buildTags(array $config): array
|
||||||
|
{
|
||||||
|
$configTags = $config['tags'] ?? [];
|
||||||
|
$tags = [];
|
||||||
|
|
||||||
|
// Add discovered tags first
|
||||||
|
foreach ($this->discoveredTags as $name => $data) {
|
||||||
|
$tags[$name] = [
|
||||||
|
'name' => $name,
|
||||||
|
'description' => $data['description'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with configured tags (config takes precedence)
|
||||||
|
foreach ($configTags as $key => $tagConfig) {
|
||||||
|
$tagName = $tagConfig['name'] ?? $key;
|
||||||
|
$tags[$tagName] = [
|
||||||
|
'name' => $tagName,
|
||||||
|
'description' => $tagConfig['description'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up null descriptions and sort
|
||||||
|
$result = [];
|
||||||
|
foreach ($tags as $tag) {
|
||||||
|
$result[] = array_filter($tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
usort($result, fn ($a, $b) => strcasecmp($a['name'], $b['name']));
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build paths section from routes.
|
||||||
|
*/
|
||||||
|
protected function buildPaths(array $config): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
$includePatterns = $config['routes']['include'] ?? ['api/*'];
|
||||||
|
$excludePatterns = $config['routes']['exclude'] ?? [];
|
||||||
|
|
||||||
|
foreach (RouteFacade::getRoutes() as $route) {
|
||||||
|
/** @var Route $route */
|
||||||
|
if (! $this->shouldIncludeRoute($route, $includePatterns, $excludePatterns)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->normalizePath($route->uri());
|
||||||
|
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$method = strtolower($method);
|
||||||
|
$operation = $this->buildOperation($route, $method, $config);
|
||||||
|
|
||||||
|
if ($operation !== null) {
|
||||||
|
$paths[$path][$method] = $operation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($paths);
|
||||||
|
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a route should be included in documentation.
|
||||||
|
*/
|
||||||
|
protected function shouldIncludeRoute(Route $route, array $include, array $exclude): bool
|
||||||
|
{
|
||||||
|
$uri = $route->uri();
|
||||||
|
|
||||||
|
// Check exclusions first
|
||||||
|
foreach ($exclude as $pattern) {
|
||||||
|
if (fnmatch($pattern, $uri)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check inclusions
|
||||||
|
foreach ($include as $pattern) {
|
||||||
|
if (fnmatch($pattern, $uri)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize route path to OpenAPI format.
|
||||||
|
*/
|
||||||
|
protected function normalizePath(string $uri): string
|
||||||
|
{
|
||||||
|
// Prepend slash if missing
|
||||||
|
$path = '/'.ltrim($uri, '/');
|
||||||
|
|
||||||
|
// Convert Laravel parameters to OpenAPI format: {param?} -> {param}
|
||||||
|
$path = preg_replace('/\{([^}?]+)\?\}/', '{$1}', $path);
|
||||||
|
|
||||||
|
return $path === '/' ? '/' : rtrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build operation for a specific route and method.
|
||||||
|
*/
|
||||||
|
protected function buildOperation(Route $route, string $method, array $config): ?array
|
||||||
|
{
|
||||||
|
$controller = $route->getController();
|
||||||
|
$action = $route->getActionMethod();
|
||||||
|
|
||||||
|
// Check for ApiHidden attribute
|
||||||
|
if ($this->isHidden($controller, $action)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$operation = [
|
||||||
|
'summary' => $this->buildSummary($route, $method),
|
||||||
|
'operationId' => $this->buildOperationId($route, $method),
|
||||||
|
'tags' => $this->buildOperationTags($route, $controller, $action),
|
||||||
|
'responses' => $this->buildResponses($controller, $action),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add description from PHPDoc if available
|
||||||
|
$description = $this->extractDescription($controller, $action);
|
||||||
|
if ($description) {
|
||||||
|
$operation['description'] = $description;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add parameters
|
||||||
|
$parameters = $this->buildParameters($route, $controller, $action, $config);
|
||||||
|
if (! empty($parameters)) {
|
||||||
|
$operation['parameters'] = $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add request body for POST/PUT/PATCH
|
||||||
|
if (in_array($method, ['post', 'put', 'patch'])) {
|
||||||
|
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add security requirements
|
||||||
|
$security = $this->buildSecurity($route, $controller, $action);
|
||||||
|
if ($security !== null) {
|
||||||
|
$operation['security'] = $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply extensions to operation
|
||||||
|
foreach ($this->extensions as $extension) {
|
||||||
|
$operation = $extension->extendOperation($operation, $route, $method, $config);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if controller/method is hidden from docs.
|
||||||
|
*/
|
||||||
|
protected function isHidden(?object $controller, string $action): bool
|
||||||
|
{
|
||||||
|
if ($controller === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
|
||||||
|
// Check class-level attribute
|
||||||
|
$classAttrs = $reflection->getAttributes(ApiHidden::class);
|
||||||
|
if (! empty($classAttrs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check method-level attribute
|
||||||
|
if ($reflection->hasMethod($action)) {
|
||||||
|
$method = $reflection->getMethod($action);
|
||||||
|
$methodAttrs = $method->getAttributes(ApiHidden::class);
|
||||||
|
if (! empty($methodAttrs)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build operation summary.
|
||||||
|
*/
|
||||||
|
protected function buildSummary(Route $route, string $method): string
|
||||||
|
{
|
||||||
|
$name = $route->getName();
|
||||||
|
|
||||||
|
if ($name) {
|
||||||
|
// Convert route name to human-readable summary
|
||||||
|
$parts = explode('.', $name);
|
||||||
|
$action = array_pop($parts);
|
||||||
|
|
||||||
|
return Str::title(str_replace(['-', '_'], ' ', $action));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate from URI and method
|
||||||
|
$uri = Str::afterLast($route->uri(), '/');
|
||||||
|
|
||||||
|
return Str::title($method.' '.str_replace(['-', '_'], ' ', $uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build operation ID from route name.
|
||||||
|
*/
|
||||||
|
protected function buildOperationId(Route $route, string $method): string
|
||||||
|
{
|
||||||
|
$name = $route->getName();
|
||||||
|
|
||||||
|
if ($name) {
|
||||||
|
return Str::camel(str_replace(['.', '-'], '_', $name));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build tags for an operation.
|
||||||
|
*/
|
||||||
|
protected function buildOperationTags(Route $route, ?object $controller, string $action): array
|
||||||
|
{
|
||||||
|
// Check for ApiTag attribute
|
||||||
|
if ($controller !== null) {
|
||||||
|
$tagAttr = $this->getAttribute($controller, $action, ApiTag::class);
|
||||||
|
if ($tagAttr !== null) {
|
||||||
|
$tag = $tagAttr->newInstance();
|
||||||
|
$this->discoveredTags[$tag->name] = ['description' => $tag->description];
|
||||||
|
|
||||||
|
return [$tag->name];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer tag from route
|
||||||
|
return [$this->inferTag($route)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infer tag from route.
|
||||||
|
*/
|
||||||
|
protected function inferTag(Route $route): string
|
||||||
|
{
|
||||||
|
$uri = $route->uri();
|
||||||
|
$name = $route->getName() ?? '';
|
||||||
|
|
||||||
|
// Common tag mappings by route prefix
|
||||||
|
$tagMap = [
|
||||||
|
'api/bio' => 'Bio Links',
|
||||||
|
'api/blocks' => 'Bio Links',
|
||||||
|
'api/shortlinks' => 'Bio Links',
|
||||||
|
'api/qr' => 'Bio Links',
|
||||||
|
'api/commerce' => 'Commerce',
|
||||||
|
'api/provisioning' => 'Commerce',
|
||||||
|
'api/workspaces' => 'Workspaces',
|
||||||
|
'api/analytics' => 'Analytics',
|
||||||
|
'api/social' => 'Social',
|
||||||
|
'api/notify' => 'Notifications',
|
||||||
|
'api/support' => 'Support',
|
||||||
|
'api/pixel' => 'Pixel',
|
||||||
|
'api/seo' => 'SEO',
|
||||||
|
'api/mcp' => 'MCP',
|
||||||
|
'api/content' => 'Content',
|
||||||
|
'api/trust' => 'Trust',
|
||||||
|
'api/webhooks' => 'Webhooks',
|
||||||
|
'api/entitlements' => 'Entitlements',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($tagMap as $prefix => $tag) {
|
||||||
|
if (str_starts_with($uri, $prefix)) {
|
||||||
|
$this->discoveredTags[$tag] = $this->discoveredTags[$tag] ?? [];
|
||||||
|
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->discoveredTags['General'] = $this->discoveredTags['General'] ?? [];
|
||||||
|
|
||||||
|
return 'General';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract description from PHPDoc.
|
||||||
|
*/
|
||||||
|
protected function extractDescription(?object $controller, string $action): ?string
|
||||||
|
{
|
||||||
|
if ($controller === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
if (! $reflection->hasMethod($action)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = $reflection->getMethod($action);
|
||||||
|
$doc = $method->getDocComment();
|
||||||
|
|
||||||
|
if (! $doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract description from PHPDoc (first paragraph before @tags)
|
||||||
|
preg_match('/\/\*\*\s*\n\s*\*\s*(.+?)(?:\n\s*\*\s*\n|\n\s*\*\s*@)/s', $doc, $matches);
|
||||||
|
|
||||||
|
if (! empty($matches[1])) {
|
||||||
|
$description = preg_replace('/\n\s*\*\s*/', ' ', $matches[1]);
|
||||||
|
|
||||||
|
return trim($description);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build parameters for operation.
|
||||||
|
*/
|
||||||
|
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
|
||||||
|
{
|
||||||
|
$parameters = [];
|
||||||
|
|
||||||
|
// Add path parameters
|
||||||
|
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
|
||||||
|
foreach ($matches[1] as $param) {
|
||||||
|
$parameters[] = [
|
||||||
|
'name' => $param,
|
||||||
|
'in' => 'path',
|
||||||
|
'required' => true,
|
||||||
|
'schema' => ['type' => 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add parameters from ApiParameter attributes
|
||||||
|
if ($controller !== null) {
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
if ($reflection->hasMethod($action)) {
|
||||||
|
$method = $reflection->getMethod($action);
|
||||||
|
$paramAttrs = $method->getAttributes(ApiParameter::class, ReflectionAttribute::IS_INSTANCEOF);
|
||||||
|
|
||||||
|
foreach ($paramAttrs as $attr) {
|
||||||
|
$param = $attr->newInstance();
|
||||||
|
$parameters[] = $param->toOpenApi();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build responses section.
|
||||||
|
*/
|
||||||
|
protected function buildResponses(?object $controller, string $action): array
|
||||||
|
{
|
||||||
|
$responses = [];
|
||||||
|
|
||||||
|
// Get ApiResponse attributes
|
||||||
|
if ($controller !== null) {
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
if ($reflection->hasMethod($action)) {
|
||||||
|
$method = $reflection->getMethod($action);
|
||||||
|
$responseAttrs = $method->getAttributes(ApiResponse::class, ReflectionAttribute::IS_INSTANCEOF);
|
||||||
|
|
||||||
|
foreach ($responseAttrs as $attr) {
|
||||||
|
$response = $attr->newInstance();
|
||||||
|
$responses[(string) $response->status] = $this->buildResponseSchema($response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default 200 response if none specified
|
||||||
|
if (empty($responses)) {
|
||||||
|
$responses['200'] = ['description' => 'Successful response'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $responses;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build response schema from ApiResponse attribute.
|
||||||
|
*/
|
||||||
|
protected function buildResponseSchema(ApiResponse $response): array
|
||||||
|
{
|
||||||
|
$result = [
|
||||||
|
'description' => $response->getDescription(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($response->resource !== null && class_exists($response->resource)) {
|
||||||
|
$schema = $this->extractResourceSchema($response->resource);
|
||||||
|
|
||||||
|
if ($response->paginated) {
|
||||||
|
$schema = $this->wrapPaginatedSchema($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['content'] = [
|
||||||
|
'application/json' => [
|
||||||
|
'schema' => $schema,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($response->headers)) {
|
||||||
|
$result['headers'] = [];
|
||||||
|
foreach ($response->headers as $header => $description) {
|
||||||
|
$result['headers'][$header] = [
|
||||||
|
'description' => $description,
|
||||||
|
'schema' => ['type' => 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract schema from JsonResource class.
|
||||||
|
*/
|
||||||
|
protected function extractResourceSchema(string $resourceClass): array
|
||||||
|
{
|
||||||
|
if (! is_subclass_of($resourceClass, JsonResource::class)) {
|
||||||
|
return ['type' => 'object'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// For now, return a generic object schema
|
||||||
|
// A more sophisticated implementation would analyze the resource's toArray method
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'additionalProperties' => true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap schema in pagination structure.
|
||||||
|
*/
|
||||||
|
protected function wrapPaginatedSchema(array $itemSchema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'data' => [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => $itemSchema,
|
||||||
|
],
|
||||||
|
'links' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'first' => ['type' => 'string', 'format' => 'uri'],
|
||||||
|
'last' => ['type' => 'string', 'format' => 'uri'],
|
||||||
|
'prev' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
|
||||||
|
'next' => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'meta' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'current_page' => ['type' => 'integer'],
|
||||||
|
'from' => ['type' => 'integer', 'nullable' => true],
|
||||||
|
'last_page' => ['type' => 'integer'],
|
||||||
|
'per_page' => ['type' => 'integer'],
|
||||||
|
'to' => ['type' => 'integer', 'nullable' => true],
|
||||||
|
'total' => ['type' => 'integer'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build request body schema.
|
||||||
|
*/
|
||||||
|
protected function buildRequestBody(?object $controller, string $action): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'required' => true,
|
||||||
|
'content' => [
|
||||||
|
'application/json' => [
|
||||||
|
'schema' => ['type' => 'object'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build security requirements.
|
||||||
|
*/
|
||||||
|
protected function buildSecurity(Route $route, ?object $controller, string $action): ?array
|
||||||
|
{
|
||||||
|
// Check for ApiSecurity attribute
|
||||||
|
if ($controller !== null) {
|
||||||
|
$securityAttr = $this->getAttribute($controller, $action, ApiSecurity::class);
|
||||||
|
if ($securityAttr !== null) {
|
||||||
|
$security = $securityAttr->newInstance();
|
||||||
|
if ($security->isPublic()) {
|
||||||
|
return []; // Empty array means no auth required
|
||||||
|
}
|
||||||
|
|
||||||
|
return [[$security->scheme => $security->scopes]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infer from route middleware
|
||||||
|
$middleware = $route->middleware();
|
||||||
|
|
||||||
|
if (in_array('auth:sanctum', $middleware) || in_array('auth', $middleware)) {
|
||||||
|
return [['bearerAuth' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('api.auth', $middleware) || in_array('auth.api', $middleware)) {
|
||||||
|
return [['apiKeyAuth' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($middleware as $m) {
|
||||||
|
if (str_contains($m, 'ApiKeyAuth') || str_contains($m, 'AuthenticateApiKey')) {
|
||||||
|
return [['apiKeyAuth' => []]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build components section.
|
||||||
|
*/
|
||||||
|
protected function buildComponents(array $config): array
|
||||||
|
{
|
||||||
|
$components = [
|
||||||
|
'securitySchemes' => [],
|
||||||
|
'schemas' => $this->buildCommonSchemas(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add API Key security scheme
|
||||||
|
$apiKeyConfig = $config['auth']['api_key'] ?? [];
|
||||||
|
if ($apiKeyConfig['enabled'] ?? true) {
|
||||||
|
$components['securitySchemes']['apiKeyAuth'] = [
|
||||||
|
'type' => 'apiKey',
|
||||||
|
'in' => $apiKeyConfig['in'] ?? 'header',
|
||||||
|
'name' => $apiKeyConfig['name'] ?? 'X-API-Key',
|
||||||
|
'description' => $apiKeyConfig['description'] ?? 'API key for authentication',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Bearer token security scheme
|
||||||
|
$bearerConfig = $config['auth']['bearer'] ?? [];
|
||||||
|
if ($bearerConfig['enabled'] ?? true) {
|
||||||
|
$components['securitySchemes']['bearerAuth'] = [
|
||||||
|
'type' => 'http',
|
||||||
|
'scheme' => $bearerConfig['scheme'] ?? 'bearer',
|
||||||
|
'bearerFormat' => $bearerConfig['format'] ?? 'JWT',
|
||||||
|
'description' => $bearerConfig['description'] ?? 'Bearer token authentication',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add OAuth2 security scheme
|
||||||
|
$oauth2Config = $config['auth']['oauth2'] ?? [];
|
||||||
|
if ($oauth2Config['enabled'] ?? false) {
|
||||||
|
$components['securitySchemes']['oauth2'] = [
|
||||||
|
'type' => 'oauth2',
|
||||||
|
'flows' => $oauth2Config['flows'] ?? [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $components;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build common reusable schemas.
|
||||||
|
*/
|
||||||
|
protected function buildCommonSchemas(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'Error' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'required' => ['message'],
|
||||||
|
'properties' => [
|
||||||
|
'message' => ['type' => 'string', 'description' => 'Error message'],
|
||||||
|
'errors' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'description' => 'Validation errors (field => messages)',
|
||||||
|
'additionalProperties' => [
|
||||||
|
'type' => 'array',
|
||||||
|
'items' => ['type' => 'string'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'Pagination' => [
|
||||||
|
'type' => 'object',
|
||||||
|
'properties' => [
|
||||||
|
'current_page' => ['type' => 'integer'],
|
||||||
|
'from' => ['type' => 'integer', 'nullable' => true],
|
||||||
|
'last_page' => ['type' => 'integer'],
|
||||||
|
'per_page' => ['type' => 'integer'],
|
||||||
|
'to' => ['type' => 'integer', 'nullable' => true],
|
||||||
|
'total' => ['type' => 'integer'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get attribute from controller class or method.
|
||||||
|
*
|
||||||
|
* @template T
|
||||||
|
*
|
||||||
|
* @param class-string<T> $attributeClass
|
||||||
|
* @return ReflectionAttribute<T>|null
|
||||||
|
*/
|
||||||
|
protected function getAttribute(object $controller, string $action, string $attributeClass): ?ReflectionAttribute
|
||||||
|
{
|
||||||
|
$reflection = new ReflectionClass($controller);
|
||||||
|
|
||||||
|
// Check method first (method takes precedence)
|
||||||
|
if ($reflection->hasMethod($action)) {
|
||||||
|
$method = $reflection->getMethod($action);
|
||||||
|
$attrs = $method->getAttributes($attributeClass);
|
||||||
|
if (! empty($attrs)) {
|
||||||
|
return $attrs[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to class
|
||||||
|
$attrs = $reflection->getAttributes($attributeClass);
|
||||||
|
|
||||||
|
return $attrs[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/Mod/Api/Documentation/Routes/docs.php
Normal file
36
src/Mod/Api/Documentation/Routes/docs.php
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\DocumentationController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Documentation Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These routes serve the OpenAPI documentation and interactive API explorers.
|
||||||
|
| Protected by the ProtectDocumentation middleware for production environments.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Documentation UI routes
|
||||||
|
Route::get('/', [DocumentationController::class, 'index'])->name('api.docs');
|
||||||
|
Route::get('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger');
|
||||||
|
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
|
||||||
|
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
|
||||||
|
|
||||||
|
// OpenAPI specification routes
|
||||||
|
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])
|
||||||
|
->name('api.docs.openapi.json')
|
||||||
|
->middleware('throttle:60,1');
|
||||||
|
|
||||||
|
Route::get('/openapi.yaml', [DocumentationController::class, 'openApiYaml'])
|
||||||
|
->name('api.docs.openapi.yaml')
|
||||||
|
->middleware('throttle:60,1');
|
||||||
|
|
||||||
|
// Cache management (admin only)
|
||||||
|
Route::post('/cache/clear', [DocumentationController::class, 'clearCache'])
|
||||||
|
->name('api.docs.cache.clear')
|
||||||
|
->middleware('auth');
|
||||||
60
src/Mod/Api/Documentation/Views/redoc.blade.php
Normal file
60
src/Mod/Api/Documentation/Views/redoc.blade.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="API Documentation - ReDoc">
|
||||||
|
<title>{{ config('api-docs.info.title', 'API Documentation') }} - ReDoc</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom ReDoc theme overrides */
|
||||||
|
.redoc-wrap {
|
||||||
|
--primary-color: #3b82f6;
|
||||||
|
--primary-color-dark: #2563eb;
|
||||||
|
--selection-color: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<redoc spec-url="{{ $specUrl }}"
|
||||||
|
expand-responses="200,201"
|
||||||
|
path-in-middle-panel
|
||||||
|
hide-hostname
|
||||||
|
hide-download-button
|
||||||
|
required-props-first
|
||||||
|
sort-props-alphabetically
|
||||||
|
no-auto-auth
|
||||||
|
theme='{
|
||||||
|
"colors": {
|
||||||
|
"primary": { "main": "#3b82f6" }
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif",
|
||||||
|
"headings": { "fontFamily": "Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif" },
|
||||||
|
"code": { "fontFamily": "\"JetBrains Mono\", \"Fira Code\", Consolas, monospace" }
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"width": "280px"
|
||||||
|
},
|
||||||
|
"rightPanel": {
|
||||||
|
"backgroundColor": "#1e293b"
|
||||||
|
}
|
||||||
|
}'>
|
||||||
|
</redoc>
|
||||||
|
|
||||||
|
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
src/Mod/Api/Documentation/Views/scalar.blade.php
Normal file
28
src/Mod/Api/Documentation/Views/scalar.blade.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="API Documentation - Scalar">
|
||||||
|
<title>{{ config('api-docs.info.title', 'API Documentation') }}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script
|
||||||
|
id="api-reference"
|
||||||
|
data-url="{{ $specUrl }}"
|
||||||
|
data-configuration='{
|
||||||
|
"theme": "{{ $config['theme'] ?? 'default' }}",
|
||||||
|
"showSidebar": {{ ($config['show_sidebar'] ?? true) ? 'true' : 'false' }},
|
||||||
|
"hideDownloadButton": {{ ($config['hide_download_button'] ?? false) ? 'true' : 'false' }},
|
||||||
|
"hideModels": {{ ($config['hide_models'] ?? false) ? 'true' : 'false' }},
|
||||||
|
"darkMode": false,
|
||||||
|
"layout": "modern",
|
||||||
|
"searchHotKey": "k"
|
||||||
|
}'
|
||||||
|
></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
src/Mod/Api/Documentation/Views/swagger.blade.php
Normal file
65
src/Mod/Api/Documentation/Views/swagger.blade.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="API Documentation - Swagger UI">
|
||||||
|
<title>{{ config('api-docs.info.title', 'API Documentation') }} - Swagger UI</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
html { box-sizing: border-box; }
|
||||||
|
*, *:before, *:after { box-sizing: inherit; }
|
||||||
|
body { margin: 0; background: #fafafa; }
|
||||||
|
.swagger-ui .topbar { display: none; }
|
||||||
|
.swagger-ui .info { margin: 20px 0; }
|
||||||
|
.swagger-ui .info .title { font-size: 28px; }
|
||||||
|
.swagger-ui .scheme-container { background: transparent; box-shadow: none; padding: 0; }
|
||||||
|
.swagger-ui .opblock-tag { font-size: 18px; }
|
||||||
|
.swagger-ui .opblock .opblock-summary-operation-id { font-size: 13px; }
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body { background: #1a1a2e; }
|
||||||
|
.swagger-ui { filter: invert(88%) hue-rotate(180deg); }
|
||||||
|
.swagger-ui .opblock-body pre { filter: invert(100%) hue-rotate(180deg); }
|
||||||
|
.swagger-ui img { filter: invert(100%) hue-rotate(180deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: @json($specUrl),
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
defaultModelsExpandDepth: -1,
|
||||||
|
docExpansion: @json($config['doc_expansion'] ?? 'none'),
|
||||||
|
filter: @json($config['filter'] ?? true),
|
||||||
|
showExtensions: @json($config['show_extensions'] ?? true),
|
||||||
|
showCommonExtensions: @json($config['show_common_extensions'] ?? true),
|
||||||
|
syntaxHighlight: {
|
||||||
|
activated: true,
|
||||||
|
theme: "monokai"
|
||||||
|
},
|
||||||
|
requestInterceptor: function(request) {
|
||||||
|
// Add any default headers here
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
319
src/Mod/Api/Documentation/config.php
Normal file
319
src/Mod/Api/Documentation/config.php
Normal file
|
|
@ -0,0 +1,319 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Documentation Configuration
|
||||||
|
*
|
||||||
|
* Configuration for OpenAPI/Swagger documentation powered by Scramble.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Documentation Enabled
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Enable or disable API documentation. When disabled, the /api/docs
|
||||||
|
| endpoint will return 404.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enabled' => env('API_DOCS_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Documentation Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The URL path where API documentation is served.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => '/api/docs',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Information
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Basic information about your API displayed in the documentation.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'info' => [
|
||||||
|
'title' => env('API_DOCS_TITLE', 'API Documentation'),
|
||||||
|
'description' => env('API_DOCS_DESCRIPTION', 'REST API for programmatic access to services.'),
|
||||||
|
'version' => env('API_DOCS_VERSION', '1.0.0'),
|
||||||
|
'contact' => [
|
||||||
|
'name' => env('API_DOCS_CONTACT_NAME'),
|
||||||
|
'email' => env('API_DOCS_CONTACT_EMAIL'),
|
||||||
|
'url' => env('API_DOCS_CONTACT_URL'),
|
||||||
|
],
|
||||||
|
'license' => [
|
||||||
|
'name' => env('API_DOCS_LICENSE_NAME', 'Proprietary'),
|
||||||
|
'url' => env('API_DOCS_LICENSE_URL'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Servers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| List of API servers displayed in the documentation.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'servers' => [
|
||||||
|
[
|
||||||
|
'url' => env('APP_URL', 'http://localhost'),
|
||||||
|
'description' => 'Current Environment',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Authentication Schemes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure how authentication is documented in OpenAPI.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'auth' => [
|
||||||
|
// API Key authentication via header
|
||||||
|
'api_key' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'name' => 'X-API-Key',
|
||||||
|
'in' => 'header',
|
||||||
|
'description' => 'API key for authentication. Create keys in your workspace settings.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Bearer token authentication
|
||||||
|
'bearer' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'scheme' => 'bearer',
|
||||||
|
'format' => 'JWT',
|
||||||
|
'description' => 'Bearer token authentication for user sessions.',
|
||||||
|
],
|
||||||
|
|
||||||
|
// OAuth2 (if applicable)
|
||||||
|
'oauth2' => [
|
||||||
|
'enabled' => false,
|
||||||
|
'flows' => [
|
||||||
|
'authorizationCode' => [
|
||||||
|
'authorizationUrl' => '/oauth/authorize',
|
||||||
|
'tokenUrl' => '/oauth/token',
|
||||||
|
'refreshUrl' => '/oauth/token',
|
||||||
|
'scopes' => [
|
||||||
|
'read' => 'Read access to resources',
|
||||||
|
'write' => 'Write access to resources',
|
||||||
|
'delete' => 'Delete access to resources',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Workspace Header
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure the workspace header documentation.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'workspace' => [
|
||||||
|
'header_name' => 'X-Workspace-ID',
|
||||||
|
'required' => false,
|
||||||
|
'description' => 'Optional workspace identifier for multi-tenant operations. If not provided, the default workspace associated with the API key will be used.',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting Documentation
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure how rate limits are documented in responses.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'rate_limits' => [
|
||||||
|
'enabled' => true,
|
||||||
|
'headers' => [
|
||||||
|
'X-RateLimit-Limit' => 'Maximum number of requests allowed per window',
|
||||||
|
'X-RateLimit-Remaining' => 'Number of requests remaining in the current window',
|
||||||
|
'X-RateLimit-Reset' => 'Unix timestamp when the rate limit window resets',
|
||||||
|
'Retry-After' => 'Seconds to wait before retrying (only on 429 responses)',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Module Tags
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Map module namespaces to documentation tags for grouping endpoints.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'tags' => [
|
||||||
|
// Module namespace => Tag configuration
|
||||||
|
'Bio' => [
|
||||||
|
'name' => 'Bio Links',
|
||||||
|
'description' => 'Bio link pages, blocks, and customization',
|
||||||
|
],
|
||||||
|
'Commerce' => [
|
||||||
|
'name' => 'Commerce',
|
||||||
|
'description' => 'Billing, subscriptions, orders, and invoices',
|
||||||
|
],
|
||||||
|
'Analytics' => [
|
||||||
|
'name' => 'Analytics',
|
||||||
|
'description' => 'Website and link analytics tracking',
|
||||||
|
],
|
||||||
|
'Social' => [
|
||||||
|
'name' => 'Social',
|
||||||
|
'description' => 'Social media management and scheduling',
|
||||||
|
],
|
||||||
|
'Notify' => [
|
||||||
|
'name' => 'Notifications',
|
||||||
|
'description' => 'Push notifications and alerts',
|
||||||
|
],
|
||||||
|
'Support' => [
|
||||||
|
'name' => 'Support',
|
||||||
|
'description' => 'Helpdesk and customer support',
|
||||||
|
],
|
||||||
|
'Tenant' => [
|
||||||
|
'name' => 'Workspaces',
|
||||||
|
'description' => 'Workspace and team management',
|
||||||
|
],
|
||||||
|
'Pixel' => [
|
||||||
|
'name' => 'Pixel',
|
||||||
|
'description' => 'Unified tracking pixel endpoints',
|
||||||
|
],
|
||||||
|
'SEO' => [
|
||||||
|
'name' => 'SEO',
|
||||||
|
'description' => 'SEO analysis and reporting',
|
||||||
|
],
|
||||||
|
'MCP' => [
|
||||||
|
'name' => 'MCP',
|
||||||
|
'description' => 'Model Context Protocol HTTP bridge',
|
||||||
|
],
|
||||||
|
'Content' => [
|
||||||
|
'name' => 'Content',
|
||||||
|
'description' => 'AI content generation',
|
||||||
|
],
|
||||||
|
'Trust' => [
|
||||||
|
'name' => 'Trust',
|
||||||
|
'description' => 'Social proof and testimonials',
|
||||||
|
],
|
||||||
|
'Webhooks' => [
|
||||||
|
'name' => 'Webhooks',
|
||||||
|
'description' => 'Webhook endpoints and management',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Route Filtering
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure which routes are included in the documentation.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'routes' => [
|
||||||
|
// Only include routes matching these patterns
|
||||||
|
'include' => [
|
||||||
|
'api/*',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Exclude routes matching these patterns
|
||||||
|
'exclude' => [
|
||||||
|
'api/sanctum/*',
|
||||||
|
'api/telescope/*',
|
||||||
|
'api/horizon/*',
|
||||||
|
],
|
||||||
|
|
||||||
|
// Hide internal/admin routes from public docs
|
||||||
|
'hide_internal' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Documentation UI
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure the documentation UI appearance.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'ui' => [
|
||||||
|
// Default UI renderer: 'swagger', 'scalar', 'redoc', 'stoplight'
|
||||||
|
'default' => 'scalar',
|
||||||
|
|
||||||
|
// Swagger UI specific options
|
||||||
|
'swagger' => [
|
||||||
|
'doc_expansion' => 'none', // 'list', 'full', 'none'
|
||||||
|
'filter' => true,
|
||||||
|
'show_extensions' => true,
|
||||||
|
'show_common_extensions' => true,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Scalar specific options
|
||||||
|
'scalar' => [
|
||||||
|
'theme' => 'default', // 'default', 'alternate', 'moon', 'purple', 'solarized'
|
||||||
|
'show_sidebar' => true,
|
||||||
|
'hide_download_button' => false,
|
||||||
|
'hide_models' => false,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Access Control
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure who can access the documentation.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'access' => [
|
||||||
|
// Require authentication to view docs
|
||||||
|
'require_auth' => env('API_DOCS_REQUIRE_AUTH', false),
|
||||||
|
|
||||||
|
// Only allow these roles to view docs (empty = all authenticated users)
|
||||||
|
'allowed_roles' => [],
|
||||||
|
|
||||||
|
// Allow unauthenticated access in these environments
|
||||||
|
'public_environments' => ['local', 'testing', 'staging'],
|
||||||
|
|
||||||
|
// IP whitelist for production (empty = no restriction)
|
||||||
|
'ip_whitelist' => [],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Caching
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure documentation caching.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cache' => [
|
||||||
|
// Enable caching of generated OpenAPI spec
|
||||||
|
'enabled' => env('API_DOCS_CACHE_ENABLED', true),
|
||||||
|
|
||||||
|
// Cache key prefix
|
||||||
|
'key' => 'api-docs:openapi',
|
||||||
|
|
||||||
|
// Cache duration in seconds (1 hour default)
|
||||||
|
'ttl' => env('API_DOCS_CACHE_TTL', 3600),
|
||||||
|
|
||||||
|
// Disable cache in these environments
|
||||||
|
'disabled_environments' => ['local', 'testing'],
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
56
src/Mod/Api/Exceptions/RateLimitExceededException.php
Normal file
56
src/Mod/Api/Exceptions/RateLimitExceededException.php
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Exceptions;
|
||||||
|
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimitResult;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exception thrown when API rate limit is exceeded.
|
||||||
|
*
|
||||||
|
* Renders as a proper JSON response with rate limit headers.
|
||||||
|
*/
|
||||||
|
class RateLimitExceededException extends HttpException
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected RateLimitResult $rateLimitResult,
|
||||||
|
string $message = 'Too many requests. Please slow down.',
|
||||||
|
) {
|
||||||
|
parent::__construct(429, $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the rate limit result.
|
||||||
|
*/
|
||||||
|
public function getRateLimitResult(): RateLimitResult
|
||||||
|
{
|
||||||
|
return $this->rateLimitResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the exception as a JSON response.
|
||||||
|
*/
|
||||||
|
public function render(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'rate_limit_exceeded',
|
||||||
|
'message' => $this->getMessage(),
|
||||||
|
'retry_after' => $this->rateLimitResult->retryAfter,
|
||||||
|
'limit' => $this->rateLimitResult->limit,
|
||||||
|
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
|
||||||
|
], 429, $this->rateLimitResult->headers());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get headers for the response.
|
||||||
|
*
|
||||||
|
* @return array<string, string|int>
|
||||||
|
*/
|
||||||
|
public function getHeaders(): array
|
||||||
|
{
|
||||||
|
return array_map(fn ($value) => (string) $value, $this->rateLimitResult->headers());
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/Mod/Api/Guards/AccessTokenGuard.php
Normal file
98
src/Mod/Api/Guards/AccessTokenGuard.php
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Guards;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
use Core\Mod\Tenant\Models\UserToken;
|
||||||
|
use Illuminate\Contracts\Auth\Factory;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom authentication guard for API token-based authentication.
|
||||||
|
*
|
||||||
|
* This guard authenticates users via Bearer tokens sent in the Authorization header.
|
||||||
|
* It's designed to work with Laravel's auth middleware system and provides
|
||||||
|
* stateful API authentication using long-lived personal access tokens.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Route::middleware('auth:access_token')->group(function () {
|
||||||
|
* // Protected API routes
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
class AccessTokenGuard
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The authentication factory instance.
|
||||||
|
*/
|
||||||
|
protected Factory $auth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new guard instance.
|
||||||
|
*/
|
||||||
|
public function __construct(Factory $auth)
|
||||||
|
{
|
||||||
|
$this->auth = $auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the authentication for the incoming request.
|
||||||
|
*
|
||||||
|
* This method is called by Laravel's authentication system when using
|
||||||
|
* the guard. It attempts to authenticate the request using the Bearer
|
||||||
|
* token and returns the authenticated user if successful.
|
||||||
|
*
|
||||||
|
* @return User|null The authenticated user or null if authentication fails
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): ?User
|
||||||
|
{
|
||||||
|
$token = $this->getTokenFromRequest($request);
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$accessToken = UserToken::findToken($token);
|
||||||
|
|
||||||
|
if (! $this->isValidAccessToken($accessToken)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last used timestamp
|
||||||
|
$accessToken->recordUsage();
|
||||||
|
|
||||||
|
return $accessToken->user;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the Bearer token from the request.
|
||||||
|
*
|
||||||
|
* Looks for the token in the Authorization header in the format:
|
||||||
|
* Authorization: Bearer {token}
|
||||||
|
*
|
||||||
|
* @return string|null The extracted token or null if not found
|
||||||
|
*/
|
||||||
|
protected function getTokenFromRequest(Request $request): ?string
|
||||||
|
{
|
||||||
|
$token = $request->bearerToken();
|
||||||
|
|
||||||
|
return ! empty($token) ? $token : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the access token.
|
||||||
|
*
|
||||||
|
* Checks if the token exists and hasn't expired.
|
||||||
|
*
|
||||||
|
* @return bool True if the token is valid, false otherwise
|
||||||
|
*/
|
||||||
|
protected function isValidAccessToken(?UserToken $accessToken): bool
|
||||||
|
{
|
||||||
|
if (! $accessToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $accessToken->isValid();
|
||||||
|
}
|
||||||
|
}
|
||||||
182
src/Mod/Api/Jobs/DeliverWebhookJob.php
Normal file
182
src/Mod/Api/Jobs/DeliverWebhookJob.php
Normal file
|
|
@ -0,0 +1,182 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Jobs;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Models\WebhookDelivery;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delivers webhook payloads to registered endpoints.
|
||||||
|
*
|
||||||
|
* Implements exponential backoff retry logic:
|
||||||
|
* - Attempt 1: Immediate
|
||||||
|
* - Attempt 2: 1 minute delay
|
||||||
|
* - Attempt 3: 5 minutes delay
|
||||||
|
* - Attempt 4: 30 minutes delay
|
||||||
|
* - Attempt 5: 2 hours delay
|
||||||
|
* - Attempt 6 (final): 24 hours delay
|
||||||
|
*/
|
||||||
|
class DeliverWebhookJob implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete the job if its models no longer exist.
|
||||||
|
*/
|
||||||
|
public bool $deleteWhenMissingModels = true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
* We handle retries manually with exponential backoff.
|
||||||
|
*/
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public WebhookDelivery $delivery
|
||||||
|
) {
|
||||||
|
// Use dedicated webhook queue if configured
|
||||||
|
$this->queue = config('api.webhooks.queue', 'default');
|
||||||
|
|
||||||
|
$connection = config('api.webhooks.queue_connection');
|
||||||
|
if ($connection) {
|
||||||
|
$this->connection = $connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
// Don't deliver if endpoint is disabled
|
||||||
|
$endpoint = $this->delivery->endpoint;
|
||||||
|
if (! $endpoint || ! $endpoint->shouldReceive($this->delivery->event_type)) {
|
||||||
|
Log::info('Webhook delivery skipped - endpoint inactive or does not receive this event', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'event_type' => $this->delivery->event_type,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get delivery payload with signature headers
|
||||||
|
$deliveryPayload = $this->delivery->getDeliveryPayload();
|
||||||
|
$timeout = config('api.webhooks.timeout', 30);
|
||||||
|
|
||||||
|
Log::info('Attempting webhook delivery', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'endpoint_url' => $endpoint->url,
|
||||||
|
'event_type' => $this->delivery->event_type,
|
||||||
|
'attempt' => $this->delivery->attempt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = Http::timeout($timeout)
|
||||||
|
->withHeaders($deliveryPayload['headers'])
|
||||||
|
->withBody($deliveryPayload['body'], 'application/json')
|
||||||
|
->post($endpoint->url);
|
||||||
|
|
||||||
|
$statusCode = $response->status();
|
||||||
|
$responseBody = $response->body();
|
||||||
|
|
||||||
|
// Success is any 2xx status code
|
||||||
|
if ($response->successful()) {
|
||||||
|
$this->delivery->markSuccess($statusCode, $responseBody);
|
||||||
|
|
||||||
|
Log::info('Webhook delivered successfully', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'status_code' => $statusCode,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-2xx response - mark as failed and potentially retry
|
||||||
|
$this->handleFailure($statusCode, $responseBody);
|
||||||
|
|
||||||
|
} catch (\Illuminate\Http\Client\ConnectionException $e) {
|
||||||
|
// Connection timeout or refused
|
||||||
|
$this->handleFailure(0, 'Connection failed: '.$e->getMessage());
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Unexpected error
|
||||||
|
$this->handleFailure(0, 'Unexpected error: '.$e->getMessage());
|
||||||
|
|
||||||
|
Log::error('Webhook delivery unexpected error', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'trace' => $e->getTraceAsString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a failed delivery attempt.
|
||||||
|
*/
|
||||||
|
protected function handleFailure(int $statusCode, ?string $responseBody): void
|
||||||
|
{
|
||||||
|
Log::warning('Webhook delivery failed', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'attempt' => $this->delivery->attempt,
|
||||||
|
'status_code' => $statusCode,
|
||||||
|
'can_retry' => $this->delivery->canRetry(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Mark as failed (this also schedules retry if attempts remain)
|
||||||
|
$this->delivery->markFailed($statusCode, $responseBody);
|
||||||
|
|
||||||
|
// If we can retry, dispatch a new job with the appropriate delay
|
||||||
|
if ($this->delivery->canRetry() && $this->delivery->next_retry_at) {
|
||||||
|
$delay = $this->delivery->next_retry_at->diffInSeconds(now());
|
||||||
|
|
||||||
|
Log::info('Scheduling webhook retry', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'next_attempt' => $this->delivery->attempt,
|
||||||
|
'delay_seconds' => $delay,
|
||||||
|
'next_retry_at' => $this->delivery->next_retry_at->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Dispatch retry with calculated delay
|
||||||
|
self::dispatch($this->delivery->fresh())->delay($delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a job failure.
|
||||||
|
*/
|
||||||
|
public function failed(\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('Webhook delivery job failed completely', [
|
||||||
|
'delivery_id' => $this->delivery->id,
|
||||||
|
'error' => $exception->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the tags for the job.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function tags(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'webhook',
|
||||||
|
'webhook:'.$this->delivery->webhook_endpoint_id,
|
||||||
|
'event:'.$this->delivery->event_type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/Mod/Api/Middleware/AuthenticateApiKey.php
Normal file
125
src/Mod/Api/Middleware/AuthenticateApiKey.php
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Middleware;
|
||||||
|
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate requests using API keys or fall back to Sanctum.
|
||||||
|
*
|
||||||
|
* API keys are prefixed with 'hk_' and scoped to a workspace.
|
||||||
|
*
|
||||||
|
* Register in bootstrap/app.php:
|
||||||
|
* ->withMiddleware(function (Middleware $middleware) {
|
||||||
|
* $middleware->alias([
|
||||||
|
* 'auth.api' => \App\Http\Middleware\Api\AuthenticateApiKey::class,
|
||||||
|
* ]);
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
class AuthenticateApiKey
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next, ?string $scope = null): Response
|
||||||
|
{
|
||||||
|
$token = $request->bearerToken();
|
||||||
|
|
||||||
|
if (! $token) {
|
||||||
|
return $this->unauthorized('API key required. Use Authorization: Bearer <api_key>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an API key (prefixed with hk_)
|
||||||
|
if (str_starts_with($token, 'hk_')) {
|
||||||
|
return $this->authenticateApiKey($request, $next, $token, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to Sanctum for OAuth tokens
|
||||||
|
return $this->authenticateSanctum($request, $next, $scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate using an API key.
|
||||||
|
*/
|
||||||
|
protected function authenticateApiKey(
|
||||||
|
Request $request,
|
||||||
|
Closure $next,
|
||||||
|
string $token,
|
||||||
|
?string $scope
|
||||||
|
): Response {
|
||||||
|
$apiKey = ApiKey::findByPlainKey($token);
|
||||||
|
|
||||||
|
if (! $apiKey) {
|
||||||
|
return $this->unauthorized('Invalid API key');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($apiKey->isExpired()) {
|
||||||
|
return $this->unauthorized('API key has expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check scope if required
|
||||||
|
if ($scope !== null && ! $apiKey->hasScope($scope)) {
|
||||||
|
return $this->forbidden("API key missing required scope: {$scope}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record usage (non-blocking)
|
||||||
|
$apiKey->recordUsage();
|
||||||
|
|
||||||
|
// Set request context
|
||||||
|
$request->setUserResolver(fn () => $apiKey->user);
|
||||||
|
$request->attributes->set('api_key', $apiKey);
|
||||||
|
$request->attributes->set('workspace', $apiKey->workspace);
|
||||||
|
$request->attributes->set('workspace_id', $apiKey->workspace_id);
|
||||||
|
$request->attributes->set('auth_type', 'api_key');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fall back to Sanctum authentication for OAuth tokens.
|
||||||
|
*/
|
||||||
|
protected function authenticateSanctum(
|
||||||
|
Request $request,
|
||||||
|
Closure $next,
|
||||||
|
?string $scope
|
||||||
|
): Response {
|
||||||
|
// For API requests, use token authentication
|
||||||
|
if (! $request->user()) {
|
||||||
|
// Try to authenticate via Sanctum token
|
||||||
|
$guard = auth('sanctum');
|
||||||
|
if (! $guard->check()) {
|
||||||
|
return $this->unauthorized('Invalid authentication token');
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->setUserResolver(fn () => $guard->user());
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->attributes->set('auth_type', 'sanctum');
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return 401 Unauthorized response.
|
||||||
|
*/
|
||||||
|
protected function unauthorized(string $message): Response
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'unauthorized',
|
||||||
|
'message' => $message,
|
||||||
|
], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return 403 Forbidden response.
|
||||||
|
*/
|
||||||
|
protected function forbidden(string $message): Response
|
||||||
|
{
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'forbidden',
|
||||||
|
'message' => $message,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/Mod/Api/Middleware/CheckApiScope.php
Normal file
52
src/Mod/Api/Middleware/CheckApiScope.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Middleware;
|
||||||
|
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check that the API key has required scopes for the request.
|
||||||
|
*
|
||||||
|
* Usage in routes:
|
||||||
|
* Route::middleware(['auth.api', 'api.scope:write'])->post('/resource', ...);
|
||||||
|
* Route::middleware(['auth.api', 'api.scope:read,write'])->put('/resource', ...);
|
||||||
|
*
|
||||||
|
* Register in bootstrap/app.php:
|
||||||
|
* ->withMiddleware(function (Middleware $middleware) {
|
||||||
|
* $middleware->alias([
|
||||||
|
* 'api.scope' => \App\Http\Middleware\Api\CheckApiScope::class,
|
||||||
|
* ]);
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
class CheckApiScope
|
||||||
|
{
|
||||||
|
public function handle(Request $request, Closure $next, string ...$scopes): Response
|
||||||
|
{
|
||||||
|
$apiKey = $request->attributes->get('api_key');
|
||||||
|
|
||||||
|
// If not authenticated via API key, allow through
|
||||||
|
// (Sanctum auth handles its own scopes)
|
||||||
|
if (! $apiKey instanceof ApiKey) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check all required scopes
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
if (! $apiKey->hasScope($scope)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'forbidden',
|
||||||
|
'message' => "API key missing required scope: {$scope}",
|
||||||
|
'required_scopes' => $scopes,
|
||||||
|
'key_scopes' => $apiKey->scopes,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/Mod/Api/Middleware/EnforceApiScope.php
Normal file
65
src/Mod/Api/Middleware/EnforceApiScope.php
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Core\Mod\Api\Models\ApiKey;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Automatically enforce API key scopes based on HTTP method.
|
||||||
|
*
|
||||||
|
* Scope mapping:
|
||||||
|
* - GET, HEAD, OPTIONS -> read
|
||||||
|
* - POST, PUT, PATCH -> write
|
||||||
|
* - DELETE -> delete
|
||||||
|
*
|
||||||
|
* Usage: Add to routes alongside api.auth middleware.
|
||||||
|
* Route::middleware(['api.auth', 'api.scope.enforce'])->group(...)
|
||||||
|
*
|
||||||
|
* For routes that need to override the auto-detection, use CheckApiScope:
|
||||||
|
* Route::middleware(['api.auth', 'api.scope:read'])->post('/readonly-action', ...)
|
||||||
|
*/
|
||||||
|
class EnforceApiScope
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* HTTP method to required scope mapping.
|
||||||
|
*/
|
||||||
|
protected const METHOD_SCOPES = [
|
||||||
|
'GET' => ApiKey::SCOPE_READ,
|
||||||
|
'HEAD' => ApiKey::SCOPE_READ,
|
||||||
|
'OPTIONS' => ApiKey::SCOPE_READ,
|
||||||
|
'POST' => ApiKey::SCOPE_WRITE,
|
||||||
|
'PUT' => ApiKey::SCOPE_WRITE,
|
||||||
|
'PATCH' => ApiKey::SCOPE_WRITE,
|
||||||
|
'DELETE' => ApiKey::SCOPE_DELETE,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$apiKey = $request->attributes->get('api_key');
|
||||||
|
|
||||||
|
// If not authenticated via API key, allow through
|
||||||
|
// Session auth and Sanctum handle their own permissions
|
||||||
|
if (! $apiKey instanceof ApiKey) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$method = strtoupper($request->method());
|
||||||
|
$requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ;
|
||||||
|
|
||||||
|
if (! $apiKey->hasScope($requiredScope)) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'forbidden',
|
||||||
|
'message' => "API key missing required scope: {$requiredScope}",
|
||||||
|
'detail' => "{$method} requests require '{$requiredScope}' scope",
|
||||||
|
'key_scopes' => $apiKey->scopes,
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Mod/Api/Middleware/PublicApiCors.php
Normal file
64
src/Mod/Api/Middleware/PublicApiCors.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS middleware for public API endpoints.
|
||||||
|
*
|
||||||
|
* Public endpoints like the unified pixel need to be accessible from any
|
||||||
|
* customer website, so we allow all origins. These endpoints are rate-limited
|
||||||
|
* and do not expose sensitive data.
|
||||||
|
*/
|
||||||
|
class PublicApiCors
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Handle preflight OPTIONS request
|
||||||
|
if ($request->isMethod('OPTIONS')) {
|
||||||
|
return $this->buildPreflightResponse($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
return $this->addCorsHeaders($response, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build preflight response for OPTIONS requests.
|
||||||
|
*/
|
||||||
|
protected function buildPreflightResponse(Request $request): Response
|
||||||
|
{
|
||||||
|
$response = response('', 204);
|
||||||
|
|
||||||
|
return $this->addCorsHeaders($response, $request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add CORS headers to response.
|
||||||
|
*/
|
||||||
|
protected function addCorsHeaders(Response $response, Request $request): Response
|
||||||
|
{
|
||||||
|
$origin = $request->header('Origin', '*');
|
||||||
|
|
||||||
|
// Allow any origin for public widget/pixel endpoints
|
||||||
|
$response->headers->set('Access-Control-Allow-Origin', $origin);
|
||||||
|
$response->headers->set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||||
|
$response->headers->set('Access-Control-Allow-Headers', 'Content-Type, Accept, X-Requested-With');
|
||||||
|
$response->headers->set('Access-Control-Expose-Headers', 'X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After');
|
||||||
|
$response->headers->set('Access-Control-Max-Age', '3600');
|
||||||
|
|
||||||
|
// Vary on Origin for proper caching
|
||||||
|
$response->headers->set('Vary', 'Origin');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
352
src/Mod/Api/Middleware/RateLimitApi.php
Normal file
352
src/Mod/Api/Middleware/RateLimitApi.php
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Core\Mod\Api\Exceptions\RateLimitExceededException;
|
||||||
|
use Core\Mod\Api\Models\ApiKey;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimit;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimitResult;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimitService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use ReflectionClass;
|
||||||
|
use ReflectionMethod;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit API requests with granular control.
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - Per-endpoint rate limits via config or #[RateLimit] attribute
|
||||||
|
* - Per-workspace rate limits with workspace ID in key
|
||||||
|
* - Per-API key rate limits
|
||||||
|
* - Tier-based limits based on workspace subscription
|
||||||
|
* - Burst allowance configuration
|
||||||
|
* - Standard rate limit headers (X-RateLimit-*)
|
||||||
|
*
|
||||||
|
* Priority (highest to lowest):
|
||||||
|
* 1. Method-level #[RateLimit] attribute
|
||||||
|
* 2. Class-level #[RateLimit] attribute
|
||||||
|
* 3. Per-endpoint config (api.rate_limits.endpoints.{route_name})
|
||||||
|
* 4. Tier-based limits (api.rate_limits.tiers.{tier})
|
||||||
|
* 5. Default authenticated limits
|
||||||
|
* 6. Default unauthenticated limits
|
||||||
|
*
|
||||||
|
* Register in bootstrap/app.php:
|
||||||
|
* ->withMiddleware(function (Middleware $middleware) {
|
||||||
|
* $middleware->alias([
|
||||||
|
* 'api.rate' => \Core\Mod\Api\Middleware\RateLimitApi::class,
|
||||||
|
* ]);
|
||||||
|
* })
|
||||||
|
*/
|
||||||
|
class RateLimitApi
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected RateLimitService $rateLimitService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Check if rate limiting is enabled
|
||||||
|
if (! config('api.rate_limits.enabled', true)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rateLimitConfig = $this->resolveRateLimitConfig($request);
|
||||||
|
$key = $this->resolveRateLimitKey($request, $rateLimitConfig);
|
||||||
|
|
||||||
|
// Perform rate limit check and hit
|
||||||
|
$result = $this->rateLimitService->hit(
|
||||||
|
key: $key,
|
||||||
|
limit: $rateLimitConfig['limit'],
|
||||||
|
window: $rateLimitConfig['window'],
|
||||||
|
burst: $rateLimitConfig['burst'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $result->allowed) {
|
||||||
|
throw new RateLimitExceededException($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
return $this->addRateLimitHeaders($response, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the rate limit configuration for the request.
|
||||||
|
*
|
||||||
|
* @return array{limit: int, window: int, burst: float, key: string|null}
|
||||||
|
*/
|
||||||
|
protected function resolveRateLimitConfig(Request $request): array
|
||||||
|
{
|
||||||
|
$defaults = config('api.rate_limits.default', [
|
||||||
|
'limit' => 60,
|
||||||
|
'window' => 60,
|
||||||
|
'burst' => 1.0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1. Check for #[RateLimit] attribute on controller/method
|
||||||
|
$attributeConfig = $this->getAttributeRateLimit($request);
|
||||||
|
if ($attributeConfig !== null) {
|
||||||
|
return array_merge($defaults, $attributeConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check for per-endpoint config
|
||||||
|
$endpointConfig = $this->getEndpointRateLimit($request);
|
||||||
|
if ($endpointConfig !== null) {
|
||||||
|
return array_merge($defaults, $endpointConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check for tier-based limits
|
||||||
|
$tierConfig = $this->getTierRateLimit($request);
|
||||||
|
if ($tierConfig !== null) {
|
||||||
|
return array_merge($defaults, $tierConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Use authenticated limits if authenticated
|
||||||
|
if ($this->isAuthenticated($request)) {
|
||||||
|
$authenticated = config('api.rate_limits.authenticated', $defaults);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $authenticated['requests'] ?? $authenticated['limit'] ?? $defaults['limit'],
|
||||||
|
'window' => ($authenticated['per_minutes'] ?? 1) * 60,
|
||||||
|
'burst' => $authenticated['burst'] ?? $defaults['burst'] ?? 1.0,
|
||||||
|
'key' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Use default limits
|
||||||
|
return [
|
||||||
|
'limit' => $defaults['requests'] ?? $defaults['limit'] ?? 60,
|
||||||
|
'window' => ($defaults['per_minutes'] ?? 1) * 60,
|
||||||
|
'burst' => $defaults['burst'] ?? 1.0,
|
||||||
|
'key' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit from #[RateLimit] attribute.
|
||||||
|
*
|
||||||
|
* @return array{limit: int, window: int, burst: float, key: string|null}|null
|
||||||
|
*/
|
||||||
|
protected function getAttributeRateLimit(Request $request): ?array
|
||||||
|
{
|
||||||
|
$route = $request->route();
|
||||||
|
if (! $route) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$controller = $route->getController();
|
||||||
|
$method = $route->getActionMethod();
|
||||||
|
|
||||||
|
if (! $controller || ! $method) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check method-level attribute first
|
||||||
|
$reflection = new ReflectionMethod($controller, $method);
|
||||||
|
$attributes = $reflection->getAttributes(RateLimit::class);
|
||||||
|
|
||||||
|
if (! empty($attributes)) {
|
||||||
|
/** @var RateLimit $rateLimit */
|
||||||
|
$rateLimit = $attributes[0]->newInstance();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $rateLimit->limit,
|
||||||
|
'window' => $rateLimit->window,
|
||||||
|
'burst' => $rateLimit->burst,
|
||||||
|
'key' => $rateLimit->key,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check class-level attribute
|
||||||
|
$classReflection = new ReflectionClass($controller);
|
||||||
|
$classAttributes = $classReflection->getAttributes(RateLimit::class);
|
||||||
|
|
||||||
|
if (! empty($classAttributes)) {
|
||||||
|
/** @var RateLimit $rateLimit */
|
||||||
|
$rateLimit = $classAttributes[0]->newInstance();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $rateLimit->limit,
|
||||||
|
'window' => $rateLimit->window,
|
||||||
|
'burst' => $rateLimit->burst,
|
||||||
|
'key' => $rateLimit->key,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
} catch (\ReflectionException) {
|
||||||
|
// Controller or method doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rate limit from per-endpoint config.
|
||||||
|
*
|
||||||
|
* @return array{limit: int, window: int, burst: float, key: string|null}|null
|
||||||
|
*/
|
||||||
|
protected function getEndpointRateLimit(Request $request): ?array
|
||||||
|
{
|
||||||
|
$route = $request->route();
|
||||||
|
if (! $route) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$routeName = $route->getName();
|
||||||
|
if (! $routeName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try exact match first (e.g., "api.users.index")
|
||||||
|
$config = config("api.rate_limits.endpoints.{$routeName}");
|
||||||
|
|
||||||
|
// Try with dots replaced (e.g., "users.index" for route "api.users.index")
|
||||||
|
if (! $config) {
|
||||||
|
$shortName = preg_replace('/^api\./', '', $routeName);
|
||||||
|
$config = config("api.rate_limits.endpoints.{$shortName}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $config) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $config['limit'] ?? $config['requests'] ?? 60,
|
||||||
|
'window' => $config['window'] ?? (($config['per_minutes'] ?? 1) * 60),
|
||||||
|
'burst' => $config['burst'] ?? 1.0,
|
||||||
|
'key' => $config['key'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get tier-based rate limit from workspace subscription.
|
||||||
|
*
|
||||||
|
* @return array{limit: int, window: int, burst: float, key: string|null}|null
|
||||||
|
*/
|
||||||
|
protected function getTierRateLimit(Request $request): ?array
|
||||||
|
{
|
||||||
|
$workspace = $request->attributes->get('workspace');
|
||||||
|
if (! $workspace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tier = $this->getWorkspaceTier($workspace);
|
||||||
|
$tierConfig = config("api.rate_limits.tiers.{$tier}");
|
||||||
|
|
||||||
|
if (! $tierConfig) {
|
||||||
|
// Fall back to by_tier for backwards compatibility
|
||||||
|
$tierConfig = config("api.rate_limits.by_tier.{$tier}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $tierConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'limit' => $tierConfig['limit'] ?? $tierConfig['requests'] ?? 60,
|
||||||
|
'window' => $tierConfig['window'] ?? (($tierConfig['per_minutes'] ?? 1) * 60),
|
||||||
|
'burst' => $tierConfig['burst'] ?? 1.0,
|
||||||
|
'key' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the rate limit key for the request.
|
||||||
|
*
|
||||||
|
* @param array{limit: int, window: int, burst: float, key: string|null} $config
|
||||||
|
*/
|
||||||
|
protected function resolveRateLimitKey(Request $request, array $config): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
// Use custom key suffix if provided
|
||||||
|
$suffix = $config['key'];
|
||||||
|
|
||||||
|
// Add endpoint to key if per_workspace is enabled and we have a route
|
||||||
|
$perWorkspace = config('api.rate_limits.per_workspace', true);
|
||||||
|
$route = $request->route();
|
||||||
|
|
||||||
|
// Build identifier based on auth context
|
||||||
|
$apiKey = $request->attributes->get('api_key');
|
||||||
|
$workspace = $request->attributes->get('workspace');
|
||||||
|
|
||||||
|
if ($apiKey instanceof ApiKey) {
|
||||||
|
$parts[] = "api_key:{$apiKey->id}";
|
||||||
|
|
||||||
|
// Include workspace if per_workspace is enabled
|
||||||
|
if ($perWorkspace && $workspace) {
|
||||||
|
$parts[] = "ws:{$workspace->id}";
|
||||||
|
}
|
||||||
|
} elseif ($request->user()) {
|
||||||
|
$parts[] = "user:{$request->user()->id}";
|
||||||
|
|
||||||
|
if ($perWorkspace && $workspace) {
|
||||||
|
$parts[] = "ws:{$workspace->id}";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$parts[] = "ip:{$request->ip()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add route name for per-endpoint isolation
|
||||||
|
if ($route && $route->getName()) {
|
||||||
|
$parts[] = "route:{$route->getName()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add custom suffix if provided
|
||||||
|
if ($suffix) {
|
||||||
|
$parts[] = $suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(':', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get workspace tier for rate limiting.
|
||||||
|
*/
|
||||||
|
protected function getWorkspaceTier(mixed $workspace): string
|
||||||
|
{
|
||||||
|
// Check if workspace has an active package/subscription
|
||||||
|
if (method_exists($workspace, 'activePackages')) {
|
||||||
|
$package = $workspace->activePackages()->first();
|
||||||
|
|
||||||
|
return $package?->slug ?? 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a tier attribute
|
||||||
|
if (property_exists($workspace, 'tier')) {
|
||||||
|
return $workspace->tier ?? 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a plan attribute
|
||||||
|
if (property_exists($workspace, 'plan')) {
|
||||||
|
return $workspace->plan ?? 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the request is authenticated.
|
||||||
|
*/
|
||||||
|
protected function isAuthenticated(Request $request): bool
|
||||||
|
{
|
||||||
|
return $request->attributes->get('api_key') !== null
|
||||||
|
|| $request->user() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add rate limit headers to response.
|
||||||
|
*/
|
||||||
|
protected function addRateLimitHeaders(Response $response, RateLimitResult $result): Response
|
||||||
|
{
|
||||||
|
foreach ($result->headers() as $header => $value) {
|
||||||
|
$response->headers->set($header, (string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/Mod/Api/Middleware/TrackApiUsage.php
Normal file
81
src/Mod/Api/Middleware/TrackApiUsage.php
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Api\Services\ApiUsageService;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track API Usage Middleware.
|
||||||
|
*
|
||||||
|
* Records request/response metrics for API analytics.
|
||||||
|
* Should be applied after authentication middleware.
|
||||||
|
*/
|
||||||
|
class TrackApiUsage
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected ApiUsageService $usageService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle an incoming request.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
// Record start time
|
||||||
|
$startTime = microtime(true);
|
||||||
|
|
||||||
|
// Process the request
|
||||||
|
$response = $next($request);
|
||||||
|
|
||||||
|
// Calculate response time
|
||||||
|
$responseTimeMs = (int) ((microtime(true) - $startTime) * 1000);
|
||||||
|
|
||||||
|
// Only track if we have an authenticated API key
|
||||||
|
$apiKey = $request->attributes->get('api_key');
|
||||||
|
|
||||||
|
if ($apiKey instanceof ApiKey) {
|
||||||
|
$this->recordUsage($request, $response, $apiKey, $responseTimeMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the API usage.
|
||||||
|
*/
|
||||||
|
protected function recordUsage(
|
||||||
|
Request $request,
|
||||||
|
Response $response,
|
||||||
|
ApiKey $apiKey,
|
||||||
|
int $responseTimeMs
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$this->usageService->record(
|
||||||
|
apiKeyId: $apiKey->id,
|
||||||
|
workspaceId: $apiKey->workspace_id,
|
||||||
|
endpoint: $request->path(),
|
||||||
|
method: $request->method(),
|
||||||
|
statusCode: $response->getStatusCode(),
|
||||||
|
responseTimeMs: $responseTimeMs,
|
||||||
|
requestSize: strlen($request->getContent()),
|
||||||
|
responseSize: strlen($response->getContent()),
|
||||||
|
ipAddress: $request->ip(),
|
||||||
|
userAgent: $request->userAgent()
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// Don't let analytics failures affect the API response
|
||||||
|
Log::warning('Failed to record API usage', [
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
'api_key_id' => $apiKey->id,
|
||||||
|
'endpoint' => $request->path(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('api_keys', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||||
|
$table->string('name');
|
||||||
|
$table->string('key', 64)->comment('SHA256 hash of the key');
|
||||||
|
$table->string('prefix', 16)->comment('Key prefix for identification (hk_xxxxxxxx)');
|
||||||
|
$table->json('scopes')->default('["read","write"]');
|
||||||
|
$table->json('server_scopes')->nullable()->comment('Per-server access: null=all, ["commerce","biohost"]=specific');
|
||||||
|
$table->timestamp('last_used_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Index for key lookup
|
||||||
|
$table->index(['prefix', 'key']);
|
||||||
|
$table->index('workspace_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('api_keys');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('webhook_endpoints', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete();
|
||||||
|
$table->string('url');
|
||||||
|
$table->string('secret', 64)->comment('HMAC signing secret');
|
||||||
|
$table->json('events')->comment('Event types to receive, or ["*"] for all');
|
||||||
|
$table->boolean('active')->default(true);
|
||||||
|
$table->string('description')->nullable();
|
||||||
|
$table->timestamp('last_triggered_at')->nullable();
|
||||||
|
$table->unsignedInteger('failure_count')->default(0);
|
||||||
|
$table->timestamp('disabled_at')->nullable()->comment('Auto-disabled after 10 consecutive failures');
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
|
||||||
|
$table->index(['workspace_id', 'active']);
|
||||||
|
$table->index(['active', 'disabled_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('webhook_endpoints');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('webhook_deliveries', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('webhook_endpoint_id')->constrained('webhook_endpoints')->cascadeOnDelete();
|
||||||
|
$table->string('event_id', 32)->comment('Unique event identifier (evt_xxx)');
|
||||||
|
$table->string('event_type', 64)->index();
|
||||||
|
$table->json('payload');
|
||||||
|
$table->unsignedSmallInteger('response_code')->nullable();
|
||||||
|
$table->text('response_body')->nullable();
|
||||||
|
$table->unsignedTinyInteger('attempt')->default(1);
|
||||||
|
$table->string('status', 16)->default('pending')->comment('pending, success, failed, retrying');
|
||||||
|
$table->timestamp('delivered_at')->nullable();
|
||||||
|
$table->timestamp('next_retry_at')->nullable()->index();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['webhook_endpoint_id', 'status']);
|
||||||
|
$table->index(['status', 'next_retry_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('webhook_deliveries');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* Adds columns to support:
|
||||||
|
* - Secure hashing with bcrypt/Argon2 (hash_algorithm tracks which was used)
|
||||||
|
* - Key rotation with grace periods
|
||||||
|
* - Tracking which key was rotated from
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('api_keys', function (Blueprint $table) {
|
||||||
|
// Track hash algorithm for backward compatibility during migration
|
||||||
|
// 'sha256' = legacy unsalted hash, 'bcrypt' = secure hash
|
||||||
|
$table->string('hash_algorithm', 16)->default('sha256')->after('key');
|
||||||
|
|
||||||
|
// Grace period for key rotation - old key remains valid until this time
|
||||||
|
$table->timestamp('grace_period_ends_at')->nullable()->after('expires_at');
|
||||||
|
|
||||||
|
// Track key rotation lineage
|
||||||
|
$table->foreignId('rotated_from_id')
|
||||||
|
->nullable()
|
||||||
|
->after('grace_period_ends_at')
|
||||||
|
->constrained('api_keys')
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('api_keys', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['rotated_from_id']);
|
||||||
|
$table->dropColumn(['hash_algorithm', 'grace_period_ends_at', 'rotated_from_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
412
src/Mod/Api/Models/ApiKey.php
Normal file
412
src/Mod/Api/Models/ApiKey.php
Normal file
|
|
@ -0,0 +1,412 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Models;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\User;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key - authenticates SDK and REST API requests.
|
||||||
|
*
|
||||||
|
* Keys are prefixed with 'hk_' for identification.
|
||||||
|
* The actual key is hashed using bcrypt and never stored in plain text.
|
||||||
|
*
|
||||||
|
* Security: Keys created before the bcrypt migration use SHA-256 (without salt).
|
||||||
|
* The hash_algorithm column tracks which algorithm was used for each key.
|
||||||
|
* Legacy SHA-256 keys should be rotated to use the secure bcrypt algorithm.
|
||||||
|
*/
|
||||||
|
class ApiKey extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash algorithm identifiers.
|
||||||
|
*/
|
||||||
|
public const HASH_SHA256 = 'sha256';
|
||||||
|
|
||||||
|
public const HASH_BCRYPT = 'bcrypt';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default grace period for key rotation (in hours).
|
||||||
|
*/
|
||||||
|
public const DEFAULT_GRACE_PERIOD_HOURS = 24;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scopes available for API keys.
|
||||||
|
*/
|
||||||
|
public const SCOPE_READ = 'read';
|
||||||
|
|
||||||
|
public const SCOPE_WRITE = 'write';
|
||||||
|
|
||||||
|
public const SCOPE_DELETE = 'delete';
|
||||||
|
|
||||||
|
public const ALL_SCOPES = [
|
||||||
|
self::SCOPE_READ,
|
||||||
|
self::SCOPE_WRITE,
|
||||||
|
self::SCOPE_DELETE,
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'workspace_id',
|
||||||
|
'user_id',
|
||||||
|
'name',
|
||||||
|
'key',
|
||||||
|
'hash_algorithm',
|
||||||
|
'prefix',
|
||||||
|
'scopes',
|
||||||
|
'server_scopes',
|
||||||
|
'last_used_at',
|
||||||
|
'expires_at',
|
||||||
|
'grace_period_ends_at',
|
||||||
|
'rotated_from_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'scopes' => 'array',
|
||||||
|
'server_scopes' => 'array',
|
||||||
|
'last_used_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'grace_period_ends_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'key', // Never expose the hashed key
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a new API key for a workspace.
|
||||||
|
*
|
||||||
|
* Returns both the ApiKey model and the plain key (only available once).
|
||||||
|
* New keys use bcrypt for secure hashing with salt.
|
||||||
|
*
|
||||||
|
* @return array{api_key: ApiKey, plain_key: string}
|
||||||
|
*/
|
||||||
|
public static function generate(
|
||||||
|
int $workspaceId,
|
||||||
|
int $userId,
|
||||||
|
string $name,
|
||||||
|
array $scopes = [self::SCOPE_READ, self::SCOPE_WRITE],
|
||||||
|
?\DateTimeInterface $expiresAt = null
|
||||||
|
): array {
|
||||||
|
$plainKey = Str::random(48);
|
||||||
|
$prefix = 'hk_'.Str::random(8);
|
||||||
|
|
||||||
|
$apiKey = static::create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'name' => $name,
|
||||||
|
'key' => Hash::make($plainKey),
|
||||||
|
'hash_algorithm' => self::HASH_BCRYPT,
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'scopes' => $scopes,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Return plain key only once - never stored
|
||||||
|
return [
|
||||||
|
'api_key' => $apiKey,
|
||||||
|
'plain_key' => "{$prefix}_{$plainKey}",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find an API key by its plain text value.
|
||||||
|
*
|
||||||
|
* Supports both legacy SHA-256 keys and new bcrypt keys.
|
||||||
|
* For bcrypt keys, we must load all candidates by prefix and verify each.
|
||||||
|
*/
|
||||||
|
public static function findByPlainKey(string $plainKey): ?static
|
||||||
|
{
|
||||||
|
// Expected format: hk_xxxxxxxx_xxxxx...
|
||||||
|
if (! str_starts_with($plainKey, 'hk_')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = explode('_', $plainKey, 3);
|
||||||
|
if (count($parts) !== 3) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$prefix = $parts[0].'_'.$parts[1]; // hk_xxxxxxxx
|
||||||
|
$key = $parts[2];
|
||||||
|
|
||||||
|
// Find potential matches by prefix
|
||||||
|
$candidates = static::where('prefix', $prefix)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->where(function ($query) {
|
||||||
|
// Exclude keys past their grace period
|
||||||
|
$query->whereNull('grace_period_ends_at')
|
||||||
|
->orWhere('grace_period_ends_at', '>', now());
|
||||||
|
})
|
||||||
|
->get();
|
||||||
|
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if ($candidate->verifyKey($key)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify if the provided key matches this API key's stored hash.
|
||||||
|
*
|
||||||
|
* Handles both legacy SHA-256 and secure bcrypt algorithms.
|
||||||
|
*/
|
||||||
|
public function verifyKey(string $plainKey): bool
|
||||||
|
{
|
||||||
|
if ($this->hash_algorithm === self::HASH_BCRYPT) {
|
||||||
|
return Hash::check($plainKey, $this->key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy SHA-256 verification (for backward compatibility)
|
||||||
|
return hash_equals($this->key, hash('sha256', $plainKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this key uses legacy (insecure) SHA-256 hashing.
|
||||||
|
*
|
||||||
|
* Keys using SHA-256 should be rotated to use bcrypt.
|
||||||
|
*/
|
||||||
|
public function usesLegacyHash(): bool
|
||||||
|
{
|
||||||
|
return $this->hash_algorithm === self::HASH_SHA256
|
||||||
|
|| $this->hash_algorithm === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate this API key, creating a new secure key.
|
||||||
|
*
|
||||||
|
* The old key remains valid during the grace period to allow
|
||||||
|
* seamless migration of integrations.
|
||||||
|
*
|
||||||
|
* @param int $gracePeriodHours Hours the old key remains valid
|
||||||
|
* @return array{api_key: ApiKey, plain_key: string, old_key: ApiKey}
|
||||||
|
*/
|
||||||
|
public function rotate(int $gracePeriodHours = self::DEFAULT_GRACE_PERIOD_HOURS): array
|
||||||
|
{
|
||||||
|
// Create new key with same settings
|
||||||
|
$result = static::generate(
|
||||||
|
$this->workspace_id,
|
||||||
|
$this->user_id,
|
||||||
|
$this->name,
|
||||||
|
$this->scopes ?? [self::SCOPE_READ, self::SCOPE_WRITE],
|
||||||
|
$this->expires_at
|
||||||
|
);
|
||||||
|
|
||||||
|
// Copy server scopes to new key
|
||||||
|
$result['api_key']->update([
|
||||||
|
'server_scopes' => $this->server_scopes,
|
||||||
|
'rotated_from_id' => $this->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Set grace period on old key
|
||||||
|
$this->update([
|
||||||
|
'grace_period_ends_at' => now()->addHours($gracePeriodHours),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'api_key' => $result['api_key'],
|
||||||
|
'plain_key' => $result['plain_key'],
|
||||||
|
'old_key' => $this,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this key is currently in a rotation grace period.
|
||||||
|
*/
|
||||||
|
public function isInGracePeriod(): bool
|
||||||
|
{
|
||||||
|
return $this->grace_period_ends_at !== null
|
||||||
|
&& $this->grace_period_ends_at->isFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the grace period has expired (key should be revoked).
|
||||||
|
*/
|
||||||
|
public function isGracePeriodExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->grace_period_ends_at !== null
|
||||||
|
&& $this->grace_period_ends_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the grace period early and revoke this key.
|
||||||
|
*/
|
||||||
|
public function endGracePeriod(): void
|
||||||
|
{
|
||||||
|
$this->update(['grace_period_ends_at' => now()]);
|
||||||
|
$this->revoke();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record API key usage.
|
||||||
|
*/
|
||||||
|
public function recordUsage(): void
|
||||||
|
{
|
||||||
|
$this->update(['last_used_at' => now()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key has a specific scope.
|
||||||
|
*/
|
||||||
|
public function hasScope(string $scope): bool
|
||||||
|
{
|
||||||
|
return in_array($scope, $this->scopes ?? [], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key has all specified scopes.
|
||||||
|
*/
|
||||||
|
public function hasScopes(array $scopes): bool
|
||||||
|
{
|
||||||
|
foreach ($scopes as $scope) {
|
||||||
|
if (! $this->hasScope($scope)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key is expired.
|
||||||
|
*/
|
||||||
|
public function isExpired(): bool
|
||||||
|
{
|
||||||
|
return $this->expires_at !== null && $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if key has access to a specific MCP server.
|
||||||
|
*/
|
||||||
|
public function hasServerAccess(string $serverId): bool
|
||||||
|
{
|
||||||
|
// Null means all servers
|
||||||
|
if ($this->server_scopes === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($serverId, $this->server_scopes, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of allowed servers (null = all).
|
||||||
|
*/
|
||||||
|
public function getAllowedServers(): ?array
|
||||||
|
{
|
||||||
|
return $this->server_scopes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke this API key.
|
||||||
|
*/
|
||||||
|
public function revoke(): void
|
||||||
|
{
|
||||||
|
$this->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the masked key for display.
|
||||||
|
* Shows prefix and last 4 characters.
|
||||||
|
*/
|
||||||
|
public function getMaskedKeyAttribute(): string
|
||||||
|
{
|
||||||
|
return "{$this->prefix}_****";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class, 'workspace_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the key this one was rotated from.
|
||||||
|
*/
|
||||||
|
public function rotatedFrom(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(static::class, 'rotated_from_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query Scopes
|
||||||
|
public function scopeForWorkspace($query, int $workspaceId)
|
||||||
|
{
|
||||||
|
return $query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->whereNull('deleted_at')
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->where(function ($q) {
|
||||||
|
$q->whereNull('grace_period_ends_at')
|
||||||
|
->orWhere('grace_period_ends_at', '>', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeExpired($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys currently in a rotation grace period.
|
||||||
|
*/
|
||||||
|
public function scopeInGracePeriod($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('grace_period_ends_at')
|
||||||
|
->where('grace_period_ends_at', '>', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys with expired grace periods (should be cleaned up).
|
||||||
|
*/
|
||||||
|
public function scopeGracePeriodExpired($query)
|
||||||
|
{
|
||||||
|
return $query->whereNotNull('grace_period_ends_at')
|
||||||
|
->where('grace_period_ends_at', '<=', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys using legacy SHA-256 hashing (should be rotated).
|
||||||
|
*/
|
||||||
|
public function scopeLegacyHash($query)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) {
|
||||||
|
$q->where('hash_algorithm', self::HASH_SHA256)
|
||||||
|
->orWhereNull('hash_algorithm');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keys using secure bcrypt hashing.
|
||||||
|
*/
|
||||||
|
public function scopeSecureHash($query)
|
||||||
|
{
|
||||||
|
return $query->where('hash_algorithm', self::HASH_BCRYPT);
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/Mod/Api/Models/ApiUsage.php
Normal file
135
src/Mod/Api/Models/ApiUsage.php
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Usage - individual API request log entry.
|
||||||
|
*
|
||||||
|
* Tracks each API call with timing, status, and size metrics.
|
||||||
|
*/
|
||||||
|
class ApiUsage extends Model
|
||||||
|
{
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected $table = 'api_usage';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'api_key_id',
|
||||||
|
'workspace_id',
|
||||||
|
'endpoint',
|
||||||
|
'method',
|
||||||
|
'status_code',
|
||||||
|
'response_time_ms',
|
||||||
|
'request_size',
|
||||||
|
'response_size',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'created_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'created_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a usage entry from request/response data.
|
||||||
|
*/
|
||||||
|
public static function record(
|
||||||
|
int $apiKeyId,
|
||||||
|
int $workspaceId,
|
||||||
|
string $endpoint,
|
||||||
|
string $method,
|
||||||
|
int $statusCode,
|
||||||
|
int $responseTimeMs,
|
||||||
|
?int $requestSize = null,
|
||||||
|
?int $responseSize = null,
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $userAgent = null
|
||||||
|
): static {
|
||||||
|
return static::create([
|
||||||
|
'api_key_id' => $apiKeyId,
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'status_code' => $statusCode,
|
||||||
|
'response_time_ms' => $responseTimeMs,
|
||||||
|
'request_size' => $requestSize,
|
||||||
|
'response_size' => $responseSize,
|
||||||
|
'ip_address' => $ipAddress,
|
||||||
|
'user_agent' => $userAgent ? substr($userAgent, 0, 500) : null,
|
||||||
|
'created_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this was a successful request (2xx status).
|
||||||
|
*/
|
||||||
|
public function isSuccess(): bool
|
||||||
|
{
|
||||||
|
return $this->status_code >= 200 && $this->status_code < 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this was a client error (4xx status).
|
||||||
|
*/
|
||||||
|
public function isClientError(): bool
|
||||||
|
{
|
||||||
|
return $this->status_code >= 400 && $this->status_code < 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this was a server error (5xx status).
|
||||||
|
*/
|
||||||
|
public function isServerError(): bool
|
||||||
|
{
|
||||||
|
return $this->status_code >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function apiKey(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ApiKey::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeForKey($query, int $apiKeyId)
|
||||||
|
{
|
||||||
|
return $query->where('api_key_id', $apiKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForWorkspace($query, int $workspaceId)
|
||||||
|
{
|
||||||
|
return $query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForEndpoint($query, string $endpoint)
|
||||||
|
{
|
||||||
|
return $query->where('endpoint', $endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeSuccessful($query)
|
||||||
|
{
|
||||||
|
return $query->whereBetween('status_code', [200, 299]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeErrors($query)
|
||||||
|
{
|
||||||
|
return $query->where('status_code', '>=', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeBetween($query, $startDate, $endDate)
|
||||||
|
{
|
||||||
|
return $query->whereBetween('created_at', [$startDate, $endDate]);
|
||||||
|
}
|
||||||
|
}
|
||||||
172
src/Mod/Api/Models/ApiUsageDaily.php
Normal file
172
src/Mod/Api/Models/ApiUsageDaily.php
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Usage Daily - aggregated daily API statistics.
|
||||||
|
*
|
||||||
|
* Pre-computed daily stats for efficient reporting and dashboards.
|
||||||
|
*/
|
||||||
|
class ApiUsageDaily extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'api_usage_daily';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'api_key_id',
|
||||||
|
'workspace_id',
|
||||||
|
'date',
|
||||||
|
'endpoint',
|
||||||
|
'method',
|
||||||
|
'request_count',
|
||||||
|
'success_count',
|
||||||
|
'error_count',
|
||||||
|
'total_response_time_ms',
|
||||||
|
'min_response_time_ms',
|
||||||
|
'max_response_time_ms',
|
||||||
|
'total_request_size',
|
||||||
|
'total_response_size',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'date' => 'date',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update or create daily stats from a usage record.
|
||||||
|
*
|
||||||
|
* Uses Laravel's upsert() for database portability while maintaining
|
||||||
|
* atomic operations. For increment operations, we use a two-step approach:
|
||||||
|
* first upsert the base record, then atomically update counters.
|
||||||
|
*/
|
||||||
|
public static function recordFromUsage(ApiUsage $usage): static
|
||||||
|
{
|
||||||
|
$isSuccess = $usage->isSuccess();
|
||||||
|
$isError = $usage->status_code >= 400;
|
||||||
|
$date = $usage->created_at->toDateString();
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
// Unique key for this daily aggregation
|
||||||
|
$uniqueKey = [
|
||||||
|
'api_key_id' => $usage->api_key_id,
|
||||||
|
'workspace_id' => $usage->workspace_id,
|
||||||
|
'date' => $date,
|
||||||
|
'endpoint' => $usage->endpoint,
|
||||||
|
'method' => $usage->method,
|
||||||
|
];
|
||||||
|
|
||||||
|
// First, ensure the record exists with upsert (database-portable)
|
||||||
|
static::upsert(
|
||||||
|
[
|
||||||
|
...$uniqueKey,
|
||||||
|
'request_count' => 0,
|
||||||
|
'success_count' => 0,
|
||||||
|
'error_count' => 0,
|
||||||
|
'total_response_time_ms' => 0,
|
||||||
|
'total_request_size' => 0,
|
||||||
|
'total_response_size' => 0,
|
||||||
|
'min_response_time_ms' => null,
|
||||||
|
'max_response_time_ms' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
['api_key_id', 'workspace_id', 'date', 'endpoint', 'method'],
|
||||||
|
['updated_at'] // Only touch updated_at if record exists
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then atomically increment counters using query builder
|
||||||
|
$query = static::where($uniqueKey);
|
||||||
|
|
||||||
|
// Build raw update for atomic increments
|
||||||
|
$query->update([
|
||||||
|
'request_count' => DB::raw('request_count + 1'),
|
||||||
|
'success_count' => DB::raw('success_count + '.($isSuccess ? 1 : 0)),
|
||||||
|
'error_count' => DB::raw('error_count + '.($isError ? 1 : 0)),
|
||||||
|
'total_response_time_ms' => DB::raw('total_response_time_ms + '.(int) $usage->response_time_ms),
|
||||||
|
'total_request_size' => DB::raw('total_request_size + '.(int) ($usage->request_size ?? 0)),
|
||||||
|
'total_response_size' => DB::raw('total_response_size + '.(int) ($usage->response_size ?? 0)),
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Update min/max response times (these need conditional logic)
|
||||||
|
$responseTimeMs = (int) $usage->response_time_ms;
|
||||||
|
static::where($uniqueKey)
|
||||||
|
->where(function ($q) use ($responseTimeMs) {
|
||||||
|
$q->whereNull('min_response_time_ms')
|
||||||
|
->orWhere('min_response_time_ms', '>', $responseTimeMs);
|
||||||
|
})
|
||||||
|
->update(['min_response_time_ms' => $responseTimeMs]);
|
||||||
|
|
||||||
|
static::where($uniqueKey)
|
||||||
|
->where(function ($q) use ($responseTimeMs) {
|
||||||
|
$q->whereNull('max_response_time_ms')
|
||||||
|
->orWhere('max_response_time_ms', '<', $responseTimeMs);
|
||||||
|
})
|
||||||
|
->update(['max_response_time_ms' => $responseTimeMs]);
|
||||||
|
|
||||||
|
// Retrieve the record for return
|
||||||
|
return static::where($uniqueKey)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate average response time.
|
||||||
|
*/
|
||||||
|
public function getAverageResponseTimeMsAttribute(): float
|
||||||
|
{
|
||||||
|
if ($this->request_count === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round($this->total_response_time_ms / $this->request_count, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate success rate percentage.
|
||||||
|
*/
|
||||||
|
public function getSuccessRateAttribute(): float
|
||||||
|
{
|
||||||
|
if ($this->request_count === 0) {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
return round(($this->success_count / $this->request_count) * 100, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function apiKey(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ApiKey::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeForKey($query, int $apiKeyId)
|
||||||
|
{
|
||||||
|
return $query->where('api_key_id', $apiKeyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForWorkspace($query, int $workspaceId)
|
||||||
|
{
|
||||||
|
return $query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForEndpoint($query, string $endpoint)
|
||||||
|
{
|
||||||
|
return $query->where('endpoint', $endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeBetween($query, $startDate, $endDate)
|
||||||
|
{
|
||||||
|
return $query->whereBetween('date', [$startDate, $endDate]);
|
||||||
|
}
|
||||||
|
}
|
||||||
209
src/Mod/Api/Models/WebhookDelivery.php
Normal file
209
src/Mod/Api/Models/WebhookDelivery.php
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook Delivery - individual delivery attempt.
|
||||||
|
*
|
||||||
|
* Tracks status, retries, and response details.
|
||||||
|
*/
|
||||||
|
class WebhookDelivery extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
|
public const STATUS_QUEUED = 'queued';
|
||||||
|
|
||||||
|
public const STATUS_SUCCESS = 'success';
|
||||||
|
|
||||||
|
public const STATUS_FAILED = 'failed';
|
||||||
|
|
||||||
|
public const STATUS_RETRYING = 'retrying';
|
||||||
|
|
||||||
|
public const STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
public const MAX_RETRIES = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry delays in minutes for each attempt.
|
||||||
|
*/
|
||||||
|
public const RETRY_DELAYS = [
|
||||||
|
1 => 1, // 1 minute
|
||||||
|
2 => 5, // 5 minutes
|
||||||
|
3 => 30, // 30 minutes
|
||||||
|
4 => 120, // 2 hours
|
||||||
|
5 => 1440, // 24 hours
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'webhook_endpoint_id',
|
||||||
|
'event_id',
|
||||||
|
'event_type',
|
||||||
|
'payload',
|
||||||
|
'response_code',
|
||||||
|
'response_body',
|
||||||
|
'attempt',
|
||||||
|
'status',
|
||||||
|
'delivered_at',
|
||||||
|
'next_retry_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'payload' => 'array',
|
||||||
|
'delivered_at' => 'datetime',
|
||||||
|
'next_retry_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new delivery for an event.
|
||||||
|
*/
|
||||||
|
public static function createForEvent(
|
||||||
|
WebhookEndpoint $endpoint,
|
||||||
|
string $eventType,
|
||||||
|
array $data,
|
||||||
|
?int $workspaceId = null
|
||||||
|
): static {
|
||||||
|
return static::create([
|
||||||
|
'webhook_endpoint_id' => $endpoint->id,
|
||||||
|
'event_id' => 'evt_'.Str::random(24),
|
||||||
|
'event_type' => $eventType,
|
||||||
|
'payload' => [
|
||||||
|
'id' => 'evt_'.Str::random(24),
|
||||||
|
'type' => $eventType,
|
||||||
|
'created_at' => now()->toIso8601String(),
|
||||||
|
'data' => $data,
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
],
|
||||||
|
'status' => self::STATUS_PENDING,
|
||||||
|
'attempt' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark as successfully delivered.
|
||||||
|
*/
|
||||||
|
public function markSuccess(int $responseCode, ?string $responseBody = null): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_SUCCESS,
|
||||||
|
'response_code' => $responseCode,
|
||||||
|
'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null,
|
||||||
|
'delivered_at' => now(),
|
||||||
|
'next_retry_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->endpoint->recordSuccess();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark as failed and schedule retry if attempts remain.
|
||||||
|
*/
|
||||||
|
public function markFailed(int $responseCode, ?string $responseBody = null): void
|
||||||
|
{
|
||||||
|
$this->endpoint->recordFailure();
|
||||||
|
|
||||||
|
if ($this->attempt >= self::MAX_RETRIES) {
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_FAILED,
|
||||||
|
'response_code' => $responseCode,
|
||||||
|
'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule retry
|
||||||
|
$nextAttempt = $this->attempt + 1;
|
||||||
|
$delayMinutes = self::RETRY_DELAYS[$nextAttempt] ?? 1440;
|
||||||
|
|
||||||
|
$this->update([
|
||||||
|
'status' => self::STATUS_RETRYING,
|
||||||
|
'response_code' => $responseCode,
|
||||||
|
'response_body' => $responseBody ? Str::limit($responseBody, 10000) : null,
|
||||||
|
'attempt' => $nextAttempt,
|
||||||
|
'next_retry_at' => now()->addMinutes($delayMinutes),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if delivery can be retried.
|
||||||
|
*/
|
||||||
|
public function canRetry(): bool
|
||||||
|
{
|
||||||
|
return $this->attempt < self::MAX_RETRIES
|
||||||
|
&& $this->status !== self::STATUS_SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get formatted payload with signature headers.
|
||||||
|
*
|
||||||
|
* Includes all required headers for webhook verification:
|
||||||
|
* - X-Webhook-Signature: HMAC-SHA256 signature of timestamp.payload
|
||||||
|
* - X-Webhook-Timestamp: Unix timestamp (for replay protection)
|
||||||
|
* - X-Webhook-Event: The event type (e.g., 'bio.created')
|
||||||
|
* - X-Webhook-Id: Unique delivery ID for idempotency
|
||||||
|
*
|
||||||
|
* ## Verification Instructions (for recipients)
|
||||||
|
*
|
||||||
|
* 1. Get the signature and timestamp from headers
|
||||||
|
* 2. Compute: HMAC-SHA256(timestamp + "." + rawBody, yourSecret)
|
||||||
|
* 3. Compare with X-Webhook-Signature using timing-safe comparison
|
||||||
|
* 4. Verify timestamp is within 5 minutes of current time
|
||||||
|
*
|
||||||
|
* @param int|null $timestamp Unix timestamp (defaults to current time)
|
||||||
|
* @return array{headers: array<string, string|int>, body: string}
|
||||||
|
*/
|
||||||
|
public function getDeliveryPayload(?int $timestamp = null): array
|
||||||
|
{
|
||||||
|
$timestamp ??= time();
|
||||||
|
$jsonPayload = json_encode($this->payload);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'headers' => [
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'X-Webhook-Id' => $this->event_id,
|
||||||
|
'X-Webhook-Event' => $this->event_type,
|
||||||
|
'X-Webhook-Timestamp' => (string) $timestamp,
|
||||||
|
'X-Webhook-Signature' => $this->endpoint->generateSignature($jsonPayload, $timestamp),
|
||||||
|
],
|
||||||
|
'body' => $jsonPayload,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function endpoint(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WebhookEndpoint::class, 'webhook_endpoint_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopePending($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_PENDING);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeRetrying($query)
|
||||||
|
{
|
||||||
|
return $query->where('status', self::STATUS_RETRYING)
|
||||||
|
->where('next_retry_at', '<=', now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeNeedsDelivery($query)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) {
|
||||||
|
$q->where('status', self::STATUS_PENDING)
|
||||||
|
->orWhere(function ($q2) {
|
||||||
|
$q2->where('status', self::STATUS_RETRYING)
|
||||||
|
->where('next_retry_at', '<=', now());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
266
src/Mod/Api/Models/WebhookEndpoint.php
Normal file
266
src/Mod/Api/Models/WebhookEndpoint.php
Normal file
|
|
@ -0,0 +1,266 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Models;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Services\WebhookSignature;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook Endpoint - receives event notifications.
|
||||||
|
*
|
||||||
|
* Uses HMAC-SHA256 signatures with timestamps for security:
|
||||||
|
* - All outbound webhooks are signed with a per-endpoint secret
|
||||||
|
* - Timestamps prevent replay attacks (5-minute tolerance)
|
||||||
|
* - Auto-disables after 10 consecutive delivery failures
|
||||||
|
*
|
||||||
|
* ## Signature Verification (for webhook recipients)
|
||||||
|
*
|
||||||
|
* Recipients should verify webhooks using:
|
||||||
|
* 1. Compute: HMAC-SHA256(timestamp + "." + payload, secret)
|
||||||
|
* 2. Compare with X-Webhook-Signature header (timing-safe)
|
||||||
|
* 3. Verify X-Webhook-Timestamp is within 5 minutes of current time
|
||||||
|
*
|
||||||
|
* See WebhookSignature service for full documentation.
|
||||||
|
*/
|
||||||
|
class WebhookEndpoint extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Available webhook events.
|
||||||
|
*/
|
||||||
|
public const EVENTS = [
|
||||||
|
// Workspace events
|
||||||
|
'workspace.created',
|
||||||
|
'workspace.updated',
|
||||||
|
'workspace.deleted',
|
||||||
|
|
||||||
|
// Subscription events
|
||||||
|
'subscription.created',
|
||||||
|
'subscription.updated',
|
||||||
|
'subscription.cancelled',
|
||||||
|
'subscription.renewed',
|
||||||
|
|
||||||
|
// Invoice events
|
||||||
|
'invoice.created',
|
||||||
|
'invoice.paid',
|
||||||
|
'invoice.failed',
|
||||||
|
|
||||||
|
// BioLink events
|
||||||
|
'bio.created',
|
||||||
|
'bio.updated',
|
||||||
|
'bio.deleted',
|
||||||
|
|
||||||
|
// Link events
|
||||||
|
'link.created',
|
||||||
|
'link.updated',
|
||||||
|
'link.deleted',
|
||||||
|
'link.clicked', // High volume - opt-in only
|
||||||
|
|
||||||
|
// QR Code events
|
||||||
|
'qrcode.created',
|
||||||
|
'qrcode.scanned', // High volume - opt-in only
|
||||||
|
|
||||||
|
// MCP events
|
||||||
|
'mcp.tool.executed', // Tool execution completed
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'workspace_id',
|
||||||
|
'url',
|
||||||
|
'secret',
|
||||||
|
'events',
|
||||||
|
'active',
|
||||||
|
'description',
|
||||||
|
'last_triggered_at',
|
||||||
|
'failure_count',
|
||||||
|
'disabled_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'events' => 'array',
|
||||||
|
'active' => 'boolean',
|
||||||
|
'last_triggered_at' => 'datetime',
|
||||||
|
'disabled_at' => 'datetime',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $hidden = [
|
||||||
|
'secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new webhook endpoint with auto-generated secret.
|
||||||
|
*/
|
||||||
|
public static function createForWorkspace(
|
||||||
|
int $workspaceId,
|
||||||
|
string $url,
|
||||||
|
array $events,
|
||||||
|
?string $description = null
|
||||||
|
): static {
|
||||||
|
$signatureService = app(WebhookSignature::class);
|
||||||
|
|
||||||
|
return static::create([
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'url' => $url,
|
||||||
|
'secret' => $signatureService->generateSecret(),
|
||||||
|
'events' => $events,
|
||||||
|
'description' => $description,
|
||||||
|
'active' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate signature for payload with timestamp.
|
||||||
|
*
|
||||||
|
* The signature includes the timestamp to prevent replay attacks.
|
||||||
|
* Format: HMAC-SHA256(timestamp + "." + payload, secret)
|
||||||
|
*
|
||||||
|
* @param string $payload The JSON-encoded webhook payload
|
||||||
|
* @param int $timestamp Unix timestamp of the request
|
||||||
|
* @return string The hex-encoded HMAC-SHA256 signature
|
||||||
|
*/
|
||||||
|
public function generateSignature(string $payload, int $timestamp): string
|
||||||
|
{
|
||||||
|
$signatureService = app(WebhookSignature::class);
|
||||||
|
|
||||||
|
return $signatureService->sign($payload, $this->secret, $timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a signature from an incoming request (for testing endpoints).
|
||||||
|
*
|
||||||
|
* @param string $payload The raw request body
|
||||||
|
* @param string $signature The signature from the header
|
||||||
|
* @param int $timestamp The timestamp from the header
|
||||||
|
* @param int $tolerance Maximum age in seconds (default: 300)
|
||||||
|
* @return bool True if the signature is valid
|
||||||
|
*/
|
||||||
|
public function verifySignature(
|
||||||
|
string $payload,
|
||||||
|
string $signature,
|
||||||
|
int $timestamp,
|
||||||
|
int $tolerance = WebhookSignature::DEFAULT_TOLERANCE
|
||||||
|
): bool {
|
||||||
|
$signatureService = app(WebhookSignature::class);
|
||||||
|
|
||||||
|
return $signatureService->verify($payload, $signature, $this->secret, $timestamp, $tolerance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if endpoint should receive an event.
|
||||||
|
*/
|
||||||
|
public function shouldReceive(string $eventType): bool
|
||||||
|
{
|
||||||
|
if (! $this->active) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->disabled_at !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return in_array($eventType, $this->events, true)
|
||||||
|
|| in_array('*', $this->events, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record successful delivery.
|
||||||
|
*/
|
||||||
|
public function recordSuccess(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'last_triggered_at' => now(),
|
||||||
|
'failure_count' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record failed delivery.
|
||||||
|
* Auto-disables after 10 consecutive failures.
|
||||||
|
*/
|
||||||
|
public function recordFailure(): void
|
||||||
|
{
|
||||||
|
$failureCount = $this->failure_count + 1;
|
||||||
|
|
||||||
|
$updates = [
|
||||||
|
'failure_count' => $failureCount,
|
||||||
|
'last_triggered_at' => now(),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Auto-disable after 10 consecutive failures
|
||||||
|
if ($failureCount >= 10) {
|
||||||
|
$updates['disabled_at'] = now();
|
||||||
|
$updates['active'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->update($updates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-enable a disabled endpoint.
|
||||||
|
*/
|
||||||
|
public function enable(): void
|
||||||
|
{
|
||||||
|
$this->update([
|
||||||
|
'active' => true,
|
||||||
|
'disabled_at' => null,
|
||||||
|
'failure_count' => 0,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate the webhook secret.
|
||||||
|
*
|
||||||
|
* Generates a new cryptographically secure secret. The old secret
|
||||||
|
* immediately becomes invalid - recipients must update their configuration.
|
||||||
|
*
|
||||||
|
* @return string The new secret (only returned once, store securely)
|
||||||
|
*/
|
||||||
|
public function rotateSecret(): string
|
||||||
|
{
|
||||||
|
$signatureService = app(WebhookSignature::class);
|
||||||
|
$newSecret = $signatureService->generateSecret();
|
||||||
|
$this->update(['secret' => $newSecret]);
|
||||||
|
|
||||||
|
return $newSecret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationships
|
||||||
|
public function workspace(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Workspace::class, 'workspace_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deliveries(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebhookDelivery::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scopes
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('active', true)
|
||||||
|
->whereNull('disabled_at');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForWorkspace($query, int $workspaceId)
|
||||||
|
{
|
||||||
|
return $query->where('workspace_id', $workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeForEvent($query, string $eventType)
|
||||||
|
{
|
||||||
|
return $query->where(function ($q) use ($eventType) {
|
||||||
|
$q->whereJsonContains('events', $eventType)
|
||||||
|
->orWhereJsonContains('events', '*');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/Mod/Api/Notifications/HighApiUsageNotification.php
Normal file
111
src/Mod/Api/Notifications/HighApiUsageNotification.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Notifications;
|
||||||
|
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Notifications\Messages\MailMessage;
|
||||||
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notification sent when API usage approaches rate limits.
|
||||||
|
*
|
||||||
|
* Levels:
|
||||||
|
* - warning: 80% of limit used
|
||||||
|
* - critical: 95% of limit used
|
||||||
|
*/
|
||||||
|
class HighApiUsageNotification extends Notification implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Queueable;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public Workspace $workspace,
|
||||||
|
public string $level,
|
||||||
|
public int $currentUsage,
|
||||||
|
public int $limit,
|
||||||
|
public string $period,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the notification's delivery channels.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public function via(object $notifiable): array
|
||||||
|
{
|
||||||
|
return ['mail'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the mail representation of the notification.
|
||||||
|
*/
|
||||||
|
public function toMail(object $notifiable): MailMessage
|
||||||
|
{
|
||||||
|
$percentage = round(($this->currentUsage / $this->limit) * 100, 1);
|
||||||
|
|
||||||
|
$subject = match ($this->level) {
|
||||||
|
'critical' => "API Usage Critical - {$percentage}% of limit reached",
|
||||||
|
default => "API Usage Warning - {$percentage}% of limit reached",
|
||||||
|
};
|
||||||
|
|
||||||
|
$message = (new MailMessage)
|
||||||
|
->subject($subject)
|
||||||
|
->greeting($this->getGreeting())
|
||||||
|
->line($this->getMainMessage())
|
||||||
|
->line("**Workspace:** {$this->workspace->name}")
|
||||||
|
->line("**Current usage:** {$this->currentUsage} requests")
|
||||||
|
->line("**Rate limit:** {$this->limit} requests per {$this->period}")
|
||||||
|
->line("**Usage:** {$percentage}%");
|
||||||
|
|
||||||
|
if ($this->level === 'critical') {
|
||||||
|
$message->line('If you exceed your rate limit, API requests will be temporarily blocked until the limit resets.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$message->action('View API Usage', url('/developer/api'))
|
||||||
|
->line('Consider upgrading your plan if you regularly approach these limits.');
|
||||||
|
|
||||||
|
return $message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the greeting based on level.
|
||||||
|
*/
|
||||||
|
protected function getGreeting(): string
|
||||||
|
{
|
||||||
|
return match ($this->level) {
|
||||||
|
'critical' => 'Warning: API Usage Critical',
|
||||||
|
default => 'Notice: API Usage High',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the main message based on level.
|
||||||
|
*/
|
||||||
|
protected function getMainMessage(): string
|
||||||
|
{
|
||||||
|
return match ($this->level) {
|
||||||
|
'critical' => 'Your API usage has reached a critical level and is approaching the rate limit.',
|
||||||
|
default => 'Your API usage is high and approaching the rate limit threshold.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the array representation of the notification.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(object $notifiable): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'level' => $this->level,
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'workspace_name' => $this->workspace->name,
|
||||||
|
'current_usage' => $this->currentUsage,
|
||||||
|
'limit' => $this->limit,
|
||||||
|
'period' => $this->period,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/Mod/Api/RateLimit/RateLimit.php
Normal file
42
src/Mod/Api/RateLimit/RateLimit.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\RateLimit;
|
||||||
|
|
||||||
|
use Attribute;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit attribute for controllers and methods.
|
||||||
|
*
|
||||||
|
* Apply to controller classes or individual methods to set custom rate limits.
|
||||||
|
* Method-level attributes take precedence over class-level attributes.
|
||||||
|
*
|
||||||
|
* Example usage:
|
||||||
|
*
|
||||||
|
* #[RateLimit(limit: 100, window: 60)]
|
||||||
|
* class UserController extends Controller
|
||||||
|
* {
|
||||||
|
* #[RateLimit(limit: 10, window: 60)] // Override for this method
|
||||||
|
* public function store() {}
|
||||||
|
*
|
||||||
|
* #[RateLimit(limit: 1000, window: 60, burst: 1.5)] // Allow 50% burst
|
||||||
|
* public function index() {}
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD)]
|
||||||
|
readonly class RateLimit
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int $limit Maximum requests allowed in the window
|
||||||
|
* @param int $window Time window in seconds
|
||||||
|
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
|
||||||
|
* @param string|null $key Custom rate limit key suffix (null uses default)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public int $limit,
|
||||||
|
public int $window = 60,
|
||||||
|
public float $burst = 1.0,
|
||||||
|
public ?string $key = null,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
71
src/Mod/Api/RateLimit/RateLimitResult.php
Normal file
71
src/Mod/Api/RateLimit/RateLimitResult.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\RateLimit;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit check result DTO.
|
||||||
|
*
|
||||||
|
* Contains information about the current rate limit status for a request.
|
||||||
|
*/
|
||||||
|
readonly class RateLimitResult
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public bool $allowed,
|
||||||
|
public int $limit,
|
||||||
|
public int $remaining,
|
||||||
|
public int $retryAfter,
|
||||||
|
public Carbon $resetsAt,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a successful result (request allowed).
|
||||||
|
*/
|
||||||
|
public static function allowed(int $limit, int $remaining, Carbon $resetsAt): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
allowed: true,
|
||||||
|
limit: $limit,
|
||||||
|
remaining: $remaining,
|
||||||
|
retryAfter: 0,
|
||||||
|
resetsAt: $resetsAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a denied result (rate limit exceeded).
|
||||||
|
*/
|
||||||
|
public static function denied(int $limit, int $retryAfter, Carbon $resetsAt): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
allowed: false,
|
||||||
|
limit: $limit,
|
||||||
|
remaining: 0,
|
||||||
|
retryAfter: $retryAfter,
|
||||||
|
resetsAt: $resetsAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get headers for the response.
|
||||||
|
*
|
||||||
|
* @return array<string, string|int>
|
||||||
|
*/
|
||||||
|
public function headers(): array
|
||||||
|
{
|
||||||
|
$headers = [
|
||||||
|
'X-RateLimit-Limit' => $this->limit,
|
||||||
|
'X-RateLimit-Remaining' => $this->remaining,
|
||||||
|
'X-RateLimit-Reset' => $this->resetsAt->timestamp,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (! $this->allowed) {
|
||||||
|
$headers['Retry-After'] = $this->retryAfter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
247
src/Mod/Api/RateLimit/RateLimitService.php
Normal file
247
src/Mod/Api/RateLimit/RateLimitService.php
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\RateLimit;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiting service with sliding window algorithm.
|
||||||
|
*
|
||||||
|
* Provides granular rate limiting with support for:
|
||||||
|
* - Per-key rate limiting (API keys, users, IPs, etc.)
|
||||||
|
* - Sliding window algorithm for smoother rate limiting
|
||||||
|
* - Burst allowance configuration
|
||||||
|
* - Tier-based limits
|
||||||
|
*/
|
||||||
|
class RateLimitService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cache prefix for rate limit keys.
|
||||||
|
*/
|
||||||
|
protected const CACHE_PREFIX = 'rate_limit:';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
protected CacheRepository $cache,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a request would be allowed without incrementing the counter.
|
||||||
|
*
|
||||||
|
* @param string $key Unique identifier for the rate limit bucket
|
||||||
|
* @param int $limit Maximum requests allowed
|
||||||
|
* @param int $window Time window in seconds
|
||||||
|
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
|
||||||
|
*/
|
||||||
|
public function check(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
|
||||||
|
{
|
||||||
|
$cacheKey = $this->getCacheKey($key);
|
||||||
|
$effectiveLimit = (int) floor($limit * $burst);
|
||||||
|
$now = Carbon::now();
|
||||||
|
$windowStart = $now->timestamp - $window;
|
||||||
|
|
||||||
|
// Get current window data
|
||||||
|
$hits = $this->getWindowHits($cacheKey, $windowStart);
|
||||||
|
$currentCount = count($hits);
|
||||||
|
$remaining = max(0, $effectiveLimit - $currentCount);
|
||||||
|
|
||||||
|
// Calculate reset time
|
||||||
|
$resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
|
||||||
|
|
||||||
|
if ($currentCount >= $effectiveLimit) {
|
||||||
|
// Find oldest hit to determine retry after
|
||||||
|
$oldestHit = min($hits);
|
||||||
|
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
|
||||||
|
|
||||||
|
return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RateLimitResult::allowed($limit, $remaining, $resetsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a hit and check if the request is allowed.
|
||||||
|
*
|
||||||
|
* @param string $key Unique identifier for the rate limit bucket
|
||||||
|
* @param int $limit Maximum requests allowed
|
||||||
|
* @param int $window Time window in seconds
|
||||||
|
* @param float $burst Burst multiplier (e.g., 1.2 for 20% burst allowance)
|
||||||
|
*/
|
||||||
|
public function hit(string $key, int $limit, int $window, float $burst = 1.0): RateLimitResult
|
||||||
|
{
|
||||||
|
$cacheKey = $this->getCacheKey($key);
|
||||||
|
$effectiveLimit = (int) floor($limit * $burst);
|
||||||
|
$now = Carbon::now();
|
||||||
|
$windowStart = $now->timestamp - $window;
|
||||||
|
|
||||||
|
// Get current window data and clean up old entries
|
||||||
|
$hits = $this->getWindowHits($cacheKey, $windowStart);
|
||||||
|
$currentCount = count($hits);
|
||||||
|
|
||||||
|
// Calculate reset time
|
||||||
|
$resetsAt = $this->calculateResetTime($hits, $window, $effectiveLimit);
|
||||||
|
|
||||||
|
if ($currentCount >= $effectiveLimit) {
|
||||||
|
// Find oldest hit to determine retry after
|
||||||
|
$oldestHit = min($hits);
|
||||||
|
$retryAfter = max(1, ($oldestHit + $window) - $now->timestamp);
|
||||||
|
|
||||||
|
return RateLimitResult::denied($limit, $retryAfter, $resetsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the hit
|
||||||
|
$hits[] = $now->timestamp;
|
||||||
|
$this->storeWindowHits($cacheKey, $hits, $window);
|
||||||
|
|
||||||
|
$remaining = max(0, $effectiveLimit - count($hits));
|
||||||
|
|
||||||
|
return RateLimitResult::allowed($limit, $remaining, $resetsAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining attempts for a key.
|
||||||
|
*
|
||||||
|
* @param string $key Unique identifier for the rate limit bucket
|
||||||
|
* @param int $limit Maximum requests allowed (needed to calculate remaining)
|
||||||
|
* @param int $window Time window in seconds
|
||||||
|
* @param float $burst Burst multiplier
|
||||||
|
*/
|
||||||
|
public function remaining(string $key, int $limit, int $window, float $burst = 1.0): int
|
||||||
|
{
|
||||||
|
$cacheKey = $this->getCacheKey($key);
|
||||||
|
$effectiveLimit = (int) floor($limit * $burst);
|
||||||
|
$windowStart = Carbon::now()->timestamp - $window;
|
||||||
|
|
||||||
|
$hits = $this->getWindowHits($cacheKey, $windowStart);
|
||||||
|
|
||||||
|
return max(0, $effectiveLimit - count($hits));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset (clear) a rate limit bucket.
|
||||||
|
*/
|
||||||
|
public function reset(string $key): void
|
||||||
|
{
|
||||||
|
$cacheKey = $this->getCacheKey($key);
|
||||||
|
$this->cache->forget($cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current hit count for a key.
|
||||||
|
*/
|
||||||
|
public function attempts(string $key, int $window): int
|
||||||
|
{
|
||||||
|
$cacheKey = $this->getCacheKey($key);
|
||||||
|
$windowStart = Carbon::now()->timestamp - $window;
|
||||||
|
|
||||||
|
return count($this->getWindowHits($cacheKey, $windowStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rate limit key for an endpoint.
|
||||||
|
*/
|
||||||
|
public function buildEndpointKey(string $identifier, string $endpoint): string
|
||||||
|
{
|
||||||
|
return "endpoint:{$identifier}:{$endpoint}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rate limit key for a workspace.
|
||||||
|
*/
|
||||||
|
public function buildWorkspaceKey(int $workspaceId, ?string $suffix = null): string
|
||||||
|
{
|
||||||
|
$key = "workspace:{$workspaceId}";
|
||||||
|
|
||||||
|
if ($suffix !== null) {
|
||||||
|
$key .= ":{$suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rate limit key for an API key.
|
||||||
|
*/
|
||||||
|
public function buildApiKeyKey(int|string $apiKeyId, ?string $suffix = null): string
|
||||||
|
{
|
||||||
|
$key = "api_key:{$apiKeyId}";
|
||||||
|
|
||||||
|
if ($suffix !== null) {
|
||||||
|
$key .= ":{$suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a rate limit key for an IP address.
|
||||||
|
*/
|
||||||
|
public function buildIpKey(string $ip, ?string $suffix = null): string
|
||||||
|
{
|
||||||
|
$key = "ip:{$ip}";
|
||||||
|
|
||||||
|
if ($suffix !== null) {
|
||||||
|
$key .= ":{$suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get hits within the sliding window.
|
||||||
|
*
|
||||||
|
* @return array<int> Array of timestamps
|
||||||
|
*/
|
||||||
|
protected function getWindowHits(string $cacheKey, int $windowStart): array
|
||||||
|
{
|
||||||
|
/** @var array<int> $hits */
|
||||||
|
$hits = $this->cache->get($cacheKey, []);
|
||||||
|
|
||||||
|
// Filter to only include hits within the window
|
||||||
|
return array_values(array_filter($hits, fn (int $timestamp) => $timestamp >= $windowStart));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store hits in cache.
|
||||||
|
*
|
||||||
|
* @param array<int> $hits Array of timestamps
|
||||||
|
*/
|
||||||
|
protected function storeWindowHits(string $cacheKey, array $hits, int $window): void
|
||||||
|
{
|
||||||
|
// Add buffer to TTL to handle clock drift
|
||||||
|
$ttl = $window + 60;
|
||||||
|
$this->cache->put($cacheKey, $hits, $ttl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate when the rate limit resets.
|
||||||
|
*
|
||||||
|
* @param array<int> $hits Array of timestamps
|
||||||
|
*/
|
||||||
|
protected function calculateResetTime(array $hits, int $window, int $limit): Carbon
|
||||||
|
{
|
||||||
|
if (empty($hits)) {
|
||||||
|
return Carbon::now()->addSeconds($window);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If under limit, reset is at the end of the window
|
||||||
|
if (count($hits) < $limit) {
|
||||||
|
return Carbon::now()->addSeconds($window);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If at or over limit, reset when the oldest hit expires
|
||||||
|
$oldestHit = min($hits);
|
||||||
|
|
||||||
|
return Carbon::createFromTimestamp($oldestHit + $window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the cache key.
|
||||||
|
*/
|
||||||
|
protected function getCacheKey(string $key): string
|
||||||
|
{
|
||||||
|
return self::CACHE_PREFIX.$key;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/Mod/Api/Resources/ApiKeyResource.php
Normal file
59
src/Mod/Api/Resources/ApiKeyResource.php
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key resource for API responses.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $name
|
||||||
|
* @property string $prefix
|
||||||
|
* @property array|null $scopes
|
||||||
|
* @property \Carbon\Carbon|null $last_used_at
|
||||||
|
* @property \Carbon\Carbon|null $expires_at
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property string $masked_key
|
||||||
|
*/
|
||||||
|
class ApiKeyResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The plain key to include in creation response.
|
||||||
|
* Only set when key is first created.
|
||||||
|
*/
|
||||||
|
public ?string $plainKey = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new resource instance with plain key.
|
||||||
|
*/
|
||||||
|
public static function withPlainKey($resource, string $plainKey): static
|
||||||
|
{
|
||||||
|
$instance = new static($resource);
|
||||||
|
$instance->plainKey = $plainKey;
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'prefix' => $this->prefix,
|
||||||
|
'scopes' => $this->scopes,
|
||||||
|
'last_used_at' => $this->last_used_at?->toIso8601String(),
|
||||||
|
'expires_at' => $this->expires_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at->toIso8601String(),
|
||||||
|
|
||||||
|
// Only included on creation
|
||||||
|
'key' => $this->when($this->plainKey !== null, $this->plainKey),
|
||||||
|
|
||||||
|
// Masked display key
|
||||||
|
'display_key' => $this->masked_key,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/Mod/Api/Resources/ErrorResource.php
Normal file
93
src/Mod/Api/Resources/ErrorResource.php
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard error response format.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* return ErrorResource::make('validation_error', 'The given data was invalid.', [
|
||||||
|
* 'name' => ['The name field is required.'],
|
||||||
|
* ])->response()->setStatusCode(422);
|
||||||
|
*/
|
||||||
|
class ErrorResource extends JsonResource
|
||||||
|
{
|
||||||
|
protected string $errorCode;
|
||||||
|
|
||||||
|
protected string $message;
|
||||||
|
|
||||||
|
protected ?array $details;
|
||||||
|
|
||||||
|
public function __construct(string $errorCode, string $message, ?array $details = null)
|
||||||
|
{
|
||||||
|
$this->errorCode = $errorCode;
|
||||||
|
$this->message = $message;
|
||||||
|
$this->details = $details;
|
||||||
|
|
||||||
|
parent::__construct(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function make(...$args): static
|
||||||
|
{
|
||||||
|
return new static(...$args);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common error factory methods.
|
||||||
|
*/
|
||||||
|
public static function unauthorized(string $message = 'Unauthorized'): static
|
||||||
|
{
|
||||||
|
return new static('unauthorized', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forbidden(string $message = 'Forbidden'): static
|
||||||
|
{
|
||||||
|
return new static('forbidden', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFound(string $message = 'Resource not found'): static
|
||||||
|
{
|
||||||
|
return new static('not_found', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function validation(array $errors): static
|
||||||
|
{
|
||||||
|
return new static('validation_error', 'The given data was invalid.', $errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function rateLimited(int $retryAfter): static
|
||||||
|
{
|
||||||
|
return new static('rate_limit_exceeded', 'Too many requests. Please slow down.', [
|
||||||
|
'retry_after' => $retryAfter,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function entitlementExceeded(string $feature): static
|
||||||
|
{
|
||||||
|
return new static('entitlement_exceeded', "Plan limit reached for: {$feature}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function serverError(string $message = 'An unexpected error occurred'): static
|
||||||
|
{
|
||||||
|
return new static('internal_error', $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$response = [
|
||||||
|
'error' => $this->errorCode,
|
||||||
|
'message' => $this->message,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->details !== null) {
|
||||||
|
$response['details'] = $this->details;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/Mod/Api/Resources/PaginatedCollection.php
Normal file
49
src/Mod/Api/Resources/PaginatedCollection.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base paginated collection with standard pagination metadata.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* return new PaginatedCollection($paginator, WorkspaceResource::class);
|
||||||
|
*/
|
||||||
|
class PaginatedCollection extends ResourceCollection
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The resource class to use for items.
|
||||||
|
*/
|
||||||
|
protected string $resourceClass;
|
||||||
|
|
||||||
|
public function __construct($resource, string $resourceClass)
|
||||||
|
{
|
||||||
|
$this->resourceClass = $resourceClass;
|
||||||
|
parent::__construct($resource);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'data' => $this->resourceClass::collection($this->collection),
|
||||||
|
'meta' => [
|
||||||
|
'current_page' => $this->currentPage(),
|
||||||
|
'from' => $this->firstItem(),
|
||||||
|
'last_page' => $this->lastPage(),
|
||||||
|
'per_page' => $this->perPage(),
|
||||||
|
'to' => $this->lastItem(),
|
||||||
|
'total' => $this->total(),
|
||||||
|
],
|
||||||
|
'links' => [
|
||||||
|
'first' => $this->url(1),
|
||||||
|
'last' => $this->url($this->lastPage()),
|
||||||
|
'prev' => $this->previousPageUrl(),
|
||||||
|
'next' => $this->nextPageUrl(),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/Mod/Api/Resources/WebhookEndpointResource.php
Normal file
67
src/Mod/Api/Resources/WebhookEndpointResource.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook endpoint resource for API responses.
|
||||||
|
*
|
||||||
|
* @property int $id
|
||||||
|
* @property string $url
|
||||||
|
* @property array $events
|
||||||
|
* @property bool $active
|
||||||
|
* @property string|null $description
|
||||||
|
* @property \Carbon\Carbon|null $last_triggered_at
|
||||||
|
* @property int $failure_count
|
||||||
|
* @property \Carbon\Carbon|null $disabled_at
|
||||||
|
* @property \Carbon\Carbon $created_at
|
||||||
|
* @property \Carbon\Carbon $updated_at
|
||||||
|
* @property string $secret
|
||||||
|
*/
|
||||||
|
class WebhookEndpointResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Include secret in response (only on creation).
|
||||||
|
*/
|
||||||
|
public bool $includeSecret = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create resource with secret visible.
|
||||||
|
*/
|
||||||
|
public static function withSecret($resource): static
|
||||||
|
{
|
||||||
|
$instance = new static($resource);
|
||||||
|
$instance->includeSecret = true;
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'url' => $this->url,
|
||||||
|
'events' => $this->events,
|
||||||
|
'active' => $this->active,
|
||||||
|
'description' => $this->description,
|
||||||
|
'last_triggered_at' => $this->last_triggered_at?->toIso8601String(),
|
||||||
|
'failure_count' => $this->failure_count,
|
||||||
|
'disabled_at' => $this->disabled_at?->toIso8601String(),
|
||||||
|
'created_at' => $this->created_at->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at->toIso8601String(),
|
||||||
|
|
||||||
|
// Only on creation
|
||||||
|
'secret' => $this->when($this->includeSecret, $this->secret),
|
||||||
|
|
||||||
|
// Links
|
||||||
|
'links' => [
|
||||||
|
'self' => route('api.v1.webhooks.show', $this->id, false),
|
||||||
|
'deliveries' => route('api.v1.webhooks.deliveries', $this->id, false),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/Mod/Api/Resources/WorkspaceResource.php
Normal file
68
src/Mod/Api/Resources/WorkspaceResource.php
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Workspace API resource.
|
||||||
|
*
|
||||||
|
* Transforms Workspace models into API responses.
|
||||||
|
*
|
||||||
|
* @mixin \Core\Mod\Tenant\Models\Workspace
|
||||||
|
*/
|
||||||
|
class WorkspaceResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'slug' => $this->slug,
|
||||||
|
'icon' => $this->icon,
|
||||||
|
'color' => $this->color,
|
||||||
|
'description' => $this->description,
|
||||||
|
'type' => $this->type,
|
||||||
|
'is_active' => $this->is_active,
|
||||||
|
|
||||||
|
// Stats
|
||||||
|
'users_count' => $this->whenCounted('users'),
|
||||||
|
'bio_pages_count' => $this->whenCounted('bioPages'),
|
||||||
|
|
||||||
|
// Role (when available via pivot)
|
||||||
|
'role' => $this->whenPivotLoaded('user_workspace', fn () => $this->pivot->role),
|
||||||
|
'is_default' => $this->whenPivotLoaded('user_workspace', fn () => $this->pivot->is_default),
|
||||||
|
|
||||||
|
// Settings (public only)
|
||||||
|
'settings' => $this->when($this->settings, fn () => $this->getPublicSettings()),
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
'created_at' => $this->created_at?->toIso8601String(),
|
||||||
|
'updated_at' => $this->updated_at?->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get public settings (filter sensitive data).
|
||||||
|
*/
|
||||||
|
protected function getPublicSettings(): array
|
||||||
|
{
|
||||||
|
$settings = $this->settings ?? [];
|
||||||
|
|
||||||
|
// Remove sensitive keys
|
||||||
|
unset(
|
||||||
|
$settings['wp_connector_secret'],
|
||||||
|
$settings['api_secrets']
|
||||||
|
);
|
||||||
|
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/Mod/Api/Routes/api.php
Normal file
103
src/Mod/Api/Routes/api.php
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Mod\Api\Controllers\EntitlementApiController;
|
||||||
|
use Core\Mod\Api\Controllers\McpApiController;
|
||||||
|
use Core\Mod\Api\Controllers\SeoReportController;
|
||||||
|
use Core\Mod\Api\Controllers\UnifiedPixelController;
|
||||||
|
use Core\Mod\Mcp\Middleware\McpApiKeyAuth;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Core API Routes
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Core API routes for cross-cutting concerns: SEO, unified pixel tracking,
|
||||||
|
| MCP HTTP bridge, and entitlements.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// SEO Report Endpoints (authenticated)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware('auth')->prefix('seo')->group(function () {
|
||||||
|
Route::post('/report', [SeoReportController::class, 'receive'])
|
||||||
|
->name('api.seo.report');
|
||||||
|
|
||||||
|
Route::get('/issues/{workspace}', [SeoReportController::class, 'issues'])
|
||||||
|
->name('api.seo.issues');
|
||||||
|
|
||||||
|
Route::post('/task/generate', [SeoReportController::class, 'generateTask'])
|
||||||
|
->name('api.seo.generate-task');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Unified Pixel API (public - high rate limit for tracking)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware('throttle:300,1')->prefix('pixel')->group(function () {
|
||||||
|
Route::get('/config', [UnifiedPixelController::class, 'config'])
|
||||||
|
->name('api.pixel.config');
|
||||||
|
Route::post('/track', [UnifiedPixelController::class, 'track'])
|
||||||
|
->name('api.pixel.track');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Entitlements API (authenticated)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware('auth')->prefix('entitlements')->group(function () {
|
||||||
|
// Check feature access (for external apps)
|
||||||
|
Route::get('/check', [EntitlementApiController::class, 'check'])
|
||||||
|
->name('api.entitlements.check');
|
||||||
|
|
||||||
|
// Record usage (for external apps)
|
||||||
|
Route::post('/usage', [EntitlementApiController::class, 'recordUsage'])
|
||||||
|
->name('api.entitlements.usage');
|
||||||
|
|
||||||
|
// Get usage summary for current user's workspace
|
||||||
|
Route::get('/summary', [EntitlementApiController::class, 'mySummary'])
|
||||||
|
->name('api.entitlements.summary');
|
||||||
|
|
||||||
|
// Get usage summary for a specific workspace (admin)
|
||||||
|
Route::get('/summary/{workspace}', [EntitlementApiController::class, 'summary'])
|
||||||
|
->name('api.entitlements.summary.workspace');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// MCP HTTP Bridge (API key auth)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce'])
|
||||||
|
->prefix('mcp')
|
||||||
|
->name('api.mcp.')
|
||||||
|
->group(function () {
|
||||||
|
// Scope enforcement: GET=read, POST=write
|
||||||
|
// Server discovery (read)
|
||||||
|
Route::get('/servers', [McpApiController::class, 'servers'])
|
||||||
|
->name('servers');
|
||||||
|
Route::get('/servers/{id}', [McpApiController::class, 'server'])
|
||||||
|
->name('servers.show');
|
||||||
|
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
|
||||||
|
->name('servers.tools');
|
||||||
|
|
||||||
|
// Tool version history (read)
|
||||||
|
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])
|
||||||
|
->name('tools.versions');
|
||||||
|
|
||||||
|
// Specific tool version (read)
|
||||||
|
Route::get('/servers/{server}/tools/{tool}/versions/{version}', [McpApiController::class, 'toolVersion'])
|
||||||
|
->name('tools.version');
|
||||||
|
|
||||||
|
// Tool execution (write)
|
||||||
|
Route::post('/tools/call', [McpApiController::class, 'callTool'])
|
||||||
|
->name('tools.call');
|
||||||
|
|
||||||
|
// Resource access (read)
|
||||||
|
Route::get('/resources/{uri}', [McpApiController::class, 'resource'])
|
||||||
|
->where('uri', '.*')
|
||||||
|
->name('resources.show');
|
||||||
|
});
|
||||||
217
src/Mod/Api/Services/ApiKeyService.php
Normal file
217
src/Mod/Api/Services/ApiKeyService.php
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Key Service - manages API key lifecycle.
|
||||||
|
*
|
||||||
|
* Provides methods for creating, rotating, and managing API keys
|
||||||
|
* with proper validation and logging.
|
||||||
|
*/
|
||||||
|
class ApiKeyService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new API key for a workspace.
|
||||||
|
*
|
||||||
|
* @return array{api_key: ApiKey, plain_key: string}
|
||||||
|
*/
|
||||||
|
public function create(
|
||||||
|
int $workspaceId,
|
||||||
|
int $userId,
|
||||||
|
string $name,
|
||||||
|
array $scopes = [ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE],
|
||||||
|
?\DateTimeInterface $expiresAt = null,
|
||||||
|
?array $serverScopes = null
|
||||||
|
): array {
|
||||||
|
// Check workspace key limit
|
||||||
|
$maxKeys = config('api.keys.max_per_workspace', 10);
|
||||||
|
$currentCount = ApiKey::forWorkspace($workspaceId)->active()->count();
|
||||||
|
|
||||||
|
if ($currentCount >= $maxKeys) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Workspace has reached the maximum number of API keys ({$maxKeys})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = ApiKey::generate($workspaceId, $userId, $name, $scopes, $expiresAt);
|
||||||
|
|
||||||
|
// Set server scopes if provided
|
||||||
|
if ($serverScopes !== null) {
|
||||||
|
$result['api_key']->update(['server_scopes' => $serverScopes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('API key created', [
|
||||||
|
'key_id' => $result['api_key']->id,
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotate an existing API key.
|
||||||
|
*
|
||||||
|
* Creates a new key with the same settings, keeping the old key
|
||||||
|
* valid for a grace period to allow migration.
|
||||||
|
*
|
||||||
|
* @param int $gracePeriodHours Hours the old key remains valid (default: 24)
|
||||||
|
* @return array{api_key: ApiKey, plain_key: string, old_key: ApiKey}
|
||||||
|
*/
|
||||||
|
public function rotate(ApiKey $apiKey, int $gracePeriodHours = ApiKey::DEFAULT_GRACE_PERIOD_HOURS): array
|
||||||
|
{
|
||||||
|
// Don't rotate keys that are already being rotated out
|
||||||
|
if ($apiKey->isInGracePeriod()) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
'This key is already being rotated. Wait for the grace period to end or end it manually.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't rotate revoked keys
|
||||||
|
if ($apiKey->trashed()) {
|
||||||
|
throw new \RuntimeException('Cannot rotate a revoked key.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $apiKey->rotate($gracePeriodHours);
|
||||||
|
|
||||||
|
Log::info('API key rotated', [
|
||||||
|
'old_key_id' => $apiKey->id,
|
||||||
|
'new_key_id' => $result['api_key']->id,
|
||||||
|
'workspace_id' => $apiKey->workspace_id,
|
||||||
|
'grace_period_hours' => $gracePeriodHours,
|
||||||
|
'grace_period_ends_at' => $apiKey->fresh()->grace_period_ends_at?->toIso8601String(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke an API key immediately.
|
||||||
|
*/
|
||||||
|
public function revoke(ApiKey $apiKey): void
|
||||||
|
{
|
||||||
|
$apiKey->revoke();
|
||||||
|
|
||||||
|
Log::info('API key revoked', [
|
||||||
|
'key_id' => $apiKey->id,
|
||||||
|
'workspace_id' => $apiKey->workspace_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* End the grace period for a rotating key and revoke it.
|
||||||
|
*/
|
||||||
|
public function endGracePeriod(ApiKey $apiKey): void
|
||||||
|
{
|
||||||
|
if (! $apiKey->isInGracePeriod()) {
|
||||||
|
throw new \RuntimeException('This key is not in a grace period.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey->endGracePeriod();
|
||||||
|
|
||||||
|
Log::info('API key grace period ended', [
|
||||||
|
'key_id' => $apiKey->id,
|
||||||
|
'workspace_id' => $apiKey->workspace_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up keys with expired grace periods.
|
||||||
|
*
|
||||||
|
* This should be called by a scheduled command to revoke
|
||||||
|
* old keys after their grace period has ended.
|
||||||
|
*
|
||||||
|
* @return int Number of keys cleaned up
|
||||||
|
*/
|
||||||
|
public function cleanupExpiredGracePeriods(): int
|
||||||
|
{
|
||||||
|
$keys = ApiKey::gracePeriodExpired()
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
foreach ($keys as $key) {
|
||||||
|
$key->revoke();
|
||||||
|
$count++;
|
||||||
|
|
||||||
|
Log::info('Cleaned up API key after grace period', [
|
||||||
|
'key_id' => $key->id,
|
||||||
|
'workspace_id' => $key->workspace_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update API key scopes.
|
||||||
|
*/
|
||||||
|
public function updateScopes(ApiKey $apiKey, array $scopes): void
|
||||||
|
{
|
||||||
|
// Validate scopes
|
||||||
|
$validScopes = array_intersect($scopes, ApiKey::ALL_SCOPES);
|
||||||
|
|
||||||
|
if (empty($validScopes)) {
|
||||||
|
throw new \InvalidArgumentException('At least one valid scope must be provided.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$apiKey->update(['scopes' => array_values($validScopes)]);
|
||||||
|
|
||||||
|
Log::info('API key scopes updated', [
|
||||||
|
'key_id' => $apiKey->id,
|
||||||
|
'scopes' => $validScopes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update API key server scopes.
|
||||||
|
*/
|
||||||
|
public function updateServerScopes(ApiKey $apiKey, ?array $serverScopes): void
|
||||||
|
{
|
||||||
|
$apiKey->update(['server_scopes' => $serverScopes]);
|
||||||
|
|
||||||
|
Log::info('API key server scopes updated', [
|
||||||
|
'key_id' => $apiKey->id,
|
||||||
|
'server_scopes' => $serverScopes,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rename an API key.
|
||||||
|
*/
|
||||||
|
public function rename(ApiKey $apiKey, string $name): void
|
||||||
|
{
|
||||||
|
$apiKey->update(['name' => $name]);
|
||||||
|
|
||||||
|
Log::info('API key renamed', [
|
||||||
|
'key_id' => $apiKey->id,
|
||||||
|
'name' => $name,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get statistics for a workspace's API keys.
|
||||||
|
*/
|
||||||
|
public function getStats(int $workspaceId): array
|
||||||
|
{
|
||||||
|
$keys = ApiKey::forWorkspace($workspaceId);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (clone $keys)->count(),
|
||||||
|
'active' => (clone $keys)->active()->count(),
|
||||||
|
'expired' => (clone $keys)->expired()->count(),
|
||||||
|
'in_grace_period' => (clone $keys)->inGracePeriod()->count(),
|
||||||
|
'revoked' => ApiKey::withTrashed()
|
||||||
|
->forWorkspace($workspaceId)
|
||||||
|
->whereNotNull('deleted_at')
|
||||||
|
->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
427
src/Mod/Api/Services/ApiSnippetService.php
Normal file
427
src/Mod/Api/Services/ApiSnippetService.php
Normal file
|
|
@ -0,0 +1,427 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Code Snippet Generator - generates code snippets in multiple languages.
|
||||||
|
*
|
||||||
|
* Used to enhance API documentation with copy-paste ready examples.
|
||||||
|
*/
|
||||||
|
class ApiSnippetService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Supported languages with their display names.
|
||||||
|
*/
|
||||||
|
public const LANGUAGES = [
|
||||||
|
'curl' => 'cURL',
|
||||||
|
'php' => 'PHP',
|
||||||
|
'javascript' => 'JavaScript',
|
||||||
|
'python' => 'Python',
|
||||||
|
'ruby' => 'Ruby',
|
||||||
|
'go' => 'Go',
|
||||||
|
'java' => 'Java',
|
||||||
|
'csharp' => 'C#',
|
||||||
|
'swift' => 'Swift',
|
||||||
|
'kotlin' => 'Kotlin',
|
||||||
|
'rust' => 'Rust',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate snippets for all supported languages.
|
||||||
|
*/
|
||||||
|
public function generateAll(
|
||||||
|
string $method,
|
||||||
|
string $endpoint,
|
||||||
|
array $headers = [],
|
||||||
|
?array $body = null,
|
||||||
|
string $baseUrl = 'https://api.host.uk.com'
|
||||||
|
): array {
|
||||||
|
$snippets = [];
|
||||||
|
|
||||||
|
foreach (array_keys(self::LANGUAGES) as $language) {
|
||||||
|
$snippets[$language] = $this->generate($language, $method, $endpoint, $headers, $body, $baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $snippets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a snippet for a specific language.
|
||||||
|
*/
|
||||||
|
public function generate(
|
||||||
|
string $language,
|
||||||
|
string $method,
|
||||||
|
string $endpoint,
|
||||||
|
array $headers = [],
|
||||||
|
?array $body = null,
|
||||||
|
string $baseUrl = 'https://api.host.uk.com'
|
||||||
|
): string {
|
||||||
|
$url = rtrim($baseUrl, '/').'/'.ltrim($endpoint, '/');
|
||||||
|
|
||||||
|
// Add default headers
|
||||||
|
$headers = array_merge([
|
||||||
|
'Authorization' => 'Bearer YOUR_API_KEY',
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Accept' => 'application/json',
|
||||||
|
], $headers);
|
||||||
|
|
||||||
|
return match ($language) {
|
||||||
|
'curl' => $this->generateCurl($method, $url, $headers, $body),
|
||||||
|
'php' => $this->generatePhp($method, $url, $headers, $body),
|
||||||
|
'javascript' => $this->generateJavaScript($method, $url, $headers, $body),
|
||||||
|
'python' => $this->generatePython($method, $url, $headers, $body),
|
||||||
|
'ruby' => $this->generateRuby($method, $url, $headers, $body),
|
||||||
|
'go' => $this->generateGo($method, $url, $headers, $body),
|
||||||
|
'java' => $this->generateJava($method, $url, $headers, $body),
|
||||||
|
'csharp' => $this->generateCSharp($method, $url, $headers, $body),
|
||||||
|
'swift' => $this->generateSwift($method, $url, $headers, $body),
|
||||||
|
'kotlin' => $this->generateKotlin($method, $url, $headers, $body),
|
||||||
|
'rust' => $this->generateRust($method, $url, $headers, $body),
|
||||||
|
default => "# Language '{$language}' not supported",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateCurl(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$lines = ["curl -X {$method} '{$url}' \\"];
|
||||||
|
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$lines[] = " -H '{$key}: {$value}' \\";
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($body) {
|
||||||
|
$json = json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
$lines[] = " -d '{$json}'";
|
||||||
|
} else {
|
||||||
|
// Remove trailing backslash from last header
|
||||||
|
$lastIndex = count($lines) - 1;
|
||||||
|
$lines[$lastIndex] = rtrim($lines[$lastIndex], ' \\');
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode("\n", $lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generatePhp(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerStr = '';
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerStr .= " '{$key}' => '{$value}',\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'null';
|
||||||
|
|
||||||
|
return <<<PHP
|
||||||
|
\$response = Http::{$this->phpMethod($method)}('{$url}', [
|
||||||
|
'headers' => [
|
||||||
|
{$headerStr} ],
|
||||||
|
'json' => {$bodyStr},
|
||||||
|
]);
|
||||||
|
|
||||||
|
\$data = \$response->json();
|
||||||
|
PHP;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateJavaScript(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerJson = json_encode($headers, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||||
|
$bodyJson = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'null';
|
||||||
|
|
||||||
|
return <<<JS
|
||||||
|
const response = await fetch('{$url}', {
|
||||||
|
method: '{$method}',
|
||||||
|
headers: {$headerJson},
|
||||||
|
body: {$bodyJson}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
JS;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generatePython(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerLines[] = " \"{$key}\": \"{$value}\"";
|
||||||
|
}
|
||||||
|
$headerStr = implode(",\n", $headerLines);
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'None';
|
||||||
|
|
||||||
|
return <<<PYTHON
|
||||||
|
import requests
|
||||||
|
|
||||||
|
response = requests.{$this->pythonMethod($method)}(
|
||||||
|
"{$url}",
|
||||||
|
headers={
|
||||||
|
{$headerStr}
|
||||||
|
},
|
||||||
|
json={$bodyStr}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
PYTHON;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateRuby(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerLines[] = " \"{$key}\" => \"{$value}\"";
|
||||||
|
}
|
||||||
|
$headerStr = implode(",\n", $headerLines);
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : 'nil';
|
||||||
|
|
||||||
|
return <<<RUBY
|
||||||
|
require 'httparty'
|
||||||
|
|
||||||
|
response = HTTParty.{$this->rubyMethod($method)}(
|
||||||
|
"{$url}",
|
||||||
|
headers: {
|
||||||
|
{$headerStr}
|
||||||
|
},
|
||||||
|
body: {$bodyStr}
|
||||||
|
)
|
||||||
|
|
||||||
|
data = JSON.parse(response.body)
|
||||||
|
RUBY;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateGo(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$bodySetup = $body
|
||||||
|
? 'jsonData, _ := json.Marshal(map[string]interface{}{'.$this->goMapEntries($body)."})\\n\\treq, _ := http.NewRequest(\"{$method}\", \"{$url}\", bytes.NewBuffer(jsonData))"
|
||||||
|
: "req, _ := http.NewRequest(\"{$method}\", \"{$url}\", nil)";
|
||||||
|
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerLines[] = "\treq.Header.Set(\"{$key}\", \"{$value}\")";
|
||||||
|
}
|
||||||
|
$headerStr = implode("\n", $headerLines);
|
||||||
|
|
||||||
|
return <<<GO
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"io/ioutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
{$bodySetup}
|
||||||
|
{$headerStr}
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
resp, _ := client.Do(req)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := ioutil.ReadAll(resp.Body)
|
||||||
|
var data map[string]interface{}
|
||||||
|
json.Unmarshal(body, &data)
|
||||||
|
}
|
||||||
|
GO;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateJava(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerLines[] = " .header(\"{$key}\", \"{$value}\")";
|
||||||
|
}
|
||||||
|
$headerStr = implode("\n", $headerLines);
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""';
|
||||||
|
|
||||||
|
return <<<JAVA
|
||||||
|
HttpClient client = HttpClient.newHttpClient();
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create("{$url}"))
|
||||||
|
.method("{$method}", HttpRequest.BodyPublishers.ofString("{$bodyStr}"))
|
||||||
|
{$headerStr}
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
String body = response.body();
|
||||||
|
JAVA;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateCSharp(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
if ($key === 'Content-Type') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$headerLines[] = "client.DefaultRequestHeaders.Add(\"{$key}\", \"{$value}\");";
|
||||||
|
}
|
||||||
|
$headerStr = implode("\n", $headerLines);
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""';
|
||||||
|
|
||||||
|
return <<<CSHARP
|
||||||
|
using var client = new HttpClient();
|
||||||
|
{$headerStr}
|
||||||
|
|
||||||
|
var content = new StringContent("{$bodyStr}", Encoding.UTF8, "application/json");
|
||||||
|
var response = await client.{$this->csharpMethod($method)}Async("{$url}", content);
|
||||||
|
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
CSHARP;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateSwift(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerLines[] = "request.setValue(\"{$value}\", forHTTPHeaderField: \"{$key}\")";
|
||||||
|
}
|
||||||
|
$headerStr = implode("\n", $headerLines);
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : 'nil';
|
||||||
|
|
||||||
|
return <<<SWIFT
|
||||||
|
var request = URLRequest(url: URL(string: "{$url}")!)
|
||||||
|
request.httpMethod = "{$method}"
|
||||||
|
{$headerStr}
|
||||||
|
request.httpBody = "{$bodyStr}".data(using: .utf8)
|
||||||
|
|
||||||
|
let task = URLSession.shared.dataTask(with: request) { data, response, error in
|
||||||
|
if let data = data {
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
task.resume()
|
||||||
|
SWIFT;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateKotlin(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerLines[] = " .addHeader(\"{$key}\", \"{$value}\")";
|
||||||
|
}
|
||||||
|
$headerStr = implode("\n", $headerLines);
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""';
|
||||||
|
|
||||||
|
return <<<KOTLIN
|
||||||
|
val client = OkHttpClient()
|
||||||
|
val mediaType = "application/json".toMediaType()
|
||||||
|
val body = "{$bodyStr}".toRequestBody(mediaType)
|
||||||
|
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url("{$url}")
|
||||||
|
.method("{$method}", body)
|
||||||
|
{$headerStr}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = client.newCall(request).execute()
|
||||||
|
val json = response.body?.string()
|
||||||
|
KOTLIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateRust(string $method, string $url, array $headers, ?array $body): string
|
||||||
|
{
|
||||||
|
$headerLines = [];
|
||||||
|
foreach ($headers as $key => $value) {
|
||||||
|
$headerLines[] = " .header(\"{$key}\", \"{$value}\")";
|
||||||
|
}
|
||||||
|
$headerStr = implode("\n", $headerLines);
|
||||||
|
|
||||||
|
$bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""';
|
||||||
|
|
||||||
|
return <<<RUST
|
||||||
|
use reqwest::blocking::Client;
|
||||||
|
|
||||||
|
let client = Client::new();
|
||||||
|
let response = client
|
||||||
|
.{$this->rustMethod($method)}("{$url}")
|
||||||
|
{$headerStr}
|
||||||
|
.body("{$bodyStr}")
|
||||||
|
.send()?;
|
||||||
|
|
||||||
|
let json: serde_json::Value = response.json()?;
|
||||||
|
RUST;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods for language-specific syntax
|
||||||
|
protected function phpMethod(string $method): string
|
||||||
|
{
|
||||||
|
return strtolower($method);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function pythonMethod(string $method): string
|
||||||
|
{
|
||||||
|
return strtolower($method);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rubyMethod(string $method): string
|
||||||
|
{
|
||||||
|
return strtolower($method);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function csharpMethod(string $method): string
|
||||||
|
{
|
||||||
|
return match (strtoupper($method)) {
|
||||||
|
'GET' => 'Get',
|
||||||
|
'POST' => 'Post',
|
||||||
|
'PUT' => 'Put',
|
||||||
|
'PATCH' => 'Patch',
|
||||||
|
'DELETE' => 'Delete',
|
||||||
|
default => 'Send',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function rustMethod(string $method): string
|
||||||
|
{
|
||||||
|
return strtolower($method);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function goMapEntries(array $data): string
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$val = is_string($value) ? "\"{$value}\"" : json_encode($value);
|
||||||
|
$entries[] = "\"{$key}\": {$val}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get language metadata for UI display.
|
||||||
|
*/
|
||||||
|
public static function getLanguages(): array
|
||||||
|
{
|
||||||
|
return collect(self::LANGUAGES)->map(fn ($name, $code) => [
|
||||||
|
'code' => $code,
|
||||||
|
'name' => $name,
|
||||||
|
'icon' => self::getLanguageIcon($code),
|
||||||
|
])->values()->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon class for a language.
|
||||||
|
*/
|
||||||
|
public static function getLanguageIcon(string $code): string
|
||||||
|
{
|
||||||
|
return match ($code) {
|
||||||
|
'curl' => 'terminal',
|
||||||
|
'php' => 'code-bracket',
|
||||||
|
'javascript' => 'code-bracket-square',
|
||||||
|
'python' => 'code-bracket',
|
||||||
|
'ruby' => 'sparkles',
|
||||||
|
'go' => 'cube',
|
||||||
|
'java' => 'fire',
|
||||||
|
'csharp' => 'window',
|
||||||
|
'swift' => 'bolt',
|
||||||
|
'kotlin' => 'beaker',
|
||||||
|
'rust' => 'cog',
|
||||||
|
default => 'code-bracket',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
361
src/Mod/Api/Services/ApiUsageService.php
Normal file
361
src/Mod/Api/Services/ApiUsageService.php
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mod\Api\Services;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Mod\Api\Models\ApiUsage;
|
||||||
|
use Mod\Api\Models\ApiUsageDaily;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Usage Service - tracks and reports API usage metrics.
|
||||||
|
*
|
||||||
|
* Provides methods for recording API calls and generating reports.
|
||||||
|
*/
|
||||||
|
class ApiUsageService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Record an API request.
|
||||||
|
*/
|
||||||
|
public function record(
|
||||||
|
int $apiKeyId,
|
||||||
|
int $workspaceId,
|
||||||
|
string $endpoint,
|
||||||
|
string $method,
|
||||||
|
int $statusCode,
|
||||||
|
int $responseTimeMs,
|
||||||
|
?int $requestSize = null,
|
||||||
|
?int $responseSize = null,
|
||||||
|
?string $ipAddress = null,
|
||||||
|
?string $userAgent = null
|
||||||
|
): ApiUsage {
|
||||||
|
// Normalise endpoint (remove query strings, IDs)
|
||||||
|
$normalisedEndpoint = $this->normaliseEndpoint($endpoint);
|
||||||
|
|
||||||
|
// Record individual usage
|
||||||
|
$usage = ApiUsage::record(
|
||||||
|
$apiKeyId,
|
||||||
|
$workspaceId,
|
||||||
|
$normalisedEndpoint,
|
||||||
|
$method,
|
||||||
|
$statusCode,
|
||||||
|
$responseTimeMs,
|
||||||
|
$requestSize,
|
||||||
|
$responseSize,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update daily aggregation
|
||||||
|
ApiUsageDaily::recordFromUsage($usage);
|
||||||
|
|
||||||
|
return $usage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary for a workspace.
|
||||||
|
*/
|
||||||
|
public function getWorkspaceSummary(
|
||||||
|
int $workspaceId,
|
||||||
|
?Carbon $startDate = null,
|
||||||
|
?Carbon $endDate = null
|
||||||
|
): array {
|
||||||
|
$startDate = $startDate ?? now()->subDays(30);
|
||||||
|
$endDate = $endDate ?? now();
|
||||||
|
|
||||||
|
$query = ApiUsageDaily::forWorkspace($workspaceId)
|
||||||
|
->between($startDate, $endDate);
|
||||||
|
|
||||||
|
$totals = (clone $query)->selectRaw('
|
||||||
|
SUM(request_count) as total_requests,
|
||||||
|
SUM(success_count) as total_success,
|
||||||
|
SUM(error_count) as total_errors,
|
||||||
|
SUM(total_response_time_ms) as total_response_time,
|
||||||
|
MIN(min_response_time_ms) as min_response_time,
|
||||||
|
MAX(max_response_time_ms) as max_response_time,
|
||||||
|
SUM(total_request_size) as total_request_size,
|
||||||
|
SUM(total_response_size) as total_response_size
|
||||||
|
')->first();
|
||||||
|
|
||||||
|
$totalRequests = (int) ($totals->total_requests ?? 0);
|
||||||
|
$totalSuccess = (int) ($totals->total_success ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'period' => [
|
||||||
|
'start' => $startDate->toIso8601String(),
|
||||||
|
'end' => $endDate->toIso8601String(),
|
||||||
|
],
|
||||||
|
'totals' => [
|
||||||
|
'requests' => $totalRequests,
|
||||||
|
'success' => $totalSuccess,
|
||||||
|
'errors' => (int) ($totals->total_errors ?? 0),
|
||||||
|
'success_rate' => $totalRequests > 0
|
||||||
|
? round(($totalSuccess / $totalRequests) * 100, 2)
|
||||||
|
: 100,
|
||||||
|
],
|
||||||
|
'response_time' => [
|
||||||
|
'average_ms' => $totalRequests > 0
|
||||||
|
? round((int) $totals->total_response_time / $totalRequests, 2)
|
||||||
|
: 0,
|
||||||
|
'min_ms' => (int) ($totals->min_response_time ?? 0),
|
||||||
|
'max_ms' => (int) ($totals->max_response_time ?? 0),
|
||||||
|
],
|
||||||
|
'data_transfer' => [
|
||||||
|
'request_bytes' => (int) ($totals->total_request_size ?? 0),
|
||||||
|
'response_bytes' => (int) ($totals->total_response_size ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get usage summary for a specific API key.
|
||||||
|
*/
|
||||||
|
public function getKeySummary(
|
||||||
|
int $apiKeyId,
|
||||||
|
?Carbon $startDate = null,
|
||||||
|
?Carbon $endDate = null
|
||||||
|
): array {
|
||||||
|
$startDate = $startDate ?? now()->subDays(30);
|
||||||
|
$endDate = $endDate ?? now();
|
||||||
|
|
||||||
|
$query = ApiUsageDaily::forKey($apiKeyId)
|
||||||
|
->between($startDate, $endDate);
|
||||||
|
|
||||||
|
$totals = (clone $query)->selectRaw('
|
||||||
|
SUM(request_count) as total_requests,
|
||||||
|
SUM(success_count) as total_success,
|
||||||
|
SUM(error_count) as total_errors,
|
||||||
|
SUM(total_response_time_ms) as total_response_time,
|
||||||
|
MIN(min_response_time_ms) as min_response_time,
|
||||||
|
MAX(max_response_time_ms) as max_response_time
|
||||||
|
')->first();
|
||||||
|
|
||||||
|
$totalRequests = (int) ($totals->total_requests ?? 0);
|
||||||
|
$totalSuccess = (int) ($totals->total_success ?? 0);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'period' => [
|
||||||
|
'start' => $startDate->toIso8601String(),
|
||||||
|
'end' => $endDate->toIso8601String(),
|
||||||
|
],
|
||||||
|
'totals' => [
|
||||||
|
'requests' => $totalRequests,
|
||||||
|
'success' => $totalSuccess,
|
||||||
|
'errors' => (int) ($totals->total_errors ?? 0),
|
||||||
|
'success_rate' => $totalRequests > 0
|
||||||
|
? round(($totalSuccess / $totalRequests) * 100, 2)
|
||||||
|
: 100,
|
||||||
|
],
|
||||||
|
'response_time' => [
|
||||||
|
'average_ms' => $totalRequests > 0
|
||||||
|
? round((int) $totals->total_response_time / $totalRequests, 2)
|
||||||
|
: 0,
|
||||||
|
'min_ms' => (int) ($totals->min_response_time ?? 0),
|
||||||
|
'max_ms' => (int) ($totals->max_response_time ?? 0),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get daily usage chart data.
|
||||||
|
*/
|
||||||
|
public function getDailyChart(
|
||||||
|
int $workspaceId,
|
||||||
|
?Carbon $startDate = null,
|
||||||
|
?Carbon $endDate = null
|
||||||
|
): array {
|
||||||
|
$startDate = $startDate ?? now()->subDays(30);
|
||||||
|
$endDate = $endDate ?? now();
|
||||||
|
|
||||||
|
$data = ApiUsageDaily::forWorkspace($workspaceId)
|
||||||
|
->between($startDate, $endDate)
|
||||||
|
->selectRaw('
|
||||||
|
date,
|
||||||
|
SUM(request_count) as requests,
|
||||||
|
SUM(success_count) as success,
|
||||||
|
SUM(error_count) as errors,
|
||||||
|
SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time
|
||||||
|
')
|
||||||
|
->groupBy('date')
|
||||||
|
->orderBy('date')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $data->map(fn ($row) => [
|
||||||
|
'date' => $row->date->toDateString(),
|
||||||
|
'requests' => (int) $row->requests,
|
||||||
|
'success' => (int) $row->success,
|
||||||
|
'errors' => (int) $row->errors,
|
||||||
|
'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2),
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get top endpoints by request count.
|
||||||
|
*/
|
||||||
|
public function getTopEndpoints(
|
||||||
|
int $workspaceId,
|
||||||
|
int $limit = 10,
|
||||||
|
?Carbon $startDate = null,
|
||||||
|
?Carbon $endDate = null
|
||||||
|
): array {
|
||||||
|
$startDate = $startDate ?? now()->subDays(30);
|
||||||
|
$endDate = $endDate ?? now();
|
||||||
|
|
||||||
|
return ApiUsageDaily::forWorkspace($workspaceId)
|
||||||
|
->between($startDate, $endDate)
|
||||||
|
->selectRaw('
|
||||||
|
endpoint,
|
||||||
|
method,
|
||||||
|
SUM(request_count) as requests,
|
||||||
|
SUM(success_count) as success,
|
||||||
|
SUM(error_count) as errors,
|
||||||
|
SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time
|
||||||
|
')
|
||||||
|
->groupBy('endpoint', 'method')
|
||||||
|
->orderByDesc('requests')
|
||||||
|
->limit($limit)
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => [
|
||||||
|
'endpoint' => $row->endpoint,
|
||||||
|
'method' => $row->method,
|
||||||
|
'requests' => (int) $row->requests,
|
||||||
|
'success' => (int) $row->success,
|
||||||
|
'errors' => (int) $row->errors,
|
||||||
|
'success_rate' => $row->requests > 0
|
||||||
|
? round(($row->success / $row->requests) * 100, 2)
|
||||||
|
: 100,
|
||||||
|
'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get error breakdown by status code.
|
||||||
|
*/
|
||||||
|
public function getErrorBreakdown(
|
||||||
|
int $workspaceId,
|
||||||
|
?Carbon $startDate = null,
|
||||||
|
?Carbon $endDate = null
|
||||||
|
): array {
|
||||||
|
$startDate = $startDate ?? now()->subDays(30);
|
||||||
|
$endDate = $endDate ?? now();
|
||||||
|
|
||||||
|
return ApiUsage::forWorkspace($workspaceId)
|
||||||
|
->between($startDate, $endDate)
|
||||||
|
->where('status_code', '>=', 400)
|
||||||
|
->selectRaw('status_code, COUNT(*) as count')
|
||||||
|
->groupBy('status_code')
|
||||||
|
->orderByDesc('count')
|
||||||
|
->get()
|
||||||
|
->map(fn ($row) => [
|
||||||
|
'status_code' => $row->status_code,
|
||||||
|
'count' => (int) $row->count,
|
||||||
|
'description' => $this->getStatusCodeDescription($row->status_code),
|
||||||
|
])
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get API key usage comparison.
|
||||||
|
*/
|
||||||
|
public function getKeyComparison(
|
||||||
|
int $workspaceId,
|
||||||
|
?Carbon $startDate = null,
|
||||||
|
?Carbon $endDate = null
|
||||||
|
): array {
|
||||||
|
$startDate = $startDate ?? now()->subDays(30);
|
||||||
|
$endDate = $endDate ?? now();
|
||||||
|
|
||||||
|
$aggregated = ApiUsageDaily::forWorkspace($workspaceId)
|
||||||
|
->between($startDate, $endDate)
|
||||||
|
->selectRaw('
|
||||||
|
api_key_id,
|
||||||
|
SUM(request_count) as requests,
|
||||||
|
SUM(success_count) as success,
|
||||||
|
SUM(error_count) as errors,
|
||||||
|
SUM(total_response_time_ms) / NULLIF(SUM(request_count), 0) as avg_response_time
|
||||||
|
')
|
||||||
|
->groupBy('api_key_id')
|
||||||
|
->orderByDesc('requests')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
// Fetch API keys separately to avoid broken eager loading with aggregation
|
||||||
|
$apiKeyIds = $aggregated->pluck('api_key_id')->filter()->unique()->all();
|
||||||
|
$apiKeys = \Mod\Api\Models\ApiKey::whereIn('id', $apiKeyIds)
|
||||||
|
->select('id', 'name', 'prefix')
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
return $aggregated->map(fn ($row) => [
|
||||||
|
'api_key_id' => $row->api_key_id,
|
||||||
|
'api_key_name' => $apiKeys->get($row->api_key_id)?->name ?? 'Unknown',
|
||||||
|
'api_key_prefix' => $apiKeys->get($row->api_key_id)?->prefix ?? 'N/A',
|
||||||
|
'requests' => (int) $row->requests,
|
||||||
|
'success' => (int) $row->success,
|
||||||
|
'errors' => (int) $row->errors,
|
||||||
|
'success_rate' => $row->requests > 0
|
||||||
|
? round(($row->success / $row->requests) * 100, 2)
|
||||||
|
: 100,
|
||||||
|
'avg_response_time_ms' => round((float) ($row->avg_response_time ?? 0), 2),
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise endpoint path for aggregation.
|
||||||
|
*
|
||||||
|
* Replaces dynamic IDs with placeholders for consistent grouping.
|
||||||
|
*/
|
||||||
|
protected function normaliseEndpoint(string $endpoint): string
|
||||||
|
{
|
||||||
|
// Remove query string
|
||||||
|
$path = parse_url($endpoint, PHP_URL_PATH) ?? $endpoint;
|
||||||
|
|
||||||
|
// Replace numeric IDs with {id} placeholder
|
||||||
|
$normalised = preg_replace('/\/\d+/', '/{id}', $path);
|
||||||
|
|
||||||
|
// Replace UUIDs with {uuid} placeholder
|
||||||
|
$normalised = preg_replace(
|
||||||
|
'/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i',
|
||||||
|
'/{uuid}',
|
||||||
|
$normalised
|
||||||
|
);
|
||||||
|
|
||||||
|
return $normalised ?? $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get human-readable status code description.
|
||||||
|
*/
|
||||||
|
protected function getStatusCodeDescription(int $statusCode): string
|
||||||
|
{
|
||||||
|
return match ($statusCode) {
|
||||||
|
400 => 'Bad Request',
|
||||||
|
401 => 'Unauthorised',
|
||||||
|
403 => 'Forbidden',
|
||||||
|
404 => 'Not Found',
|
||||||
|
405 => 'Method Not Allowed',
|
||||||
|
422 => 'Validation Failed',
|
||||||
|
429 => 'Rate Limit Exceeded',
|
||||||
|
500 => 'Internal Server Error',
|
||||||
|
502 => 'Bad Gateway',
|
||||||
|
503 => 'Service Unavailable',
|
||||||
|
504 => 'Gateway Timeout',
|
||||||
|
default => 'Error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune old detailed usage records.
|
||||||
|
*
|
||||||
|
* Keeps aggregated daily data but removes detailed logs older than retention period.
|
||||||
|
*
|
||||||
|
* @return int Number of records deleted
|
||||||
|
*/
|
||||||
|
public function pruneOldRecords(int $retentionDays = 30): int
|
||||||
|
{
|
||||||
|
$cutoff = now()->subDays($retentionDays);
|
||||||
|
|
||||||
|
return ApiUsage::where('created_at', '<', $cutoff)->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
192
src/Mod/Api/Services/WebhookService.php
Normal file
192
src/Mod/Api/Services/WebhookService.php
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Services;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Jobs\DeliverWebhookJob;
|
||||||
|
use Core\Mod\Api\Models\WebhookDelivery;
|
||||||
|
use Core\Mod\Api\Models\WebhookEndpoint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook Service - dispatches events to registered webhook endpoints.
|
||||||
|
*
|
||||||
|
* Finds all active endpoints subscribed to an event type and queues
|
||||||
|
* delivery jobs with proper payload formatting and signature generation.
|
||||||
|
*/
|
||||||
|
class WebhookService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Dispatch an event to all subscribed webhook endpoints.
|
||||||
|
*
|
||||||
|
* @param int $workspaceId The workspace that owns the webhooks
|
||||||
|
* @param string $eventType The event type (e.g., 'bio.created')
|
||||||
|
* @param array $data The event payload data
|
||||||
|
* @return array<WebhookDelivery> The created delivery records
|
||||||
|
*/
|
||||||
|
public function dispatch(int $workspaceId, string $eventType, array $data): array
|
||||||
|
{
|
||||||
|
// Find all active endpoints for this workspace that subscribe to this event
|
||||||
|
$endpoints = WebhookEndpoint::query()
|
||||||
|
->forWorkspace($workspaceId)
|
||||||
|
->active()
|
||||||
|
->forEvent($eventType)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($endpoints->isEmpty()) {
|
||||||
|
Log::debug('No webhook endpoints found for event', [
|
||||||
|
'workspace_id' => $workspaceId,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$deliveries = [];
|
||||||
|
|
||||||
|
// Wrap all deliveries in a transaction to ensure atomicity
|
||||||
|
DB::transaction(function () use ($endpoints, $eventType, $data, $workspaceId, &$deliveries) {
|
||||||
|
foreach ($endpoints as $endpoint) {
|
||||||
|
// Create delivery record
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
$eventType,
|
||||||
|
$data,
|
||||||
|
$workspaceId
|
||||||
|
);
|
||||||
|
|
||||||
|
$deliveries[] = $delivery;
|
||||||
|
|
||||||
|
// Queue the delivery job after the transaction commits
|
||||||
|
DeliverWebhookJob::dispatch($delivery)->afterCommit();
|
||||||
|
|
||||||
|
Log::info('Webhook delivery queued', [
|
||||||
|
'delivery_id' => $delivery->id,
|
||||||
|
'endpoint_id' => $endpoint->id,
|
||||||
|
'event_type' => $eventType,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return $deliveries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retry a specific failed delivery.
|
||||||
|
*
|
||||||
|
* @return bool True if retry was queued, false if not eligible
|
||||||
|
*/
|
||||||
|
public function retry(WebhookDelivery $delivery): bool
|
||||||
|
{
|
||||||
|
if (! $delivery->canRetry()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($delivery) {
|
||||||
|
// Reset status for manual retry but preserve attempt history
|
||||||
|
$delivery->update([
|
||||||
|
'status' => WebhookDelivery::STATUS_PENDING,
|
||||||
|
'next_retry_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DeliverWebhookJob::dispatch($delivery)->afterCommit();
|
||||||
|
|
||||||
|
Log::info('Manual webhook retry queued', [
|
||||||
|
'delivery_id' => $delivery->id,
|
||||||
|
'attempt' => $delivery->attempt,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process all pending and retryable deliveries.
|
||||||
|
*
|
||||||
|
* This method is typically called by a scheduled command.
|
||||||
|
*
|
||||||
|
* @return int Number of deliveries queued
|
||||||
|
*/
|
||||||
|
public function processQueue(): int
|
||||||
|
{
|
||||||
|
$count = 0;
|
||||||
|
|
||||||
|
// Process deliveries one at a time with row locking to prevent race conditions
|
||||||
|
$deliveryIds = WebhookDelivery::query()
|
||||||
|
->needsDelivery()
|
||||||
|
->limit(100)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($deliveryIds as $deliveryId) {
|
||||||
|
DB::transaction(function () use ($deliveryId, &$count) {
|
||||||
|
// Lock the row for update to prevent concurrent processing
|
||||||
|
$delivery = WebhookDelivery::query()
|
||||||
|
->with('endpoint')
|
||||||
|
->where('id', $deliveryId)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $delivery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already being processed (status changed since initial query)
|
||||||
|
if (! in_array($delivery->status, [WebhookDelivery::STATUS_PENDING, WebhookDelivery::STATUS_RETRYING])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle inactive endpoints by cancelling the delivery
|
||||||
|
if (! $delivery->endpoint?->shouldReceive($delivery->event_type)) {
|
||||||
|
$delivery->update(['status' => WebhookDelivery::STATUS_CANCELLED]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as queued to prevent duplicate processing
|
||||||
|
$delivery->update(['status' => WebhookDelivery::STATUS_QUEUED]);
|
||||||
|
|
||||||
|
DeliverWebhookJob::dispatch($delivery)->afterCommit();
|
||||||
|
$count++;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count > 0) {
|
||||||
|
Log::info('Processed webhook queue', ['count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get delivery statistics for a workspace.
|
||||||
|
*/
|
||||||
|
public function getStats(int $workspaceId): array
|
||||||
|
{
|
||||||
|
$endpointIds = WebhookEndpoint::query()
|
||||||
|
->forWorkspace($workspaceId)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($endpointIds->isEmpty()) {
|
||||||
|
return [
|
||||||
|
'total' => 0,
|
||||||
|
'pending' => 0,
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'retrying' => 0,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$deliveries = WebhookDelivery::query()
|
||||||
|
->whereIn('webhook_endpoint_id', $endpointIds);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'total' => (clone $deliveries)->count(),
|
||||||
|
'pending' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_PENDING)->count(),
|
||||||
|
'success' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_SUCCESS)->count(),
|
||||||
|
'failed' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_FAILED)->count(),
|
||||||
|
'retrying' => (clone $deliveries)->where('status', WebhookDelivery::STATUS_RETRYING)->count(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
206
src/Mod/Api/Services/WebhookSignature.php
Normal file
206
src/Mod/Api/Services/WebhookSignature.php
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Webhook Signature Service - handles HMAC signing and verification for outbound webhooks.
|
||||||
|
*
|
||||||
|
* This service provides cryptographic signing for webhook payloads to ensure:
|
||||||
|
* 1. **Authenticity**: Recipients can verify the request came from our platform
|
||||||
|
* 2. **Integrity**: Recipients can verify the payload wasn't tampered with
|
||||||
|
* 3. **Replay Protection**: Timestamps prevent replay attacks
|
||||||
|
*
|
||||||
|
* ## Signature Algorithm
|
||||||
|
*
|
||||||
|
* The signature is computed as:
|
||||||
|
* ```
|
||||||
|
* signature = HMAC-SHA256(timestamp + "." + payload, secret)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Including the timestamp in the signed data prevents replay attacks where an
|
||||||
|
* attacker could capture a valid webhook and resend it later.
|
||||||
|
*
|
||||||
|
* ## Verification Example (for webhook recipients)
|
||||||
|
*
|
||||||
|
* ```php
|
||||||
|
* // Get headers and body from the request
|
||||||
|
* $signature = $request->header('X-Webhook-Signature');
|
||||||
|
* $timestamp = $request->header('X-Webhook-Timestamp');
|
||||||
|
* $payload = $request->getContent();
|
||||||
|
*
|
||||||
|
* // Compute expected signature
|
||||||
|
* $expectedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, $webhookSecret);
|
||||||
|
*
|
||||||
|
* // Verify signature using timing-safe comparison
|
||||||
|
* if (!hash_equals($expectedSignature, $signature)) {
|
||||||
|
* abort(401, 'Invalid webhook signature');
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // Verify timestamp is within tolerance (e.g., 5 minutes)
|
||||||
|
* $tolerance = 300; // seconds
|
||||||
|
* if (abs(time() - (int)$timestamp) > $tolerance) {
|
||||||
|
* abort(401, 'Webhook timestamp too old');
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
class WebhookSignature
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Default secret length in bytes (64 characters when hex-encoded).
|
||||||
|
*/
|
||||||
|
private const SECRET_LENGTH = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default tolerance for timestamp verification in seconds.
|
||||||
|
* 5 minutes allows for reasonable clock skew and network delays.
|
||||||
|
*/
|
||||||
|
public const DEFAULT_TOLERANCE = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hashing algorithm used for HMAC.
|
||||||
|
*/
|
||||||
|
private const ALGORITHM = 'sha256';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure webhook signing secret.
|
||||||
|
*
|
||||||
|
* The secret is a 64-character random string suitable for HMAC-SHA256 signing.
|
||||||
|
* This should be stored securely and shared with the webhook recipient out-of-band.
|
||||||
|
*
|
||||||
|
* @return string A 64-character random string
|
||||||
|
*/
|
||||||
|
public function generateSecret(): string
|
||||||
|
{
|
||||||
|
return Str::random(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a webhook payload with the given secret and timestamp.
|
||||||
|
*
|
||||||
|
* The signature format is:
|
||||||
|
* HMAC-SHA256(timestamp + "." + payload, secret)
|
||||||
|
*
|
||||||
|
* This format ensures the timestamp cannot be changed without invalidating
|
||||||
|
* the signature, providing replay attack protection.
|
||||||
|
*
|
||||||
|
* @param string $payload The JSON-encoded webhook payload
|
||||||
|
* @param string $secret The endpoint's signing secret
|
||||||
|
* @param int $timestamp Unix timestamp of when the webhook was sent
|
||||||
|
* @return string The HMAC-SHA256 signature (hex-encoded, 64 characters)
|
||||||
|
*/
|
||||||
|
public function sign(string $payload, string $secret, int $timestamp): string
|
||||||
|
{
|
||||||
|
$signedPayload = $this->buildSignedPayload($timestamp, $payload);
|
||||||
|
|
||||||
|
return hash_hmac(self::ALGORITHM, $signedPayload, $secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a webhook signature.
|
||||||
|
*
|
||||||
|
* Performs a timing-safe comparison to prevent timing attacks, and optionally
|
||||||
|
* validates that the timestamp is within the specified tolerance.
|
||||||
|
*
|
||||||
|
* @param string $payload The raw request body (JSON string)
|
||||||
|
* @param string $signature The signature from X-Webhook-Signature header
|
||||||
|
* @param string $secret The webhook endpoint's secret
|
||||||
|
* @param int $timestamp The timestamp from X-Webhook-Timestamp header
|
||||||
|
* @param int $tolerance Maximum age of the timestamp in seconds (default: 300)
|
||||||
|
* @return bool True if the signature is valid and timestamp is within tolerance
|
||||||
|
*/
|
||||||
|
public function verify(
|
||||||
|
string $payload,
|
||||||
|
string $signature,
|
||||||
|
string $secret,
|
||||||
|
int $timestamp,
|
||||||
|
int $tolerance = self::DEFAULT_TOLERANCE
|
||||||
|
): bool {
|
||||||
|
// Check timestamp is within tolerance
|
||||||
|
if (! $this->isTimestampValid($timestamp, $tolerance)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute expected signature
|
||||||
|
$expectedSignature = $this->sign($payload, $secret, $timestamp);
|
||||||
|
|
||||||
|
// Use timing-safe comparison to prevent timing attacks
|
||||||
|
return hash_equals($expectedSignature, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify signature without timestamp validation.
|
||||||
|
*
|
||||||
|
* Use this method when you need to verify the signature but handle
|
||||||
|
* timestamp validation separately (e.g., for testing or special cases).
|
||||||
|
*
|
||||||
|
* @param string $payload The raw request body
|
||||||
|
* @param string $signature The signature from the header
|
||||||
|
* @param string $secret The webhook secret
|
||||||
|
* @param int $timestamp The timestamp from the header
|
||||||
|
* @return bool True if the signature is valid
|
||||||
|
*/
|
||||||
|
public function verifySignatureOnly(
|
||||||
|
string $payload,
|
||||||
|
string $signature,
|
||||||
|
string $secret,
|
||||||
|
int $timestamp
|
||||||
|
): bool {
|
||||||
|
$expectedSignature = $this->sign($payload, $secret, $timestamp);
|
||||||
|
|
||||||
|
return hash_equals($expectedSignature, $signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a timestamp is within the allowed tolerance.
|
||||||
|
*
|
||||||
|
* @param int $timestamp The Unix timestamp to check
|
||||||
|
* @param int $tolerance Maximum age in seconds
|
||||||
|
* @return bool True if the timestamp is within tolerance
|
||||||
|
*/
|
||||||
|
public function isTimestampValid(int $timestamp, int $tolerance = self::DEFAULT_TOLERANCE): bool
|
||||||
|
{
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
return abs($now - $timestamp) <= $tolerance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the signed payload string.
|
||||||
|
*
|
||||||
|
* Format: "{timestamp}.{payload}"
|
||||||
|
*
|
||||||
|
* @param int $timestamp Unix timestamp
|
||||||
|
* @param string $payload The JSON payload
|
||||||
|
* @return string The combined string to be signed
|
||||||
|
*/
|
||||||
|
private function buildSignedPayload(int $timestamp, string $payload): string
|
||||||
|
{
|
||||||
|
return $timestamp.'.'.$payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the headers to include with a webhook request.
|
||||||
|
*
|
||||||
|
* Returns an array of headers ready to be used with HTTP client:
|
||||||
|
* - X-Webhook-Signature: The HMAC signature
|
||||||
|
* - X-Webhook-Timestamp: Unix timestamp
|
||||||
|
*
|
||||||
|
* @param string $payload The JSON-encoded payload
|
||||||
|
* @param string $secret The signing secret
|
||||||
|
* @param int|null $timestamp Unix timestamp (defaults to current time)
|
||||||
|
* @return array<string, string|int> Headers array
|
||||||
|
*/
|
||||||
|
public function getHeaders(string $payload, string $secret, ?int $timestamp = null): array
|
||||||
|
{
|
||||||
|
$timestamp ??= time();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'X-Webhook-Signature' => $this->sign($payload, $secret, $timestamp),
|
||||||
|
'X-Webhook-Timestamp' => $timestamp,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php
Normal file
232
src/Mod/Api/Tests/Feature/ApiKeyRotationTest.php
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Api\Services\ApiKeyService;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->workspace = Workspace::factory()->create();
|
||||||
|
$this->workspace->users()->attach($this->user->id, [
|
||||||
|
'role' => 'owner',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
$this->service = app(ApiKeyService::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// API Key Rotation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('API Key Rotation', function () {
|
||||||
|
it('rotates a key creating new key with same settings', function () {
|
||||||
|
$original = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Original Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->service->rotate($original['api_key']);
|
||||||
|
|
||||||
|
expect($result)->toHaveKeys(['api_key', 'plain_key', 'old_key']);
|
||||||
|
expect($result['api_key']->name)->toBe('Original Key');
|
||||||
|
expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]);
|
||||||
|
expect($result['api_key']->workspace_id)->toBe($this->workspace->id);
|
||||||
|
expect($result['api_key']->rotated_from_id)->toBe($original['api_key']->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets grace period on old key during rotation', function () {
|
||||||
|
$original = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Grace Period Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->service->rotate($original['api_key'], 24);
|
||||||
|
|
||||||
|
$oldKey = $result['old_key']->fresh();
|
||||||
|
expect($oldKey->grace_period_ends_at)->not->toBeNull();
|
||||||
|
expect($oldKey->isInGracePeriod())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('old key remains valid during grace period', function () {
|
||||||
|
$original = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Still Valid Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->service->rotate($original['api_key'], 24);
|
||||||
|
|
||||||
|
// Old key should still be findable
|
||||||
|
$foundKey = ApiKey::findByPlainKey($original['plain_key']);
|
||||||
|
expect($foundKey)->not->toBeNull();
|
||||||
|
expect($foundKey->id)->toBe($original['api_key']->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('old key becomes invalid after grace period expires', function () {
|
||||||
|
$original = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Expired Grace Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$original['api_key']->update([
|
||||||
|
'grace_period_ends_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$foundKey = ApiKey::findByPlainKey($original['plain_key']);
|
||||||
|
expect($foundKey)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prevents rotating key already in grace period', function () {
|
||||||
|
$original = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Already Rotating Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->service->rotate($original['api_key']);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->rotate($original['api_key']->fresh()))
|
||||||
|
->toThrow(\RuntimeException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can end grace period early', function () {
|
||||||
|
$original = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Early End Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->service->rotate($original['api_key'], 24);
|
||||||
|
$this->service->endGracePeriod($original['api_key']->fresh());
|
||||||
|
|
||||||
|
expect($original['api_key']->fresh()->trashed())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves server scopes during rotation', function () {
|
||||||
|
$original = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Server Scoped Key'
|
||||||
|
);
|
||||||
|
$original['api_key']->update(['server_scopes' => ['commerce', 'biohost']]);
|
||||||
|
|
||||||
|
$result = $this->service->rotate($original['api_key']->fresh());
|
||||||
|
|
||||||
|
expect($result['api_key']->server_scopes)->toBe(['commerce', 'biohost']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up keys with expired grace periods', function () {
|
||||||
|
// Create keys with expired grace periods
|
||||||
|
$key1 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 1');
|
||||||
|
$key1['api_key']->update(['grace_period_ends_at' => now()->subDay()]);
|
||||||
|
|
||||||
|
$key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired 2');
|
||||||
|
$key2['api_key']->update(['grace_period_ends_at' => now()->subHour()]);
|
||||||
|
|
||||||
|
// Create key still in grace period
|
||||||
|
$key3 = ApiKey::generate($this->workspace->id, $this->user->id, 'Still Active');
|
||||||
|
$key3['api_key']->update(['grace_period_ends_at' => now()->addDay()]);
|
||||||
|
|
||||||
|
$cleaned = $this->service->cleanupExpiredGracePeriods();
|
||||||
|
|
||||||
|
expect($cleaned)->toBe(2);
|
||||||
|
expect($key1['api_key']->fresh()->trashed())->toBeTrue();
|
||||||
|
expect($key2['api_key']->fresh()->trashed())->toBeTrue();
|
||||||
|
expect($key3['api_key']->fresh()->trashed())->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// API Key Scopes via Service
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('API Key Service Scopes', function () {
|
||||||
|
it('updates key scopes', function () {
|
||||||
|
$result = $this->service->create(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Scoped Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->service->updateScopes($result['api_key'], [ApiKey::SCOPE_READ]);
|
||||||
|
|
||||||
|
expect($result['api_key']->fresh()->scopes)->toBe([ApiKey::SCOPE_READ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requires at least one valid scope', function () {
|
||||||
|
$result = $this->service->create(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Invalid Scopes Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fn () => $this->service->updateScopes($result['api_key'], ['invalid']))
|
||||||
|
->toThrow(\InvalidArgumentException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates server scopes', function () {
|
||||||
|
$result = $this->service->create(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Server Scoped Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->service->updateServerScopes($result['api_key'], ['commerce']);
|
||||||
|
|
||||||
|
expect($result['api_key']->fresh()->server_scopes)->toBe(['commerce']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears server scopes with null', function () {
|
||||||
|
$result = $this->service->create(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Clear Server Scopes Key',
|
||||||
|
serverScopes: ['commerce']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->service->updateServerScopes($result['api_key'], null);
|
||||||
|
|
||||||
|
expect($result['api_key']->fresh()->server_scopes)->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// API Key Service Limits
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('API Key Service Limits', function () {
|
||||||
|
it('enforces max keys per workspace limit', function () {
|
||||||
|
config(['api.keys.max_per_workspace' => 2]);
|
||||||
|
|
||||||
|
$this->service->create($this->workspace->id, $this->user->id, 'Key 1');
|
||||||
|
$this->service->create($this->workspace->id, $this->user->id, 'Key 2');
|
||||||
|
|
||||||
|
expect(fn () => $this->service->create($this->workspace->id, $this->user->id, 'Key 3'))
|
||||||
|
->toThrow(\RuntimeException::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns workspace key statistics', function () {
|
||||||
|
$key1 = $this->service->create($this->workspace->id, $this->user->id, 'Active Key');
|
||||||
|
$key2 = $this->service->create($this->workspace->id, $this->user->id, 'Expired Key');
|
||||||
|
$key2['api_key']->update(['expires_at' => now()->subDay()]);
|
||||||
|
|
||||||
|
$key3 = $this->service->create($this->workspace->id, $this->user->id, 'Rotating Key');
|
||||||
|
$this->service->rotate($key3['api_key']);
|
||||||
|
|
||||||
|
$stats = $this->service->getStats($this->workspace->id);
|
||||||
|
|
||||||
|
expect($stats)->toHaveKeys(['total', 'active', 'expired', 'in_grace_period', 'revoked']);
|
||||||
|
expect($stats['total'])->toBe(4); // 3 original + 1 rotated
|
||||||
|
expect($stats['expired'])->toBe(1);
|
||||||
|
expect($stats['in_grace_period'])->toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
381
src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php
Normal file
381
src/Mod/Api/Tests/Feature/ApiKeySecurityTest.php
Normal file
|
|
@ -0,0 +1,381 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Mod\Api\Database\Factories\ApiKeyFactory;
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->workspace = Workspace::factory()->create();
|
||||||
|
$this->workspace->users()->attach($this->user->id, [
|
||||||
|
'role' => 'owner',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Secure Hashing (bcrypt)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Secure Hashing', function () {
|
||||||
|
it('uses bcrypt for new API keys', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Secure Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
|
||||||
|
expect($result['api_key']->key)->toStartWith('$2y$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies bcrypt hashed keys correctly', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Verifiable Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$parts = explode('_', $result['plain_key'], 3);
|
||||||
|
$keyPart = $parts[2];
|
||||||
|
|
||||||
|
expect($result['api_key']->verifyKey($keyPart))->toBeTrue();
|
||||||
|
expect($result['api_key']->verifyKey('wrong-key'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds bcrypt keys by plain key', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Findable Bcrypt Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$found = ApiKey::findByPlainKey($result['plain_key']);
|
||||||
|
|
||||||
|
expect($found)->not->toBeNull();
|
||||||
|
expect($found->id)->toBe($result['api_key']->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bcrypt keys are not vulnerable to timing attacks', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Timing Safe Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$parts = explode('_', $result['plain_key'], 3);
|
||||||
|
$keyPart = $parts[2];
|
||||||
|
|
||||||
|
// bcrypt verification should take similar time for valid and invalid keys
|
||||||
|
// (this is a property test, not a precise timing test)
|
||||||
|
expect($result['api_key']->verifyKey($keyPart))->toBeTrue();
|
||||||
|
expect($result['api_key']->verifyKey('x'.$keyPart))->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Legacy SHA-256 Backward Compatibility
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Legacy SHA-256 Compatibility', function () {
|
||||||
|
it('identifies legacy hash keys', function () {
|
||||||
|
$result = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_SHA256);
|
||||||
|
expect($result['api_key']->usesLegacyHash())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies legacy SHA-256 keys correctly', function () {
|
||||||
|
$result = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
$parts = explode('_', $result['plain_key'], 3);
|
||||||
|
$keyPart = $parts[2];
|
||||||
|
|
||||||
|
expect($result['api_key']->verifyKey($keyPart))->toBeTrue();
|
||||||
|
expect($result['api_key']->verifyKey('wrong-key'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finds legacy SHA-256 keys by plain key', function () {
|
||||||
|
$result = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
$found = ApiKey::findByPlainKey($result['plain_key']);
|
||||||
|
|
||||||
|
expect($found)->not->toBeNull();
|
||||||
|
expect($found->id)->toBe($result['api_key']->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats null hash_algorithm as legacy', function () {
|
||||||
|
// Create a key without hash_algorithm (simulating pre-migration key)
|
||||||
|
$plainKey = Str::random(48);
|
||||||
|
$prefix = 'hk_'.Str::random(8);
|
||||||
|
|
||||||
|
$apiKey = ApiKey::create([
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'user_id' => $this->user->id,
|
||||||
|
'name' => 'Pre-migration Key',
|
||||||
|
'key' => hash('sha256', $plainKey),
|
||||||
|
'hash_algorithm' => null, // Simulate pre-migration
|
||||||
|
'prefix' => $prefix,
|
||||||
|
'scopes' => [ApiKey::SCOPE_READ],
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($apiKey->usesLegacyHash())->toBeTrue();
|
||||||
|
|
||||||
|
// Should still be findable
|
||||||
|
$found = ApiKey::findByPlainKey("{$prefix}_{$plainKey}");
|
||||||
|
expect($found)->not->toBeNull();
|
||||||
|
expect($found->id)->toBe($apiKey->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can query for legacy hash keys', function () {
|
||||||
|
// Create a bcrypt key
|
||||||
|
ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Secure Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a legacy key
|
||||||
|
ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
$legacyKeys = ApiKey::legacyHash()->get();
|
||||||
|
$secureKeys = ApiKey::secureHash()->get();
|
||||||
|
|
||||||
|
expect($legacyKeys)->toHaveCount(1);
|
||||||
|
expect($secureKeys)->toHaveCount(1);
|
||||||
|
expect($legacyKeys->first()->name)->toContain('API Key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Key Rotation for Security Migration
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Security Migration via Rotation', function () {
|
||||||
|
it('rotates legacy key to secure bcrypt key', function () {
|
||||||
|
$legacy = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($legacy['api_key']->usesLegacyHash())->toBeTrue();
|
||||||
|
|
||||||
|
$rotated = $legacy['api_key']->rotate();
|
||||||
|
|
||||||
|
expect($rotated['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
|
||||||
|
expect($rotated['api_key']->usesLegacyHash())->toBeFalse();
|
||||||
|
expect($rotated['api_key']->key)->toStartWith('$2y$');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves settings when rotating legacy key', function () {
|
||||||
|
$legacy = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user,
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$legacy['api_key']->update(['server_scopes' => ['commerce', 'biohost']]);
|
||||||
|
|
||||||
|
$rotated = $legacy['api_key']->fresh()->rotate();
|
||||||
|
|
||||||
|
expect($rotated['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]);
|
||||||
|
expect($rotated['api_key']->server_scopes)->toBe(['commerce', 'biohost']);
|
||||||
|
expect($rotated['api_key']->workspace_id)->toBe($this->workspace->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('legacy key remains valid during grace period after rotation', function () {
|
||||||
|
$legacy = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
$legacy['api_key']->rotate(24); // 24 hour grace period
|
||||||
|
|
||||||
|
// Old key should still work
|
||||||
|
$found = ApiKey::findByPlainKey($legacy['plain_key']);
|
||||||
|
expect($found)->not->toBeNull();
|
||||||
|
expect($found->isInGracePeriod())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tracks rotation lineage', function () {
|
||||||
|
$original = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
$rotated = $original['api_key']->rotate();
|
||||||
|
|
||||||
|
expect($rotated['api_key']->rotated_from_id)->toBe($original['api_key']->id);
|
||||||
|
expect($rotated['api_key']->rotatedFrom->id)->toBe($original['api_key']->id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Grace Period Handling
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Grace Period', function () {
|
||||||
|
it('sets grace period on rotation', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'To Be Rotated'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result['api_key']->rotate(48);
|
||||||
|
|
||||||
|
$oldKey = $result['api_key']->fresh();
|
||||||
|
expect($oldKey->grace_period_ends_at)->not->toBeNull();
|
||||||
|
expect($oldKey->isInGracePeriod())->toBeTrue();
|
||||||
|
expect($oldKey->grace_period_ends_at->diffInHours(now()))->toBeLessThanOrEqual(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('key becomes invalid after grace period expires', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Expiring Grace Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result['api_key']->update([
|
||||||
|
'grace_period_ends_at' => now()->subHour(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$found = ApiKey::findByPlainKey($result['plain_key']);
|
||||||
|
expect($found)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can end grace period early', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Early End Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result['api_key']->rotate(24);
|
||||||
|
|
||||||
|
$oldKey = $result['api_key']->fresh();
|
||||||
|
expect($oldKey->isInGracePeriod())->toBeTrue();
|
||||||
|
|
||||||
|
$oldKey->endGracePeriod();
|
||||||
|
|
||||||
|
expect($oldKey->fresh()->trashed())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes keys in grace period correctly', function () {
|
||||||
|
// Key in grace period
|
||||||
|
$key1 = ApiKey::generate($this->workspace->id, $this->user->id, 'In Grace');
|
||||||
|
$key1['api_key']->update(['grace_period_ends_at' => now()->addHours(12)]);
|
||||||
|
|
||||||
|
// Key with expired grace period
|
||||||
|
$key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Expired Grace');
|
||||||
|
$key2['api_key']->update(['grace_period_ends_at' => now()->subHours(1)]);
|
||||||
|
|
||||||
|
// Normal key
|
||||||
|
ApiKey::generate($this->workspace->id, $this->user->id, 'Normal Key');
|
||||||
|
|
||||||
|
expect(ApiKey::inGracePeriod()->count())->toBe(1);
|
||||||
|
expect(ApiKey::gracePeriodExpired()->count())->toBe(1);
|
||||||
|
expect(ApiKey::active()->count())->toBe(2); // Normal + In Grace
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects grace period expired status', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Status Check Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not in grace period
|
||||||
|
expect($result['api_key']->isInGracePeriod())->toBeFalse();
|
||||||
|
expect($result['api_key']->isGracePeriodExpired())->toBeFalse();
|
||||||
|
|
||||||
|
// In grace period
|
||||||
|
$result['api_key']->update(['grace_period_ends_at' => now()->addHour()]);
|
||||||
|
expect($result['api_key']->fresh()->isInGracePeriod())->toBeTrue();
|
||||||
|
expect($result['api_key']->fresh()->isGracePeriodExpired())->toBeFalse();
|
||||||
|
|
||||||
|
// Grace period expired
|
||||||
|
$result['api_key']->update(['grace_period_ends_at' => now()->subHour()]);
|
||||||
|
expect($result['api_key']->fresh()->isInGracePeriod())->toBeFalse();
|
||||||
|
expect($result['api_key']->fresh()->isGracePeriodExpired())->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Hash Algorithm Constants
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Hash Algorithm Constants', function () {
|
||||||
|
it('defines correct hash algorithm constants', function () {
|
||||||
|
expect(ApiKey::HASH_SHA256)->toBe('sha256');
|
||||||
|
expect(ApiKey::HASH_BCRYPT)->toBe('bcrypt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defines default grace period constant', function () {
|
||||||
|
expect(ApiKey::DEFAULT_GRACE_PERIOD_HOURS)->toBe(24);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Factory Legacy Support
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Factory Legacy Support', function () {
|
||||||
|
it('creates legacy keys via static helper', function () {
|
||||||
|
$result = ApiKeyFactory::createLegacyKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_SHA256);
|
||||||
|
expect($result['api_key']->key)->not->toStartWith('$2y$');
|
||||||
|
|
||||||
|
// Should be a 64-char hex string (SHA-256)
|
||||||
|
expect(strlen($result['api_key']->key))->toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates keys in grace period via factory', function () {
|
||||||
|
$key = ApiKey::factory()
|
||||||
|
->for($this->workspace)
|
||||||
|
->for($this->user)
|
||||||
|
->inGracePeriod(6)
|
||||||
|
->create();
|
||||||
|
|
||||||
|
expect($key->isInGracePeriod())->toBeTrue();
|
||||||
|
expect($key->grace_period_ends_at->diffInHours(now()))->toBeLessThanOrEqual(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates keys with expired grace period via factory', function () {
|
||||||
|
$key = ApiKey::factory()
|
||||||
|
->for($this->workspace)
|
||||||
|
->for($this->user)
|
||||||
|
->gracePeriodExpired()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
expect($key->isGracePeriodExpired())->toBeTrue();
|
||||||
|
expect($key->isInGracePeriod())->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
617
src/Mod/Api/Tests/Feature/ApiKeyTest.php
Normal file
617
src/Mod/Api/Tests/Feature/ApiKeyTest.php
Normal file
|
|
@ -0,0 +1,617 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Mod\Api\Database\Factories\ApiKeyFactory;
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->workspace = Workspace::factory()->create();
|
||||||
|
$this->workspace->users()->attach($this->user->id, [
|
||||||
|
'role' => 'owner',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// API Key Creation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('API Key Creation', function () {
|
||||||
|
it('generates a new API key with correct format', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Test API Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result)->toHaveKeys(['api_key', 'plain_key']);
|
||||||
|
expect($result['api_key'])->toBeInstanceOf(ApiKey::class);
|
||||||
|
expect($result['plain_key'])->toStartWith('hk_');
|
||||||
|
|
||||||
|
// Plain key format: hk_xxxxxxxx_xxxx...
|
||||||
|
$parts = explode('_', $result['plain_key']);
|
||||||
|
expect($parts)->toHaveCount(3);
|
||||||
|
expect($parts[0])->toBe('hk');
|
||||||
|
expect(strlen($parts[1]))->toBe(8);
|
||||||
|
expect(strlen($parts[2]))->toBe(48);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates key with default read and write scopes', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Default Scopes Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->scopes)->toBe([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates key with custom scopes', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Full Access Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->scopes)->toBe(ApiKey::ALL_SCOPES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates key with expiry date', function () {
|
||||||
|
$expiresAt = now()->addDays(30);
|
||||||
|
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Expiring Key',
|
||||||
|
[ApiKey::SCOPE_READ],
|
||||||
|
$expiresAt
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->expires_at)->not->toBeNull();
|
||||||
|
expect($result['api_key']->expires_at->timestamp)->toBe($expiresAt->timestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores key as bcrypt hashed value', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Hashed Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract the key part from plain key
|
||||||
|
$parts = explode('_', $result['plain_key'], 3);
|
||||||
|
$keyPart = $parts[2];
|
||||||
|
|
||||||
|
// The stored key should be a bcrypt hash (starts with $2y$)
|
||||||
|
expect($result['api_key']->key)->toStartWith('$2y$');
|
||||||
|
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
|
||||||
|
|
||||||
|
// Verify the key matches using Hash::check
|
||||||
|
expect(\Illuminate\Support\Facades\Hash::check($keyPart, $result['api_key']->key))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets hash_algorithm to bcrypt for new keys', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Bcrypt Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->hash_algorithm)->toBe(ApiKey::HASH_BCRYPT);
|
||||||
|
expect($result['api_key']->usesLegacyHash())->toBeFalse();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// API Key Authentication
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('API Key Authentication', function () {
|
||||||
|
it('finds key by valid plain key', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Findable Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||||
|
|
||||||
|
expect($foundKey)->not->toBeNull();
|
||||||
|
expect($foundKey->id)->toBe($result['api_key']->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for invalid key format', function () {
|
||||||
|
expect(ApiKey::findByPlainKey('invalid-key'))->toBeNull();
|
||||||
|
expect(ApiKey::findByPlainKey('hk_only_two_parts'))->toBeNull();
|
||||||
|
expect(ApiKey::findByPlainKey(''))->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for non-existent key', function () {
|
||||||
|
$result = ApiKey::findByPlainKey('hk_nonexist_'.str_repeat('x', 48));
|
||||||
|
|
||||||
|
expect($result)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for expired key', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Expired Key',
|
||||||
|
[ApiKey::SCOPE_READ],
|
||||||
|
now()->subDay() // Already expired
|
||||||
|
);
|
||||||
|
|
||||||
|
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||||
|
|
||||||
|
expect($foundKey)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for revoked (soft-deleted) key', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Revoked Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$result['api_key']->revoke();
|
||||||
|
|
||||||
|
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||||
|
|
||||||
|
expect($foundKey)->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records usage on authentication', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Tracking Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->last_used_at)->toBeNull();
|
||||||
|
|
||||||
|
$result['api_key']->recordUsage();
|
||||||
|
|
||||||
|
expect($result['api_key']->fresh()->last_used_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Scope Checking
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Scope Checking', function () {
|
||||||
|
it('checks for single scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Scoped Key',
|
||||||
|
[ApiKey::SCOPE_READ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$key = $result['api_key'];
|
||||||
|
|
||||||
|
expect($key->hasScope(ApiKey::SCOPE_READ))->toBeTrue();
|
||||||
|
expect($key->hasScope(ApiKey::SCOPE_WRITE))->toBeFalse();
|
||||||
|
expect($key->hasScope(ApiKey::SCOPE_DELETE))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks for multiple scopes', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Multi-Scoped Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$key = $result['api_key'];
|
||||||
|
|
||||||
|
expect($key->hasScopes([ApiKey::SCOPE_READ]))->toBeTrue();
|
||||||
|
expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]))->toBeTrue();
|
||||||
|
expect($key->hasScopes([ApiKey::SCOPE_READ, ApiKey::SCOPE_DELETE]))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns available scope constants', function () {
|
||||||
|
expect(ApiKey::SCOPE_READ)->toBe('read');
|
||||||
|
expect(ApiKey::SCOPE_WRITE)->toBe('write');
|
||||||
|
expect(ApiKey::SCOPE_DELETE)->toBe('delete');
|
||||||
|
expect(ApiKey::ALL_SCOPES)->toBe(['read', 'write', 'delete']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Expiry Handling
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Expiry Handling', function () {
|
||||||
|
it('detects expired key', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Past Expiry Key',
|
||||||
|
[ApiKey::SCOPE_READ],
|
||||||
|
now()->subDay()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->isExpired())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects non-expired key', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Future Expiry Key',
|
||||||
|
[ApiKey::SCOPE_READ],
|
||||||
|
now()->addDay()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->isExpired())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keys without expiry are never expired', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'No Expiry Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->expires_at)->toBeNull();
|
||||||
|
expect($result['api_key']->isExpired())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes expired keys correctly', function () {
|
||||||
|
// Create expired key
|
||||||
|
ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Expired Key 1',
|
||||||
|
[ApiKey::SCOPE_READ],
|
||||||
|
now()->subDays(2)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create active key
|
||||||
|
ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Active Key',
|
||||||
|
[ApiKey::SCOPE_READ],
|
||||||
|
now()->addDays(30)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create no-expiry key
|
||||||
|
ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'No Expiry Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$expired = ApiKey::expired()->count();
|
||||||
|
$active = ApiKey::active()->count();
|
||||||
|
|
||||||
|
expect($expired)->toBe(1);
|
||||||
|
expect($active)->toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Server Scopes (MCP Access)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Server Scopes', function () {
|
||||||
|
it('allows all servers when server_scopes is null', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'All Servers Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$key = $result['api_key'];
|
||||||
|
|
||||||
|
expect($key->server_scopes)->toBeNull();
|
||||||
|
expect($key->hasServerAccess('commerce'))->toBeTrue();
|
||||||
|
expect($key->hasServerAccess('biohost'))->toBeTrue();
|
||||||
|
expect($key->hasServerAccess('anything'))->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restricts to specific servers when server_scopes is set', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Limited Servers Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$key = $result['api_key'];
|
||||||
|
$key->update(['server_scopes' => ['commerce', 'biohost']]);
|
||||||
|
|
||||||
|
expect($key->hasServerAccess('commerce'))->toBeTrue();
|
||||||
|
expect($key->hasServerAccess('biohost'))->toBeTrue();
|
||||||
|
expect($key->hasServerAccess('analytics'))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns allowed servers list', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Specific Servers Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$key = $result['api_key'];
|
||||||
|
$key->update(['server_scopes' => ['commerce']]);
|
||||||
|
|
||||||
|
expect($key->getAllowedServers())->toBe(['commerce']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Key Revocation
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Key Revocation', function () {
|
||||||
|
it('revokes key via soft delete', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'To Be Revoked'
|
||||||
|
);
|
||||||
|
|
||||||
|
$key = $result['api_key'];
|
||||||
|
$keyId = $key->id;
|
||||||
|
|
||||||
|
$key->revoke();
|
||||||
|
|
||||||
|
// Should be soft deleted
|
||||||
|
expect(ApiKey::find($keyId))->toBeNull();
|
||||||
|
expect(ApiKey::withTrashed()->find($keyId))->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('revoked keys are excluded from workspace scope', function () {
|
||||||
|
// Create active key
|
||||||
|
ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Active Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create and revoke a key
|
||||||
|
$revokedResult = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Revoked Key'
|
||||||
|
);
|
||||||
|
$revokedResult['api_key']->revoke();
|
||||||
|
|
||||||
|
$keys = ApiKey::forWorkspace($this->workspace->id)->get();
|
||||||
|
|
||||||
|
expect($keys)->toHaveCount(1);
|
||||||
|
expect($keys->first()->name)->toBe('Active Key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Masked Key Display
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Masked Key Display', function () {
|
||||||
|
it('provides masked key for display', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Masked Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$key = $result['api_key'];
|
||||||
|
$maskedKey = $key->masked_key;
|
||||||
|
|
||||||
|
expect($maskedKey)->toStartWith($key->prefix);
|
||||||
|
expect($maskedKey)->toEndWith('_****');
|
||||||
|
expect($maskedKey)->toBe("{$key->prefix}_****");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides raw key in JSON serialization', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Hidden Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$json = $result['api_key']->toArray();
|
||||||
|
|
||||||
|
expect($json)->not->toHaveKey('key');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Relationships
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Relationships', function () {
|
||||||
|
it('belongs to workspace', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Workspace Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->workspace->id)->toBe($this->workspace->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('belongs to user', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'User Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result['api_key']->user->id)->toBe($this->user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is deleted when workspace is deleted', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Cascade Key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$keyId = $result['api_key']->id;
|
||||||
|
|
||||||
|
$this->workspace->delete();
|
||||||
|
|
||||||
|
expect(ApiKey::withTrashed()->find($keyId))->toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Factory Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Factory', function () {
|
||||||
|
it('creates key via factory', function () {
|
||||||
|
$key = ApiKey::factory()
|
||||||
|
->for($this->workspace)
|
||||||
|
->for($this->user)
|
||||||
|
->create();
|
||||||
|
|
||||||
|
expect($key)->toBeInstanceOf(ApiKey::class);
|
||||||
|
expect($key->workspace_id)->toBe($this->workspace->id);
|
||||||
|
expect($key->user_id)->toBe($this->user->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates read-only key via factory', function () {
|
||||||
|
$key = ApiKey::factory()
|
||||||
|
->for($this->workspace)
|
||||||
|
->for($this->user)
|
||||||
|
->readOnly()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
expect($key->scopes)->toBe([ApiKey::SCOPE_READ]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates full access key via factory', function () {
|
||||||
|
$key = ApiKey::factory()
|
||||||
|
->for($this->workspace)
|
||||||
|
->for($this->user)
|
||||||
|
->fullAccess()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
expect($key->scopes)->toBe(ApiKey::ALL_SCOPES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates expired key via factory', function () {
|
||||||
|
$key = ApiKey::factory()
|
||||||
|
->for($this->workspace)
|
||||||
|
->for($this->user)
|
||||||
|
->expired()
|
||||||
|
->create();
|
||||||
|
|
||||||
|
expect($key->isExpired())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates key with known credentials via helper', function () {
|
||||||
|
$result = ApiKeyFactory::createWithPlainKey(
|
||||||
|
$this->workspace,
|
||||||
|
$this->user,
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($result)->toHaveKeys(['api_key', 'plain_key']);
|
||||||
|
|
||||||
|
// Verify the plain key works for lookup
|
||||||
|
$foundKey = ApiKey::findByPlainKey($result['plain_key']);
|
||||||
|
expect($foundKey)->not->toBeNull();
|
||||||
|
expect($foundKey->id)->toBe($result['api_key']->id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rate Limiting (Integration)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Rate Limiting Configuration', function () {
|
||||||
|
it('has default rate limits configured', function () {
|
||||||
|
$default = config('api.rate_limits.default');
|
||||||
|
|
||||||
|
expect($default)->toHaveKeys(['requests', 'per_minutes']);
|
||||||
|
expect($default['requests'])->toBeInt();
|
||||||
|
expect($default['per_minutes'])->toBeInt();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has authenticated rate limits configured', function () {
|
||||||
|
$authenticated = config('api.rate_limits.authenticated');
|
||||||
|
|
||||||
|
expect($authenticated)->toHaveKeys(['requests', 'per_minutes']);
|
||||||
|
expect($authenticated['requests'])->toBeGreaterThan(config('api.rate_limits.default.requests'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has tier-based rate limits configured', function () {
|
||||||
|
$tiers = ['starter', 'pro', 'agency', 'enterprise'];
|
||||||
|
|
||||||
|
foreach ($tiers as $tier) {
|
||||||
|
$limits = config("api.rate_limits.by_tier.{$tier}");
|
||||||
|
expect($limits)->toHaveKeys(['requests', 'per_minutes']);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tier limits increase with tier level', function () {
|
||||||
|
$starter = config('api.rate_limits.by_tier.starter.requests');
|
||||||
|
$pro = config('api.rate_limits.by_tier.pro.requests');
|
||||||
|
$agency = config('api.rate_limits.by_tier.agency.requests');
|
||||||
|
$enterprise = config('api.rate_limits.by_tier.enterprise.requests');
|
||||||
|
|
||||||
|
expect($pro)->toBeGreaterThan($starter);
|
||||||
|
expect($agency)->toBeGreaterThan($pro);
|
||||||
|
expect($enterprise)->toBeGreaterThan($agency);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has route-level rate limit names configured', function () {
|
||||||
|
$routeLimits = config('api.rate_limits.routes');
|
||||||
|
|
||||||
|
expect($routeLimits)->toBeArray();
|
||||||
|
expect($routeLimits)->toHaveKeys(['mcp', 'pixel']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// HTTP Authentication Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('HTTP Authentication', function () {
|
||||||
|
it('requires authorization header', function () {
|
||||||
|
$response = $this->getJson('/api/mcp/servers');
|
||||||
|
|
||||||
|
expect($response->status())->toBe(401);
|
||||||
|
expect($response->json('error'))->toBe('unauthorized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid API key', function () {
|
||||||
|
$response = $this->getJson('/api/mcp/servers', [
|
||||||
|
'Authorization' => 'Bearer hk_invalid_'.str_repeat('x', 48),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expired API key via HTTP', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Expired HTTP Key',
|
||||||
|
[ApiKey::SCOPE_READ],
|
||||||
|
now()->subDay()
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/mcp/servers', [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
232
src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php
Normal file
232
src/Mod/Api/Tests/Feature/ApiScopeEnforcementTest.php
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Cache::flush();
|
||||||
|
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->workspace = Workspace::factory()->create();
|
||||||
|
$this->workspace->users()->attach($this->user->id, [
|
||||||
|
'role' => 'owner',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Register test routes with scope enforcement
|
||||||
|
Route::middleware(['api', 'api.auth', 'api.scope.enforce'])
|
||||||
|
->prefix('test-scope')
|
||||||
|
->group(function () {
|
||||||
|
Route::get('/read', fn () => response()->json(['status' => 'ok']));
|
||||||
|
Route::post('/write', fn () => response()->json(['status' => 'ok']));
|
||||||
|
Route::put('/update', fn () => response()->json(['status' => 'ok']));
|
||||||
|
Route::patch('/patch', fn () => response()->json(['status' => 'ok']));
|
||||||
|
Route::delete('/delete', fn () => response()->json(['status' => 'ok']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Read Scope Enforcement
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Read Scope Enforcement', function () {
|
||||||
|
it('allows GET request with read scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read Only Key',
|
||||||
|
[ApiKey::SCOPE_READ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->getJson('/api/test-scope/read', [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(200);
|
||||||
|
expect($response->json('status'))->toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies POST request with read-only scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read Only Key',
|
||||||
|
[ApiKey::SCOPE_READ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/test-scope/write', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json('error'))->toBe('forbidden');
|
||||||
|
expect($response->json('message'))->toContain('write');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies DELETE request with read-only scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read Only Key',
|
||||||
|
[ApiKey::SCOPE_READ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->deleteJson('/api/test-scope/delete', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json('error'))->toBe('forbidden');
|
||||||
|
expect($response->json('message'))->toContain('delete');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Write Scope Enforcement
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Write Scope Enforcement', function () {
|
||||||
|
it('allows POST request with write scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read/Write Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->postJson('/api/test-scope/write', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows PUT request with write scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read/Write Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->putJson('/api/test-scope/update', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows PATCH request with write scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read/Write Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->patchJson('/api/test-scope/patch', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies DELETE request without delete scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read/Write Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->deleteJson('/api/test-scope/delete', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json('message'))->toContain('delete');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Delete Scope Enforcement
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Delete Scope Enforcement', function () {
|
||||||
|
it('allows DELETE request with delete scope', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Full Access Key',
|
||||||
|
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->deleteJson('/api/test-scope/delete', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes key scopes in error response', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Read Only Key',
|
||||||
|
[ApiKey::SCOPE_READ]
|
||||||
|
);
|
||||||
|
|
||||||
|
$response = $this->deleteJson('/api/test-scope/delete', [], [
|
||||||
|
'Authorization' => "Bearer {$result['plain_key']}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($response->status())->toBe(403);
|
||||||
|
expect($response->json('key_scopes'))->toBe([ApiKey::SCOPE_READ]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Full Access Keys
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Full Access Keys', function () {
|
||||||
|
it('allows all operations with full access', function () {
|
||||||
|
$result = ApiKey::generate(
|
||||||
|
$this->workspace->id,
|
||||||
|
$this->user->id,
|
||||||
|
'Full Access Key',
|
||||||
|
ApiKey::ALL_SCOPES
|
||||||
|
);
|
||||||
|
|
||||||
|
$headers = ['Authorization' => "Bearer {$result['plain_key']}"];
|
||||||
|
|
||||||
|
expect($this->getJson('/api/test-scope/read', $headers)->status())->toBe(200);
|
||||||
|
expect($this->postJson('/api/test-scope/write', [], $headers)->status())->toBe(200);
|
||||||
|
expect($this->putJson('/api/test-scope/update', [], $headers)->status())->toBe(200);
|
||||||
|
expect($this->patchJson('/api/test-scope/patch', [], $headers)->status())->toBe(200);
|
||||||
|
expect($this->deleteJson('/api/test-scope/delete', [], $headers)->status())->toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Non-API Key Auth (Session)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Non-API Key Auth', function () {
|
||||||
|
it('passes through for session authenticated users', function () {
|
||||||
|
// For session auth, the middleware should allow through
|
||||||
|
// as scope enforcement only applies to API key auth
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
|
||||||
|
// The api.auth middleware will require API key, so this tests
|
||||||
|
// that if somehow session auth is used, scope middleware allows it
|
||||||
|
// In practice, routes use either 'auth' OR 'api.auth', not both
|
||||||
|
});
|
||||||
|
});
|
||||||
362
src/Mod/Api/Tests/Feature/ApiUsageTest.php
Normal file
362
src/Mod/Api/Tests/Feature/ApiUsageTest.php
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Mod\Api\Models\ApiKey;
|
||||||
|
use Mod\Api\Models\ApiUsage;
|
||||||
|
use Mod\Api\Models\ApiUsageDaily;
|
||||||
|
use Mod\Api\Services\ApiUsageService;
|
||||||
|
use Mod\Tenant\Models\User;
|
||||||
|
use Mod\Tenant\Models\Workspace;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->workspace = Workspace::factory()->create();
|
||||||
|
$this->workspace->users()->attach($this->user->id, [
|
||||||
|
'role' => 'owner',
|
||||||
|
'is_default' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$result = ApiKey::generate($this->workspace->id, $this->user->id, 'Test Key');
|
||||||
|
$this->apiKey = $result['api_key'];
|
||||||
|
|
||||||
|
$this->service = app(ApiUsageService::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Recording Usage
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Recording API Usage', function () {
|
||||||
|
it('records individual usage entries', function () {
|
||||||
|
$usage = $this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/workspaces',
|
||||||
|
method: 'GET',
|
||||||
|
statusCode: 200,
|
||||||
|
responseTimeMs: 150,
|
||||||
|
requestSize: 0,
|
||||||
|
responseSize: 1024
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($usage)->toBeInstanceOf(ApiUsage::class);
|
||||||
|
expect($usage->api_key_id)->toBe($this->apiKey->id);
|
||||||
|
expect($usage->endpoint)->toBe('/api/v1/workspaces');
|
||||||
|
expect($usage->method)->toBe('GET');
|
||||||
|
expect($usage->status_code)->toBe(200);
|
||||||
|
expect($usage->response_time_ms)->toBe(150);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises endpoint paths with IDs', function () {
|
||||||
|
$usage = $this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/workspaces/123/users/456',
|
||||||
|
method: 'GET',
|
||||||
|
statusCode: 200,
|
||||||
|
responseTimeMs: 100
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($usage->endpoint)->toBe('/api/v1/workspaces/{id}/users/{id}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises endpoint paths with UUIDs', function () {
|
||||||
|
$usage = $this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/resources/550e8400-e29b-41d4-a716-446655440000',
|
||||||
|
method: 'GET',
|
||||||
|
statusCode: 200,
|
||||||
|
responseTimeMs: 100
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($usage->endpoint)->toBe('/api/v1/resources/{uuid}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates daily aggregation on record', function () {
|
||||||
|
$this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/test',
|
||||||
|
method: 'GET',
|
||||||
|
statusCode: 200,
|
||||||
|
responseTimeMs: 100
|
||||||
|
);
|
||||||
|
|
||||||
|
$daily = ApiUsageDaily::forKey($this->apiKey->id)
|
||||||
|
->where('date', now()->toDateString())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($daily)->not->toBeNull();
|
||||||
|
expect($daily->request_count)->toBe(1);
|
||||||
|
expect($daily->success_count)->toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('increments daily counts correctly', function () {
|
||||||
|
// Record multiple requests
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/test',
|
||||||
|
method: 'GET',
|
||||||
|
statusCode: 200,
|
||||||
|
responseTimeMs: 100 + ($i * 10)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record some errors
|
||||||
|
for ($i = 0; $i < 2; $i++) {
|
||||||
|
$this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/test',
|
||||||
|
method: 'GET',
|
||||||
|
statusCode: 500,
|
||||||
|
responseTimeMs: 50
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$daily = ApiUsageDaily::forKey($this->apiKey->id)
|
||||||
|
->where('date', now()->toDateString())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
expect($daily->request_count)->toBe(7);
|
||||||
|
expect($daily->success_count)->toBe(5);
|
||||||
|
expect($daily->error_count)->toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Usage Summaries
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Usage Summaries', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
// Create some usage data
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/workspaces',
|
||||||
|
method: 'GET',
|
||||||
|
statusCode: 200,
|
||||||
|
responseTimeMs: 100 + $i
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$this->service->record(
|
||||||
|
apiKeyId: $this->apiKey->id,
|
||||||
|
workspaceId: $this->workspace->id,
|
||||||
|
endpoint: '/api/v1/workspaces',
|
||||||
|
method: 'POST',
|
||||||
|
statusCode: 422,
|
||||||
|
responseTimeMs: 50
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns workspace summary', function () {
|
||||||
|
$summary = $this->service->getWorkspaceSummary($this->workspace->id);
|
||||||
|
|
||||||
|
expect($summary)->toHaveKeys(['period', 'totals', 'response_time', 'data_transfer']);
|
||||||
|
expect($summary['totals']['requests'])->toBe(13);
|
||||||
|
expect($summary['totals']['success'])->toBe(10);
|
||||||
|
expect($summary['totals']['errors'])->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns key summary', function () {
|
||||||
|
$summary = $this->service->getKeySummary($this->apiKey->id);
|
||||||
|
|
||||||
|
expect($summary['totals']['requests'])->toBe(13);
|
||||||
|
expect($summary['totals']['success_rate'])->toBeGreaterThan(70);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates average response time', function () {
|
||||||
|
$summary = $this->service->getWorkspaceSummary($this->workspace->id);
|
||||||
|
|
||||||
|
// (100+101+102+...+109 + 50*3) / 13
|
||||||
|
expect($summary['response_time']['average_ms'])->toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by date range', function () {
|
||||||
|
// Create usage for 2 days ago with correct timestamp upfront
|
||||||
|
$oldDate = now()->subDays(2);
|
||||||
|
$usage = ApiUsage::create([
|
||||||
|
'api_key_id' => $this->apiKey->id,
|
||||||
|
'workspace_id' => $this->workspace->id,
|
||||||
|
'endpoint' => '/api/v1/old',
|
||||||
|
'method' => 'GET',
|
||||||
|
'status_code' => 200,
|
||||||
|
'response_time_ms' => 100,
|
||||||
|
'created_at' => $oldDate,
|
||||||
|
'updated_at' => $oldDate,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Also create a backdated daily aggregate for consistency
|
||||||
|
ApiUsageDaily::updateOrCreate(
|
||||||
|
[
|
||||||
|
'api_key_id' => $this->apiKey->id,
|
||||||
|
'date' => $oldDate->toDateString(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'request_count' => 1,
|
||||||
|
'success_count' => 1,
|
||||||
|
'error_count' => 0,
|
||||||
|
'total_response_time_ms' => 100,
|
||||||
|
'total_request_size' => 0,
|
||||||
|
'total_response_size' => 0,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Summary for last 24 hours should not include old data
|
||||||
|
$summary = $this->service->getWorkspaceSummary(
|
||||||
|
$this->workspace->id,
|
||||||
|
now()->subDay(),
|
||||||
|
now()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($summary['totals']['requests'])->toBe(13); // Only today's requests
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Charts and Reports
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Charts and Reports', function () {
|
||||||
|
beforeEach(function () {
|
||||||
|
// Create usage spread across days
|
||||||
|
for ($day = 0; $day < 7; $day++) {
|
||||||
|
$date = now()->subDays($day);
|
||||||
|
$requests = 10 - $day;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $requests; $i++) {
|
||||||
|
$usage = ApiUsage::record(
|
||||||
|
$this->apiKey->id,
|
||||||
|
$this->workspace->id,
|
||||||
|
'/api/v1/test',
|
||||||
|
'GET',
|
||||||
|
200,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
$usage->update(['created_at' => $date]);
|
||||||
|
|
||||||
|
ApiUsageDaily::recordFromUsage($usage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns daily chart data', function () {
|
||||||
|
$chart = $this->service->getDailyChart($this->workspace->id);
|
||||||
|
|
||||||
|
expect($chart)->toBeArray();
|
||||||
|
expect(count($chart))->toBeGreaterThan(0);
|
||||||
|
expect($chart[0])->toHaveKeys(['date', 'requests', 'success', 'errors', 'avg_response_time_ms']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns top endpoints', function () {
|
||||||
|
// Add some variety
|
||||||
|
$this->service->record(
|
||||||
|
$this->apiKey->id,
|
||||||
|
$this->workspace->id,
|
||||||
|
'/api/v1/popular',
|
||||||
|
'GET',
|
||||||
|
200,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
|
||||||
|
$endpoints = $this->service->getTopEndpoints($this->workspace->id, 5);
|
||||||
|
|
||||||
|
expect($endpoints)->toBeArray();
|
||||||
|
expect($endpoints[0])->toHaveKeys(['endpoint', 'method', 'requests', 'success_rate', 'avg_response_time_ms']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns error breakdown', function () {
|
||||||
|
// Add some errors
|
||||||
|
$this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 401, 50);
|
||||||
|
$this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 404, 50);
|
||||||
|
$this->service->record($this->apiKey->id, $this->workspace->id, '/api/v1/test', 'GET', 500, 50);
|
||||||
|
|
||||||
|
$errors = $this->service->getErrorBreakdown($this->workspace->id);
|
||||||
|
|
||||||
|
expect($errors)->toBeArray();
|
||||||
|
expect(count($errors))->toBe(3);
|
||||||
|
expect($errors[0])->toHaveKeys(['status_code', 'count', 'description']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns key comparison', function () {
|
||||||
|
// Create another key with usage
|
||||||
|
$key2 = ApiKey::generate($this->workspace->id, $this->user->id, 'Second Key');
|
||||||
|
$this->service->record($key2['api_key']->id, $this->workspace->id, '/api/v1/test', 'GET', 200, 100);
|
||||||
|
|
||||||
|
$comparison = $this->service->getKeyComparison($this->workspace->id);
|
||||||
|
|
||||||
|
expect($comparison)->toBeArray();
|
||||||
|
expect(count($comparison))->toBe(2);
|
||||||
|
expect($comparison[0])->toHaveKeys(['api_key_id', 'api_key_name', 'requests', 'success_rate']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// Data Retention
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Data Retention', function () {
|
||||||
|
it('prunes old detailed records', function () {
|
||||||
|
// Create old records
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$usage = ApiUsage::record(
|
||||||
|
$this->apiKey->id,
|
||||||
|
$this->workspace->id,
|
||||||
|
'/api/v1/old',
|
||||||
|
'GET',
|
||||||
|
200,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
$usage->update(['created_at' => now()->subDays(60)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create recent records
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
ApiUsage::record(
|
||||||
|
$this->apiKey->id,
|
||||||
|
$this->workspace->id,
|
||||||
|
'/api/v1/recent',
|
||||||
|
'GET',
|
||||||
|
200,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$deleted = $this->service->pruneOldRecords(30);
|
||||||
|
|
||||||
|
expect($deleted)->toBe(5);
|
||||||
|
expect(ApiUsage::count())->toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps daily aggregates when pruning detailed records', function () {
|
||||||
|
// Create and aggregate old record
|
||||||
|
$usage = ApiUsage::record(
|
||||||
|
$this->apiKey->id,
|
||||||
|
$this->workspace->id,
|
||||||
|
'/api/v1/old',
|
||||||
|
'GET',
|
||||||
|
200,
|
||||||
|
100
|
||||||
|
);
|
||||||
|
$usage->update(['created_at' => now()->subDays(60)]);
|
||||||
|
ApiUsageDaily::recordFromUsage($usage);
|
||||||
|
|
||||||
|
$dailyCountBefore = ApiUsageDaily::count();
|
||||||
|
|
||||||
|
$this->service->pruneOldRecords(30);
|
||||||
|
|
||||||
|
// Daily aggregates should remain
|
||||||
|
expect(ApiUsageDaily::count())->toBe($dailyCountBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
120
src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php
Normal file
120
src/Mod/Api/Tests/Feature/OpenApiDocumentationTest.php
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Tests\Feature;
|
||||||
|
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiHidden;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiParameter;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiResponse;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiSecurity;
|
||||||
|
use Core\Mod\Api\Documentation\Attributes\ApiTag;
|
||||||
|
use Core\Mod\Api\Documentation\Extension;
|
||||||
|
use Core\Mod\Api\Documentation\Extensions\ApiKeyAuthExtension;
|
||||||
|
use Core\Mod\Api\Documentation\Extensions\RateLimitExtension;
|
||||||
|
use Core\Mod\Api\Documentation\Extensions\WorkspaceHeaderExtension;
|
||||||
|
use Core\Mod\Api\Documentation\OpenApiBuilder;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test OpenAPI documentation generation.
|
||||||
|
*/
|
||||||
|
class OpenApiDocumentationTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_openapi_builder_can_be_instantiated(): void
|
||||||
|
{
|
||||||
|
$builder = new OpenApiBuilder;
|
||||||
|
|
||||||
|
$this->assertInstanceOf(OpenApiBuilder::class, $builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_extensions_implement_interface(): void
|
||||||
|
{
|
||||||
|
$this->assertInstanceOf(Extension::class, new WorkspaceHeaderExtension);
|
||||||
|
$this->assertInstanceOf(Extension::class, new RateLimitExtension);
|
||||||
|
$this->assertInstanceOf(Extension::class, new ApiKeyAuthExtension);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_tag_attribute(): void
|
||||||
|
{
|
||||||
|
$tag = new ApiTag('Users', 'User management');
|
||||||
|
|
||||||
|
$this->assertEquals('Users', $tag->name);
|
||||||
|
$this->assertEquals('User management', $tag->description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_response_attribute(): void
|
||||||
|
{
|
||||||
|
$response = new ApiResponse(200, null, 'Success');
|
||||||
|
|
||||||
|
$this->assertEquals(200, $response->status);
|
||||||
|
$this->assertEquals('Success', $response->getDescription());
|
||||||
|
$this->assertFalse($response->paginated);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_response_generates_description_from_status(): void
|
||||||
|
{
|
||||||
|
$response = new ApiResponse(404);
|
||||||
|
|
||||||
|
$this->assertEquals('Not found', $response->getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_security_attribute(): void
|
||||||
|
{
|
||||||
|
$security = new ApiSecurity('apiKey', ['read', 'write']);
|
||||||
|
|
||||||
|
$this->assertEquals('apiKey', $security->scheme);
|
||||||
|
$this->assertEquals(['read', 'write'], $security->scopes);
|
||||||
|
$this->assertFalse($security->isPublic());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_security_public(): void
|
||||||
|
{
|
||||||
|
$security = new ApiSecurity(null);
|
||||||
|
|
||||||
|
$this->assertTrue($security->isPublic());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_parameter_attribute(): void
|
||||||
|
{
|
||||||
|
$param = new ApiParameter(
|
||||||
|
name: 'page',
|
||||||
|
in: 'query',
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Page number',
|
||||||
|
required: false,
|
||||||
|
example: 1
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertEquals('page', $param->name);
|
||||||
|
$this->assertEquals('query', $param->in);
|
||||||
|
$this->assertEquals('integer', $param->type);
|
||||||
|
$this->assertEquals(1, $param->example);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_parameter_to_openapi(): void
|
||||||
|
{
|
||||||
|
$param = new ApiParameter(
|
||||||
|
name: 'page',
|
||||||
|
in: 'query',
|
||||||
|
type: 'integer',
|
||||||
|
description: 'Page number',
|
||||||
|
required: false,
|
||||||
|
example: 1
|
||||||
|
);
|
||||||
|
|
||||||
|
$openApi = $param->toOpenApi();
|
||||||
|
|
||||||
|
$this->assertEquals('page', $openApi['name']);
|
||||||
|
$this->assertEquals('query', $openApi['in']);
|
||||||
|
$this->assertFalse($openApi['required']);
|
||||||
|
$this->assertEquals('integer', $openApi['schema']['type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_hidden_attribute(): void
|
||||||
|
{
|
||||||
|
$hidden = new ApiHidden('Internal only');
|
||||||
|
|
||||||
|
$this->assertEquals('Internal only', $hidden->reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
532
src/Mod/Api/Tests/Feature/RateLimitTest.php
Normal file
532
src/Mod/Api/Tests/Feature/RateLimitTest.php
Normal file
|
|
@ -0,0 +1,532 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Mod\Api\Tests\Feature;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use Core\LifecycleEventProvider;
|
||||||
|
use Core\Mod\Api\Exceptions\RateLimitExceededException;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimit;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimitResult;
|
||||||
|
use Core\Mod\Api\RateLimit\RateLimitService;
|
||||||
|
use Illuminate\Contracts\Cache\Repository as CacheRepository;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Config;
|
||||||
|
use Orchestra\Testbench\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate Limiting Tests
|
||||||
|
*
|
||||||
|
* Tests for the rate limiting service, result DTO, attribute, exception,
|
||||||
|
* and configuration.
|
||||||
|
*/
|
||||||
|
class RateLimitTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected RateLimitService $rateLimitService;
|
||||||
|
|
||||||
|
protected function getPackageProviders($app): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
LifecycleEventProvider::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Cache::flush();
|
||||||
|
Carbon::setTestNow(Carbon::now());
|
||||||
|
|
||||||
|
$this->rateLimitService = new RateLimitService($this->app->make(CacheRepository::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
Carbon::setTestNow();
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// RateLimitResult DTO Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_rate_limit_result_creates_allowed_result(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::allowed(100, 99, $resetsAt);
|
||||||
|
|
||||||
|
$this->assertTrue($result->allowed);
|
||||||
|
$this->assertSame(100, $result->limit);
|
||||||
|
$this->assertSame(99, $result->remaining);
|
||||||
|
$this->assertSame(0, $result->retryAfter);
|
||||||
|
$this->assertSame($resetsAt->timestamp, $result->resetsAt->timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limit_result_creates_denied_result(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::denied(100, 30, $resetsAt);
|
||||||
|
|
||||||
|
$this->assertFalse($result->allowed);
|
||||||
|
$this->assertSame(100, $result->limit);
|
||||||
|
$this->assertSame(0, $result->remaining);
|
||||||
|
$this->assertSame(30, $result->retryAfter);
|
||||||
|
$this->assertSame($resetsAt->timestamp, $result->resetsAt->timestamp);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limit_result_generates_correct_headers_for_allowed(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::allowed(100, 99, $resetsAt);
|
||||||
|
|
||||||
|
$headers = $result->headers();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('X-RateLimit-Limit', $headers);
|
||||||
|
$this->assertArrayHasKey('X-RateLimit-Remaining', $headers);
|
||||||
|
$this->assertArrayHasKey('X-RateLimit-Reset', $headers);
|
||||||
|
$this->assertSame(100, $headers['X-RateLimit-Limit']);
|
||||||
|
$this->assertSame(99, $headers['X-RateLimit-Remaining']);
|
||||||
|
$this->assertSame($resetsAt->timestamp, $headers['X-RateLimit-Reset']);
|
||||||
|
$this->assertArrayNotHasKey('Retry-After', $headers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_rate_limit_result_generates_correct_headers_for_denied(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::denied(100, 30, $resetsAt);
|
||||||
|
|
||||||
|
$headers = $result->headers();
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('X-RateLimit-Limit', $headers);
|
||||||
|
$this->assertArrayHasKey('X-RateLimit-Remaining', $headers);
|
||||||
|
$this->assertArrayHasKey('X-RateLimit-Reset', $headers);
|
||||||
|
$this->assertArrayHasKey('Retry-After', $headers);
|
||||||
|
$this->assertSame(100, $headers['X-RateLimit-Limit']);
|
||||||
|
$this->assertSame(0, $headers['X-RateLimit-Remaining']);
|
||||||
|
$this->assertSame(30, $headers['Retry-After']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// RateLimitService - Basic Rate Limiting Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_service_allows_requests_under_the_limit(): void
|
||||||
|
{
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
|
||||||
|
$this->assertTrue($result->allowed);
|
||||||
|
$this->assertSame(9, $result->remaining);
|
||||||
|
$this->assertSame(10, $result->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_service_tracks_requests_correctly(): void
|
||||||
|
{
|
||||||
|
// Make 5 requests
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertTrue($result->allowed);
|
||||||
|
$this->assertSame(5, $result->remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_service_blocks_requests_when_limit_exceeded(): void
|
||||||
|
{
|
||||||
|
// Make 10 requests (at limit)
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11th request should be blocked
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
|
||||||
|
$this->assertFalse($result->allowed);
|
||||||
|
$this->assertSame(0, $result->remaining);
|
||||||
|
$this->assertGreaterThan(0, $result->retryAfter);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_check_method_does_not_increment_counter(): void
|
||||||
|
{
|
||||||
|
// Hit once
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
|
||||||
|
// Check multiple times (should not count)
|
||||||
|
$this->rateLimitService->check('test-key', 10, 60);
|
||||||
|
$this->rateLimitService->check('test-key', 10, 60);
|
||||||
|
$this->rateLimitService->check('test-key', 10, 60);
|
||||||
|
|
||||||
|
// Verify only 1 hit was recorded
|
||||||
|
$this->assertSame(9, $this->rateLimitService->remaining('test-key', 10, 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_service_resets_correctly(): void
|
||||||
|
{
|
||||||
|
// Make some requests
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60));
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
$this->rateLimitService->reset('test-key');
|
||||||
|
|
||||||
|
$this->assertSame(10, $this->rateLimitService->remaining('test-key', 10, 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_service_returns_correct_attempts_count(): void
|
||||||
|
{
|
||||||
|
$this->assertSame(0, $this->rateLimitService->attempts('test-key', 60));
|
||||||
|
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
|
||||||
|
$this->assertSame(3, $this->rateLimitService->attempts('test-key', 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// RateLimitService - Sliding Window Algorithm Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_sliding_window_expires_old_requests(): void
|
||||||
|
{
|
||||||
|
// Make 5 requests now
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60));
|
||||||
|
|
||||||
|
// Move time forward 61 seconds (past the window)
|
||||||
|
Carbon::setTestNow(Carbon::now()->addSeconds(61));
|
||||||
|
|
||||||
|
// Old requests should have expired
|
||||||
|
$this->assertSame(10, $this->rateLimitService->remaining('test-key', 10, 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sliding_window_maintains_requests_within_window(): void
|
||||||
|
{
|
||||||
|
// Make 5 requests now
|
||||||
|
for ($i = 0; $i < 5; $i++) {
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move time forward 30 seconds (still within window)
|
||||||
|
Carbon::setTestNow(Carbon::now()->addSeconds(30));
|
||||||
|
|
||||||
|
// Requests should still count
|
||||||
|
$this->assertSame(5, $this->rateLimitService->remaining('test-key', 10, 60));
|
||||||
|
|
||||||
|
// Make 3 more requests
|
||||||
|
for ($i = 0; $i < 3; $i++) {
|
||||||
|
$this->rateLimitService->hit('test-key', 10, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertSame(2, $this->rateLimitService->remaining('test-key', 10, 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// RateLimitService - Burst Allowance Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_burst_allows_when_configured(): void
|
||||||
|
{
|
||||||
|
// With 20% burst, limit of 10 becomes effective limit of 12
|
||||||
|
for ($i = 0; $i < 12; $i++) {
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.2);
|
||||||
|
$this->assertTrue($result->allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13th request should be blocked
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.2);
|
||||||
|
$this->assertFalse($result->allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_burst_reports_base_limit_not_burst_limit(): void
|
||||||
|
{
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.5);
|
||||||
|
|
||||||
|
// Limit shown should be the base limit (10), not the burst limit (15)
|
||||||
|
$this->assertSame(10, $result->limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_burst_calculates_remaining_based_on_burst_limit(): void
|
||||||
|
{
|
||||||
|
// With 50% burst, limit of 10 becomes effective limit of 15
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.5);
|
||||||
|
|
||||||
|
// After 1 hit, remaining should be 14 (15 - 1)
|
||||||
|
$this->assertSame(14, $result->remaining);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_burst_works_without_burst(): void
|
||||||
|
{
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.0);
|
||||||
|
$this->assertTrue($result->allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = $this->rateLimitService->hit('test-key', 10, 60, 1.0);
|
||||||
|
$this->assertFalse($result->allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// RateLimitService - Key Builders Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_builds_endpoint_keys_correctly(): void
|
||||||
|
{
|
||||||
|
$key = $this->rateLimitService->buildEndpointKey('api_key:123', 'users.index');
|
||||||
|
$this->assertSame('endpoint:api_key:123:users.index', $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_workspace_keys_correctly(): void
|
||||||
|
{
|
||||||
|
$key = $this->rateLimitService->buildWorkspaceKey(456);
|
||||||
|
$this->assertSame('workspace:456', $key);
|
||||||
|
|
||||||
|
$keyWithSuffix = $this->rateLimitService->buildWorkspaceKey(456, 'users.index');
|
||||||
|
$this->assertSame('workspace:456:users.index', $keyWithSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_api_key_keys_correctly(): void
|
||||||
|
{
|
||||||
|
$key = $this->rateLimitService->buildApiKeyKey(789);
|
||||||
|
$this->assertSame('api_key:789', $key);
|
||||||
|
|
||||||
|
$keyWithSuffix = $this->rateLimitService->buildApiKeyKey(789, 'users.index');
|
||||||
|
$this->assertSame('api_key:789:users.index', $keyWithSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_builds_ip_keys_correctly(): void
|
||||||
|
{
|
||||||
|
$key = $this->rateLimitService->buildIpKey('192.168.1.1');
|
||||||
|
$this->assertSame('ip:192.168.1.1', $key);
|
||||||
|
|
||||||
|
$keyWithSuffix = $this->rateLimitService->buildIpKey('192.168.1.1', 'users.index');
|
||||||
|
$this->assertSame('ip:192.168.1.1:users.index', $keyWithSuffix);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// RateLimit Attribute Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_attribute_instantiates_with_required_parameters(): void
|
||||||
|
{
|
||||||
|
$attribute = new RateLimit(limit: 100);
|
||||||
|
|
||||||
|
$this->assertSame(100, $attribute->limit);
|
||||||
|
$this->assertSame(60, $attribute->window); // default
|
||||||
|
$this->assertSame(1.0, $attribute->burst); // default
|
||||||
|
$this->assertNull($attribute->key); // default
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_attribute_instantiates_with_all_parameters(): void
|
||||||
|
{
|
||||||
|
$attribute = new RateLimit(
|
||||||
|
limit: 200,
|
||||||
|
window: 120,
|
||||||
|
burst: 1.5,
|
||||||
|
key: 'custom-key'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->assertSame(200, $attribute->limit);
|
||||||
|
$this->assertSame(120, $attribute->window);
|
||||||
|
$this->assertSame(1.5, $attribute->burst);
|
||||||
|
$this->assertSame('custom-key', $attribute->key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// RateLimitExceededException Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_exception_creates_with_rate_limit_result(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::denied(100, 30, $resetsAt);
|
||||||
|
$exception = new RateLimitExceededException($result);
|
||||||
|
|
||||||
|
$this->assertSame(429, $exception->getStatusCode());
|
||||||
|
$this->assertSame($result, $exception->getRateLimitResult());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_exception_renders_as_json_response(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::denied(100, 30, $resetsAt);
|
||||||
|
$exception = new RateLimitExceededException($result);
|
||||||
|
|
||||||
|
$response = $exception->render();
|
||||||
|
|
||||||
|
$this->assertSame(429, $response->getStatusCode());
|
||||||
|
|
||||||
|
$content = json_decode($response->getContent(), true);
|
||||||
|
$this->assertSame('rate_limit_exceeded', $content['error']);
|
||||||
|
$this->assertSame(30, $content['retry_after']);
|
||||||
|
$this->assertSame(100, $content['limit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_exception_includes_rate_limit_headers_in_response(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::denied(100, 30, $resetsAt);
|
||||||
|
$exception = new RateLimitExceededException($result);
|
||||||
|
|
||||||
|
$response = $exception->render();
|
||||||
|
|
||||||
|
$this->assertSame('100', $response->headers->get('X-RateLimit-Limit'));
|
||||||
|
$this->assertSame('0', $response->headers->get('X-RateLimit-Remaining'));
|
||||||
|
$this->assertSame('30', $response->headers->get('Retry-After'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_exception_allows_custom_message(): void
|
||||||
|
{
|
||||||
|
$resetsAt = Carbon::now()->addMinute();
|
||||||
|
$result = RateLimitResult::denied(100, 30, $resetsAt);
|
||||||
|
$exception = new RateLimitExceededException($result, 'Custom rate limit message');
|
||||||
|
|
||||||
|
$response = $exception->render();
|
||||||
|
$content = json_decode($response->getContent(), true);
|
||||||
|
|
||||||
|
$this->assertSame('Custom rate limit message', $content['message']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Per-Workspace Rate Limiting Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_isolates_rate_limits_by_workspace(): void
|
||||||
|
{
|
||||||
|
// Create two different workspace keys
|
||||||
|
$key1 = $this->rateLimitService->buildWorkspaceKey(1, 'endpoint');
|
||||||
|
$key2 = $this->rateLimitService->buildWorkspaceKey(2, 'endpoint');
|
||||||
|
|
||||||
|
// Hit rate limit for workspace 1
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$this->rateLimitService->hit($key1, 10, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace 1 should be blocked
|
||||||
|
$result1 = $this->rateLimitService->hit($key1, 10, 60);
|
||||||
|
$this->assertFalse($result1->allowed);
|
||||||
|
|
||||||
|
// Workspace 2 should still be allowed
|
||||||
|
$result2 = $this->rateLimitService->hit($key2, 10, 60);
|
||||||
|
$this->assertTrue($result2->allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// Rate Limit Configuration Tests
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function test_config_has_enabled_flag(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.enabled', true);
|
||||||
|
$this->assertTrue(config('api.rate_limits.enabled'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_config_has_default_limits(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.default', [
|
||||||
|
'limit' => 60,
|
||||||
|
'window' => 60,
|
||||||
|
'burst' => 1.0,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$default = config('api.rate_limits.default');
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('limit', $default);
|
||||||
|
$this->assertArrayHasKey('window', $default);
|
||||||
|
$this->assertArrayHasKey('burst', $default);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_config_has_authenticated_limits(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.authenticated', [
|
||||||
|
'limit' => 1000,
|
||||||
|
'window' => 60,
|
||||||
|
'burst' => 1.2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$authenticated = config('api.rate_limits.authenticated');
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('limit', $authenticated);
|
||||||
|
$this->assertSame(1000, $authenticated['limit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_config_has_per_workspace_flag(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.per_workspace', true);
|
||||||
|
$this->assertTrue(config('api.rate_limits.per_workspace'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_config_has_endpoints_configuration(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.endpoints', []);
|
||||||
|
$this->assertIsArray(config('api.rate_limits.endpoints'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_config_has_tier_based_limits(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.tiers', [
|
||||||
|
'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0],
|
||||||
|
'starter' => ['limit' => 1000, 'window' => 60, 'burst' => 1.2],
|
||||||
|
'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3],
|
||||||
|
'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5],
|
||||||
|
'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tiers = config('api.rate_limits.tiers');
|
||||||
|
|
||||||
|
$this->assertArrayHasKey('free', $tiers);
|
||||||
|
$this->assertArrayHasKey('starter', $tiers);
|
||||||
|
$this->assertArrayHasKey('pro', $tiers);
|
||||||
|
$this->assertArrayHasKey('agency', $tiers);
|
||||||
|
$this->assertArrayHasKey('enterprise', $tiers);
|
||||||
|
|
||||||
|
foreach ($tiers as $tier => $tierConfig) {
|
||||||
|
$this->assertArrayHasKey('limit', $tierConfig);
|
||||||
|
$this->assertArrayHasKey('window', $tierConfig);
|
||||||
|
$this->assertArrayHasKey('burst', $tierConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_tier_limits_increase_with_tier_level(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.tiers', [
|
||||||
|
'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0],
|
||||||
|
'starter' => ['limit' => 1000, 'window' => 60, 'burst' => 1.2],
|
||||||
|
'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3],
|
||||||
|
'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5],
|
||||||
|
'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tiers = config('api.rate_limits.tiers');
|
||||||
|
|
||||||
|
$this->assertGreaterThan($tiers['free']['limit'], $tiers['starter']['limit']);
|
||||||
|
$this->assertGreaterThan($tiers['starter']['limit'], $tiers['pro']['limit']);
|
||||||
|
$this->assertGreaterThan($tiers['pro']['limit'], $tiers['agency']['limit']);
|
||||||
|
$this->assertGreaterThan($tiers['agency']['limit'], $tiers['enterprise']['limit']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_higher_tiers_have_higher_burst_allowance(): void
|
||||||
|
{
|
||||||
|
Config::set('api.rate_limits.tiers', [
|
||||||
|
'free' => ['limit' => 60, 'window' => 60, 'burst' => 1.0],
|
||||||
|
'pro' => ['limit' => 5000, 'window' => 60, 'burst' => 1.3],
|
||||||
|
'agency' => ['limit' => 20000, 'window' => 60, 'burst' => 1.5],
|
||||||
|
'enterprise' => ['limit' => 100000, 'window' => 60, 'burst' => 2.0],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$tiers = config('api.rate_limits.tiers');
|
||||||
|
|
||||||
|
$this->assertGreaterThanOrEqual($tiers['pro']['burst'], $tiers['agency']['burst']);
|
||||||
|
$this->assertGreaterThanOrEqual($tiers['agency']['burst'], $tiers['enterprise']['burst']);
|
||||||
|
}
|
||||||
|
}
|
||||||
770
src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php
Normal file
770
src/Mod/Api/Tests/Feature/WebhookDeliveryTest.php
Normal file
|
|
@ -0,0 +1,770 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Core\Mod\Api\Jobs\DeliverWebhookJob;
|
||||||
|
use Core\Mod\Api\Models\WebhookDelivery;
|
||||||
|
use Core\Mod\Api\Models\WebhookEndpoint;
|
||||||
|
use Core\Mod\Api\Services\WebhookService;
|
||||||
|
use Core\Mod\Api\Services\WebhookSignature;
|
||||||
|
use Core\Mod\Tenant\Models\Workspace;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
Http::fake();
|
||||||
|
|
||||||
|
$this->workspace = Workspace::factory()->create();
|
||||||
|
$this->service = app(WebhookService::class);
|
||||||
|
$this->signatureService = app(WebhookSignature::class);
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Webhook Signature Service
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Webhook Signature Service', function () {
|
||||||
|
it('generates a 64-character secret', function () {
|
||||||
|
$secret = $this->signatureService->generateSecret();
|
||||||
|
|
||||||
|
expect($secret)->toBeString();
|
||||||
|
expect(strlen($secret))->toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates unique secrets', function () {
|
||||||
|
$secrets = [];
|
||||||
|
for ($i = 0; $i < 100; $i++) {
|
||||||
|
$secrets[] = $this->signatureService->generateSecret();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(array_unique($secrets))->toHaveCount(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('signs payload with timestamp', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'test_secret_key';
|
||||||
|
$timestamp = 1704067200; // Fixed timestamp for testing
|
||||||
|
|
||||||
|
$signature = $this->signatureService->sign($payload, $secret, $timestamp);
|
||||||
|
|
||||||
|
// Verify it's a 64-character hex string (SHA256)
|
||||||
|
expect($signature)->toBeString();
|
||||||
|
expect(strlen($signature))->toBe(64);
|
||||||
|
expect(ctype_xdigit($signature))->toBeTrue();
|
||||||
|
|
||||||
|
// Verify signature is deterministic
|
||||||
|
$signature2 = $this->signatureService->sign($payload, $secret, $timestamp);
|
||||||
|
expect($signature)->toBe($signature2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different signatures for different payloads', function () {
|
||||||
|
$secret = 'test_secret_key';
|
||||||
|
$timestamp = 1704067200;
|
||||||
|
|
||||||
|
$sig1 = $this->signatureService->sign('{"a":1}', $secret, $timestamp);
|
||||||
|
$sig2 = $this->signatureService->sign('{"a":2}', $secret, $timestamp);
|
||||||
|
|
||||||
|
expect($sig1)->not->toBe($sig2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different signatures for different timestamps', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'test_secret_key';
|
||||||
|
|
||||||
|
$sig1 = $this->signatureService->sign($payload, $secret, 1704067200);
|
||||||
|
$sig2 = $this->signatureService->sign($payload, $secret, 1704067201);
|
||||||
|
|
||||||
|
expect($sig1)->not->toBe($sig2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different signatures for different secrets', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$timestamp = 1704067200;
|
||||||
|
|
||||||
|
$sig1 = $this->signatureService->sign($payload, 'secret1', $timestamp);
|
||||||
|
$sig2 = $this->signatureService->sign($payload, 'secret2', $timestamp);
|
||||||
|
|
||||||
|
expect($sig1)->not->toBe($sig2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies valid signature', function () {
|
||||||
|
$payload = '{"event":"test","data":{"id":123}}';
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$timestamp = time();
|
||||||
|
|
||||||
|
$signature = $this->signatureService->sign($payload, $secret, $timestamp);
|
||||||
|
|
||||||
|
$isValid = $this->signatureService->verify(
|
||||||
|
$payload,
|
||||||
|
$signature,
|
||||||
|
$secret,
|
||||||
|
$timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid signature', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$timestamp = time();
|
||||||
|
|
||||||
|
$isValid = $this->signatureService->verify(
|
||||||
|
$payload,
|
||||||
|
'invalid_signature_abc123',
|
||||||
|
$secret,
|
||||||
|
$timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tampered payload', function () {
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$timestamp = time();
|
||||||
|
|
||||||
|
// Sign original payload
|
||||||
|
$signature = $this->signatureService->sign('{"event":"test"}', $secret, $timestamp);
|
||||||
|
|
||||||
|
// Verify with tampered payload
|
||||||
|
$isValid = $this->signatureService->verify(
|
||||||
|
'{"event":"test","hacked":true}',
|
||||||
|
$signature,
|
||||||
|
$secret,
|
||||||
|
$timestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects tampered timestamp', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$originalTimestamp = time();
|
||||||
|
|
||||||
|
// Sign with original timestamp
|
||||||
|
$signature = $this->signatureService->sign($payload, $secret, $originalTimestamp);
|
||||||
|
|
||||||
|
// Verify with different timestamp (simulating replay attack)
|
||||||
|
$isValid = $this->signatureService->verifySignatureOnly(
|
||||||
|
$payload,
|
||||||
|
$signature,
|
||||||
|
$secret,
|
||||||
|
$originalTimestamp + 1
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects expired timestamp', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$oldTimestamp = time() - 600; // 10 minutes ago
|
||||||
|
|
||||||
|
$signature = $this->signatureService->sign($payload, $secret, $oldTimestamp);
|
||||||
|
|
||||||
|
// Default tolerance is 5 minutes
|
||||||
|
$isValid = $this->signatureService->verify(
|
||||||
|
$payload,
|
||||||
|
$signature,
|
||||||
|
$secret,
|
||||||
|
$oldTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts timestamp within tolerance', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$recentTimestamp = time() - 60; // 1 minute ago
|
||||||
|
|
||||||
|
$signature = $this->signatureService->sign($payload, $secret, $recentTimestamp);
|
||||||
|
|
||||||
|
$isValid = $this->signatureService->verify(
|
||||||
|
$payload,
|
||||||
|
$signature,
|
||||||
|
$secret,
|
||||||
|
$recentTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows custom tolerance', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$oldTimestamp = time() - 600; // 10 minutes ago
|
||||||
|
|
||||||
|
$signature = $this->signatureService->sign($payload, $secret, $oldTimestamp);
|
||||||
|
|
||||||
|
// Verify with 15-minute tolerance
|
||||||
|
$isValid = $this->signatureService->verify(
|
||||||
|
$payload,
|
||||||
|
$signature,
|
||||||
|
$secret,
|
||||||
|
$oldTimestamp,
|
||||||
|
tolerance: 900
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks timestamp validity correctly', function () {
|
||||||
|
$now = time();
|
||||||
|
|
||||||
|
// Within tolerance
|
||||||
|
expect($this->signatureService->isTimestampValid($now))->toBeTrue();
|
||||||
|
expect($this->signatureService->isTimestampValid($now - 60))->toBeTrue();
|
||||||
|
expect($this->signatureService->isTimestampValid($now - 299))->toBeTrue();
|
||||||
|
|
||||||
|
// Outside tolerance
|
||||||
|
expect($this->signatureService->isTimestampValid($now - 301))->toBeFalse();
|
||||||
|
expect($this->signatureService->isTimestampValid($now - 600))->toBeFalse();
|
||||||
|
|
||||||
|
// Future timestamp within tolerance
|
||||||
|
expect($this->signatureService->isTimestampValid($now + 60))->toBeTrue();
|
||||||
|
|
||||||
|
// Future timestamp outside tolerance
|
||||||
|
expect($this->signatureService->isTimestampValid($now + 400))->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns correct headers', function () {
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$secret = 'webhook_secret_abc123';
|
||||||
|
$timestamp = 1704067200;
|
||||||
|
|
||||||
|
$headers = $this->signatureService->getHeaders($payload, $secret, $timestamp);
|
||||||
|
|
||||||
|
expect($headers)->toHaveKey('X-Webhook-Signature');
|
||||||
|
expect($headers)->toHaveKey('X-Webhook-Timestamp');
|
||||||
|
expect($headers['X-Webhook-Timestamp'])->toBe($timestamp);
|
||||||
|
expect($headers['X-Webhook-Signature'])->toBe(
|
||||||
|
$this->signatureService->sign($payload, $secret, $timestamp)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Webhook Endpoint Signing
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Webhook Endpoint Signing', function () {
|
||||||
|
it('generates signature for payload with timestamp', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$timestamp = time();
|
||||||
|
|
||||||
|
$signature = $endpoint->generateSignature($payload, $timestamp);
|
||||||
|
|
||||||
|
expect($signature)->toBeString();
|
||||||
|
expect(strlen($signature))->toBe(64);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verifies valid signature', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = '{"event":"test","data":{"id":123}}';
|
||||||
|
$timestamp = time();
|
||||||
|
|
||||||
|
$signature = $endpoint->generateSignature($payload, $timestamp);
|
||||||
|
|
||||||
|
$isValid = $endpoint->verifySignature($payload, $signature, $timestamp);
|
||||||
|
|
||||||
|
expect($isValid)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid signature', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$isValid = $endpoint->verifySignature(
|
||||||
|
'{"event":"test"}',
|
||||||
|
'invalid_signature',
|
||||||
|
time()
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($isValid)->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rotates secret and invalidates old signatures', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = '{"event":"test"}';
|
||||||
|
$timestamp = time();
|
||||||
|
|
||||||
|
// Sign with original secret
|
||||||
|
$originalSignature = $endpoint->generateSignature($payload, $timestamp);
|
||||||
|
|
||||||
|
// Rotate secret
|
||||||
|
$newSecret = $endpoint->rotateSecret();
|
||||||
|
$endpoint->refresh();
|
||||||
|
|
||||||
|
// Old signature should be invalid
|
||||||
|
$isValid = $endpoint->verifySignature($payload, $originalSignature, $timestamp);
|
||||||
|
expect($isValid)->toBeFalse();
|
||||||
|
|
||||||
|
// New signature should be valid
|
||||||
|
$newSignature = $endpoint->generateSignature($payload, $timestamp);
|
||||||
|
$isValid = $endpoint->verifySignature($payload, $newSignature, $timestamp);
|
||||||
|
expect($isValid)->toBeTrue();
|
||||||
|
|
||||||
|
// New secret should be 64 characters
|
||||||
|
expect(strlen($newSecret))->toBe(64);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Webhook Service
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Webhook Service', function () {
|
||||||
|
it('dispatches event to subscribed endpoints', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$deliveries = $this->service->dispatch(
|
||||||
|
$this->workspace->id,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123, 'name' => 'Test Bio']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($deliveries)->toHaveCount(1);
|
||||||
|
expect($deliveries[0]->event_type)->toBe('bio.created');
|
||||||
|
expect($deliveries[0]->webhook_endpoint_id)->toBe($endpoint->id);
|
||||||
|
expect($deliveries[0]->status)->toBe(WebhookDelivery::STATUS_PENDING);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch to endpoints not subscribed to event', function () {
|
||||||
|
WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.updated'] // Different event
|
||||||
|
);
|
||||||
|
|
||||||
|
$deliveries = $this->service->dispatch(
|
||||||
|
$this->workspace->id,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($deliveries)->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dispatches to wildcard subscribed endpoints', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['*'] // Subscribe to all events
|
||||||
|
);
|
||||||
|
|
||||||
|
$deliveries = $this->service->dispatch(
|
||||||
|
$this->workspace->id,
|
||||||
|
'any.event.type',
|
||||||
|
['data' => 'test']
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($deliveries)->toHaveCount(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch to inactive endpoints', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
$endpoint->update(['active' => false]);
|
||||||
|
|
||||||
|
$deliveries = $this->service->dispatch(
|
||||||
|
$this->workspace->id,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($deliveries)->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not dispatch to disabled endpoints', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
$endpoint->update(['disabled_at' => now()]);
|
||||||
|
|
||||||
|
$deliveries = $this->service->dispatch(
|
||||||
|
$this->workspace->id,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect($deliveries)->toBeEmpty();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns webhook stats for workspace', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create some deliveries
|
||||||
|
WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 1]);
|
||||||
|
$delivery2 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 2]);
|
||||||
|
$delivery2->markSuccess(200);
|
||||||
|
$delivery3 = WebhookDelivery::createForEvent($endpoint, 'bio.created', ['id' => 3]);
|
||||||
|
$delivery3->markFailed(500, 'Server Error');
|
||||||
|
|
||||||
|
$stats = $this->service->getStats($this->workspace->id);
|
||||||
|
|
||||||
|
expect($stats['total'])->toBe(3);
|
||||||
|
expect($stats['pending'])->toBe(1);
|
||||||
|
expect($stats['success'])->toBe(1);
|
||||||
|
expect($stats['retrying'])->toBe(1); // Failed with retries remaining
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Webhook Delivery Job
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Webhook Delivery Job', function () {
|
||||||
|
it('marks delivery as success on 2xx response', function () {
|
||||||
|
Http::fake([
|
||||||
|
'example.com/*' => Http::response(['received' => true], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new DeliverWebhookJob($delivery);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
$delivery->refresh();
|
||||||
|
expect($delivery->status)->toBe(WebhookDelivery::STATUS_SUCCESS);
|
||||||
|
expect($delivery->response_code)->toBe(200);
|
||||||
|
expect($delivery->delivered_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks delivery as retrying on 5xx response', function () {
|
||||||
|
Http::fake([
|
||||||
|
'example.com/*' => Http::response('Server Error', 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new DeliverWebhookJob($delivery);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
$delivery->refresh();
|
||||||
|
expect($delivery->status)->toBe(WebhookDelivery::STATUS_RETRYING);
|
||||||
|
expect($delivery->response_code)->toBe(500);
|
||||||
|
expect($delivery->attempt)->toBe(2);
|
||||||
|
expect($delivery->next_retry_at)->not->toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks delivery as failed after max retries', function () {
|
||||||
|
Http::fake([
|
||||||
|
'example.com/*' => Http::response('Server Error', 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
$delivery->update(['attempt' => WebhookDelivery::MAX_RETRIES]);
|
||||||
|
|
||||||
|
$job = new DeliverWebhookJob($delivery);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
$delivery->refresh();
|
||||||
|
expect($delivery->status)->toBe(WebhookDelivery::STATUS_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes correct signature and timestamp headers', function () {
|
||||||
|
Http::fake(function ($request) {
|
||||||
|
// Verify all required headers exist
|
||||||
|
expect($request->hasHeader('X-Webhook-Signature'))->toBeTrue();
|
||||||
|
expect($request->hasHeader('X-Webhook-Timestamp'))->toBeTrue();
|
||||||
|
expect($request->hasHeader('X-Webhook-Event'))->toBeTrue();
|
||||||
|
expect($request->hasHeader('X-Webhook-Id'))->toBeTrue();
|
||||||
|
|
||||||
|
// Verify timestamp is a valid Unix timestamp
|
||||||
|
$timestamp = $request->header('X-Webhook-Timestamp')[0];
|
||||||
|
expect(is_numeric($timestamp))->toBeTrue();
|
||||||
|
expect((int) $timestamp)->toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify signature is a 64-character hex string
|
||||||
|
$signature = $request->header('X-Webhook-Signature')[0];
|
||||||
|
expect(strlen($signature))->toBe(64);
|
||||||
|
expect(ctype_xdigit($signature))->toBeTrue();
|
||||||
|
|
||||||
|
return Http::response(['ok' => true], 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new DeliverWebhookJob($delivery);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) {
|
||||||
|
return $request->url() === 'https://example.com/webhook';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends verifiable signature', function () {
|
||||||
|
$capturedRequest = null;
|
||||||
|
|
||||||
|
Http::fake(function ($request) use (&$capturedRequest) {
|
||||||
|
$capturedRequest = $request;
|
||||||
|
|
||||||
|
return Http::response(['ok' => true], 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
$job = new DeliverWebhookJob($delivery);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
// Verify the signature can be verified by a recipient
|
||||||
|
$body = $capturedRequest->body();
|
||||||
|
$signature = $capturedRequest->header('X-Webhook-Signature')[0];
|
||||||
|
$timestamp = (int) $capturedRequest->header('X-Webhook-Timestamp')[0];
|
||||||
|
|
||||||
|
$isValid = $endpoint->verifySignature($body, $signature, $timestamp);
|
||||||
|
expect($isValid)->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips delivery if endpoint becomes inactive', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deactivate endpoint after delivery created
|
||||||
|
$endpoint->update(['active' => false]);
|
||||||
|
|
||||||
|
$job = new DeliverWebhookJob($delivery);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
// Should not have made any HTTP requests
|
||||||
|
Http::assertNothingSent();
|
||||||
|
|
||||||
|
// Delivery should remain pending (skipped)
|
||||||
|
$delivery->refresh();
|
||||||
|
expect($delivery->status)->toBe(WebhookDelivery::STATUS_PENDING);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Webhook Endpoint Auto-Disable
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Webhook Endpoint Auto-Disable', function () {
|
||||||
|
it('disables endpoint after consecutive failures', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate 10 consecutive failures
|
||||||
|
for ($i = 0; $i < 10; $i++) {
|
||||||
|
$endpoint->recordFailure();
|
||||||
|
}
|
||||||
|
|
||||||
|
$endpoint->refresh();
|
||||||
|
expect($endpoint->active)->toBeFalse();
|
||||||
|
expect($endpoint->disabled_at)->not->toBeNull();
|
||||||
|
expect($endpoint->failure_count)->toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets failure count on success', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Record some failures
|
||||||
|
$endpoint->recordFailure();
|
||||||
|
$endpoint->recordFailure();
|
||||||
|
$endpoint->recordFailure();
|
||||||
|
expect($endpoint->fresh()->failure_count)->toBe(3);
|
||||||
|
|
||||||
|
// Record success
|
||||||
|
$endpoint->recordSuccess();
|
||||||
|
|
||||||
|
$endpoint->refresh();
|
||||||
|
expect($endpoint->failure_count)->toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be re-enabled after being disabled', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Disable it
|
||||||
|
$endpoint->update([
|
||||||
|
'active' => false,
|
||||||
|
'disabled_at' => now(),
|
||||||
|
'failure_count' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Re-enable
|
||||||
|
$endpoint->enable();
|
||||||
|
|
||||||
|
$endpoint->refresh();
|
||||||
|
expect($endpoint->active)->toBeTrue();
|
||||||
|
expect($endpoint->disabled_at)->toBeNull();
|
||||||
|
expect($endpoint->failure_count)->toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Delivery Payload Headers
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('Delivery Payload Headers', function () {
|
||||||
|
it('includes all required headers', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = $delivery->getDeliveryPayload();
|
||||||
|
|
||||||
|
expect($payload)->toHaveKey('headers');
|
||||||
|
expect($payload)->toHaveKey('body');
|
||||||
|
expect($payload['headers'])->toHaveKey('Content-Type');
|
||||||
|
expect($payload['headers'])->toHaveKey('X-Webhook-Id');
|
||||||
|
expect($payload['headers'])->toHaveKey('X-Webhook-Event');
|
||||||
|
expect($payload['headers'])->toHaveKey('X-Webhook-Timestamp');
|
||||||
|
expect($payload['headers'])->toHaveKey('X-Webhook-Signature');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses provided timestamp', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
$fixedTimestamp = 1704067200;
|
||||||
|
$payload = $delivery->getDeliveryPayload($fixedTimestamp);
|
||||||
|
|
||||||
|
expect($payload['headers']['X-Webhook-Timestamp'])->toBe((string) $fixedTimestamp);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('generates valid signature in payload', function () {
|
||||||
|
$endpoint = WebhookEndpoint::createForWorkspace(
|
||||||
|
$this->workspace->id,
|
||||||
|
'https://example.com/webhook',
|
||||||
|
['bio.created']
|
||||||
|
);
|
||||||
|
|
||||||
|
$delivery = WebhookDelivery::createForEvent(
|
||||||
|
$endpoint,
|
||||||
|
'bio.created',
|
||||||
|
['bio_id' => 123]
|
||||||
|
);
|
||||||
|
|
||||||
|
$payload = $delivery->getDeliveryPayload();
|
||||||
|
|
||||||
|
$timestamp = (int) $payload['headers']['X-Webhook-Timestamp'];
|
||||||
|
$signature = $payload['headers']['X-Webhook-Signature'];
|
||||||
|
$body = $payload['body'];
|
||||||
|
|
||||||
|
// Verify the signature is valid
|
||||||
|
$isValid = $endpoint->verifySignature($body, $signature, $timestamp);
|
||||||
|
expect($isValid)->toBeTrue();
|
||||||
|
});
|
||||||
|
});
|
||||||
237
src/Mod/Api/config.php
Normal file
237
src/Mod/Api/config.php
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API Configuration
|
||||||
|
*
|
||||||
|
* Rate limiting, versioning, and API-specific settings.
|
||||||
|
* Integrated with EntitlementService for tier-based rate limits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Version
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The current API version. Used in URL prefix and documentation.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'version' => env('API_VERSION', '1'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Rate Limiting
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure rate limits for API requests.
|
||||||
|
|
|
||||||
|
| Features:
|
||||||
|
| - Per-endpoint limits via 'endpoints' config or #[RateLimit] attribute
|
||||||
|
| - Per-workspace limits (when 'per_workspace' is true)
|
||||||
|
| - Tier-based limits based on workspace subscription
|
||||||
|
| - Burst allowance for temporary traffic spikes
|
||||||
|
| - Sliding window algorithm for smoother rate limiting
|
||||||
|
|
|
||||||
|
| Priority (highest to lowest):
|
||||||
|
| 1. Method-level #[RateLimit] attribute
|
||||||
|
| 2. Class-level #[RateLimit] attribute
|
||||||
|
| 3. Per-endpoint config (endpoints.{route_name})
|
||||||
|
| 4. Tier-based limits (tiers.{tier})
|
||||||
|
| 5. Authenticated limits
|
||||||
|
| 6. Default limits
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'rate_limits' => [
|
||||||
|
// Enable/disable rate limiting globally
|
||||||
|
'enabled' => env('API_RATE_LIMITING_ENABLED', true),
|
||||||
|
|
||||||
|
// Unauthenticated requests (by IP)
|
||||||
|
'default' => [
|
||||||
|
'limit' => 60,
|
||||||
|
'window' => 60, // seconds
|
||||||
|
'burst' => 1.0, // no burst allowance for unauthenticated
|
||||||
|
// Legacy support
|
||||||
|
'requests' => 60,
|
||||||
|
'per_minutes' => 1,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Authenticated requests (by user/key)
|
||||||
|
'authenticated' => [
|
||||||
|
'limit' => 1000,
|
||||||
|
'window' => 60, // seconds
|
||||||
|
'burst' => 1.2, // 20% burst allowance
|
||||||
|
// Legacy support
|
||||||
|
'requests' => 1000,
|
||||||
|
'per_minutes' => 1,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Enable per-workspace rate limiting (isolates limits by workspace)
|
||||||
|
'per_workspace' => true,
|
||||||
|
|
||||||
|
// Per-endpoint rate limits (route names)
|
||||||
|
// Example: 'users.index' => ['limit' => 100, 'window' => 60]
|
||||||
|
'endpoints' => [
|
||||||
|
// High-volume endpoints may need higher limits
|
||||||
|
// 'links.index' => ['limit' => 500, 'window' => 60],
|
||||||
|
// 'qrcodes.index' => ['limit' => 500, 'window' => 60],
|
||||||
|
|
||||||
|
// Sensitive endpoints may need lower limits
|
||||||
|
// 'auth.login' => ['limit' => 10, 'window' => 60],
|
||||||
|
// 'keys.create' => ['limit' => 20, 'window' => 60],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Tier-based limits (based on workspace subscription/plan)
|
||||||
|
'tiers' => [
|
||||||
|
'free' => [
|
||||||
|
'limit' => 60,
|
||||||
|
'window' => 60, // seconds
|
||||||
|
'burst' => 1.0,
|
||||||
|
],
|
||||||
|
'starter' => [
|
||||||
|
'limit' => 1000,
|
||||||
|
'window' => 60,
|
||||||
|
'burst' => 1.2,
|
||||||
|
],
|
||||||
|
'pro' => [
|
||||||
|
'limit' => 5000,
|
||||||
|
'window' => 60,
|
||||||
|
'burst' => 1.3,
|
||||||
|
],
|
||||||
|
'agency' => [
|
||||||
|
'limit' => 20000,
|
||||||
|
'window' => 60,
|
||||||
|
'burst' => 1.5,
|
||||||
|
],
|
||||||
|
'enterprise' => [
|
||||||
|
'limit' => 100000,
|
||||||
|
'window' => 60,
|
||||||
|
'burst' => 2.0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Legacy: Tier-based limits (deprecated, use 'tiers' instead)
|
||||||
|
'by_tier' => [
|
||||||
|
'starter' => [
|
||||||
|
'requests' => 1000,
|
||||||
|
'per_minutes' => 1,
|
||||||
|
],
|
||||||
|
'pro' => [
|
||||||
|
'requests' => 5000,
|
||||||
|
'per_minutes' => 1,
|
||||||
|
],
|
||||||
|
'agency' => [
|
||||||
|
'requests' => 20000,
|
||||||
|
'per_minutes' => 1,
|
||||||
|
],
|
||||||
|
'enterprise' => [
|
||||||
|
'requests' => 100000,
|
||||||
|
'per_minutes' => 1,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
// Route-specific rate limiters (for named routes)
|
||||||
|
'routes' => [
|
||||||
|
'mcp' => 'authenticated',
|
||||||
|
'pixel' => 'default',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Usage Alerts
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configure notifications when API usage approaches limits.
|
||||||
|
|
|
||||||
|
| Thresholds define percentages of rate limit that trigger alerts:
|
||||||
|
| - warning: First alert level (default: 80%)
|
||||||
|
| - critical: Urgent alert level (default: 95%)
|
||||||
|
|
|
||||||
|
| Cooldown prevents duplicate notifications for the same level.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'alerts' => [
|
||||||
|
// Enable/disable usage alerting
|
||||||
|
'enabled' => env('API_USAGE_ALERTS_ENABLED', true),
|
||||||
|
|
||||||
|
// Alert thresholds (percentage of rate limit)
|
||||||
|
'thresholds' => [
|
||||||
|
'warning' => 80,
|
||||||
|
'critical' => 95,
|
||||||
|
],
|
||||||
|
|
||||||
|
// Hours between notifications of the same level
|
||||||
|
'cooldown_hours' => 6,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| API Key Settings
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Configuration for API key generation and validation.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'keys' => [
|
||||||
|
// Prefix for all API keys
|
||||||
|
'prefix' => 'hk_',
|
||||||
|
|
||||||
|
// Default scopes for new API keys
|
||||||
|
'default_scopes' => ['read', 'write'],
|
||||||
|
|
||||||
|
// Maximum API keys per workspace
|
||||||
|
'max_per_workspace' => 10,
|
||||||
|
|
||||||
|
// Auto-expire keys after this many days (null = never)
|
||||||
|
'default_expiry_days' => null,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Webhooks
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Webhook delivery settings.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'webhooks' => [
|
||||||
|
// Maximum webhook endpoints per workspace
|
||||||
|
'max_per_workspace' => 5,
|
||||||
|
|
||||||
|
// Timeout for webhook delivery in seconds
|
||||||
|
'timeout' => 30,
|
||||||
|
|
||||||
|
// Max retries for failed deliveries
|
||||||
|
'max_retries' => 5,
|
||||||
|
|
||||||
|
// Disable endpoint after this many consecutive failures
|
||||||
|
'disable_after_failures' => 10,
|
||||||
|
|
||||||
|
// Events that are high-volume and opt-in only
|
||||||
|
'high_volume_events' => [
|
||||||
|
'link.clicked',
|
||||||
|
'qrcode.scanned',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pagination
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Default pagination settings for API responses.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'pagination' => [
|
||||||
|
'default_per_page' => 25,
|
||||||
|
'max_per_page' => 100,
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
35
src/Website/Api/Boot.php
Normal file
35
src/Website/Api/Boot.php
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Website\Api;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class Boot extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
$this->registerViews();
|
||||||
|
$this->registerRoutes();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function registerViews(): void
|
||||||
|
{
|
||||||
|
View::addNamespace('api', __DIR__.'/View/Blade');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function registerRoutes(): void
|
||||||
|
{
|
||||||
|
// Skip domain binding during console commands (no request available)
|
||||||
|
if ($this->app->runningInConsole()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Route::middleware('web')
|
||||||
|
->domain(request()->getHost())
|
||||||
|
->group(__DIR__.'/Routes/web.php');
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Website/Api/Controllers/DocsController.php
Normal file
72
src/Website/Api/Controllers/DocsController.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Website\Api\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
use Website\Api\Services\OpenApiGenerator;
|
||||||
|
|
||||||
|
class DocsController
|
||||||
|
{
|
||||||
|
public function index(): View
|
||||||
|
{
|
||||||
|
return view('api::index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function guides(): View
|
||||||
|
{
|
||||||
|
return view('api::guides.index');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function quickstart(): View
|
||||||
|
{
|
||||||
|
return view('api::guides.quickstart');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authentication(): View
|
||||||
|
{
|
||||||
|
return view('api::guides.authentication');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function qrcodes(): View
|
||||||
|
{
|
||||||
|
return view('api::guides.qrcodes');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function webhooks(): View
|
||||||
|
{
|
||||||
|
return view('api::guides.webhooks');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function errors(): View
|
||||||
|
{
|
||||||
|
return view('api::guides.errors');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reference(): View
|
||||||
|
{
|
||||||
|
return view('api::reference');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function swagger(): View
|
||||||
|
{
|
||||||
|
return view('api::swagger');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scalar(): View
|
||||||
|
{
|
||||||
|
return view('api::scalar');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function redoc(): View
|
||||||
|
{
|
||||||
|
return view('api::redoc');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function openapi(OpenApiGenerator $generator): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($generator->generate());
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/Website/Api/Routes/web.php
Normal file
34
src/Website/Api/Routes/web.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Website\Api\Controllers\DocsController;
|
||||||
|
|
||||||
|
// Documentation landing
|
||||||
|
Route::get('/', [DocsController::class, 'index'])->name('api.docs');
|
||||||
|
|
||||||
|
// Guides
|
||||||
|
Route::get('/guides', [DocsController::class, 'guides'])->name('api.guides');
|
||||||
|
Route::get('/guides/quickstart', [DocsController::class, 'quickstart'])->name('api.guides.quickstart');
|
||||||
|
Route::get('/guides/authentication', [DocsController::class, 'authentication'])->name('api.guides.authentication');
|
||||||
|
Route::get('/guides/qrcodes', [DocsController::class, 'qrcodes'])->name('api.guides.qrcodes');
|
||||||
|
Route::get('/guides/webhooks', [DocsController::class, 'webhooks'])->name('api.guides.webhooks');
|
||||||
|
Route::get('/guides/errors', [DocsController::class, 'errors'])->name('api.guides.errors');
|
||||||
|
|
||||||
|
// API Reference
|
||||||
|
Route::get('/reference', [DocsController::class, 'reference'])->name('api.reference');
|
||||||
|
|
||||||
|
// Swagger UI
|
||||||
|
Route::get('/swagger', [DocsController::class, 'swagger'])->name('api.swagger');
|
||||||
|
|
||||||
|
// Scalar (modern API reference with sidebar)
|
||||||
|
Route::get('/scalar', [DocsController::class, 'scalar'])->name('api.scalar');
|
||||||
|
|
||||||
|
// ReDoc (three-panel API reference)
|
||||||
|
Route::get('/redoc', [DocsController::class, 'redoc'])->name('api.redoc');
|
||||||
|
|
||||||
|
// OpenAPI spec (rate limited - expensive to generate)
|
||||||
|
Route::get('/openapi.json', [DocsController::class, 'openapi'])
|
||||||
|
->middleware('throttle:60,1')
|
||||||
|
->name('api.openapi.json');
|
||||||
348
src/Website/Api/Services/OpenApiGenerator.php
Normal file
348
src/Website/Api/Services/OpenApiGenerator.php
Normal file
|
|
@ -0,0 +1,348 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Core\Website\Api\Services;
|
||||||
|
|
||||||
|
use Illuminate\Routing\Route;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Route as RouteFacade;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class OpenApiGenerator
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Cache duration in seconds (1 hour in production, 0 in local).
|
||||||
|
*/
|
||||||
|
protected function getCacheDuration(): int
|
||||||
|
{
|
||||||
|
return app()->isProduction() ? 3600 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate OpenAPI 3.0 specification from Laravel routes.
|
||||||
|
*/
|
||||||
|
public function generate(): array
|
||||||
|
{
|
||||||
|
$duration = $this->getCacheDuration();
|
||||||
|
|
||||||
|
if ($duration === 0) {
|
||||||
|
return $this->buildSpec();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Cache::remember('openapi:spec', $duration, fn () => $this->buildSpec());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the cached OpenAPI spec.
|
||||||
|
*/
|
||||||
|
public function clearCache(): void
|
||||||
|
{
|
||||||
|
Cache::forget('openapi:spec');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full OpenAPI specification.
|
||||||
|
*/
|
||||||
|
protected function buildSpec(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'openapi' => '3.0.0',
|
||||||
|
'info' => $this->buildInfo(),
|
||||||
|
'servers' => $this->buildServers(),
|
||||||
|
'tags' => $this->buildTags(),
|
||||||
|
'paths' => $this->buildPaths(),
|
||||||
|
'components' => $this->buildComponents(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildInfo(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => config('app.name').' API',
|
||||||
|
'description' => 'Unified API for Host UK services including commerce, analytics, push notifications, support, and MCP.',
|
||||||
|
'version' => config('api.version', '1.0.0'),
|
||||||
|
'contact' => [
|
||||||
|
'name' => config('app.name').' Support',
|
||||||
|
'url' => config('app.url').'/contact',
|
||||||
|
'email' => config('mail.from.address', 'support@host.uk.com'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildServers(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'url' => config('app.url').'/api',
|
||||||
|
'description' => 'Production API',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildTags(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['name' => 'Analytics', 'description' => 'Website analytics and tracking'],
|
||||||
|
['name' => 'Bio', 'description' => 'Bio link pages, blocks, and QR codes'],
|
||||||
|
['name' => 'Chat Widget', 'description' => 'Public chat widget API'],
|
||||||
|
['name' => 'Commerce', 'description' => 'Billing, orders, invoices, subscriptions, and provisioning'],
|
||||||
|
['name' => 'Content', 'description' => 'AI content generation and briefs'],
|
||||||
|
['name' => 'Entitlements', 'description' => 'Feature entitlements and usage'],
|
||||||
|
['name' => 'MCP', 'description' => 'Model Context Protocol HTTP bridge'],
|
||||||
|
['name' => 'Notify', 'description' => 'Push notification management'],
|
||||||
|
['name' => 'Pixel', 'description' => 'Unified pixel tracking'],
|
||||||
|
['name' => 'SEO', 'description' => 'SEO report and analysis endpoints'],
|
||||||
|
['name' => 'Social', 'description' => 'Social media management'],
|
||||||
|
['name' => 'Support', 'description' => 'Helpdesk API'],
|
||||||
|
['name' => 'Tenant', 'description' => 'Workspaces and multi-tenancy'],
|
||||||
|
['name' => 'Trees', 'description' => 'Trees for Agents statistics'],
|
||||||
|
['name' => 'Trust', 'description' => 'Social proof widgets'],
|
||||||
|
['name' => 'Webhooks', 'description' => 'Incoming webhook endpoints for external services'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildPaths(): array
|
||||||
|
{
|
||||||
|
$paths = [];
|
||||||
|
|
||||||
|
foreach (RouteFacade::getRoutes() as $route) {
|
||||||
|
/** @var Route $route */
|
||||||
|
if (! $this->isApiRoute($route)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$path = $this->normalisePath($route->uri());
|
||||||
|
$methods = array_filter($route->methods(), fn ($m) => $m !== 'HEAD');
|
||||||
|
|
||||||
|
foreach ($methods as $method) {
|
||||||
|
$method = strtolower($method);
|
||||||
|
$paths[$path][$method] = $this->buildOperation($route, $method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($paths);
|
||||||
|
|
||||||
|
return $paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function isApiRoute(Route $route): bool
|
||||||
|
{
|
||||||
|
$uri = $route->uri();
|
||||||
|
|
||||||
|
// Must start with 'api/' or be exactly 'api'
|
||||||
|
if (! str_starts_with($uri, 'api/') && $uri !== 'api') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip sanctum routes
|
||||||
|
if (str_contains($uri, 'sanctum')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalisePath(string $uri): string
|
||||||
|
{
|
||||||
|
// Remove 'api' prefix, keep leading slash
|
||||||
|
$path = '/'.ltrim(Str::after($uri, 'api/'), '/');
|
||||||
|
|
||||||
|
// Convert Laravel route parameters to OpenAPI format
|
||||||
|
$path = preg_replace('/\{([^}]+)\}/', '{$1}', $path);
|
||||||
|
|
||||||
|
return $path === '/' ? '/' : rtrim($path, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildOperation(Route $route, string $method): array
|
||||||
|
{
|
||||||
|
$name = $route->getName() ?? '';
|
||||||
|
$tag = $this->inferTag($route);
|
||||||
|
|
||||||
|
$operation = [
|
||||||
|
'tags' => [$tag],
|
||||||
|
'summary' => $this->generateSummary($route, $method),
|
||||||
|
'operationId' => $name ?: Str::camel($method.'_'.str_replace('/', '_', $route->uri())),
|
||||||
|
'responses' => [
|
||||||
|
'200' => ['description' => 'Successful response'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add parameters for path variables
|
||||||
|
$parameters = $this->buildParameters($route);
|
||||||
|
if (! empty($parameters)) {
|
||||||
|
$operation['parameters'] = $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add request body for POST/PUT/PATCH
|
||||||
|
if (in_array($method, ['post', 'put', 'patch'])) {
|
||||||
|
$operation['requestBody'] = [
|
||||||
|
'required' => true,
|
||||||
|
'content' => [
|
||||||
|
'application/json' => [
|
||||||
|
'schema' => ['type' => 'object'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add security based on middleware
|
||||||
|
$security = $this->inferSecurity($route);
|
||||||
|
if (! empty($security)) {
|
||||||
|
$operation['security'] = $security;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $operation;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function inferTag(Route $route): string
|
||||||
|
{
|
||||||
|
$uri = $route->uri();
|
||||||
|
$name = $route->getName() ?? '';
|
||||||
|
|
||||||
|
// Match by route name prefix
|
||||||
|
$tagMap = [
|
||||||
|
'api.webhook' => 'Webhooks',
|
||||||
|
'api.trees' => 'Trees',
|
||||||
|
'api.seo' => 'SEO',
|
||||||
|
'api.pixel' => 'Pixel',
|
||||||
|
'api.commerce' => 'Commerce',
|
||||||
|
'api.entitlements' => 'Entitlements',
|
||||||
|
'api.support.chat' => 'Chat Widget',
|
||||||
|
'api.support' => 'Support',
|
||||||
|
'api.mcp' => 'MCP',
|
||||||
|
'api.social' => 'Social',
|
||||||
|
'api.notify' => 'Notify',
|
||||||
|
'api.bio' => 'Bio',
|
||||||
|
'api.blocks' => 'Bio',
|
||||||
|
'api.shortlinks' => 'Bio',
|
||||||
|
'api.qr' => 'Bio',
|
||||||
|
'api.workspaces' => 'Tenant',
|
||||||
|
'api.key.workspaces' => 'Tenant',
|
||||||
|
'api.key.bio' => 'Bio',
|
||||||
|
'api.key.blocks' => 'Bio',
|
||||||
|
'api.key.shortlinks' => 'Bio',
|
||||||
|
'api.key.qr' => 'Bio',
|
||||||
|
'api.content' => 'Content',
|
||||||
|
'api.key.content' => 'Content',
|
||||||
|
'api.trust' => 'Trust',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($tagMap as $prefix => $tag) {
|
||||||
|
if (str_starts_with($name, $prefix)) {
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match by URI prefix (check start of path after 'api/')
|
||||||
|
$path = preg_replace('#^api/#', '', $uri);
|
||||||
|
$uriTagMap = [
|
||||||
|
'webhooks' => 'Webhooks',
|
||||||
|
'trees' => 'Trees',
|
||||||
|
'pixel' => 'Pixel',
|
||||||
|
'provisioning' => 'Commerce',
|
||||||
|
'commerce' => 'Commerce',
|
||||||
|
'entitlements' => 'Entitlements',
|
||||||
|
'support/chat' => 'Chat Widget',
|
||||||
|
'support' => 'Support',
|
||||||
|
'mcp' => 'MCP',
|
||||||
|
'bio' => 'Bio',
|
||||||
|
'shortlinks' => 'Bio',
|
||||||
|
'qr' => 'Bio',
|
||||||
|
'blocks' => 'Bio',
|
||||||
|
'workspaces' => 'Tenant',
|
||||||
|
'analytics' => 'Analytics',
|
||||||
|
'social' => 'Social',
|
||||||
|
'trust' => 'Trust',
|
||||||
|
'notify' => 'Notify',
|
||||||
|
'content' => 'Content',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($uriTagMap as $prefix => $tag) {
|
||||||
|
if (str_starts_with($path, $prefix)) {
|
||||||
|
return $tag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'General';
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function generateSummary(Route $route, string $method): string
|
||||||
|
{
|
||||||
|
$name = $route->getName();
|
||||||
|
|
||||||
|
if ($name) {
|
||||||
|
// Convert route name to human-readable summary
|
||||||
|
$parts = explode('.', $name);
|
||||||
|
$action = array_pop($parts);
|
||||||
|
|
||||||
|
return Str::title(str_replace(['-', '_'], ' ', $action));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate from URI and method
|
||||||
|
$uri = Str::afterLast($route->uri(), '/');
|
||||||
|
|
||||||
|
return Str::title($method.' '.str_replace(['-', '_'], ' ', $uri));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildParameters(Route $route): array
|
||||||
|
{
|
||||||
|
$parameters = [];
|
||||||
|
preg_match_all('/\{([^}]+)\}/', $route->uri(), $matches);
|
||||||
|
|
||||||
|
foreach ($matches[1] as $param) {
|
||||||
|
$optional = str_ends_with($param, '?');
|
||||||
|
$paramName = rtrim($param, '?');
|
||||||
|
|
||||||
|
$parameters[] = [
|
||||||
|
'name' => $paramName,
|
||||||
|
'in' => 'path',
|
||||||
|
'required' => ! $optional,
|
||||||
|
'schema' => ['type' => 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parameters;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function inferSecurity(Route $route): array
|
||||||
|
{
|
||||||
|
$middleware = $route->middleware();
|
||||||
|
|
||||||
|
if (in_array('auth', $middleware) || in_array('auth:sanctum', $middleware)) {
|
||||||
|
return [['bearerAuth' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array('commerce.api', $middleware)) {
|
||||||
|
return [['apiKeyAuth' => []]];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($middleware as $m) {
|
||||||
|
if (str_contains($m, 'McpApiKeyAuth')) {
|
||||||
|
return [['apiKeyAuth' => []]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function buildComponents(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'securitySchemes' => [
|
||||||
|
'bearerAuth' => [
|
||||||
|
'type' => 'http',
|
||||||
|
'scheme' => 'bearer',
|
||||||
|
'bearerFormat' => 'JWT',
|
||||||
|
'description' => 'Sanctum authentication token',
|
||||||
|
],
|
||||||
|
'apiKeyAuth' => [
|
||||||
|
'type' => 'apiKey',
|
||||||
|
'in' => 'header',
|
||||||
|
'name' => 'X-API-Key',
|
||||||
|
'description' => 'API key for service-to-service authentication',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/Website/Api/View/Blade/docs.blade.php
Normal file
111
src/Website/Api/View/Blade/docs.blade.php
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Host UK API Documentation</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
*,
|
||||||
|
*:before,
|
||||||
|
*:after {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
.swagger-ui .topbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.swagger-ui .info {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.swagger-ui .info .title {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
/* Custom header */
|
||||||
|
.api-header {
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
|
||||||
|
padding: 24px 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.api-header-title {
|
||||||
|
color: white;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.api-header-title svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
.api-header-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.api-header-links a {
|
||||||
|
color: #94a3b8;
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.api-header-links a:hover {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="api-header">
|
||||||
|
<h1 class="api-header-title">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||||
|
</svg>
|
||||||
|
Host UK API
|
||||||
|
</h1>
|
||||||
|
<nav class="api-header-links">
|
||||||
|
<a href="{{ config('app.url') }}">Host UK</a>
|
||||||
|
<a href="/openapi.json" target="_blank">OpenAPI JSON</a>
|
||||||
|
<a href="{{ str_replace('api.', 'mcp.', request()->getSchemeAndHttpHost()) }}">MCP Portal</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: "/openapi.json",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout",
|
||||||
|
defaultModelsExpandDepth: -1,
|
||||||
|
docExpansion: 'list',
|
||||||
|
filter: true,
|
||||||
|
showExtensions: true,
|
||||||
|
showCommonExtensions: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
187
src/Website/Api/View/Blade/guides/authentication.blade.php
Normal file
187
src/Website/Api/View/Blade/guides/authentication.blade.php
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'Authentication')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="flex">
|
||||||
|
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<aside class="hidden lg:block fixed left-0 top-16 md:top-20 bottom-0 w-64 border-r border-slate-200 dark:border-slate-800">
|
||||||
|
<div class="h-full px-4 py-8 overflow-y-auto no-scrollbar">
|
||||||
|
<nav>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="#overview" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#api-keys" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
API Keys
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#using-keys" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Using API Keys
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#scopes" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Scopes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#security" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Security Best Practices
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Main content --}}
|
||||||
|
<div class="lg:pl-64 w-full">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
|
||||||
|
{{-- Breadcrumb --}}
|
||||||
|
<nav class="mb-8">
|
||||||
|
<ol class="flex items-center gap-2 text-sm">
|
||||||
|
<li><a href="{{ route('api.guides') }}" class="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">Guides</a></li>
|
||||||
|
<li class="text-slate-400">/</li>
|
||||||
|
<li class="text-slate-800 dark:text-slate-200">Authentication</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">Authentication</h1>
|
||||||
|
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
|
||||||
|
Learn how to authenticate your API requests using API keys.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Overview --}}
|
||||||
|
<section id="overview" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
The API uses API keys for authentication. Each API key is scoped to a specific workspace and has configurable permissions.
|
||||||
|
</p>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400">
|
||||||
|
API keys are prefixed with <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-sm">hk_</code> to make them easily identifiable.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- API Keys --}}
|
||||||
|
<section id="api-keys" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">API Keys</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
To create an API key:
|
||||||
|
</p>
|
||||||
|
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
<li>Log in to your account</li>
|
||||||
|
<li>Navigate to <strong>Settings → API Keys</strong></li>
|
||||||
|
<li>Click <strong>Create API Key</strong></li>
|
||||||
|
<li>Enter a descriptive name (e.g., "Production", "Development")</li>
|
||||||
|
<li>Select the required scopes</li>
|
||||||
|
<li>Copy the generated key immediately</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="text-sm p-4 bg-amber-50 border border-amber-200 rounded-sm dark:bg-amber-900/20 dark:border-amber-800">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="fill-amber-500 shrink-0 mr-3 mt-0.5" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm0 12a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm1-4a1 1 0 0 1-2 0V5a1 1 0 0 1 2 0v3z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>Important:</strong> API keys are only shown once when created. Store them securely as they cannot be retrieved later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Using Keys --}}
|
||||||
|
<section id="using-keys" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Using API Keys</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Include your API key in the <code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-sm">Authorization</code> header as a Bearer token:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">HTTP Header</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">Authorization: Bearer hk_your_api_key_here</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Example request with cURL:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">cURL</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> GET \
|
||||||
|
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer hk_your_api_key'</span></code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Scopes --}}
|
||||||
|
<section id="scopes" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Scopes</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
API keys can have different scopes to limit their permissions:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Scope</th>
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">read</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Read access to resources (GET requests)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">write</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Create and update resources (POST, PUT requests)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">delete</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Delete resources (DELETE requests)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Security --}}
|
||||||
|
<section id="security" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Security Best Practices</h2>
|
||||||
|
<ul class="list-disc list-inside space-y-2 text-slate-600 dark:text-slate-400">
|
||||||
|
<li>Never commit API keys to version control</li>
|
||||||
|
<li>Use environment variables to store keys</li>
|
||||||
|
<li>Rotate keys periodically</li>
|
||||||
|
<li>Use the minimum required scopes</li>
|
||||||
|
<li>Revoke unused keys immediately</li>
|
||||||
|
<li>Never expose keys in client-side code</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Next steps --}}
|
||||||
|
<div class="flex items-center justify-between pt-8 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<a href="{{ route('api.guides.quickstart') }}" class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200">
|
||||||
|
← Quick Start
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.guides.biolinks') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 font-medium">
|
||||||
|
Managing Biolinks →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
211
src/Website/Api/View/Blade/guides/errors.blade.php
Normal file
211
src/Website/Api/View/Blade/guides/errors.blade.php
Normal file
|
|
@ -0,0 +1,211 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'Error Handling')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="flex">
|
||||||
|
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<aside class="hidden lg:block fixed left-0 top-16 md:top-20 bottom-0 w-64 border-r border-slate-200 dark:border-slate-800">
|
||||||
|
<div class="h-full px-4 py-8 overflow-y-auto no-scrollbar">
|
||||||
|
<nav>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="#overview" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#http-codes" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
HTTP Status Codes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#error-format" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Error Format
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#common-errors" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Common Errors
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#rate-limiting" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Rate Limiting
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Main content --}}
|
||||||
|
<div class="lg:pl-64 w-full">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
|
||||||
|
{{-- Breadcrumb --}}
|
||||||
|
<nav class="mb-8">
|
||||||
|
<ol class="flex items-center gap-2 text-sm">
|
||||||
|
<li><a href="{{ route('api.guides') }}" class="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">Guides</a></li>
|
||||||
|
<li class="text-slate-400">/</li>
|
||||||
|
<li class="text-slate-800 dark:text-slate-200">Error Handling</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">Error Handling</h1>
|
||||||
|
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
|
||||||
|
Understand API error codes and how to handle them gracefully.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Overview --}}
|
||||||
|
<section id="overview" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
The API uses conventional HTTP response codes to indicate success or failure. Codes in the 2xx range indicate success, 4xx indicate client errors, and 5xx indicate server errors.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- HTTP Codes --}}
|
||||||
|
<section id="http-codes" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">HTTP Status Codes</h2>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Code</th>
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Meaning</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded text-xs font-medium">200</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Success - Request completed successfully</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 rounded text-xs font-medium">201</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Created - Resource was created successfully</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs font-medium">400</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Bad Request - Invalid request parameters</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs font-medium">401</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Unauthorised - Invalid or missing API key</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs font-medium">403</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Forbidden - Insufficient permissions</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs font-medium">404</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Not Found - Resource doesn't exist</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs font-medium">422</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Unprocessable - Validation failed</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded text-xs font-medium">429</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Too Many Requests - Rate limit exceeded</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><span class="px-2 py-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 rounded text-xs font-medium">500</span></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Server Error - Something went wrong on our end</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Error Format --}}
|
||||||
|
<section id="error-format" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Error Format</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Error responses include a JSON body with details:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">{
|
||||||
|
<span class="text-blue-400">"message"</span>: <span class="text-green-400">"The given data was invalid."</span>,
|
||||||
|
<span class="text-blue-400">"errors"</span>: {
|
||||||
|
<span class="text-blue-400">"url"</span>: [
|
||||||
|
<span class="text-green-400">"The url has already been taken."</span>
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Common Errors --}}
|
||||||
|
<section id="common-errors" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Common Errors</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="p-4 border border-slate-200 dark:border-slate-700 rounded-sm">
|
||||||
|
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2">Invalid API Key</h4>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Returned when the API key is missing, malformed, or revoked.
|
||||||
|
</p>
|
||||||
|
<code class="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded">401 Unauthorised</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border border-slate-200 dark:border-slate-700 rounded-sm">
|
||||||
|
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2">Resource Not Found</h4>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
The requested resource (biolink, workspace, etc.) doesn't exist or you don't have access.
|
||||||
|
</p>
|
||||||
|
<code class="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded">404 Not Found</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border border-slate-200 dark:border-slate-700 rounded-sm">
|
||||||
|
<h4 class="font-medium text-slate-800 dark:text-slate-200 mb-2">Validation Failed</h4>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400 mb-2">
|
||||||
|
Request data failed validation. Check the <code>errors</code> object for specific fields.
|
||||||
|
</p>
|
||||||
|
<code class="text-xs px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded">422 Unprocessable Entity</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Rate Limiting --}}
|
||||||
|
<section id="rate-limiting" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Rate Limiting</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
API requests are rate limited to ensure fair usage. Rate limit headers are included in all responses:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">X-RateLimit-Limit: 60
|
||||||
|
X-RateLimit-Remaining: 58
|
||||||
|
X-RateLimit-Reset: 1705320000</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
When rate limited, you'll receive a 429 response. Wait until the reset timestamp before retrying.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="text-sm p-4 bg-slate-50 border border-slate-200 rounded-sm dark:bg-slate-800 dark:border-slate-700">
|
||||||
|
<p class="text-slate-600 dark:text-slate-400">
|
||||||
|
<strong>Tip:</strong> Implement exponential backoff in your retry logic. Start with a 1-second delay and double it with each retry, up to a maximum of 32 seconds.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Next steps --}}
|
||||||
|
<div class="flex items-center justify-between pt-8 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<a href="{{ route('api.guides.webhooks') }}" class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200">
|
||||||
|
← Webhooks
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.reference') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 font-medium">
|
||||||
|
API Reference →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
88
src/Website/Api/View/Blade/guides/index.blade.php
Normal file
88
src/Website/Api/View/Blade/guides/index.blade.php
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'Guides')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<h1 class="h2 mb-4 text-slate-800 dark:text-slate-100">Guides</h1>
|
||||||
|
<p class="text-lg text-slate-600 dark:text-slate-400 mb-12">
|
||||||
|
Step-by-step tutorials and best practices for integrating with the API.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
|
||||||
|
{{-- Quick Start --}}
|
||||||
|
<a href="{{ route('api.guides.quickstart') }}" class="group block p-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-8 h-8 flex items-center justify-center bg-blue-100 dark:bg-blue-900/30 rounded-sm">
|
||||||
|
<svg class="w-4 h-4 fill-blue-600" viewBox="0 0 16 16">
|
||||||
|
<path d="M11.953 4.29a.5.5 0 0 0-.454-.292H6.14L6.984.62A.5.5 0 0 0 6.12.173l-6 7a.5.5 0 0 0 .379.825h5.359l-.844 3.38a.5.5 0 0 0 .864.445l6-7a.5.5 0 0 0 .075-.534Z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Getting Started</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-500">Quick Start</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Get up and running with the API in under 5 minutes.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Authentication --}}
|
||||||
|
<a href="{{ route('api.guides.authentication') }}" class="group block p-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-8 h-8 flex items-center justify-center bg-amber-100 dark:bg-amber-900/30 rounded-sm">
|
||||||
|
<svg class="w-4 h-4 fill-amber-600" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 1a4 4 0 0 0-4 4v3H3a1 1 0 0 0-1 1v5a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V9a1 1 0 0 0-1-1h-1V5a4 4 0 0 0-4-4zm2 7V5a2 2 0 1 0-4 0v3h4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Security</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-500">Authentication</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Learn how to authenticate your API requests using API keys.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- QR Codes --}}
|
||||||
|
<a href="{{ route('api.guides.qrcodes') }}" class="group block p-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-8 h-8 flex items-center justify-center bg-teal-100 dark:bg-teal-900/30 rounded-sm">
|
||||||
|
<svg class="w-4 h-4 fill-teal-600" viewBox="0 0 16 16">
|
||||||
|
<path d="M2 3a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3zm2 2V4h1v1H4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Core</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-500">QR Code Generation</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Generate customisable QR codes with colours, logos, and formats.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Webhooks --}}
|
||||||
|
<a href="{{ route('api.guides.webhooks') }}" class="group block p-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-8 h-8 flex items-center justify-center bg-rose-100 dark:bg-rose-900/30 rounded-sm">
|
||||||
|
<svg class="w-4 h-4 fill-rose-600" viewBox="0 0 16 16">
|
||||||
|
<path d="M8.94 1.5a.75.75 0 0 0-1.06 0L7 2.38 6.12 1.5a.75.75 0 0 0-1.06 1.06l.88.88-.88.88a.75.75 0 1 0 1.06 1.06L7 4.5l.88.88a.75.75 0 1 0 1.06-1.06l-.88-.88.88-.88a.75.75 0 0 0 0-1.06z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Advanced</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-500">Webhooks</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Receive real-time notifications for events in your workspace.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Error Handling --}}
|
||||||
|
<a href="{{ route('api.guides.errors') }}" class="group block p-6 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="w-8 h-8 flex items-center justify-center bg-slate-100 dark:bg-slate-700 rounded-sm">
|
||||||
|
<svg class="w-4 h-4 fill-slate-600 dark:fill-slate-400" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1zm0 3a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider">Reference</span>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100 group-hover:text-blue-600 dark:group-hover:text-blue-500">Error Handling</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Understand API error codes and how to handle them gracefully.</p>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
202
src/Website/Api/View/Blade/guides/qrcodes.blade.php
Normal file
202
src/Website/Api/View/Blade/guides/qrcodes.blade.php
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'QR Code Generation')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="flex">
|
||||||
|
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<aside class="hidden lg:block fixed left-0 top-16 md:top-20 bottom-0 w-64 border-r border-slate-200 dark:border-slate-800">
|
||||||
|
<div class="h-full px-4 py-8 overflow-y-auto no-scrollbar">
|
||||||
|
<nav>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="#overview" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#biolink-qr" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Biolink QR Codes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#custom-qr" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Custom URL QR Codes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#options" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Customisation Options
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#download" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Download Formats
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Main content --}}
|
||||||
|
<div class="lg:pl-64 w-full">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
|
||||||
|
{{-- Breadcrumb --}}
|
||||||
|
<nav class="mb-8">
|
||||||
|
<ol class="flex items-center gap-2 text-sm">
|
||||||
|
<li><a href="{{ route('api.guides') }}" class="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">Guides</a></li>
|
||||||
|
<li class="text-slate-400">/</li>
|
||||||
|
<li class="text-slate-800 dark:text-slate-200">QR Code Generation</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">QR Code Generation</h1>
|
||||||
|
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
|
||||||
|
Generate customisable QR codes for your biolinks or any URL.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Overview --}}
|
||||||
|
<section id="overview" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
The API provides two ways to generate QR codes:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside space-y-2 text-slate-600 dark:text-slate-400">
|
||||||
|
<li><strong>Biolink QR codes</strong> - Generate QR codes for your existing biolinks</li>
|
||||||
|
<li><strong>Custom URL QR codes</strong> - Generate QR codes for any URL</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Biolink QR --}}
|
||||||
|
<section id="biolink-qr" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Biolink QR Codes</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Get QR code data for an existing biolink:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">cURL</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> GET \
|
||||||
|
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio/1/qr'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">Response:</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">{
|
||||||
|
<span class="text-blue-400">"data"</span>: {
|
||||||
|
<span class="text-blue-400">"svg"</span>: <span class="text-green-400">"<svg>...</svg>"</span>,
|
||||||
|
<span class="text-blue-400">"url"</span>: <span class="text-green-400">"https://example.com/mypage"</span>
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Custom QR --}}
|
||||||
|
<section id="custom-qr" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Custom URL QR Codes</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Generate a QR code for any URL:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">cURL</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> POST \
|
||||||
|
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/qr/generate'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Content-Type: application/json'</span> \
|
||||||
|
<span class="text-slate-500">--data</span> <span class="text-amber-400">'{
|
||||||
|
"url": "https://example.com",
|
||||||
|
"format": "svg",
|
||||||
|
"size": 300
|
||||||
|
}'</span></code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Options --}}
|
||||||
|
<section id="options" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Customisation Options</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Available customisation parameters:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Parameter</th>
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Type</th>
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">format</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">string</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Output format: <code>svg</code> or <code>png</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">size</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">integer</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Size in pixels (100-2000)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">color</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">string</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Foreground colour (hex)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">background</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">string</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Background colour (hex)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Download --}}
|
||||||
|
<section id="download" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Download Formats</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Download QR codes directly as image files:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">cURL</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> GET \
|
||||||
|
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio/1/qr/download?format=png&size=500'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span> \
|
||||||
|
<span class="text-slate-500">--output</span> <span class="text-amber-400">qrcode.png</span></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400">
|
||||||
|
The response is binary image data with appropriate Content-Type header.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Next steps --}}
|
||||||
|
<div class="flex items-center justify-between pt-8 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<a href="{{ route('api.guides.biolinks') }}" class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200">
|
||||||
|
← Managing Biolinks
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.guides.webhooks') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 font-medium">
|
||||||
|
Webhooks →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
193
src/Website/Api/View/Blade/guides/quickstart.blade.php
Normal file
193
src/Website/Api/View/Blade/guides/quickstart.blade.php
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'Quick Start')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="flex">
|
||||||
|
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<aside class="hidden lg:block fixed left-0 top-16 md:top-20 bottom-0 w-64 border-r border-slate-200 dark:border-slate-800">
|
||||||
|
<div class="h-full px-4 py-8 overflow-y-auto no-scrollbar">
|
||||||
|
<nav>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="#prerequisites" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Prerequisites
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#create-api-key" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Create an API Key
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#first-request" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Make Your First Request
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#create-biolink" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Create a Biolink
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#next-steps" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Next Steps
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Main content --}}
|
||||||
|
<div class="lg:pl-64 w-full">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
|
||||||
|
{{-- Breadcrumb --}}
|
||||||
|
<nav class="mb-8">
|
||||||
|
<ol class="flex items-center gap-2 text-sm">
|
||||||
|
<li><a href="{{ route('api.guides') }}" class="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">Guides</a></li>
|
||||||
|
<li class="text-slate-400">/</li>
|
||||||
|
<li class="text-slate-800 dark:text-slate-200">Quick Start</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">Quick Start</h1>
|
||||||
|
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
|
||||||
|
Get up and running with the API in under 5 minutes.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Prerequisites --}}
|
||||||
|
<section id="prerequisites" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Prerequisites</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Before you begin, you'll need:
|
||||||
|
</p>
|
||||||
|
<ul class="list-disc list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
<li>An account with API access</li>
|
||||||
|
<li>A workspace (created automatically on signup)</li>
|
||||||
|
<li>cURL or any HTTP client</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Create API Key --}}
|
||||||
|
<section id="create-api-key" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Create an API Key</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Navigate to your workspace settings and create a new API key:
|
||||||
|
</p>
|
||||||
|
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
<li>Go to <strong>Settings → API Keys</strong></li>
|
||||||
|
<li>Click <strong>Create API Key</strong></li>
|
||||||
|
<li>Give it a name (e.g., "Development")</li>
|
||||||
|
<li>Select the scopes you need (read, write, delete)</li>
|
||||||
|
<li>Copy the key - it won't be shown again!</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{{-- Note box --}}
|
||||||
|
<div class="text-sm p-4 bg-amber-50 border border-amber-200 rounded-sm dark:bg-amber-900/20 dark:border-amber-800">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="fill-amber-500 shrink-0 mr-3 mt-0.5" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm0 12a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm1-4a1 1 0 0 1-2 0V5a1 1 0 0 1 2 0v3z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>Important:</strong> Store your API key securely. Never commit it to version control or expose it in client-side code.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- First Request --}}
|
||||||
|
<section id="first-request" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Make Your First Request</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Let's verify your API key by listing your workspaces:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">cURL</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> GET \
|
||||||
|
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/workspaces/current'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
You should receive a response like:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">Response</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">{
|
||||||
|
<span class="text-blue-400">"data"</span>: {
|
||||||
|
<span class="text-blue-400">"id"</span>: <span class="text-amber-400">1</span>,
|
||||||
|
<span class="text-blue-400">"name"</span>: <span class="text-green-400">"My Workspace"</span>,
|
||||||
|
<span class="text-blue-400">"slug"</span>: <span class="text-green-400">"my-workspace-abc123"</span>,
|
||||||
|
<span class="text-blue-400">"is_active"</span>: <span class="text-purple-400">true</span>
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Create Biolink --}}
|
||||||
|
<section id="create-biolink" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Create a Biolink</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Now let's create your first biolink programmatically:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-4">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">cURL</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> POST \
|
||||||
|
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer YOUR_API_KEY'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Content-Type: application/json'</span> \
|
||||||
|
<span class="text-slate-500">--data</span> <span class="text-amber-400">'{
|
||||||
|
"url": "mypage",
|
||||||
|
"type": "biolink"
|
||||||
|
}'</span></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400">
|
||||||
|
This creates a new biolink page at your configured short URL.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Next Steps --}}
|
||||||
|
<section id="next-steps" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Next Steps</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
Now that you've made your first API calls, explore more:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="grid sm:grid-cols-2 gap-4">
|
||||||
|
<a href="{{ route('api.guides.biolinks') }}" class="p-4 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<h3 class="font-medium text-slate-800 dark:text-slate-200 mb-1">Managing Biolinks</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Add blocks, update settings, and customise themes.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.guides.qrcodes') }}" class="p-4 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<h3 class="font-medium text-slate-800 dark:text-slate-200 mb-1">QR Code Generation</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Generate customised QR codes for your biolinks.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.reference') }}" class="p-4 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<h3 class="font-medium text-slate-800 dark:text-slate-200 mb-1">API Reference</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Complete documentation of all endpoints.</p>
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.swagger') }}" class="p-4 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600">
|
||||||
|
<h3 class="font-medium text-slate-800 dark:text-slate-200 mb-1">Swagger UI</h3>
|
||||||
|
<p class="text-sm text-slate-600 dark:text-slate-400">Interactive API explorer with try-it-out.</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
586
src/Website/Api/View/Blade/guides/webhooks.blade.php
Normal file
586
src/Website/Api/View/Blade/guides/webhooks.blade.php
Normal file
|
|
@ -0,0 +1,586 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'Webhooks')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="flex">
|
||||||
|
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<aside class="hidden lg:block fixed left-0 top-16 md:top-20 bottom-0 w-64 border-r border-slate-200 dark:border-slate-800">
|
||||||
|
<div class="h-full px-4 py-8 overflow-y-auto no-scrollbar">
|
||||||
|
<nav>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li>
|
||||||
|
<a href="#overview" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Overview
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#setup" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Setup
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#events" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Event Types
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#payload" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Payload Format
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#headers" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Request Headers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#verification" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Signature Verification
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#retry-policy" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Retry Policy
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#best-practices" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Best Practices
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Main content --}}
|
||||||
|
<div class="lg:pl-64 w-full">
|
||||||
|
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
|
||||||
|
{{-- Breadcrumb --}}
|
||||||
|
<nav class="mb-8">
|
||||||
|
<ol class="flex items-center gap-2 text-sm">
|
||||||
|
<li><a href="{{ route('api.guides') }}" class="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">Guides</a></li>
|
||||||
|
<li class="text-slate-400">/</li>
|
||||||
|
<li class="text-slate-800 dark:text-slate-200">Webhooks</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">Webhooks</h1>
|
||||||
|
<p class="text-xl text-slate-600 dark:text-slate-400 mb-12">
|
||||||
|
Receive real-time notifications for events in your workspace with cryptographically signed payloads.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Overview --}}
|
||||||
|
<section id="overview" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Overview</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Webhooks allow your application to receive real-time HTTP callbacks when events occur in your workspace. Instead of polling the API, webhooks push data to your server as events happen.
|
||||||
|
</p>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
All webhook requests are cryptographically signed using HMAC-SHA256, allowing you to verify that requests genuinely came from our platform and haven't been tampered with.
|
||||||
|
</p>
|
||||||
|
<div class="text-sm p-4 bg-amber-50 border border-amber-200 rounded-sm dark:bg-amber-900/20 dark:border-amber-800">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="fill-amber-500 shrink-0 mr-3 mt-0.5" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm0 12a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm1-3H7V4h2v5z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-amber-800 dark:text-amber-200">
|
||||||
|
<strong>Security:</strong> Always verify webhook signatures before processing. Never trust unverified webhook requests.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Setup --}}
|
||||||
|
<section id="setup" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Setup</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
To configure webhooks:
|
||||||
|
</p>
|
||||||
|
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
<li>Go to <strong>Settings → Webhooks</strong> in your workspace</li>
|
||||||
|
<li>Click <strong>Add Webhook</strong></li>
|
||||||
|
<li>Enter your endpoint URL (must be HTTPS in production)</li>
|
||||||
|
<li>Select the events you want to receive</li>
|
||||||
|
<li>Save and securely store your webhook secret</li>
|
||||||
|
</ol>
|
||||||
|
<div class="text-sm p-4 bg-blue-50 border border-blue-200 rounded-sm dark:bg-blue-900/20 dark:border-blue-800">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<svg class="fill-blue-500 shrink-0 mr-3 mt-0.5" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm1 12H7V7h2v5zm0-6H7V4h2v2z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-blue-800 dark:text-blue-200">
|
||||||
|
Your webhook secret is only shown once when you create the endpoint. Store it securely - you'll need it to verify incoming webhooks.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Events --}}
|
||||||
|
<section id="events" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Event Types</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Available webhook events:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Event</th>
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">bio.created</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A new biolink was created</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">bio.updated</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A biolink was updated</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">bio.deleted</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A biolink was deleted</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">link.created</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A new link was created</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">link.clicked</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A link was clicked (high volume)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">qrcode.created</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A QR code was generated</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">qrcode.scanned</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">A QR code was scanned (high volume)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">*</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Subscribe to all events (wildcard)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Payload --}}
|
||||||
|
<section id="payload" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Payload Format</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Webhook payloads are sent as JSON with a consistent structure:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300">{
|
||||||
|
<span class="text-blue-400">"id"</span>: <span class="text-green-400">"evt_abc123xyz456"</span>,
|
||||||
|
<span class="text-blue-400">"type"</span>: <span class="text-green-400">"bio.created"</span>,
|
||||||
|
<span class="text-blue-400">"created_at"</span>: <span class="text-green-400">"2024-01-15T10:30:00Z"</span>,
|
||||||
|
<span class="text-blue-400">"workspace_id"</span>: <span class="text-amber-400">1</span>,
|
||||||
|
<span class="text-blue-400">"data"</span>: {
|
||||||
|
<span class="text-blue-400">"id"</span>: <span class="text-amber-400">123</span>,
|
||||||
|
<span class="text-blue-400">"url"</span>: <span class="text-green-400">"mypage"</span>,
|
||||||
|
<span class="text-blue-400">"type"</span>: <span class="text-green-400">"biolink"</span>
|
||||||
|
}
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Headers --}}
|
||||||
|
<section id="headers" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Request Headers</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Every webhook request includes the following headers:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Header</th>
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Signature</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">HMAC-SHA256 signature for verification</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Timestamp</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Unix timestamp when the webhook was sent</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Event</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">The event type (e.g., <code>bio.created</code>)</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">X-Webhook-Id</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Unique delivery ID for idempotency</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4"><code class="px-1.5 py-0.5 bg-slate-100 dark:bg-slate-800 rounded text-xs">Content-Type</code></td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Always <code>application/json</code></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Verification --}}
|
||||||
|
<section id="verification" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Signature Verification</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
To verify a webhook signature, compute the HMAC-SHA256 of the timestamp concatenated with the raw request body using your webhook secret. The signature includes the timestamp to prevent replay attacks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Verification Algorithm</h3>
|
||||||
|
<ol class="list-decimal list-inside space-y-2 text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
<li>Extract <code>X-Webhook-Signature</code> and <code>X-Webhook-Timestamp</code> headers</li>
|
||||||
|
<li>Concatenate: <code>timestamp + "." + raw_request_body</code></li>
|
||||||
|
<li>Compute: <code>HMAC-SHA256(concatenated_string, your_webhook_secret)</code></li>
|
||||||
|
<li>Compare using timing-safe comparison (prevents timing attacks)</li>
|
||||||
|
<li>Verify timestamp is within 5 minutes of current time (prevents replay attacks)</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{{-- PHP Example --}}
|
||||||
|
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">PHP</h3>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">webhook-handler.php</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-purple-400"><?php</span>
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Get request data</span>
|
||||||
|
<span class="text-purple-400">$payload</span> = <span class="text-teal-400">file_get_contents</span>(<span class="text-green-400">'php://input'</span>);
|
||||||
|
<span class="text-purple-400">$signature</span> = <span class="text-purple-400">$_SERVER</span>[<span class="text-green-400">'HTTP_X_WEBHOOK_SIGNATURE'</span>] ?? <span class="text-green-400">''</span>;
|
||||||
|
<span class="text-purple-400">$timestamp</span> = <span class="text-purple-400">$_SERVER</span>[<span class="text-green-400">'HTTP_X_WEBHOOK_TIMESTAMP'</span>] ?? <span class="text-green-400">''</span>;
|
||||||
|
<span class="text-purple-400">$secret</span> = <span class="text-teal-400">getenv</span>(<span class="text-green-400">'WEBHOOK_SECRET'</span>);
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Verify timestamp (5 minute tolerance)</span>
|
||||||
|
<span class="text-purple-400">$tolerance</span> = <span class="text-amber-400">300</span>;
|
||||||
|
<span class="text-pink-400">if</span> (<span class="text-teal-400">abs</span>(<span class="text-teal-400">time</span>() - (<span class="text-pink-400">int</span>)<span class="text-purple-400">$timestamp</span>) > <span class="text-purple-400">$tolerance</span>) {
|
||||||
|
<span class="text-teal-400">http_response_code</span>(<span class="text-amber-400">401</span>);
|
||||||
|
<span class="text-pink-400">die</span>(<span class="text-green-400">'Webhook timestamp expired'</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Compute expected signature</span>
|
||||||
|
<span class="text-purple-400">$signedPayload</span> = <span class="text-purple-400">$timestamp</span> . <span class="text-green-400">'.'</span> . <span class="text-purple-400">$payload</span>;
|
||||||
|
<span class="text-purple-400">$expectedSignature</span> = <span class="text-teal-400">hash_hmac</span>(<span class="text-green-400">'sha256'</span>, <span class="text-purple-400">$signedPayload</span>, <span class="text-purple-400">$secret</span>);
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Verify signature (timing-safe comparison)</span>
|
||||||
|
<span class="text-pink-400">if</span> (!<span class="text-teal-400">hash_equals</span>(<span class="text-purple-400">$expectedSignature</span>, <span class="text-purple-400">$signature</span>)) {
|
||||||
|
<span class="text-teal-400">http_response_code</span>(<span class="text-amber-400">401</span>);
|
||||||
|
<span class="text-pink-400">die</span>(<span class="text-green-400">'Invalid webhook signature'</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Signature valid - process the webhook</span>
|
||||||
|
<span class="text-purple-400">$event</span> = <span class="text-teal-400">json_decode</span>(<span class="text-purple-400">$payload</span>, <span class="text-pink-400">true</span>);
|
||||||
|
<span class="text-teal-400">processWebhook</span>(<span class="text-purple-400">$event</span>);</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Node.js Example --}}
|
||||||
|
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Node.js</h3>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">webhook-handler.js</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">const</span> crypto = <span class="text-teal-400">require</span>(<span class="text-green-400">'crypto'</span>);
|
||||||
|
<span class="text-pink-400">const</span> express = <span class="text-teal-400">require</span>(<span class="text-green-400">'express'</span>);
|
||||||
|
|
||||||
|
<span class="text-pink-400">const</span> app = <span class="text-teal-400">express</span>();
|
||||||
|
app.<span class="text-teal-400">use</span>(express.<span class="text-teal-400">raw</span>({ type: <span class="text-green-400">'application/json'</span> }));
|
||||||
|
|
||||||
|
<span class="text-pink-400">const</span> WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
|
||||||
|
<span class="text-pink-400">const</span> TOLERANCE = <span class="text-amber-400">300</span>; <span class="text-slate-500">// 5 minutes</span>
|
||||||
|
|
||||||
|
app.<span class="text-teal-400">post</span>(<span class="text-green-400">'/webhook'</span>, (req, res) => {
|
||||||
|
<span class="text-pink-400">const</span> signature = req.headers[<span class="text-green-400">'x-webhook-signature'</span>];
|
||||||
|
<span class="text-pink-400">const</span> timestamp = req.headers[<span class="text-green-400">'x-webhook-timestamp'</span>];
|
||||||
|
<span class="text-pink-400">const</span> payload = req.body;
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Verify timestamp</span>
|
||||||
|
<span class="text-pink-400">const</span> now = Math.<span class="text-teal-400">floor</span>(Date.<span class="text-teal-400">now</span>() / <span class="text-amber-400">1000</span>);
|
||||||
|
<span class="text-pink-400">if</span> (Math.<span class="text-teal-400">abs</span>(now - <span class="text-teal-400">parseInt</span>(timestamp)) > TOLERANCE) {
|
||||||
|
<span class="text-pink-400">return</span> res.<span class="text-teal-400">status</span>(<span class="text-amber-400">401</span>).<span class="text-teal-400">send</span>(<span class="text-green-400">'Webhook timestamp expired'</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Compute expected signature</span>
|
||||||
|
<span class="text-pink-400">const</span> signedPayload = <span class="text-green-400">`${</span>timestamp<span class="text-green-400">}.${</span>payload<span class="text-green-400">}`</span>;
|
||||||
|
<span class="text-pink-400">const</span> expectedSignature = crypto
|
||||||
|
.<span class="text-teal-400">createHmac</span>(<span class="text-green-400">'sha256'</span>, WEBHOOK_SECRET)
|
||||||
|
.<span class="text-teal-400">update</span>(signedPayload)
|
||||||
|
.<span class="text-teal-400">digest</span>(<span class="text-green-400">'hex'</span>);
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Verify signature (timing-safe comparison)</span>
|
||||||
|
<span class="text-pink-400">if</span> (!crypto.<span class="text-teal-400">timingSafeEqual</span>(
|
||||||
|
Buffer.<span class="text-teal-400">from</span>(expectedSignature),
|
||||||
|
Buffer.<span class="text-teal-400">from</span>(signature)
|
||||||
|
)) {
|
||||||
|
<span class="text-pink-400">return</span> res.<span class="text-teal-400">status</span>(<span class="text-amber-400">401</span>).<span class="text-teal-400">send</span>(<span class="text-green-400">'Invalid webhook signature'</span>);
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Signature valid - process the webhook</span>
|
||||||
|
<span class="text-pink-400">const</span> event = JSON.<span class="text-teal-400">parse</span>(payload);
|
||||||
|
<span class="text-teal-400">processWebhook</span>(event);
|
||||||
|
res.<span class="text-teal-400">status</span>(<span class="text-amber-400">200</span>).<span class="text-teal-400">send</span>(<span class="text-green-400">'OK'</span>);
|
||||||
|
});</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Python Example --}}
|
||||||
|
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Python</h3>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">webhook_handler.py</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">import</span> hmac
|
||||||
|
<span class="text-pink-400">import</span> hashlib
|
||||||
|
<span class="text-pink-400">import</span> time
|
||||||
|
<span class="text-pink-400">import</span> os
|
||||||
|
<span class="text-pink-400">from</span> flask <span class="text-pink-400">import</span> Flask, request, abort
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
WEBHOOK_SECRET = os.environ[<span class="text-green-400">'WEBHOOK_SECRET'</span>]
|
||||||
|
TOLERANCE = <span class="text-amber-400">300</span> <span class="text-slate-500"># 5 minutes</span>
|
||||||
|
|
||||||
|
<span class="text-pink-400">@</span>app.route(<span class="text-green-400">'/webhook'</span>, methods=[<span class="text-green-400">'POST'</span>])
|
||||||
|
<span class="text-pink-400">def</span> <span class="text-teal-400">webhook</span>():
|
||||||
|
signature = request.headers.get(<span class="text-green-400">'X-Webhook-Signature'</span>, <span class="text-green-400">''</span>)
|
||||||
|
timestamp = request.headers.get(<span class="text-green-400">'X-Webhook-Timestamp'</span>, <span class="text-green-400">''</span>)
|
||||||
|
payload = request.get_data(as_text=<span class="text-pink-400">True</span>)
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Verify timestamp</span>
|
||||||
|
<span class="text-pink-400">if</span> abs(time.time() - int(timestamp)) > TOLERANCE:
|
||||||
|
abort(<span class="text-amber-400">401</span>, <span class="text-green-400">'Webhook timestamp expired'</span>)
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Compute expected signature</span>
|
||||||
|
signed_payload = <span class="text-green-400">f'{timestamp}.{payload}'</span>
|
||||||
|
expected_signature = hmac.new(
|
||||||
|
WEBHOOK_SECRET.encode(),
|
||||||
|
signed_payload.encode(),
|
||||||
|
hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Verify signature (timing-safe comparison)</span>
|
||||||
|
<span class="text-pink-400">if not</span> hmac.compare_digest(expected_signature, signature):
|
||||||
|
abort(<span class="text-amber-400">401</span>, <span class="text-green-400">'Invalid webhook signature'</span>)
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Signature valid - process the webhook</span>
|
||||||
|
event = request.get_json()
|
||||||
|
process_webhook(event)
|
||||||
|
<span class="text-pink-400">return</span> <span class="text-green-400">'OK'</span>, <span class="text-amber-400">200</span></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Ruby Example --}}
|
||||||
|
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Ruby</h3>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden mb-6">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">webhook_handler.rb</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">require</span> <span class="text-green-400">'sinatra'</span>
|
||||||
|
<span class="text-pink-400">require</span> <span class="text-green-400">'openssl'</span>
|
||||||
|
<span class="text-pink-400">require</span> <span class="text-green-400">'json'</span>
|
||||||
|
|
||||||
|
WEBHOOK_SECRET = ENV[<span class="text-green-400">'WEBHOOK_SECRET'</span>]
|
||||||
|
TOLERANCE = <span class="text-amber-400">300</span> <span class="text-slate-500"># 5 minutes</span>
|
||||||
|
|
||||||
|
post <span class="text-green-400">'/webhook'</span> <span class="text-pink-400">do</span>
|
||||||
|
signature = request.env[<span class="text-green-400">'HTTP_X_WEBHOOK_SIGNATURE'</span>] || <span class="text-green-400">''</span>
|
||||||
|
timestamp = request.env[<span class="text-green-400">'HTTP_X_WEBHOOK_TIMESTAMP'</span>] || <span class="text-green-400">''</span>
|
||||||
|
payload = request.body.read
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Verify timestamp</span>
|
||||||
|
<span class="text-pink-400">if</span> (Time.now.to_i - timestamp.to_i).<span class="text-teal-400">abs</span> > TOLERANCE
|
||||||
|
halt <span class="text-amber-400">401</span>, <span class="text-green-400">'Webhook timestamp expired'</span>
|
||||||
|
<span class="text-pink-400">end</span>
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Compute expected signature</span>
|
||||||
|
signed_payload = <span class="text-green-400">"#{timestamp}.#{payload}"</span>
|
||||||
|
expected_signature = OpenSSL::HMAC.hexdigest(
|
||||||
|
<span class="text-green-400">'sha256'</span>,
|
||||||
|
WEBHOOK_SECRET,
|
||||||
|
signed_payload
|
||||||
|
)
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Verify signature (timing-safe comparison)</span>
|
||||||
|
<span class="text-pink-400">unless</span> Rack::Utils.secure_compare(expected_signature, signature)
|
||||||
|
halt <span class="text-amber-400">401</span>, <span class="text-green-400">'Invalid webhook signature'</span>
|
||||||
|
<span class="text-pink-400">end</span>
|
||||||
|
|
||||||
|
<span class="text-slate-500"># Signature valid - process the webhook</span>
|
||||||
|
event = JSON.parse(payload)
|
||||||
|
process_webhook(event)
|
||||||
|
<span class="text-amber-400">200</span>
|
||||||
|
<span class="text-pink-400">end</span></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Go Example --}}
|
||||||
|
<h3 class="h4 mb-3 text-slate-800 dark:text-slate-100">Go</h3>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">webhook_handler.go</span>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-pink-400">package</span> main
|
||||||
|
|
||||||
|
<span class="text-pink-400">import</span> (
|
||||||
|
<span class="text-green-400">"crypto/hmac"</span>
|
||||||
|
<span class="text-green-400">"crypto/sha256"</span>
|
||||||
|
<span class="text-green-400">"crypto/subtle"</span>
|
||||||
|
<span class="text-green-400">"encoding/hex"</span>
|
||||||
|
<span class="text-green-400">"io"</span>
|
||||||
|
<span class="text-green-400">"math"</span>
|
||||||
|
<span class="text-green-400">"net/http"</span>
|
||||||
|
<span class="text-green-400">"os"</span>
|
||||||
|
<span class="text-green-400">"strconv"</span>
|
||||||
|
<span class="text-green-400">"time"</span>
|
||||||
|
)
|
||||||
|
|
||||||
|
<span class="text-pink-400">const</span> tolerance = <span class="text-amber-400">300</span> <span class="text-slate-500">// 5 minutes</span>
|
||||||
|
|
||||||
|
<span class="text-pink-400">func</span> <span class="text-teal-400">webhookHandler</span>(w http.ResponseWriter, r *http.Request) {
|
||||||
|
signature := r.Header.Get(<span class="text-green-400">"X-Webhook-Signature"</span>)
|
||||||
|
timestamp := r.Header.Get(<span class="text-green-400">"X-Webhook-Timestamp"</span>)
|
||||||
|
secret := os.Getenv(<span class="text-green-400">"WEBHOOK_SECRET"</span>)
|
||||||
|
|
||||||
|
payload, _ := io.ReadAll(r.Body)
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Verify timestamp</span>
|
||||||
|
ts, _ := strconv.ParseInt(timestamp, <span class="text-amber-400">10</span>, <span class="text-amber-400">64</span>)
|
||||||
|
<span class="text-pink-400">if</span> math.Abs(<span class="text-teal-400">float64</span>(time.Now().Unix()-ts)) > tolerance {
|
||||||
|
http.Error(w, <span class="text-green-400">"Webhook timestamp expired"</span>, <span class="text-amber-400">401</span>)
|
||||||
|
<span class="text-pink-400">return</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Compute expected signature</span>
|
||||||
|
signedPayload := timestamp + <span class="text-green-400">"."</span> + <span class="text-teal-400">string</span>(payload)
|
||||||
|
mac := hmac.New(sha256.New, []<span class="text-teal-400">byte</span>(secret))
|
||||||
|
mac.Write([]<span class="text-teal-400">byte</span>(signedPayload))
|
||||||
|
expectedSignature := hex.EncodeToString(mac.Sum(<span class="text-pink-400">nil</span>))
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Verify signature (timing-safe comparison)</span>
|
||||||
|
<span class="text-pink-400">if</span> subtle.ConstantTimeCompare(
|
||||||
|
[]<span class="text-teal-400">byte</span>(expectedSignature),
|
||||||
|
[]<span class="text-teal-400">byte</span>(signature),
|
||||||
|
) != <span class="text-amber-400">1</span> {
|
||||||
|
http.Error(w, <span class="text-green-400">"Invalid webhook signature"</span>, <span class="text-amber-400">401</span>)
|
||||||
|
<span class="text-pink-400">return</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="text-slate-500">// Signature valid - process the webhook</span>
|
||||||
|
processWebhook(payload)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}</code></pre>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Retry Policy --}}
|
||||||
|
<section id="retry-policy" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Retry Policy</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
If your endpoint returns a non-2xx status code or times out, we'll retry with exponential backoff:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto mb-4">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Attempt</th>
|
||||||
|
<th class="text-left py-3 px-4 font-medium text-slate-800 dark:text-slate-200">Delay</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-200 dark:divide-slate-700">
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">1 (initial)</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">Immediate</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">2</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">1 minute</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">3</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">5 minutes</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">4</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">30 minutes</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">5 (final)</td>
|
||||||
|
<td class="py-3 px-4 text-slate-600 dark:text-slate-400">2 hours</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-slate-600 dark:text-slate-400">
|
||||||
|
After 5 failed attempts, the delivery is marked as failed. If your endpoint fails 10 consecutive deliveries, it will be automatically disabled. You can re-enable it from your webhook settings.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Best Practices --}}
|
||||||
|
<section id="best-practices" data-scrollspy-target class="mb-12">
|
||||||
|
<h2 class="h3 mb-4 text-slate-800 dark:text-slate-100">Best Practices</h2>
|
||||||
|
<ul class="space-y-3 text-slate-600 dark:text-slate-400">
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span><strong>Always verify signatures</strong> - Never process webhooks without verification</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span><strong>Respond quickly</strong> - Return 200 within 30 seconds to avoid timeouts</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span><strong>Process asynchronously</strong> - Queue webhook processing for long-running tasks</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span><strong>Handle duplicates</strong> - Use <code>X-Webhook-Id</code> for idempotency</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span><strong>Use HTTPS</strong> - Always use HTTPS endpoints in production</span>
|
||||||
|
</li>
|
||||||
|
<li class="flex items-start">
|
||||||
|
<svg class="fill-green-500 shrink-0 mr-3 mt-1" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path d="M8 0a8 8 0 1 0 0 16A8 8 0 0 0 8 0zm3.78 5.22a.75.75 0 0 1 0 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.75.75 0 1 1 1.06-1.06L6.75 9.19l3.97-3.97a.75.75 0 0 1 1.06 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span><strong>Rotate secrets regularly</strong> - Rotate your webhook secret periodically</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Next steps --}}
|
||||||
|
<div class="flex items-center justify-between pt-8 border-t border-slate-200 dark:border-slate-700">
|
||||||
|
<a href="{{ route('api.guides.qrcodes') }}" class="text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200">
|
||||||
|
← QR Code Generation
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.guides.errors') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 font-medium">
|
||||||
|
Error Handling →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
136
src/Website/Api/View/Blade/index.blade.php
Normal file
136
src/Website/Api/View/Blade/index.blade.php
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'API Documentation')
|
||||||
|
@section('description', 'Build powerful integrations with the Host UK API. Access biolinks, workspaces, QR codes, and more.')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-12 md:py-20">
|
||||||
|
|
||||||
|
{{-- Hero --}}
|
||||||
|
<div class="max-w-3xl mx-auto text-center mb-16">
|
||||||
|
<div class="mb-4">
|
||||||
|
<span class="font-nycd text-xl text-blue-600">Developer Documentation</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="h1 mb-6 text-slate-800 dark:text-slate-100">Build with the Host UK API</h1>
|
||||||
|
<p class="text-xl text-slate-600 dark:text-slate-400 mb-8">
|
||||||
|
Integrate biolinks, workspaces, QR codes, and analytics into your applications.
|
||||||
|
Full REST API with comprehensive documentation and SDK support.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap justify-center gap-4">
|
||||||
|
<a href="{{ route('api.guides.quickstart') }}" class="btn text-white bg-blue-600 hover:bg-blue-700 px-6 py-3 rounded-sm font-medium">
|
||||||
|
Get Started
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.reference') }}" class="btn text-slate-600 bg-white border border-slate-200 hover:border-slate-300 dark:text-slate-300 dark:bg-slate-800 dark:border-slate-700 dark:hover:border-slate-600 px-6 py-3 rounded-sm font-medium">
|
||||||
|
API Reference
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Features grid --}}
|
||||||
|
<div class="grid md:grid-cols-3 gap-8 mb-16">
|
||||||
|
|
||||||
|
{{-- Authentication --}}
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm p-6">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center bg-blue-100 dark:bg-blue-900/30 rounded-sm mb-4">
|
||||||
|
<svg class="w-5 h-5 fill-blue-600" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 2a5 5 0 0 0-5 5v2a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-5a2 2 0 0 0-2-2V7a5 5 0 0 0-5-5zm3 7V7a3 3 0 1 0-6 0v2h6z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100">Authentication</h3>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Secure API key authentication with scoped permissions. Generate keys from your workspace settings.
|
||||||
|
</p>
|
||||||
|
<a href="{{ route('api.guides.authentication') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 text-sm font-medium">
|
||||||
|
Learn more →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Biolinks --}}
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm p-6">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center bg-purple-100 dark:bg-purple-900/30 rounded-sm mb-4">
|
||||||
|
<svg class="w-5 h-5 fill-purple-600" viewBox="0 0 20 20">
|
||||||
|
<path d="M12.586 4.586a2 2 0 1 1 2.828 2.828l-3 3a2 2 0 0 1-2.828 0 1 1 0 0 0-1.414 1.414 4 4 0 0 0 5.656 0l3-3a4 4 0 0 0-5.656-5.656l-1.5 1.5a1 1 0 1 0 1.414 1.414l1.5-1.5zm-5 5a2 2 0 0 1 2.828 0 1 1 0 1 0 1.414-1.414 4 4 0 0 0-5.656 0l-3 3a4 4 0 1 0 5.656 5.656l1.5-1.5a1 1 0 1 0-1.414-1.414l-1.5 1.5a2 2 0 1 1-2.828-2.828l3-3z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100">Biolinks</h3>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Create, update, and manage biolink pages with blocks, themes, and analytics programmatically.
|
||||||
|
</p>
|
||||||
|
<a href="{{ route('api.guides.biolinks') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 text-sm font-medium">
|
||||||
|
Learn more →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- QR Codes --}}
|
||||||
|
<div class="bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm p-6">
|
||||||
|
<div class="w-10 h-10 flex items-center justify-center bg-teal-100 dark:bg-teal-900/30 rounded-sm mb-4">
|
||||||
|
<svg class="w-5 h-5 fill-teal-600" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M3 4a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4zm2 2V5h1v1H5zm-2 7a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-3zm2 2v-1h1v1H5zm7-13a1 1 0 0 0-1 1v3a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1h-3zm1 2v1h1V5h-1z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100">QR Codes</h3>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Generate customisable QR codes with colours, logos, and multiple formats for any URL.
|
||||||
|
</p>
|
||||||
|
<a href="{{ route('api.guides.qrcodes') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 text-sm font-medium">
|
||||||
|
Learn more →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Quick start code example --}}
|
||||||
|
<div class="max-w-4xl mx-auto">
|
||||||
|
<h2 class="h3 mb-6 text-center text-slate-800 dark:text-slate-100">Quick Start</h2>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<div class="flex items-center justify-between px-4 py-2 border-b border-slate-700">
|
||||||
|
<span class="text-sm text-slate-400">cURL</span>
|
||||||
|
<button class="text-xs text-slate-500 hover:text-slate-300" onclick="navigator.clipboard.writeText(this.closest('.bg-slate-800').querySelector('code').textContent)">
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="overflow-x-auto p-4 text-sm"><code class="font-pt-mono text-slate-300"><span class="text-teal-400">curl</span> <span class="text-slate-500">--request</span> GET \
|
||||||
|
<span class="text-slate-500">--url</span> <span class="text-amber-400">'https://api.host.uk.com/api/v1/bio'</span> \
|
||||||
|
<span class="text-slate-500">--header</span> <span class="text-amber-400">'Authorization: Bearer hk_your_api_key'</span></code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<a href="{{ route('api.guides.quickstart') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 text-sm font-medium">
|
||||||
|
View full quick start guide →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- API endpoints preview --}}
|
||||||
|
<div class="mt-16">
|
||||||
|
<h2 class="h3 mb-8 text-center text-slate-800 dark:text-slate-100">API Endpoints</h2>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4 max-w-4xl mx-auto">
|
||||||
|
@foreach([
|
||||||
|
['method' => 'GET', 'path' => '/api/v1/workspaces', 'desc' => 'List all workspaces'],
|
||||||
|
['method' => 'GET', 'path' => '/api/v1/bio', 'desc' => 'List all biolinks'],
|
||||||
|
['method' => 'POST', 'path' => '/api/v1/bio', 'desc' => 'Create a biolink'],
|
||||||
|
['method' => 'GET', 'path' => '/api/v1/bio/{id}/qr', 'desc' => 'Generate QR code'],
|
||||||
|
['method' => 'GET', 'path' => '/api/v1/shortlinks', 'desc' => 'List short links'],
|
||||||
|
['method' => 'POST', 'path' => '/api/v1/qr/generate', 'desc' => 'Generate QR for any URL'],
|
||||||
|
] as $endpoint)
|
||||||
|
<a href="{{ route('api.reference') }}" class="flex items-center gap-4 p-4 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm hover:border-blue-300 dark:hover:border-blue-600 transition-colors">
|
||||||
|
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-medium rounded {{ $endpoint['method'] === 'GET' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400' }}">
|
||||||
|
{{ $endpoint['method'] }}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<code class="text-sm font-pt-mono text-slate-800 dark:text-slate-200 truncate block">{{ $endpoint['path'] }}</code>
|
||||||
|
<span class="text-xs text-slate-500 dark:text-slate-400">{{ $endpoint['desc'] }}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 text-center">
|
||||||
|
<a href="{{ route('api.swagger') }}" class="text-blue-600 hover:text-blue-700 dark:hover:text-blue-500 font-medium">
|
||||||
|
View all endpoints in Swagger UI →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
166
src/Website/Api/View/Blade/layouts/docs.blade.php
Normal file
166
src/Website/Api/View/Blade/layouts/docs.blade.php
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="scroll-smooth">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>@yield('title', 'API Documentation') - Host UK</title>
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
<meta name="description" content="@yield('description', 'Host UK API documentation, guides, and reference.')">
|
||||||
|
|
||||||
|
<!-- Respect user's dark mode preference before page renders -->
|
||||||
|
<script>
|
||||||
|
if (localStorage.getItem('flux.appearance') === 'dark' ||
|
||||||
|
(!localStorage.getItem('flux.appearance') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
@include('layouts::partials.fonts')
|
||||||
|
|
||||||
|
<!-- Font Awesome Pro -->
|
||||||
|
<link rel="stylesheet" href="{{ \Core\Helpers\Cdn::versioned('vendor/fontawesome/css/all.min.css') }}">
|
||||||
|
|
||||||
|
<!-- Tailwind / Vite -->
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
<!-- Alpine.js -->
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<style>[x-cloak] { display: none !important; }</style>
|
||||||
|
|
||||||
|
@stack('head')
|
||||||
|
</head>
|
||||||
|
<body class="font-sans antialiased bg-white text-slate-800 dark:bg-slate-900 dark:text-slate-200">
|
||||||
|
|
||||||
|
<div class="flex flex-col min-h-screen overflow-hidden">
|
||||||
|
|
||||||
|
{{-- Site header --}}
|
||||||
|
<header class="fixed w-full z-30">
|
||||||
|
<div class="absolute inset-0 bg-white/70 border-b border-slate-200 backdrop-blur-sm -z-10 dark:bg-slate-900/70 dark:border-slate-800" aria-hidden="true"></div>
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6">
|
||||||
|
<div class="flex items-center justify-between h-16 md:h-20">
|
||||||
|
|
||||||
|
{{-- Site branding --}}
|
||||||
|
<div class="grow">
|
||||||
|
<div class="flex items-center gap-4 md:gap-8">
|
||||||
|
{{-- Logo --}}
|
||||||
|
<a href="{{ route('api.docs') }}" class="flex items-center gap-2">
|
||||||
|
<svg class="w-8 h-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-semibold text-slate-800 dark:text-slate-200">Host UK API</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{{-- Search --}}
|
||||||
|
<div class="grow" x-data="{ searchOpen: false }">
|
||||||
|
<button
|
||||||
|
class="w-full sm:w-80 text-sm bg-white text-slate-400 inline-flex items-center justify-between leading-5 pl-3 pr-2 py-2 rounded border border-slate-200 hover:border-slate-300 shadow-sm whitespace-nowrap dark:text-slate-500 dark:bg-slate-800 dark:border-slate-700 dark:hover:border-slate-600"
|
||||||
|
@click.prevent="searchOpen = true"
|
||||||
|
@keydown.slash.window="searchOpen = true"
|
||||||
|
>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fa-solid fa-magnifying-glass w-4 h-4 mr-3 text-slate-400"></i>
|
||||||
|
<span>Search<span class="hidden sm:inline"> docs</span>...</span>
|
||||||
|
</div>
|
||||||
|
<kbd class="hidden sm:inline-flex items-center justify-center h-5 w-5 text-xs font-medium text-slate-500 rounded border border-slate-200 dark:bg-slate-700 dark:text-slate-400 dark:border-slate-600">/</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{-- Search modal placeholder --}}
|
||||||
|
<template x-teleport="body">
|
||||||
|
<div x-show="searchOpen" x-cloak>
|
||||||
|
<div class="fixed inset-0 bg-slate-900/20 z-50" @click="searchOpen = false" @keydown.escape.window="searchOpen = false"></div>
|
||||||
|
<div class="fixed inset-0 z-50 overflow-hidden flex items-start top-20 justify-center px-4">
|
||||||
|
<div class="bg-white overflow-auto max-w-2xl w-full max-h-[80vh] rounded-lg shadow-lg dark:bg-slate-800 p-4" @click.outside="searchOpen = false">
|
||||||
|
<p class="text-slate-500 dark:text-slate-400 text-center py-8">Search coming soon...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Desktop nav --}}
|
||||||
|
<nav class="flex items-center gap-6">
|
||||||
|
<a href="{{ route('api.guides') }}" class="text-sm {{ request()->routeIs('api.guides*') ? 'font-medium text-blue-600 dark:text-blue-400' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200' }}">Guides</a>
|
||||||
|
<a href="{{ route('api.reference') }}" class="text-sm {{ request()->routeIs('api.reference') ? 'font-medium text-blue-600 dark:text-blue-400' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200' }}">API Reference</a>
|
||||||
|
|
||||||
|
{{-- API Explorer dropdown --}}
|
||||||
|
<div class="relative" x-data="{ open: false }" @click.outside="open = false">
|
||||||
|
<button
|
||||||
|
@click="open = !open"
|
||||||
|
class="text-sm flex items-center gap-1 {{ request()->routeIs('api.swagger', 'api.scalar', 'api.redoc') ? 'font-medium text-blue-600 dark:text-blue-400' : 'text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200' }}"
|
||||||
|
>
|
||||||
|
API Explorer
|
||||||
|
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': open }" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition:enter="transition ease-out duration-100"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-75"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95"
|
||||||
|
class="absolute right-0 mt-2 w-40 origin-top-right rounded-lg bg-white shadow-lg ring-1 ring-slate-200 dark:bg-slate-800 dark:ring-slate-700"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
<div class="py-1">
|
||||||
|
<a href="{{ route('api.swagger') }}" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 {{ request()->routeIs('api.swagger') ? 'bg-slate-100 dark:bg-slate-700' : '' }}">
|
||||||
|
<i class="fa-solid fa-flask w-4 mr-2 text-slate-400"></i>Swagger
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.scalar') }}" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 {{ request()->routeIs('api.scalar') ? 'bg-slate-100 dark:bg-slate-700' : '' }}">
|
||||||
|
<i class="fa-solid fa-bolt w-4 mr-2 text-slate-400"></i>Scalar
|
||||||
|
</a>
|
||||||
|
<a href="{{ route('api.redoc') }}" class="block px-4 py-2 text-sm text-slate-700 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 {{ request()->routeIs('api.redoc') ? 'bg-slate-100 dark:bg-slate-700' : '' }}">
|
||||||
|
<i class="fa-solid fa-book w-4 mr-2 text-slate-400"></i>ReDoc
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Dark mode toggle --}}
|
||||||
|
<button
|
||||||
|
x-data="{ dark: document.documentElement.classList.contains('dark') }"
|
||||||
|
type="button"
|
||||||
|
class="p-2 text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||||
|
aria-label="Toggle dark mode"
|
||||||
|
x-on:click="dark = !dark; document.documentElement.classList.toggle('dark'); localStorage.setItem('flux.appearance', dark ? 'dark' : 'light')"
|
||||||
|
>
|
||||||
|
<i x-show="!dark" class="fa-solid fa-moon"></i>
|
||||||
|
<i x-show="dark" class="fa-solid fa-sun"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{{-- Page content --}}
|
||||||
|
<main class="grow pt-16 md:pt-20">
|
||||||
|
@yield('content')
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{-- Site footer --}}
|
||||||
|
<footer class="border-t border-slate-200 dark:border-slate-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div class="text-sm text-slate-500 dark:text-slate-400">
|
||||||
|
© {{ date('Y') }} Host UK. All rights reserved.
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-6">
|
||||||
|
<a href="https://host.uk.com" class="text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">Host UK</a>
|
||||||
|
<a href="{{ route('api.openapi.json') }}" class="text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">OpenAPI Spec</a>
|
||||||
|
<a href="{{ str_replace('api.', 'mcp.', request()->getSchemeAndHttpHost()) }}" class="text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200">MCP Portal</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@stack('scripts')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
src/Website/Api/View/Blade/partials/endpoint.blade.php
Normal file
37
src/Website/Api/View/Blade/partials/endpoint.blade.php
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
@props(['method', 'path', 'description', 'body' => null, 'response'])
|
||||||
|
|
||||||
|
<div class="mb-8 border border-slate-200 dark:border-slate-700 rounded-sm overflow-hidden">
|
||||||
|
{{-- Header --}}
|
||||||
|
<div class="flex items-center gap-4 px-4 py-3 bg-slate-50 dark:bg-slate-800/50 border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<span class="inline-flex items-center justify-center px-2 py-1 text-xs font-semibold rounded
|
||||||
|
@if($method === 'GET') bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400
|
||||||
|
@elseif($method === 'POST') bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400
|
||||||
|
@elseif($method === 'PUT') bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400
|
||||||
|
@elseif($method === 'DELETE') bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400
|
||||||
|
@endif">
|
||||||
|
{{ $method }}
|
||||||
|
</span>
|
||||||
|
<code class="text-sm font-pt-mono text-slate-800 dark:text-slate-200">{{ $path }}</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Body --}}
|
||||||
|
<div class="p-4">
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">{{ $description }}</p>
|
||||||
|
|
||||||
|
@if($body)
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">Request Body</h4>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<pre class="overflow-x-auto p-3 text-sm"><code class="font-pt-mono text-slate-300">{{ $body }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 class="text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">Response</h4>
|
||||||
|
<div class="bg-slate-800 rounded-sm overflow-hidden">
|
||||||
|
<pre class="overflow-x-auto p-3 text-sm"><code class="font-pt-mono text-slate-300">{{ $response }}</code></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
73
src/Website/Api/View/Blade/redoc.blade.php
Normal file
73
src/Website/Api/View/Blade/redoc.blade.php
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API Reference - Host UK</title>
|
||||||
|
<meta name="description" content="Host UK API Reference - ReDoc documentation">
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; }
|
||||||
|
.api-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
height: 40px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-bottom: 1px solid #2d2d44;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.api-nav a {
|
||||||
|
color: #a0a0b8;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.api-nav a:hover { color: #fff; }
|
||||||
|
.api-nav svg { width: 16px; height: 16px; }
|
||||||
|
#redoc-container { padding-top: 40px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="api-nav">
|
||||||
|
<a href="{{ route('api.docs') }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back to API Docs
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div id="redoc-container"></div>
|
||||||
|
<script src="https://unpkg.com/redoc@latest/bundles/redoc.standalone.js"></script>
|
||||||
|
<script>
|
||||||
|
Redoc.init('{{ route('api.openapi.json') }}', {
|
||||||
|
theme: {
|
||||||
|
typography: {
|
||||||
|
fontFamily: 'Inter, system-ui, sans-serif',
|
||||||
|
headings: { fontFamily: 'Inter, system-ui, sans-serif' }
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: { main: '#3b82f6' }
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
backgroundColor: '#1e293b',
|
||||||
|
textColor: '#94a3b8'
|
||||||
|
},
|
||||||
|
rightPanel: {
|
||||||
|
backgroundColor: '#0f172a'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollYOffset: 40
|
||||||
|
}, document.getElementById('redoc-container'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
261
src/Website/Api/View/Blade/reference.blade.php
Normal file
261
src/Website/Api/View/Blade/reference.blade.php
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'API Reference')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="flex">
|
||||||
|
|
||||||
|
{{-- Sidebar --}}
|
||||||
|
<aside class="hidden lg:block fixed left-0 top-16 md:top-20 bottom-0 w-64 border-r border-slate-200 dark:border-slate-800">
|
||||||
|
<div class="h-full px-4 py-8 overflow-y-auto no-scrollbar">
|
||||||
|
<nav>
|
||||||
|
<h3 class="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3">Resources</h3>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li>
|
||||||
|
<a href="#workspaces" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Workspaces
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#biolinks" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Biolinks
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#blocks" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Blocks
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#shortlinks" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Short Links
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#qrcodes" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
QR Codes
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#analytics" data-scrollspy-link class="block px-3 py-2 text-sm text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 rounded-sm relative before:absolute before:inset-y-1 before:left-0 before:w-0.5 before:rounded-full">
|
||||||
|
Analytics
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{-- Main content --}}
|
||||||
|
<div class="lg:pl-64 w-full">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 sm:px-6 py-12">
|
||||||
|
|
||||||
|
<h1 class="h1 mb-4 text-slate-800 dark:text-slate-100">API Reference</h1>
|
||||||
|
<p class="text-xl text-slate-600 dark:text-slate-400 mb-4">
|
||||||
|
Complete reference for all Host UK API endpoints.
|
||||||
|
</p>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-12">
|
||||||
|
Base URL: <code class="px-2 py-1 bg-slate-100 dark:bg-slate-800 rounded text-sm font-pt-mono">https://api.host.uk.com/api/v1</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{-- Workspaces --}}
|
||||||
|
<section id="workspaces" data-scrollspy-target class="mb-16">
|
||||||
|
<h2 class="h2 mb-6 text-slate-800 dark:text-slate-100 pb-2 border-b border-slate-200 dark:border-slate-700">Workspaces</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
Workspaces are containers for your biolinks, short links, and other resources.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/workspaces',
|
||||||
|
'description' => 'List all workspaces you have access to.',
|
||||||
|
'response' => '{"data": [{"id": 1, "name": "My Workspace", "slug": "my-workspace"}]}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/workspaces/current',
|
||||||
|
'description' => 'Get the current workspace (from API key context).',
|
||||||
|
'response' => '{"data": {"id": 1, "name": "My Workspace", "slug": "my-workspace"}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/workspaces/{id}',
|
||||||
|
'description' => 'Get a specific workspace by ID.',
|
||||||
|
'response' => '{"data": {"id": 1, "name": "My Workspace", "slug": "my-workspace"}}'
|
||||||
|
])
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Biolinks --}}
|
||||||
|
<section id="biolinks" data-scrollspy-target class="mb-16">
|
||||||
|
<h2 class="h2 mb-6 text-slate-800 dark:text-slate-100 pb-2 border-b border-slate-200 dark:border-slate-700">Biolinks</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
Biolinks are customisable landing pages with blocks of content.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/bio',
|
||||||
|
'description' => 'List all biolinks in the workspace.',
|
||||||
|
'response' => '{"data": [{"id": 1, "url": "mypage", "type": "biolink"}]}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'POST',
|
||||||
|
'path' => '/bio',
|
||||||
|
'description' => 'Create a new biolink.',
|
||||||
|
'body' => '{"url": "mypage", "type": "biolink"}',
|
||||||
|
'response' => '{"data": {"id": 1, "url": "mypage", "type": "biolink"}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/bio/{id}',
|
||||||
|
'description' => 'Get a specific biolink by ID.',
|
||||||
|
'response' => '{"data": {"id": 1, "url": "mypage", "type": "biolink"}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'PUT',
|
||||||
|
'path' => '/bio/{id}',
|
||||||
|
'description' => 'Update a biolink.',
|
||||||
|
'body' => '{"url": "newpage"}',
|
||||||
|
'response' => '{"data": {"id": 1, "url": "newpage", "type": "biolink"}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'path' => '/bio/{id}',
|
||||||
|
'description' => 'Delete a biolink.',
|
||||||
|
'response' => '{"message": "Deleted successfully"}'
|
||||||
|
])
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Blocks --}}
|
||||||
|
<section id="blocks" data-scrollspy-target class="mb-16">
|
||||||
|
<h2 class="h2 mb-6 text-slate-800 dark:text-slate-100 pb-2 border-b border-slate-200 dark:border-slate-700">Blocks</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
Blocks are content elements within a biolink page.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/bio/{bioId}/blocks',
|
||||||
|
'description' => 'List all blocks for a biolink.',
|
||||||
|
'response' => '{"data": [{"id": 1, "type": "link", "data": {"title": "My Link"}}]}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'POST',
|
||||||
|
'path' => '/bio/{bioId}/blocks',
|
||||||
|
'description' => 'Add a new block to a biolink.',
|
||||||
|
'body' => '{"type": "link", "data": {"title": "My Link", "url": "https://example.com"}}',
|
||||||
|
'response' => '{"data": {"id": 1, "type": "link", "data": {"title": "My Link"}}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'PUT',
|
||||||
|
'path' => '/bio/{bioId}/blocks/{id}',
|
||||||
|
'description' => 'Update a block.',
|
||||||
|
'body' => '{"data": {"title": "Updated Link"}}',
|
||||||
|
'response' => '{"data": {"id": 1, "type": "link", "data": {"title": "Updated Link"}}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'DELETE',
|
||||||
|
'path' => '/bio/{bioId}/blocks/{id}',
|
||||||
|
'description' => 'Delete a block.',
|
||||||
|
'response' => '{"message": "Deleted successfully"}'
|
||||||
|
])
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Short Links --}}
|
||||||
|
<section id="shortlinks" data-scrollspy-target class="mb-16">
|
||||||
|
<h2 class="h2 mb-6 text-slate-800 dark:text-slate-100 pb-2 border-b border-slate-200 dark:border-slate-700">Short Links</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
Short links redirect to any URL with tracking.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/shortlinks',
|
||||||
|
'description' => 'List all short links in the workspace.',
|
||||||
|
'response' => '{"data": [{"id": 1, "url": "abc123", "destination": "https://example.com"}]}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'POST',
|
||||||
|
'path' => '/shortlinks',
|
||||||
|
'description' => 'Create a new short link.',
|
||||||
|
'body' => '{"destination": "https://example.com"}',
|
||||||
|
'response' => '{"data": {"id": 1, "url": "abc123", "destination": "https://example.com"}}'
|
||||||
|
])
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- QR Codes --}}
|
||||||
|
<section id="qrcodes" data-scrollspy-target class="mb-16">
|
||||||
|
<h2 class="h2 mb-6 text-slate-800 dark:text-slate-100 pb-2 border-b border-slate-200 dark:border-slate-700">QR Codes</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
Generate customisable QR codes for biolinks or any URL.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/bio/{id}/qr',
|
||||||
|
'description' => 'Get QR code data for a biolink.',
|
||||||
|
'response' => '{"data": {"svg": "<svg>...</svg>", "url": "https://lt.hn/mypage"}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/bio/{id}/qr/download',
|
||||||
|
'description' => 'Download QR code as PNG/SVG. Query params: format (png|svg), size (100-2000).',
|
||||||
|
'response' => 'Binary image data'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'POST',
|
||||||
|
'path' => '/qr/generate',
|
||||||
|
'description' => 'Generate QR code for any URL.',
|
||||||
|
'body' => '{"url": "https://example.com", "format": "svg", "size": 300}',
|
||||||
|
'response' => '{"data": {"svg": "<svg>...</svg>"}}'
|
||||||
|
])
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/qr/options',
|
||||||
|
'description' => 'Get available QR code customisation options.',
|
||||||
|
'response' => '{"data": {"formats": ["png", "svg"], "sizes": {"min": 100, "max": 2000}}}'
|
||||||
|
])
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- Analytics --}}
|
||||||
|
<section id="analytics" data-scrollspy-target class="mb-16">
|
||||||
|
<h2 class="h2 mb-6 text-slate-800 dark:text-slate-100 pb-2 border-b border-slate-200 dark:border-slate-700">Analytics</h2>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
||||||
|
View analytics data for your biolinks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
@include('api::partials.endpoint', [
|
||||||
|
'method' => 'GET',
|
||||||
|
'path' => '/bio/{id}/analytics',
|
||||||
|
'description' => 'Get analytics for a biolink. Query params: period (7d|30d|90d).',
|
||||||
|
'response' => '{"data": {"views": 1234, "clicks": 567, "unique_visitors": 890}}'
|
||||||
|
])
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{-- CTA --}}
|
||||||
|
<div class="mt-12 p-6 bg-slate-50 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-sm text-center">
|
||||||
|
<h3 class="h4 mb-2 text-slate-800 dark:text-slate-100">Try it out</h3>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400 mb-4">Test endpoints interactively with Swagger UI.</p>
|
||||||
|
<a href="{{ route('api.swagger') }}" class="btn text-white bg-blue-600 hover:bg-blue-700 px-6 py-2 rounded-sm font-medium">
|
||||||
|
Open Swagger UI
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
71
src/Website/Api/View/Blade/scalar.blade.php
Normal file
71
src/Website/Api/View/Blade/scalar.blade.php
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>API Reference - Host UK</title>
|
||||||
|
<meta name="description" content="Host UK API Reference - Interactive documentation with code samples">
|
||||||
|
<link rel="icon" type="image/png" href="/favicon.png">
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; }
|
||||||
|
.api-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 9999;
|
||||||
|
height: 40px;
|
||||||
|
background: #1a1a2e;
|
||||||
|
border-bottom: 1px solid #2d2d44;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.api-nav a {
|
||||||
|
color: #a0a0b8;
|
||||||
|
text-decoration: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.api-nav a:hover { color: #fff; }
|
||||||
|
.api-nav svg { width: 16px; height: 16px; }
|
||||||
|
.scalar-wrapper { padding-top: 40px; height: 100vh; }
|
||||||
|
.scalar-app { --scalar-font: 'Inter', system-ui, sans-serif; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="api-nav">
|
||||||
|
<a href="{{ route('api.docs') }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back to API Docs
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
<div class="scalar-wrapper">
|
||||||
|
<script
|
||||||
|
id="api-reference"
|
||||||
|
data-url="/openapi.json"
|
||||||
|
data-configuration='{
|
||||||
|
"theme": "kepler",
|
||||||
|
"layout": "modern",
|
||||||
|
"darkMode": true,
|
||||||
|
"hiddenClients": ["unirest"],
|
||||||
|
"defaultHttpClient": {
|
||||||
|
"targetKey": "php",
|
||||||
|
"clientKey": "guzzle"
|
||||||
|
},
|
||||||
|
"metaData": {
|
||||||
|
"title": "Host UK API",
|
||||||
|
"description": "API documentation for Host UK services"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
src/Website/Api/View/Blade/swagger.blade.php
Normal file
58
src/Website/Api/View/Blade/swagger.blade.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
@extends('api::layouts.docs')
|
||||||
|
|
||||||
|
@section('title', 'Swagger UI')
|
||||||
|
|
||||||
|
@push('head')
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
.swagger-ui .topbar { display: none; }
|
||||||
|
.swagger-ui .info { margin: 20px 0; }
|
||||||
|
.swagger-ui .info .title { font-size: 28px; }
|
||||||
|
.swagger-ui .scheme-container { background: transparent; box-shadow: none; padding: 0; }
|
||||||
|
.swagger-ui .opblock-tag { font-size: 18px; }
|
||||||
|
.swagger-ui .opblock .opblock-summary-operation-id { font-size: 13px; }
|
||||||
|
.dark .swagger-ui { filter: invert(88%) hue-rotate(180deg); }
|
||||||
|
.dark .swagger-ui .opblock-body pre { filter: invert(100%) hue-rotate(180deg); }
|
||||||
|
.dark .swagger-ui img { filter: invert(100%) hue-rotate(180deg); }
|
||||||
|
</style>
|
||||||
|
@endpush
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 py-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="h2 mb-2 text-slate-800 dark:text-slate-100">Swagger UI</h1>
|
||||||
|
<p class="text-slate-600 dark:text-slate-400">
|
||||||
|
Interactive API explorer. Try out endpoints directly from your browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="swagger-ui" class="bg-white dark:bg-slate-800 rounded-sm border border-slate-200 dark:border-slate-700 p-4"></div>
|
||||||
|
</div>
|
||||||
|
@endsection
|
||||||
|
|
||||||
|
@push('scripts')
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {
|
||||||
|
window.ui = SwaggerUIBundle({
|
||||||
|
url: "/openapi.json",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
deepLinking: true,
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
layout: "BaseLayout",
|
||||||
|
defaultModelsExpandDepth: -1,
|
||||||
|
docExpansion: 'none',
|
||||||
|
filter: true,
|
||||||
|
showExtensions: true,
|
||||||
|
showCommonExtensions: true
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
@endpush
|
||||||
Loading…
Add table
Reference in a new issue