From 753812ad57c3c5aa0cd66880e26433d71bb79f86 Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 14 Mar 2026 10:03:29 +0000 Subject: [PATCH] feat(api): merge go-api + php-api into polyglot repo Go source at root level (Option B), PHP under src/php/. Module path: forge.lthn.ai/core/api Package name: lthn/api Co-Authored-By: Virgil --- .core/build.yaml | 24 + .core/release.yaml | 20 + .gitattributes | 7 + .gitignore | 14 + CLAUDE.md | 104 ++ LICENCE | 287 +++++ api.go | 191 +++ api_test.go | 204 ++++ authentik.go | 228 ++++ authentik_integration_test.go | 337 ++++++ authentik_test.go | 460 +++++++ authz_test.go | 222 ++++ bridge.go | 122 ++ bridge_test.go | 234 ++++ brotli.go | 120 ++ brotli_test.go | 132 ++ cache.go | 126 ++ cache_test.go | 252 ++++ cmd/api/cmd.go | 18 + cmd/api/cmd_sdk.go | 89 ++ cmd/api/cmd_spec.go | 54 + cmd/api/cmd_test.go | 101 ++ codegen.go | 101 ++ codegen_test.go | 94 ++ composer.json | 39 + docs/architecture.md | 617 ++++++++++ docs/development.md | 451 +++++++ docs/history.md | 219 ++++ docs/index.md | 173 +++ export.go | 56 + export_test.go | 166 +++ expvar_test.go | 141 +++ go.mod | 124 ++ go.sum | 341 ++++++ graphql.go | 63 + graphql_test.go | 234 ++++ group.go | 44 + group_test.go | 226 ++++ gzip_test.go | 133 ++ httpsign_test.go | 216 ++++ i18n.go | 121 ++ i18n_test.go | 226 ++++ location_test.go | 180 +++ middleware.go | 59 + middleware_test.go | 220 ++++ modernization_test.go | 93 ++ norace_test.go | 6 + openapi.go | 184 +++ openapi_test.go | 403 +++++++ options.go | 325 +++++ pkg/provider/provider.go | 52 + pkg/provider/proxy.go | 12 + pkg/provider/registry.go | 146 +++ pkg/provider/registry_test.go | 160 +++ pprof_test.go | 124 ++ race_test.go | 6 + response.go | 71 ++ response_test.go | 205 ++++ secure_test.go | 185 +++ sessions_test.go | 198 +++ slog_test.go | 167 +++ src/php/phpunit.xml | 33 + src/php/src/Api/Boot.php | 146 +++ src/php/src/Api/Concerns/HasApiResponses.php | 124 ++ src/php/src/Api/Concerns/HasApiTokens.php | 76 ++ .../src/Api/Concerns/ResolvesWorkspace.php | 84 ++ .../Console/Commands/CheckApiUsageAlerts.php | 291 +++++ .../Commands/CleanupExpiredGracePeriods.php | 67 ++ .../Commands/CleanupExpiredSecrets.php | 141 +++ src/php/src/Api/Contracts/WebhookEvent.php | 35 + .../Api/WebhookSecretController.php | 268 +++++ .../Api/WebhookTemplateController.php | 369 ++++++ .../src/Api/Controllers/McpApiController.php | 625 ++++++++++ .../Api/Database/Factories/ApiKeyFactory.php | 266 ++++ .../Documentation/Attributes/ApiHidden.php | 41 + .../Documentation/Attributes/ApiParameter.php | 101 ++ .../Documentation/Attributes/ApiResponse.php | 80 ++ .../Documentation/Attributes/ApiSecurity.php | 51 + .../Api/Documentation/Attributes/ApiTag.php | 38 + .../Documentation/DocumentationController.php | 128 ++ .../DocumentationServiceProvider.php | 87 ++ .../Documentation/Examples/CommonExamples.php | 278 +++++ src/php/src/Api/Documentation/Extension.php | 40 + .../Extensions/ApiKeyAuthExtension.php | 234 ++++ .../Extensions/RateLimitExtension.php | 228 ++++ .../Extensions/WorkspaceHeaderExtension.php | 111 ++ .../Middleware/ProtectDocumentation.php | 76 ++ .../src/Api/Documentation/ModuleDiscovery.php | 209 ++++ .../src/Api/Documentation/OpenApiBuilder.php | 819 +++++++++++++ src/php/src/Api/Documentation/Routes/docs.php | 36 + .../Api/Documentation/Views/redoc.blade.php | 60 + .../Api/Documentation/Views/scalar.blade.php | 28 + .../Api/Documentation/Views/swagger.blade.php | 65 + src/php/src/Api/Documentation/config.php | 319 +++++ src/php/src/Api/Enums/BuiltinTemplateType.php | 144 +++ .../src/Api/Enums/WebhookTemplateFormat.php | 73 ++ .../Exceptions/RateLimitExceededException.php | 56 + src/php/src/Api/Guards/AccessTokenGuard.php | 98 ++ src/php/src/Api/Jobs/DeliverWebhookJob.php | 182 +++ .../src/Api/Middleware/AuthenticateApiKey.php | 136 +++ src/php/src/Api/Middleware/CheckApiScope.php | 52 + .../src/Api/Middleware/EnforceApiScope.php | 65 + src/php/src/Api/Middleware/PublicApiCors.php | 64 + src/php/src/Api/Middleware/RateLimitApi.php | 352 ++++++ src/php/src/Api/Middleware/TrackApiUsage.php | 81 ++ .../0001_01_01_000001_create_api_tables.php | 65 + ...026_01_07_002358_create_api_keys_table.php | 41 + ..._002400_create_webhook_endpoints_table.php | 40 + ...002401_create_webhook_deliveries_table.php | 40 + ...000_add_webhook_secret_rotation_fields.php | 58 + ...0_add_secure_hashing_to_api_keys_table.php | 46 + ...0000_add_allowed_ips_to_api_keys_table.php | 43 + src/php/src/Api/Models/ApiKey.php | 515 ++++++++ src/php/src/Api/Models/ApiUsage.php | 135 +++ src/php/src/Api/Models/ApiUsageDaily.php | 172 +++ src/php/src/Api/Models/WebhookDelivery.php | 209 ++++ src/php/src/Api/Models/WebhookEndpoint.php | 266 ++++ .../src/Api/Models/WebhookPayloadTemplate.php | 321 +++++ .../HighApiUsageNotification.php | 111 ++ src/php/src/Api/RateLimit/RateLimit.php | 42 + src/php/src/Api/RateLimit/RateLimitResult.php | 71 ++ .../src/Api/RateLimit/RateLimitService.php | 247 ++++ src/php/src/Api/Resources/ApiKeyResource.php | 59 + src/php/src/Api/Resources/ErrorResource.php | 93 ++ .../src/Api/Resources/PaginatedCollection.php | 49 + .../Api/Resources/WebhookEndpointResource.php | 67 ++ .../src/Api/Resources/WorkspaceResource.php | 68 ++ src/php/src/Api/Routes/admin.php | 19 + src/php/src/Api/Routes/api.php | 54 + src/php/src/Api/Services/ApiKeyService.php | 217 ++++ .../src/Api/Services/ApiSnippetService.php | 427 +++++++ src/php/src/Api/Services/ApiUsageService.php | 361 ++++++ .../src/Api/Services/IpRestrictionService.php | 308 +++++ .../Services/WebhookSecretRotationService.php | 308 +++++ src/php/src/Api/Services/WebhookService.php | 192 +++ src/php/src/Api/Services/WebhookSignature.php | 206 ++++ .../Api/Services/WebhookTemplateService.php | 629 ++++++++++ .../Tests/Feature/ApiKeyIpWhitelistTest.php | 403 +++++++ .../Api/Tests/Feature/ApiKeyRotationTest.php | 232 ++++ .../Api/Tests/Feature/ApiKeySecurityTest.php | 381 ++++++ src/php/src/Api/Tests/Feature/ApiKeyTest.php | 617 ++++++++++ .../Tests/Feature/ApiScopeEnforcementTest.php | 742 ++++++++++++ .../src/Api/Tests/Feature/ApiUsageTest.php | 362 ++++++ .../OpenApiDocumentationComprehensiveTest.php | 1069 +++++++++++++++++ .../Feature/OpenApiDocumentationTest.php | 120 ++ .../Api/Tests/Feature/PublicApiCorsTest.php | 363 ++++++ .../src/Api/Tests/Feature/RateLimitTest.php | 532 ++++++++ .../Api/Tests/Feature/RateLimitingTest.php | 785 ++++++++++++ .../Api/Tests/Feature/WebhookDeliveryTest.php | 770 ++++++++++++ .../admin/webhook-template-manager.blade.php | 353 ++++++ .../Modal/Admin/WebhookTemplateManager.php | 442 +++++++ src/php/src/Api/config.php | 237 ++++ src/php/src/Front/Api/ApiVersionService.php | 253 ++++ src/php/src/Front/Api/Boot.php | 111 ++ .../src/Front/Api/Middleware/ApiSunset.php | 112 ++ .../src/Front/Api/Middleware/ApiVersion.php | 246 ++++ src/php/src/Front/Api/README.md | 266 ++++ src/php/src/Front/Api/VersionedRoutes.php | 248 ++++ src/php/src/Front/Api/config.php | 78 ++ src/php/src/Website/.DS_Store | Bin 0 -> 6148 bytes src/php/src/Website/Api/Boot.php | 35 + .../Api/Controllers/DocsController.php | 72 ++ src/php/src/Website/Api/Routes/web.php | 34 + .../Website/Api/Services/OpenApiGenerator.php | 348 ++++++ .../src/Website/Api/View/Blade/docs.blade.php | 111 ++ .../Blade/guides/authentication.blade.php | 187 +++ .../Api/View/Blade/guides/errors.blade.php | 211 ++++ .../Api/View/Blade/guides/index.blade.php | 66 + .../Api/View/Blade/guides/qrcodes.blade.php | 202 ++++ .../View/Blade/guides/quickstart.blade.php | 192 +++ .../Api/View/Blade/guides/webhooks.blade.php | 586 +++++++++ .../Website/Api/View/Blade/index.blade.php | 132 ++ .../Api/View/Blade/layouts/docs.blade.php | 158 +++ .../View/Blade/partials/endpoint.blade.php | 37 + .../Website/Api/View/Blade/redoc.blade.php | 73 ++ .../Api/View/Blade/reference.blade.php | 149 +++ .../Website/Api/View/Blade/scalar.blade.php | 71 ++ .../Website/Api/View/Blade/swagger.blade.php | 58 + src/php/tests/Feature/.gitkeep | 0 src/php/tests/TestCase.php | 10 + src/php/tests/Unit/.gitkeep | 0 sse.go | 145 +++ sse_test.go | 308 +++++ static_test.go | 164 +++ swagger.go | 55 + swagger_test.go | 319 +++++ timeout_test.go | 172 +++ tracing.go | 47 + tracing_test.go | 252 ++++ websocket.go | 17 + websocket_test.go | 116 ++ 191 files changed, 35312 insertions(+) create mode 100644 .core/build.yaml create mode 100644 .core/release.yaml create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENCE create mode 100644 api.go create mode 100644 api_test.go create mode 100644 authentik.go create mode 100644 authentik_integration_test.go create mode 100644 authentik_test.go create mode 100644 authz_test.go create mode 100644 bridge.go create mode 100644 bridge_test.go create mode 100644 brotli.go create mode 100644 brotli_test.go create mode 100644 cache.go create mode 100644 cache_test.go create mode 100644 cmd/api/cmd.go create mode 100644 cmd/api/cmd_sdk.go create mode 100644 cmd/api/cmd_spec.go create mode 100644 cmd/api/cmd_test.go create mode 100644 codegen.go create mode 100644 codegen_test.go create mode 100644 composer.json create mode 100644 docs/architecture.md create mode 100644 docs/development.md create mode 100644 docs/history.md create mode 100644 docs/index.md create mode 100644 export.go create mode 100644 export_test.go create mode 100644 expvar_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 graphql.go create mode 100644 graphql_test.go create mode 100644 group.go create mode 100644 group_test.go create mode 100644 gzip_test.go create mode 100644 httpsign_test.go create mode 100644 i18n.go create mode 100644 i18n_test.go create mode 100644 location_test.go create mode 100644 middleware.go create mode 100644 middleware_test.go create mode 100644 modernization_test.go create mode 100644 norace_test.go create mode 100644 openapi.go create mode 100644 openapi_test.go create mode 100644 options.go create mode 100644 pkg/provider/provider.go create mode 100644 pkg/provider/proxy.go create mode 100644 pkg/provider/registry.go create mode 100644 pkg/provider/registry_test.go create mode 100644 pprof_test.go create mode 100644 race_test.go create mode 100644 response.go create mode 100644 response_test.go create mode 100644 secure_test.go create mode 100644 sessions_test.go create mode 100644 slog_test.go create mode 100644 src/php/phpunit.xml create mode 100644 src/php/src/Api/Boot.php create mode 100644 src/php/src/Api/Concerns/HasApiResponses.php create mode 100644 src/php/src/Api/Concerns/HasApiTokens.php create mode 100644 src/php/src/Api/Concerns/ResolvesWorkspace.php create mode 100644 src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php create mode 100644 src/php/src/Api/Console/Commands/CleanupExpiredGracePeriods.php create mode 100644 src/php/src/Api/Console/Commands/CleanupExpiredSecrets.php create mode 100644 src/php/src/Api/Contracts/WebhookEvent.php create mode 100644 src/php/src/Api/Controllers/Api/WebhookSecretController.php create mode 100644 src/php/src/Api/Controllers/Api/WebhookTemplateController.php create mode 100644 src/php/src/Api/Controllers/McpApiController.php create mode 100644 src/php/src/Api/Database/Factories/ApiKeyFactory.php create mode 100644 src/php/src/Api/Documentation/Attributes/ApiHidden.php create mode 100644 src/php/src/Api/Documentation/Attributes/ApiParameter.php create mode 100644 src/php/src/Api/Documentation/Attributes/ApiResponse.php create mode 100644 src/php/src/Api/Documentation/Attributes/ApiSecurity.php create mode 100644 src/php/src/Api/Documentation/Attributes/ApiTag.php create mode 100644 src/php/src/Api/Documentation/DocumentationController.php create mode 100644 src/php/src/Api/Documentation/DocumentationServiceProvider.php create mode 100644 src/php/src/Api/Documentation/Examples/CommonExamples.php create mode 100644 src/php/src/Api/Documentation/Extension.php create mode 100644 src/php/src/Api/Documentation/Extensions/ApiKeyAuthExtension.php create mode 100644 src/php/src/Api/Documentation/Extensions/RateLimitExtension.php create mode 100644 src/php/src/Api/Documentation/Extensions/WorkspaceHeaderExtension.php create mode 100644 src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php create mode 100644 src/php/src/Api/Documentation/ModuleDiscovery.php create mode 100644 src/php/src/Api/Documentation/OpenApiBuilder.php create mode 100644 src/php/src/Api/Documentation/Routes/docs.php create mode 100644 src/php/src/Api/Documentation/Views/redoc.blade.php create mode 100644 src/php/src/Api/Documentation/Views/scalar.blade.php create mode 100644 src/php/src/Api/Documentation/Views/swagger.blade.php create mode 100644 src/php/src/Api/Documentation/config.php create mode 100644 src/php/src/Api/Enums/BuiltinTemplateType.php create mode 100644 src/php/src/Api/Enums/WebhookTemplateFormat.php create mode 100644 src/php/src/Api/Exceptions/RateLimitExceededException.php create mode 100644 src/php/src/Api/Guards/AccessTokenGuard.php create mode 100644 src/php/src/Api/Jobs/DeliverWebhookJob.php create mode 100644 src/php/src/Api/Middleware/AuthenticateApiKey.php create mode 100644 src/php/src/Api/Middleware/CheckApiScope.php create mode 100644 src/php/src/Api/Middleware/EnforceApiScope.php create mode 100644 src/php/src/Api/Middleware/PublicApiCors.php create mode 100644 src/php/src/Api/Middleware/RateLimitApi.php create mode 100644 src/php/src/Api/Middleware/TrackApiUsage.php create mode 100644 src/php/src/Api/Migrations/0001_01_01_000001_create_api_tables.php create mode 100644 src/php/src/Api/Migrations/2026_01_07_002358_create_api_keys_table.php create mode 100644 src/php/src/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php create mode 100644 src/php/src/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php create mode 100644 src/php/src/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php create mode 100644 src/php/src/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php create mode 100644 src/php/src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php create mode 100644 src/php/src/Api/Models/ApiKey.php create mode 100644 src/php/src/Api/Models/ApiUsage.php create mode 100644 src/php/src/Api/Models/ApiUsageDaily.php create mode 100644 src/php/src/Api/Models/WebhookDelivery.php create mode 100644 src/php/src/Api/Models/WebhookEndpoint.php create mode 100644 src/php/src/Api/Models/WebhookPayloadTemplate.php create mode 100644 src/php/src/Api/Notifications/HighApiUsageNotification.php create mode 100644 src/php/src/Api/RateLimit/RateLimit.php create mode 100644 src/php/src/Api/RateLimit/RateLimitResult.php create mode 100644 src/php/src/Api/RateLimit/RateLimitService.php create mode 100644 src/php/src/Api/Resources/ApiKeyResource.php create mode 100644 src/php/src/Api/Resources/ErrorResource.php create mode 100644 src/php/src/Api/Resources/PaginatedCollection.php create mode 100644 src/php/src/Api/Resources/WebhookEndpointResource.php create mode 100644 src/php/src/Api/Resources/WorkspaceResource.php create mode 100644 src/php/src/Api/Routes/admin.php create mode 100644 src/php/src/Api/Routes/api.php create mode 100644 src/php/src/Api/Services/ApiKeyService.php create mode 100644 src/php/src/Api/Services/ApiSnippetService.php create mode 100644 src/php/src/Api/Services/ApiUsageService.php create mode 100644 src/php/src/Api/Services/IpRestrictionService.php create mode 100644 src/php/src/Api/Services/WebhookSecretRotationService.php create mode 100644 src/php/src/Api/Services/WebhookService.php create mode 100644 src/php/src/Api/Services/WebhookSignature.php create mode 100644 src/php/src/Api/Services/WebhookTemplateService.php create mode 100644 src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php create mode 100644 src/php/src/Api/Tests/Feature/ApiKeyRotationTest.php create mode 100644 src/php/src/Api/Tests/Feature/ApiKeySecurityTest.php create mode 100644 src/php/src/Api/Tests/Feature/ApiKeyTest.php create mode 100644 src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php create mode 100644 src/php/src/Api/Tests/Feature/ApiUsageTest.php create mode 100644 src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php create mode 100644 src/php/src/Api/Tests/Feature/OpenApiDocumentationTest.php create mode 100644 src/php/src/Api/Tests/Feature/PublicApiCorsTest.php create mode 100644 src/php/src/Api/Tests/Feature/RateLimitTest.php create mode 100644 src/php/src/Api/Tests/Feature/RateLimitingTest.php create mode 100644 src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php create mode 100644 src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php create mode 100644 src/php/src/Api/View/Modal/Admin/WebhookTemplateManager.php create mode 100644 src/php/src/Api/config.php create mode 100644 src/php/src/Front/Api/ApiVersionService.php create mode 100644 src/php/src/Front/Api/Boot.php create mode 100644 src/php/src/Front/Api/Middleware/ApiSunset.php create mode 100644 src/php/src/Front/Api/Middleware/ApiVersion.php create mode 100644 src/php/src/Front/Api/README.md create mode 100644 src/php/src/Front/Api/VersionedRoutes.php create mode 100644 src/php/src/Front/Api/config.php create mode 100644 src/php/src/Website/.DS_Store create mode 100644 src/php/src/Website/Api/Boot.php create mode 100644 src/php/src/Website/Api/Controllers/DocsController.php create mode 100644 src/php/src/Website/Api/Routes/web.php create mode 100644 src/php/src/Website/Api/Services/OpenApiGenerator.php create mode 100644 src/php/src/Website/Api/View/Blade/docs.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/guides/authentication.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/guides/errors.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/guides/index.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/index.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/layouts/docs.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/partials/endpoint.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/redoc.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/reference.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/scalar.blade.php create mode 100644 src/php/src/Website/Api/View/Blade/swagger.blade.php create mode 100644 src/php/tests/Feature/.gitkeep create mode 100644 src/php/tests/TestCase.php create mode 100644 src/php/tests/Unit/.gitkeep create mode 100644 sse.go create mode 100644 sse_test.go create mode 100644 static_test.go create mode 100644 swagger.go create mode 100644 swagger_test.go create mode 100644 timeout_test.go create mode 100644 tracing.go create mode 100644 tracing_test.go create mode 100644 websocket.go create mode 100644 websocket_test.go diff --git a/.core/build.yaml b/.core/build.yaml new file mode 100644 index 0000000..3db33c4 --- /dev/null +++ b/.core/build.yaml @@ -0,0 +1,24 @@ +version: 1 + +project: + name: core-api + description: REST API framework (Go + PHP) + binary: "" + +build: + cgo: false + flags: + - -trimpath + ldflags: + - -s + - -w + +targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 diff --git a/.core/release.yaml b/.core/release.yaml new file mode 100644 index 0000000..788e8fc --- /dev/null +++ b/.core/release.yaml @@ -0,0 +1,20 @@ +version: 1 + +project: + name: core-api + repository: core/api + +publishers: [] + +changelog: + include: + - feat + - fix + - perf + - refactor + exclude: + - chore + - docs + - style + - test + - ci diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..cbab090 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +*.go export-ignore +go.mod export-ignore +go.sum export-ignore +cmd/ export-ignore +pkg/ export-ignore +.core/ export-ignore +src/php/tests/ export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fff2342 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Binaries +core-api +*.exe + +# IDE +.idea/ +.vscode/ + +# Go +vendor/ + +# PHP +/vendor/ +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..80ec574 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,104 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Core API is the REST framework for the Lethean ecosystem, providing both a **Go HTTP engine** (Gin-based, with OpenAPI generation, WebSocket/SSE, ToolBridge) and a **PHP Laravel package** (rate limiting, webhooks, API key management, OpenAPI documentation). Both halves serve the same purpose in their respective stacks. + +Module: `forge.lthn.ai/core/api` | Package: `lthn/api` | Licence: EUPL-1.2 + +## Build and Test Commands + +### Go + +```bash +core build # Build binary (if cmd/ has main) +go build ./... # Build library + +core go test # Run all Go tests +core go test --run TestName # Run a single test +core go cov # Coverage report +core go cov --open # Open HTML coverage in browser +core go qa # Format + vet + lint + test +core go qa full # Also race detector, vuln scan, security audit +core go fmt # gofmt +core go lint # golangci-lint +core go vet # go vet +``` + +### PHP (from repo root) + +```bash +composer test # Run all PHP tests (Pest) +composer test -- --filter=ApiKey # Single test +composer lint # Laravel Pint (PSR-12) +./vendor/bin/pint --dirty # Format changed files +``` + +Tests live in `src/php/src/Api/Tests/Feature/` (in-source) and `src/php/tests/` (standalone). + +## Architecture + +### Go Engine (root-level .go files) + +`Engine` is the central type, configured via functional `Option` functions passed to `New()`: + +```go +engine, _ := api.New(api.WithAddr(":8080"), api.WithCORS("*"), api.WithSwagger(...)) +engine.Register(myRouteGroup) +engine.Serve(ctx) +``` + +**Extension interfaces** (`group.go`): +- `RouteGroup` — minimum: `Name()`, `BasePath()`, `RegisterRoutes(*gin.RouterGroup)` +- `StreamGroup` — optional: `Channels() []string` for WebSocket +- `DescribableGroup` — extends RouteGroup with `Describe() []RouteDescription` for OpenAPI + +**ToolBridge** (`bridge.go`): Converts `ToolDescriptor` structs into `POST /{tool_name}` REST endpoints with auto-generated OpenAPI paths. + +**Authentication** (`authentik.go`): Authentik OIDC integration + static bearer token. Permissive middleware with `RequireAuth()` / `RequireGroup()` guards. + +**OpenAPI** (`openapi.go`, `export.go`, `codegen.go`): `SpecBuilder.Build()` generates OpenAPI 3.1 JSON. `SDKGenerator` wraps openapi-generator-cli for 11 languages. + +**CLI** (`cmd/api/`): Registers `core api spec` and `core api sdk` commands. + +### PHP Package (`src/php/`) + +Three namespace roots: + +| Namespace | Path | Role | +|-----------|------|------| +| `Core\Front\Api` | `src/php/src/Front/Api/` | API frontage — middleware, versioning, auto-discovered provider | +| `Core\Api` | `src/php/src/Api/` | Backend — auth, scopes, models, webhooks, OpenAPI docs | +| `Core\Website\Api` | `src/php/src/Website/Api/` | Documentation UI — controllers, Blade views, web routes | + +Boot chain: `Front\Api\Boot` (auto-discovered) fires `ApiRoutesRegistering` -> `Api\Boot` registers middleware and routes. + +Key services: `WebhookService`, `RateLimitService`, `IpRestrictionService`, `OpenApiBuilder`, `ApiKeyService`. + +## Conventions + +- **UK English** in all user-facing strings and docs (colour, organisation, unauthorised) +- **SPDX headers** in Go files: `// SPDX-License-Identifier: EUPL-1.2` +- **`declare(strict_types=1);`** in every PHP file +- **Full type hints** on all PHP parameters and return types +- **Pest syntax** for PHP tests (not PHPUnit) +- **Flux Pro** components in Livewire views; **Font Awesome** icons +- **Conventional commits**: `type(scope): description` +- **Co-Author**: `Co-Authored-By: Virgil ` +- Go test names use `_Good` / `_Bad` / `_Ugly` suffixes + +## Key Dependencies + +| Go module | Role | +|-----------|------| +| `forge.lthn.ai/core/cli` | CLI command registration | +| `github.com/gin-gonic/gin` | HTTP router | +| `github.com/casbin/casbin/v2` | Authorisation policies | +| `github.com/coreos/go-oidc/v3` | OIDC / Authentik | +| `go.opentelemetry.io/otel` | OpenTelemetry tracing | + +PHP: `lthn/php` (Core framework), Laravel 12, `symfony/yaml`. + +Go workspace: this module is part of `~/Code/go.work`. Requires Go 1.26+, PHP 8.2+. diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..4153cd3 --- /dev/null +++ b/LICENCE @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/api.go b/api.go new file mode 100644 index 0000000..d391726 --- /dev/null +++ b/api.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: EUPL-1.2 + +// Package api provides a Gin-based REST framework with OpenAPI generation. +// Subsystems implement RouteGroup to register their own endpoints. +package api + +import ( + "context" + "errors" + "iter" + "net/http" + "slices" + "time" + + "github.com/gin-contrib/expvar" + "github.com/gin-contrib/pprof" + "github.com/gin-gonic/gin" +) + +const defaultAddr = ":8080" + +// shutdownTimeout is the maximum duration to wait for in-flight requests +// to complete during graceful shutdown. +const shutdownTimeout = 10 * time.Second + +// Engine is the central API server managing route groups and middleware. +type Engine struct { + addr string + groups []RouteGroup + middlewares []gin.HandlerFunc + wsHandler http.Handler + sseBroker *SSEBroker + swaggerEnabled bool + swaggerTitle string + swaggerDesc string + swaggerVersion string + pprofEnabled bool + expvarEnabled bool + graphql *graphqlConfig +} + +// New creates an Engine with the given options. +// The default listen address is ":8080". +func New(opts ...Option) (*Engine, error) { + e := &Engine{ + addr: defaultAddr, + } + for _, opt := range opts { + opt(e) + } + return e, nil +} + +// Addr returns the configured listen address. +func (e *Engine) Addr() string { + return e.addr +} + +// Groups returns all registered route groups. +func (e *Engine) Groups() []RouteGroup { + return e.groups +} + +// GroupsIter returns an iterator over all registered route groups. +func (e *Engine) GroupsIter() iter.Seq[RouteGroup] { + return slices.Values(e.groups) +} + +// Register adds a route group to the engine. +func (e *Engine) Register(group RouteGroup) { + e.groups = append(e.groups, group) +} + +// Channels returns all WebSocket channel names from registered StreamGroups. +// Groups that do not implement StreamGroup are silently skipped. +func (e *Engine) Channels() []string { + var channels []string + for _, g := range e.groups { + if sg, ok := g.(StreamGroup); ok { + channels = append(channels, sg.Channels()...) + } + } + return channels +} + +// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups. +func (e *Engine) ChannelsIter() iter.Seq[string] { + return func(yield func(string) bool) { + for _, g := range e.groups { + if sg, ok := g.(StreamGroup); ok { + for _, c := range sg.Channels() { + if !yield(c) { + return + } + } + } + } + } +} + +// Handler builds the Gin engine and returns it as an http.Handler. +// Each call produces a fresh handler reflecting the current set of groups. +func (e *Engine) Handler() http.Handler { + return e.build() +} + +// Serve starts the HTTP server and blocks until the context is cancelled, +// then performs a graceful shutdown allowing in-flight requests to complete. +func (e *Engine) Serve(ctx context.Context) error { + srv := &http.Server{ + Addr: e.addr, + Handler: e.build(), + } + + errCh := make(chan error, 1) + go func() { + if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- err + } + close(errCh) + }() + + // Block until context is cancelled. + <-ctx.Done() + + // Graceful shutdown with timeout. + shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + + if err := srv.Shutdown(shutdownCtx); err != nil { + return err + } + + // Return any listen error that occurred before shutdown. + return <-errCh +} + +// build creates a configured Gin engine with recovery middleware, +// user-supplied middleware, the health endpoint, and all registered route groups. +func (e *Engine) build() *gin.Engine { + r := gin.New() + r.Use(gin.Recovery()) + + // Apply user-supplied middleware after recovery but before routes. + for _, mw := range e.middlewares { + r.Use(mw) + } + + // Built-in health check. + r.GET("/health", func(c *gin.Context) { + c.JSON(http.StatusOK, OK("healthy")) + }) + + // Mount each registered group at its base path. + for _, g := range e.groups { + rg := r.Group(g.BasePath()) + g.RegisterRoutes(rg) + } + + // Mount WebSocket handler if configured. + if e.wsHandler != nil { + r.GET("/ws", wrapWSHandler(e.wsHandler)) + } + + // Mount SSE endpoint if configured. + if e.sseBroker != nil { + r.GET("/events", e.sseBroker.Handler()) + } + + // Mount GraphQL endpoint if configured. + if e.graphql != nil { + mountGraphQL(r, e.graphql) + } + + // Mount Swagger UI if enabled. + if e.swaggerEnabled { + registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups) + } + + // Mount pprof profiling endpoints if enabled. + if e.pprofEnabled { + pprof.Register(r) + } + + // Mount expvar runtime metrics endpoint if enabled. + if e.expvarEnabled { + r.GET("/debug/vars", expvar.Handler()) + } + + return r +} diff --git a/api_test.go b/api_test.go new file mode 100644 index 0000000..96f1337 --- /dev/null +++ b/api_test.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Test helpers ──────────────────────────────────────────────────────── + +// healthGroup is a minimal RouteGroup for testing Engine integration. +type healthGroup struct{} + +func (h *healthGroup) Name() string { return "health-extra" } +func (h *healthGroup) BasePath() string { return "/v1" } +func (h *healthGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/echo", func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("echo")) + }) +} + +// ── New ───────────────────────────────────────────────────────────────── + +func TestNew_Good(t *testing.T) { + e, err := api.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e == nil { + t.Fatal("expected non-nil Engine") + } +} + +func TestNew_Good_WithAddr(t *testing.T) { + e, err := api.New(api.WithAddr(":9090")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if e.Addr() != ":9090" { + t.Fatalf("expected addr=%q, got %q", ":9090", e.Addr()) + } +} + +// ── Default address ───────────────────────────────────────────────────── + +func TestAddr_Good_Default(t *testing.T) { + e, _ := api.New() + if e.Addr() != ":8080" { + t.Fatalf("expected default addr=%q, got %q", ":8080", e.Addr()) + } +} + +// ── Register + Groups ─────────────────────────────────────────────────── + +func TestRegister_Good(t *testing.T) { + e, _ := api.New() + e.Register(&healthGroup{}) + + groups := e.Groups() + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + if groups[0].Name() != "health-extra" { + t.Fatalf("expected group name=%q, got %q", "health-extra", groups[0].Name()) + } +} + +func TestRegister_Good_MultipleGroups(t *testing.T) { + e, _ := api.New() + e.Register(&healthGroup{}) + e.Register(&stubGroup{}) + + if len(e.Groups()) != 2 { + t.Fatalf("expected 2 groups, got %d", len(e.Groups())) + } +} + +// ── Handler ───────────────────────────────────────────────────────────── + +func TestHandler_Good_HealthEndpoint(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !resp.Success { + t.Fatal("expected Success=true") + } + if resp.Data != "healthy" { + t.Fatalf("expected Data=%q, got %q", "healthy", resp.Data) + } +} + +func TestHandler_Good_RegisteredRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New() + e.Register(&healthGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/echo", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data != "echo" { + t.Fatalf("expected Data=%q, got %q", "echo", resp.Data) + } +} + +func TestHandler_Bad_NotFound(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/nonexistent", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +// ── Serve + graceful shutdown ─────────────────────────────────────────── + +func TestServe_Good_GracefulShutdown(t *testing.T) { + // Pick a random free port to avoid conflicts. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to find free port: %v", err) + } + addr := ln.Addr().String() + ln.Close() + + e, _ := api.New(api.WithAddr(addr)) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + + go func() { + errCh <- e.Serve(ctx) + }() + + // Wait for server to be ready. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond) + if err == nil { + conn.Close() + break + } + time.Sleep(50 * time.Millisecond) + } + + // Verify the server responds. + resp, err := http.Get("http://" + addr + "/health") + if err != nil { + t.Fatalf("health request failed: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + // Cancel context to trigger graceful shutdown. + cancel() + + select { + case serveErr := <-errCh: + if serveErr != nil { + t.Fatalf("Serve returned unexpected error: %v", serveErr) + } + case <-time.After(5 * time.Second): + t.Fatal("Serve did not return within 5 seconds after context cancellation") + } +} diff --git a/authentik.go b/authentik.go new file mode 100644 index 0000000..fa08217 --- /dev/null +++ b/authentik.go @@ -0,0 +1,228 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "context" + "net/http" + "slices" + "strings" + "sync" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/gin-gonic/gin" +) + +// AuthentikConfig holds settings for the Authentik forward-auth integration. +type AuthentikConfig struct { + // Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/). + Issuer string + + // ClientID is the OAuth2 client identifier. + ClientID string + + // TrustedProxy enables reading X-authentik-* headers set by a reverse proxy. + // When false, headers are ignored to prevent spoofing from untrusted sources. + TrustedProxy bool + + // PublicPaths lists additional paths that do not require authentication. + // /health and /swagger are always public. + PublicPaths []string +} + +// AuthentikUser represents an authenticated user extracted from Authentik +// forward-auth headers or a validated JWT. +type AuthentikUser struct { + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + UID string `json:"uid"` + Groups []string `json:"groups,omitempty"` + Entitlements []string `json:"entitlements,omitempty"` + JWT string `json:"-"` +} + +// HasGroup reports whether the user belongs to the named group. +func (u *AuthentikUser) HasGroup(group string) bool { + return slices.Contains(u.Groups, group) +} + +// authentikUserKey is the Gin context key used to store the authenticated user. +const authentikUserKey = "authentik_user" + +// GetUser retrieves the AuthentikUser from the Gin context. +// Returns nil when no user has been set (unauthenticated request or +// middleware not active). +func GetUser(c *gin.Context) *AuthentikUser { + val, exists := c.Get(authentikUserKey) + if !exists { + return nil + } + user, ok := val.(*AuthentikUser) + if !ok { + return nil + } + return user +} + +// oidcProviderMu guards the provider cache. +var oidcProviderMu sync.Mutex + +// oidcProviders caches OIDC providers by issuer URL to avoid repeated +// discovery requests. +var oidcProviders = make(map[string]*oidc.Provider) + +// getOIDCProvider returns a cached OIDC provider for the given issuer, +// performing discovery on first access. +func getOIDCProvider(ctx context.Context, issuer string) (*oidc.Provider, error) { + oidcProviderMu.Lock() + defer oidcProviderMu.Unlock() + + if p, ok := oidcProviders[issuer]; ok { + return p, nil + } + + p, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return nil, err + } + + oidcProviders[issuer] = p + return p, nil +} + +// validateJWT verifies a raw JWT against the configured OIDC issuer and +// extracts user claims on success. +func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*AuthentikUser, error) { + provider, err := getOIDCProvider(ctx, cfg.Issuer) + if err != nil { + return nil, err + } + + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}) + + idToken, err := verifier.Verify(ctx, rawToken) + if err != nil { + return nil, err + } + + var claims struct { + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Name string `json:"name"` + Sub string `json:"sub"` + Groups []string `json:"groups"` + } + if err := idToken.Claims(&claims); err != nil { + return nil, err + } + + return &AuthentikUser{ + Username: claims.PreferredUsername, + Email: claims.Email, + Name: claims.Name, + UID: claims.Sub, + Groups: claims.Groups, + JWT: rawToken, + }, nil +} + +// authentikMiddleware returns Gin middleware that extracts user identity from +// X-authentik-* headers set by a trusted reverse proxy (e.g. Traefik with +// Authentik forward-auth) or from a JWT in the Authorization header. +// +// The middleware is PERMISSIVE: it populates the context when credentials are +// present but never rejects unauthenticated requests. Downstream handlers +// use GetUser to check authentication. +func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc { + // Build the set of public paths that skip header extraction entirely. + public := map[string]bool{ + "/health": true, + "/swagger": true, + } + for _, p := range cfg.PublicPaths { + public[p] = true + } + + return func(c *gin.Context) { + // Skip public paths. + path := c.Request.URL.Path + for p := range public { + if strings.HasPrefix(path, p) { + c.Next() + return + } + } + + // Block 1: Extract user from X-authentik-* forward-auth headers. + if cfg.TrustedProxy { + username := c.GetHeader("X-authentik-username") + if username != "" { + user := &AuthentikUser{ + Username: username, + Email: c.GetHeader("X-authentik-email"), + Name: c.GetHeader("X-authentik-name"), + UID: c.GetHeader("X-authentik-uid"), + JWT: c.GetHeader("X-authentik-jwt"), + } + + if groups := c.GetHeader("X-authentik-groups"); groups != "" { + user.Groups = strings.Split(groups, "|") + } + if ent := c.GetHeader("X-authentik-entitlements"); ent != "" { + user.Entitlements = strings.Split(ent, "|") + } + + c.Set(authentikUserKey, user) + } + } + + // Block 2: Attempt JWT validation for direct API clients. + // Only when OIDC is configured and no user was extracted from headers. + if cfg.Issuer != "" && cfg.ClientID != "" && GetUser(c) == nil { + if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") { + rawToken := strings.TrimPrefix(auth, "Bearer ") + if user, err := validateJWT(c.Request.Context(), cfg, rawToken); err == nil { + c.Set(authentikUserKey, user) + } + // On failure: continue without user (fail open / permissive). + } + } + + c.Next() + } +} + +// RequireAuth is Gin middleware that rejects unauthenticated requests. +// It checks for a user set by the Authentik middleware and returns 401 +// when none is present. +func RequireAuth() gin.HandlerFunc { + return func(c *gin.Context) { + if GetUser(c) == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, + Fail("unauthorised", "Authentication required")) + return + } + c.Next() + } +} + +// RequireGroup is Gin middleware that rejects requests from users who do +// not belong to the specified group. Returns 401 when no user is present +// and 403 when the user lacks the required group membership. +func RequireGroup(group string) gin.HandlerFunc { + return func(c *gin.Context) { + user := GetUser(c) + if user == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, + Fail("unauthorised", "Authentication required")) + return + } + if !user.HasGroup(group) { + c.AbortWithStatusJSON(http.StatusForbidden, + Fail("forbidden", "Insufficient permissions")) + return + } + c.Next() + } +} diff --git a/authentik_integration_test.go b/authentik_integration_test.go new file mode 100644 index 0000000..a5e3631 --- /dev/null +++ b/authentik_integration_test.go @@ -0,0 +1,337 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "testing" + + api "forge.lthn.ai/core/api" + "github.com/gin-gonic/gin" +) + +// testAuthRoutes provides endpoints for integration testing. +type testAuthRoutes struct{} + +func (r *testAuthRoutes) Name() string { return "authtest" } +func (r *testAuthRoutes) BasePath() string { return "/v1" } + +func (r *testAuthRoutes) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/public", func(c *gin.Context) { + c.JSON(200, api.OK("public")) + }) + rg.GET("/whoami", api.RequireAuth(), func(c *gin.Context) { + user := api.GetUser(c) + c.JSON(200, api.OK(user)) + }) + rg.GET("/admin", api.RequireGroup("admins"), func(c *gin.Context) { + user := api.GetUser(c) + c.JSON(200, api.OK(user)) + }) +} + +// getClientCredentialsToken fetches a token from Authentik using +// the client_credentials grant. +func getClientCredentialsToken(t *testing.T, issuer, clientID, clientSecret string) (accessToken, idToken string) { + t.Helper() + + // Discover token endpoint. + disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + resp, err := http.Get(disc) + if err != nil { + t.Fatalf("OIDC discovery failed: %v", err) + } + defer resp.Body.Close() + + var config struct { + TokenEndpoint string `json:"token_endpoint"` + } + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + t.Fatalf("decode discovery: %v", err) + } + + // Request token. + data := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "scope": {"openid email profile entitlements"}, + } + resp, err = http.PostForm(config.TokenEndpoint, data) + if err != nil { + t.Fatalf("token request failed: %v", err) + } + defer resp.Body.Close() + + var tokenResp struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { + t.Fatalf("decode token response: %v", err) + } + if tokenResp.Error != "" { + t.Fatalf("token error: %s — %s", tokenResp.Error, tokenResp.ErrorDesc) + } + + return tokenResp.AccessToken, tokenResp.IDToken +} + +func TestAuthentikIntegration(t *testing.T) { + // Skip unless explicitly enabled — requires live Authentik at auth.lthn.io. + if os.Getenv("AUTHENTIK_INTEGRATION") != "1" { + t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests") + } + + issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/") + clientID := envOr("AUTHENTIK_CLIENT_ID", "core-api") + clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET") + if clientSecret == "" { + t.Fatal("AUTHENTIK_CLIENT_SECRET is required") + } + + gin.SetMode(gin.TestMode) + + // Fetch a real token from Authentik. + t.Run("TokenAcquisition", func(t *testing.T) { + access, id := getClientCredentialsToken(t, issuer, clientID, clientSecret) + if access == "" { + t.Fatal("empty access_token") + } + if id == "" { + t.Fatal("empty id_token") + } + t.Logf("access_token length: %d", len(access)) + t.Logf("id_token length: %d", len(id)) + }) + + // Build the engine with real Authentik config. + engine, err := api.New( + api.WithAuthentik(api.AuthentikConfig{ + Issuer: issuer, + ClientID: clientID, + TrustedProxy: true, + }), + ) + if err != nil { + t.Fatalf("engine: %v", err) + } + engine.Register(&testAuthRoutes{}) + ts := httptest.NewServer(engine.Handler()) + defer ts.Close() + + accessToken, _ := getClientCredentialsToken(t, issuer, clientID, clientSecret) + + t.Run("Health_NoAuth", func(t *testing.T) { + resp := get(t, ts.URL+"/health", "") + assertStatus(t, resp, 200) + body := readBody(t, resp) + t.Logf("health: %s", body) + }) + + t.Run("Public_NoAuth", func(t *testing.T) { + resp := get(t, ts.URL+"/v1/public", "") + assertStatus(t, resp, 200) + body := readBody(t, resp) + t.Logf("public: %s", body) + }) + + t.Run("Whoami_NoToken_401", func(t *testing.T) { + resp := get(t, ts.URL+"/v1/whoami", "") + assertStatus(t, resp, 401) + }) + + t.Run("Whoami_WithAccessToken", func(t *testing.T) { + resp := get(t, ts.URL+"/v1/whoami", accessToken) + assertStatus(t, resp, 200) + body := readBody(t, resp) + t.Logf("whoami (access_token): %s", body) + + // Parse response and verify user fields. + var envelope struct { + Data api.AuthentikUser `json:"data"` + } + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + t.Fatalf("parse whoami: %v", err) + } + if envelope.Data.UID == "" { + t.Error("expected non-empty UID") + } + if !strings.Contains(envelope.Data.Username, "client_credentials") { + t.Logf("username: %s (service account)", envelope.Data.Username) + } + }) + + t.Run("Admin_ServiceAccount_403", func(t *testing.T) { + // Service account has no groups — should get 403. + resp := get(t, ts.URL+"/v1/admin", accessToken) + assertStatus(t, resp, 403) + }) + + t.Run("Whoami_ForwardAuthHeaders", func(t *testing.T) { + // Simulate what Traefik sends after forward auth. + req, _ := http.NewRequest("GET", ts.URL+"/v1/whoami", nil) + req.Header.Set("X-authentik-username", "akadmin") + req.Header.Set("X-authentik-email", "mafiafire@proton.me") + req.Header.Set("X-authentik-name", "Admin User") + req.Header.Set("X-authentik-uid", "abc123") + req.Header.Set("X-authentik-groups", "authentik Admins|admins|developers") + req.Header.Set("X-authentik-entitlements", "") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + assertStatus(t, resp, 200) + + body := readBody(t, resp) + t.Logf("whoami (forward auth): %s", body) + + var envelope struct { + Data api.AuthentikUser `json:"data"` + } + if err := json.Unmarshal([]byte(body), &envelope); err != nil { + t.Fatalf("parse: %v", err) + } + if envelope.Data.Username != "akadmin" { + t.Errorf("expected username akadmin, got %s", envelope.Data.Username) + } + if !envelope.Data.HasGroup("admins") { + t.Error("expected admins group") + } + }) + + t.Run("Admin_ForwardAuth_Admins_200", func(t *testing.T) { + req, _ := http.NewRequest("GET", ts.URL+"/v1/admin", nil) + req.Header.Set("X-authentik-username", "akadmin") + req.Header.Set("X-authentik-email", "mafiafire@proton.me") + req.Header.Set("X-authentik-name", "Admin User") + req.Header.Set("X-authentik-uid", "abc123") + req.Header.Set("X-authentik-groups", "authentik Admins|admins|developers") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request: %v", err) + } + defer resp.Body.Close() + assertStatus(t, resp, 200) + t.Logf("admin (forward auth): %s", readBody(t, resp)) + }) + + t.Run("InvalidJWT_FailOpen", func(t *testing.T) { + // Invalid token on a public endpoint — should still work (permissive). + resp := get(t, ts.URL+"/v1/public", "not-a-real-token") + assertStatus(t, resp, 200) + }) + + t.Run("InvalidJWT_Protected_401", func(t *testing.T) { + // Invalid token on a protected endpoint — no user extracted, RequireAuth returns 401. + resp := get(t, ts.URL+"/v1/whoami", "not-a-real-token") + assertStatus(t, resp, 401) + }) +} + +func get(t *testing.T, url, bearerToken string) *http.Response { + t.Helper() + req, _ := http.NewRequest("GET", url, nil) + if bearerToken != "" { + req.Header.Set("Authorization", "Bearer "+bearerToken) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + return resp +} + +func readBody(t *testing.T, resp *http.Response) string { + t.Helper() + b, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + t.Fatalf("read body: %v", err) + } + return string(b) +} + +func assertStatus(t *testing.T, resp *http.Response, want int) { + t.Helper() + if resp.StatusCode != want { + b, _ := io.ReadAll(resp.Body) + resp.Body.Close() + t.Fatalf("want status %d, got %d: %s", want, resp.StatusCode, string(b)) + } +} + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +// TestOIDCDiscovery validates that the OIDC discovery endpoint is reachable. +func TestOIDCDiscovery(t *testing.T) { + if os.Getenv("AUTHENTIK_INTEGRATION") != "1" { + t.Skip("set AUTHENTIK_INTEGRATION=1 to run live Authentik tests") + } + + issuer := envOr("AUTHENTIK_ISSUER", "https://auth.lthn.io/application/o/core-api/") + disc := strings.TrimSuffix(issuer, "/") + "/.well-known/openid-configuration" + + resp, err := http.Get(disc) + if err != nil { + t.Fatalf("discovery request: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("discovery status: %d", resp.StatusCode) + } + + var config map[string]any + if err := json.NewDecoder(resp.Body).Decode(&config); err != nil { + t.Fatalf("decode: %v", err) + } + + // Verify essential fields. + for _, field := range []string{"issuer", "token_endpoint", "jwks_uri", "authorization_endpoint"} { + if config[field] == nil { + t.Errorf("missing field: %s", field) + } + } + + if config["issuer"] != issuer { + t.Errorf("issuer mismatch: got %v, want %s", config["issuer"], issuer) + } + + // Verify grant types include client_credentials. + grants, ok := config["grant_types_supported"].([]any) + if !ok { + t.Fatal("missing grant_types_supported") + } + found := false + for _, g := range grants { + if g == "client_credentials" { + found = true + break + } + } + if !found { + t.Error("client_credentials grant not supported") + } + + fmt.Printf(" OIDC discovery OK — issuer: %s\n", config["issuer"]) + fmt.Printf(" Token endpoint: %s\n", config["token_endpoint"]) + fmt.Printf(" JWKS URI: %s\n", config["jwks_uri"]) +} diff --git a/authentik_test.go b/authentik_test.go new file mode 100644 index 0000000..cae46c9 --- /dev/null +++ b/authentik_test.go @@ -0,0 +1,460 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── AuthentikUser ────────────────────────────────────────────────────── + +func TestAuthentikUser_Good(t *testing.T) { + u := api.AuthentikUser{ + Username: "alice", + Email: "alice@example.com", + Name: "Alice Smith", + UID: "abc-123", + Groups: []string{"editors", "admins"}, + Entitlements: []string{"premium"}, + JWT: "tok.en.here", + } + + if u.Username != "alice" { + t.Fatalf("expected Username=%q, got %q", "alice", u.Username) + } + if u.Email != "alice@example.com" { + t.Fatalf("expected Email=%q, got %q", "alice@example.com", u.Email) + } + if u.Name != "Alice Smith" { + t.Fatalf("expected Name=%q, got %q", "Alice Smith", u.Name) + } + if u.UID != "abc-123" { + t.Fatalf("expected UID=%q, got %q", "abc-123", u.UID) + } + if len(u.Groups) != 2 || u.Groups[0] != "editors" { + t.Fatalf("expected Groups=[editors admins], got %v", u.Groups) + } + if len(u.Entitlements) != 1 || u.Entitlements[0] != "premium" { + t.Fatalf("expected Entitlements=[premium], got %v", u.Entitlements) + } + if u.JWT != "tok.en.here" { + t.Fatalf("expected JWT=%q, got %q", "tok.en.here", u.JWT) + } +} + +func TestAuthentikUserHasGroup_Good(t *testing.T) { + u := api.AuthentikUser{ + Groups: []string{"editors", "admins"}, + } + + if !u.HasGroup("admins") { + t.Fatal("expected HasGroup(admins) = true") + } + if !u.HasGroup("editors") { + t.Fatal("expected HasGroup(editors) = true") + } +} + +func TestAuthentikUserHasGroup_Bad_Empty(t *testing.T) { + u := api.AuthentikUser{} + + if u.HasGroup("admins") { + t.Fatal("expected HasGroup(admins) = false for empty user") + } +} + +func TestAuthentikConfig_Good(t *testing.T) { + cfg := api.AuthentikConfig{ + Issuer: "https://auth.example.com", + ClientID: "my-client", + TrustedProxy: true, + PublicPaths: []string{"/public", "/docs"}, + } + + if cfg.Issuer != "https://auth.example.com" { + t.Fatalf("expected Issuer=%q, got %q", "https://auth.example.com", cfg.Issuer) + } + if cfg.ClientID != "my-client" { + t.Fatalf("expected ClientID=%q, got %q", "my-client", cfg.ClientID) + } + if !cfg.TrustedProxy { + t.Fatal("expected TrustedProxy=true") + } + if len(cfg.PublicPaths) != 2 { + t.Fatalf("expected 2 public paths, got %d", len(cfg.PublicPaths)) + } +} + +// ── Forward auth middleware ──────────────────────────────────────────── + +func TestForwardAuthHeaders_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New(api.WithAuthentik(cfg)) + + var gotUser *api.AuthentikUser + e.Register(&authTestGroup{onRequest: func(c *gin.Context) { + gotUser = api.GetUser(c) + c.JSON(http.StatusOK, api.OK("ok")) + }}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil) + req.Header.Set("X-authentik-username", "bob") + req.Header.Set("X-authentik-email", "bob@example.com") + req.Header.Set("X-authentik-name", "Bob Jones") + req.Header.Set("X-authentik-uid", "uid-456") + req.Header.Set("X-authentik-jwt", "jwt.tok.en") + req.Header.Set("X-authentik-groups", "staff|admins|ops") + req.Header.Set("X-authentik-entitlements", "read|write") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if gotUser == nil { + t.Fatal("expected GetUser to return a user, got nil") + } + if gotUser.Username != "bob" { + t.Fatalf("expected Username=%q, got %q", "bob", gotUser.Username) + } + if gotUser.Email != "bob@example.com" { + t.Fatalf("expected Email=%q, got %q", "bob@example.com", gotUser.Email) + } + if gotUser.Name != "Bob Jones" { + t.Fatalf("expected Name=%q, got %q", "Bob Jones", gotUser.Name) + } + if gotUser.UID != "uid-456" { + t.Fatalf("expected UID=%q, got %q", "uid-456", gotUser.UID) + } + if gotUser.JWT != "jwt.tok.en" { + t.Fatalf("expected JWT=%q, got %q", "jwt.tok.en", gotUser.JWT) + } + if len(gotUser.Groups) != 3 { + t.Fatalf("expected 3 groups, got %d: %v", len(gotUser.Groups), gotUser.Groups) + } + if gotUser.Groups[0] != "staff" || gotUser.Groups[1] != "admins" || gotUser.Groups[2] != "ops" { + t.Fatalf("expected groups [staff admins ops], got %v", gotUser.Groups) + } + if len(gotUser.Entitlements) != 2 { + t.Fatalf("expected 2 entitlements, got %d: %v", len(gotUser.Entitlements), gotUser.Entitlements) + } + if gotUser.Entitlements[0] != "read" || gotUser.Entitlements[1] != "write" { + t.Fatalf("expected entitlements [read write], got %v", gotUser.Entitlements) + } +} + +func TestForwardAuthHeaders_Good_NoHeaders(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New(api.WithAuthentik(cfg)) + + var gotUser *api.AuthentikUser + e.Register(&authTestGroup{onRequest: func(c *gin.Context) { + gotUser = api.GetUser(c) + c.JSON(http.StatusOK, api.OK("ok")) + }}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if gotUser != nil { + t.Fatalf("expected GetUser to return nil without headers, got %+v", gotUser) + } +} + +func TestForwardAuthHeaders_Bad_NotTrusted(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: false} + e, _ := api.New(api.WithAuthentik(cfg)) + + var gotUser *api.AuthentikUser + e.Register(&authTestGroup{onRequest: func(c *gin.Context) { + gotUser = api.GetUser(c) + c.JSON(http.StatusOK, api.OK("ok")) + }}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil) + req.Header.Set("X-authentik-username", "mallory") + req.Header.Set("X-authentik-email", "mallory@evil.com") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if gotUser != nil { + t.Fatalf("expected GetUser to return nil when TrustedProxy=false, got %+v", gotUser) + } +} + +func TestHealthBypassesAuthentik_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New(api.WithAuthentik(cfg)) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for /health, got %d", w.Code) + } +} + +func TestGetUser_Good_NilContext(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Engine without WithAuthentik — GetUser should return nil. + e, _ := api.New() + + var gotUser *api.AuthentikUser + e.Register(&authTestGroup{onRequest: func(c *gin.Context) { + gotUser = api.GetUser(c) + c.JSON(http.StatusOK, api.OK("ok")) + }}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if gotUser != nil { + t.Fatalf("expected GetUser to return nil without middleware, got %+v", gotUser) + } +} + +// ── JWT validation ──────────────────────────────────────────────────── + +func TestJWTValidation_Bad_InvalidToken(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Use a fake issuer that won't resolve — JWT validation should fail open. + cfg := api.AuthentikConfig{ + Issuer: "https://fake-issuer.invalid", + ClientID: "test-client", + } + e, _ := api.New(api.WithAuthentik(cfg)) + + var gotUser *api.AuthentikUser + e.Register(&authTestGroup{onRequest: func(c *gin.Context) { + gotUser = api.GetUser(c) + c.JSON(http.StatusOK, api.OK("ok")) + }}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil) + req.Header.Set("Authorization", "Bearer invalid-jwt-token") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (permissive), got %d", w.Code) + } + if gotUser != nil { + t.Fatalf("expected GetUser to return nil for invalid JWT, got %+v", gotUser) + } +} + +func TestBearerAndAuthentikCoexist_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Engine with BOTH bearer auth AND authentik middleware. + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New( + api.WithBearerAuth("secret-token"), + api.WithAuthentik(cfg), + ) + + var gotUser *api.AuthentikUser + e.Register(&authTestGroup{onRequest: func(c *gin.Context) { + gotUser = api.GetUser(c) + c.JSON(http.StatusOK, api.OK("ok")) + }}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/check", nil) + req.Header.Set("Authorization", "Bearer secret-token") + req.Header.Set("X-authentik-username", "carol") + req.Header.Set("X-authentik-email", "carol@example.com") + req.Header.Set("X-authentik-name", "Carol White") + req.Header.Set("X-authentik-uid", "uid-789") + req.Header.Set("X-authentik-groups", "developers|admins") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + if gotUser == nil { + t.Fatal("expected GetUser to return a user, got nil") + } + if gotUser.Username != "carol" { + t.Fatalf("expected Username=%q, got %q", "carol", gotUser.Username) + } + if gotUser.Email != "carol@example.com" { + t.Fatalf("expected Email=%q, got %q", "carol@example.com", gotUser.Email) + } + if len(gotUser.Groups) != 2 || gotUser.Groups[0] != "developers" || gotUser.Groups[1] != "admins" { + t.Fatalf("expected groups [developers admins], got %v", gotUser.Groups) + } +} + +// ── RequireAuth / RequireGroup ──────────────────────────────────────── + +func TestRequireAuth_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New(api.WithAuthentik(cfg)) + e.Register(&protectedGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil) + req.Header.Set("X-authentik-username", "alice") + req.Header.Set("X-authentik-email", "alice@example.com") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRequireAuth_Bad_NoUser(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New(api.WithAuthentik(cfg)) + e.Register(&protectedGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"unauthorised"`) { + t.Fatalf("expected error code 'unauthorised' in body, got %s", body) + } +} + +func TestRequireAuth_Bad_NoAuthentikMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Engine without WithAuthentik — RequireAuth should still reject. + e, _ := api.New() + e.Register(&protectedGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/protected/data", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRequireGroup_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New(api.WithAuthentik(cfg)) + e.Register(&groupRequireGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/admin/panel", nil) + req.Header.Set("X-authentik-username", "admin-user") + req.Header.Set("X-authentik-groups", "admins|staff") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRequireGroup_Bad_WrongGroup(t *testing.T) { + gin.SetMode(gin.TestMode) + + cfg := api.AuthentikConfig{TrustedProxy: true} + e, _ := api.New(api.WithAuthentik(cfg)) + e.Register(&groupRequireGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/admin/panel", nil) + req.Header.Set("X-authentik-username", "dev-user") + req.Header.Set("X-authentik-groups", "developers") + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403, got %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, `"forbidden"`) { + t.Fatalf("expected error code 'forbidden' in body, got %s", body) + } +} + +// ── Test helpers ─────────────────────────────────────────────────────── + +// authTestGroup provides a /v1/check endpoint that calls a custom handler. +type authTestGroup struct { + onRequest func(c *gin.Context) +} + +func (a *authTestGroup) Name() string { return "auth-test" } +func (a *authTestGroup) BasePath() string { return "/v1" } +func (a *authTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/check", a.onRequest) +} + +// protectedGroup provides a /v1/protected/data endpoint guarded by RequireAuth. +type protectedGroup struct{} + +func (g *protectedGroup) Name() string { return "protected" } +func (g *protectedGroup) BasePath() string { return "/v1/protected" } +func (g *protectedGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/data", api.RequireAuth(), func(c *gin.Context) { + user := api.GetUser(c) + c.JSON(200, api.OK(user.Username)) + }) +} + +// groupRequireGroup provides a /v1/admin/panel endpoint guarded by RequireGroup. +type groupRequireGroup struct{} + +func (g *groupRequireGroup) Name() string { return "adminonly" } +func (g *groupRequireGroup) BasePath() string { return "/v1/admin" } +func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/panel", api.RequireGroup("admins"), func(c *gin.Context) { + c.JSON(200, api.OK("admin panel")) + }) +} diff --git a/authz_test.go b/authz_test.go new file mode 100644 index 0000000..b823cab --- /dev/null +++ b/authz_test.go @@ -0,0 +1,222 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// casbinModel is a minimal RESTful ACL model for testing authorisation. +const casbinModel = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = r.sub == p.sub && keyMatch(r.obj, p.obj) && r.act == p.act +` + +// newTestEnforcer creates a Casbin enforcer from the inline model and adds +// the given policies programmatically. Each policy is a [subject, object, action] triple. +func newTestEnforcer(t *testing.T, policies [][3]string) *casbin.Enforcer { + t.Helper() + + m, err := model.NewModelFromString(casbinModel) + if err != nil { + t.Fatalf("failed to create casbin model: %v", err) + } + + e, err := casbin.NewEnforcer(m) + if err != nil { + t.Fatalf("failed to create casbin enforcer: %v", err) + } + + for _, p := range policies { + if _, err := e.AddPolicy(p[0], p[1], p[2]); err != nil { + t.Fatalf("failed to add policy %v: %v", p, err) + } + } + + return e +} + +// setBasicAuth sets the HTTP Basic Authentication header on a request. +func setBasicAuth(req *http.Request, user, pass string) { + req.SetBasicAuth(user, pass) +} + +// ── WithAuthz ───────────────────────────────────────────────────────────── + +func TestWithAuthz_Good_AllowsPermittedRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + enforcer := newTestEnforcer(t, [][3]string{ + {"alice", "/stub/*", "GET"}, + }) + + e, _ := api.New(api.WithAuthz(enforcer)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + setBasicAuth(req, "alice", "secret") + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for permitted request, got %d", w.Code) + } +} + +func TestWithAuthz_Bad_DeniesUnpermittedRequest(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Only alice is permitted; bob has no policy entry. + enforcer := newTestEnforcer(t, [][3]string{ + {"alice", "/stub/*", "GET"}, + }) + + e, _ := api.New(api.WithAuthz(enforcer)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + setBasicAuth(req, "bob", "secret") + + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for unpermitted request, got %d", w.Code) + } +} + +func TestWithAuthz_Good_DifferentMethodsEvaluatedSeparately(t *testing.T) { + gin.SetMode(gin.TestMode) + + // alice can GET but not DELETE. + enforcer := newTestEnforcer(t, [][3]string{ + {"alice", "/stub/*", "GET"}, + }) + + e, _ := api.New(api.WithAuthz(enforcer)) + e.Register(&stubGroup{}) + + h := e.Handler() + + // GET should succeed. + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + setBasicAuth(req, "alice", "secret") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for GET, got %d", w.Code) + } + + // DELETE should be denied (no policy for DELETE). + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodDelete, "/stub/ping", nil) + setBasicAuth(req, "alice", "secret") + h.ServeHTTP(w, req) + + if w.Code != http.StatusForbidden { + t.Fatalf("expected 403 for DELETE, got %d", w.Code) + } +} + +func TestWithAuthz_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + enforcer := newTestEnforcer(t, [][3]string{ + {"alice", "/stub/*", "GET"}, + }) + + e, _ := api.New( + api.WithRequestID(), + api.WithAuthz(enforcer), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + setBasicAuth(req, "alice", "secret") + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Both authz (allowed) and request ID should be active. + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +// casbinWildcardModel extends the base model with a matcher that treats +// "*" as a wildcard subject, allowing any authenticated user through. +const casbinWildcardModel = ` +[request_definition] +r = sub, obj, act + +[policy_definition] +p = sub, obj, act + +[policy_effect] +e = some(where (p.eft == allow)) + +[matchers] +m = (r.sub == p.sub || p.sub == "*") && keyMatch(r.obj, p.obj) && r.act == p.act +` + +func TestWithAuthz_Ugly_WildcardPolicyAllowsAll(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Use a model whose matcher treats "*" as a wildcard subject. + m, err := model.NewModelFromString(casbinWildcardModel) + if err != nil { + t.Fatalf("failed to create casbin model: %v", err) + } + + enforcer, err := casbin.NewEnforcer(m) + if err != nil { + t.Fatalf("failed to create casbin enforcer: %v", err) + } + + if _, err := enforcer.AddPolicy("*", "/stub/*", "GET"); err != nil { + t.Fatalf("failed to add wildcard policy: %v", err) + } + + e, _ := api.New(api.WithAuthz(enforcer)) + e.Register(&stubGroup{}) + + h := e.Handler() + + // Any user should be allowed by the wildcard policy. + for _, user := range []string{"alice", "bob", "charlie"} { + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + setBasicAuth(req, user, "secret") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for user %q with wildcard policy, got %d", user, w.Code) + } + } +} diff --git a/bridge.go b/bridge.go new file mode 100644 index 0000000..79e2e78 --- /dev/null +++ b/bridge.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "iter" + + "github.com/gin-gonic/gin" +) + +// ToolDescriptor describes a tool that can be exposed as a REST endpoint. +type ToolDescriptor struct { + Name string // Tool name, e.g. "file_read" (becomes POST path segment) + Description string // Human-readable description + Group string // OpenAPI tag group, e.g. "files" + InputSchema map[string]any // JSON Schema for request body + OutputSchema map[string]any // JSON Schema for response data (optional) +} + +// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths. +// It implements both RouteGroup and DescribableGroup. +type ToolBridge struct { + basePath string + name string + tools []boundTool +} + +type boundTool struct { + descriptor ToolDescriptor + handler gin.HandlerFunc +} + +// NewToolBridge creates a bridge that mounts tool endpoints at basePath. +func NewToolBridge(basePath string) *ToolBridge { + return &ToolBridge{ + basePath: basePath, + name: "tools", + } +} + +// Add registers a tool with its HTTP handler. +func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) { + b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler}) +} + +// Name returns the bridge identifier. +func (b *ToolBridge) Name() string { return b.name } + +// BasePath returns the URL prefix for all tool endpoints. +func (b *ToolBridge) BasePath() string { return b.basePath } + +// RegisterRoutes mounts POST /{tool_name} for each registered tool. +func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) { + for _, t := range b.tools { + rg.POST("/"+t.descriptor.Name, t.handler) + } +} + +// Describe returns OpenAPI route descriptions for all registered tools. +func (b *ToolBridge) Describe() []RouteDescription { + descs := make([]RouteDescription, 0, len(b.tools)) + for _, t := range b.tools { + tags := []string{t.descriptor.Group} + if t.descriptor.Group == "" { + tags = []string{b.name} + } + descs = append(descs, RouteDescription{ + Method: "POST", + Path: "/" + t.descriptor.Name, + Summary: t.descriptor.Description, + Description: t.descriptor.Description, + Tags: tags, + RequestBody: t.descriptor.InputSchema, + Response: t.descriptor.OutputSchema, + }) + } + return descs +} + +// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools. +func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] { + return func(yield func(RouteDescription) bool) { + for _, t := range b.tools { + tags := []string{t.descriptor.Group} + if t.descriptor.Group == "" { + tags = []string{b.name} + } + rd := RouteDescription{ + Method: "POST", + Path: "/" + t.descriptor.Name, + Summary: t.descriptor.Description, + Description: t.descriptor.Description, + Tags: tags, + RequestBody: t.descriptor.InputSchema, + Response: t.descriptor.OutputSchema, + } + if !yield(rd) { + return + } + } + } +} + +// Tools returns all registered tool descriptors. +func (b *ToolBridge) Tools() []ToolDescriptor { + descs := make([]ToolDescriptor, len(b.tools)) + for i, t := range b.tools { + descs[i] = t.descriptor + } + return descs +} + +// ToolsIter returns an iterator over all registered tool descriptors. +func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] { + return func(yield func(ToolDescriptor) bool) { + for _, t := range b.tools { + if !yield(t.descriptor) { + return + } + } + } +} diff --git a/bridge_test.go b/bridge_test.go new file mode 100644 index 0000000..433b55d --- /dev/null +++ b/bridge_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── ToolBridge ───────────────────────────────────────────────────────── + +func TestToolBridge_Good_RegisterAndServe(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file", + Group: "files", + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("result1")) + }) + bridge.Add(api.ToolDescriptor{ + Name: "file_write", + Description: "Write a file", + Group: "files", + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("result2")) + }) + + rg := engine.Group(bridge.BasePath()) + bridge.RegisterRoutes(rg) + + // POST /tools/file_read + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil) + engine.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("expected 200 for file_read, got %d", w1.Code) + } + var resp1 api.Response[string] + if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp1.Data != "result1" { + t.Fatalf("expected Data=%q, got %q", "result1", resp1.Data) + } + + // POST /tools/file_write + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodPost, "/tools/file_write", nil) + engine.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200 for file_write, got %d", w2.Code) + } + var resp2 api.Response[string] + if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp2.Data != "result2" { + t.Fatalf("expected Data=%q, got %q", "result2", resp2.Data) + } +} + +func TestToolBridge_Good_BasePath(t *testing.T) { + bridge := api.NewToolBridge("/api/v1/tools") + + if bridge.BasePath() != "/api/v1/tools" { + t.Fatalf("expected BasePath=%q, got %q", "/api/v1/tools", bridge.BasePath()) + } + if bridge.Name() != "tools" { + t.Fatalf("expected Name=%q, got %q", "tools", bridge.Name()) + } +} + +func TestToolBridge_Good_Describe(t *testing.T) { + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file from disk", + Group: "files", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) {}) + bridge.Add(api.ToolDescriptor{ + Name: "metrics_query", + Description: "Query metrics data", + Group: "metrics", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) {}) + + // Verify DescribableGroup interface satisfaction. + var dg api.DescribableGroup = bridge + descs := dg.Describe() + + if len(descs) != 2 { + t.Fatalf("expected 2 descriptions, got %d", len(descs)) + } + + // First tool. + if descs[0].Method != "POST" { + t.Fatalf("expected descs[0].Method=%q, got %q", "POST", descs[0].Method) + } + if descs[0].Path != "/file_read" { + t.Fatalf("expected descs[0].Path=%q, got %q", "/file_read", descs[0].Path) + } + if descs[0].Summary != "Read a file from disk" { + t.Fatalf("expected descs[0].Summary=%q, got %q", "Read a file from disk", descs[0].Summary) + } + if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "files" { + t.Fatalf("expected descs[0].Tags=[files], got %v", descs[0].Tags) + } + if descs[0].RequestBody == nil { + t.Fatal("expected descs[0].RequestBody to be non-nil") + } + if descs[0].Response == nil { + t.Fatal("expected descs[0].Response to be non-nil") + } + + // Second tool. + if descs[1].Path != "/metrics_query" { + t.Fatalf("expected descs[1].Path=%q, got %q", "/metrics_query", descs[1].Path) + } + if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "metrics" { + t.Fatalf("expected descs[1].Tags=[metrics], got %v", descs[1].Tags) + } + if descs[1].Response != nil { + t.Fatalf("expected descs[1].Response to be nil, got %v", descs[1].Response) + } +} + +func TestToolBridge_Good_ToolsAccessor(t *testing.T) { + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {}) + bridge.Add(api.ToolDescriptor{Name: "beta", Description: "Tool B", Group: "b"}, func(c *gin.Context) {}) + bridge.Add(api.ToolDescriptor{Name: "gamma", Description: "Tool C", Group: "c"}, func(c *gin.Context) {}) + + tools := bridge.Tools() + if len(tools) != 3 { + t.Fatalf("expected 3 tools, got %d", len(tools)) + } + + expected := []string{"alpha", "beta", "gamma"} + for i, want := range expected { + if tools[i].Name != want { + t.Fatalf("expected tools[%d].Name=%q, got %q", i, want, tools[i].Name) + } + } +} + +func TestToolBridge_Bad_EmptyBridge(t *testing.T) { + gin.SetMode(gin.TestMode) + bridge := api.NewToolBridge("/tools") + + // RegisterRoutes should not panic with no tools. + engine := gin.New() + rg := engine.Group(bridge.BasePath()) + bridge.RegisterRoutes(rg) + + // Describe should return empty slice. + descs := bridge.Describe() + if len(descs) != 0 { + t.Fatalf("expected 0 descriptions, got %d", len(descs)) + } + + // Tools should return empty slice. + tools := bridge.Tools() + if len(tools) != 0 { + t.Fatalf("expected 0 tools, got %d", len(tools)) + } +} + +func TestToolBridge_Good_IntegrationWithEngine(t *testing.T) { + gin.SetMode(gin.TestMode) + e, err := api.New() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "ping", + Description: "Ping tool", + Group: "util", + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("pong")) + }) + + e.Register(bridge) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/tools/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !resp.Success { + t.Fatal("expected Success=true") + } + if resp.Data != "pong" { + t.Fatalf("expected Data=%q, got %q", "pong", resp.Data) + } +} diff --git a/brotli.go b/brotli.go new file mode 100644 index 0000000..b203cf2 --- /dev/null +++ b/brotli.go @@ -0,0 +1,120 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "io" + "net/http" + "strconv" + "strings" + "sync" + + "github.com/andybalholm/brotli" + "github.com/gin-gonic/gin" +) + +const ( + // BrotliBestSpeed is the lowest (fastest) Brotli compression level. + BrotliBestSpeed = brotli.BestSpeed + + // BrotliBestCompression is the highest (smallest output) Brotli level. + BrotliBestCompression = brotli.BestCompression + + // BrotliDefaultCompression is the default Brotli compression level. + BrotliDefaultCompression = brotli.DefaultCompression +) + +// brotliHandler manages a pool of brotli writers for reuse across requests. +type brotliHandler struct { + pool sync.Pool + level int +} + +// newBrotliHandler creates a handler that pools brotli writers at the given level. +func newBrotliHandler(level int) *brotliHandler { + if level < BrotliBestSpeed || level > BrotliBestCompression { + level = BrotliDefaultCompression + } + return &brotliHandler{ + level: level, + pool: sync.Pool{ + New: func() any { + return brotli.NewWriterLevel(io.Discard, level) + }, + }, + } +} + +// Handle is the Gin middleware function that compresses responses with Brotli. +func (h *brotliHandler) Handle(c *gin.Context) { + if !strings.Contains(c.Request.Header.Get("Accept-Encoding"), "br") { + c.Next() + return + } + + w := h.pool.Get().(*brotli.Writer) + w.Reset(c.Writer) + + c.Header("Content-Encoding", "br") + c.Writer.Header().Add("Vary", "Accept-Encoding") + + bw := &brotliWriter{ResponseWriter: c.Writer, writer: w} + c.Writer = bw + + defer func() { + if bw.status >= http.StatusBadRequest { + bw.Header().Del("Content-Encoding") + bw.Header().Del("Vary") + w.Reset(io.Discard) + } else if c.Writer.Size() < 0 { + w.Reset(io.Discard) + } + _ = w.Close() + if c.Writer.Size() > -1 { + c.Header("Content-Length", strconv.Itoa(c.Writer.Size())) + } + h.pool.Put(w) + }() + + c.Next() +} + +// brotliWriter wraps gin.ResponseWriter to intercept writes through brotli. +type brotliWriter struct { + gin.ResponseWriter + writer *brotli.Writer + statusWritten bool + status int +} + +func (b *brotliWriter) Write(data []byte) (int, error) { + b.Header().Del("Content-Length") + + if !b.statusWritten { + b.status = b.ResponseWriter.Status() + } + + if b.status >= http.StatusBadRequest { + b.Header().Del("Content-Encoding") + b.Header().Del("Vary") + return b.ResponseWriter.Write(data) + } + + return b.writer.Write(data) +} + +func (b *brotliWriter) WriteString(s string) (int, error) { + return b.Write([]byte(s)) +} + +func (b *brotliWriter) WriteHeader(code int) { + b.status = code + b.statusWritten = true + b.Header().Del("Content-Length") + b.ResponseWriter.WriteHeader(code) +} + +func (b *brotliWriter) Flush() { + _ = b.writer.Flush() + b.ResponseWriter.Flush() +} diff --git a/brotli_test.go b/brotli_test.go new file mode 100644 index 0000000..de170d1 --- /dev/null +++ b/brotli_test.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── WithBrotli ──────────────────────────────────────────────────────── + +func TestWithBrotli_Good_CompressesResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBrotli()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "br") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "br" { + t.Fatalf("expected Content-Encoding=%q, got %q", "br", ce) + } +} + +func TestWithBrotli_Good_NoCompressionWithoutAcceptHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBrotli()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + // Deliberately not setting Accept-Encoding header. + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce == "br" { + t.Fatal("expected no br Content-Encoding when client does not request it") + } +} + +func TestWithBrotli_Good_DefaultLevel(t *testing.T) { + // Calling WithBrotli() with no arguments should use default compression + // and not panic. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBrotli()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "br") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "br" { + t.Fatalf("expected Content-Encoding=%q with default level, got %q", "br", ce) + } +} + +func TestWithBrotli_Good_CustomLevel(t *testing.T) { + // WithBrotli(BrotliBestSpeed) should work without panicking and still compress. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBrotli(api.BrotliBestSpeed)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "br") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "br" { + t.Fatalf("expected Content-Encoding=%q with BestSpeed, got %q", "br", ce) + } +} + +func TestWithBrotli_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithBrotli(), + api.WithRequestID(), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "br") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Both brotli compression and request ID should be present. + ce := w.Header().Get("Content-Encoding") + if ce != "br" { + t.Fatalf("expected Content-Encoding=%q, got %q", "br", ce) + } + + rid := w.Header().Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} diff --git a/cache.go b/cache.go new file mode 100644 index 0000000..d032346 --- /dev/null +++ b/cache.go @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "bytes" + "maps" + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// cacheEntry holds a cached response body, status code, headers, and expiry. +type cacheEntry struct { + status int + headers http.Header + body []byte + expires time.Time +} + +// cacheStore is a simple thread-safe in-memory cache keyed by request URL. +type cacheStore struct { + mu sync.RWMutex + entries map[string]*cacheEntry +} + +// newCacheStore creates an empty cache store. +func newCacheStore() *cacheStore { + return &cacheStore{ + entries: make(map[string]*cacheEntry), + } +} + +// get retrieves a non-expired entry for the given key. +// Returns nil if the key is missing or expired. +func (s *cacheStore) get(key string) *cacheEntry { + s.mu.RLock() + entry, ok := s.entries[key] + s.mu.RUnlock() + + if !ok { + return nil + } + if time.Now().After(entry.expires) { + s.mu.Lock() + delete(s.entries, key) + s.mu.Unlock() + return nil + } + return entry +} + +// set stores a cache entry with the given TTL. +func (s *cacheStore) set(key string, entry *cacheEntry) { + s.mu.Lock() + s.entries[key] = entry + s.mu.Unlock() +} + +// cacheWriter intercepts writes to capture the response body and status. +type cacheWriter struct { + gin.ResponseWriter + body *bytes.Buffer +} + +func (w *cacheWriter) Write(data []byte) (int, error) { + w.body.Write(data) + return w.ResponseWriter.Write(data) +} + +func (w *cacheWriter) WriteString(s string) (int, error) { + w.body.WriteString(s) + return w.ResponseWriter.WriteString(s) +} + +// cacheMiddleware returns Gin middleware that caches GET responses in memory. +// Only successful responses (2xx) are cached. Non-GET methods pass through. +func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc { + return func(c *gin.Context) { + // Only cache GET requests. + if c.Request.Method != http.MethodGet { + c.Next() + return + } + + key := c.Request.URL.RequestURI() + + // Serve from cache if a valid entry exists. + if entry := store.get(key); entry != nil { + for k, vals := range entry.headers { + for _, v := range vals { + c.Writer.Header().Set(k, v) + } + } + c.Writer.Header().Set("X-Cache", "HIT") + c.Writer.WriteHeader(entry.status) + _, _ = c.Writer.Write(entry.body) + c.Abort() + return + } + + // Wrap the writer to capture the response. + cw := &cacheWriter{ + ResponseWriter: c.Writer, + body: &bytes.Buffer{}, + } + c.Writer = cw + + c.Next() + + // Only cache successful responses. + status := cw.ResponseWriter.Status() + if status >= 200 && status < 300 { + headers := make(http.Header) + maps.Copy(headers, cw.ResponseWriter.Header()) + store.set(key, &cacheEntry{ + status: status, + headers: headers, + body: cw.body.Bytes(), + expires: time.Now().Add(ttl), + }) + } + } +} diff --git a/cache_test.go b/cache_test.go new file mode 100644 index 0000000..fdc3605 --- /dev/null +++ b/cache_test.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// cacheCounterGroup registers routes that increment a counter on each call, +// allowing tests to distinguish cached from uncached responses. +type cacheCounterGroup struct { + counter atomic.Int64 +} + +func (g *cacheCounterGroup) Name() string { return "cache-test" } +func (g *cacheCounterGroup) BasePath() string { return "/cache" } +func (g *cacheCounterGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/counter", func(c *gin.Context) { + n := g.counter.Add(1) + c.JSON(http.StatusOK, api.OK(fmt.Sprintf("call-%d", n))) + }) + rg.GET("/other", func(c *gin.Context) { + n := g.counter.Add(1) + c.JSON(http.StatusOK, api.OK(fmt.Sprintf("other-%d", n))) + }) + rg.POST("/counter", func(c *gin.Context) { + n := g.counter.Add(1) + c.JSON(http.StatusOK, api.OK(fmt.Sprintf("post-%d", n))) + }) +} + +// ── WithCache ─────────────────────────────────────────────────────────── + +func TestWithCache_Good_CachesGETResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + grp := &cacheCounterGroup{} + e, _ := api.New(api.WithCache(5 * time.Second)) + e.Register(grp) + + h := e.Handler() + + // First request — cache MISS. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w1.Code) + } + + body1 := w1.Body.String() + if !strings.Contains(body1, "call-1") { + t.Fatalf("expected body to contain %q, got %q", "call-1", body1) + } + + // Second request — should be a cache HIT returning the same body. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w2.Code) + } + + body2 := w2.Body.String() + if body1 != body2 { + t.Fatalf("expected cached body %q, got %q", body1, body2) + } + + cacheHeader := w2.Header().Get("X-Cache") + if cacheHeader != "HIT" { + t.Fatalf("expected X-Cache=HIT, got %q", cacheHeader) + } + + // Counter should still be 1 (handler was not called again). + if grp.counter.Load() != 1 { + t.Fatalf("expected counter=1 (cached), got %d", grp.counter.Load()) + } +} + +func TestWithCache_Good_POSTNotCached(t *testing.T) { + gin.SetMode(gin.TestMode) + grp := &cacheCounterGroup{} + e, _ := api.New(api.WithCache(5 * time.Second)) + e.Register(grp) + + h := e.Handler() + + // First POST request. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodPost, "/cache/counter", nil) + h.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w1.Code) + } + + var resp1 api.Response[string] + if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp1.Data != "post-1" { + t.Fatalf("expected Data=%q, got %q", "post-1", resp1.Data) + } + + // Second POST request — should NOT be cached, counter increments. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodPost, "/cache/counter", nil) + h.ServeHTTP(w2, req2) + + var resp2 api.Response[string] + if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp2.Data != "post-2" { + t.Fatalf("expected Data=%q, got %q", "post-2", resp2.Data) + } + + // Counter should be 2 — both POST requests hit the handler. + if grp.counter.Load() != 2 { + t.Fatalf("expected counter=2, got %d", grp.counter.Load()) + } +} + +func TestWithCache_Good_DifferentPathsSeparatelyCached(t *testing.T) { + gin.SetMode(gin.TestMode) + grp := &cacheCounterGroup{} + e, _ := api.New(api.WithCache(5 * time.Second)) + e.Register(grp) + + h := e.Handler() + + // Request to /cache/counter. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w1, req1) + + body1 := w1.Body.String() + if !strings.Contains(body1, "call-1") { + t.Fatalf("expected body to contain %q, got %q", "call-1", body1) + } + + // Request to /cache/other — different path, should miss cache. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/cache/other", nil) + h.ServeHTTP(w2, req2) + + body2 := w2.Body.String() + if !strings.Contains(body2, "other-2") { + t.Fatalf("expected body to contain %q, got %q", "other-2", body2) + } + + // Counter is 2 — both paths hit the handler. + if grp.counter.Load() != 2 { + t.Fatalf("expected counter=2, got %d", grp.counter.Load()) + } + + // Re-request /cache/counter — should serve cached "call-1". + w3 := httptest.NewRecorder() + req3, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w3, req3) + + body3 := w3.Body.String() + if body1 != body3 { + t.Fatalf("expected cached body %q, got %q", body1, body3) + } + + // Counter unchanged — served from cache. + if grp.counter.Load() != 2 { + t.Fatalf("expected counter=2 (cached), got %d", grp.counter.Load()) + } +} + +func TestWithCache_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + grp := &cacheCounterGroup{} + e, _ := api.New( + api.WithRequestID(), + api.WithCache(5*time.Second), + ) + e.Register(grp) + + h := e.Handler() + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // RequestID middleware should still set X-Request-ID. + rid := w.Header().Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } + + // Body should contain the expected response. + body := w.Body.String() + if !strings.Contains(body, "call-1") { + t.Fatalf("expected body to contain %q, got %q", "call-1", body) + } +} + +func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) { + gin.SetMode(gin.TestMode) + grp := &cacheCounterGroup{} + e, _ := api.New(api.WithCache(50 * time.Millisecond)) + e.Register(grp) + + h := e.Handler() + + // First request — populates cache. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w1, req1) + + body1 := w1.Body.String() + if !strings.Contains(body1, "call-1") { + t.Fatalf("expected body to contain %q, got %q", "call-1", body1) + } + + // Wait for cache to expire. + time.Sleep(100 * time.Millisecond) + + // Second request — cache expired, handler called again. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil) + h.ServeHTTP(w2, req2) + + body2 := w2.Body.String() + if !strings.Contains(body2, "call-2") { + t.Fatalf("expected body to contain %q after expiry, got %q", "call-2", body2) + } + + // Counter should be 2 — both requests hit the handler. + if grp.counter.Load() != 2 { + t.Fatalf("expected counter=2, got %d", grp.counter.Load()) + } +} diff --git a/cmd/api/cmd.go b/cmd/api/cmd.go new file mode 100644 index 0000000..e0fb419 --- /dev/null +++ b/cmd/api/cmd.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import "forge.lthn.ai/core/cli/pkg/cli" + +func init() { + cli.RegisterCommands(AddAPICommands) +} + +// AddAPICommands registers the 'api' command group. +func AddAPICommands(root *cli.Command) { + apiCmd := cli.NewGroup("api", "API specification and SDK generation", "") + root.AddCommand(apiCmd) + + addSpecCommand(apiCmd) + addSDKCommand(apiCmd) +} diff --git a/cmd/api/cmd_sdk.go b/cmd/api/cmd_sdk.go new file mode 100644 index 0000000..6d23b84 --- /dev/null +++ b/cmd/api/cmd_sdk.go @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "context" + "fmt" + "os" + "strings" + + "forge.lthn.ai/core/cli/pkg/cli" + + goapi "forge.lthn.ai/core/api" +) + +func addSDKCommand(parent *cli.Command) { + var ( + lang string + output string + specFile string + packageName string + ) + + cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error { + if lang == "" { + return fmt.Errorf("--lang is required. Supported: %s", strings.Join(goapi.SupportedLanguages(), ", ")) + } + + // If no spec file provided, generate one to a temp file. + if specFile == "" { + builder := &goapi.SpecBuilder{ + Title: "Lethean Core API", + Description: "Lethean Core API", + Version: "1.0.0", + } + + bridge := goapi.NewToolBridge("/tools") + groups := []goapi.RouteGroup{bridge} + + tmpFile, err := os.CreateTemp("", "openapi-*.json") + if err != nil { + return fmt.Errorf("create temp spec file: %w", err) + } + defer os.Remove(tmpFile.Name()) + + if err := goapi.ExportSpec(tmpFile, "json", builder, groups); err != nil { + tmpFile.Close() + return fmt.Errorf("generate spec: %w", err) + } + tmpFile.Close() + specFile = tmpFile.Name() + } + + gen := &goapi.SDKGenerator{ + SpecPath: specFile, + OutputDir: output, + PackageName: packageName, + } + + if !gen.Available() { + fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:") + fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)") + fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g") + return fmt.Errorf("openapi-generator-cli not installed") + } + + // Generate for each language. + for l := range strings.SplitSeq(lang, ",") { + l = strings.TrimSpace(l) + if l == "" { + continue + } + fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l) + if err := gen.Generate(context.Background(), l); err != nil { + return fmt.Errorf("generate %s: %w", l, err) + } + fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l) + } + + return nil + }) + + cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)") + cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs") + cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to existing OpenAPI spec (generates from MCP tools if not provided)") + cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK") + + parent.AddCommand(cmd) +} diff --git a/cmd/api/cmd_spec.go b/cmd/api/cmd_spec.go new file mode 100644 index 0000000..99639d0 --- /dev/null +++ b/cmd/api/cmd_spec.go @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "fmt" + "os" + + "forge.lthn.ai/core/cli/pkg/cli" + + goapi "forge.lthn.ai/core/api" +) + +func addSpecCommand(parent *cli.Command) { + var ( + output string + format string + title string + version string + ) + + cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error { + // Build spec from registered route groups. + // Additional groups can be added here as the platform grows. + builder := &goapi.SpecBuilder{ + Title: title, + Description: "Lethean Core API", + Version: version, + } + + // Start with the default tool bridge — future versions will + // auto-populate from the MCP tool registry once the bridge + // integration lands in the local go-ai module. + bridge := goapi.NewToolBridge("/tools") + groups := []goapi.RouteGroup{bridge} + + if output != "" { + if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Spec written to %s\n", output) + return nil + } + + return goapi.ExportSpec(os.Stdout, format, builder, groups) + }) + + cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout") + cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml") + cli.StringFlag(cmd, &title, "title", "t", "Lethean Core API", "API title in spec") + cli.StringFlag(cmd, &version, "version", "V", "1.0.0", "API version in spec") + + parent.AddCommand(cmd) +} diff --git a/cmd/api/cmd_test.go b/cmd/api/cmd_test.go new file mode 100644 index 0000000..b24c723 --- /dev/null +++ b/cmd/api/cmd_test.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "bytes" + "testing" + + "forge.lthn.ai/core/cli/pkg/cli" +) + +func TestAPISpecCmd_Good_CommandStructure(t *testing.T) { + root := &cli.Command{Use: "root"} + AddAPICommands(root) + + apiCmd, _, err := root.Find([]string{"api"}) + if err != nil { + t.Fatalf("api command not found: %v", err) + } + + specCmd, _, err := apiCmd.Find([]string{"spec"}) + if err != nil { + t.Fatalf("spec subcommand not found: %v", err) + } + if specCmd.Use != "spec" { + t.Fatalf("expected Use=spec, got %s", specCmd.Use) + } +} + +func TestAPISpecCmd_Good_JSON(t *testing.T) { + root := &cli.Command{Use: "root"} + AddAPICommands(root) + + apiCmd, _, err := root.Find([]string{"api"}) + if err != nil { + t.Fatalf("api command not found: %v", err) + } + + specCmd, _, err := apiCmd.Find([]string{"spec"}) + if err != nil { + t.Fatalf("spec subcommand not found: %v", err) + } + + // Verify flags exist + if specCmd.Flag("format") == nil { + t.Fatal("expected --format flag on spec command") + } + if specCmd.Flag("output") == nil { + t.Fatal("expected --output flag on spec command") + } + if specCmd.Flag("title") == nil { + t.Fatal("expected --title flag on spec command") + } + if specCmd.Flag("version") == nil { + t.Fatal("expected --version flag on spec command") + } +} + +func TestAPISDKCmd_Bad_NoLang(t *testing.T) { + root := &cli.Command{Use: "root"} + AddAPICommands(root) + + root.SetArgs([]string{"api", "sdk"}) + buf := new(bytes.Buffer) + root.SetOut(buf) + root.SetErr(buf) + + err := root.Execute() + if err == nil { + t.Fatal("expected error when --lang not provided") + } +} + +func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) { + root := &cli.Command{Use: "root"} + AddAPICommands(root) + + apiCmd, _, err := root.Find([]string{"api"}) + if err != nil { + t.Fatalf("api command not found: %v", err) + } + + sdkCmd, _, err := apiCmd.Find([]string{"sdk"}) + if err != nil { + t.Fatalf("sdk subcommand not found: %v", err) + } + + // Verify flags exist + if sdkCmd.Flag("lang") == nil { + t.Fatal("expected --lang flag on sdk command") + } + if sdkCmd.Flag("output") == nil { + t.Fatal("expected --output flag on sdk command") + } + if sdkCmd.Flag("spec") == nil { + t.Fatal("expected --spec flag on sdk command") + } + if sdkCmd.Flag("package") == nil { + t.Fatal("expected --package flag on sdk command") + } +} diff --git a/codegen.go b/codegen.go new file mode 100644 index 0000000..c3d25fe --- /dev/null +++ b/codegen.go @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "context" + "fmt" + "iter" + "maps" + "os" + "os/exec" + "path/filepath" + "slices" +) + +// Supported SDK target languages. +var supportedLanguages = map[string]string{ + "go": "go", + "typescript-fetch": "typescript-fetch", + "typescript-axios": "typescript-axios", + "python": "python", + "java": "java", + "csharp": "csharp-netcore", + "ruby": "ruby", + "swift": "swift5", + "kotlin": "kotlin", + "rust": "rust", + "php": "php", +} + +// SDKGenerator wraps openapi-generator-cli for SDK generation. +type SDKGenerator struct { + // SpecPath is the path to the OpenAPI spec file (JSON or YAML). + SpecPath string + + // OutputDir is the base directory for generated SDK output. + OutputDir string + + // PackageName is the name used for the generated package/module. + PackageName string +} + +// Generate creates an SDK for the given language using openapi-generator-cli. +// The language must be one of the supported languages returned by SupportedLanguages(). +func (g *SDKGenerator) Generate(ctx context.Context, language string) error { + generator, ok := supportedLanguages[language] + if !ok { + return fmt.Errorf("unsupported language %q: supported languages are %v", language, SupportedLanguages()) + } + + if _, err := os.Stat(g.SpecPath); os.IsNotExist(err) { + return fmt.Errorf("spec file not found: %s", g.SpecPath) + } + + outputDir := filepath.Join(g.OutputDir, language) + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + + args := g.buildArgs(generator, outputDir) + cmd := exec.CommandContext(ctx, "openapi-generator-cli", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("openapi-generator-cli failed for %s: %w", language, err) + } + + return nil +} + +// buildArgs constructs the openapi-generator-cli command arguments. +func (g *SDKGenerator) buildArgs(generator, outputDir string) []string { + args := []string{ + "generate", + "-i", g.SpecPath, + "-g", generator, + "-o", outputDir, + } + if g.PackageName != "" { + args = append(args, "--additional-properties", "packageName="+g.PackageName) + } + return args +} + +// Available checks if openapi-generator-cli is installed and accessible. +func (g *SDKGenerator) Available() bool { + _, err := exec.LookPath("openapi-generator-cli") + return err == nil +} + +// SupportedLanguages returns the list of supported SDK target languages +// in sorted order for deterministic output. +func SupportedLanguages() []string { + return slices.Sorted(maps.Keys(supportedLanguages)) +} + +// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order. +func SupportedLanguagesIter() iter.Seq[string] { + return slices.Values(SupportedLanguages()) +} diff --git a/codegen_test.go b/codegen_test.go new file mode 100644 index 0000000..a9b7764 --- /dev/null +++ b/codegen_test.go @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + api "forge.lthn.ai/core/api" +) + +// ── SDKGenerator tests ───────────────────────────────────────────────────── + +func TestSDKGenerator_Good_SupportedLanguages(t *testing.T) { + langs := api.SupportedLanguages() + if len(langs) == 0 { + t.Fatal("expected at least one supported language") + } + + expected := []string{"go", "typescript-fetch", "python", "java", "csharp"} + for _, lang := range expected { + if !slices.Contains(langs, lang) { + t.Errorf("expected %q in supported languages, got %v", lang, langs) + } + } +} + +func TestSDKGenerator_Bad_UnsupportedLanguage(t *testing.T) { + gen := &api.SDKGenerator{ + SpecPath: "spec.json", + OutputDir: t.TempDir(), + } + + err := gen.Generate(context.Background(), "brainfuck") + if err == nil { + t.Fatal("expected error for unsupported language, got nil") + } + if !strings.Contains(err.Error(), "unsupported language") { + t.Fatalf("expected error to contain 'unsupported language', got: %v", err) + } +} + +func TestSDKGenerator_Bad_MissingSpec(t *testing.T) { + gen := &api.SDKGenerator{ + SpecPath: filepath.Join(t.TempDir(), "nonexistent.json"), + OutputDir: t.TempDir(), + } + + err := gen.Generate(context.Background(), "go") + if err == nil { + t.Fatal("expected error for missing spec file, got nil") + } + if !strings.Contains(err.Error(), "spec file not found") { + t.Fatalf("expected error to contain 'spec file not found', got: %v", err) + } +} + +func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) { + // Write a minimal spec file so we pass the file-exists check. + specDir := t.TempDir() + specPath := filepath.Join(specDir, "spec.json") + if err := os.WriteFile(specPath, []byte(`{"openapi":"3.1.0"}`), 0o644); err != nil { + t.Fatalf("failed to write spec file: %v", err) + } + + outputDir := filepath.Join(t.TempDir(), "nested", "sdk") + gen := &api.SDKGenerator{ + SpecPath: specPath, + OutputDir: outputDir, + } + + // Generate will fail at the exec step (openapi-generator-cli likely not installed), + // but the output directory should have been created before that. + _ = gen.Generate(context.Background(), "go") + + expected := filepath.Join(outputDir, "go") + info, err := os.Stat(expected) + if err != nil { + t.Fatalf("expected output directory %s to exist, got error: %v", expected, err) + } + if !info.IsDir() { + t.Fatalf("expected %s to be a directory", expected) + } +} + +func TestSDKGenerator_Good_Available(t *testing.T) { + gen := &api.SDKGenerator{} + // Just verify it returns a bool and does not panic. + _ = gen.Available() +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..93fdd64 --- /dev/null +++ b/composer.json @@ -0,0 +1,39 @@ +{ + "name": "lthn/api", + "description": "REST API module — Laravel API layer + standalone Go binary", + "keywords": ["api", "rest", "laravel", "openapi"], + "license": "EUPL-1.2", + "require": { + "php": "^8.2", + "lthn/php": "*", + "symfony/yaml": "^7.0" + }, + "autoload": { + "psr-4": { + "Core\\Api\\": "src/php/src/Api/", + "Core\\Front\\Api\\": "src/php/src/Front/Api/", + "Core\\Website\\Api\\": "src/php/src/Website/Api/" + } + }, + "autoload-dev": { + "psr-4": { + "Core\\Api\\Tests\\": "src/php/tests/" + } + }, + "extra": { + "laravel": { + "providers": [ + "Core\\Front\\Api\\Boot" + ] + } + }, + "config": { + "sort-packages": true + }, + "minimum-stability": "dev", + "prefer-stable": true, + "replace": { + "core/php-api": "self.version", + "lthn/php-api": "self.version" + } +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..db1deb4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,617 @@ +--- +title: Architecture +description: Internals of the go-api REST framework -- Engine, RouteGroup, middleware composition, response envelope, authentication, real-time transports, OpenAPI generation, and SDK codegen. +--- + + + +# Architecture + +This document explains how go-api works internally. It covers every major subsystem, the key +types, and the data flow from incoming HTTP request to outgoing JSON response. + +--- + +## 1. Engine + +### 1.1 The Engine struct + +`Engine` is the central container. It holds the listen address, the ordered list of registered +route groups, the middleware chain, and all optional integrations: + +```go +type Engine struct { + addr string + groups []RouteGroup + middlewares []gin.HandlerFunc + wsHandler http.Handler + sseBroker *SSEBroker + swaggerEnabled bool + swaggerTitle string + swaggerDesc string + swaggerVersion string + pprofEnabled bool + expvarEnabled bool + graphql *graphqlConfig +} +``` + +All fields are private. Configuration happens exclusively through `Option` functions passed +to `New()`. + +### 1.2 Construction + +`New()` applies functional options and returns a configured engine. The default listen address +is `:8080`. No middleware is added automatically beyond Gin's built-in panic recovery; every +feature requires an explicit `With*()` option: + +```go +engine, err := api.New( + api.WithAddr(":9000"), + api.WithBearerAuth("secret"), + api.WithCORS("*"), + api.WithRequestID(), + api.WithSlog(nil), + api.WithSwagger("My API", "Description", "1.0.0"), +) +``` + +After construction, call `engine.Register(group)` to add route groups, then either +`engine.Serve(ctx)` to start an HTTP server or `engine.Handler()` to obtain an `http.Handler` +for use with `httptest` or an external server. + +### 1.3 Build sequence + +`Engine.build()` is called internally by `Handler()` and `Serve()`. It assembles a fresh +`*gin.Engine` each time. The order is fixed: + +1. `gin.Recovery()` -- panic recovery (always first). +2. User middleware in registration order -- all `With*()` options that append to `e.middlewares`. +3. Built-in `GET /health` endpoint -- always present, returns `{"success":true,"data":"healthy"}`. +4. Route groups -- each mounted at its `BasePath()`. +5. WebSocket handler at `GET /ws` -- when `WithWSHandler()` was called. +6. SSE broker at `GET /events` -- when `WithSSE()` was called. +7. GraphQL endpoint -- when `WithGraphQL()` was called. +8. Swagger UI at `GET /swagger/*any` -- when `WithSwagger()` was called. +9. pprof endpoints at `GET /debug/pprof/*` -- when `WithPprof()` was called. +10. expvar endpoint at `GET /debug/vars` -- when `WithExpvar()` was called. + +### 1.4 Graceful shutdown + +`Serve()` starts an `http.Server` in a goroutine and blocks on `ctx.Done()`. When the context +is cancelled, a 10-second shutdown deadline is applied. In-flight requests complete or time out +before the server exits. Any listen error that occurred before shutdown is returned to the +caller. + +### 1.5 Iterators + +`Engine` provides iterator methods following Go 1.23+ conventions: + +- `GroupsIter()` returns `iter.Seq[RouteGroup]` over all registered groups. +- `ChannelsIter()` returns `iter.Seq[string]` over WebSocket channel names from groups that + implement `StreamGroup`. + +--- + +## 2. RouteGroup, StreamGroup, and DescribableGroup + +Three interfaces form the extension point model: + +```go +// RouteGroup is the minimum interface. All subsystems implement this. +type RouteGroup interface { + Name() string + BasePath() string + RegisterRoutes(rg *gin.RouterGroup) +} + +// StreamGroup is optionally implemented by groups that publish WebSocket channels. +type StreamGroup interface { + Channels() []string +} + +// DescribableGroup extends RouteGroup with OpenAPI metadata. +// Groups implementing this have their endpoints included in the generated spec. +type DescribableGroup interface { + RouteGroup + Describe() []RouteDescription +} +``` + +`RouteDescription` carries the HTTP method, path (relative to `BasePath()`), summary, +description, tags, and JSON Schema maps for the request body and response data: + +```go +type RouteDescription struct { + Method string + Path string + Summary string + Description string + Tags []string + RequestBody map[string]any + Response map[string]any +} +``` + +`Engine.Channels()` iterates all registered groups and collects channel names from those that +implement `StreamGroup`. This list is used when initialising a WebSocket hub. + +--- + +## 3. Middleware Stack + +All middleware options append to `Engine.middlewares` in the order they are passed to `New()`. +They execute after `gin.Recovery()` but before any route handler. The `Option` type is simply +`func(*Engine)`. + +### Complete option reference + +| Option | Purpose | Key detail | +|--------|---------|-----------| +| `WithAddr(addr)` | Listen address | Default `:8080` | +| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` | +| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise | +| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` | +| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware | +| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled | +| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` | +| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects | +| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` | +| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication | +| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication | +| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer | +| `WithGzip(level...)` | Gzip response compression | Default compression if level omitted | +| `WithBrotli(level...)` | Brotli response compression | Writer pool for efficiency; default compression if level omitted | +| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil | +| `WithTimeout(d)` | Per-request deadline | 504 with standard error envelope on timeout | +| `WithCache(ttl)` | In-memory GET response caching | `X-Cache: HIT` header on cache hits; 2xx only | +| `WithSessions(name, secret)` | Cookie-backed server sessions | gin-contrib/sessions with cookie store | +| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth; 403 on deny | +| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures; 401/400 on failure | +| `WithSSE(broker)` | Server-Sent Events at `/events` | `?channel=` query parameter filtering | +| `WithLocation()` | Reverse proxy header detection | X-Forwarded-Proto / X-Forwarded-Host | +| `WithI18n(cfg...)` | Accept-Language locale detection | BCP 47 matching via `golang.org/x/text/language` | +| `WithTracing(name, opts...)` | OpenTelemetry distributed tracing | otelgin + W3C `traceparent` header propagation | +| `WithGraphQL(schema, opts...)` | GraphQL endpoint | gqlgen `ExecutableSchema`; optional playground UI | + +### Bearer authentication flow + +`bearerAuthMiddleware` validates the `Authorization: Bearer ` header. Requests to paths +in the skip list (`/health`, `/swagger`) pass through without authentication. Missing or +invalid tokens produce a `401 Unauthorised` response using the standard error envelope. + +### Request ID flow + +`requestIDMiddleware` checks for an incoming `X-Request-ID` header. If present, the value is +preserved. Otherwise, a cryptographically random 16-byte hex string is generated. The ID is +stored in the Gin context under the key `"request_id"` and set as an `X-Request-ID` response +header. + +--- + +## 4. Response Envelope + +All API responses use a single generic envelope: + +```go +type Response[T any] struct { + Success bool `json:"success"` + Data T `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} +``` + +Supporting types: + +```go +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +type Meta struct { + RequestID string `json:"request_id,omitempty"` + Duration string `json:"duration,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + Total int `json:"total,omitempty"` +} +``` + +### Constructor helpers + +| Helper | Produces | +|--------|----------| +| `OK(data)` | `{"success":true,"data":...}` | +| `Fail(code, message)` | `{"success":false,"error":{"code":"...","message":"..."}}` | +| `FailWithDetails(code, message, details)` | Same as `Fail` with an additional `details` field | +| `Paginated(data, page, perPage, total)` | `{"success":true,"data":...,"meta":{"page":...,"per_page":...,"total":...}}` | + +All handlers should use these helpers rather than constructing `Response[T]` manually. This +guarantees a consistent envelope across every route group. + +--- + +## 5. Authentik Integration + +The `WithAuthentik()` option installs a permissive identity middleware that runs on every +non-public request. It has two extraction paths: + +### Path 1 -- Forward-auth headers (TrustedProxy: true) + +When a reverse proxy (e.g. Traefik) is configured with Authentik forward-auth, it injects +headers: `X-authentik-username`, `X-authentik-email`, `X-authentik-name`, `X-authentik-uid`, +`X-authentik-groups` (pipe-separated), `X-authentik-entitlements` (pipe-separated), and +`X-authentik-jwt`. The middleware reads these and populates an `AuthentikUser` in the Gin +context. + +### Path 2 -- OIDC JWT validation + +For direct API clients that present a `Bearer` token, the middleware validates the JWT against +the configured OIDC issuer and client ID. Providers are cached by issuer URL to avoid repeated +discovery requests. + +### Fail-open behaviour + +In both paths, if extraction fails the request continues unauthenticated. The middleware never +rejects requests. Handlers check identity with: + +```go +user := api.GetUser(c) // returns nil when unauthenticated +``` + +### Route guards + +For protected routes, apply guards as Gin middleware on individual routes: + +```go +rg.GET("/private", api.RequireAuth(), handler) // 401 if no user +rg.GET("/admin", api.RequireGroup("admins"), handler) // 403 if wrong group +``` + +`RequireAuth()` returns 401 when `GetUser(c)` is nil. `RequireGroup(group)` returns 401 when +no user is present, or 403 when the user lacks the specified group membership. + +### AuthentikUser type + +```go +type AuthentikUser struct { + Username string `json:"username"` + Email string `json:"email"` + Name string `json:"name"` + UID string `json:"uid"` + Groups []string `json:"groups,omitempty"` + Entitlements []string `json:"entitlements,omitempty"` + JWT string `json:"-"` +} +``` + +The `HasGroup(group string) bool` method provides a convenience check for group membership. + +### Configuration + +```go +type AuthentikConfig struct { + Issuer string // OIDC issuer URL + ClientID string // OAuth2 client identifier + TrustedProxy bool // Whether to read X-authentik-* headers + PublicPaths []string // Additional paths exempt from header extraction +} +``` + +`/health` and `/swagger` are always public. Additional paths may be specified via +`PublicPaths`. + +--- + +## 6. WebSocket and Server-Sent Events + +### WebSocket + +`WithWSHandler(h)` mounts any `http.Handler` at `GET /ws`. The handler is responsible for +upgrading the connection. The intended pairing is a WebSocket hub (e.g. from go-ws): + +```go +hub := ws.NewHub() +go hub.Run(ctx) +engine, _ := api.New(api.WithWSHandler(hub.Handler())) +``` + +Groups implementing `StreamGroup` declare channel names, which `Engine.Channels()` aggregates +into a single slice. + +### Server-Sent Events + +`SSEBroker` manages persistent SSE connections at `GET /events`. Clients optionally subscribe +to a named channel via the `?channel=` query parameter. Clients without a channel +parameter receive events on all channels. + +```go +broker := api.NewSSEBroker() +engine, _ := api.New(api.WithSSE(broker)) + +// Publish from anywhere: +broker.Publish("deployments", "deploy.started", payload) +``` + +Key implementation details: + +- Each client has a 64-event buffered channel. Overflow events are dropped without blocking + the publisher. +- `SSEBroker.ClientCount()` returns the number of currently connected clients. +- `SSEBroker.Drain()` signals all clients to disconnect, useful during graceful shutdown. +- The response is streamed with `Content-Type: text/event-stream`, `Cache-Control: no-cache`, + `Connection: keep-alive`, and `X-Accel-Buffering: no` headers. +- Data payloads are JSON-encoded before being written as SSE `data:` fields. + +--- + +## 7. GraphQL + +`WithGraphQL()` mounts a gqlgen `ExecutableSchema` at `/graphql` (or a custom path via +`WithGraphQLPath()`). An optional `WithPlayground()` adds the interactive GraphQL Playground +at `{path}/playground`. + +```go +engine, _ := api.New( + api.WithGraphQL(schema, + api.WithPlayground(), + api.WithGraphQLPath("/gql"), + ), +) +``` + +The endpoint accepts all HTTP methods (POST for queries and mutations, GET for playground +redirects and introspection). The GraphQL handler is created via gqlgen's +`handler.NewDefaultServer()`. + +--- + +## 8. Response Caching + +`WithCache(ttl)` installs a URL-keyed in-memory response cache scoped to GET requests: + +- Only successful 2xx responses are cached. +- Non-GET methods pass through uncached. +- Cached responses are served with an `X-Cache: HIT` header. +- Expired entries are evicted lazily on the next access for the same key. +- The cache is not shared across `Engine` instances. +- There is no size limit on the cache. + +The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and +capture the response body and status code for storage. + +--- + +## 9. Brotli Compression + +`WithBrotli(level...)` adds Brotli response compression. The middleware checks the +`Accept-Encoding` header for `br` support before compressing. + +Key implementation details: + +- A `sync.Pool` of `brotli.Writer` instances is used to avoid allocation per request. +- Error responses (4xx and above) bypass compression and are sent uncompressed. +- Three compression level constants are exported: `BrotliBestSpeed`, `BrotliBestCompression`, + and `BrotliDefaultCompression`. + +--- + +## 10. Internationalisation + +`WithI18n(cfg)` parses the `Accept-Language` header on every request and stores the resolved +BCP 47 locale tag in the Gin context. The `golang.org/x/text/language` matcher handles +quality-weighted negotiation and script/region subtag matching. + +```go +engine, _ := api.New( + api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: []string{"en", "fr", "de"}, + Messages: map[string]map[string]string{ + "en": {"greeting": "Hello"}, + "fr": {"greeting": "Bonjour"}, + }, + }), +) +``` + +Handlers retrieve the locale and optional localised messages: + +```go +locale := api.GetLocale(c) // e.g. "en", "fr" +msg, ok := api.GetMessage(c, "greeting") // from configured Messages map +``` + +The built-in message map is a lightweight bridge. The `go-i18n` grammar engine is the intended +replacement for production-grade localisation. + +--- + +## 11. OpenTelemetry Tracing + +`WithTracing(serviceName)` adds otelgin middleware that creates a span for each request, tagged +with HTTP method, route template, and response status code. Trace context is propagated via the +W3C `traceparent` header. + +`NewTracerProvider(exporter)` is a convenience helper for tests and simple deployments that +constructs a synchronous `TracerProvider` and installs it globally: + +```go +tp := api.NewTracerProvider(exporter) +defer tp.Shutdown(ctx) + +engine, _ := api.New(api.WithTracing("my-service")) +``` + +Production deployments should build a batching provider with appropriate resource attributes +and span processors. + +--- + +## 12. OpenAPI Specification Generation + +### SpecBuilder + +`SpecBuilder` generates an OpenAPI 3.1 JSON document from registered route groups: + +```go +builder := &api.SpecBuilder{ + Title: "My API", + Description: "Service description", + Version: "1.0.0", +} +data, err := builder.Build(engine.Groups()) +``` + +The built document includes: + +- The `GET /health` endpoint under the `system` tag. +- One path entry per `RouteDescription` returned by `DescribableGroup.Describe()`. +- `#/components/schemas/Error` and `#/components/schemas/Meta` shared schemas. +- All response bodies wrapped in the `Response[T]` envelope schema. +- Tags derived from every registered group's `Name()`. + +Groups that implement `RouteGroup` but not `DescribableGroup` contribute a tag but no paths. + +### Export + +Two convenience functions write the spec to an `io.Writer` or directly to a file: + +```go +// Write JSON or YAML to a writer: +api.ExportSpec(os.Stdout, "yaml", builder, engine.Groups()) + +// Write to a file (parent directories created automatically): +api.ExportSpecToFile("./api/openapi.yaml", "yaml", builder, engine.Groups()) +``` + +### Swagger UI + +When `WithSwagger()` is active, the spec is built lazily on first access by a `swaggerSpec` +wrapper that satisfies the `swag.Spec` interface. It is registered in the global `swag` registry +with a unique sequence-based instance name (via `atomic.Uint64`), so multiple `Engine` instances +in the same process do not collide. + +--- + +## 13. ToolBridge + +`ToolBridge` converts tool descriptors into REST endpoints and OpenAPI paths. It implements both +`RouteGroup` and `DescribableGroup`. This is the primary mechanism for exposing MCP tool +descriptors as a REST API. + +```go +bridge := api.NewToolBridge("/v1/tools") +bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file", + Group: "files", + InputSchema: map[string]any{"type": "object", "properties": ...}, +}, fileReadHandler) + +engine.Register(bridge) +``` + +Each registered tool becomes a `POST /v1/tools/{tool_name}` endpoint. The bridge provides: + +- `Tools()` / `ToolsIter()` -- enumerate registered tool descriptors. +- `Describe()` / `DescribeIter()` -- generate `RouteDescription` entries for OpenAPI. + +`ToolDescriptor` carries: + +```go +type ToolDescriptor struct { + Name string // Tool name (becomes POST path segment) + Description string // Human-readable description + Group string // OpenAPI tag group + InputSchema map[string]any // JSON Schema for request body + OutputSchema map[string]any // JSON Schema for response data (optional) +} +``` + +--- + +## 14. SDK Codegen + +`SDKGenerator` wraps `openapi-generator-cli` to generate client SDKs from an exported OpenAPI +spec: + +```go +gen := &api.SDKGenerator{ + SpecPath: "./api/openapi.yaml", + OutputDir: "./sdk", + PackageName: "myapi", +} +if gen.Available() { + _ = gen.Generate(ctx, "typescript-fetch") + _ = gen.Generate(ctx, "python") +} +``` + +Supported target languages (11 total): `csharp`, `go`, `java`, `kotlin`, `php`, `python`, +`ruby`, `rust`, `swift`, `typescript-axios`, `typescript-fetch`. + +- `SupportedLanguages()` returns the full list in sorted order. +- `SupportedLanguagesIter()` returns an `iter.Seq[string]` over the same list. +- `SDKGenerator.Available()` checks whether `openapi-generator-cli` is on `PATH`. + +--- + +## 15. CLI Subcommands + +The `cmd/api/` package registers two CLI subcommands under the `core api` namespace: + +### `core api spec` + +Generates an OpenAPI 3.1 specification from registered route groups. + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--output` | `-o` | (stdout) | Write spec to file | +| `--format` | `-f` | `json` | Output format: `json` or `yaml` | +| `--title` | `-t` | `Lethean Core API` | API title | +| `--version` | `-V` | `1.0.0` | API version | + +### `core api sdk` + +Generates client SDKs from an OpenAPI spec using `openapi-generator-cli`. + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--lang` | `-l` | (required) | Target language(s), comma-separated | +| `--output` | `-o` | `./sdk` | Output directory | +| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec | +| `--package` | `-p` | `lethean` | Package name for generated SDK | + +--- + +## 16. Data Flow Summary + +``` +HTTP Request + | + v +gin.Recovery() -- panic recovery + | + v +User middleware chain -- WithBearerAuth, WithCORS, WithRequestID, WithAuthentik, etc. + | (in registration order) + v +Route matching -- /health (built-in) or BasePath() + route from RouteGroup + | + v +Handler function -- uses api.OK(), api.Fail(), api.Paginated() + | + v +Response[T] envelope -- {"success": bool, "data": T, "error": Error, "meta": Meta} + | + v +HTTP Response +``` + +Real-time transports (WebSocket at `/ws`, SSE at `/events`) and development endpoints +(Swagger at `/swagger/`, pprof at `/debug/pprof/`, expvar at `/debug/vars`) are mounted +alongside the route groups during the build phase. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..27eeb51 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,451 @@ +--- +title: Development Guide +description: How to build, test, and contribute to the go-api REST framework -- prerequisites, test patterns, extension guides, and coding standards. +--- + + + +# Development Guide + +This guide covers everything needed to build, test, extend, and contribute to go-api. + +**Module path:** `forge.lthn.ai/core/go-api` +**Licence:** EUPL-1.2 +**Language:** Go 1.26 + +--- + +## Table of Contents + +1. [Prerequisites](#prerequisites) +2. [Building](#building) +3. [Testing](#testing) +4. [Test Patterns](#test-patterns) +5. [Adding a New With*() Option](#adding-a-new-with-option) +6. [Adding a RouteGroup](#adding-a-routegroup) +7. [Adding a DescribableGroup](#adding-a-describablegroup) +8. [Coding Standards](#coding-standards) +9. [Commit Guidelines](#commit-guidelines) + +--- + +## Prerequisites + +### Go toolchain + +Go 1.26 or later is required. Verify the installed version: + +```bash +go version +``` + +### Minimal dependencies + +go-api has no sibling `forge.lthn.ai/core/*` dependencies at the library level (the `cmd/api/` +subcommands import `core/cli`, but the main package compiles independently). There are no +`replace` directives. Cloning go-api alone is sufficient to build and test the library. + +If working within the Go workspace at `~/Code/go.work`, the workspace `use` directive handles +local module resolution automatically. + +--- + +## Building + +go-api is a library module with no `main` package. Build all packages to verify that everything +compiles cleanly: + +```bash +go build ./... +``` + +Vet for suspicious constructs: + +```bash +go vet ./... +``` + +Neither command produces a binary. If you need a runnable server for manual testing, create a +temporary `main.go` that imports go-api and calls `engine.Serve()`. + +--- + +## Testing + +### Run all tests + +```bash +go test ./... +``` + +### Run a single test by name + +```bash +go test -run TestName ./... +``` + +The `-run` flag accepts a regular expression: + +```bash +go test -run TestToolBridge ./... +go test -run TestSpecBuilder_Good ./... +go test -run "Test.*_Bad" ./... +``` + +### Verbose output + +```bash +go test -v ./... +``` + +### Race detector + +Always run with `-race` before opening a pull request. The middleware layer uses concurrency +(SSE broker, cache store, Brotli writer pool), and the race detector catches data races +reliably: + +```bash +go test -race ./... +``` + +Note: The repository includes `race_test.go` and `norace_test.go` build-tag files that control +which tests run under the race detector. + +### Live Authentik integration tests + +`authentik_integration_test.go` contains tests that require a live Authentik instance. These +are skipped automatically unless the `AUTHENTIK_ISSUER` and `AUTHENTIK_CLIENT_ID` environment +variables are set. They do not run in standard CI. + +To run them locally: + +```bash +AUTHENTIK_ISSUER=https://auth.example.com/application/o/my-app/ \ +AUTHENTIK_CLIENT_ID=my-client-id \ +go test -run TestAuthentik_Integration ./... +``` + +--- + +## Test Patterns + +### Naming convention + +All test functions follow the `_Good`, `_Bad`, `_Ugly` suffix pattern: + +| Suffix | Purpose | +|---------|---------| +| `_Good` | Happy path -- the input is valid and the operation succeeds | +| `_Bad` | Expected error conditions -- invalid input, missing config, wrong state | +| `_Ugly` | Panics and extreme edge cases -- nil receivers, resource exhaustion, concurrent access | + +Examples from the codebase: + +``` +TestNew_Good +TestNew_Good_WithAddr +TestHandler_Bad_NotFound +TestSDKGenerator_Bad_UnsupportedLanguage +TestSpecBuilder_Good_SingleDescribableGroup +``` + +### Engine test helpers + +Tests that need a running HTTP server use `httptest.NewRecorder()` or `httptest.NewServer()`. +Build the engine handler directly rather than calling `Serve()`: + +```go +gin.SetMode(gin.TestMode) +engine, _ := api.New(api.WithBearerAuth("tok")) +engine.Register(&myGroup{}) +handler := engine.Handler() + +req := httptest.NewRequest(http.MethodGet, "/health", nil) +rec := httptest.NewRecorder() +handler.ServeHTTP(rec, req) + +if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) +} +``` + +### SSE tests + +SSE handler tests open a real HTTP connection with `httptest.NewServer()` and read the +`text/event-stream` response body line by line. Publish from a goroutine and use a deadline +to avoid hanging indefinitely: + +```go +srv := httptest.NewServer(engine.Handler()) +defer srv.Close() + +ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) +defer cancel() + +req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/events", nil) +resp, _ := http.DefaultClient.Do(req) +// read lines from resp.Body +``` + +### Cache tests + +Cache tests must use `httptest.NewServer()` rather than a recorder because the cache middleware +needs a proper response cycle to capture and replay responses. Verify the `X-Cache: HIT` header +on the second request to the same URL. + +### Authentik tests + +Authentik middleware tests use raw `httptest.NewRequest()` with `X-authentik-*` headers set +directly. No live Authentik instance is required for unit tests -- the permissive middleware is +exercised entirely through header injection and context assertions. + +--- + +## Adding a New With*() Option + +`With*()` options are the primary extension point. All options follow an identical pattern. + +### Step 1: Add the option function + +Open `options.go` and add a new exported function that returns an `Option`: + +```go +// WithRateLimit adds request rate limiting middleware. +// Requests exceeding limit per second per IP are rejected with 429 Too Many Requests. +func WithRateLimit(limit int) Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, rateLimitMiddleware(limit)) + } +} +``` + +If the option stores state on `Engine` (like `swaggerEnabled` or `sseBroker`), add the +corresponding field to the `Engine` struct in `api.go` and reference it in `build()`. + +### Step 2: Implement the middleware + +If the option wraps a `gin-contrib` package, follow the existing pattern in `options.go` +(inline). For options with non-trivial logic, create a dedicated file (e.g. `ratelimit.go`). +Every new source file must begin with the EUPL-1.2 SPDX identifier: + +```go +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import "github.com/gin-gonic/gin" + +func rateLimitMiddleware(limit int) gin.HandlerFunc { + // implementation +} +``` + +### Step 3: Add the dependency to go.mod + +If the option relies on a new external package: + +```bash +go get github.com/example/ratelimit +go mod tidy +``` + +### Step 4: Write tests + +Create a test file (e.g. `ratelimit_test.go`) following the `_Good`/`_Bad`/`_Ugly` naming +convention. Test with `httptest` rather than calling `Serve()`. + +### Step 5: Update the build path + +If the option adds a new built-in HTTP endpoint (like WebSocket at `/ws` or SSE at `/events`), +add it to the `build()` method in `api.go` after the GraphQL block but before Swagger. + +--- + +## Adding a RouteGroup + +`RouteGroup` is the standard way for subsystem packages to contribute REST endpoints. + +### Minimum implementation + +```go +// SPDX-License-Identifier: EUPL-1.2 + +package mypackage + +import ( + "net/http" + + api "forge.lthn.ai/core/go-api" + "github.com/gin-gonic/gin" +) + +type Routes struct { + service *Service +} + +func NewRoutes(svc *Service) *Routes { + return &Routes{service: svc} +} + +func (r *Routes) Name() string { return "mypackage" } +func (r *Routes) BasePath() string { return "/v1/mypackage" } + +func (r *Routes) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/items", r.listItems) + rg.POST("/items", r.createItem) +} + +func (r *Routes) listItems(c *gin.Context) { + items, err := r.service.List(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, api.Fail("internal", err.Error())) + return + } + c.JSON(http.StatusOK, api.OK(items)) +} +``` + +Register with the engine: + +```go +engine.Register(mypackage.NewRoutes(svc)) +``` + +### Adding StreamGroup + +If the group publishes WebSocket channels, implement `StreamGroup` as well: + +```go +func (r *Routes) Channels() []string { + return []string{"mypackage.items.updated"} +} +``` + +--- + +## Adding a DescribableGroup + +`DescribableGroup` extends `RouteGroup` with OpenAPI metadata. Implementing it ensures the +group's endpoints appear in the generated spec and Swagger UI. + +Add a `Describe()` method that returns a slice of `RouteDescription`: + +```go +func (r *Routes) Describe() []api.RouteDescription { + return []api.RouteDescription{ + { + Method: "GET", + Path: "/items", + Summary: "List items", + Tags: []string{"mypackage"}, + Response: map[string]any{ + "type": "array", + "items": map[string]any{"type": "object"}, + }, + }, + { + Method: "POST", + Path: "/items", + Summary: "Create an item", + Tags: []string{"mypackage"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + "required": []string{"name"}, + }, + Response: map[string]any{"type": "object"}, + }, + } +} +``` + +Paths in `RouteDescription` are relative to `BasePath()`. The `SpecBuilder` concatenates them +when building the full OpenAPI path. + +--- + +## Coding Standards + +### Language + +Use **UK English** in all comments, documentation, log messages, and user-facing strings: +`colour`, `organisation`, `centre`, `initialise`, `licence` (noun), `license` (verb), +`unauthorised`, `authorisation`. + +### Licence header + +Every new Go source file must carry the EUPL-1.2 SPDX identifier as the first line: + +```go +// SPDX-License-Identifier: EUPL-1.2 + +package api +``` + +### Error handling + +- Always return errors rather than panicking. +- Wrap errors with context: `fmt.Errorf("component.Operation: what failed: %w", err)`. +- Do not discard errors with `_` unless the operation is genuinely fire-and-forget and the + reason is documented with a comment. +- Log errors at the point of handling, not at the point of wrapping. + +### Response envelope + +All HTTP handlers must use the `api.OK()`, `api.Fail()`, `api.FailWithDetails()`, or +`api.Paginated()` helpers rather than constructing `Response[T]` directly. This ensures the +envelope structure is consistent across all route groups. + +### Test naming + +- Function names: `Test{Type}_{Suffix}_{Description}` where `{Suffix}` is `Good`, `Bad`, + or `Ugly`. +- Helper constructors: `newTest{Type}(t *testing.T, ...) *Type`. +- Always call `t.Helper()` at the top of every test helper function. + +### Formatting + +The codebase uses `gofmt` defaults. Run before committing: + +```bash +gofmt -l -w . +``` + +### Middleware conventions + +- Every `With*()` function must append to `e.middlewares`, not modify Gin routes directly. + Routes are only registered in `build()`. +- Options that require `Engine` struct fields (like `swaggerEnabled` or `sseBroker`) must be + readable by `build()`, not set inside a closure without a backing field. +- Middleware that exposes sensitive data (`WithPprof`, `WithExpvar`) must carry a `// WARNING:` + comment in the godoc directing users away from production exposure without authentication. + +--- + +## Commit Guidelines + +Use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +type(scope): description + +Body explaining what changed and why. + +Co-Authored-By: Virgil +``` + +Types in use across the repository: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `perf`. + +Example: + +``` +feat(api): add WithRateLimit per-IP rate limiting middleware + +Adds configurable per-IP rate limiting using a token-bucket algorithm. +Requests exceeding the limit per second are rejected with 429 Too Many +Requests and a standard Fail() error envelope. + +Co-Authored-By: Virgil +``` diff --git a/docs/history.md b/docs/history.md new file mode 100644 index 0000000..823e360 --- /dev/null +++ b/docs/history.md @@ -0,0 +1,219 @@ + + +# go-api — Project History and Known Limitations + +Module: `forge.lthn.ai/core/go-api` + +--- + +## Origins + +`go-api` was created as the dedicated HTTP framework for the Lethean Go ecosystem. The motivation +was to give every Go package in the stack a consistent way to expose REST endpoints without each +package taking its own opinion on routing, middleware, response formatting, or OpenAPI generation. +It was scaffolded independently from the start — it was never extracted from a monolith — and has +no `forge.lthn.ai/core/*` dependencies. This keeps it at the bottom of the import graph: every +other package can import go-api, but go-api imports nothing from the ecosystem. + +--- + +## Development Phases + +### Phase 1 — Core Engine (36 tests) + +Commits `889391a` through `22f8a69` + +The initial phase established the foundational abstractions that all subsequent work builds on. + +**Scaffold** (`889391a`): +Module path `forge.lthn.ai/core/go-api` created. `go.mod` initialised with Gin as the only +direct dependency. + +**Response envelope** (`7835837`): +`Response[T]`, `Error`, and `Meta` types defined. `OK()`, `Fail()`, and `Paginated()` helpers +added. The generic envelope was established from the start rather than retrofitted. + +**RouteGroup interface** (`6f5fb69`): +`RouteGroup` (Name, BasePath, RegisterRoutes) and `StreamGroup` (Channels) interfaces defined +in `group.go`. The interface-driven extension model was the core design decision of Phase 1. + +**Engine** (`db75c88`): +`Engine` struct added with `New()`, `Register()`, `Handler()`, `Serve()`, and graceful shutdown. +Default listen address `:8080`. Built-in `GET /health` endpoint. Panic recovery via `gin.Recovery()` +always applied first. + +**Bearer auth, request ID, CORS** (`d21734d`): +First three middleware options: `WithBearerAuth()`, `WithRequestID()`, `WithCORS()`. +The functional `Option` type and the `With*()` pattern were established here. + +**Swagger UI** (`22f8a69`): +`WithSwagger()` added. The initial implementation served a static Swagger UI backed by a placeholder +spec; this was later replaced in Phase 3. + +**WebSocket** (`22f8a69`): +`WithWSHandler()` added, mounting any `http.Handler` at `GET /ws`. `Engine.Channels()` added to +aggregate channel names from `StreamGroup` implementations. + +By the end of Phase 1, the module had 36 tests covering the engine lifecycle, health endpoint, +response helpers, bearer auth, request ID propagation, CORS, and WebSocket mounting. + +--- + +### Phase 2 — 21 Middleware Options (143 tests) + +Commits `d760e77` through `8ba1716` + +Phase 2 expanded the middleware library in four waves, reaching 21 `With*()` options total. + +**Wave 1 — Security and Identity** (`d760e77` through `8f3e496`): + +The Authentik integration was the most significant addition of this wave. + +- `WithAuthentik()` — permissive forward-auth middleware. Reads `X-authentik-*` headers when + `TrustedProxy: true`, validates JWTs via OIDC discovery when `Issuer` and `ClientID` are set. + Fail-open: unauthenticated requests are never rejected by this middleware alone. +- `RequireAuth()`, `RequireGroup()` — explicit guards for protected routes, returning 401 and 403 + respectively via the standard `Fail()` envelope. +- `GetUser()` — context accessor for the current `AuthentikUser`. +- `AuthentikUser` — carries Username, Email, Name, UID, Groups, Entitlements, and JWT. `HasGroup()` + convenience method added. +- Live integration tests added in `authentik_integration_test.go`, guarded by environment variables. +- `WithSecure()` — HSTS, X-Frame-Options DENY, X-Content-Type-Options nosniff, strict referrer + policy. SSL redirect deliberately omitted to work correctly behind a TLS-terminating proxy. + +**Wave 2 — Compression and Logging** (`6521b90` through `6bb7195`): + +- `WithTimeout(d)` — per-request deadline via gin-contrib/timeout. Returns 504 with the standard + Fail() envelope on expiry. +- `WithGzip(level...)` — gzip response compression; defaults to `gzip.DefaultCompression`. +- `WithBrotli(level...)` — Brotli compression via `andybalholm/brotli`. Custom `brotliHandler` + wrapping `brotli.HTTPCompressor`. +- `WithSlog(logger)` — structured request logging via gin-contrib/slog. Falls back to + `slog.Default()` when nil is passed. +- `WithStatic(prefix, root)` — static file serving via gin-contrib/static; directory listing + disabled. + +**Wave 3 — Auth, Caching, Streaming** (`0ab962a` through `7b3f99e`): + +- `WithCache(ttl)` — in-memory GET response cache. Custom `cacheWriter` intercepts the response + body without affecting the downstream handler. `X-Cache: HIT` on served cache entries. +- `WithSessions(name, secret)` — cookie-backed server sessions via gin-contrib/sessions. +- `WithAuthz(enforcer)` — Casbin policy-based authorisation via gin-contrib/authz. Subject from + HTTP Basic Auth. +- `WithHTTPSign(secrets, opts...)` — HTTP Signatures verification via gin-contrib/httpsign. +- `WithSSE(broker)` — Server-Sent Events at `GET /events`. `SSEBroker` added with `Publish()`, + channel filtering, 64-event per-client buffer, and `Drain()` for graceful shutdown. + +**Wave 4 — Infrastructure and Protocol** (`a612d85` through `8ba1716`): + +- `WithLocation()` — reverse proxy header detection via gin-contrib/location/v2. +- `WithI18n(cfg...)` — Accept-Language parsing and BCP 47 locale matching via + `golang.org/x/text/language`. `GetLocale()` and `GetMessage()` context accessors added. +- `WithGraphQL(schema, opts...)` — gqlgen `ExecutableSchema` mounting. `WithPlayground()` and + `WithGraphQLPath()` sub-options. Playground at `{path}/playground`. +- `WithPprof()` — Go runtime profiling at `/debug/pprof/`. +- `WithExpvar()` — expvar runtime metrics at `/debug/vars`. +- `WithTracing(name, opts...)` — OpenTelemetry distributed tracing via otelgin. `NewTracerProvider()` + convenience helper added. W3C `traceparent` propagation. + +At the end of Phase 2, the module had 143 tests. + +--- + +### Phase 3 — OpenAPI, ToolBridge, SDK Codegen (176 tests) + +Commits `465bd60` through `1910aec` + +Phase 3 upgraded the Swagger integration from a placeholder to a full runtime OpenAPI 3.1 pipeline +and added two new subsystems: `ToolBridge` and `SDKGenerator`. + +**DescribableGroup interface** (`465bd60`): +`DescribableGroup` added to `group.go`, extending `RouteGroup` with `Describe() []RouteDescription`. +`RouteDescription` carries HTTP method, path, summary, description, tags, and JSON Schema maps +for request body and response data. This was the contract that `SpecBuilder` and `ToolBridge` +would both consume. + +**ToolBridge** (`2b63c7b`): +`ToolBridge` added to `bridge.go`. Converts `ToolDescriptor` values into `POST /{tool_name}` +Gin routes and implements `DescribableGroup` so those routes appear in the OpenAPI spec. Designed +to bridge the MCP tool model (as used by go-ai) into the REST world. `Tools()` accessor added +for external enumeration. + +**SpecBuilder** (`3e96f9b`): +`SpecBuilder` added to `openapi.go`. Generates a complete OpenAPI 3.1 JSON document from registered +`RouteGroup` and `DescribableGroup` values. Includes the built-in `GET /health` endpoint, shared +`Error` and `Meta` component schemas, and the `Response[T]` envelope schema wrapping every response +body. Tags are derived from all group names, not just describable ones. + +**Spec export** (`e94283b`): +`ExportSpec()` and `ExportSpecToFile()` added to `export.go`. Supports `"json"` and `"yaml"` +output formats. YAML output is produced by unmarshalling the JSON then re-encoding with +`gopkg.in/yaml.v3` at 2-space indentation. Parent directories created automatically by +`ExportSpecToFile()`. + +**Swagger refactor** (`303779f`): +`registerSwagger()` in `swagger.go` rewritten to use `SpecBuilder` rather than the previous +placeholder. A `swaggerSpec` wrapper satisfies the `swag.Spec` interface and builds the spec +lazily on first access via `sync.Once`. A `swaggerSeq` atomic counter assigns unique instance +names so multiple `Engine` instances in the same test binary do not collide in the global +`swag` registry. + +**SDK codegen** (`a09a4e9`, `1910aec`): +`SDKGenerator` added to `codegen.go`. Wraps `openapi-generator-cli` to generate client SDKs +for 11 target languages. `SupportedLanguages()` returns the list in sorted order (the sort was +added in `1910aec` to ensure deterministic output in tests and documentation). + +At the end of Phase 3, the module has 176 tests. + +--- + +## Known Limitations + +### 1. Cache has no size limit + +`WithCache(ttl)` stores all successful GET responses in memory with no maximum entry count or +total size bound. For a server receiving requests to many distinct URLs, the cache will grow +without bound. A LRU eviction policy or a configurable maximum is the natural next step. + +### 2. SDK codegen requires an external binary + +`SDKGenerator.Generate()` shells out to `openapi-generator-cli`. This requires a JVM and the +openapi-generator JAR to be installed on the host. `Available()` checks whether the CLI is on +`PATH` but there is no programmatic fallback. Packaging `openapi-generator-cli` via a Docker +wrapper or replacing it with a pure-Go generator would remove this external dependency. + +### 3. OpenAPI spec generation is build-time only + +`SpecBuilder.Build()` generates the spec from `Describe()` return values, which are static at +the time of construction. Dynamic route generation (for example, routes registered after +`New()` returns) is not reflected in the spec. This matches the current design — all groups +must be registered before `Serve()` is called — but it would conflict with any future dynamic +route registration model. + +### 4. i18n message map is a lightweight bridge only + +`WithI18n()` accepts a `Messages map[string]map[string]string` for simple key-value lookups. +It does not support pluralisation, gender inflection, argument interpolation, or any of the +grammar features provided by `go-i18n`. Applications requiring production-grade localisation +should use `go-i18n` directly and use `GetLocale()` to pass the detected locale to it. + +### 5. Authentik JWT validation performs OIDC discovery on first request + +`getOIDCProvider()` performs an OIDC discovery request on first use and caches the resulting +`*oidc.Provider` by issuer URL. This is lazy — the first request to a non-public path will +incur a network round-trip to the issuer. A warm-up call during application startup would +eliminate this latency from the first real request. + +### 6. ToolBridge has no input validation + +`ToolBridge.Add()` accepts a `ToolDescriptor` with `InputSchema` and `OutputSchema` maps, but +these are used only for OpenAPI documentation. The registered `gin.HandlerFunc` is responsible +for its own input validation. There is no automatic binding or validation of incoming request +bodies against the declared JSON Schema. + +### 7. SSEBroker.Drain() does not wait for clients to disconnect + +`Drain()` closes all client event channels to signal disconnection but returns immediately +without waiting for client goroutines to exit. In a graceful shutdown sequence, there is a +brief window where client HTTP connections remain open. The engine's 10-second shutdown +deadline covers this window in practice, but there is no explicit coordination. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..3dec037 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,173 @@ +--- +title: go-api +description: Gin-based REST framework with OpenAPI generation, middleware composition, and SDK codegen for the Lethean Go ecosystem. +--- + + + +# go-api + +**Module path:** `forge.lthn.ai/core/go-api` +**Language:** Go 1.26 +**Licence:** EUPL-1.2 + +go-api is a REST framework built on top of [Gin](https://github.com/gin-gonic/gin). It provides +an `Engine` that subsystems plug into via the `RouteGroup` interface. Each ecosystem package +(go-ai, go-ml, go-rag, and others) registers its own route group, and go-api handles the HTTP +plumbing: middleware composition, response envelopes, WebSocket and SSE integration, GraphQL +hosting, Authentik identity, OpenAPI 3.1 specification generation, and client SDK codegen. + +go-api is a library. It has no `main` package and produces no binary on its own. Callers +construct an `Engine`, register route groups, and call `Serve()`. + +--- + +## Quick Start + +```go +package main + +import ( + "context" + "os/signal" + "syscall" + + api "forge.lthn.ai/core/go-api" +) + +func main() { + engine, _ := api.New( + api.WithAddr(":8080"), + api.WithBearerAuth("my-secret-token"), + api.WithCORS("*"), + api.WithRequestID(), + api.WithSecure(), + api.WithSlog(nil), + api.WithSwagger("My API", "A service description", "1.0.0"), + ) + + engine.Register(myRoutes) // any RouteGroup implementation + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + _ = engine.Serve(ctx) // blocks until context is cancelled, then shuts down gracefully +} +``` + +The default listen address is `:8080`. A built-in `GET /health` endpoint is always present. +Every feature beyond panic recovery requires an explicit `With*()` option. + +--- + +## Implementing a RouteGroup + +Any type that satisfies the `RouteGroup` interface can register endpoints: + +```go +type Routes struct{ service *mypackage.Service } + +func (r *Routes) Name() string { return "mypackage" } +func (r *Routes) BasePath() string { return "/v1/mypackage" } + +func (r *Routes) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/items", r.listItems) + rg.POST("/items", r.createItem) +} + +func (r *Routes) listItems(c *gin.Context) { + items, _ := r.service.List(c.Request.Context()) + c.JSON(200, api.OK(items)) +} +``` + +Register with the engine: + +```go +engine.Register(&Routes{service: svc}) +``` + +--- + +## Package Layout + +| File | Purpose | +|------|---------| +| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` | +| `options.go` | All `With*()` option functions (25 options) | +| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` | +| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` | +| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` | +| `authentik.go` | `AuthentikUser`, `AuthentikConfig`, `GetUser()`, `RequireAuth()`, `RequireGroup()` | +| `websocket.go` | `wrapWSHandler()` helper | +| `sse.go` | `SSEBroker`, `NewSSEBroker()`, `Publish()`, `Handler()`, `Drain()`, `ClientCount()` | +| `cache.go` | `cacheStore`, `cacheEntry`, `cacheWriter`, `cacheMiddleware()` | +| `brotli.go` | `brotliHandler`, `newBrotliHandler()`; compression level constants | +| `graphql.go` | `graphqlConfig`, `GraphQLOption`, `WithPlayground()`, `WithGraphQLPath()`, `mountGraphQL()` | +| `i18n.go` | `I18nConfig`, `WithI18n()`, `i18nMiddleware()`, `GetLocale()`, `GetMessage()` | +| `tracing.go` | `WithTracing()`, `NewTracerProvider()` | +| `swagger.go` | `swaggerSpec`, `registerSwagger()`; sequence counter for multi-instance safety | +| `openapi.go` | `SpecBuilder`, `Build()`, `buildPaths()`, `buildTags()`, `envelopeSchema()` | +| `export.go` | `ExportSpec()`, `ExportSpecToFile()` | +| `bridge.go` | `ToolDescriptor`, `ToolBridge`, `NewToolBridge()`, `Add()`, `Describe()`, `Tools()` | +| `codegen.go` | `SDKGenerator`, `Generate()`, `Available()`, `SupportedLanguages()` | +| `cmd/api/` | CLI subcommands: `core api spec` and `core api sdk` | + +--- + +## Dependencies + +### Direct + +| Module | Role | +|--------|------| +| `github.com/gin-gonic/gin` | HTTP router and middleware engine | +| `github.com/gin-contrib/cors` | CORS policy middleware | +| `github.com/gin-contrib/secure` | Security headers (HSTS, X-Frame-Options, nosniff) | +| `github.com/gin-contrib/gzip` | Gzip response compression | +| `github.com/gin-contrib/slog` | Structured request logging via `log/slog` | +| `github.com/gin-contrib/timeout` | Per-request deadline enforcement | +| `github.com/gin-contrib/static` | Static file serving | +| `github.com/gin-contrib/sessions` | Cookie-backed server sessions | +| `github.com/gin-contrib/authz` | Casbin policy-based authorisation | +| `github.com/gin-contrib/httpsign` | HTTP Signatures verification | +| `github.com/gin-contrib/location/v2` | Reverse proxy header detection | +| `github.com/gin-contrib/pprof` | Go profiling endpoints | +| `github.com/gin-contrib/expvar` | Runtime metrics endpoint | +| `github.com/casbin/casbin/v2` | Policy-based access control engine | +| `github.com/coreos/go-oidc/v3` | OIDC provider discovery and JWT validation | +| `github.com/andybalholm/brotli` | Brotli compression | +| `github.com/gorilla/websocket` | WebSocket upgrade support | +| `github.com/swaggo/gin-swagger` | Swagger UI handler | +| `github.com/swaggo/files` | Swagger UI static assets | +| `github.com/swaggo/swag` | Swagger spec registry | +| `github.com/99designs/gqlgen` | GraphQL schema execution (gqlgen) | +| `go.opentelemetry.io/otel` | OpenTelemetry tracing SDK | +| `go.opentelemetry.io/contrib/.../otelgin` | OpenTelemetry Gin instrumentation | +| `golang.org/x/text` | BCP 47 language tag matching | +| `gopkg.in/yaml.v3` | YAML export of OpenAPI specs | +| `forge.lthn.ai/core/cli` | CLI command registration (for `cmd/api/` subcommands) | + +### Ecosystem position + +go-api sits at the base of the Lethean HTTP stack. It has no imports from other Lethean +ecosystem modules (beyond `core/cli` for the CLI subcommands). Other packages import go-api +to expose their functionality as REST endpoints: + +``` +Application main / Core CLI + | + v + go-api Engine <-- this module + | | | + | | +-- OpenAPI spec --> SDKGenerator --> openapi-generator-cli + | +-- ToolBridge --> go-ai / go-ml / go-rag route groups + +-- RouteGroups ----------> any package implementing RouteGroup +``` + +--- + +## Further Reading + +- [Architecture](architecture.md) -- internals, key types, data flow, middleware stack +- [Development](development.md) -- building, testing, contributing, coding standards diff --git a/export.go b/export.go new file mode 100644 index 0000000..bb233ce --- /dev/null +++ b/export.go @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +// ExportSpec generates the OpenAPI spec and writes it to w. +// Format must be "json" or "yaml". +func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error { + data, err := builder.Build(groups) + if err != nil { + return fmt.Errorf("build spec: %w", err) + } + + switch format { + case "json": + _, err = w.Write(data) + return err + case "yaml": + // Unmarshal JSON then re-marshal as YAML. + var obj any + if err := json.Unmarshal(data, &obj); err != nil { + return fmt.Errorf("unmarshal spec: %w", err) + } + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + if err := enc.Encode(obj); err != nil { + return fmt.Errorf("encode yaml: %w", err) + } + return enc.Close() + default: + return fmt.Errorf("unsupported format %q: use \"json\" or \"yaml\"", format) + } +} + +// ExportSpecToFile writes the spec to the given path. +// The parent directory is created if it does not exist. +func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create directory: %w", err) + } + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("create file: %w", err) + } + defer f.Close() + return ExportSpec(f, format, builder, groups) +} diff --git a/export_test.go b/export_test.go new file mode 100644 index 0000000..952145e --- /dev/null +++ b/export_test.go @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "gopkg.in/yaml.v3" + + api "forge.lthn.ai/core/api" +) + +// ── ExportSpec tests ───────────────────────────────────────────────────── + +func TestExportSpec_Good_JSON(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + var buf bytes.Buffer + if err := api.ExportSpec(&buf, "json", builder, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(buf.Bytes(), &spec); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + if spec["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) + } + + info := spec["info"].(map[string]any) + if info["title"] != "Test" { + t.Fatalf("expected title=Test, got %v", info["title"]) + } +} + +func TestExportSpec_Good_YAML(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + var buf bytes.Buffer + if err := api.ExportSpec(&buf, "yaml", builder, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "openapi:") { + t.Fatalf("expected YAML output to contain 'openapi:', got:\n%s", output) + } + + var spec map[string]any + if err := yaml.Unmarshal(buf.Bytes(), &spec); err != nil { + t.Fatalf("output is not valid YAML: %v", err) + } + + if spec["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) + } +} + +func TestExportSpec_Bad_InvalidFormat(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + var buf bytes.Buffer + err := api.ExportSpec(&buf, "xml", builder, nil) + if err == nil { + t.Fatal("expected error for unsupported format, got nil") + } + if !strings.Contains(err.Error(), "unsupported format") { + t.Fatalf("expected error to contain 'unsupported format', got: %v", err) + } +} + +func TestExportSpecToFile_Good_CreatesFile(t *testing.T) { + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + dir := t.TempDir() + path := filepath.Join(dir, "subdir", "spec.json") + + if err := api.ExportSpecToFile(path, "json", builder, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("file content is not valid JSON: %v", err) + } + + if spec["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) + } +} + +func TestExportSpec_Good_WithToolBridge(t *testing.T) { + gin.SetMode(gin.TestMode) + + builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"} + + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file", + Group: "files", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + bridge.Add(api.ToolDescriptor{ + Name: "metrics_query", + Description: "Query metrics", + Group: "metrics", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + + var buf bytes.Buffer + if err := api.ExportSpec(&buf, "json", builder, []api.RouteGroup{bridge}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + output := buf.String() + if !strings.Contains(output, "/tools/file_read") { + t.Fatalf("expected output to contain /tools/file_read, got:\n%s", output) + } + if !strings.Contains(output, "/tools/metrics_query") { + t.Fatalf("expected output to contain /tools/metrics_query, got:\n%s", output) + } + + // Verify it's valid JSON. + var spec map[string]any + if err := json.Unmarshal(buf.Bytes(), &spec); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + // Verify paths exist. + paths := spec["paths"].(map[string]any) + if _, ok := paths["/tools/file_read"]; !ok { + t.Fatal("expected /tools/file_read path in spec") + } + if _, ok := paths["/tools/metrics_query"]; !ok { + t.Fatal("expected /tools/metrics_query path in spec") + } +} diff --git a/expvar_test.go b/expvar_test.go new file mode 100644 index 0000000..54338db --- /dev/null +++ b/expvar_test.go @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Expvar runtime metrics endpoint ───────────────────────────────── + +func TestWithExpvar_Good_EndpointReturnsJSON(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/vars, got %d", resp.StatusCode) + } + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "application/json") { + t.Fatalf("expected application/json content type, got %q", ct) + } +} + +func TestWithExpvar_Good_ContainsMemstats(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(body), "memstats") { + t.Fatal("expected response body to contain \"memstats\"") + } +} + +func TestWithExpvar_Good_ContainsCmdline(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(body), "cmdline") { + t.Fatal("expected response body to contain \"cmdline\"") + } +} + +func TestWithExpvar_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithRequestID(), api.WithExpvar()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/vars") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/vars with middleware, got %d", resp.StatusCode) + } + + // Verify the request ID middleware is still active. + rid := resp.Header.Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID middleware") + } +} + +func TestWithExpvar_Bad_NotMountedWithoutOption(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/debug/vars", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /debug/vars without WithExpvar, got %d", w.Code) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5b408d3 --- /dev/null +++ b/go.mod @@ -0,0 +1,124 @@ +module forge.lthn.ai/core/api + +go 1.26.0 + +require ( + forge.lthn.ai/core/cli v0.1.0 + github.com/99designs/gqlgen v0.17.87 + github.com/andybalholm/brotli v1.2.0 + github.com/casbin/casbin/v2 v2.135.0 + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/gin-contrib/authz v1.0.6 + github.com/gin-contrib/cors v1.7.6 + github.com/gin-contrib/expvar v1.0.3 + github.com/gin-contrib/gzip v1.2.5 + github.com/gin-contrib/httpsign v1.0.3 + github.com/gin-contrib/location/v2 v2.0.0 + github.com/gin-contrib/pprof v1.5.3 + github.com/gin-contrib/secure v1.1.2 + github.com/gin-contrib/sessions v1.0.4 + github.com/gin-contrib/slog v1.2.0 + github.com/gin-contrib/static v1.1.5 + github.com/gin-contrib/timeout v1.1.0 + github.com/gin-gonic/gin v1.11.0 + github.com/gorilla/websocket v1.5.3 + github.com/swaggo/files v1.0.1 + github.com/swaggo/gin-swagger v1.6.1 + github.com/swaggo/swag v1.16.6 + github.com/vektah/gqlparser/v2 v2.5.32 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 + go.opentelemetry.io/otel v1.40.0 + go.opentelemetry.io/otel/sdk v1.40.0 + go.opentelemetry.io/otel/trace v1.40.0 + golang.org/x/text v0.34.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + forge.lthn.ai/core/go v0.1.0 // indirect + forge.lthn.ai/core/go-crypt v0.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/ProtonMail/go-crypto v1.3.0 // indirect + github.com/agnivade/levenshtein v1.2.1 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect + github.com/bytedance/gopkg v0.1.3 // indirect + github.com/bytedance/sonic v1.15.0 // indirect + github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/casbin/govaluate v1.10.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.2 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.22.4 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/spec v0.22.0 // indirect + github.com/go-openapi/swag/conv v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.4 // indirect + github.com/go-openapi/swag/jsonutils v0.25.1 // indirect + github.com/go-openapi/swag/loading v0.25.1 // indirect + github.com/go-openapi/swag/stringutils v0.25.1 // indirect + github.com/go-openapi/swag/typeutils v0.25.1 // indirect + github.com/go-openapi/swag/yamlutils v0.25.1 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.30.1 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.19.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/context v1.1.2 // indirect + github.com/gorilla/securecookie v1.1.2 // indirect + github.com/gorilla/sessions v1.4.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.6.0 // indirect + github.com/quic-go/quic-go v0.59.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/sosodev/duration v1.3.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.1 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.23.0 // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/tools v0.42.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4a3942b --- /dev/null +++ b/go.sum @@ -0,0 +1,341 @@ +forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM= +forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc= +forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI= +forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ= +forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw= +forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw= +github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8= +github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw= +github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= +github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= +github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= +github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= +github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= +github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= +github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/casbin/casbin/v2 v2.135.0 h1:6BLkMQiGotYyS5yYeWgW19vxqugUlvHFkFiLnLR/bxk= +github.com/casbin/casbin/v2 v2.135.0/go.mod h1:FmcfntdXLTcYXv/hxgNntcRPqAbwOG9xsism0yXT+18= +github.com/casbin/govaluate v1.3.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/casbin/govaluate v1.10.0 h1:ffGw51/hYH3w3rZcxO/KcaUIDOLP84w7nsidMVgaDG0= +github.com/casbin/govaluate v1.10.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= +github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE= +github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= +github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gin-contrib/authz v1.0.6 h1:qAO4sSSzOPCwYRZI6YtubC+h2tZVwhwSJeyEZn2W+5k= +github.com/gin-contrib/authz v1.0.6/go.mod h1:A2B5Im1M/HIoHPjLc31j3RlENSE6j8euJY9NFdzZeYo= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/expvar v1.0.3 h1:nIbUaokxZfUEC/35h+RyWCP1SMF/suV/ARbXL3H3jrw= +github.com/gin-contrib/expvar v1.0.3/go.mod h1:bwqqmhty1Zl2JYVLzBIL6CSHDWDbQoQoicalAnBvUnY= +github.com/gin-contrib/gzip v1.2.5 h1:fIZs0S+l17pIu1P5XRJOo/YNqfIuPCrZZ3TWB7pjckI= +github.com/gin-contrib/gzip v1.2.5/go.mod h1:aomRgR7ftdZV3uWY0gW/m8rChfxau0n8YVvwlOHONzw= +github.com/gin-contrib/httpsign v1.0.3 h1:NpeDQjmUV0qFjGCm/rkXSp3HH0hU7r84q1v+VtTiI5I= +github.com/gin-contrib/httpsign v1.0.3/go.mod h1:n4GC7StmHNBhIzWzuW2njKbZMeEWh4tDbmn3bD1ab+k= +github.com/gin-contrib/location/v2 v2.0.0 h1:iLx5RatHQHSxgC0tm2AG0sIuQKecI7FhREessVd6RWY= +github.com/gin-contrib/location/v2 v2.0.0/go.mod h1:276TDNr25NENBA/NQZUuEIlwxy/I5CYVFIr/d2TgOdU= +github.com/gin-contrib/pprof v1.5.3 h1:Bj5SxJ3kQDVez/s/+f9+meedJIqLS+xlkIVDe/lcvgM= +github.com/gin-contrib/pprof v1.5.3/go.mod h1:0+LQSZ4SLO0B6+2n6JBzaEygpTBxe/nI+YEYpfQQ6xY= +github.com/gin-contrib/secure v1.1.2 h1:6G8/NCOTSywWY7TeaH/0Yfaa6bfkE5ukkqtIm7lK11U= +github.com/gin-contrib/secure v1.1.2/go.mod h1:xI3jI5/BpOYMCBtjgmIVrMA3kI7y9LwCFxs+eLf5S3w= +github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kbcqy8U= +github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs= +github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk= +github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmnzz4= +github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM= +github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw= +github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= +github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw= +github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0= +github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= +github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= +github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= +github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg= +github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= +github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= +github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= +github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= +github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= +github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= +github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= +github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ= +github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= +github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY= +github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxLnILT/qNiTw= +github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= +github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= +github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc= +github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg= +go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg= +golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/graphql.go b/graphql.go new file mode 100644 index 0000000..c878ee3 --- /dev/null +++ b/graphql.go @@ -0,0 +1,63 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "net/http" + + "github.com/99designs/gqlgen/graphql" + "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/playground" + "github.com/gin-gonic/gin" +) + +// defaultGraphQLPath is the URL path where the GraphQL endpoint is mounted. +const defaultGraphQLPath = "/graphql" + +// graphqlConfig holds configuration for the GraphQL endpoint. +type graphqlConfig struct { + schema graphql.ExecutableSchema + path string + playground bool +} + +// GraphQLOption configures a GraphQL endpoint. +type GraphQLOption func(*graphqlConfig) + +// WithPlayground enables the GraphQL Playground UI at {path}/playground. +func WithPlayground() GraphQLOption { + return func(cfg *graphqlConfig) { + cfg.playground = true + } +} + +// WithGraphQLPath sets a custom URL path for the GraphQL endpoint. +// The default path is "/graphql". +func WithGraphQLPath(path string) GraphQLOption { + return func(cfg *graphqlConfig) { + cfg.path = path + } +} + +// mountGraphQL registers the GraphQL handler and optional playground on the Gin engine. +func mountGraphQL(r *gin.Engine, cfg *graphqlConfig) { + srv := handler.NewDefaultServer(cfg.schema) + graphqlHandler := gin.WrapH(srv) + + // Mount the GraphQL endpoint for all HTTP methods (POST for queries/mutations, + // GET for playground redirects and introspection). + r.Any(cfg.path, graphqlHandler) + + if cfg.playground { + playgroundPath := cfg.path + "/playground" + playgroundHandler := playground.Handler("GraphQL", cfg.path) + r.GET(playgroundPath, wrapHTTPHandler(playgroundHandler)) + } +} + +// wrapHTTPHandler adapts a standard http.Handler to a Gin handler function. +func wrapHTTPHandler(h http.Handler) gin.HandlerFunc { + return func(c *gin.Context) { + h.ServeHTTP(c.Writer, c.Request) + } +} diff --git a/graphql_test.go b/graphql_test.go new file mode 100644 index 0000000..0b18f4c --- /dev/null +++ b/graphql_test.go @@ -0,0 +1,234 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/99designs/gqlgen/graphql" + "github.com/gin-gonic/gin" + "github.com/vektah/gqlparser/v2" + "github.com/vektah/gqlparser/v2/ast" + + api "forge.lthn.ai/core/api" +) + +// newTestSchema creates a minimal ExecutableSchema that responds to { name } +// with {"name":"test"}. This avoids importing gqlgen's internal testserver +// while providing a realistic schema for handler tests. +func newTestSchema() graphql.ExecutableSchema { + schema := gqlparser.MustLoadSchema(&ast.Source{Input: ` + type Query { + name: String! + } + `}) + + return &graphql.ExecutableSchemaMock{ + SchemaFunc: func() *ast.Schema { + return schema + }, + ExecFunc: func(ctx context.Context) graphql.ResponseHandler { + ran := false + return func(ctx context.Context) *graphql.Response { + if ran { + return nil + } + ran = true + return &graphql.Response{Data: []byte(`{"name":"test"}`)} + } + }, + ComplexityFunc: func(_ context.Context, _, _ string, childComplexity int, _ map[string]any) (int, bool) { + return childComplexity, true + }, + } +} + +// ── GraphQL endpoint ────────────────────────────────────────────────── + +func TestWithGraphQL_Good_EndpointResponds(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithGraphQL(newTestSchema())) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + body := `{"query":"{ name }"}` + resp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(respBody), `"name":"test"`) { + t.Fatalf("expected response containing name:test, got %q", string(respBody)) + } +} + +func TestWithGraphQL_Good_PlaygroundServesHTML(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithPlayground())) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/graphql/playground") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + ct := resp.Header.Get("Content-Type") + if !strings.Contains(ct, "text/html") { + t.Fatalf("expected Content-Type containing text/html, got %q", ct) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(body), "GraphQL") { + t.Fatalf("expected playground HTML containing 'GraphQL', got %q", string(body)[:200]) + } +} + +func TestWithGraphQL_Good_NoPlaygroundByDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Without WithPlayground(), /graphql/playground should return 404. + e, err := api.New(api.WithGraphQL(newTestSchema())) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/graphql/playground", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /graphql/playground without WithPlayground, got %d", w.Code) + } +} + +func TestWithGraphQL_Good_CustomPath(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath("/gql"), api.WithPlayground())) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + // Query endpoint should be at /gql. + body := `{"query":"{ name }"}` + resp, err := http.Post(srv.URL+"/gql", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 at /gql, got %d", resp.StatusCode) + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(respBody), `"name":"test"`) { + t.Fatalf("expected response containing name:test, got %q", string(respBody)) + } + + // Playground should be at /gql/playground. + pgResp, err := http.Get(srv.URL + "/gql/playground") + if err != nil { + t.Fatalf("playground request failed: %v", err) + } + defer pgResp.Body.Close() + + if pgResp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 at /gql/playground, got %d", pgResp.StatusCode) + } + + // The default path should not exist. + defaultResp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("default path request failed: %v", err) + } + defer defaultResp.Body.Close() + + if defaultResp.StatusCode != http.StatusNotFound { + t.Fatalf("expected 404 at /graphql when custom path is /gql, got %d", defaultResp.StatusCode) + } +} + +func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New( + api.WithRequestID(), + api.WithGraphQL(newTestSchema()), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + body := `{"query":"{ name }"}` + resp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + // RequestID middleware should have injected the header. + reqID := resp.Header.Get("X-Request-ID") + if reqID == "" { + t.Fatal("expected X-Request-ID header from RequestID middleware") + } + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + if !strings.Contains(string(respBody), `"name":"test"`) { + t.Fatalf("expected response containing name:test, got %q", string(respBody)) + } +} diff --git a/group.go b/group.go new file mode 100644 index 0000000..46d0cf4 --- /dev/null +++ b/group.go @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import "github.com/gin-gonic/gin" + +// RouteGroup registers API routes onto a Gin router group. +// Subsystems implement this interface to declare their endpoints. +type RouteGroup interface { + // Name returns a human-readable identifier for the group. + Name() string + + // BasePath returns the URL prefix for all routes in this group. + BasePath() string + + // RegisterRoutes mounts handlers onto the provided router group. + RegisterRoutes(rg *gin.RouterGroup) +} + +// StreamGroup optionally declares WebSocket channels a subsystem publishes to. +type StreamGroup interface { + // Channels returns the list of channel names this group streams on. + Channels() []string +} + +// DescribableGroup extends RouteGroup with OpenAPI metadata. +// RouteGroups that implement this will have their endpoints +// included in the generated OpenAPI specification. +type DescribableGroup interface { + RouteGroup + // Describe returns endpoint descriptions for OpenAPI generation. + Describe() []RouteDescription +} + +// RouteDescription describes a single endpoint for OpenAPI generation. +type RouteDescription struct { + Method string // HTTP method: GET, POST, PUT, DELETE, PATCH + Path string // Path relative to BasePath, e.g. "/generate" + Summary string // Short summary + Description string // Long description + Tags []string // OpenAPI tags for grouping + RequestBody map[string]any // JSON Schema for request body (nil for GET) + Response map[string]any // JSON Schema for success response data +} diff --git a/group_test.go b/group_test.go new file mode 100644 index 0000000..c751070 --- /dev/null +++ b/group_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Stub implementations ──────────────────────────────────────────────── + +type stubGroup struct{} + +func (s *stubGroup) Name() string { return "stub" } +func (s *stubGroup) BasePath() string { return "/stub" } +func (s *stubGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("pong")) + }) +} + +type stubStreamGroup struct { + stubGroup +} + +func (s *stubStreamGroup) Channels() []string { + return []string{"events", "updates"} +} + +// ── RouteGroup interface ──────────────────────────────────────────────── + +func TestRouteGroup_Good_InterfaceSatisfaction(t *testing.T) { + var g api.RouteGroup = &stubGroup{} + + if g.Name() != "stub" { + t.Fatalf("expected Name=%q, got %q", "stub", g.Name()) + } + if g.BasePath() != "/stub" { + t.Fatalf("expected BasePath=%q, got %q", "/stub", g.BasePath()) + } +} + +func TestRouteGroup_Good_RegisterRoutes(t *testing.T) { + gin.SetMode(gin.TestMode) + engine := gin.New() + + g := &stubGroup{} + rg := engine.Group(g.BasePath()) + g.RegisterRoutes(rg) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } +} + +// ── StreamGroup interface ─────────────────────────────────────────────── + +func TestStreamGroup_Good_InterfaceSatisfaction(t *testing.T) { + var g api.StreamGroup = &stubStreamGroup{} + + channels := g.Channels() + if len(channels) != 2 { + t.Fatalf("expected 2 channels, got %d", len(channels)) + } + if channels[0] != "events" { + t.Fatalf("expected channels[0]=%q, got %q", "events", channels[0]) + } + if channels[1] != "updates" { + t.Fatalf("expected channels[1]=%q, got %q", "updates", channels[1]) + } +} + +func TestStreamGroup_Good_AlsoSatisfiesRouteGroup(t *testing.T) { + sg := &stubStreamGroup{} + + // A StreamGroup's embedded stubGroup should also satisfy RouteGroup. + var rg api.RouteGroup = sg + if rg.Name() != "stub" { + t.Fatalf("expected Name=%q, got %q", "stub", rg.Name()) + } +} + +// ── DescribableGroup interface ──────────────────────────────────────── + +// describableStub implements DescribableGroup for testing. +type describableStub struct { + stubGroup + descriptions []api.RouteDescription +} + +func (d *describableStub) Describe() []api.RouteDescription { + return d.descriptions +} + +func TestDescribableGroup_Good_ImplementsRouteGroup(t *testing.T) { + stub := &describableStub{} + + // Must satisfy DescribableGroup. + var dg api.DescribableGroup = stub + if dg.Name() != "stub" { + t.Fatalf("expected Name=%q, got %q", "stub", dg.Name()) + } + + // Must also satisfy RouteGroup since DescribableGroup embeds it. + var rg api.RouteGroup = stub + if rg.BasePath() != "/stub" { + t.Fatalf("expected BasePath=%q, got %q", "/stub", rg.BasePath()) + } +} + +func TestDescribableGroup_Good_DescribeReturnsRoutes(t *testing.T) { + stub := &describableStub{ + descriptions: []api.RouteDescription{ + { + Method: "GET", + Path: "/items", + Summary: "List items", + Tags: []string{"items"}, + }, + { + Method: "POST", + Path: "/items", + Summary: "Create item", + Tags: []string{"items"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if len(descs) != 2 { + t.Fatalf("expected 2 descriptions, got %d", len(descs)) + } + if descs[0].Method != "GET" { + t.Fatalf("expected descs[0].Method=%q, got %q", "GET", descs[0].Method) + } + if descs[0].Summary != "List items" { + t.Fatalf("expected descs[0].Summary=%q, got %q", "List items", descs[0].Summary) + } + if descs[1].Method != "POST" { + t.Fatalf("expected descs[1].Method=%q, got %q", "POST", descs[1].Method) + } + if descs[1].RequestBody == nil { + t.Fatal("expected descs[1].RequestBody to be non-nil") + } +} + +func TestDescribableGroup_Good_EmptyDescribe(t *testing.T) { + stub := &describableStub{ + descriptions: nil, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if descs != nil { + t.Fatalf("expected nil descriptions, got %v", descs) + } +} + +func TestDescribableGroup_Good_MultipleVerbs(t *testing.T) { + stub := &describableStub{ + descriptions: []api.RouteDescription{ + {Method: "GET", Path: "/resources", Summary: "List resources"}, + {Method: "POST", Path: "/resources", Summary: "Create resource"}, + {Method: "DELETE", Path: "/resources/:id", Summary: "Delete resource"}, + }, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if len(descs) != 3 { + t.Fatalf("expected 3 descriptions, got %d", len(descs)) + } + + expected := []string{"GET", "POST", "DELETE"} + for i, want := range expected { + if descs[i].Method != want { + t.Fatalf("expected descs[%d].Method=%q, got %q", i, want, descs[i].Method) + } + } +} + +func TestDescribableGroup_Bad_NilSchemas(t *testing.T) { + stub := &describableStub{ + descriptions: []api.RouteDescription{ + { + Method: "GET", + Path: "/health", + Summary: "Health check", + RequestBody: nil, + Response: nil, + }, + }, + } + + var dg api.DescribableGroup = stub + descs := dg.Describe() + + if len(descs) != 1 { + t.Fatalf("expected 1 description, got %d", len(descs)) + } + if descs[0].RequestBody != nil { + t.Fatalf("expected nil RequestBody, got %v", descs[0].RequestBody) + } + if descs[0].Response != nil { + t.Fatalf("expected nil Response, got %v", descs[0].Response) + } +} diff --git a/gzip_test.go b/gzip_test.go new file mode 100644 index 0000000..3cae670 --- /dev/null +++ b/gzip_test.go @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "compress/gzip" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── WithGzip ────────────────────────────────────────────────────────── + +func TestWithGzip_Good_CompressesResponse(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce) + } +} + +func TestWithGzip_Good_NoCompressionWithoutAcceptHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + // Deliberately not setting Accept-Encoding header. + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce == "gzip" { + t.Fatal("expected no gzip Content-Encoding when client does not request it") + } +} + +func TestWithGzip_Good_DefaultLevel(t *testing.T) { + // Calling WithGzip() with no arguments should use default compression + // and not panic. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q with default level, got %q", "gzip", ce) + } +} + +func TestWithGzip_Good_CustomLevel(t *testing.T) { + // WithGzip(gzip.BestSpeed) should work without panicking and still compress. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithGzip(gzip.BestSpeed)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q with BestSpeed, got %q", "gzip", ce) + } +} + +func TestWithGzip_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithGzip(), + api.WithRequestID(), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Accept-Encoding", "gzip") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Both gzip compression and request ID should be present. + ce := w.Header().Get("Content-Encoding") + if ce != "gzip" { + t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce) + } + + rid := w.Header().Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} diff --git a/httpsign_test.go b/httpsign_test.go new file mode 100644 index 0000000..7ca24b2 --- /dev/null +++ b/httpsign_test.go @@ -0,0 +1,216 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-contrib/httpsign" + "github.com/gin-contrib/httpsign/crypto" + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +const testSecretKey = "test-secret-key-for-hmac-sha256" + +// testKeyID is the key ID used in HTTP signature tests. +var testKeyID = httpsign.KeyID("test-client") + +// newTestSecrets builds a Secrets map with a single HMAC-SHA256 key for testing. +func newTestSecrets() httpsign.Secrets { + return httpsign.Secrets{ + testKeyID: &httpsign.Secret{ + Key: testSecretKey, + Algorithm: &crypto.HmacSha256{}, + }, + } +} + +// signRequest constructs a valid HTTP Signature Authorization header for the +// given request, signing the specified headers with HMAC-SHA256 and the test +// secret key. The Date header is set to the current time if not already present. +func signRequest(req *http.Request, keyID httpsign.KeyID, secret string, headers []string) { + // Ensure a Date header exists. + if req.Header.Get("Date") == "" { + req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) + } + + // Build the signing string in the same way the library does: + // each header as "header: value", joined by newlines. + var parts []string + for _, h := range headers { + var val string + switch h { + case "(request-target)": + val = fmt.Sprintf("%s %s", strings.ToLower(req.Method), req.URL.RequestURI()) + case "host": + val = req.Host + default: + val = req.Header.Get(h) + } + parts = append(parts, fmt.Sprintf("%s: %s", h, val)) + } + signingString := strings.Join(parts, "\n") + + // Sign with HMAC-SHA256. + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(signingString)) + sig := base64.StdEncoding.EncodeToString(mac.Sum(nil)) + + // Build the Authorization header. + authValue := fmt.Sprintf( + "Signature keyId=\"%s\",algorithm=\"hmac-sha256\",headers=\"%s\",signature=\"%s\"", + keyID, + strings.Join(headers, " "), + sig, + ) + req.Header.Set("Authorization", authValue) +} + +// ── WithHTTPSign ────────────────────────────────────────────────────────── + +func TestWithHTTPSign_Good_ValidSignatureAccepted(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Use only (request-target) and date as required headers, disable + // validators to keep the test focused on signature verification. + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), // no validators — pure signature check + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + signRequest(req, testKeyID, testSecretKey, requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for validly signed request, got %d (body: %s)", w.Code, w.Body.String()) + } +} + +func TestWithHTTPSign_Bad_InvalidSignatureRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + + // Sign with the wrong secret so the signature is invalid. + signRequest(req, testKeyID, "wrong-secret-key", requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for invalid signature, got %d", w.Code) + } +} + +func TestWithHTTPSign_Bad_MissingSignatureRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + + // Send a request with no signature at all. + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat)) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for missing signature, got %d", w.Code) + } +} + +func TestWithHTTPSign_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New( + api.WithRequestID(), + api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + ), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + signRequest(req, testKeyID, testSecretKey, requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + + // Verify that WithRequestID also ran. + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithHTTPSign_Ugly_UnknownKeyIDRejected(t *testing.T) { + gin.SetMode(gin.TestMode) + + requiredHeaders := []string{"(request-target)", "date"} + + e, _ := api.New(api.WithHTTPSign( + newTestSecrets(), + httpsign.WithRequiredHeaders(requiredHeaders), + httpsign.WithValidator(), + )) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + + // Sign with an unknown key ID that does not exist in the secrets map. + unknownKeyID := httpsign.KeyID("unknown-client") + signRequest(req, unknownKeyID, testSecretKey, requiredHeaders) + + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for unknown key ID, got %d", w.Code) + } +} diff --git a/i18n.go b/i18n.go new file mode 100644 index 0000000..a9b5974 --- /dev/null +++ b/i18n.go @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "github.com/gin-gonic/gin" + "golang.org/x/text/language" +) + +// i18nContextKey is the Gin context key for the detected locale string. +const i18nContextKey = "i18n.locale" + +// i18nMessagesKey is the Gin context key for the message lookup map. +const i18nMessagesKey = "i18n.messages" + +// I18nConfig configures the internationalisation middleware. +type I18nConfig struct { + // DefaultLocale is the fallback locale when the Accept-Language header + // is absent or does not match any supported locale. Defaults to "en". + DefaultLocale string + + // Supported lists the locale tags the application supports. + // Each entry should be a BCP 47 language tag (e.g. "en", "fr", "de"). + // If empty, only the default locale is supported. + Supported []string + + // Messages maps locale tags to key-value message pairs. + // For example: {"en": {"greeting": "Hello"}, "fr": {"greeting": "Bonjour"}} + // This is optional — handlers can use GetLocale() alone for custom logic. + Messages map[string]map[string]string +} + +// WithI18n adds Accept-Language header parsing and locale detection middleware. +// The middleware uses golang.org/x/text/language for RFC 5646 language matching +// with quality weighting support. The detected locale is stored in the Gin +// context and can be retrieved by handlers via GetLocale(). +// +// If messages are configured, handlers can look up localised strings via +// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine +// can replace the message map later. +func WithI18n(cfg ...I18nConfig) Option { + return func(e *Engine) { + var config I18nConfig + if len(cfg) > 0 { + config = cfg[0] + } + if config.DefaultLocale == "" { + config.DefaultLocale = "en" + } + + // Build the language.Matcher from supported locales. + tags := []language.Tag{language.Make(config.DefaultLocale)} + for _, s := range config.Supported { + tag := language.Make(s) + // Avoid duplicating the default if it also appears in Supported. + if tag != tags[0] { + tags = append(tags, tag) + } + } + matcher := language.NewMatcher(tags) + + e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config)) + } +} + +// i18nMiddleware returns Gin middleware that parses Accept-Language, matches +// it against supported locales, and stores the result in the context. +func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc { + return func(c *gin.Context) { + accept := c.GetHeader("Accept-Language") + + var locale string + if accept == "" { + locale = cfg.DefaultLocale + } else { + tags, _, _ := language.ParseAcceptLanguage(accept) + tag, _, _ := matcher.Match(tags...) + base, _ := tag.Base() + locale = base.String() + } + + c.Set(i18nContextKey, locale) + + // Attach the message map for this locale if messages are configured. + if cfg.Messages != nil { + if msgs, ok := cfg.Messages[locale]; ok { + c.Set(i18nMessagesKey, msgs) + } else if msgs, ok := cfg.Messages[cfg.DefaultLocale]; ok { + // Fall back to default locale messages. + c.Set(i18nMessagesKey, msgs) + } + } + + c.Next() + } +} + +// GetLocale returns the detected locale for the current request. +// Returns "en" if the i18n middleware was not applied. +func GetLocale(c *gin.Context) string { + if v, ok := c.Get(i18nContextKey); ok { + if s, ok := v.(string); ok { + return s + } + } + return "en" +} + +// GetMessage looks up a localised message by key for the current request. +// Returns the message string and true if found, or empty string and false +// if the key does not exist or the i18n middleware was not applied. +func GetMessage(c *gin.Context, key string) (string, bool) { + if v, ok := c.Get(i18nMessagesKey); ok { + if msgs, ok := v.(map[string]string); ok { + if msg, ok := msgs[key]; ok { + return msg, true + } + } + } + return "", false +} diff --git a/i18n_test.go b/i18n_test.go new file mode 100644 index 0000000..d2b6db0 --- /dev/null +++ b/i18n_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Helpers ───────────────────────────────────────────────────────────── + +// i18nTestGroup provides routes that expose locale detection results. +type i18nTestGroup struct{} + +func (i *i18nTestGroup) Name() string { return "i18n" } +func (i *i18nTestGroup) BasePath() string { return "/i18n" } +func (i *i18nTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/locale", func(c *gin.Context) { + locale := api.GetLocale(c) + c.JSON(http.StatusOK, api.OK(map[string]string{"locale": locale})) + }) + rg.GET("/greeting", func(c *gin.Context) { + msg, ok := api.GetMessage(c, "greeting") + c.JSON(http.StatusOK, api.OK(map[string]any{ + "locale": api.GetLocale(c), + "message": msg, + "found": ok, + })) + }) +} + +// i18nLocaleResponse is the typed response for locale detection tests. +type i18nLocaleResponse struct { + Success bool `json:"success"` + Data map[string]string `json:"data"` +} + +// i18nMessageResponse is the typed response for message lookup tests. +type i18nMessageResponse struct { + Success bool `json:"success"` + Data struct { + Locale string `json:"locale"` + Message string `json:"message"` + Found bool `json:"found"` + } `json:"data"` +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +func TestWithI18n_Good_DetectsLocaleFromHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + Supported: []string{"en", "fr", "de"}, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil) + req.Header.Set("Accept-Language", "fr") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"]) + } +} + +func TestWithI18n_Good_FallsBackToDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: []string{"en", "fr"}, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil) + // No Accept-Language header. + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "en" { + t.Fatalf("expected locale=%q, got %q", "en", resp.Data["locale"]) + } +} + +func TestWithI18n_Good_QualityWeighting(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + Supported: []string{"en", "fr", "de"}, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil) + // French has higher quality weight than German. + req.Header.Set("Accept-Language", "de;q=0.5, fr;q=0.9") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"]) + } +} + +func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithI18n(api.I18nConfig{ + Supported: []string{"en", "fr"}, + }), + api.WithRequestID(), + ) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil) + req.Header.Set("Accept-Language", "fr") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // i18n middleware should detect French. + var resp i18nLocaleResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["locale"] != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"]) + } + + // RequestID middleware should also have run. + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithI18n_Good_LooksUpMessage(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithI18n(api.I18nConfig{ + DefaultLocale: "en", + Supported: []string{"en", "fr"}, + Messages: map[string]map[string]string{ + "en": {"greeting": "Hello"}, + "fr": {"greeting": "Bonjour"}, + }, + })) + e.Register(&i18nTestGroup{}) + + h := e.Handler() + + // Test French message lookup. + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil) + req.Header.Set("Accept-Language", "fr") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp i18nMessageResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data.Locale != "fr" { + t.Fatalf("expected locale=%q, got %q", "fr", resp.Data.Locale) + } + if resp.Data.Message != "Bonjour" { + t.Fatalf("expected message=%q, got %q", "Bonjour", resp.Data.Message) + } + if !resp.Data.Found { + t.Fatal("expected found=true") + } + + // Test English message lookup. + w = httptest.NewRecorder() + req, _ = http.NewRequest(http.MethodGet, "/i18n/greeting", nil) + req.Header.Set("Accept-Language", "en") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var respEn i18nMessageResponse + if err := json.Unmarshal(w.Body.Bytes(), &respEn); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if respEn.Data.Message != "Hello" { + t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message) + } +} diff --git a/location_test.go b/location_test.go new file mode 100644 index 0000000..2364d38 --- /dev/null +++ b/location_test.go @@ -0,0 +1,180 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/location/v2" + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Helpers ───────────────────────────────────────────────────────────── + +// locationTestGroup exposes a route that returns the detected location. +type locationTestGroup struct{} + +func (l *locationTestGroup) Name() string { return "loc" } +func (l *locationTestGroup) BasePath() string { return "/loc" } +func (l *locationTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/info", func(c *gin.Context) { + url := location.Get(c) + c.JSON(http.StatusOK, api.OK(map[string]string{ + "scheme": url.Scheme, + "host": url.Host, + })) + }) +} + +// locationResponse is the typed response envelope for location info tests. +type locationResponse struct { + Success bool `json:"success"` + Data map[string]string `json:"data"` +} + +// ── WithLocation ──────────────────────────────────────────────────────── + +func TestWithLocation_Good_DetectsForwardedHost(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithLocation()) + e.Register(&locationTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil) + req.Header.Set("X-Forwarded-Host", "api.example.com") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp locationResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["host"] != "api.example.com" { + t.Fatalf("expected host=%q, got %q", "api.example.com", resp.Data["host"]) + } +} + +func TestWithLocation_Good_DetectsForwardedProto(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithLocation()) + e.Register(&locationTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil) + req.Header.Set("X-Forwarded-Proto", "https") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp locationResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["scheme"] != "https" { + t.Fatalf("expected scheme=%q, got %q", "https", resp.Data["scheme"]) + } +} + +func TestWithLocation_Good_FallsBackToRequestHost(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithLocation()) + e.Register(&locationTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil) + // No X-Forwarded-* headers — middleware should fall back to defaults. + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp locationResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + // Without forwarded headers the middleware falls back to its default + // scheme ("http"). The host will be either the request Host header + // value or the configured default; either way it must not be empty. + if resp.Data["scheme"] != "http" { + t.Fatalf("expected fallback scheme=%q, got %q", "http", resp.Data["scheme"]) + } + if resp.Data["host"] == "" { + t.Fatal("expected a non-empty host in fallback mode") + } +} + +func TestWithLocation_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithLocation(), + api.WithRequestID(), + ) + e.Register(&locationTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil) + req.Header.Set("X-Forwarded-Host", "proxy.example.com") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Location middleware should populate the detected host. + var resp locationResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["host"] != "proxy.example.com" { + t.Fatalf("expected host=%q, got %q", "proxy.example.com", resp.Data["host"]) + } + + // RequestID middleware should also have run. + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithLocation_Good_BothHeadersCombined(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithLocation()) + e.Register(&locationTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/loc/info", nil) + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "secure.example.com") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp locationResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data["scheme"] != "https" { + t.Fatalf("expected scheme=%q, got %q", "https", resp.Data["scheme"]) + } + if resp.Data["host"] != "secure.example.com" { + t.Fatalf("expected host=%q, got %q", "secure.example.com", resp.Data["host"]) + } +} diff --git a/middleware.go b/middleware.go new file mode 100644 index 0000000..55fe8ae --- /dev/null +++ b/middleware.go @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "strings" + + "github.com/gin-gonic/gin" +) + +// bearerAuthMiddleware validates the Authorization: Bearer header. +// Requests to paths in the skip list are allowed through without authentication. +// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens. +func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc { + return func(c *gin.Context) { + // Check whether the request path should bypass authentication. + for _, path := range skip { + if strings.HasPrefix(c.Request.URL.Path, path) { + c.Next() + return + } + } + + header := c.GetHeader("Authorization") + if header == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "missing authorization header")) + return + } + + parts := strings.SplitN(header, " ", 2) + if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token { + c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token")) + return + } + + c.Next() + } +} + +// requestIDMiddleware ensures every response carries an X-Request-ID header. +// If the client sends one, it is preserved; otherwise a random 16-byte hex +// string is generated. The ID is also stored in the Gin context as "request_id". +func requestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + id := c.GetHeader("X-Request-ID") + if id == "" { + b := make([]byte, 16) + _, _ = rand.Read(b) + id = hex.EncodeToString(b) + } + + c.Set("request_id", id) + c.Header("X-Request-ID", id) + c.Next() + } +} diff --git a/middleware_test.go b/middleware_test.go new file mode 100644 index 0000000..bcd1561 --- /dev/null +++ b/middleware_test.go @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Helpers ───────────────────────────────────────────────────────────── + +// mwTestGroup provides a simple /v1/secret endpoint for middleware tests. +type mwTestGroup struct{} + +func (m *mwTestGroup) Name() string { return "mw-test" } +func (m *mwTestGroup) BasePath() string { return "/v1" } +func (m *mwTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/secret", func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("classified")) + }) +} + +// ── Bearer auth ───────────────────────────────────────────────────────── + +func TestBearerAuth_Bad_MissingToken(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBearerAuth("s3cret")) + e.Register(&mwTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + + var resp api.Response[any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Error == nil || resp.Error.Code != "unauthorised" { + t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error) + } +} + +func TestBearerAuth_Bad_WrongToken(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBearerAuth("s3cret")) + e.Register(&mwTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil) + req.Header.Set("Authorization", "Bearer wrong-token") + h.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d", w.Code) + } + + var resp api.Response[any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Error == nil || resp.Error.Code != "unauthorised" { + t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error) + } +} + +func TestBearerAuth_Good_CorrectToken(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBearerAuth("s3cret")) + e.Register(&mwTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil) + req.Header.Set("Authorization", "Bearer s3cret") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data != "classified" { + t.Fatalf("expected Data=%q, got %q", "classified", resp.Data) + } +} + +func TestBearerAuth_Good_HealthBypassesAuth(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithBearerAuth("s3cret")) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + // No Authorization header. + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for /health, got %d", w.Code) + } +} + +// ── Request ID ────────────────────────────────────────────────────────── + +func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithRequestID()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + id := w.Header().Get("X-Request-ID") + if id == "" { + t.Fatal("expected X-Request-ID header to be set") + } + // 16 bytes = 32 hex characters. + if len(id) != 32 { + t.Fatalf("expected 32-char hex ID, got %d chars: %q", len(id), id) + } +} + +func TestRequestID_Good_PreservesClientID(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithRequestID()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + req.Header.Set("X-Request-ID", "client-id-abc") + h.ServeHTTP(w, req) + + id := w.Header().Get("X-Request-ID") + if id != "client-id-abc" { + t.Fatalf("expected X-Request-ID=%q, got %q", "client-id-abc", id) + } +} + +// ── CORS ──────────────────────────────────────────────────────────────── + +func TestCORS_Good_PreflightAllOrigins(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithCORS("*")) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodOptions, "/health", nil) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + req.Header.Set("Access-Control-Request-Headers", "Authorization") + h.ServeHTTP(w, req) + + if w.Code != http.StatusNoContent && w.Code != http.StatusOK { + t.Fatalf("expected 200 or 204 for preflight, got %d", w.Code) + } + + origin := w.Header().Get("Access-Control-Allow-Origin") + if origin != "*" { + t.Fatalf("expected Access-Control-Allow-Origin=%q, got %q", "*", origin) + } + + methods := w.Header().Get("Access-Control-Allow-Methods") + if methods == "" { + t.Fatal("expected Access-Control-Allow-Methods to be set") + } + + headers := w.Header().Get("Access-Control-Allow-Headers") + if headers == "" { + t.Fatal("expected Access-Control-Allow-Headers to be set") + } +} + +func TestCORS_Good_SpecificOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithCORS("https://app.example.com")) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodOptions, "/health", nil) + req.Header.Set("Origin", "https://app.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + h.ServeHTTP(w, req) + + origin := w.Header().Get("Access-Control-Allow-Origin") + if origin != "https://app.example.com" { + t.Fatalf("expected origin=%q, got %q", "https://app.example.com", origin) + } +} + +func TestCORS_Bad_DisallowedOrigin(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithCORS("https://allowed.example.com")) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodOptions, "/health", nil) + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("Access-Control-Request-Method", "GET") + h.ServeHTTP(w, req) + + origin := w.Header().Get("Access-Control-Allow-Origin") + if origin != "" { + t.Fatalf("expected no Access-Control-Allow-Origin for disallowed origin, got %q", origin) + } +} diff --git a/modernization_test.go b/modernization_test.go new file mode 100644 index 0000000..fae39b6 --- /dev/null +++ b/modernization_test.go @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "slices" + "testing" + + api "forge.lthn.ai/core/api" +) + +func TestEngine_GroupsIter(t *testing.T) { + e, _ := api.New() + g1 := &healthGroup{} + e.Register(g1) + + var groups []api.RouteGroup + for g := range e.GroupsIter() { + groups = append(groups, g) + } + + if len(groups) != 1 { + t.Fatalf("expected 1 group, got %d", len(groups)) + } + if groups[0].Name() != "health-extra" { + t.Errorf("expected group name 'health-extra', got %q", groups[0].Name()) + } +} + +type streamGroupStub struct { + healthGroup + channels []string +} + +func (s *streamGroupStub) Channels() []string { return s.channels } + +func TestEngine_ChannelsIter(t *testing.T) { + e, _ := api.New() + g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}} + g2 := &streamGroupStub{channels: []string{"ch3"}} + e.Register(g1) + e.Register(g2) + + var channels []string + for ch := range e.ChannelsIter() { + channels = append(channels, ch) + } + + expected := []string{"ch1", "ch2", "ch3"} + if !slices.Equal(channels, expected) { + t.Fatalf("expected channels %v, got %v", expected, channels) + } +} + +func TestToolBridge_Iterators(t *testing.T) { + b := api.NewToolBridge("/tools") + desc := api.ToolDescriptor{Name: "test", Group: "g1"} + b.Add(desc, nil) + + // Test ToolsIter + var tools []api.ToolDescriptor + for t := range b.ToolsIter() { + tools = append(tools, t) + } + if len(tools) != 1 || tools[0].Name != "test" { + t.Errorf("ToolsIter failed, got %v", tools) + } + + // Test DescribeIter + var descs []api.RouteDescription + for d := range b.DescribeIter() { + descs = append(descs, d) + } + if len(descs) != 1 || descs[0].Path != "/test" { + t.Errorf("DescribeIter failed, got %v", descs) + } +} + +func TestCodegen_SupportedLanguagesIter(t *testing.T) { + var langs []string + for l := range api.SupportedLanguagesIter() { + langs = append(langs, l) + } + + if !slices.Contains(langs, "go") { + t.Errorf("SupportedLanguagesIter missing 'go'") + } + + // Should be sorted + if !slices.IsSorted(langs) { + t.Errorf("SupportedLanguagesIter should be sorted, got %v", langs) + } +} diff --git a/norace_test.go b/norace_test.go new file mode 100644 index 0000000..25a5e55 --- /dev/null +++ b/norace_test.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: EUPL-1.2 +//go:build !race + +package api_test + +const raceDetectorEnabled = false diff --git a/openapi.go b/openapi.go new file mode 100644 index 0000000..b98d8d1 --- /dev/null +++ b/openapi.go @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "encoding/json" + "strings" +) + +// SpecBuilder constructs an OpenAPI 3.1 specification from registered RouteGroups. +type SpecBuilder struct { + Title string + Description string + Version string +} + +// Build generates the complete OpenAPI 3.1 JSON spec. +// Groups implementing DescribableGroup contribute endpoint documentation. +// Other groups are listed as tags only. +func (sb *SpecBuilder) Build(groups []RouteGroup) ([]byte, error) { + spec := map[string]any{ + "openapi": "3.1.0", + "info": map[string]any{ + "title": sb.Title, + "description": sb.Description, + "version": sb.Version, + }, + "paths": sb.buildPaths(groups), + "tags": sb.buildTags(groups), + } + + // Add component schemas for the response envelope. + spec["components"] = map[string]any{ + "schemas": map[string]any{ + "Error": map[string]any{ + "type": "object", + "properties": map[string]any{ + "code": map[string]any{"type": "string"}, + "message": map[string]any{"type": "string"}, + "details": map[string]any{}, + }, + "required": []string{"code", "message"}, + }, + "Meta": map[string]any{ + "type": "object", + "properties": map[string]any{ + "request_id": map[string]any{"type": "string"}, + "duration": map[string]any{"type": "string"}, + "page": map[string]any{"type": "integer"}, + "per_page": map[string]any{"type": "integer"}, + "total": map[string]any{"type": "integer"}, + }, + }, + }, + } + + return json.MarshalIndent(spec, "", " ") +} + +// buildPaths generates the paths object from all DescribableGroups. +func (sb *SpecBuilder) buildPaths(groups []RouteGroup) map[string]any { + paths := map[string]any{ + // Built-in health endpoint. + "/health": map[string]any{ + "get": map[string]any{ + "summary": "Health check", + "description": "Returns server health status", + "tags": []string{"system"}, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Server is healthy", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": envelopeSchema(map[string]any{"type": "string"}), + }, + }, + }, + }, + }, + }, + } + + for _, g := range groups { + dg, ok := g.(DescribableGroup) + if !ok { + continue + } + for _, rd := range dg.Describe() { + fullPath := g.BasePath() + rd.Path + method := strings.ToLower(rd.Method) + + operation := map[string]any{ + "summary": rd.Summary, + "description": rd.Description, + "tags": rd.Tags, + "responses": map[string]any{ + "200": map[string]any{ + "description": "Successful response", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": envelopeSchema(rd.Response), + }, + }, + }, + "400": map[string]any{ + "description": "Bad request", + "content": map[string]any{ + "application/json": map[string]any{ + "schema": envelopeSchema(nil), + }, + }, + }, + }, + } + + // Add request body for methods that accept one. + if rd.RequestBody != nil && (method == "post" || method == "put" || method == "patch") { + operation["requestBody"] = map[string]any{ + "required": true, + "content": map[string]any{ + "application/json": map[string]any{ + "schema": rd.RequestBody, + }, + }, + } + } + + // Create or extend path item. + if existing, exists := paths[fullPath]; exists { + existing.(map[string]any)[method] = operation + } else { + paths[fullPath] = map[string]any{ + method: operation, + } + } + } + } + + return paths +} + +// buildTags generates the tags array from all RouteGroups. +func (sb *SpecBuilder) buildTags(groups []RouteGroup) []map[string]any { + tags := []map[string]any{ + {"name": "system", "description": "System endpoints"}, + } + seen := map[string]bool{"system": true} + + for _, g := range groups { + name := g.Name() + if !seen[name] { + tags = append(tags, map[string]any{ + "name": name, + "description": name + " endpoints", + }) + seen[name] = true + } + } + + return tags +} + +// envelopeSchema wraps a data schema in the standard Response[T] envelope. +func envelopeSchema(dataSchema map[string]any) map[string]any { + properties := map[string]any{ + "success": map[string]any{"type": "boolean"}, + "error": map[string]any{ + "$ref": "#/components/schemas/Error", + }, + "meta": map[string]any{ + "$ref": "#/components/schemas/Meta", + }, + } + + if dataSchema != nil { + properties["data"] = dataSchema + } + + return map[string]any{ + "type": "object", + "properties": properties, + "required": []string{"success"}, + } +} diff --git a/openapi_test.go b/openapi_test.go new file mode 100644 index 0000000..89413ff --- /dev/null +++ b/openapi_test.go @@ -0,0 +1,403 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Test helpers ────────────────────────────────────────────────────────── + +type specStubGroup struct { + name string + basePath string + descs []api.RouteDescription +} + +func (s *specStubGroup) Name() string { return s.name } +func (s *specStubGroup) BasePath() string { return s.basePath } +func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} +func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs } + +type plainStubGroup struct{} + +func (plainStubGroup) Name() string { return "plain" } +func (plainStubGroup) BasePath() string { return "/plain" } +func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {} + +// ── SpecBuilder tests ───────────────────────────────────────────────────── + +func TestSpecBuilder_Good_EmptyGroups(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Description: "Empty test", + Version: "0.0.1", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Verify OpenAPI version. + if spec["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"]) + } + + // Verify /health path exists. + paths := spec["paths"].(map[string]any) + if _, ok := paths["/health"]; !ok { + t.Fatal("expected /health path in spec") + } + + // Verify system tag exists. + tags := spec["tags"].([]any) + found := false + for _, tag := range tags { + tm := tag.(map[string]any) + if tm["name"] == "system" { + found = true + break + } + } + if !found { + t.Fatal("expected system tag in spec") + } +} + +func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Description: "Test API", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: "items", + basePath: "/api/items", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/list", + Summary: "List items", + Tags: []string{"items"}, + Response: map[string]any{ + "type": "array", + "items": map[string]any{ + "type": "string", + }, + }, + }, + { + Method: "POST", + Path: "/create", + Summary: "Create item", + Description: "Creates a new item", + Tags: []string{"items"}, + RequestBody: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "integer"}, + }, + }, + }, + }, + } + + data, err := sb.Build([]api.RouteGroup{group}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + + // Verify GET /api/items/list exists. + listPath, ok := paths["/api/items/list"] + if !ok { + t.Fatal("expected /api/items/list path in spec") + } + getOp := listPath.(map[string]any)["get"] + if getOp == nil { + t.Fatal("expected GET operation on /api/items/list") + } + if getOp.(map[string]any)["summary"] != "List items" { + t.Fatalf("expected summary='List items', got %v", getOp.(map[string]any)["summary"]) + } + + // Verify POST /api/items/create exists with request body. + createPath, ok := paths["/api/items/create"] + if !ok { + t.Fatal("expected /api/items/create path in spec") + } + postOp := createPath.(map[string]any)["post"] + if postOp == nil { + t.Fatal("expected POST operation on /api/items/create") + } + if postOp.(map[string]any)["summary"] != "Create item" { + t.Fatalf("expected summary='Create item', got %v", postOp.(map[string]any)["summary"]) + } + if postOp.(map[string]any)["requestBody"] == nil { + t.Fatal("expected requestBody on POST /api/items/create") + } +} + +func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + group := &specStubGroup{ + name: "data", + basePath: "/data", + descs: []api.RouteDescription{ + { + Method: "GET", + Path: "/fetch", + Summary: "Fetch data", + Tags: []string{"data"}, + Response: map[string]any{ + "type": "object", + "properties": map[string]any{ + "value": map[string]any{"type": "string"}, + }, + }, + }, + }, + } + + data, err := sb.Build([]api.RouteGroup{group}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + fetchPath := paths["/data/fetch"].(map[string]any) + getOp := fetchPath["get"].(map[string]any) + responses := getOp["responses"].(map[string]any) + resp200 := responses["200"].(map[string]any) + content := resp200["content"].(map[string]any) + appJSON := content["application/json"].(map[string]any) + schema := appJSON["schema"].(map[string]any) + + // Verify envelope structure. + if schema["type"] != "object" { + t.Fatalf("expected schema type=object, got %v", schema["type"]) + } + + properties := schema["properties"].(map[string]any) + + // Verify success field. + success := properties["success"].(map[string]any) + if success["type"] != "boolean" { + t.Fatalf("expected success.type=boolean, got %v", success["type"]) + } + + // Verify data field contains the original response schema. + dataField := properties["data"].(map[string]any) + if dataField["type"] != "object" { + t.Fatalf("expected data.type=object, got %v", dataField["type"]) + } + dataProps := dataField["properties"].(map[string]any) + if dataProps["value"] == nil { + t.Fatal("expected data.properties.value to exist") + } + + // Verify required contains "success". + required := schema["required"].([]any) + foundSuccess := false + for _, r := range required { + if r == "success" { + foundSuccess = true + break + } + } + if !foundSuccess { + t.Fatal("expected 'success' in required array") + } +} + +func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "Test", + Version: "1.0.0", + } + + data, err := sb.Build([]api.RouteGroup{plainStubGroup{}}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + // Verify plainStubGroup appears in tags. + tags := spec["tags"].([]any) + foundPlain := false + for _, tag := range tags { + tm := tag.(map[string]any) + if tm["name"] == "plain" { + foundPlain = true + break + } + } + if !foundPlain { + t.Fatal("expected 'plain' tag in spec for non-describable group") + } + + // Verify only /health exists in paths (plain group adds no paths). + paths := spec["paths"].(map[string]any) + if len(paths) != 1 { + t.Fatalf("expected 1 path (/health only), got %d", len(paths)) + } + if _, ok := paths["/health"]; !ok { + t.Fatal("expected /health path in spec") + } +} + +func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) { + gin.SetMode(gin.TestMode) + + sb := &api.SpecBuilder{ + Title: "Tool API", + Version: "1.0.0", + } + + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file from disk", + Group: "files", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + bridge.Add(api.ToolDescriptor{ + Name: "metrics_query", + Description: "Query metrics data", + Group: "metrics", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + + data, err := sb.Build([]api.RouteGroup{bridge}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := spec["paths"].(map[string]any) + + // Verify POST /tools/file_read exists. + fileReadPath, ok := paths["/tools/file_read"] + if !ok { + t.Fatal("expected /tools/file_read path in spec") + } + postOp := fileReadPath.(map[string]any)["post"] + if postOp == nil { + t.Fatal("expected POST operation on /tools/file_read") + } + if postOp.(map[string]any)["summary"] != "Read a file from disk" { + t.Fatalf("expected summary='Read a file from disk', got %v", postOp.(map[string]any)["summary"]) + } + + // Verify POST /tools/metrics_query exists. + metricsPath, ok := paths["/tools/metrics_query"] + if !ok { + t.Fatal("expected /tools/metrics_query path in spec") + } + metricsOp := metricsPath.(map[string]any)["post"] + if metricsOp == nil { + t.Fatal("expected POST operation on /tools/metrics_query") + } + if metricsOp.(map[string]any)["summary"] != "Query metrics data" { + t.Fatalf("expected summary='Query metrics data', got %v", metricsOp.(map[string]any)["summary"]) + } + + // Verify request body is present on both (both are POST with InputSchema). + if postOp.(map[string]any)["requestBody"] == nil { + t.Fatal("expected requestBody on POST /tools/file_read") + } + if metricsOp.(map[string]any)["requestBody"] == nil { + t.Fatal("expected requestBody on POST /tools/metrics_query") + } +} + +func TestSpecBuilder_Bad_InfoFields(t *testing.T) { + sb := &api.SpecBuilder{ + Title: "MyAPI", + Description: "Test API", + Version: "1.0.0", + } + + data, err := sb.Build(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(data, &spec); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + info := spec["info"].(map[string]any) + if info["title"] != "MyAPI" { + t.Fatalf("expected title=MyAPI, got %v", info["title"]) + } + if info["description"] != "Test API" { + t.Fatalf("expected description='Test API', got %v", info["description"]) + } + if info["version"] != "1.0.0" { + t.Fatalf("expected version=1.0.0, got %v", info["version"]) + } +} diff --git a/options.go b/options.go new file mode 100644 index 0000000..bdf3f66 --- /dev/null +++ b/options.go @@ -0,0 +1,325 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "compress/gzip" + "log/slog" + "net/http" + "slices" + "time" + + "github.com/99designs/gqlgen/graphql" + "github.com/casbin/casbin/v2" + "github.com/gin-contrib/authz" + "github.com/gin-contrib/cors" + gingzip "github.com/gin-contrib/gzip" + "github.com/gin-contrib/httpsign" + "github.com/gin-contrib/location/v2" + "github.com/gin-contrib/secure" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + ginslog "github.com/gin-contrib/slog" + "github.com/gin-contrib/static" + "github.com/gin-contrib/timeout" + "github.com/gin-gonic/gin" +) + +// Option configures an Engine during construction. +type Option func(*Engine) + +// WithAddr sets the listen address for the server. +func WithAddr(addr string) Option { + return func(e *Engine) { + e.addr = addr + } +} + +// WithBearerAuth adds bearer token authentication middleware. +// Requests to /health and paths starting with /swagger are exempt. +func WithBearerAuth(token string) Option { + return func(e *Engine) { + skip := []string{"/health", "/swagger"} + e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip)) + } +} + +// WithRequestID adds middleware that assigns an X-Request-ID to every response. +// Client-provided IDs are preserved; otherwise a random hex ID is generated. +func WithRequestID() Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, requestIDMiddleware()) + } +} + +// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors. +// Pass "*" to allow all origins, or supply specific origin URLs. +// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common +// headers (Authorization, Content-Type, X-Request-ID) are permitted. +func WithCORS(allowOrigins ...string) Option { + return func(e *Engine) { + cfg := cors.Config{ + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Authorization", "Content-Type", "X-Request-ID"}, + MaxAge: 12 * time.Hour, + } + + if slices.Contains(allowOrigins, "*") { + cfg.AllowAllOrigins = true + } + if !cfg.AllowAllOrigins { + cfg.AllowOrigins = allowOrigins + } + + e.middlewares = append(e.middlewares, cors.New(cfg)) + } +} + +// WithMiddleware appends arbitrary Gin middleware to the engine. +func WithMiddleware(mw ...gin.HandlerFunc) Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, mw...) + } +} + +// WithStatic serves static files from the given root directory at urlPrefix. +// Directory listing is disabled; only individual files are served. +// Internally this uses gin-contrib/static as Gin middleware. +func WithStatic(urlPrefix, root string) Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false))) + } +} + +// WithWSHandler registers a WebSocket handler at GET /ws. +// Typically this wraps a go-ws Hub.Handler(). +func WithWSHandler(h http.Handler) Option { + return func(e *Engine) { + e.wsHandler = h + } +} + +// WithAuthentik adds Authentik forward-auth middleware that extracts user +// identity from X-authentik-* headers set by a trusted reverse proxy. +// The middleware is permissive: unauthenticated requests are allowed through. +func WithAuthentik(cfg AuthentikConfig) Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, authentikMiddleware(cfg)) + } +} + +// WithSwagger enables the Swagger UI at /swagger/. +// The title, description, and version populate the OpenAPI info block. +func WithSwagger(title, description, version string) Option { + return func(e *Engine) { + e.swaggerTitle = title + e.swaggerDesc = description + e.swaggerVersion = version + e.swaggerEnabled = true + } +} + +// WithPprof enables Go runtime profiling endpoints at /debug/pprof/. +// The standard pprof handlers (index, cmdline, profile, symbol, trace, +// allocs, block, goroutine, heap, mutex, threadcreate) are registered +// via gin-contrib/pprof. +// +// WARNING: pprof exposes sensitive runtime data and should only be +// enabled in development or behind authentication in production. +func WithPprof() Option { + return func(e *Engine) { + e.pprofEnabled = true + } +} + +// WithExpvar enables the Go runtime metrics endpoint at /debug/vars. +// The endpoint serves JSON containing memstats, cmdline, and any +// custom expvar variables registered by the application. Powered by +// gin-contrib/expvar wrapping Go's standard expvar.Handler(). +// +// WARNING: expvar exposes runtime internals (memory allocation, +// goroutine counts, command-line arguments) and should only be +// enabled in development or behind authentication in production. +func WithExpvar() Option { + return func(e *Engine) { + e.expvarEnabled = true + } +} + +// WithSecure adds security headers middleware via gin-contrib/secure. +// Default policy sets HSTS (1 year, includeSubDomains), X-Frame-Options DENY, +// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin. +// SSL redirect is not enabled so the middleware works behind a reverse proxy +// that terminates TLS. +func WithSecure() Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, secure.New(secure.Config{ + STSSeconds: 31536000, + STSIncludeSubdomains: true, + FrameDeny: true, + ContentTypeNosniff: true, + ReferrerPolicy: "strict-origin-when-cross-origin", + IsDevelopment: false, + })) + } +} + +// WithGzip adds gzip response compression middleware via gin-contrib/gzip. +// An optional compression level may be supplied (e.g. gzip.BestSpeed, +// gzip.BestCompression). If omitted, gzip.DefaultCompression is used. +func WithGzip(level ...int) Option { + return func(e *Engine) { + l := gzip.DefaultCompression + if len(level) > 0 { + l = level[0] + } + e.middlewares = append(e.middlewares, gingzip.Gzip(l)) + } +} + +// WithBrotli adds Brotli response compression middleware using andybalholm/brotli. +// An optional compression level may be supplied (e.g. BrotliBestSpeed, +// BrotliBestCompression). If omitted, BrotliDefaultCompression is used. +func WithBrotli(level ...int) Option { + return func(e *Engine) { + l := BrotliDefaultCompression + if len(level) > 0 { + l = level[0] + } + e.middlewares = append(e.middlewares, newBrotliHandler(l).Handle) + } +} + +// WithSlog adds structured request logging middleware via gin-contrib/slog. +// Each request is logged with method, path, status code, latency, and client IP. +// If logger is nil, slog.Default() is used. +func WithSlog(logger *slog.Logger) Option { + return func(e *Engine) { + if logger == nil { + logger = slog.Default() + } + e.middlewares = append(e.middlewares, ginslog.SetLogger( + ginslog.WithLogger(func(_ *gin.Context, l *slog.Logger) *slog.Logger { + return logger + }), + )) + } +} + +// WithTimeout adds per-request timeout middleware via gin-contrib/timeout. +// If a handler exceeds the given duration, the request is aborted with a +// 504 Gateway Timeout carrying the standard error envelope: +// +// {"success":false,"error":{"code":"timeout","message":"Request timed out"}} +// +// A zero or negative duration effectively disables the timeout (the handler +// runs without a deadline) — this is safe and will not panic. +func WithTimeout(d time.Duration) Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, timeout.New( + timeout.WithTimeout(d), + timeout.WithResponse(timeoutResponse), + )) + } +} + +// timeoutResponse writes a 504 Gateway Timeout with the standard error envelope. +func timeoutResponse(c *gin.Context) { + c.JSON(http.StatusGatewayTimeout, Fail("timeout", "Request timed out")) +} + +// WithCache adds in-memory response caching middleware for GET requests. +// Successful (2xx) GET responses are cached for the given TTL and served +// with an X-Cache: HIT header on subsequent requests. Non-GET methods +// and error responses pass through uncached. +func WithCache(ttl time.Duration) Option { + return func(e *Engine) { + store := newCacheStore() + e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl)) + } +} + +// WithSessions adds server-side session management middleware via +// gin-contrib/sessions using a cookie-based store. The name parameter +// sets the session cookie name (e.g. "session") and secret is the key +// used for cookie signing and encryption. +func WithSessions(name string, secret []byte) Option { + return func(e *Engine) { + store := cookie.NewStore(secret) + e.middlewares = append(e.middlewares, sessions.Sessions(name, store)) + } +} + +// WithAuthz adds Casbin policy-based authorisation middleware via +// gin-contrib/authz. The caller provides a pre-configured Casbin enforcer +// holding the desired model and policy rules. The middleware extracts the +// subject from HTTP Basic Authentication, evaluates it against the request +// method and path, and returns 403 Forbidden when the policy denies access. +func WithAuthz(enforcer *casbin.Enforcer) Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer)) + } +} + +// WithHTTPSign adds HTTP signature verification middleware via +// gin-contrib/httpsign. Incoming requests must carry a valid cryptographic +// signature in the Authorization or Signature header as defined by the HTTP +// Signatures specification (draft-cavage-http-signatures). +// +// The caller provides a key store mapping key IDs to secrets (each pairing a +// shared key with a signing algorithm). Optional httpsign.Option values may +// configure required headers or custom validators; sensible defaults apply +// when omitted (date, digest, and request-target headers are required; date +// and digest validators are enabled). +// +// Requests with a missing, malformed, or invalid signature are rejected with +// 401 Unauthorised or 400 Bad Request. +func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option { + return func(e *Engine) { + auth := httpsign.NewAuthenticator(secrets, opts...) + e.middlewares = append(e.middlewares, auth.Authenticated()) + } +} + +// WithSSE registers a Server-Sent Events broker at GET /events. +// Clients connect to the endpoint and receive a streaming text/event-stream +// response. The broker manages client connections and broadcasts events +// published via its Publish method. +func WithSSE(broker *SSEBroker) Option { + return func(e *Engine) { + e.sseBroker = broker + } +} + +// WithLocation adds reverse proxy header detection middleware via +// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host +// headers to determine the original scheme and host when the server runs +// behind a TLS-terminating reverse proxy such as Traefik. +// +// After this middleware runs, handlers can call location.Get(c) to retrieve +// a *url.URL with the detected scheme, host, and base path. +func WithLocation() Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, location.Default()) + } +} + +// WithGraphQL mounts a GraphQL endpoint serving the given gqlgen ExecutableSchema. +// By default the endpoint is mounted at "/graphql". Use GraphQLOption helpers to +// enable the playground UI or customise the path: +// +// api.New( +// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")), +// ) +func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option { + return func(e *Engine) { + cfg := &graphqlConfig{ + schema: schema, + path: defaultGraphQLPath, + } + for _, opt := range opts { + opt(cfg) + } + e.graphql = cfg + } +} diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go new file mode 100644 index 0000000..ff8710e --- /dev/null +++ b/pkg/provider/provider.go @@ -0,0 +1,52 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +// Package provider defines the Service Provider Framework interfaces. +// +// A Provider extends api.RouteGroup with a provider identity. Providers +// register through the existing api.Engine.Register() method, inheriting +// middleware, CORS, Swagger, and OpenAPI generation automatically. +// +// Optional interfaces (Streamable, Describable, Renderable) declare +// additional capabilities that consumers (GUI, MCP, WS hub) can discover +// via type assertion. +package provider + +import ( + "forge.lthn.ai/core/api" +) + +// Provider extends RouteGroup with a provider identity. +// Every Provider is a RouteGroup and registers through api.Engine.Register(). +type Provider interface { + api.RouteGroup // Name(), BasePath(), RegisterRoutes(*gin.RouterGroup) +} + +// Streamable providers emit real-time events via WebSocket. +// The hub is injected at construction time. Channels() declares the +// event prefixes this provider will emit (e.g. "brain.*", "process.*"). +type Streamable interface { + Provider + Channels() []string +} + +// Describable providers expose structured route descriptions for OpenAPI. +// This extends the existing DescribableGroup interface from go-api. +type Describable interface { + Provider + api.DescribableGroup // Describe() []RouteDescription +} + +// Renderable providers declare a custom element for GUI display. +type Renderable interface { + Provider + Element() ElementSpec +} + +// ElementSpec describes a web component for GUI rendering. +type ElementSpec struct { + // Tag is the custom element tag name, e.g. "core-brain-panel". + Tag string `json:"tag"` + + // Source is the URL or embedded path to the JS bundle. + Source string `json:"source"` +} diff --git a/pkg/provider/proxy.go b/pkg/provider/proxy.go new file mode 100644 index 0000000..f7d3831 --- /dev/null +++ b/pkg/provider/proxy.go @@ -0,0 +1,12 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package provider + +// ProxyProvider will wrap polyglot (PHP/TS) providers that publish an OpenAPI +// spec and run their own HTTP handler. The Go API layer reverse-proxies to +// their endpoint. +// +// This is a Phase 3 feature. The type is declared here as a forward reference +// so the package structure is established. +// +// See the design spec SS Polyglot Providers for the full ProxyProvider contract. diff --git a/pkg/provider/registry.go b/pkg/provider/registry.go new file mode 100644 index 0000000..4af76c0 --- /dev/null +++ b/pkg/provider/registry.go @@ -0,0 +1,146 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package provider + +import ( + "iter" + "slices" + "sync" + + "forge.lthn.ai/core/api" +) + +// Registry collects providers and mounts them on an api.Engine. +// It is a convenience wrapper — providers could be registered directly +// via engine.Register(), but the Registry enables discovery by consumers +// (GUI, MCP) that need to query provider capabilities. +type Registry struct { + mu sync.RWMutex + providers []Provider +} + +// NewRegistry creates an empty provider registry. +func NewRegistry() *Registry { + return &Registry{} +} + +// Add registers a provider. Providers are mounted in the order they are added. +func (r *Registry) Add(p Provider) { + r.mu.Lock() + defer r.mu.Unlock() + r.providers = append(r.providers, p) +} + +// MountAll registers every provider with the given api.Engine. +// Each provider is passed to engine.Register(), which mounts it as a +// RouteGroup at its BasePath with all configured middleware. +func (r *Registry) MountAll(engine *api.Engine) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, p := range r.providers { + engine.Register(p) + } +} + +// List returns a copy of all registered providers. +func (r *Registry) List() []Provider { + r.mu.RLock() + defer r.mu.RUnlock() + return slices.Clone(r.providers) +} + +// Iter returns an iterator over all registered providers. +func (r *Registry) Iter() iter.Seq[Provider] { + r.mu.RLock() + defer r.mu.RUnlock() + return slices.Values(slices.Clone(r.providers)) +} + +// Len returns the number of registered providers. +func (r *Registry) Len() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.providers) +} + +// Get returns a provider by name, or nil if not found. +func (r *Registry) Get(name string) Provider { + r.mu.RLock() + defer r.mu.RUnlock() + for _, p := range r.providers { + if p.Name() == name { + return p + } + } + return nil +} + +// Streamable returns all providers that implement the Streamable interface. +func (r *Registry) Streamable() []Streamable { + r.mu.RLock() + defer r.mu.RUnlock() + var result []Streamable + for _, p := range r.providers { + if s, ok := p.(Streamable); ok { + result = append(result, s) + } + } + return result +} + +// Describable returns all providers that implement the Describable interface. +func (r *Registry) Describable() []Describable { + r.mu.RLock() + defer r.mu.RUnlock() + var result []Describable + for _, p := range r.providers { + if d, ok := p.(Describable); ok { + result = append(result, d) + } + } + return result +} + +// Renderable returns all providers that implement the Renderable interface. +func (r *Registry) Renderable() []Renderable { + r.mu.RLock() + defer r.mu.RUnlock() + var result []Renderable + for _, p := range r.providers { + if rv, ok := p.(Renderable); ok { + result = append(result, rv) + } + } + return result +} + +// ProviderInfo is a serialisable summary of a registered provider. +type ProviderInfo struct { + Name string `json:"name"` + BasePath string `json:"basePath"` + Channels []string `json:"channels,omitempty"` + Element *ElementSpec `json:"element,omitempty"` +} + +// Info returns a summary of all registered providers. +func (r *Registry) Info() []ProviderInfo { + r.mu.RLock() + defer r.mu.RUnlock() + + infos := make([]ProviderInfo, 0, len(r.providers)) + for _, p := range r.providers { + info := ProviderInfo{ + Name: p.Name(), + BasePath: p.BasePath(), + } + if s, ok := p.(Streamable); ok { + info.Channels = s.Channels() + } + if rv, ok := p.(Renderable); ok { + elem := rv.Element() + info.Element = &elem + } + infos = append(infos, info) + } + return infos +} diff --git a/pkg/provider/registry_test.go b/pkg/provider/registry_test.go new file mode 100644 index 0000000..24ea464 --- /dev/null +++ b/pkg/provider/registry_test.go @@ -0,0 +1,160 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package provider_test + +import ( + "testing" + + "forge.lthn.ai/core/api" + "forge.lthn.ai/core/api/pkg/provider" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// -- Test helpers (minimal providers) ----------------------------------------- + +type stubProvider struct{} + +func (s *stubProvider) Name() string { return "stub" } +func (s *stubProvider) BasePath() string { return "/api/stub" } +func (s *stubProvider) RegisterRoutes(rg *gin.RouterGroup) {} + +type streamableProvider struct{ stubProvider } + +func (s *streamableProvider) Channels() []string { return []string{"stub.event"} } + +type describableProvider struct{ stubProvider } + +func (d *describableProvider) Describe() []api.RouteDescription { + return []api.RouteDescription{ + {Method: "GET", Path: "/items", Summary: "List items", Tags: []string{"stub"}}, + } +} + +type renderableProvider struct{ stubProvider } + +func (r *renderableProvider) Element() provider.ElementSpec { + return provider.ElementSpec{Tag: "core-stub-panel", Source: "/assets/stub.js"} +} + +type fullProvider struct { + streamableProvider +} + +func (f *fullProvider) Name() string { return "full" } +func (f *fullProvider) BasePath() string { return "/api/full" } +func (f *fullProvider) Describe() []api.RouteDescription { + return []api.RouteDescription{ + {Method: "GET", Path: "/status", Summary: "Status", Tags: []string{"full"}}, + } +} +func (f *fullProvider) Element() provider.ElementSpec { + return provider.ElementSpec{Tag: "core-full-panel", Source: "/assets/full.js"} +} + +// -- Tests -------------------------------------------------------------------- + +func TestRegistry_Add_Good(t *testing.T) { + reg := provider.NewRegistry() + assert.Equal(t, 0, reg.Len()) + + reg.Add(&stubProvider{}) + assert.Equal(t, 1, reg.Len()) + + reg.Add(&streamableProvider{}) + assert.Equal(t, 2, reg.Len()) +} + +func TestRegistry_Get_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&stubProvider{}) + + p := reg.Get("stub") + require.NotNil(t, p) + assert.Equal(t, "stub", p.Name()) +} + +func TestRegistry_Get_Bad(t *testing.T) { + reg := provider.NewRegistry() + p := reg.Get("nonexistent") + assert.Nil(t, p) +} + +func TestRegistry_List_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&stubProvider{}) + reg.Add(&streamableProvider{}) + + list := reg.List() + assert.Len(t, list, 2) +} + +func TestRegistry_MountAll_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&stubProvider{}) + reg.Add(&streamableProvider{}) + + engine, err := api.New() + require.NoError(t, err) + + reg.MountAll(engine) + assert.Len(t, engine.Groups(), 2) +} + +func TestRegistry_Streamable_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&stubProvider{}) // not streamable + reg.Add(&streamableProvider{}) // streamable + + s := reg.Streamable() + assert.Len(t, s, 1) + assert.Equal(t, []string{"stub.event"}, s[0].Channels()) +} + +func TestRegistry_Describable_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&stubProvider{}) // not describable + reg.Add(&describableProvider{}) // describable + + d := reg.Describable() + assert.Len(t, d, 1) + assert.Len(t, d[0].Describe(), 1) +} + +func TestRegistry_Renderable_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&stubProvider{}) // not renderable + reg.Add(&renderableProvider{}) // renderable + + r := reg.Renderable() + assert.Len(t, r, 1) + assert.Equal(t, "core-stub-panel", r[0].Element().Tag) +} + +func TestRegistry_Info_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&fullProvider{}) + + infos := reg.Info() + require.Len(t, infos, 1) + + info := infos[0] + assert.Equal(t, "full", info.Name) + assert.Equal(t, "/api/full", info.BasePath) + assert.Equal(t, []string{"stub.event"}, info.Channels) + require.NotNil(t, info.Element) + assert.Equal(t, "core-full-panel", info.Element.Tag) +} + +func TestRegistry_Iter_Good(t *testing.T) { + reg := provider.NewRegistry() + reg.Add(&stubProvider{}) + reg.Add(&streamableProvider{}) + + count := 0 + for range reg.Iter() { + count++ + } + assert.Equal(t, 2, count) +} diff --git a/pprof_test.go b/pprof_test.go new file mode 100644 index 0000000..307b246 --- /dev/null +++ b/pprof_test.go @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Pprof profiling endpoints ───────────────────────────────────────── + +func TestWithPprof_Good_IndexAccessible(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithPprof()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/pprof/") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/pprof/, got %d", resp.StatusCode) + } +} + +func TestWithPprof_Good_ProfileEndpointExists(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithPprof()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/pprof/heap") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/pprof/heap, got %d", resp.StatusCode) + } +} + +func TestWithPprof_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithRequestID(), api.WithPprof()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/pprof/") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/pprof/ with middleware, got %d", resp.StatusCode) + } + + // Verify the request ID middleware is still active. + rid := resp.Header.Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID middleware") + } +} + +func TestWithPprof_Bad_NotMountedWithoutOption(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/debug/pprof/", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /debug/pprof/ without WithPprof, got %d", w.Code) + } +} + +func TestWithPprof_Good_CmdlineEndpointExists(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithPprof()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/debug/pprof/cmdline") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200 for /debug/pprof/cmdline, got %d", resp.StatusCode) + } +} diff --git a/race_test.go b/race_test.go new file mode 100644 index 0000000..9ce40dd --- /dev/null +++ b/race_test.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: EUPL-1.2 +//go:build race + +package api_test + +const raceDetectorEnabled = true diff --git a/response.go b/response.go new file mode 100644 index 0000000..2a77e18 --- /dev/null +++ b/response.go @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +// Response is the standard envelope for all API responses. +type Response[T any] struct { + Success bool `json:"success"` + Data T `json:"data,omitempty"` + Error *Error `json:"error,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +// Error describes a failed API request. +type Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details any `json:"details,omitempty"` +} + +// Meta carries pagination and request metadata. +type Meta struct { + RequestID string `json:"request_id,omitempty"` + Duration string `json:"duration,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + Total int `json:"total,omitempty"` +} + +// OK wraps data in a successful response envelope. +func OK[T any](data T) Response[T] { + return Response[T]{ + Success: true, + Data: data, + } +} + +// Fail creates an error response with the given code and message. +func Fail(code, message string) Response[any] { + return Response[any]{ + Success: false, + Error: &Error{ + Code: code, + Message: message, + }, + } +} + +// FailWithDetails creates an error response with additional detail payload. +func FailWithDetails(code, message string, details any) Response[any] { + return Response[any]{ + Success: false, + Error: &Error{ + Code: code, + Message: message, + Details: details, + }, + } +} + +// Paginated wraps data in a successful response with pagination metadata. +func Paginated[T any](data T, page, perPage, total int) Response[T] { + return Response[T]{ + Success: true, + Data: data, + Meta: &Meta{ + Page: page, + PerPage: perPage, + Total: total, + }, + } +} diff --git a/response_test.go b/response_test.go new file mode 100644 index 0000000..f2f5819 --- /dev/null +++ b/response_test.go @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "testing" + + api "forge.lthn.ai/core/api" +) + +// ── OK ────────────────────────────────────────────────────────────────── + +func TestOK_Good(t *testing.T) { + r := api.OK("hello") + + if !r.Success { + t.Fatal("expected Success=true") + } + if r.Data != "hello" { + t.Fatalf("expected Data=%q, got %q", "hello", r.Data) + } + if r.Error != nil { + t.Fatal("expected Error to be nil") + } + if r.Meta != nil { + t.Fatal("expected Meta to be nil") + } +} + +func TestOK_Good_StructData(t *testing.T) { + type user struct { + Name string `json:"name"` + } + r := api.OK(user{Name: "Ada"}) + + if !r.Success { + t.Fatal("expected Success=true") + } + if r.Data.Name != "Ada" { + t.Fatalf("expected Data.Name=%q, got %q", "Ada", r.Data.Name) + } +} + +func TestOK_Good_JSONOmitsErrorAndMeta(t *testing.T) { + r := api.OK("data") + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if _, ok := raw["error"]; ok { + t.Fatal("expected 'error' field to be omitted from JSON") + } + if _, ok := raw["meta"]; ok { + t.Fatal("expected 'meta' field to be omitted from JSON") + } + if _, ok := raw["success"]; !ok { + t.Fatal("expected 'success' field to be present") + } + if _, ok := raw["data"]; !ok { + t.Fatal("expected 'data' field to be present") + } +} + +// ── Fail ──────────────────────────────────────────────────────────────── + +func TestFail_Good(t *testing.T) { + r := api.Fail("NOT_FOUND", "resource not found") + + if r.Success { + t.Fatal("expected Success=false") + } + if r.Error == nil { + t.Fatal("expected Error to be non-nil") + } + if r.Error.Code != "NOT_FOUND" { + t.Fatalf("expected Code=%q, got %q", "NOT_FOUND", r.Error.Code) + } + if r.Error.Message != "resource not found" { + t.Fatalf("expected Message=%q, got %q", "resource not found", r.Error.Message) + } + if r.Error.Details != nil { + t.Fatal("expected Details to be nil") + } +} + +func TestFail_Good_JSONOmitsData(t *testing.T) { + r := api.Fail("ERR", "something went wrong") + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if _, ok := raw["data"]; ok { + t.Fatal("expected 'data' field to be omitted from JSON") + } + if _, ok := raw["error"]; !ok { + t.Fatal("expected 'error' field to be present") + } +} + +// ── FailWithDetails ───────────────────────────────────────────────────── + +func TestFailWithDetails_Good(t *testing.T) { + details := map[string]string{"field": "email", "reason": "invalid format"} + r := api.FailWithDetails("VALIDATION", "validation failed", details) + + if r.Success { + t.Fatal("expected Success=false") + } + if r.Error == nil { + t.Fatal("expected Error to be non-nil") + } + if r.Error.Code != "VALIDATION" { + t.Fatalf("expected Code=%q, got %q", "VALIDATION", r.Error.Code) + } + if r.Error.Details == nil { + t.Fatal("expected Details to be non-nil") + } +} + +func TestFailWithDetails_Good_JSONIncludesDetails(t *testing.T) { + r := api.FailWithDetails("ERR", "bad", "extra info") + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + errObj, ok := raw["error"].(map[string]any) + if !ok { + t.Fatal("expected 'error' to be an object") + } + if _, ok := errObj["details"]; !ok { + t.Fatal("expected 'details' field to be present in error") + } +} + +// ── Paginated ─────────────────────────────────────────────────────────── + +func TestPaginated_Good(t *testing.T) { + items := []string{"a", "b", "c"} + r := api.Paginated(items, 2, 25, 100) + + if !r.Success { + t.Fatal("expected Success=true") + } + if len(r.Data) != 3 { + t.Fatalf("expected 3 items, got %d", len(r.Data)) + } + if r.Meta == nil { + t.Fatal("expected Meta to be non-nil") + } + if r.Meta.Page != 2 { + t.Fatalf("expected Page=2, got %d", r.Meta.Page) + } + if r.Meta.PerPage != 25 { + t.Fatalf("expected PerPage=25, got %d", r.Meta.PerPage) + } + if r.Meta.Total != 100 { + t.Fatalf("expected Total=100, got %d", r.Meta.Total) + } +} + +func TestPaginated_Good_JSONIncludesMeta(t *testing.T) { + r := api.Paginated([]int{1}, 1, 10, 50) + b, err := json.Marshal(r) + if err != nil { + t.Fatalf("marshal error: %v", err) + } + + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if _, ok := raw["meta"]; !ok { + t.Fatal("expected 'meta' field to be present") + } + meta := raw["meta"].(map[string]any) + if meta["page"].(float64) != 1 { + t.Fatalf("expected page=1, got %v", meta["page"]) + } + if meta["per_page"].(float64) != 10 { + t.Fatalf("expected per_page=10, got %v", meta["per_page"]) + } + if meta["total"].(float64) != 50 { + t.Fatalf("expected total=50, got %v", meta["total"]) + } +} diff --git a/secure_test.go b/secure_test.go new file mode 100644 index 0000000..409452b --- /dev/null +++ b/secure_test.go @@ -0,0 +1,185 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── WithSecure ────────────────────────────────────────────────────────── + +func TestWithSecure_Good_SetsHSTSHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + sts := w.Header().Get("Strict-Transport-Security") + if sts == "" { + t.Fatal("expected Strict-Transport-Security header to be set") + } + if !strings.Contains(sts, "max-age=31536000") { + t.Fatalf("expected max-age=31536000 in STS header, got %q", sts) + } + if !strings.Contains(strings.ToLower(sts), "includesubdomains") { + t.Fatalf("expected includeSubdomains in STS header, got %q", sts) + } +} + +func TestWithSecure_Good_SetsFrameOptionsDeny(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + xfo := w.Header().Get("X-Frame-Options") + if xfo != "DENY" { + t.Fatalf("expected X-Frame-Options=%q, got %q", "DENY", xfo) + } +} + +func TestWithSecure_Good_SetsContentTypeNosniff(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + cto := w.Header().Get("X-Content-Type-Options") + if cto != "nosniff" { + t.Fatalf("expected X-Content-Type-Options=%q, got %q", "nosniff", cto) + } +} + +func TestWithSecure_Good_SetsReferrerPolicy(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + rp := w.Header().Get("Referrer-Policy") + if rp != "strict-origin-when-cross-origin" { + t.Fatalf("expected Referrer-Policy=%q, got %q", "strict-origin-when-cross-origin", rp) + } +} + +func TestWithSecure_Good_AllHeadersPresent(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Verify all security headers are present on a regular route. + checks := map[string]string{ + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin", + } + + for header, want := range checks { + got := w.Header().Get(header) + if got != want { + t.Errorf("header %s: expected %q, got %q", header, want, got) + } + } + + sts := w.Header().Get("Strict-Transport-Security") + if sts == "" { + t.Error("expected Strict-Transport-Security header to be set") + } +} + +func TestWithSecure_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithSecure(), + api.WithRequestID(), + ) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Both secure headers and request ID should be present. + if w.Header().Get("X-Frame-Options") != "DENY" { + t.Fatal("expected X-Frame-Options header from WithSecure") + } + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithSecure_Bad_NoSSLRedirect(t *testing.T) { + // SSL redirect is not enabled — the middleware runs behind a TLS-terminating + // reverse proxy. Verify plain HTTP requests are not redirected. + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSecure()) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + // Should get 200, not a 301/302 redirect. + if w.Code != http.StatusOK { + t.Fatalf("expected 200 (no SSL redirect), got %d", w.Code) + } +} + +func TestWithSecure_Ugly_DoubleSecureDoesNotPanic(t *testing.T) { + // Applying WithSecure twice should not panic or cause issues. + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithSecure(), + api.WithSecure(), + ) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Headers should still be correctly set. + if w.Header().Get("X-Frame-Options") != "DENY" { + t.Fatal("expected X-Frame-Options=DENY after double WithSecure") + } +} diff --git a/sessions_test.go b/sessions_test.go new file mode 100644 index 0000000..16e8218 --- /dev/null +++ b/sessions_test.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-contrib/sessions" + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Helpers ───────────────────────────────────────────────────────────── + +// sessionTestGroup provides /sess/set and /sess/get endpoints for session tests. +type sessionTestGroup struct{} + +func (s *sessionTestGroup) Name() string { return "sess" } +func (s *sessionTestGroup) BasePath() string { return "/sess" } +func (s *sessionTestGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.POST("/set", func(c *gin.Context) { + session := sessions.Default(c) + session.Set("key", "value") + session.Save() + c.JSON(http.StatusOK, api.OK("saved")) + }) + rg.GET("/get", func(c *gin.Context) { + session := sessions.Default(c) + val := session.Get("key") + c.JSON(http.StatusOK, api.OK(val)) + }) +} + +// ── WithSessions ──────────────────────────────────────────────────────── + +func TestWithSessions_Good_SetsSessionCookie(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!"))) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + cookies := w.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == "session" { + found = true + break + } + } + if !found { + t.Fatal("expected Set-Cookie header with name 'session'") + } +} + +func TestWithSessions_Good_SessionPersistsAcrossRequests(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!"))) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + + // First request: set session value. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("set: expected 200, got %d", w1.Code) + } + + // Extract the session cookie from the response. + var sessionCookie *http.Cookie + for _, c := range w1.Result().Cookies() { + if c.Name == "session" { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("set: expected session cookie in response") + } + + // Second request: get session value, sending the cookie back. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/sess/get", nil) + req2.AddCookie(sessionCookie) + h.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("get: expected 200, got %d", w2.Code) + } + + var resp api.Response[any] + if err := json.Unmarshal(w2.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + data, ok := resp.Data.(string) + if !ok || data != "value" { + t.Fatalf("expected Data=%q, got %v", "value", resp.Data) + } +} + +func TestWithSessions_Good_EmptySessionReturnsNil(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithSessions("session", []byte("test-secret-key!"))) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/sess/get", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + + if resp.Data != nil { + t.Fatalf("expected nil Data for empty session, got %v", resp.Data) + } +} + +func TestWithSessions_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithSessions("session", []byte("test-secret-key!")), + api.WithRequestID(), + ) + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Session cookie should be present. + found := false + for _, c := range w.Result().Cookies() { + if c.Name == "session" { + found = true + break + } + } + if !found { + t.Fatal("expected session cookie") + } + + // Request ID should also be present. + rid := w.Header().Get("X-Request-ID") + if rid == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithSessions_Ugly_DoubleSessionsDoesNotPanic(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Applying WithSessions twice should not panic. + e, err := api.New( + api.WithSessions("session", []byte("secret-one-here!")), + api.WithSessions("session", []byte("secret-two-here!")), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + e.Register(&sessionTestGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/sess/set", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} diff --git a/slog_test.go b/slog_test.go new file mode 100644 index 0000000..742b0bb --- /dev/null +++ b/slog_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "bytes" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── WithSlog ────────────────────────────────────────────────────────── + +func TestWithSlog_Good_LogsRequestFields(t *testing.T) { + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) + + e, _ := api.New(api.WithSlog(logger)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + output := buf.String() + if output == "" { + t.Fatal("expected slog output, got empty string") + } + + // The structured log should contain request fields. + for _, field := range []string{"status", "method", "path", "latency", "ip"} { + if !bytes.Contains(buf.Bytes(), []byte(field)) { + t.Errorf("expected log output to contain field %q, got: %s", field, output) + } + } +} + +func TestWithSlog_Good_NilLoggerUsesDefault(t *testing.T) { + // Passing nil should not panic; it uses slog.Default(). + gin.SetMode(gin.TestMode) + + e, _ := api.New(api.WithSlog(nil)) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} + +func TestWithSlog_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + e, _ := api.New( + api.WithSlog(logger), + api.WithRequestID(), + ) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Both slog output and request ID header should be present. + if buf.Len() == 0 { + t.Fatal("expected slog output from WithSlog") + } + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithSlog_Good_Logs404Status(t *testing.T) { + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + e, _ := api.New(api.WithSlog(logger)) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/nonexistent", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } + + output := buf.String() + if output == "" { + t.Fatal("expected slog output for 404 request") + } + + // Should contain the 404 status. + if !bytes.Contains(buf.Bytes(), []byte("404")) { + t.Errorf("expected log to contain status 404, got: %s", output) + } +} + +func TestWithSlog_Bad_LogsMethodAndPath(t *testing.T) { + // Verifies POST method and custom path appear in log output. + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + e, _ := api.New(api.WithSlog(logger)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPost, "/stub/ping", nil) + h.ServeHTTP(w, req) + + output := buf.String() + if !bytes.Contains(buf.Bytes(), []byte("POST")) { + t.Errorf("expected log to contain method POST, got: %s", output) + } + if !bytes.Contains(buf.Bytes(), []byte("/stub/ping")) { + t.Errorf("expected log to contain path /stub/ping, got: %s", output) + } +} + +func TestWithSlog_Ugly_DoubleSlogDoesNotPanic(t *testing.T) { + // Applying WithSlog twice should not panic. + gin.SetMode(gin.TestMode) + + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + e, _ := api.New( + api.WithSlog(logger), + api.WithSlog(logger), + ) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/health", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } +} diff --git a/src/php/phpunit.xml b/src/php/phpunit.xml new file mode 100644 index 0000000..02250d6 --- /dev/null +++ b/src/php/phpunit.xml @@ -0,0 +1,33 @@ + + + + + tests/Unit + + + tests/Feature + + + + + src + + + + + + + + + + + + + + + + diff --git a/src/php/src/Api/Boot.php b/src/php/src/Api/Boot.php new file mode 100644 index 0000000..904a679 --- /dev/null +++ b/src/php/src/Api/Boot.php @@ -0,0 +1,146 @@ + + */ + public static array $listens = [ + AdminPanelBooting::class => 'onAdminPanel', + 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 webhook services + $this->app->singleton(Services\WebhookTemplateService::class); + $this->app->singleton(Services\WebhookSecretRotationService::class); + + // Register IP restriction service for API key whitelisting + $this->app->singleton(Services\IpRestrictionService::class); + + // Register API Documentation provider + $this->app->register(DocumentationServiceProvider::class); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + $this->loadMigrationsFrom(__DIR__.'/Migrations'); + $this->configureRateLimiting(); + } + + /** + * Configure rate limiters for API endpoints. + */ + protected function configureRateLimiting(): void + { + // Rate limit for webhook template operations: 30 per minute per user + RateLimiter::for('api-webhook-templates', function (Request $request) { + $user = $request->user(); + + return $user + ? Limit::perMinute(30)->by('user:'.$user->id) + : Limit::perMinute(10)->by($request->ip()); + }); + + // Rate limit for template preview/validation: 60 per minute per user + RateLimiter::for('api-template-preview', function (Request $request) { + $user = $request->user(); + + return $user + ? Limit::perMinute(60)->by('user:'.$user->id) + : Limit::perMinute(20)->by($request->ip()); + }); + } + + // ------------------------------------------------------------------------- + // Event-driven handlers + // ------------------------------------------------------------------------- + + public function onAdminPanel(AdminPanelBooting $event): void + { + $event->views($this->moduleName, __DIR__.'/View/Blade'); + + if (file_exists(__DIR__.'/Routes/admin.php')) { + $event->routes(fn () => require __DIR__.'/Routes/admin.php'); + } + + $event->livewire('api.webhook-template-manager', View\Modal\Admin\WebhookTemplateManager::class); + } + + 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); + } +} diff --git a/src/php/src/Api/Concerns/HasApiResponses.php b/src/php/src/Api/Concerns/HasApiResponses.php new file mode 100644 index 0000000..3ab973b --- /dev/null +++ b/src/php/src/Api/Concerns/HasApiResponses.php @@ -0,0 +1,124 @@ +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); + } + + /** + * Return an invalid status error response. + * + * Used when an operation cannot be performed due to the resource's current status. + */ + protected function invalidStatusResponse(string $message): JsonResponse + { + return response()->json([ + 'error' => 'invalid_status', + 'message' => $message, + ], 422); + } + + /** + * Return a provider error response. + * + * Used when an external provider operation fails. + */ + protected function providerErrorResponse(string $message, ?string $provider = null): JsonResponse + { + $response = [ + 'error' => 'provider_error', + 'message' => $message, + ]; + + if ($provider !== null) { + $response['provider'] = $provider; + } + + return response()->json($response, 400); + } +} diff --git a/src/php/src/Api/Concerns/HasApiTokens.php b/src/php/src/Api/Concerns/HasApiTokens.php new file mode 100644 index 0000000..f6e243e --- /dev/null +++ b/src/php/src/Api/Concerns/HasApiTokens.php @@ -0,0 +1,76 @@ + + */ + 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(); + } +} diff --git a/src/php/src/Api/Concerns/ResolvesWorkspace.php b/src/php/src/Api/Concerns/ResolvesWorkspace.php new file mode 100644 index 0000000..fe85e7d --- /dev/null +++ b/src/php/src/Api/Concerns/ResolvesWorkspace.php @@ -0,0 +1,84 @@ +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'; + } +} diff --git a/src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php b/src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php new file mode 100644 index 0000000..1c744a3 --- /dev/null +++ b/src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php @@ -0,0 +1,291 @@ +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}"; + } +} diff --git a/src/php/src/Api/Console/Commands/CleanupExpiredGracePeriods.php b/src/php/src/Api/Console/Commands/CleanupExpiredGracePeriods.php new file mode 100644 index 0000000..2cf5f26 --- /dev/null +++ b/src/php/src/Api/Console/Commands/CleanupExpiredGracePeriods.php @@ -0,0 +1,67 @@ +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; + } +} diff --git a/src/php/src/Api/Console/Commands/CleanupExpiredSecrets.php b/src/php/src/Api/Console/Commands/CleanupExpiredSecrets.php new file mode 100644 index 0000000..c7f44cb --- /dev/null +++ b/src/php/src/Api/Console/Commands/CleanupExpiredSecrets.php @@ -0,0 +1,141 @@ + + */ + protected array $webhookModels = [ + 'social' => Webhook::class, + 'content' => ContentWebhookEndpoint::class, + ]; + + public function handle(WebhookSecretRotationService $service): int + { + $dryRun = $this->option('dry-run'); + $modelFilter = $this->option('model'); + + $this->info('Starting webhook secret cleanup...'); + + if ($dryRun) { + $this->warn('DRY RUN MODE - No data will be modified'); + } + + $startTime = microtime(true); + $totalCleaned = 0; + + $modelsToProcess = $this->getModelsToProcess($modelFilter); + + if (empty($modelsToProcess)) { + $this->error('No valid models to process.'); + + return Command::FAILURE; + } + + foreach ($modelsToProcess as $name => $modelClass) { + if (! class_exists($modelClass)) { + $this->warn("Model class {$modelClass} not found, skipping..."); + + continue; + } + + $this->info("Processing {$name} webhooks..."); + + if ($dryRun) { + $count = $this->countExpiredGracePeriods($modelClass, $service); + $this->line(" Would clean up: {$count} webhook(s)"); + $totalCleaned += $count; + } else { + $count = $service->cleanupExpiredGracePeriods($modelClass); + $this->line(" Cleaned up: {$count} webhook(s)"); + $totalCleaned += $count; + } + } + + $elapsed = round(microtime(true) - $startTime, 2); + + $this->newLine(); + $this->info('Cleanup Summary:'); + $this->line(" Total cleaned: {$totalCleaned} webhook(s)"); + $this->line(" Time elapsed: {$elapsed}s"); + + if (! $dryRun && $totalCleaned > 0) { + Log::info('Webhook secret cleanup completed', [ + 'total_cleaned' => $totalCleaned, + 'elapsed_seconds' => $elapsed, + ]); + } + + $this->info('Webhook secret cleanup complete.'); + + return Command::SUCCESS; + } + + /** + * Get the webhook models to process based on the filter. + * + * @return array + */ + protected function getModelsToProcess(?string $filter): array + { + if ($filter === null) { + return $this->webhookModels; + } + + $filter = strtolower($filter); + + if (! isset($this->webhookModels[$filter])) { + $this->error("Invalid model filter: {$filter}"); + $this->line('Available models: '.implode(', ', array_keys($this->webhookModels))); + + return []; + } + + return [$filter => $this->webhookModels[$filter]]; + } + + /** + * Count webhooks with expired grace periods (for dry run). + */ + protected function countExpiredGracePeriods(string $modelClass, WebhookSecretRotationService $service): int + { + $count = 0; + + $modelClass::query() + ->whereNotNull('previous_secret') + ->whereNotNull('secret_rotated_at') + ->chunkById(100, function ($webhooks) use ($service, &$count) { + foreach ($webhooks as $webhook) { + if (! $service->isInGracePeriod($webhook)) { + $count++; + } + } + }); + + return $count; + } +} diff --git a/src/php/src/Api/Contracts/WebhookEvent.php b/src/php/src/Api/Contracts/WebhookEvent.php new file mode 100644 index 0000000..613e5f6 --- /dev/null +++ b/src/php/src/Api/Contracts/WebhookEvent.php @@ -0,0 +1,35 @@ + + */ + public function payload(): array; + + /** + * Get a human-readable message describing the event. + */ + public function message(): string; +} diff --git a/src/php/src/Api/Controllers/Api/WebhookSecretController.php b/src/php/src/Api/Controllers/Api/WebhookSecretController.php new file mode 100644 index 0000000..5dee8c1 --- /dev/null +++ b/src/php/src/Api/Controllers/Api/WebhookSecretController.php @@ -0,0 +1,268 @@ +user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'nullable|integer|min:300|max:604800', // 5 min to 7 days + ]); + + $newSecret = $this->rotationService->rotateSecret( + $webhook, + $validated['grace_period_seconds'] ?? null + ); + + return response()->json([ + 'success' => true, + 'message' => 'Secret rotated successfully', + 'data' => [ + 'secret' => $newSecret, + 'status' => $this->rotationService->getSecretStatus($webhook->fresh()), + ], + ]); + } + + /** + * Rotate a content webhook endpoint secret. + */ + public function rotateContentSecret(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'nullable|integer|min:300|max:604800', + ]); + + $newSecret = $this->rotationService->rotateSecret( + $endpoint, + $validated['grace_period_seconds'] ?? null + ); + + return response()->json([ + 'success' => true, + 'message' => 'Secret rotated successfully', + 'data' => [ + 'secret' => $newSecret, + 'status' => $this->rotationService->getSecretStatus($endpoint->fresh()), + ], + ]); + } + + /** + * Get secret rotation status for a social webhook. + */ + public function socialSecretStatus(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + return response()->json([ + 'data' => $this->rotationService->getSecretStatus($webhook), + ]); + } + + /** + * Get secret rotation status for a content webhook endpoint. + */ + public function contentSecretStatus(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + return response()->json([ + 'data' => $this->rotationService->getSecretStatus($endpoint), + ]); + } + + /** + * Invalidate the previous secret for a social webhook. + */ + public function invalidateSocialPreviousSecret(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + $this->rotationService->invalidatePreviousSecret($webhook); + + return response()->json([ + 'success' => true, + 'message' => 'Previous secret invalidated', + ]); + } + + /** + * Invalidate the previous secret for a content webhook endpoint. + */ + public function invalidateContentPreviousSecret(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + $this->rotationService->invalidatePreviousSecret($endpoint); + + return response()->json([ + 'success' => true, + 'message' => 'Previous secret invalidated', + ]); + } + + /** + * Update the grace period for a social webhook. + */ + public function updateSocialGracePeriod(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $webhook = Webhook::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $webhook) { + return response()->json(['error' => 'Webhook not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'required|integer|min:300|max:604800', + ]); + + $this->rotationService->updateGracePeriod($webhook, $validated['grace_period_seconds']); + + return response()->json([ + 'success' => true, + 'message' => 'Grace period updated', + 'data' => [ + 'grace_period_seconds' => $webhook->fresh()->grace_period_seconds, + ], + ]); + } + + /** + * Update the grace period for a content webhook endpoint. + */ + public function updateContentGracePeriod(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $endpoint) { + return response()->json(['error' => 'Webhook endpoint not found'], 404); + } + + $validated = $request->validate([ + 'grace_period_seconds' => 'required|integer|min:300|max:604800', + ]); + + $this->rotationService->updateGracePeriod($endpoint, $validated['grace_period_seconds']); + + return response()->json([ + 'success' => true, + 'message' => 'Grace period updated', + 'data' => [ + 'grace_period_seconds' => $endpoint->fresh()->grace_period_seconds, + ], + ]); + } +} diff --git a/src/php/src/Api/Controllers/Api/WebhookTemplateController.php b/src/php/src/Api/Controllers/Api/WebhookTemplateController.php new file mode 100644 index 0000000..d9f20eb --- /dev/null +++ b/src/php/src/Api/Controllers/Api/WebhookTemplateController.php @@ -0,0 +1,369 @@ +user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $query = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->active() + ->ordered(); + + // Optional filtering + if ($request->has('builtin')) { + $request->boolean('builtin') + ? $query->builtin() + : $query->custom(); + } + + $templates = $query->get()->map(fn ($template) => $this->formatTemplate($template)); + + return response()->json([ + 'data' => $templates, + 'meta' => [ + 'total' => $templates->count(), + ], + ]); + } + + /** + * Get a single template by UUID. + */ + public function show(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + return response()->json([ + 'data' => $this->formatTemplate($template, true), + ]); + } + + /** + * Create a new template. + */ + public function store(Request $request): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'description' => 'nullable|string|max:1000', + 'format' => 'required|in:simple,mustache,json', + 'template' => 'required|string|max:65535', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]); + + // Validate template syntax + $format = WebhookTemplateFormat::from($validated['format']); + $validation = $this->templateService->validateTemplate($validated['template'], $format); + + if (! $validation['valid']) { + return response()->json([ + 'error' => 'Invalid template', + 'errors' => $validation['errors'], + ], 422); + } + + $template = WebhookPayloadTemplate::create([ + 'uuid' => Str::uuid()->toString(), + 'workspace_id' => $workspace->id, + 'namespace_id' => $workspace->default_namespace_id ?? null, + 'name' => $validated['name'], + 'description' => $validated['description'] ?? null, + 'format' => $validated['format'], + 'template' => $validated['template'], + 'is_default' => $validated['is_default'] ?? false, + 'is_active' => $validated['is_active'] ?? true, + ]); + + return response()->json([ + 'data' => $this->formatTemplate($template, true), + ], 201); + } + + /** + * Update an existing template. + */ + public function update(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + $validated = $request->validate([ + 'name' => 'sometimes|string|max:255', + 'description' => 'nullable|string|max:1000', + 'format' => 'sometimes|in:simple,mustache,json', + 'template' => 'sometimes|string|max:65535', + 'is_default' => 'boolean', + 'is_active' => 'boolean', + ]); + + // Validate template syntax if template is being updated + if (isset($validated['template'])) { + $format = WebhookTemplateFormat::from($validated['format'] ?? $template->format->value); + $validation = $this->templateService->validateTemplate($validated['template'], $format); + + if (! $validation['valid']) { + return response()->json([ + 'error' => 'Invalid template', + 'errors' => $validation['errors'], + ], 422); + } + } + + // Don't allow modifying builtin templates' format + if ($template->isBuiltin()) { + unset($validated['format']); + } + + $template->update($validated); + + return response()->json([ + 'data' => $this->formatTemplate($template->fresh(), true), + ]); + } + + /** + * Delete a template. + */ + public function destroy(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + // Don't allow deleting builtin templates + if ($template->isBuiltin()) { + return response()->json(['error' => 'Built-in templates cannot be deleted'], 403); + } + + $template->delete(); + + return response()->json(null, 204); + } + + /** + * Validate a template without saving. + */ + public function validate(Request $request): JsonResponse + { + $validated = $request->validate([ + 'format' => 'required|in:simple,mustache,json', + 'template' => 'required|string|max:65535', + ]); + + $format = WebhookTemplateFormat::from($validated['format']); + $validation = $this->templateService->validateTemplate($validated['template'], $format); + + return response()->json([ + 'valid' => $validation['valid'], + 'errors' => $validation['errors'], + ]); + } + + /** + * Preview a template with sample data. + */ + public function preview(Request $request): JsonResponse + { + $validated = $request->validate([ + 'format' => 'required|in:simple,mustache,json', + 'template' => 'required|string|max:65535', + 'event_type' => 'nullable|string|max:100', + ]); + + $format = WebhookTemplateFormat::from($validated['format']); + $result = $this->templateService->previewPayload( + $validated['template'], + $format, + $validated['event_type'] ?? null + ); + + return response()->json($result); + } + + /** + * Duplicate an existing template. + */ + public function duplicate(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + $newName = $request->input('name', $template->name.' (copy)'); + $duplicate = $template->duplicate($newName); + + return response()->json([ + 'data' => $this->formatTemplate($duplicate, true), + ], 201); + } + + /** + * Set a template as the workspace default. + */ + public function setDefault(Request $request, string $uuid): JsonResponse + { + $workspace = $request->user()?->defaultHostWorkspace(); + + if (! $workspace) { + return response()->json(['error' => 'Workspace not found'], 404); + } + + $template = WebhookPayloadTemplate::where('workspace_id', $workspace->id) + ->where('uuid', $uuid) + ->first(); + + if (! $template) { + return response()->json(['error' => 'Template not found'], 404); + } + + $template->setAsDefault(); + + return response()->json([ + 'data' => $this->formatTemplate($template->fresh(), true), + ]); + } + + /** + * Get available template variables. + */ + public function variables(Request $request): JsonResponse + { + $eventType = $request->input('event_type'); + $variables = $this->templateService->getAvailableVariables($eventType); + + return response()->json([ + 'data' => $variables, + ]); + } + + /** + * Get available template filters. + */ + public function filters(): JsonResponse + { + $filters = $this->templateService->getAvailableFilters(); + + return response()->json([ + 'data' => $filters, + ]); + } + + /** + * Get builtin template definitions. + */ + public function builtins(): JsonResponse + { + $templates = $this->templateService->getBuiltinTemplates(); + + return response()->json([ + 'data' => $templates, + ]); + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Format a template for API response. + */ + protected function formatTemplate(WebhookPayloadTemplate $template, bool $includeContent = false): array + { + $data = [ + 'uuid' => $template->uuid, + 'name' => $template->name, + 'description' => $template->description, + 'format' => $template->format->value, + 'is_default' => $template->is_default, + 'is_active' => $template->is_active, + 'is_builtin' => $template->isBuiltin(), + 'builtin_type' => $template->builtin_type?->value, + 'created_at' => $template->created_at?->toIso8601String(), + 'updated_at' => $template->updated_at?->toIso8601String(), + ]; + + if ($includeContent) { + $data['template'] = $template->template; + $data['example_output'] = $template->example_output; + } + + return $data; + } +} diff --git a/src/php/src/Api/Controllers/McpApiController.php b/src/php/src/Api/Controllers/McpApiController.php new file mode 100644 index 0000000..828e85b --- /dev/null +++ b/src/php/src/Api/Controllers/McpApiController.php @@ -0,0 +1,625 @@ +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 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'] ?? []), + ]; + } +} diff --git a/src/php/src/Api/Database/Factories/ApiKeyFactory.php b/src/php/src/Api/Database/Factories/ApiKeyFactory.php new file mode 100644 index 0000000..f992d8c --- /dev/null +++ b/src/php/src/Api/Database/Factories/ApiKeyFactory.php @@ -0,0 +1,266 @@ + + */ +class ApiKeyFactory extends Factory +{ + /** + * The name of the factory's corresponding model. + * + * @var class-string + */ + 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 + */ + 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, + 'allowed_ips' => 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 $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|null $servers + */ + public function withServerScopes(?array $servers): static + { + return $this->state(fn (array $attributes) => [ + 'server_scopes' => $servers, + ]); + } + + /** + * Set IP whitelist restrictions. + * + * @param array|null $ips Array of IP addresses/CIDRs + */ + public function withAllowedIps(?array $ips): static + { + return $this->state(fn (array $attributes) => [ + 'allowed_ips' => $ips, + ]); + } + + /** + * 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), + ]); + } +} diff --git a/src/php/src/Api/Documentation/Attributes/ApiHidden.php b/src/php/src/Api/Documentation/Attributes/ApiHidden.php new file mode 100644 index 0000000..0918804 --- /dev/null +++ b/src/php/src/Api/Documentation/Attributes/ApiHidden.php @@ -0,0 +1,41 @@ + $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; + } +} diff --git a/src/php/src/Api/Documentation/Attributes/ApiResponse.php b/src/php/src/Api/Documentation/Attributes/ApiResponse.php new file mode 100644 index 0000000..222350b --- /dev/null +++ b/src/php/src/Api/Documentation/Attributes/ApiResponse.php @@ -0,0 +1,80 @@ + $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', + }; + } +} diff --git a/src/php/src/Api/Documentation/Attributes/ApiSecurity.php b/src/php/src/Api/Documentation/Attributes/ApiSecurity.php new file mode 100644 index 0000000..5354d07 --- /dev/null +++ b/src/php/src/Api/Documentation/Attributes/ApiSecurity.php @@ -0,0 +1,51 @@ + $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; + } +} diff --git a/src/php/src/Api/Documentation/Attributes/ApiTag.php b/src/php/src/Api/Documentation/Attributes/ApiTag.php new file mode 100644 index 0000000..9ab3c5b --- /dev/null +++ b/src/php/src/Api/Documentation/Attributes/ApiTag.php @@ -0,0 +1,38 @@ + $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}"; + } +} diff --git a/src/php/src/Api/Documentation/DocumentationServiceProvider.php b/src/php/src/Api/Documentation/DocumentationServiceProvider.php new file mode 100644 index 0000000..e7cc309 --- /dev/null +++ b/src/php/src/Api/Documentation/DocumentationServiceProvider.php @@ -0,0 +1,87 @@ +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'); + } +} diff --git a/src/php/src/Api/Documentation/Examples/CommonExamples.php b/src/php/src/Api/Documentation/Examples/CommonExamples.php new file mode 100644 index 0000000..2e5b685 --- /dev/null +++ b/src/php/src/Api/Documentation/Examples/CommonExamples.php @@ -0,0 +1,278 @@ + [ + '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 = "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; + } +} diff --git a/src/php/src/Api/Documentation/Extension.php b/src/php/src/Api/Documentation/Extension.php new file mode 100644 index 0000000..0dc2591 --- /dev/null +++ b/src/php/src/Api/Documentation/Extension.php @@ -0,0 +1,40 @@ +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 << '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; + } +} diff --git a/src/php/src/Api/Documentation/Extensions/WorkspaceHeaderExtension.php b/src/php/src/Api/Documentation/Extensions/WorkspaceHeaderExtension.php new file mode 100644 index 0000000..8a05983 --- /dev/null +++ b/src/php/src/Api/Documentation/Extensions/WorkspaceHeaderExtension.php @@ -0,0 +1,111 @@ + $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; + } +} diff --git a/src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php b/src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php new file mode 100644 index 0000000..b5a168b --- /dev/null +++ b/src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php @@ -0,0 +1,76 @@ +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); + } +} diff --git a/src/php/src/Api/Documentation/ModuleDiscovery.php b/src/php/src/Api/Documentation/ModuleDiscovery.php new file mode 100644 index 0000000..285d5ea --- /dev/null +++ b/src/php/src/Api/Documentation/ModuleDiscovery.php @@ -0,0 +1,209 @@ + + */ + protected array $modules = []; + + /** + * Discover all API modules and their routes. + * + * @return 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 + */ + 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; + } +} diff --git a/src/php/src/Api/Documentation/OpenApiBuilder.php b/src/php/src/Api/Documentation/OpenApiBuilder.php new file mode 100644 index 0000000..8a21b8e --- /dev/null +++ b/src/php/src/Api/Documentation/OpenApiBuilder.php @@ -0,0 +1,819 @@ + + */ + protected array $extensions = []; + + /** + * Discovered tags from modules. + * + * @var 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 $attributeClass + * @return ReflectionAttribute|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; + } +} diff --git a/src/php/src/Api/Documentation/Routes/docs.php b/src/php/src/Api/Documentation/Routes/docs.php new file mode 100644 index 0000000..5ff04e2 --- /dev/null +++ b/src/php/src/Api/Documentation/Routes/docs.php @@ -0,0 +1,36 @@ +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'); diff --git a/src/php/src/Api/Documentation/Views/redoc.blade.php b/src/php/src/Api/Documentation/Views/redoc.blade.php new file mode 100644 index 0000000..d1fd68e --- /dev/null +++ b/src/php/src/Api/Documentation/Views/redoc.blade.php @@ -0,0 +1,60 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} - ReDoc + + + + + + + + + + diff --git a/src/php/src/Api/Documentation/Views/scalar.blade.php b/src/php/src/Api/Documentation/Views/scalar.blade.php new file mode 100644 index 0000000..85ac8c8 --- /dev/null +++ b/src/php/src/Api/Documentation/Views/scalar.blade.php @@ -0,0 +1,28 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} + + + + + + + diff --git a/src/php/src/Api/Documentation/Views/swagger.blade.php b/src/php/src/Api/Documentation/Views/swagger.blade.php new file mode 100644 index 0000000..2515ddd --- /dev/null +++ b/src/php/src/Api/Documentation/Views/swagger.blade.php @@ -0,0 +1,65 @@ + + + + + + + {{ config('api-docs.info.title', 'API Documentation') }} - Swagger UI + + + + +
+ + + + + + diff --git a/src/php/src/Api/Documentation/config.php b/src/php/src/Api/Documentation/config.php new file mode 100644 index 0000000..0c43186 --- /dev/null +++ b/src/php/src/Api/Documentation/config.php @@ -0,0 +1,319 @@ + 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'], + ], + +]; diff --git a/src/php/src/Api/Enums/BuiltinTemplateType.php b/src/php/src/Api/Enums/BuiltinTemplateType.php new file mode 100644 index 0000000..e1320b5 --- /dev/null +++ b/src/php/src/Api/Enums/BuiltinTemplateType.php @@ -0,0 +1,144 @@ + 'Full payload', + self::MINIMAL => 'Minimal payload', + self::SLACK => 'Slack message', + self::DISCORD => 'Discord message', + }; + } + + /** + * Get description for the type. + */ + public function description(): string + { + return match ($this) { + self::FULL => 'Sends all event data in a structured format.', + self::MINIMAL => 'Sends only essential fields: event type, ID, and timestamp.', + self::SLACK => 'Formats payload for Slack incoming webhooks with blocks.', + self::DISCORD => 'Formats payload for Discord webhooks with embeds.', + }; + } + + /** + * Get the default template content for this type. + */ + public function template(): string + { + return match ($this) { + self::FULL => <<<'JSON' +{ + "event": "{{event.type}}", + "timestamp": "{{timestamp}}", + "timestamp_unix": {{timestamp_unix}}, + "data": {{data | json}} +} +JSON, + self::MINIMAL => <<<'JSON' +{ + "event": "{{event.type}}", + "id": "{{data.id}}", + "timestamp": "{{timestamp}}" +} +JSON, + self::SLACK => <<<'JSON' +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "{{event.name}}" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "{{message}}" + } + }, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": "*Event:* `{{event.type}}` | *Time:* {{timestamp | iso8601}}" + } + ] + } + ] +} +JSON, + self::DISCORD => <<<'JSON' +{ + "embeds": [ + { + "title": "{{event.name}}", + "description": "{{message}}", + "color": 5814783, + "fields": [ + { + "name": "Event Type", + "value": "`{{event.type}}`", + "inline": true + }, + { + "name": "ID", + "value": "{{data.id | default:N/A}}", + "inline": true + } + ], + "timestamp": "{{timestamp}}" + } + ] +} +JSON, + }; + } + + /** + * Get the template format for this type. + */ + public function format(): WebhookTemplateFormat + { + return WebhookTemplateFormat::JSON; + } +} diff --git a/src/php/src/Api/Enums/WebhookTemplateFormat.php b/src/php/src/Api/Enums/WebhookTemplateFormat.php new file mode 100644 index 0000000..a82202d --- /dev/null +++ b/src/php/src/Api/Enums/WebhookTemplateFormat.php @@ -0,0 +1,73 @@ + 'Simple (variable substitution)', + self::MUSTACHE => 'Mustache (conditionals and loops)', + self::JSON => 'JSON (structured template)', + }; + } + + /** + * Get description for the format. + */ + public function description(): string + { + return match ($this) { + self::SIMPLE => 'Basic {{variable}} replacement. Best for simple payloads.', + self::MUSTACHE => 'Full Mustache syntax with {{#if}}, {{#each}}, and filters.', + self::JSON => 'JSON template with embedded {{variables}}. Validates structure.', + }; + } + + /** + * Get example template for the format. + */ + public function example(): string + { + return match ($this) { + self::SIMPLE => '{"event": "{{event.type}}", "id": "{{data.id}}"}', + self::MUSTACHE => '{"event": "{{event.type}}"{{#if data.user}}, "user": "{{data.user.name}}"{{/if}}}', + self::JSON => <<<'JSON' +{ + "event": "{{event.type}}", + "timestamp": "{{timestamp | iso8601}}", + "data": { + "id": "{{data.id}}", + "name": "{{data.name}}" + } +} +JSON, + }; + } +} diff --git a/src/php/src/Api/Exceptions/RateLimitExceededException.php b/src/php/src/Api/Exceptions/RateLimitExceededException.php new file mode 100644 index 0000000..cad4e41 --- /dev/null +++ b/src/php/src/Api/Exceptions/RateLimitExceededException.php @@ -0,0 +1,56 @@ +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 + */ + public function getHeaders(): array + { + return array_map(fn ($value) => (string) $value, $this->rateLimitResult->headers()); + } +} diff --git a/src/php/src/Api/Guards/AccessTokenGuard.php b/src/php/src/Api/Guards/AccessTokenGuard.php new file mode 100644 index 0000000..a4e0999 --- /dev/null +++ b/src/php/src/Api/Guards/AccessTokenGuard.php @@ -0,0 +1,98 @@ +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(); + } +} diff --git a/src/php/src/Api/Jobs/DeliverWebhookJob.php b/src/php/src/Api/Jobs/DeliverWebhookJob.php new file mode 100644 index 0000000..2e2d9ad --- /dev/null +++ b/src/php/src/Api/Jobs/DeliverWebhookJob.php @@ -0,0 +1,182 @@ +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 + */ + public function tags(): array + { + return [ + 'webhook', + 'webhook:'.$this->delivery->webhook_endpoint_id, + 'event:'.$this->delivery->event_type, + ]; + } +} diff --git a/src/php/src/Api/Middleware/AuthenticateApiKey.php b/src/php/src/Api/Middleware/AuthenticateApiKey.php new file mode 100644 index 0000000..40b6fe9 --- /dev/null +++ b/src/php/src/Api/Middleware/AuthenticateApiKey.php @@ -0,0 +1,136 @@ +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 '); + } + + // 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 IP whitelist if restrictions are enabled + if ($apiKey->hasIpRestrictions()) { + $ipService = app(IpRestrictionService::class); + $requestIp = $request->ip(); + + if (! $ipService->isIpAllowed($requestIp, $apiKey->getAllowedIps() ?? [])) { + return $this->forbidden('IP address not allowed for this API key'); + } + } + + // 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); + } +} diff --git a/src/php/src/Api/Middleware/CheckApiScope.php b/src/php/src/Api/Middleware/CheckApiScope.php new file mode 100644 index 0000000..32aeec0 --- /dev/null +++ b/src/php/src/Api/Middleware/CheckApiScope.php @@ -0,0 +1,52 @@ +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); + } +} diff --git a/src/php/src/Api/Middleware/EnforceApiScope.php b/src/php/src/Api/Middleware/EnforceApiScope.php new file mode 100644 index 0000000..958e3fa --- /dev/null +++ b/src/php/src/Api/Middleware/EnforceApiScope.php @@ -0,0 +1,65 @@ + 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); + } +} diff --git a/src/php/src/Api/Middleware/PublicApiCors.php b/src/php/src/Api/Middleware/PublicApiCors.php new file mode 100644 index 0000000..10e7761 --- /dev/null +++ b/src/php/src/Api/Middleware/PublicApiCors.php @@ -0,0 +1,64 @@ +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; + } +} diff --git a/src/php/src/Api/Middleware/RateLimitApi.php b/src/php/src/Api/Middleware/RateLimitApi.php new file mode 100644 index 0000000..db955a5 --- /dev/null +++ b/src/php/src/Api/Middleware/RateLimitApi.php @@ -0,0 +1,352 @@ +withMiddleware(function (Middleware $middleware) { + * $middleware->alias([ + * 'api.rate' => \Core\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; + } +} diff --git a/src/php/src/Api/Middleware/TrackApiUsage.php b/src/php/src/Api/Middleware/TrackApiUsage.php new file mode 100644 index 0000000..7c37005 --- /dev/null +++ b/src/php/src/Api/Middleware/TrackApiUsage.php @@ -0,0 +1,81 @@ +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(), + ]); + } + } +} diff --git a/src/php/src/Api/Migrations/0001_01_01_000001_create_api_tables.php b/src/php/src/Api/Migrations/0001_01_01_000001_create_api_tables.php new file mode 100644 index 0000000..d8123b5 --- /dev/null +++ b/src/php/src/Api/Migrations/0001_01_01_000001_create_api_tables.php @@ -0,0 +1,65 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('workspace_id')->constrained('workspaces')->cascadeOnDelete(); + $table->foreignId('namespace_id')->nullable()->constrained('namespaces')->nullOnDelete(); + + $table->string('name'); + $table->text('description')->nullable(); + + // Template format: simple, mustache, json + $table->string('format', 20)->default('simple'); + + // The actual template content (JSON/Twig-like syntax) + $table->text('template'); + + // Example rendered output for preview + $table->text('example_output')->nullable(); + + // Template metadata + $table->boolean('is_default')->default(false); + $table->unsignedSmallInteger('sort_order')->default(0); + $table->boolean('is_active')->default(true); + + // Built-in template type (null for custom templates) + // Values: full, minimal, slack, discord + $table->string('builtin_type', 20)->nullable(); + + $table->timestamps(); + + $table->index(['workspace_id', 'is_active']); + $table->index(['workspace_id', 'is_default']); + $table->index(['workspace_id', 'sort_order']); + $table->index('builtin_type'); + }); + + Schema::enableForeignKeyConstraints(); + } + + public function down(): void + { + Schema::dropIfExists('api_webhook_payload_templates'); + } +}; diff --git a/src/php/src/Api/Migrations/2026_01_07_002358_create_api_keys_table.php b/src/php/src/Api/Migrations/2026_01_07_002358_create_api_keys_table.php new file mode 100644 index 0000000..eb3547a --- /dev/null +++ b/src/php/src/Api/Migrations/2026_01_07_002358_create_api_keys_table.php @@ -0,0 +1,41 @@ +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'); + } +}; diff --git a/src/php/src/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php b/src/php/src/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php new file mode 100644 index 0000000..eebe7b3 --- /dev/null +++ b/src/php/src/Api/Migrations/2026_01_07_002400_create_webhook_endpoints_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/src/php/src/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php b/src/php/src/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php new file mode 100644 index 0000000..96faf41 --- /dev/null +++ b/src/php/src/Api/Migrations/2026_01_07_002401_create_webhook_deliveries_table.php @@ -0,0 +1,40 @@ +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'); + } +}; diff --git a/src/php/src/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php b/src/php/src/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php new file mode 100644 index 0000000..aefe65a --- /dev/null +++ b/src/php/src/Api/Migrations/2026_01_26_200000_add_webhook_secret_rotation_fields.php @@ -0,0 +1,58 @@ +text('previous_secret')->nullable()->after('secret'); + $table->timestamp('secret_rotated_at')->nullable()->after('previous_secret'); + $table->unsignedInteger('grace_period_seconds')->default(86400)->after('secret_rotated_at'); // 24 hours + }); + } + + // Add grace period fields to content_webhook_endpoints + if (Schema::hasTable('content_webhook_endpoints')) { + Schema::table('content_webhook_endpoints', function (Blueprint $table) { + $table->text('previous_secret')->nullable()->after('secret'); + $table->timestamp('secret_rotated_at')->nullable()->after('previous_secret'); + $table->unsignedInteger('grace_period_seconds')->default(86400)->after('secret_rotated_at'); // 24 hours + }); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + if (Schema::hasTable('social_webhooks')) { + Schema::table('social_webhooks', function (Blueprint $table) { + $table->dropColumn(['previous_secret', 'secret_rotated_at', 'grace_period_seconds']); + }); + } + + if (Schema::hasTable('content_webhook_endpoints')) { + Schema::table('content_webhook_endpoints', function (Blueprint $table) { + $table->dropColumn(['previous_secret', 'secret_rotated_at', 'grace_period_seconds']); + }); + } + } +}; diff --git a/src/php/src/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php b/src/php/src/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php new file mode 100644 index 0000000..4883ffc --- /dev/null +++ b/src/php/src/Api/Migrations/2026_01_27_000000_add_secure_hashing_to_api_keys_table.php @@ -0,0 +1,46 @@ +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']); + }); + } +}; diff --git a/src/php/src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php b/src/php/src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php new file mode 100644 index 0000000..0a0803f --- /dev/null +++ b/src/php/src/Api/Migrations/2026_01_29_000000_add_allowed_ips_to_api_keys_table.php @@ -0,0 +1,43 @@ +json('allowed_ips') + ->nullable() + ->after('server_scopes') + ->comment('IP whitelist: null=all IPs allowed, ["192.168.1.0/24"]=specific'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('api_keys', function (Blueprint $table) { + $table->dropColumn('allowed_ips'); + }); + } +}; diff --git a/src/php/src/Api/Models/ApiKey.php b/src/php/src/Api/Models/ApiKey.php new file mode 100644 index 0000000..3c229ee --- /dev/null +++ b/src/php/src/Api/Models/ApiKey.php @@ -0,0 +1,515 @@ + 'array', + 'server_scopes' => 'array', + 'allowed_ips' => '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 and IP restrictions to new key + $result['api_key']->update([ + 'server_scopes' => $this->server_scopes, + 'allowed_ips' => $this->allowed_ips, + '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. + * + * Supports wildcard matching: + * - `posts:*` grants all actions on posts resource + * - `*:read` grants read action on all resources + * - `*` grants full access to everything + */ + public function hasScope(string $scope): bool + { + $scopes = $this->scopes ?? []; + + // Exact match + if (in_array($scope, $scopes, true)) { + return true; + } + + // Full wildcard (grants everything) + if (in_array('*', $scopes, true)) { + return true; + } + + // Check for resource:action pattern + if (! str_contains($scope, ':')) { + // Simple scope (read, write, delete) - no wildcard matching + return false; + } + + [$resource, $action] = explode(':', $scope, 2); + + // Resource wildcard (e.g., posts:* grants posts:read, posts:write, etc.) + if (in_array("{$resource}:*", $scopes, true)) { + return true; + } + + // Action wildcard (e.g., *:read grants posts:read, users:read, etc.) + if (in_array("*:{$action}", $scopes, true)) { + return true; + } + + return false; + } + + /** + * 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 has any of the specified scopes. + */ + public function hasAnyScope(array $scopes): bool + { + foreach ($scopes as $scope) { + if ($this->hasScope($scope)) { + return true; + } + } + + return false; + } + + /** + * 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; + } + + /** + * Check if this key has IP restrictions enabled. + */ + public function hasIpRestrictions(): bool + { + return ! empty($this->allowed_ips); + } + + /** + * Get the allowed IPs list (null = all IPs allowed). + * + * @return array|null + */ + public function getAllowedIps(): ?array + { + return $this->allowed_ips; + } + + /** + * Update the IP whitelist. + * + * @param array|null $ips Array of IP addresses/CIDRs, or null to allow all + */ + public function updateAllowedIps(?array $ips): void + { + $this->update(['allowed_ips' => $ips]); + } + + /** + * Add an IP or CIDR to the whitelist. + */ + public function addAllowedIp(string $ipOrCidr): void + { + $whitelist = $this->allowed_ips ?? []; + + if (! in_array($ipOrCidr, $whitelist, true)) { + $whitelist[] = $ipOrCidr; + $this->update(['allowed_ips' => $whitelist]); + } + } + + /** + * Remove an IP or CIDR from the whitelist. + */ + public function removeAllowedIp(string $ipOrCidr): void + { + $whitelist = $this->allowed_ips ?? []; + $whitelist = array_values(array_filter($whitelist, fn ($entry) => $entry !== $ipOrCidr)); + $this->update(['allowed_ips' => empty($whitelist) ? null : $whitelist]); + } + + /** + * 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); + } +} diff --git a/src/php/src/Api/Models/ApiUsage.php b/src/php/src/Api/Models/ApiUsage.php new file mode 100644 index 0000000..7da66ad --- /dev/null +++ b/src/php/src/Api/Models/ApiUsage.php @@ -0,0 +1,135 @@ + '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]); + } +} diff --git a/src/php/src/Api/Models/ApiUsageDaily.php b/src/php/src/Api/Models/ApiUsageDaily.php new file mode 100644 index 0000000..d8a528d --- /dev/null +++ b/src/php/src/Api/Models/ApiUsageDaily.php @@ -0,0 +1,172 @@ + '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]); + } +} diff --git a/src/php/src/Api/Models/WebhookDelivery.php b/src/php/src/Api/Models/WebhookDelivery.php new file mode 100644 index 0000000..79fea94 --- /dev/null +++ b/src/php/src/Api/Models/WebhookDelivery.php @@ -0,0 +1,209 @@ + 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, 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()); + }); + }); + } +} diff --git a/src/php/src/Api/Models/WebhookEndpoint.php b/src/php/src/Api/Models/WebhookEndpoint.php new file mode 100644 index 0000000..a2990e3 --- /dev/null +++ b/src/php/src/Api/Models/WebhookEndpoint.php @@ -0,0 +1,266 @@ + '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', '*'); + }); + } +} diff --git a/src/php/src/Api/Models/WebhookPayloadTemplate.php b/src/php/src/Api/Models/WebhookPayloadTemplate.php new file mode 100644 index 0000000..5f3cebb --- /dev/null +++ b/src/php/src/Api/Models/WebhookPayloadTemplate.php @@ -0,0 +1,321 @@ + WebhookTemplateFormat::class, + 'is_default' => 'boolean', + 'sort_order' => 'integer', + 'is_active' => 'boolean', + 'builtin_type' => BuiltinTemplateType::class, + ]; + + protected static function boot(): void + { + parent::boot(); + + static::creating(function (WebhookPayloadTemplate $template) { + if (empty($template->uuid)) { + $template->uuid = (string) Str::uuid(); + } + }); + + // Ensure only one default template per workspace + static::saving(function (WebhookPayloadTemplate $template) { + if ($template->is_default) { + static::where('workspace_id', $template->workspace_id) + ->where('id', '!=', $template->id ?? 0) + ->where('is_default', true) + ->update(['is_default' => false]); + } + }); + } + + // ------------------------------------------------------------------------- + // Relationships + // ------------------------------------------------------------------------- + + public function workspace(): BelongsTo + { + return $this->belongsTo(Workspace::class); + } + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + public function scopeActive(Builder $query): Builder + { + return $query->where('is_active', true); + } + + public function scopeDefault(Builder $query): Builder + { + return $query->where('is_default', true); + } + + public function scopeBuiltin(Builder $query): Builder + { + return $query->whereNotNull('builtin_type'); + } + + public function scopeCustom(Builder $query): Builder + { + return $query->whereNull('builtin_type'); + } + + public function scopeOrdered(Builder $query): Builder + { + return $query->orderBy('sort_order')->orderBy('name'); + } + + public function scopeForWorkspace(Builder $query, int $workspaceId): Builder + { + return $query->where('workspace_id', $workspaceId); + } + + // ------------------------------------------------------------------------- + // State Checks + // ------------------------------------------------------------------------- + + public function isActive(): bool + { + return $this->is_active === true; + } + + public function isDefault(): bool + { + return $this->is_default === true; + } + + public function isBuiltin(): bool + { + return $this->builtin_type !== null; + } + + public function isCustom(): bool + { + return $this->builtin_type === null; + } + + // ------------------------------------------------------------------------- + // Template Methods + // ------------------------------------------------------------------------- + + /** + * Get the template format enum. + */ + public function getFormat(): WebhookTemplateFormat + { + return $this->format ?? WebhookTemplateFormat::SIMPLE; + } + + /** + * Get the builtin type if this is a builtin template. + */ + public function getBuiltinType(): ?BuiltinTemplateType + { + return $this->builtin_type; + } + + /** + * Update the example output preview. + */ + public function updateExampleOutput(string $output): void + { + $this->update(['example_output' => $output]); + } + + /** + * Set this template as the workspace default. + */ + public function setAsDefault(): void + { + $this->update(['is_default' => true]); + } + + /** + * Duplicate this template with a new name. + */ + public function duplicate(?string $newName = null): static + { + $duplicate = $this->replicate(['uuid', 'is_default']); + $duplicate->uuid = (string) Str::uuid(); + $duplicate->name = $newName ?? $this->name.' (copy)'; + $duplicate->is_default = false; + $duplicate->builtin_type = null; // Custom copy + $duplicate->save(); + + return $duplicate; + } + + // ------------------------------------------------------------------------- + // Utilities + // ------------------------------------------------------------------------- + + public function getRouteKeyName(): string + { + return 'uuid'; + } + + public function getActivitylogOptions(): LogOptions + { + return LogOptions::defaults() + ->logOnly(['name', 'description', 'format', 'template', 'is_default', 'is_active']) + ->logOnlyDirty() + ->dontSubmitEmptyLogs() + ->setDescriptionForEvent(fn (string $eventName) => "Webhook template {$eventName}"); + } + + /** + * Get Flux badge colour for status. + */ + public function getStatusColorAttribute(): string + { + if (! $this->is_active) { + return 'zinc'; + } + + if ($this->is_default) { + return 'green'; + } + + return 'blue'; + } + + /** + * Get status label. + */ + public function getStatusLabelAttribute(): string + { + if (! $this->is_active) { + return 'Inactive'; + } + + if ($this->is_default) { + return 'Default'; + } + + return 'Active'; + } + + /** + * Get icon for template type. + */ + public function getTypeIconAttribute(): string + { + if ($this->isBuiltin()) { + return match ($this->builtin_type) { + BuiltinTemplateType::SLACK => 'slack', + BuiltinTemplateType::DISCORD => 'discord', + BuiltinTemplateType::FULL => 'code-bracket', + BuiltinTemplateType::MINIMAL => 'minus', + default => 'document-text', + }; + } + + return 'document'; + } + + // ------------------------------------------------------------------------- + // Factory Methods + // ------------------------------------------------------------------------- + + /** + * Create all builtin templates for a workspace. + */ + public static function createBuiltinTemplates(int $workspaceId, ?int $namespaceId = null): void + { + $sortOrder = 0; + + foreach (BuiltinTemplateType::cases() as $type) { + static::firstOrCreate( + [ + 'workspace_id' => $workspaceId, + 'builtin_type' => $type, + ], + [ + 'uuid' => (string) Str::uuid(), + 'namespace_id' => $namespaceId, + 'name' => $type->label(), + 'description' => $type->description(), + 'format' => $type->format(), + 'template' => $type->template(), + 'is_default' => $type === BuiltinTemplateType::FULL, + 'sort_order' => $sortOrder++, + 'is_active' => true, + ] + ); + } + } + + /** + * Get or create the default template for a workspace. + */ + public static function getDefaultForWorkspace(int $workspaceId): ?static + { + return static::forWorkspace($workspaceId) + ->active() + ->default() + ->first(); + } +} diff --git a/src/php/src/Api/Notifications/HighApiUsageNotification.php b/src/php/src/Api/Notifications/HighApiUsageNotification.php new file mode 100644 index 0000000..d631640 --- /dev/null +++ b/src/php/src/Api/Notifications/HighApiUsageNotification.php @@ -0,0 +1,111 @@ + + */ + 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 + */ + 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, + ]; + } +} diff --git a/src/php/src/Api/RateLimit/RateLimit.php b/src/php/src/Api/RateLimit/RateLimit.php new file mode 100644 index 0000000..6210f90 --- /dev/null +++ b/src/php/src/Api/RateLimit/RateLimit.php @@ -0,0 +1,42 @@ + + */ + 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; + } +} diff --git a/src/php/src/Api/RateLimit/RateLimitService.php b/src/php/src/Api/RateLimit/RateLimitService.php new file mode 100644 index 0000000..d442bf7 --- /dev/null +++ b/src/php/src/Api/RateLimit/RateLimitService.php @@ -0,0 +1,247 @@ +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 Array of timestamps + */ + protected function getWindowHits(string $cacheKey, int $windowStart): array + { + /** @var array $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 $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 $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; + } +} diff --git a/src/php/src/Api/Resources/ApiKeyResource.php b/src/php/src/Api/Resources/ApiKeyResource.php new file mode 100644 index 0000000..a041ddc --- /dev/null +++ b/src/php/src/Api/Resources/ApiKeyResource.php @@ -0,0 +1,59 @@ +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, + ]; + } +} diff --git a/src/php/src/Api/Resources/ErrorResource.php b/src/php/src/Api/Resources/ErrorResource.php new file mode 100644 index 0000000..ca62eca --- /dev/null +++ b/src/php/src/Api/Resources/ErrorResource.php @@ -0,0 +1,93 @@ + ['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; + } +} diff --git a/src/php/src/Api/Resources/PaginatedCollection.php b/src/php/src/Api/Resources/PaginatedCollection.php new file mode 100644 index 0000000..4bc02d6 --- /dev/null +++ b/src/php/src/Api/Resources/PaginatedCollection.php @@ -0,0 +1,49 @@ +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(), + ], + ]; + } +} diff --git a/src/php/src/Api/Resources/WebhookEndpointResource.php b/src/php/src/Api/Resources/WebhookEndpointResource.php new file mode 100644 index 0000000..878b83b --- /dev/null +++ b/src/php/src/Api/Resources/WebhookEndpointResource.php @@ -0,0 +1,67 @@ +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), + ], + ]; + } +} diff --git a/src/php/src/Api/Resources/WorkspaceResource.php b/src/php/src/Api/Resources/WorkspaceResource.php new file mode 100644 index 0000000..c77e3c6 --- /dev/null +++ b/src/php/src/Api/Resources/WorkspaceResource.php @@ -0,0 +1,68 @@ + + */ + 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; + } +} diff --git a/src/php/src/Api/Routes/admin.php b/src/php/src/Api/Routes/admin.php new file mode 100644 index 0000000..1e6899f --- /dev/null +++ b/src/php/src/Api/Routes/admin.php @@ -0,0 +1,19 @@ +name('hub.api.')->group(function () { + Route::get('/webhook-templates', WebhookTemplateManager::class)->name('webhook-templates'); +}); diff --git a/src/php/src/Api/Routes/api.php b/src/php/src/Api/Routes/api.php new file mode 100644 index 0000000..cec4478 --- /dev/null +++ b/src/php/src/Api/Routes/api.php @@ -0,0 +1,54 @@ +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'); + }); diff --git a/src/php/src/Api/Services/ApiKeyService.php b/src/php/src/Api/Services/ApiKeyService.php new file mode 100644 index 0000000..2175826 --- /dev/null +++ b/src/php/src/Api/Services/ApiKeyService.php @@ -0,0 +1,217 @@ +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(), + ]; + } +} diff --git a/src/php/src/Api/Services/ApiSnippetService.php b/src/php/src/Api/Services/ApiSnippetService.php new file mode 100644 index 0000000..e5a8955 --- /dev/null +++ b/src/php/src/Api/Services/ApiSnippetService.php @@ -0,0 +1,427 @@ + '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 <<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 << $value) { + $headerLines[] = " \"{$key}\": \"{$value}\""; + } + $headerStr = implode(",\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) : 'None'; + + return <<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 <<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 << $value) { + $headerLines[] = " .header(\"{$key}\", \"{$value}\")"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; + + return << 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 <<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 << $value) { + $headerLines[] = " .addHeader(\"{$key}\", \"{$value}\")"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; + + return << $value) { + $headerLines[] = " .header(\"{$key}\", \"{$value}\")"; + } + $headerStr = implode("\n", $headerLines); + + $bodyStr = $body ? json_encode($body, JSON_UNESCAPED_SLASHES) : '""'; + + return <<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', + }; + } +} diff --git a/src/php/src/Api/Services/ApiUsageService.php b/src/php/src/Api/Services/ApiUsageService.php new file mode 100644 index 0000000..204f444 --- /dev/null +++ b/src/php/src/Api/Services/ApiUsageService.php @@ -0,0 +1,361 @@ +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(); + } +} diff --git a/src/php/src/Api/Services/IpRestrictionService.php b/src/php/src/Api/Services/IpRestrictionService.php new file mode 100644 index 0000000..f2cb576 --- /dev/null +++ b/src/php/src/Api/Services/IpRestrictionService.php @@ -0,0 +1,308 @@ + $whitelist + */ + public function isIpAllowed(string $ip, array $whitelist): bool + { + $ip = trim($ip); + + // Empty whitelist means no restrictions + if (empty($whitelist)) { + return true; + } + + // Validate the request IP is a valid IP address + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + return false; + } + + foreach ($whitelist as $entry) { + $entry = trim($entry); + + if (empty($entry)) { + continue; + } + + // Check for CIDR notation + if (str_contains($entry, '/')) { + if ($this->ipMatchesCidr($ip, $entry)) { + return true; + } + } else { + // Exact IP match (normalise both for comparison) + if ($this->normaliseIp($ip) === $this->normaliseIp($entry)) { + return true; + } + } + } + + return false; + } + + /** + * Check if an IP matches a CIDR range. + */ + public function ipMatchesCidr(string $ip, string $cidr): bool + { + $parts = explode('/', $cidr, 2); + + if (count($parts) !== 2) { + return false; + } + + [$range, $prefix] = $parts; + $prefix = (int) $prefix; + + // Validate both IPs + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + return false; + } + + if (! filter_var($range, FILTER_VALIDATE_IP)) { + return false; + } + + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + $isRangeIpv6 = filter_var($range, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + + // IP version must match + if ($isIpv6 !== $isRangeIpv6) { + return false; + } + + if ($isIpv6) { + return $this->ipv6MatchesCidr($ip, $range, $prefix); + } + + return $this->ipv4MatchesCidr($ip, $range, $prefix); + } + + /** + * Check if an IPv4 address matches a CIDR range. + */ + protected function ipv4MatchesCidr(string $ip, string $range, int $prefix): bool + { + // Validate prefix length + if ($prefix < 0 || $prefix > 32) { + return false; + } + + $ipLong = ip2long($ip); + $rangeLong = ip2long($range); + + if ($ipLong === false || $rangeLong === false) { + return false; + } + + // Create the subnet mask + $mask = -1 << (32 - $prefix); + + // Apply mask and compare + return ($ipLong & $mask) === ($rangeLong & $mask); + } + + /** + * Check if an IPv6 address matches a CIDR range. + */ + protected function ipv6MatchesCidr(string $ip, string $range, int $prefix): bool + { + // Validate prefix length + if ($prefix < 0 || $prefix > 128) { + return false; + } + + // Convert to binary representation + $ipBin = $this->ipv6ToBinary($ip); + $rangeBin = $this->ipv6ToBinary($range); + + if ($ipBin === null || $rangeBin === null) { + return false; + } + + // Compare the first $prefix bits + $prefixBytes = (int) floor($prefix / 8); + $remainingBits = $prefix % 8; + + // Compare full bytes + if (substr($ipBin, 0, $prefixBytes) !== substr($rangeBin, 0, $prefixBytes)) { + return false; + } + + // Compare remaining bits if any + if ($remainingBits > 0) { + $mask = 0xFF << (8 - $remainingBits); + $ipByte = ord($ipBin[$prefixBytes]); + $rangeByte = ord($rangeBin[$prefixBytes]); + + if (($ipByte & $mask) !== ($rangeByte & $mask)) { + return false; + } + } + + return true; + } + + /** + * Convert an IPv6 address to its binary representation. + */ + protected function ipv6ToBinary(string $ip): ?string + { + $packed = inet_pton($ip); + + if ($packed === false) { + return null; + } + + return $packed; + } + + /** + * Normalise an IP address for comparison. + * + * - IPv4: No change needed + * - IPv6: Expand to full form for consistent comparison + */ + public function normaliseIp(string $ip): string + { + $ip = trim($ip); + + // Try to pack and unpack for normalisation + $packed = inet_pton($ip); + + if ($packed === false) { + return $ip; // Return original if invalid + } + + // inet_ntop will return normalised form + $normalised = inet_ntop($packed); + + return $normalised !== false ? $normalised : $ip; + } + + /** + * Validate an IP address or CIDR notation. + * + * @return array{valid: bool, error: ?string} + */ + public function validateEntry(string $entry): array + { + $entry = trim($entry); + + if (empty($entry)) { + return ['valid' => false, 'error' => 'Empty entry']; + } + + // Check for CIDR notation + if (str_contains($entry, '/')) { + return $this->validateCidr($entry); + } + + // Validate as plain IP + if (! filter_var($entry, FILTER_VALIDATE_IP)) { + return ['valid' => false, 'error' => 'Invalid IP address']; + } + + return ['valid' => true, 'error' => null]; + } + + /** + * Validate CIDR notation. + * + * @return array{valid: bool, error: ?string} + */ + public function validateCidr(string $cidr): array + { + $parts = explode('/', $cidr, 2); + + if (count($parts) !== 2) { + return ['valid' => false, 'error' => 'Invalid CIDR notation']; + } + + [$ip, $prefix] = $parts; + + // Validate IP portion + if (! filter_var($ip, FILTER_VALIDATE_IP)) { + return ['valid' => false, 'error' => 'Invalid IP address in CIDR']; + } + + // Validate prefix is numeric + if (! is_numeric($prefix)) { + return ['valid' => false, 'error' => 'Invalid prefix length']; + } + + $prefix = (int) $prefix; + $isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6); + + // Validate prefix range + if ($isIpv6) { + if ($prefix < 0 || $prefix > 128) { + return ['valid' => false, 'error' => 'IPv6 prefix must be between 0 and 128']; + } + } else { + if ($prefix < 0 || $prefix > 32) { + return ['valid' => false, 'error' => 'IPv4 prefix must be between 0 and 32']; + } + } + + return ['valid' => true, 'error' => null]; + } + + /** + * Parse a multi-line string of IPs/CIDRs into an array. + * + * @return array{entries: array, errors: array} + */ + public function parseWhitelistInput(string $input): array + { + $lines = preg_split('/[\r\n,]+/', $input); + $entries = []; + $errors = []; + + foreach ($lines as $line) { + $line = trim($line); + + if (empty($line)) { + continue; + } + + // Skip comments + if (str_starts_with($line, '#')) { + continue; + } + + $validation = $this->validateEntry($line); + + if ($validation['valid']) { + $entries[] = $line; + } else { + $errors[] = "{$line}: {$validation['error']}"; + } + } + + return [ + 'entries' => $entries, + 'errors' => $errors, + ]; + } +} diff --git a/src/php/src/Api/Services/WebhookSecretRotationService.php b/src/php/src/Api/Services/WebhookSecretRotationService.php new file mode 100644 index 0000000..3b03a93 --- /dev/null +++ b/src/php/src/Api/Services/WebhookSecretRotationService.php @@ -0,0 +1,308 @@ +secret; + + // Determine grace period + $gracePeriod = $gracePeriodSeconds ?? $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD; + $gracePeriod = max(self::MIN_GRACE_PERIOD, min(self::MAX_GRACE_PERIOD, $gracePeriod)); + + DB::transaction(function () use ($webhook, $currentSecret, $newSecret, $gracePeriod) { + $webhook->update([ + 'previous_secret' => $currentSecret, + 'secret' => $newSecret, + 'secret_rotated_at' => now(), + 'grace_period_seconds' => $gracePeriod, + ]); + }); + + Log::info('Webhook secret rotated', [ + 'webhook_id' => $webhook->id, + 'webhook_type' => class_basename($webhook), + 'grace_period_seconds' => $gracePeriod, + ]); + + return $newSecret; + } + + /** + * Verify a signature against both current and previous secrets during grace period. + * + * @param Model $webhook The webhook model + * @param string $payload The raw payload to verify + * @param string|null $signature The provided signature + * @param string $algorithm Hash algorithm (default: sha256) + * @return array{valid: bool, used_previous: bool, message: string} + */ + public function verifySignature( + Model $webhook, + string $payload, + ?string $signature, + string $algorithm = 'sha256' + ): array { + // If no secret configured, skip verification + if (empty($webhook->secret)) { + return [ + 'valid' => true, + 'used_previous' => false, + 'message' => 'No secret configured, verification skipped', + ]; + } + + // Signature required when secret is set + if (empty($signature)) { + return [ + 'valid' => false, + 'used_previous' => false, + 'message' => 'Signature required but not provided', + ]; + } + + // Normalise signature (strip prefix like sha256= if present) + $signature = $this->normaliseSignature($signature, $algorithm); + + // Check against current secret + $expectedSignature = hash_hmac($algorithm, $payload, $webhook->secret); + if (hash_equals($expectedSignature, $signature)) { + return [ + 'valid' => true, + 'used_previous' => false, + 'message' => 'Signature verified with current secret', + ]; + } + + // Check against previous secret if in grace period + if ($this->isInGracePeriod($webhook) && ! empty($webhook->previous_secret)) { + $previousExpectedSignature = hash_hmac($algorithm, $payload, $webhook->previous_secret); + if (hash_equals($previousExpectedSignature, $signature)) { + return [ + 'valid' => true, + 'used_previous' => true, + 'message' => 'Signature verified with previous secret (grace period)', + ]; + } + } + + return [ + 'valid' => false, + 'used_previous' => false, + 'message' => 'Signature verification failed', + ]; + } + + /** + * Check if the webhook is currently in a grace period. + */ + public function isInGracePeriod(Model $webhook): bool + { + if (empty($webhook->secret_rotated_at)) { + return false; + } + + $rotatedAt = Carbon::parse($webhook->secret_rotated_at); + $gracePeriodSeconds = $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD; + $graceEndsAt = $rotatedAt->copy()->addSeconds($gracePeriodSeconds); + + return now()->isBefore($graceEndsAt); + } + + /** + * Get the secret rotation status for a webhook. + * + * @return array{ + * has_previous_secret: bool, + * in_grace_period: bool, + * grace_period_seconds: int, + * rotated_at: ?string, + * grace_ends_at: ?string, + * time_remaining_seconds: ?int, + * time_remaining_human: ?string + * } + */ + public function getSecretStatus(Model $webhook): array + { + $hasPreviousSecret = ! empty($webhook->previous_secret); + $inGracePeriod = $this->isInGracePeriod($webhook); + $gracePeriodSeconds = $webhook->grace_period_seconds ?? self::DEFAULT_GRACE_PERIOD; + + $rotatedAt = $webhook->secret_rotated_at ? Carbon::parse($webhook->secret_rotated_at) : null; + $graceEndsAt = $rotatedAt ? $rotatedAt->copy()->addSeconds($gracePeriodSeconds) : null; + $timeRemaining = ($inGracePeriod && $graceEndsAt) ? now()->diffInSeconds($graceEndsAt, false) : null; + $timeRemainingHuman = $timeRemaining > 0 ? $this->humanReadableTime($timeRemaining) : null; + + return [ + 'has_previous_secret' => $hasPreviousSecret, + 'in_grace_period' => $inGracePeriod, + 'grace_period_seconds' => $gracePeriodSeconds, + 'rotated_at' => $rotatedAt?->toIso8601String(), + 'grace_ends_at' => $graceEndsAt?->toIso8601String(), + 'time_remaining_seconds' => $timeRemaining > 0 ? (int) $timeRemaining : null, + 'time_remaining_human' => $timeRemainingHuman, + ]; + } + + /** + * Immediately invalidate the previous secret. + * + * Use this to end the grace period early (e.g., if the old secret was compromised). + */ + public function invalidatePreviousSecret(Model $webhook): void + { + $webhook->update([ + 'previous_secret' => null, + 'secret_rotated_at' => null, + ]); + + Log::info('Webhook previous secret invalidated', [ + 'webhook_id' => $webhook->id, + 'webhook_type' => class_basename($webhook), + ]); + } + + /** + * Clean up expired grace periods for a specific model class. + * + * @param string $modelClass The webhook model class to clean up + * @return int Number of webhooks cleaned up + */ + public function cleanupExpiredGracePeriods(string $modelClass): int + { + $count = 0; + + $modelClass::query() + ->whereNotNull('previous_secret') + ->whereNotNull('secret_rotated_at') + ->chunkById(100, function ($webhooks) use (&$count) { + foreach ($webhooks as $webhook) { + if (! $this->isInGracePeriod($webhook)) { + $webhook->update([ + 'previous_secret' => null, + 'secret_rotated_at' => null, + ]); + $count++; + } + } + }); + + if ($count > 0) { + Log::info('Cleaned up expired webhook secret grace periods', [ + 'model_class' => $modelClass, + 'count' => $count, + ]); + } + + return $count; + } + + /** + * Update the grace period duration for a webhook. + */ + public function updateGracePeriod(Model $webhook, int $gracePeriodSeconds): void + { + $gracePeriodSeconds = max(self::MIN_GRACE_PERIOD, min(self::MAX_GRACE_PERIOD, $gracePeriodSeconds)); + + $webhook->update([ + 'grace_period_seconds' => $gracePeriodSeconds, + ]); + } + + /** + * Normalise a signature by removing common prefixes. + */ + protected function normaliseSignature(string $signature, string $algorithm): string + { + // Handle sha256=... format (GitHub, WordPress) + $prefix = $algorithm.'='; + if (str_starts_with($signature, $prefix)) { + return substr($signature, strlen($prefix)); + } + + return $signature; + } + + /** + * Convert seconds to human-readable time string. + */ + protected function humanReadableTime(int $seconds): string + { + if ($seconds < 60) { + return $seconds.' second'.($seconds !== 1 ? 's' : ''); + } + + if ($seconds < 3600) { + $minutes = (int) floor($seconds / 60); + + return $minutes.' minute'.($minutes !== 1 ? 's' : ''); + } + + if ($seconds < 86400) { + $hours = (int) floor($seconds / 3600); + $minutes = (int) floor(($seconds % 3600) / 60); + + $result = $hours.' hour'.($hours !== 1 ? 's' : ''); + if ($minutes > 0) { + $result .= ' '.$minutes.' minute'.($minutes !== 1 ? 's' : ''); + } + + return $result; + } + + $days = (int) floor($seconds / 86400); + $hours = (int) floor(($seconds % 86400) / 3600); + + $result = $days.' day'.($days !== 1 ? 's' : ''); + if ($hours > 0) { + $result .= ' '.$hours.' hour'.($hours !== 1 ? 's' : ''); + } + + return $result; + } +} diff --git a/src/php/src/Api/Services/WebhookService.php b/src/php/src/Api/Services/WebhookService.php new file mode 100644 index 0000000..a708cc0 --- /dev/null +++ b/src/php/src/Api/Services/WebhookService.php @@ -0,0 +1,192 @@ + 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(), + ]; + } +} diff --git a/src/php/src/Api/Services/WebhookSignature.php b/src/php/src/Api/Services/WebhookSignature.php new file mode 100644 index 0000000..0befe1d --- /dev/null +++ b/src/php/src/Api/Services/WebhookSignature.php @@ -0,0 +1,206 @@ +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 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, + ]; + } +} diff --git a/src/php/src/Api/Services/WebhookTemplateService.php b/src/php/src/Api/Services/WebhookTemplateService.php new file mode 100644 index 0000000..4a2d02c --- /dev/null +++ b/src/php/src/Api/Services/WebhookTemplateService.php @@ -0,0 +1,629 @@ + 'formatIso8601', + 'timestamp' => 'formatTimestamp', + 'currency' => 'formatCurrency', + 'json' => 'formatJson', + 'upper' => 'formatUpper', + 'lower' => 'formatLower', + 'default' => 'formatDefault', + 'truncate' => 'formatTruncate', + 'escape' => 'formatEscape', + 'urlencode' => 'formatUrlencode', + ]; + + /** + * Render a template with the given event data. + * + * @param WebhookPayloadTemplate $template The template containing the pattern + * @param WebhookEvent $event The event providing data + * @return array The rendered payload + * + * @throws \InvalidArgumentException If template is invalid + */ + public function render(WebhookPayloadTemplate $template, WebhookEvent $event): array + { + $templateContent = $template->template; + $format = $template->getFormat(); + $context = $this->buildContext($event); + + return $this->renderTemplate($templateContent, $format, $context); + } + + /** + * Render a template string with context data. + * + * @param string $templateContent The template content + * @param WebhookTemplateFormat $format The template format + * @param array $context The context data + * @return array The rendered payload + * + * @throws \InvalidArgumentException If template renders to invalid JSON + */ + public function renderTemplate(string $templateContent, WebhookTemplateFormat $format, array $context): array + { + $rendered = match ($format) { + WebhookTemplateFormat::SIMPLE => $this->renderSimple($templateContent, $context), + WebhookTemplateFormat::MUSTACHE => $this->renderMustache($templateContent, $context), + WebhookTemplateFormat::JSON => $this->renderJson($templateContent, $context), + }; + + // Parse as JSON if it's a string + if (is_string($rendered)) { + $decoded = json_decode($rendered, true); + if (json_last_error() !== JSON_ERROR_NONE) { + throw new \InvalidArgumentException('Template rendered to invalid JSON: '.json_last_error_msg()); + } + + return $decoded; + } + + return $rendered; + } + + /** + * Build the default payload structure for an event. + */ + public function buildDefaultPayload(WebhookEvent $event): array + { + return [ + 'event' => $event::name(), + 'data' => $event->payload(), + 'timestamp' => now()->toIso8601String(), + ]; + } + + /** + * Validate a template for syntax errors. + * + * @param string $template The template content + * @param WebhookTemplateFormat $format The template format + * @return array{valid: bool, errors: array} + */ + public function validateTemplate(string $template, WebhookTemplateFormat $format): array + { + $errors = []; + + // Check for empty template + if (empty(trim($template))) { + return ['valid' => false, 'errors' => ['Template cannot be empty.']]; + } + + // Format-specific validation + $errors = match ($format) { + WebhookTemplateFormat::SIMPLE => $this->validateSimple($template), + WebhookTemplateFormat::MUSTACHE => $this->validateMustache($template), + WebhookTemplateFormat::JSON => $this->validateJson($template), + }; + + return [ + 'valid' => empty($errors), + 'errors' => $errors, + ]; + } + + /** + * Get available variables for an event type. + * + * @param string|null $eventType The event type (e.g., 'post.published') + * @return array + */ + public function getAvailableVariables(?string $eventType = null): array + { + // Base variables available for all events + $variables = [ + 'event.type' => [ + 'type' => 'string', + 'description' => 'The event identifier', + 'example' => $eventType ?? 'resource.action', + ], + 'event.name' => [ + 'type' => 'string', + 'description' => 'Human-readable event name', + 'example' => 'Resource Updated', + ], + 'message' => [ + 'type' => 'string', + 'description' => 'Human-readable event message', + 'example' => 'A resource was updated successfully.', + ], + 'timestamp' => [ + 'type' => 'datetime', + 'description' => 'When the event occurred (ISO 8601)', + 'example' => now()->toIso8601String(), + ], + 'timestamp_unix' => [ + 'type' => 'integer', + 'description' => 'Unix timestamp of the event', + 'example' => now()->timestamp, + ], + 'data' => [ + 'type' => 'object', + 'description' => 'Event-specific data payload', + 'example' => ['id' => 1, 'name' => 'Example'], + ], + 'data.id' => [ + 'type' => 'mixed', + 'description' => 'Primary identifier of the resource', + 'example' => 123, + ], + 'data.uuid' => [ + 'type' => 'string', + 'description' => 'UUID of the resource (if available)', + 'example' => '550e8400-e29b-41d4-a716-446655440000', + ], + ]; + + return $variables; + } + + /** + * Get available filters for template variables. + * + * @return array + */ + public function getAvailableFilters(): array + { + return [ + 'iso8601' => 'Format datetime as ISO 8601', + 'timestamp' => 'Format datetime as Unix timestamp', + 'currency' => 'Format number as currency (2 decimal places)', + 'json' => 'Encode value as JSON', + 'upper' => 'Convert to uppercase', + 'lower' => 'Convert to lowercase', + 'default' => 'Provide default value if empty (e.g., {{value | default:N/A}})', + 'truncate' => 'Truncate to specified length (e.g., {{text | truncate:100}})', + 'escape' => 'HTML escape the value', + 'urlencode' => 'URL encode the value', + ]; + } + + /** + * Preview a template with sample data. + * + * @param string $template The template content + * @param WebhookTemplateFormat $format The template format + * @param string|null $eventType The event type for sample data + * @return array{success: bool, output: mixed, errors: array} + */ + public function previewPayload(string $template, WebhookTemplateFormat $format, ?string $eventType = null): array + { + // Validate first + $validation = $this->validateTemplate($template, $format); + if (! $validation['valid']) { + return [ + 'success' => false, + 'output' => null, + 'errors' => $validation['errors'], + ]; + } + + // Build sample context + $context = $this->buildSampleContext($eventType); + + try { + $rendered = match ($format) { + WebhookTemplateFormat::SIMPLE => $this->renderSimple($template, $context), + WebhookTemplateFormat::MUSTACHE => $this->renderMustache($template, $context), + WebhookTemplateFormat::JSON => $this->renderJson($template, $context), + }; + + // Parse as JSON + if (is_string($rendered)) { + $decoded = json_decode($rendered, true); + if (json_last_error() !== JSON_ERROR_NONE) { + return [ + 'success' => false, + 'output' => $rendered, + 'errors' => ['Rendered template is not valid JSON: '.json_last_error_msg()], + ]; + } + + return [ + 'success' => true, + 'output' => $decoded, + 'errors' => [], + ]; + } + + return [ + 'success' => true, + 'output' => $rendered, + 'errors' => [], + ]; + } catch (\Exception $e) { + return [ + 'success' => false, + 'output' => null, + 'errors' => [$e->getMessage()], + ]; + } + } + + /** + * Get builtin template content by type. + */ + public function getBuiltinTemplate(BuiltinTemplateType $type): string + { + return $type->template(); + } + + /** + * Get all builtin templates. + * + * @return array + */ + public function getBuiltinTemplates(): array + { + $templates = []; + + foreach (BuiltinTemplateType::cases() as $type) { + $templates[$type->value] = [ + 'name' => $type->label(), + 'description' => $type->description(), + 'template' => $type->template(), + 'format' => $type->format(), + ]; + } + + return $templates; + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + /** + * Build template context from an event. + */ + protected function buildContext(WebhookEvent $event): array + { + return [ + 'event' => [ + 'type' => $event::name(), + 'name' => $event::nameLocalised(), + ], + 'data' => $event->payload(), + 'message' => $event->message(), + 'timestamp' => now()->toIso8601String(), + 'timestamp_unix' => now()->timestamp, + ]; + } + + /** + * Build sample context for preview. + */ + protected function buildSampleContext(?string $eventType): array + { + $variables = $this->getAvailableVariables($eventType); + $context = []; + + foreach ($variables as $path => $info) { + Arr::set($context, $path, $info['example']); + } + + // Add message + $context['message'] = 'Sample webhook event message'; + + return $context; + } + + /** + * Render simple template with variable substitution. + */ + protected function renderSimple(string $template, array $context): string + { + // Match {{variable}} or {{variable | filter}} or {{variable | filter:arg}} + return preg_replace_callback( + '/\{\{\s*([a-zA-Z0-9_\.]+)(?:\s*\|\s*([a-zA-Z0-9_]+)(?::([^\}]+))?)?\s*\}\}/', + function ($matches) use ($context) { + $path = $matches[1]; + $filter = $matches[2] ?? null; + $filterArg = $matches[3] ?? null; + + $value = Arr::get($context, $path); + + // Apply filter if specified + if ($filter && isset(self::FILTERS[$filter])) { + $method = self::FILTERS[$filter]; + $value = $this->$method($value, $filterArg); + } + + // Convert arrays/objects to JSON strings + if (is_array($value) || is_object($value)) { + return json_encode($value); + } + + return (string) ($value ?? ''); + }, + $template + ); + } + + /** + * Render mustache-style template. + */ + protected function renderMustache(string $template, array $context): string + { + // Process conditionals: {{#if variable}}...{{/if}} + $template = preg_replace_callback( + '/\{\{#if\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/if\}\}/s', + function ($matches) use ($context) { + $path = $matches[1]; + $content = $matches[2]; + $value = Arr::get($context, $path); + + // Check if value is truthy + if ($value && (! is_array($value) || ! empty($value))) { + return $this->renderMustache($content, $context); + } + + return ''; + }, + $template + ); + + // Process negative conditionals: {{#unless variable}}...{{/unless}} + $template = preg_replace_callback( + '/\{\{#unless\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/unless\}\}/s', + function ($matches) use ($context) { + $path = $matches[1]; + $content = $matches[2]; + $value = Arr::get($context, $path); + + // Check if value is falsy + if (! $value || (is_array($value) && empty($value))) { + return $this->renderMustache($content, $context); + } + + return ''; + }, + $template + ); + + // Process loops: {{#each variable}}...{{/each}} + $template = preg_replace_callback( + '/\{\{#each\s+([a-zA-Z0-9_\.]+)\s*\}\}(.*?)\{\{\/each\}\}/s', + function ($matches) use ($context) { + $path = $matches[1]; + $content = $matches[2]; + $items = Arr::get($context, $path, []); + + if (! is_array($items)) { + return ''; + } + + $output = ''; + foreach ($items as $index => $item) { + $itemContext = array_merge($context, [ + 'this' => $item, + '@index' => $index, + '@first' => $index === 0, + '@last' => $index === count($items) - 1, + ]); + + // Also allow direct access to item properties + if (is_array($item)) { + $itemContext = array_merge($itemContext, $item); + } + + $output .= $this->renderMustache($content, $itemContext); + } + + return $output; + }, + $template + ); + + // Finally, do simple variable replacement + return $this->renderSimple($template, $context); + } + + /** + * Render JSON template. + */ + protected function renderJson(string $template, array $context): string + { + // For JSON format, we do simple rendering then validate the result + return $this->renderSimple($template, $context); + } + + /** + * Validate simple template syntax. + */ + protected function validateSimple(string $template): array + { + $errors = []; + + // Check for unclosed braces + $openCount = substr_count($template, '{{'); + $closeCount = substr_count($template, '}}'); + + if ($openCount !== $closeCount) { + $errors[] = 'Mismatched template braces. Found '.$openCount.' opening and '.$closeCount.' closing.'; + } + + // Check for invalid variable names + preg_match_all('/\{\{\s*([^}|]+)/', $template, $matches); + foreach ($matches[1] as $varName) { + $varName = trim($varName); + // Allow #if, #unless, #each, /if, /unless, /each for mustache compatibility + if (! preg_match('/^[#\/]?[a-zA-Z0-9_\.@]+$/', $varName)) { + $errors[] = "Invalid variable name: {$varName}"; + } + } + + // Check for unknown filters + preg_match_all('/\|\s*([a-zA-Z0-9_]+)/', $template, $filterMatches); + foreach ($filterMatches[1] as $filter) { + if (! isset(self::FILTERS[$filter])) { + $errors[] = "Unknown filter: {$filter}. Available: ".implode(', ', array_keys(self::FILTERS)); + } + } + + return $errors; + } + + /** + * Validate mustache template syntax. + */ + protected function validateMustache(string $template): array + { + $errors = $this->validateSimple($template); + + // Check for unclosed blocks + $blocks = ['if', 'unless', 'each']; + foreach ($blocks as $block) { + $openCount = preg_match_all('/\{\{#'.$block.'\s/', $template); + $closeCount = preg_match_all('/\{\{\/'.$block.'\}\}/', $template); + + if ($openCount !== $closeCount) { + $errors[] = "Unclosed {{#{$block}}} block. Found {$openCount} opening and {$closeCount} closing."; + } + } + + return $errors; + } + + /** + * Validate JSON template syntax. + */ + protected function validateJson(string $template): array + { + $errors = $this->validateSimple($template); + + // Try to parse as JSON after replacing variables with placeholders + $testTemplate = preg_replace('/\{\{[^}]+\}\}/', '"__placeholder__"', $template); + + json_decode($testTemplate); + if (json_last_error() !== JSON_ERROR_NONE) { + $errors[] = 'Template is not valid JSON structure: '.json_last_error_msg(); + } + + return $errors; + } + + // ------------------------------------------------------------------------- + // Filter methods + // ------------------------------------------------------------------------- + + protected function formatIso8601(mixed $value, ?string $arg = null): string + { + if ($value instanceof Carbon) { + return $value->toIso8601String(); + } + + if (is_numeric($value)) { + return Carbon::createFromTimestamp($value)->toIso8601String(); + } + + if (is_string($value)) { + try { + return Carbon::parse($value)->toIso8601String(); + } catch (\Exception) { + return (string) $value; + } + } + + return (string) $value; + } + + protected function formatTimestamp(mixed $value, ?string $arg = null): int + { + if ($value instanceof Carbon) { + return $value->timestamp; + } + + if (is_numeric($value)) { + return (int) $value; + } + + if (is_string($value)) { + try { + return Carbon::parse($value)->timestamp; + } catch (\Exception) { + return 0; + } + } + + return 0; + } + + protected function formatCurrency(mixed $value, ?string $arg = null): string + { + $decimals = $arg ? (int) $arg : 2; + + return number_format((float) $value, $decimals); + } + + protected function formatJson(mixed $value, ?string $arg = null): string + { + return json_encode($value) ?: '""'; + } + + protected function formatUpper(mixed $value, ?string $arg = null): string + { + return mb_strtoupper((string) $value); + } + + protected function formatLower(mixed $value, ?string $arg = null): string + { + return mb_strtolower((string) $value); + } + + protected function formatDefault(mixed $value, ?string $arg = null): mixed + { + if ($value === null || $value === '' || (is_array($value) && empty($value))) { + return $arg ?? ''; + } + + return $value; + } + + protected function formatTruncate(mixed $value, ?string $arg = null): string + { + $length = $arg ? (int) $arg : 100; + $string = (string) $value; + + if (mb_strlen($string) <= $length) { + return $string; + } + + return mb_substr($string, 0, $length - 3).'...'; + } + + protected function formatEscape(mixed $value, ?string $arg = null): string + { + return htmlspecialchars((string) $value, ENT_QUOTES | ENT_HTML5, 'UTF-8'); + } + + protected function formatUrlencode(mixed $value, ?string $arg = null): string + { + return urlencode((string) $value); + } +} diff --git a/src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php b/src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php new file mode 100644 index 0000000..b67a592 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php @@ -0,0 +1,403 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + $this->ipService = app(IpRestrictionService::class); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// IP Restriction Service - IPv4 +// ───────────────────────────────────────────────────────────────────────────── + +describe('IP Restriction Service - IPv4', function () { + it('allows IP when whitelist is empty', function () { + expect($this->ipService->isIpAllowed('192.168.1.1', []))->toBeTrue(); + }); + + it('matches exact IPv4 address', function () { + $whitelist = ['192.168.1.1', '10.0.0.1']; + + expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.2', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /24 range', function () { + $whitelist = ['192.168.1.0/24']; + + expect($this->ipService->isIpAllowed('192.168.1.0', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.255', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.2.1', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /16 range', function () { + $whitelist = ['10.0.0.0/16']; + + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.0.255.255', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.1.0.1', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /32 single host', function () { + $whitelist = ['192.168.1.100/32']; + + expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('192.168.1.101', $whitelist))->toBeFalse(); + }); + + it('matches IPv4 CIDR /8 class A range', function () { + $whitelist = ['10.0.0.0/8']; + + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.255.255.255', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('11.0.0.1', $whitelist))->toBeFalse(); + }); + + it('rejects invalid IPv4 addresses', function () { + $whitelist = ['192.168.1.0/24']; + + expect($this->ipService->isIpAllowed('invalid', $whitelist))->toBeFalse(); + expect($this->ipService->isIpAllowed('256.256.256.256', $whitelist))->toBeFalse(); + expect($this->ipService->isIpAllowed('', $whitelist))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// IP Restriction Service - IPv6 +// ───────────────────────────────────────────────────────────────────────────── + +describe('IP Restriction Service - IPv6', function () { + it('matches exact IPv6 address', function () { + $whitelist = ['::1', '2001:db8::1']; + + expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8::2', $whitelist))->toBeFalse(); + }); + + it('normalises IPv6 for comparison', function () { + $whitelist = ['2001:db8:0000:0000:0000:0000:0000:0001']; + + // Shortened form should match expanded form + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + }); + + it('matches IPv6 CIDR /64 range', function () { + $whitelist = ['2001:db8::/64']; + + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8::ffff', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8:0:1::1', $whitelist))->toBeFalse(); + }); + + it('matches IPv6 CIDR /32 range', function () { + $whitelist = ['2001:db8::/32']; + + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db8:ffff::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse(); + }); + + it('matches IPv6 loopback', function () { + $whitelist = ['::1/128']; + + expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('::2', $whitelist))->toBeFalse(); + }); + + it('does not match IPv4 against IPv6 CIDR', function () { + $whitelist = ['2001:db8::/32']; + + expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeFalse(); + }); + + it('does not match IPv6 against IPv4 CIDR', function () { + $whitelist = ['192.168.1.0/24']; + + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// IP Restriction Service - Validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('IP Restriction Service - Validation', function () { + it('validates correct IPv4 addresses', function () { + $result = $this->ipService->validateEntry('192.168.1.1'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('validates correct IPv6 addresses', function () { + $result = $this->ipService->validateEntry('2001:db8::1'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('validates correct IPv4 CIDR', function () { + $result = $this->ipService->validateEntry('192.168.1.0/24'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('validates correct IPv6 CIDR', function () { + $result = $this->ipService->validateEntry('2001:db8::/32'); + + expect($result['valid'])->toBeTrue(); + expect($result['error'])->toBeNull(); + }); + + it('rejects invalid IP addresses', function () { + $result = $this->ipService->validateEntry('not-an-ip'); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Invalid IP address'); + }); + + it('rejects invalid CIDR prefix for IPv4', function () { + $result = $this->ipService->validateEntry('192.168.1.0/33'); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('IPv4 prefix must be between 0 and 32'); + }); + + it('rejects invalid CIDR prefix for IPv6', function () { + $result = $this->ipService->validateEntry('2001:db8::/129'); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('IPv6 prefix must be between 0 and 128'); + }); + + it('rejects empty entries', function () { + $result = $this->ipService->validateEntry(''); + + expect($result['valid'])->toBeFalse(); + expect($result['error'])->toBe('Empty entry'); + }); + + it('parses multi-line whitelist input', function () { + $input = "192.168.1.1\n10.0.0.0/8\n# Comment line\n2001:db8::1\ninvalid-ip"; + + $result = $this->ipService->parseWhitelistInput($input); + + expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.0/8', '2001:db8::1']); + expect($result['errors'])->toHaveCount(1); + expect($result['errors'][0])->toContain('invalid-ip'); + }); + + it('handles comma-separated whitelist input', function () { + $input = '192.168.1.1, 10.0.0.1, 172.16.0.0/12'; + + $result = $this->ipService->parseWhitelistInput($input); + + expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/12']); + expect($result['errors'])->toBeEmpty(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key IP Whitelist Model Methods +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key IP Whitelist Model', function () { + it('reports no restrictions when allowed_ips is null', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Restrictions Key' + ); + + expect($result['api_key']->hasIpRestrictions())->toBeFalse(); + expect($result['api_key']->getAllowedIps())->toBeNull(); + }); + + it('reports no restrictions when allowed_ips is empty', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Empty Whitelist Key' + ); + $result['api_key']->update(['allowed_ips' => []]); + + expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse(); + }); + + it('reports restrictions when allowed_ips has entries', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Restricted Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.0/24']]); + + $key = $result['api_key']->fresh(); + expect($key->hasIpRestrictions())->toBeTrue(); + expect($key->getAllowedIps())->toBe(['192.168.1.0/24']); + }); + + it('updates allowed IPs', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Update IPs Key' + ); + + $result['api_key']->updateAllowedIps(['10.0.0.0/8', '192.168.1.1']); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.0/8', '192.168.1.1']); + }); + + it('adds IP to whitelist', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Add IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]); + + $result['api_key']->addAllowedIp('10.0.0.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1', '10.0.0.1']); + }); + + it('does not add duplicate IPs', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Duplicate IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]); + + $result['api_key']->addAllowedIp('192.168.1.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1']); + }); + + it('removes IP from whitelist', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Remove IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1', '10.0.0.1']]); + + $result['api_key']->removeAllowedIp('192.168.1.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.1']); + }); + + it('sets allowed_ips to null when removing last IP', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Remove Last IP Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]); + + $result['api_key']->removeAllowedIp('192.168.1.1'); + + expect($result['api_key']->fresh()->getAllowedIps())->toBeNull(); + expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Rotation with IP Whitelist +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Rotation with IP Whitelist', function () { + it('preserves IP whitelist during rotation', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'IP Restricted Key' + ); + $result['api_key']->update(['allowed_ips' => ['192.168.1.0/24', '10.0.0.1']]); + + $rotated = $result['api_key']->fresh()->rotate(); + + expect($rotated['api_key']->getAllowedIps())->toBe(['192.168.1.0/24', '10.0.0.1']); + }); + + it('preserves empty IP whitelist during rotation', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Restrictions Key' + ); + + $rotated = $result['api_key']->rotate(); + + expect($rotated['api_key']->getAllowedIps())->toBeNull(); + expect($rotated['api_key']->hasIpRestrictions())->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// API Key Factory IP Whitelist +// ───────────────────────────────────────────────────────────────────────────── + +describe('API Key Factory IP Whitelist', function () { + it('creates keys with IP whitelist via factory', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->withAllowedIps(['192.168.1.0/24', '::1']) + ->create(); + + expect($key->hasIpRestrictions())->toBeTrue(); + expect($key->getAllowedIps())->toBe(['192.168.1.0/24', '::1']); + }); + + it('creates keys without IP restrictions by default', function () { + $key = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->create(); + + expect($key->hasIpRestrictions())->toBeFalse(); + expect($key->getAllowedIps())->toBeNull(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Mixed IP Versions +// ───────────────────────────────────────────────────────────────────────────── + +describe('Mixed IP Versions in Whitelist', function () { + it('handles mixed IPv4 and IPv6 entries', function () { + $whitelist = ['192.168.1.0/24', '2001:db8::/32', '10.0.0.1', '::1']; + + // IPv4 matching + expect($this->ipService->isIpAllowed('192.168.1.100', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('172.16.0.1', $whitelist))->toBeFalse(); + + // IPv6 matching + expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue(); + expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse(); + }); +}); diff --git a/src/php/src/Api/Tests/Feature/ApiKeyRotationTest.php b/src/php/src/Api/Tests/Feature/ApiKeyRotationTest.php new file mode 100644 index 0000000..86c2f5c --- /dev/null +++ b/src/php/src/Api/Tests/Feature/ApiKeyRotationTest.php @@ -0,0 +1,232 @@ +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); + }); +}); diff --git a/src/php/src/Api/Tests/Feature/ApiKeySecurityTest.php b/src/php/src/Api/Tests/Feature/ApiKeySecurityTest.php new file mode 100644 index 0000000..d9f0545 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/ApiKeySecurityTest.php @@ -0,0 +1,381 @@ +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(); + }); +}); diff --git a/src/php/src/Api/Tests/Feature/ApiKeyTest.php b/src/php/src/Api/Tests/Feature/ApiKeyTest.php new file mode 100644 index 0000000..109811c --- /dev/null +++ b/src/php/src/Api/Tests/Feature/ApiKeyTest.php @@ -0,0 +1,617 @@ +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); + }); +}); diff --git a/src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php b/src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php new file mode 100644 index 0000000..6da35e8 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php @@ -0,0 +1,742 @@ +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 + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Wildcard Scopes - Resource Wildcards (posts:*) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Resource Wildcard Scopes', function () { + it('grants access with resource wildcard scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Admin Key', + ['posts:*'] + ); + + $apiKey = $result['api_key']; + + // Resource wildcard should grant all actions on that resource + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + expect($apiKey->hasScope('posts:publish'))->toBeTrue(); + }); + + it('resource wildcard does not grant access to other resources', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Only Key', + ['posts:*'] + ); + + $apiKey = $result['api_key']; + + // Should not grant access to other resources + expect($apiKey->hasScope('users:read'))->toBeFalse(); + expect($apiKey->hasScope('analytics:read'))->toBeFalse(); + expect($apiKey->hasScope('webhooks:write'))->toBeFalse(); + }); + + it('multiple resource wildcards work independently', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Content Admin Key', + ['posts:*', 'pages:*'] + ); + + $apiKey = $result['api_key']; + + // Both resource wildcards should work + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + expect($apiKey->hasScope('pages:write'))->toBeTrue(); + expect($apiKey->hasScope('pages:publish'))->toBeTrue(); + + // Others should not + expect($apiKey->hasScope('users:read'))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Wildcard Scopes - Action Wildcards (*:read) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Action Wildcard Scopes', function () { + it('grants read access to all resources with action wildcard', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read Only All Key', + ['*:read'] + ); + + $apiKey = $result['api_key']; + + // Action wildcard should grant read on all resources + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('analytics:read'))->toBeTrue(); + expect($apiKey->hasScope('webhooks:read'))->toBeTrue(); + }); + + it('action wildcard does not grant other actions', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read Only All Key', + ['*:read'] + ); + + $apiKey = $result['api_key']; + + // Should not grant write or delete + expect($apiKey->hasScope('posts:write'))->toBeFalse(); + expect($apiKey->hasScope('users:delete'))->toBeFalse(); + expect($apiKey->hasScope('webhooks:manage'))->toBeFalse(); + }); + + it('multiple action wildcards work independently', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read/Write All Key', + ['*:read', '*:write'] + ); + + $apiKey = $result['api_key']; + + // Both action wildcards should work + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('users:write'))->toBeTrue(); + + // Delete should not be granted + expect($apiKey->hasScope('posts:delete'))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Wildcard Scopes - Full Wildcard (*) +// ───────────────────────────────────────────────────────────────────────────── + +describe('Full Wildcard Scope', function () { + it('full wildcard grants access to everything', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'God Mode Key', + ['*'] + ); + + $apiKey = $result['api_key']; + + // Full wildcard should grant everything + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('admin:system'))->toBeTrue(); + expect($apiKey->hasScope('any:thing'))->toBeTrue(); + }); + + it('full wildcard grants simple scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'God Mode Key', + ['*'] + ); + + $apiKey = $result['api_key']; + + // Simple scopes should also be granted + expect($apiKey->hasScope('read'))->toBeTrue(); + expect($apiKey->hasScope('write'))->toBeTrue(); + expect($apiKey->hasScope('delete'))->toBeTrue(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scope Inheritance and Hierarchy +// ───────────────────────────────────────────────────────────────────────────── + +describe('Scope Inheritance', function () { + it('exact scopes take precedence over wildcards', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Mixed Key', + ['posts:read', 'posts:*'] + ); + + $apiKey = $result['api_key']; + + // Both exact and wildcard should work + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + }); + + it('combined resource and action wildcards cover different scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Mixed Wildcards Key', + ['posts:*', '*:read'] + ); + + $apiKey = $result['api_key']; + + // posts:* should grant all post actions + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('posts:write'))->toBeTrue(); + expect($apiKey->hasScope('posts:delete'))->toBeTrue(); + + // *:read should grant read on all resources + expect($apiKey->hasScope('users:read'))->toBeTrue(); + expect($apiKey->hasScope('analytics:read'))->toBeTrue(); + + // But not write on other resources + expect($apiKey->hasScope('users:write'))->toBeFalse(); + }); + + it('empty scopes array denies all access', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'No Scopes Key', + [] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScope('read'))->toBeFalse(); + expect($apiKey->hasScope('posts:read'))->toBeFalse(); + expect($apiKey->hasScope('*'))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// hasScopes and hasAnyScope Methods +// ───────────────────────────────────────────────────────────────────────────── + +describe('Multiple Scope Checking', function () { + it('hasScopes requires all scopes to be present', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Limited Key', + ['posts:read', 'posts:write'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScopes(['posts:read']))->toBeTrue(); + expect($apiKey->hasScopes(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasScopes(['posts:read', 'posts:delete']))->toBeFalse(); + }); + + it('hasAnyScope requires at least one scope to be present', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Limited Key', + ['posts:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasAnyScope(['posts:read']))->toBeTrue(); + expect($apiKey->hasAnyScope(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasAnyScope(['posts:delete', 'users:read']))->toBeFalse(); + }); + + it('hasScopes works with wildcards', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Admin Key', + ['posts:*'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScopes(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasScopes(['posts:read', 'users:read']))->toBeFalse(); + }); + + it('hasAnyScope works with wildcards', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Read All Key', + ['*:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasAnyScope(['posts:read', 'posts:write']))->toBeTrue(); + expect($apiKey->hasAnyScope(['posts:write', 'users:write']))->toBeFalse(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CheckApiScope Middleware (Explicit Scope Requirements) +// ───────────────────────────────────────────────────────────────────────────── + +describe('CheckApiScope Middleware', function () { + beforeEach(function () { + // Register routes with explicit scope requirements + Route::middleware(['api', 'api.auth', 'api.scope:posts:read']) + ->get('/test-explicit/posts', fn () => response()->json(['status' => 'ok'])); + + Route::middleware(['api', 'api.auth', 'api.scope:posts:write']) + ->post('/test-explicit/posts', fn () => response()->json(['status' => 'ok'])); + + Route::middleware(['api', 'api.auth', 'api.scope:posts:read,posts:write']) + ->put('/test-explicit/posts', fn () => response()->json(['status' => 'ok'])); + }); + + it('allows request with exact required scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Reader Key', + ['posts:read'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(200); + }); + + it('allows request with wildcard matching required scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Admin Key', + ['posts:*'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(200); + }); + + it('denies request without required scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Users Only Key', + ['users:read'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json('error'))->toBe('forbidden'); + expect($response->json('message'))->toContain('posts:read'); + }); + + it('requires all scopes when multiple specified', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Posts Reader Only Key', + ['posts:read'] + ); + + // Route requires both posts:read AND posts:write + $response = $this->putJson('/api/test-explicit/posts', [], [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json('message'))->toContain('posts:write'); + }); + + it('allows all scopes with full wildcard', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Admin Key', + ['*'] + ); + + $headers = ['Authorization' => "Bearer {$result['plain_key']}"]; + + expect($this->getJson('/api/test-explicit/posts', $headers)->status())->toBe(200); + expect($this->postJson('/api/test-explicit/posts', [], $headers)->status())->toBe(200); + expect($this->putJson('/api/test-explicit/posts', [], $headers)->status())->toBe(200); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scope Denial Error Responses +// ───────────────────────────────────────────────────────────────────────────── + +describe('Scope Denial Error Responses', function () { + it('EnforceApiScope returns 403 with detailed error', 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())->toHaveKeys(['error', 'message', 'detail', 'key_scopes']); + expect($response->json('error'))->toBe('forbidden'); + expect($response->json('detail'))->toContain('POST'); + expect($response->json('detail'))->toContain('write'); + }); + + it('CheckApiScope returns 403 with required scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Wrong Scopes Key', + ['users:read'] + ); + + $response = $this->getJson('/api/test-explicit/posts', [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json())->toHaveKeys(['error', 'message', 'required_scopes', 'key_scopes']); + expect($response->json('required_scopes'))->toContain('posts:read'); + expect($response->json('key_scopes'))->toBe(['users:read']); + }); + + it('error response contains accurate key scopes', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Multi Scope Key', + ['posts:read', 'users:read', 'analytics:read'] + ); + + $response = $this->deleteJson('/api/test-scope/delete', [], [ + 'Authorization' => "Bearer {$result['plain_key']}", + ]); + + expect($response->status())->toBe(403); + expect($response->json('key_scopes'))->toBe(['posts:read', 'users:read', 'analytics:read']); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Edge Cases +// ───────────────────────────────────────────────────────────────────────────── + +describe('Edge Cases', function () { + it('handles null scopes array gracefully', function () { + // Directly create a key with null scopes (bypassing generate()) + $apiKey = ApiKey::factory() + ->for($this->workspace) + ->for($this->user) + ->create(['scopes' => null]); + + expect($apiKey->hasScope('read'))->toBeFalse(); + expect($apiKey->hasScope('posts:read'))->toBeFalse(); + expect($apiKey->hasAnyScope(['read']))->toBeFalse(); + }); + + it('handles scope with multiple colons', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Nested Scope Key', + ['api:v2:posts:read'] + ); + + $apiKey = $result['api_key']; + + // Exact match should work + expect($apiKey->hasScope('api:v2:posts:read'))->toBeTrue(); + + // The first segment before colon is treated as resource + // Resource wildcard for 'api' should match + $result2 = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'API Wildcard Key', + ['api:*'] + ); + + // api:* should match api:v2:posts:read (treats v2:posts:read as action) + expect($result2['api_key']->hasScope('api:v2:posts:read'))->toBeTrue(); + }); + + it('handles empty string scope', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Valid Key', + ['posts:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScope(''))->toBeFalse(); + }); + + it('scope matching is case-sensitive', function () { + $result = ApiKey::generate( + $this->workspace->id, + $this->user->id, + 'Lowercase Key', + ['posts:read'] + ); + + $apiKey = $result['api_key']; + + expect($apiKey->hasScope('posts:read'))->toBeTrue(); + expect($apiKey->hasScope('Posts:Read'))->toBeFalse(); + expect($apiKey->hasScope('POSTS:READ'))->toBeFalse(); + }); +}); diff --git a/src/php/src/Api/Tests/Feature/ApiUsageTest.php b/src/php/src/Api/Tests/Feature/ApiUsageTest.php new file mode 100644 index 0000000..20c3f0d --- /dev/null +++ b/src/php/src/Api/Tests/Feature/ApiUsageTest.php @@ -0,0 +1,362 @@ +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); + }); +}); diff --git a/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php new file mode 100644 index 0000000..3669b87 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php @@ -0,0 +1,1069 @@ +build(); + + expect($spec)->toBeArray() + ->toHaveKey('openapi') + ->toHaveKey('info') + ->toHaveKey('servers') + ->toHaveKey('tags') + ->toHaveKey('paths') + ->toHaveKey('components'); + + expect($spec['openapi'])->toBe('3.1.0'); + }); + + it('builds info section with title and version', function () { + config(['api-docs.info.title' => 'Test API']); + config(['api-docs.info.version' => '2.0.0']); + config(['api-docs.info.description' => 'A test API']); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['info'])->toHaveKey('title') + ->toHaveKey('version'); + expect($spec['info']['title'])->toContain('API'); + }); + + it('builds servers section with default URL', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['servers'])->toBeArray(); + expect($spec['servers'][0])->toHaveKey('url'); + }); + + it('builds components with security schemes', function () { + config(['api-docs.auth.api_key.enabled' => true]); + config(['api-docs.auth.bearer.enabled' => true]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['components'])->toHaveKey('securitySchemes') + ->toHaveKey('schemas'); + expect($spec['components']['securitySchemes'])->toHaveKey('apiKeyAuth') + ->toHaveKey('bearerAuth'); + }); + + it('builds common error schema in components', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['components']['schemas'])->toHaveKey('Error'); + expect($spec['components']['schemas']['Error']['type'])->toBe('object'); + expect($spec['components']['schemas']['Error']['properties'])->toHaveKey('message'); + }); + + it('builds pagination schema in components', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['components']['schemas'])->toHaveKey('Pagination'); + expect($spec['components']['schemas']['Pagination']['properties']) + ->toHaveKey('current_page') + ->toHaveKey('per_page') + ->toHaveKey('total'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// OpenApiBuilder Controller Scanning +// ───────────────────────────────────────────────────────────────────────────── + +describe('OpenApiBuilder Controller Scanning', function () { + beforeEach(function () { + // Register test routes with various configurations + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/test-scan/items', [TestOpenApiController::class, 'index']) + ->name('test-scan.items.index'); + RouteFacade::get('/test-scan/items/{id}', [TestOpenApiController::class, 'show']) + ->name('test-scan.items.show'); + RouteFacade::post('/test-scan/items', [TestOpenApiController::class, 'store']) + ->name('test-scan.items.store'); + RouteFacade::put('/test-scan/items/{id}', [TestOpenApiController::class, 'update']) + ->name('test-scan.items.update'); + RouteFacade::delete('/test-scan/items/{id}', [TestOpenApiController::class, 'destroy']) + ->name('test-scan.items.destroy'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + config(['api-docs.routes.exclude' => []]); + }); + + it('discovers routes matching include patterns', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['paths'])->toHaveKey('/api/test-scan/items'); + }); + + it('generates correct HTTP methods for routes', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['paths']['/api/test-scan/items'])->toHaveKey('get') + ->toHaveKey('post'); + expect($spec['paths']['/api/test-scan/items/{id}'])->toHaveKey('get') + ->toHaveKey('put') + ->toHaveKey('delete'); + }); + + it('normalises path parameters to OpenAPI format', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + // Laravel {id} should remain as {id} in OpenAPI + expect($spec['paths'])->toHaveKey('/api/test-scan/items/{id}'); + }); + + it('generates operation IDs from route names', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/test-scan/items']['get']; + expect($operation['operationId'])->toBe('testScanItemsIndex'); + }); + + it('generates summary from route name', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/test-scan/items']['get']; + expect($operation)->toHaveKey('summary'); + expect($operation['summary'])->toContain('Index'); + }); + + it('extracts path parameters as required', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/test-scan/items/{id}']['get']; + expect($operation['parameters'])->toBeArray(); + + $idParam = collect($operation['parameters'])->firstWhere('name', 'id'); + expect($idParam)->not->toBeNull(); + expect($idParam['in'])->toBe('path'); + expect($idParam['required'])->toBeTrue(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ApiParameter Attribute Parsing +// ───────────────────────────────────────────────────────────────────────────── + +describe('ApiParameter Attribute Parsing', function () { + it('creates parameter with all properties', function () { + $param = new ApiParameter( + name: 'filter', + in: 'query', + type: 'string', + description: 'Filter results', + required: true, + example: 'active', + default: null, + enum: ['active', 'inactive'], + format: null + ); + + expect($param->name)->toBe('filter'); + expect($param->in)->toBe('query'); + expect($param->type)->toBe('string'); + expect($param->description)->toBe('Filter results'); + expect($param->required)->toBeTrue(); + expect($param->example)->toBe('active'); + expect($param->enum)->toBe(['active', 'inactive']); + }); + + it('defaults to query parameter type', function () { + $param = new ApiParameter('search'); + + expect($param->in)->toBe('query'); + expect($param->type)->toBe('string'); + expect($param->required)->toBeFalse(); + }); + + it('converts to OpenAPI schema format', function () { + $param = new ApiParameter( + name: 'page', + in: 'query', + type: 'integer', + description: 'Page number', + example: 1, + default: 1, + format: 'int32' + ); + + $schema = $param->toSchema(); + + expect($schema['type'])->toBe('integer'); + expect($schema['format'])->toBe('int32'); + expect($schema['example'])->toBe(1); + expect($schema['default'])->toBe(1); + }); + + it('converts to full OpenAPI parameter object', function () { + $param = new ApiParameter( + name: 'status', + in: 'query', + type: 'string', + description: 'Status filter', + required: false, + enum: ['draft', 'published', 'archived'] + ); + + $openApi = $param->toOpenApi(); + + expect($openApi['name'])->toBe('status'); + expect($openApi['in'])->toBe('query'); + expect($openApi['required'])->toBeFalse(); + expect($openApi['description'])->toBe('Status filter'); + expect($openApi['schema']['type'])->toBe('string'); + expect($openApi['schema']['enum'])->toBe(['draft', 'published', 'archived']); + }); + + it('forces path parameters to be required', function () { + $param = new ApiParameter( + name: 'id', + in: 'path', + type: 'string', + required: false // Should be overridden + ); + + $openApi = $param->toOpenApi(); + + expect($openApi['required'])->toBeTrue(); + }); + + it('supports header parameters', function () { + $param = new ApiParameter( + name: 'X-Custom-Header', + in: 'header', + type: 'string', + description: 'Custom header value' + ); + + $openApi = $param->toOpenApi(); + + expect($openApi['in'])->toBe('header'); + expect($openApi['name'])->toBe('X-Custom-Header'); + }); + + it('supports cookie parameters', function () { + $param = new ApiParameter( + name: 'session_id', + in: 'cookie', + type: 'string' + ); + + $openApi = $param->toOpenApi(); + + expect($openApi['in'])->toBe('cookie'); + }); + + it('handles array type parameters', function () { + $param = new ApiParameter( + name: 'ids', + in: 'query', + type: 'array', + description: 'List of IDs' + ); + + $schema = $param->toSchema(); + + expect($schema['type'])->toBe('array'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ApiResponse Attribute Rendering +// ───────────────────────────────────────────────────────────────────────────── + +describe('ApiResponse Attribute Rendering', function () { + it('creates response with status and description', function () { + $response = new ApiResponse( + status: 200, + description: 'Successful operation' + ); + + expect($response->status)->toBe(200); + expect($response->getDescription())->toBe('Successful operation'); + }); + + it('generates description from status code', function () { + $testCases = [ + 200 => 'Successful response', + 201 => 'Resource created', + 204 => 'No content', + 400 => 'Bad request', + 401 => 'Unauthorized', + 403 => 'Forbidden', + 404 => 'Not found', + 422 => 'Validation error', + 429 => 'Too many requests', + 500 => 'Internal server error', + ]; + + foreach ($testCases as $status => $expectedDescription) { + $response = new ApiResponse(status: $status); + expect($response->getDescription())->toBe($expectedDescription); + } + }); + + it('supports resource class reference', function () { + $response = new ApiResponse( + status: 200, + resource: TestJsonResource::class, + description: 'User details' + ); + + expect($response->resource)->toBe(TestJsonResource::class); + }); + + it('supports paginated flag', function () { + $response = new ApiResponse( + status: 200, + resource: TestJsonResource::class, + paginated: true + ); + + expect($response->paginated)->toBeTrue(); + }); + + it('supports response headers', function () { + $response = new ApiResponse( + status: 200, + headers: [ + 'X-Request-Id' => 'Unique request identifier', + 'X-Rate-Limit-Remaining' => 'Remaining rate limit', + ] + ); + + expect($response->headers)->toHaveKey('X-Request-Id') + ->toHaveKey('X-Rate-Limit-Remaining'); + }); + + it('handles unknown status codes gracefully', function () { + $response = new ApiResponse(status: 418); + + expect($response->getDescription())->toBe('Response'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ApiSecurity Attribute Requirements +// ───────────────────────────────────────────────────────────────────────────── + +describe('ApiSecurity Attribute Requirements', function () { + it('creates security requirement with scheme', function () { + $security = new ApiSecurity(scheme: 'apiKey'); + + expect($security->scheme)->toBe('apiKey'); + expect($security->scopes)->toBe([]); + expect($security->isPublic())->toBeFalse(); + }); + + it('supports OAuth2 scopes', function () { + $security = new ApiSecurity( + scheme: 'oauth2', + scopes: ['read:users', 'write:users'] + ); + + expect($security->scheme)->toBe('oauth2'); + expect($security->scopes)->toBe(['read:users', 'write:users']); + }); + + it('marks endpoint as public with null scheme', function () { + $security = new ApiSecurity(scheme: null); + + expect($security->isPublic())->toBeTrue(); + }); + + it('supports bearer authentication', function () { + $security = new ApiSecurity(scheme: 'bearer'); + + expect($security->scheme)->toBe('bearer'); + expect($security->isPublic())->toBeFalse(); + }); + + it('supports apiKey authentication', function () { + $security = new ApiSecurity(scheme: 'apiKey'); + + expect($security->scheme)->toBe('apiKey'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ApiHidden Attribute Filtering +// ───────────────────────────────────────────────────────────────────────────── + +describe('ApiHidden Attribute Filtering', function () { + it('creates hidden attribute without reason', function () { + $hidden = new ApiHidden; + + expect($hidden->reason)->toBeNull(); + }); + + it('creates hidden attribute with reason', function () { + $hidden = new ApiHidden(reason: 'Internal endpoint only'); + + expect($hidden->reason)->toBe('Internal endpoint only'); + }); + + it('excludes hidden endpoints from documentation', function () { + // Register a hidden route + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/hidden-test/public', [TestPublicController::class, 'index']) + ->name('hidden-test.public'); + RouteFacade::get('/hidden-test/internal', [TestHiddenController::class, 'index']) + ->name('hidden-test.internal'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + // Public endpoint should be present + expect($spec['paths'])->toHaveKey('/api/hidden-test/public'); + + // Hidden endpoint should not be present + expect($spec['paths'])->not->toHaveKey('/api/hidden-test/internal'); + }); + + it('excludes hidden methods but includes non-hidden ones', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/partial-hidden/public', [TestPartialHiddenController::class, 'publicMethod']) + ->name('partial-hidden.public'); + RouteFacade::get('/partial-hidden/hidden', [TestPartialHiddenController::class, 'hiddenMethod']) + ->name('partial-hidden.hidden'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['paths'])->toHaveKey('/api/partial-hidden/public'); + expect($spec['paths'])->not->toHaveKey('/api/partial-hidden/hidden'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// ApiTag Attribute Grouping +// ───────────────────────────────────────────────────────────────────────────── + +describe('ApiTag Attribute Grouping', function () { + it('creates tag with name only', function () { + $tag = new ApiTag(name: 'Users'); + + expect($tag->name)->toBe('Users'); + expect($tag->description)->toBeNull(); + }); + + it('creates tag with name and description', function () { + $tag = new ApiTag( + name: 'Products', + description: 'Product management endpoints' + ); + + expect($tag->name)->toBe('Products'); + expect($tag->description)->toBe('Product management endpoints'); + }); + + it('discovers tags from controller attributes', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/tagged/items', [TestTaggedController::class, 'index']) + ->name('tagged.items.index'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/tagged/items']['get']; + expect($operation['tags'])->toContain('Custom Tag'); + }); + + it('collects discovered tags in tags section', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/tagged/items', [TestTaggedController::class, 'index']) + ->name('tagged.items.index'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $tagNames = collect($spec['tags'])->pluck('name')->toArray(); + expect($tagNames)->toContain('Custom Tag'); + }); + + it('infers tags from route prefixes when not specified', function () { + RouteFacade::prefix('api/bio') + ->middleware('api') + ->group(function () { + RouteFacade::get('/links', fn () => response()->json([])) + ->name('bio.links.index'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/bio/links']['get']; + expect($operation['tags'])->toContain('Bio Links'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Extension System +// ───────────────────────────────────────────────────────────────────────────── + +describe('Extension System', function () { + it('registers default extensions', function () { + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + // Default extensions should have been applied + // Check for workspace header parameter (WorkspaceHeaderExtension) + // Check for rate limit response (RateLimitExtension) + // Check for auth error responses (ApiKeyAuthExtension) + expect($spec['components'])->toBeArray(); + }); + + it('allows adding custom extensions', function () { + $builder = new OpenApiBuilder; + $customExtension = new TestCustomExtension; + + $builder->addExtension($customExtension); + $spec = $builder->build(); + + expect($spec['x-custom-extension'])->toBe('added'); + }); + + it('WorkspaceHeaderExtension adds workspace parameter', function () { + config(['api-docs.workspace' => [ + 'header_name' => 'X-Workspace-ID', + 'required' => false, + 'description' => 'Workspace identifier', + ]]); + + $extension = new WorkspaceHeaderExtension; + $spec = ['components' => ['parameters' => []]]; + + $result = $extension->extend($spec, config('api-docs')); + + expect($result['components']['parameters'])->toHaveKey('workspaceId'); + expect($result['components']['parameters']['workspaceId']['name'])->toBe('X-Workspace-ID'); + }); + + it('RateLimitExtension adds rate limit headers', function () { + config(['api-docs.rate_limits' => ['enabled' => true]]); + + $extension = new RateLimitExtension; + $spec = ['components' => ['headers' => [], 'responses' => []]]; + + $result = $extension->extend($spec, config('api-docs')); + + expect($result['components']['responses'])->toHaveKey('RateLimitExceeded'); + }); + + it('ApiKeyAuthExtension adds authentication schemas', function () { + config(['api-docs.auth.api_key' => ['enabled' => true, 'name' => 'X-API-Key']]); + config(['api-docs.auth.bearer' => ['enabled' => true]]); + + $extension = new ApiKeyAuthExtension; + $spec = [ + 'info' => ['description' => 'Test API'], + 'components' => ['securitySchemes' => ['apiKeyAuth' => []]], + ]; + + $result = $extension->extend($spec, config('api-docs')); + + expect($result['components']['schemas'])->toHaveKey('UnauthorizedError') + ->toHaveKey('ForbiddenError'); + expect($result['components']['responses'])->toHaveKey('Unauthorized') + ->toHaveKey('Forbidden'); + }); + + it('extensions can modify individual operations', function () { + $extension = new RateLimitExtension; + $config = ['rate_limits' => ['enabled' => true]]; + + // Create a mock route with rate limiting + $route = RouteFacade::get('/test', fn () => 'test')->middleware('throttle:60,1'); + $route->prepareForSerialization(); + + $operation = [ + 'summary' => 'Test', + 'responses' => ['200' => ['description' => 'OK']], + ]; + + $result = $extension->extendOperation($operation, $route, 'get', $config); + + expect($result['responses'])->toHaveKey('429'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Error Response Documentation +// ───────────────────────────────────────────────────────────────────────────── + +describe('Error Response Documentation', function () { + it('documents 401 Unauthorized response', function () { + $extension = new ApiKeyAuthExtension; + $spec = [ + 'info' => [], + 'components' => ['securitySchemes' => ['apiKeyAuth' => []]], + ]; + + $result = $extension->extend($spec, ['auth' => ['api_key' => ['enabled' => true]]]); + + expect($result['components']['responses']['Unauthorized'])->toHaveKey('description') + ->toHaveKey('content'); + expect($result['components']['responses']['Unauthorized']['content']['application/json']['examples']) + ->toHaveKey('missing_key') + ->toHaveKey('invalid_key') + ->toHaveKey('expired_key'); + }); + + it('documents 403 Forbidden response', function () { + $extension = new ApiKeyAuthExtension; + $spec = [ + 'info' => [], + 'components' => ['securitySchemes' => ['apiKeyAuth' => []]], + ]; + + $result = $extension->extend($spec, ['auth' => ['api_key' => ['enabled' => true]]]); + + expect($result['components']['responses']['Forbidden'])->toHaveKey('description') + ->toHaveKey('content'); + expect($result['components']['responses']['Forbidden']['content']['application/json']['examples']) + ->toHaveKey('insufficient_scope') + ->toHaveKey('workspace_access'); + }); + + it('documents 429 Rate Limit Exceeded response', function () { + $extension = new RateLimitExtension; + $spec = ['components' => ['headers' => [], 'responses' => []]]; + + $result = $extension->extend($spec, ['rate_limits' => ['enabled' => true]]); + + expect($result['components']['responses']['RateLimitExceeded'])->toHaveKey('description') + ->toHaveKey('headers') + ->toHaveKey('content'); + expect($result['components']['responses']['RateLimitExceeded']['headers']) + ->toHaveKey('Retry-After'); + }); + + it('automatically adds auth error responses to protected operations', function () { + $extension = new ApiKeyAuthExtension; + + $route = RouteFacade::get('/test', fn () => 'test'); + $operation = [ + 'security' => [['apiKeyAuth' => []]], + 'responses' => ['200' => ['description' => 'OK']], + ]; + + $result = $extension->extendOperation($operation, $route, 'get', []); + + expect($result['responses'])->toHaveKey('401') + ->toHaveKey('403'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Authentication Documentation +// ───────────────────────────────────────────────────────────────────────────── + +describe('Authentication Documentation', function () { + it('documents API Key authentication scheme', function () { + config(['api-docs.auth.api_key' => [ + 'enabled' => true, + 'name' => 'X-API-Key', + 'in' => 'header', + 'description' => 'API key for authentication', + ]]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $apiKeyScheme = $spec['components']['securitySchemes']['apiKeyAuth']; + + expect($apiKeyScheme['type'])->toBe('apiKey'); + expect($apiKeyScheme['in'])->toBe('header'); + expect($apiKeyScheme['name'])->toBe('X-API-Key'); + }); + + it('documents Bearer authentication scheme', function () { + config(['api-docs.auth.bearer' => [ + 'enabled' => true, + 'scheme' => 'bearer', + 'format' => 'JWT', + ]]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $bearerScheme = $spec['components']['securitySchemes']['bearerAuth']; + + expect($bearerScheme['type'])->toBe('http'); + expect($bearerScheme['scheme'])->toBe('bearer'); + expect($bearerScheme['bearerFormat'])->toBe('JWT'); + }); + + it('infers security from route middleware', function () { + RouteFacade::prefix('api') + ->middleware(['api', 'auth:sanctum']) + ->group(function () { + RouteFacade::get('/auth-test/protected', fn () => response()->json([])) + ->name('auth-test.protected'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/auth-test/protected']['get']; + expect($operation['security'][0])->toHaveKey('bearerAuth'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Request/Response Examples Validation +// ───────────────────────────────────────────────────────────────────────────── + +describe('Request/Response Examples Validation', function () { + it('includes request body for POST operations', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::post('/example/create', fn () => response()->json([])) + ->name('example.create'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/example/create']['post']; + expect($operation)->toHaveKey('requestBody'); + expect($operation['requestBody'])->toHaveKey('content'); + }); + + it('includes request body for PUT operations', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::put('/example/update/{id}', fn () => response()->json([])) + ->name('example.update'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/example/update/{id}']['put']; + expect($operation)->toHaveKey('requestBody'); + }); + + it('includes request body for PATCH operations', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::patch('/example/patch/{id}', fn () => response()->json([])) + ->name('example.patch'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/example/patch/{id}']['patch']; + expect($operation)->toHaveKey('requestBody'); + }); + + it('does not include request body for GET operations', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/example/list', fn () => response()->json([])) + ->name('example.list'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/example/list']['get']; + expect($operation)->not->toHaveKey('requestBody'); + }); + + it('includes default 200 response when none specified', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/default-response', fn () => response()->json([])) + ->name('default-response.index'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + $operation = $spec['paths']['/api/default-response']['get']; + expect($operation['responses'])->toHaveKey('200'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Caching Behaviour +// ───────────────────────────────────────────────────────────────────────────── + +describe('Caching Behaviour', function () { + it('respects cache disabled environments', function () { + config(['api-docs.cache' => [ + 'enabled' => true, + 'disabled_environments' => ['testing'], + ]]); + + // In testing environment, cache should be disabled + $builder = new OpenApiBuilder; + $spec1 = $builder->build(); + $spec2 = $builder->build(); + + // Both should return fresh data (not cached) + expect($spec1)->toEqual($spec2); + }); + + it('clears cache when requested', function () { + $builder = new OpenApiBuilder; + $builder->clearCache(); + + // Should not throw an exception + expect(true)->toBeTrue(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Route Exclusion +// ───────────────────────────────────────────────────────────────────────────── + +describe('Route Exclusion', function () { + it('excludes routes matching exclude patterns', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/included', fn () => response()->json([])) + ->name('included'); + RouteFacade::get('/internal/excluded', fn () => response()->json([])) + ->name('internal.excluded'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + config(['api-docs.routes.exclude' => ['api/internal/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + expect($spec['paths'])->toHaveKey('/api/included'); + expect($spec['paths'])->not->toHaveKey('/api/internal/excluded'); + }); + + it('excludes HEAD methods from documentation', function () { + RouteFacade::prefix('api') + ->middleware('api') + ->group(function () { + RouteFacade::get('/head-test', fn () => response()->json([])) + ->name('head-test'); + }); + + config(['api-docs.routes.include' => ['api/*']]); + + $builder = new OpenApiBuilder; + $spec = $builder->build(); + + // GET route also creates HEAD route, but HEAD should be excluded + $operation = $spec['paths']['/api/head-test'] ?? []; + expect($operation)->not->toHaveKey('head'); + expect($operation)->toHaveKey('get'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// RateLimit Attribute Integration +// ───────────────────────────────────────────────────────────────────────────── + +describe('RateLimit Attribute Integration', function () { + it('creates RateLimit attribute with properties', function () { + $rateLimit = new RateLimit( + limit: 100, + window: 60, + burst: 1.2, + key: 'custom' + ); + + expect($rateLimit->limit)->toBe(100); + expect($rateLimit->window)->toBe(60); + expect($rateLimit->burst)->toBe(1.2); + expect($rateLimit->key)->toBe('custom'); + }); + + it('defaults to 60 second window', function () { + $rateLimit = new RateLimit(limit: 100); + + expect($rateLimit->window)->toBe(60); + }); + + it('defaults to no burst', function () { + $rateLimit = new RateLimit(limit: 100); + + expect($rateLimit->burst)->toBe(1.0); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Test Fixtures (Controllers and Resources) +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Test controller for OpenAPI scanning tests. + */ +class TestOpenApiController +{ + #[ApiParameter('filter', 'query', 'string', 'Filter items')] + #[ApiParameter('page', 'query', 'integer', 'Page number', false, 1)] + #[ApiResponse(200, TestJsonResource::class, 'List of items', paginated: true)] + public function index(): void {} + + #[ApiResponse(200, TestJsonResource::class, 'Item details')] + #[ApiResponse(404, null, 'Item not found')] + public function show(string $id): void {} + + #[ApiSecurity('apiKey', ['write'])] + #[ApiResponse(201, TestJsonResource::class, 'Item created')] + #[ApiResponse(422, null, 'Validation failed')] + public function store(): void {} + + #[ApiSecurity('apiKey', ['write'])] + #[ApiResponse(200, TestJsonResource::class, 'Item updated')] + public function update(string $id): void {} + + #[ApiSecurity('apiKey', ['delete'])] + #[ApiResponse(204, null, 'Item deleted')] + public function destroy(string $id): void {} +} + +/** + * Test hidden controller. + */ +#[ApiHidden('Internal use only')] +class TestHiddenController +{ + public function index(): void {} +} + +/** + * Test public controller. + */ +class TestPublicController +{ + public function index(): void {} +} + +/** + * Test controller with partially hidden methods. + */ +class TestPartialHiddenController +{ + public function publicMethod(): void {} + + #[ApiHidden] + public function hiddenMethod(): void {} +} + +/** + * Test tagged controller. + */ +#[ApiTag('Custom Tag', 'Custom tag description')] +class TestTaggedController +{ + public function index(): void {} +} + +/** + * Test JSON Resource. + */ +class TestJsonResource extends JsonResource +{ + public function toArray($request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + ]; + } +} + +/** + * Test custom extension. + */ +class TestCustomExtension implements Extension +{ + public function extend(array $spec, array $config): array + { + $spec['x-custom-extension'] = 'added'; + + return $spec; + } + + public function extendOperation(array $operation, Route $route, string $method, array $config): array + { + return $operation; + } +} diff --git a/src/php/src/Api/Tests/Feature/OpenApiDocumentationTest.php b/src/php/src/Api/Tests/Feature/OpenApiDocumentationTest.php new file mode 100644 index 0000000..69fc496 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/OpenApiDocumentationTest.php @@ -0,0 +1,120 @@ +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); + } +} diff --git a/src/php/src/Api/Tests/Feature/PublicApiCorsTest.php b/src/php/src/Api/Tests/Feature/PublicApiCorsTest.php new file mode 100644 index 0000000..3038d3c --- /dev/null +++ b/src/php/src/Api/Tests/Feature/PublicApiCorsTest.php @@ -0,0 +1,363 @@ +middleware = new PublicApiCors(); + }); + + it('returns 204 status for OPTIONS request', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('Should not reach')); + + expect($response->getStatusCode())->toBe(204); + }); + + it('returns empty body for OPTIONS preflight', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('Should not reach')); + + expect($response->getContent())->toBe(''); + }); + + it('does not call the next handler for OPTIONS requests', function () { + $request = createCorsRequest('OPTIONS'); + $called = false; + + $this->middleware->handle($request, function () use (&$called) { + $called = true; + + return new Response(''); + }); + + expect($called)->toBeFalse(); + }); + + it('includes all required CORS headers on OPTIONS response', function () { + $request = createCorsRequest('OPTIONS', ['Origin' => 'https://example.com']); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + expect($response->headers->has('Access-Control-Allow-Origin'))->toBeTrue(); + expect($response->headers->has('Access-Control-Allow-Methods'))->toBeTrue(); + expect($response->headers->has('Access-Control-Allow-Headers'))->toBeTrue(); + expect($response->headers->has('Access-Control-Max-Age'))->toBeTrue(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// CORS Headers on Regular Requests +// ───────────────────────────────────────────────────────────────────────────── + +describe('CORS Headers on Regular Requests', function () { + beforeEach(function () { + $this->middleware = new PublicApiCors(); + }); + + it('adds CORS headers to GET response', function () { + $request = createCorsRequest('GET', ['Origin' => 'https://example.com']); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->has('Access-Control-Allow-Origin'))->toBeTrue(); + expect($response->getContent())->toBe('OK'); + }); + + it('adds CORS headers to POST response', function () { + $request = createCorsRequest('POST', ['Origin' => 'https://example.com']); + + $response = $this->middleware->handle($request, fn () => new Response('Created', 201)); + + expect($response->headers->has('Access-Control-Allow-Origin'))->toBeTrue(); + expect($response->getStatusCode())->toBe(201); + }); + + it('passes through request to next handler', function () { + $request = createCorsRequest('GET'); + $nextCalled = false; + + $this->middleware->handle($request, function () use (&$nextCalled) { + $nextCalled = true; + + return new Response('OK'); + }); + + expect($nextCalled)->toBeTrue(); + }); + + it('preserves original response content and status', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('{"data":"value"}', 200)); + + expect($response->getContent())->toBe('{"data":"value"}'); + expect($response->getStatusCode())->toBe(200); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Origin Handling +// ───────────────────────────────────────────────────────────────────────────── + +describe('Origin Handling', function () { + beforeEach(function () { + $this->middleware = new PublicApiCors(); + }); + + it('echoes back the request Origin header on GET', function () { + $request = createCorsRequest('GET', ['Origin' => 'https://customer-site.com']); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Allow-Origin'))->toBe('https://customer-site.com'); + }); + + it('echoes back the Origin on OPTIONS preflight', function () { + $request = createCorsRequest('OPTIONS', ['Origin' => 'https://app.example.org']); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + expect($response->headers->get('Access-Control-Allow-Origin'))->toBe('https://app.example.org'); + }); + + it('uses wildcard when no Origin header is present on GET', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Allow-Origin'))->toBe('*'); + }); + + it('uses wildcard on OPTIONS when no Origin header is present', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + expect($response->headers->get('Access-Control-Allow-Origin'))->toBe('*'); + }); + + it('accepts any origin (public API design for customer websites)', function () { + $origins = [ + 'https://customer1.com', + 'https://shop.example.org', + 'http://localhost:3000', + 'https://subdomain.company.co.uk', + ]; + + $middleware = new PublicApiCors(); + + foreach ($origins as $origin) { + $request = createCorsRequest('GET', ['Origin' => $origin]); + $response = $middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Allow-Origin'))->toBe($origin); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Allowed Methods +// ───────────────────────────────────────────────────────────────────────────── + +describe('Allowed Methods', function () { + beforeEach(function () { + $this->middleware = new PublicApiCors(); + }); + + it('sets allowed methods header to GET, POST, OPTIONS', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Allow-Methods'))->toBe('GET, POST, OPTIONS'); + }); + + it('includes OPTIONS in allowed methods on preflight response', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + $methods = $response->headers->get('Access-Control-Allow-Methods'); + expect($methods)->toContain('OPTIONS'); + expect($methods)->toContain('GET'); + expect($methods)->toContain('POST'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Allowed Request Headers +// ───────────────────────────────────────────────────────────────────────────── + +describe('Allowed Request Headers', function () { + beforeEach(function () { + $this->middleware = new PublicApiCors(); + }); + + it('sets allowed headers to Content-Type, Accept, X-Requested-With', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + expect($response->headers->get('Access-Control-Allow-Headers')) + ->toBe('Content-Type, Accept, X-Requested-With'); + }); + + it('includes Content-Type in allowed headers on regular request', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Allow-Headers'))->toContain('Content-Type'); + }); + + it('includes Accept in allowed headers', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Allow-Headers'))->toContain('Accept'); + }); + + it('includes X-Requested-With in allowed headers', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Allow-Headers'))->toContain('X-Requested-With'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Exposed Response Headers +// ───────────────────────────────────────────────────────────────────────────── + +describe('Exposed Response Headers', function () { + beforeEach(function () { + $this->middleware = new PublicApiCors(); + }); + + it('exposes rate limit headers to the browser', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + $exposed = $response->headers->get('Access-Control-Expose-Headers'); + expect($exposed)->toContain('X-RateLimit-Limit'); + expect($exposed)->toContain('X-RateLimit-Remaining'); + expect($exposed)->toContain('X-RateLimit-Reset'); + expect($exposed)->toContain('Retry-After'); + }); + + it('exposes rate limit headers on OPTIONS preflight too', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + $exposed = $response->headers->get('Access-Control-Expose-Headers'); + expect($exposed)->toContain('X-RateLimit-Limit'); + expect($exposed)->toContain('X-RateLimit-Remaining'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Preflight Cache and Vary Headers +// ───────────────────────────────────────────────────────────────────────────── + +describe('Preflight Cache and Vary Headers', function () { + beforeEach(function () { + $this->middleware = new PublicApiCors(); + }); + + it('sets Max-Age to 3600 seconds for preflight caching', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + expect($response->headers->get('Access-Control-Max-Age'))->toBe('3600'); + }); + + it('sets Max-Age on regular responses', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Access-Control-Max-Age'))->toBe('3600'); + }); + + it('sets Vary header to Origin for correct cache keying', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('Vary'))->toBe('Origin'); + }); + + it('sets Vary header on OPTIONS preflight', function () { + $request = createCorsRequest('OPTIONS'); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + expect($response->headers->get('Vary'))->toBe('Origin'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Security Boundaries +// ───────────────────────────────────────────────────────────────────────────── + +describe('Security Boundaries', function () { + beforeEach(function () { + $this->middleware = new PublicApiCors(); + }); + + it('does not set Access-Control-Allow-Credentials on regular requests', function () { + $request = createCorsRequest('GET', ['Origin' => 'https://example.com']); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->has('Access-Control-Allow-Credentials'))->toBeFalse(); + }); + + it('does not set Access-Control-Allow-Credentials on OPTIONS preflight', function () { + $request = createCorsRequest('OPTIONS', ['Origin' => 'https://example.com']); + + $response = $this->middleware->handle($request, fn () => new Response('')); + + expect($response->headers->has('Access-Control-Allow-Credentials'))->toBeFalse(); + }); + + it('allows requests without Origin header (non-browser clients)', function () { + $request = createCorsRequest('GET'); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->getStatusCode())->toBe(200); + expect($response->headers->get('Access-Control-Allow-Origin'))->toBe('*'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Helper Functions +// ───────────────────────────────────────────────────────────────────────────── + +function createCorsRequest(string $method = 'GET', array $headers = []): Request +{ + $request = Request::create('/api/public/test', $method); + + foreach ($headers as $key => $value) { + $request->headers->set($key, $value); + } + + return $request; +} diff --git a/src/php/src/Api/Tests/Feature/RateLimitTest.php b/src/php/src/Api/Tests/Feature/RateLimitTest.php new file mode 100644 index 0000000..790fe07 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/RateLimitTest.php @@ -0,0 +1,532 @@ +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']); + } +} diff --git a/src/php/src/Api/Tests/Feature/RateLimitingTest.php b/src/php/src/Api/Tests/Feature/RateLimitingTest.php new file mode 100644 index 0000000..0287a93 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/RateLimitingTest.php @@ -0,0 +1,785 @@ +rateLimitService = app(RateLimitService::class); + $this->middleware = new RateLimitApi($this->rateLimitService); + + $this->user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(); + $this->workspace->users()->attach($this->user->id, [ + 'role' => 'owner', + 'is_default' => true, + ]); + + // Set up default configuration + Config::set('api.rate_limits.enabled', true); + Config::set('api.rate_limits.default', [ + 'limit' => 60, + 'window' => 60, + 'burst' => 1.0, + ]); + Config::set('api.rate_limits.authenticated', [ + 'limit' => 1000, + 'window' => 60, + 'burst' => 1.2, + ]); + Config::set('api.rate_limits.per_workspace', true); + 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], + ]); +}); + +afterEach(function () { + Carbon::setTestNow(); +}); + +/** + * Mock workspace object with tier property for testing tier-based rate limits. + * + * The RateLimitApi middleware uses property_exists() to check for tier, + * so we need a class with the property defined. + */ +class MockTieredWorkspace +{ + public int $id; + public string $tier; + + public function __construct(int $id, string $tier) + { + $this->id = $id; + $this->tier = $tier; + } +} + +// ----------------------------------------------------------------------------- +// Rate Limit Enforcement +// ----------------------------------------------------------------------------- + +describe('Rate Limit Enforcement', function () { + it('allows requests under the limit', function () { + $request = createMockRequest(); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->getContent())->toBe('OK'); + expect($response->getStatusCode())->toBe(200); + }); + + it('blocks requests when limit is exceeded', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + // Next request should be blocked + $this->middleware->handle($request, fn () => new Response('OK')); + })->throws(RateLimitExceededException::class); + + it('tracks requests correctly across multiple calls', function () { + $request = createMockRequest(); + + // Make 30 requests + for ($i = 0; $i < 30; $i++) { + $response = $this->middleware->handle($request, fn () => new Response('OK')); + } + + // Verify remaining count in headers + expect($response->headers->get('X-RateLimit-Remaining'))->toBe('30'); + }); + + it('allows requests again after window expires', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + // Move time forward past the window + Carbon::setTestNow(Carbon::now()->addSeconds(61)); + + // Should be allowed again + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->getContent())->toBe('OK'); + expect($response->getStatusCode())->toBe(200); + }); + + it('can be disabled via configuration', function () { + Config::set('api.rate_limits.enabled', false); + + $request = createMockRequest(); + + // Even with 100 requests, should not be blocked + for ($i = 0; $i < 100; $i++) { + $response = $this->middleware->handle($request, fn () => new Response('OK')); + } + + expect($response->getContent())->toBe('OK'); + }); +}); + +// ----------------------------------------------------------------------------- +// Rate Limit Headers +// ----------------------------------------------------------------------------- + +describe('Rate Limit Headers', function () { + it('includes X-RateLimit-Limit header', function () { + $request = createMockRequest(); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->has('X-RateLimit-Limit'))->toBeTrue(); + expect($response->headers->get('X-RateLimit-Limit'))->toBe('60'); + }); + + it('includes X-RateLimit-Remaining header', function () { + $request = createMockRequest(); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->has('X-RateLimit-Remaining'))->toBeTrue(); + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(59); + }); + + it('includes X-RateLimit-Reset header', function () { + $request = createMockRequest(); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->has('X-RateLimit-Reset'))->toBeTrue(); + + $resetTimestamp = (int) $response->headers->get('X-RateLimit-Reset'); + expect($resetTimestamp)->toBeGreaterThan(time()); + }); + + it('decrements remaining count with each request', function () { + $request = createMockRequest(); + + $response1 = $this->middleware->handle($request, fn () => new Response('OK')); + $response2 = $this->middleware->handle($request, fn () => new Response('OK')); + $response3 = $this->middleware->handle($request, fn () => new Response('OK')); + + expect((int) $response1->headers->get('X-RateLimit-Remaining'))->toBe(59); + expect((int) $response2->headers->get('X-RateLimit-Remaining'))->toBe(58); + expect((int) $response3->headers->get('X-RateLimit-Remaining'))->toBe(57); + }); + + it('includes Retry-After header when limit exceeded', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + try { + $this->middleware->handle($request, fn () => new Response('OK')); + } catch (RateLimitExceededException $e) { + $response = $e->render(); + + expect($response->headers->has('Retry-After'))->toBeTrue(); + expect((int) $response->headers->get('Retry-After'))->toBeGreaterThan(0); + } + }); + + it('shows zero remaining when limit is reached', function () { + $request = createMockRequest(); + + // Make 59 requests (one less than limit) + for ($i = 0; $i < 59; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + // 60th request uses the last allowance + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(0); + }); +}); + +// ----------------------------------------------------------------------------- +// Tier-Based Rate Limits +// ----------------------------------------------------------------------------- + +describe('Tier-Based Rate Limits', function () { + it('applies free tier limits by default', function () { + $request = createMockRequest(['workspace' => createWorkspaceWithTier('free')]); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-RateLimit-Limit'))->toBe('60'); + }); + + it('applies starter tier limits', function () { + $request = createMockRequest(['workspace' => createWorkspaceWithTier('starter')]); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-RateLimit-Limit'))->toBe('1000'); + }); + + it('applies pro tier limits', function () { + $request = createMockRequest(['workspace' => createWorkspaceWithTier('pro')]); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-RateLimit-Limit'))->toBe('5000'); + }); + + it('applies agency tier limits', function () { + $request = createMockRequest(['workspace' => createWorkspaceWithTier('agency')]); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-RateLimit-Limit'))->toBe('20000'); + }); + + it('applies enterprise tier limits', function () { + $request = createMockRequest(['workspace' => createWorkspaceWithTier('enterprise')]); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-RateLimit-Limit'))->toBe('100000'); + }); + + it('falls back to free tier for unknown tier', function () { + $request = createMockRequest(['workspace' => createWorkspaceWithTier('unknown')]); + + // Without tier config, falls back to default + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // Should use default (60) since 'unknown' tier doesn't exist + expect((int) $response->headers->get('X-RateLimit-Limit'))->toBeLessThanOrEqual(1000); + }); + + it('higher tiers have higher limits', function () { + $tiers = Config::get('api.rate_limits.tiers'); + + expect($tiers['starter']['limit'])->toBeGreaterThan($tiers['free']['limit']); + expect($tiers['pro']['limit'])->toBeGreaterThan($tiers['starter']['limit']); + expect($tiers['agency']['limit'])->toBeGreaterThan($tiers['pro']['limit']); + expect($tiers['enterprise']['limit'])->toBeGreaterThan($tiers['agency']['limit']); + }); +}); + +// ----------------------------------------------------------------------------- +// Workspace-Scoped Rate Limits +// ----------------------------------------------------------------------------- + +describe('Workspace-Scoped Rate Limits', function () { + it('isolates rate limits between workspaces', function () { + $workspace1 = Workspace::factory()->create(); + $workspace2 = Workspace::factory()->create(); + + $request1 = createMockRequest(['workspace' => $workspace1]); + $request2 = createMockRequest(['workspace' => $workspace2]); + + // Exhaust rate limit for workspace 1 + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request1, fn () => new Response('OK')); + } + + // Workspace 2 should still have full quota + $response = $this->middleware->handle($request2, fn () => new Response('OK')); + + expect($response->getStatusCode())->toBe(200); + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(59); + }); + + it('includes workspace ID in rate limit key', function () { + $workspace = Workspace::factory()->create(); + $apiKey = createApiKeyForWorkspace($workspace); + + $request = createMockRequest([ + 'workspace' => $workspace, + 'api_key' => $apiKey, + ]); + + $this->middleware->handle($request, fn () => new Response('OK')); + + // Verify key was created with workspace scope + $cacheKey = "rate_limit:api_key:{$apiKey->id}:ws:{$workspace->id}:route:test.route"; + expect(Cache::has($cacheKey))->toBeTrue(); + }); + + it('can disable per-workspace limiting', function () { + Config::set('api.rate_limits.per_workspace', false); + + $workspace1 = Workspace::factory()->create(); + $workspace2 = Workspace::factory()->create(); + + $apiKey1 = createApiKeyForWorkspace($workspace1); + $apiKey2 = createApiKeyForWorkspace($workspace2); + + // Use same API key ID to test shared limit + $request1 = createMockRequest([ + 'workspace' => $workspace1, + 'api_key' => $apiKey1, + ]); + + // Make requests from workspace 1 + for ($i = 0; $i < 30; $i++) { + $this->middleware->handle($request1, fn () => new Response('OK')); + } + + // The remaining should reflect shared limit usage + $response = $this->middleware->handle($request1, fn () => new Response('OK')); + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBeLessThan(60); + }); +}); + +// ----------------------------------------------------------------------------- +// Burst Allowance +// ----------------------------------------------------------------------------- + +describe('Burst Allowance', function () { + it('allows burst above base limit when configured', function () { + // Configure with 20% burst (limit 10 becomes effective 12) + Config::set('api.rate_limits.default', [ + 'limit' => 10, + 'window' => 60, + 'burst' => 1.2, + ]); + + $request = createMockRequest(); + + // Should allow 12 requests (10 * 1.2) + for ($i = 0; $i < 12; $i++) { + $response = $this->middleware->handle($request, fn () => new Response('OK')); + expect($response->getStatusCode())->toBe(200); + } + + // 13th request should be blocked + $this->middleware->handle($request, fn () => new Response('OK')); + })->throws(RateLimitExceededException::class); + + it('reports base limit in headers not burst limit', function () { + Config::set('api.rate_limits.default', [ + 'limit' => 10, + 'window' => 60, + 'burst' => 1.5, + ]); + + $request = createMockRequest(); + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // Should show base limit of 10, not burst limit of 15 + expect($response->headers->get('X-RateLimit-Limit'))->toBe('10'); + }); + + it('calculates remaining based on burst limit', function () { + Config::set('api.rate_limits.default', [ + 'limit' => 10, + 'window' => 60, + 'burst' => 1.5, + ]); + + $request = createMockRequest(); + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // After 1 hit, remaining should be 14 (15 - 1 where 15 = 10 * 1.5) + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(14); + }); + + it('applies tier-specific burst allowance', function () { + $workspace = createWorkspaceWithTier('enterprise'); + $request = createMockRequest(['workspace' => $workspace]); + + // Enterprise tier has burst of 2.0 (100000 * 2.0 = 200000 effective) + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // After 1 hit, remaining should be 199999 + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(199999); + }); + + it('has no burst allowance for free tier', function () { + $workspace = createWorkspaceWithTier('free'); + $request = createMockRequest(['workspace' => $workspace]); + + // Free tier has burst of 1.0 (no burst) + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // After 1 hit, remaining should be 59 (60 - 1, no burst) + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(59); + }); +}); + +// ----------------------------------------------------------------------------- +// Quota Exceeded Response +// ----------------------------------------------------------------------------- + +describe('Quota Exceeded Response', function () { + it('returns 429 status code when limit exceeded', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + try { + $this->middleware->handle($request, fn () => new Response('OK')); + } catch (RateLimitExceededException $e) { + expect($e->getStatusCode())->toBe(429); + } + }); + + it('returns proper JSON error response', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + try { + $this->middleware->handle($request, fn () => new Response('OK')); + } catch (RateLimitExceededException $e) { + $response = $e->render(); + $content = json_decode($response->getContent(), true); + + expect($content['error'])->toBe('rate_limit_exceeded'); + expect($content)->toHaveKey('message'); + expect($content)->toHaveKey('retry_after'); + expect($content)->toHaveKey('limit'); + expect($content)->toHaveKey('resets_at'); + } + }); + + it('includes retry_after in error response', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + try { + $this->middleware->handle($request, fn () => new Response('OK')); + } catch (RateLimitExceededException $e) { + $response = $e->render(); + $content = json_decode($response->getContent(), true); + + expect($content['retry_after'])->toBeGreaterThan(0); + expect($content['retry_after'])->toBeLessThanOrEqual(60); + } + }); + + it('includes resets_at ISO8601 timestamp', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + try { + $this->middleware->handle($request, fn () => new Response('OK')); + } catch (RateLimitExceededException $e) { + $response = $e->render(); + $content = json_decode($response->getContent(), true); + + expect($content['resets_at'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/'); + } + }); + + it('includes rate limit headers in error response', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + try { + $this->middleware->handle($request, fn () => new Response('OK')); + } catch (RateLimitExceededException $e) { + $response = $e->render(); + + expect($response->headers->has('X-RateLimit-Limit'))->toBeTrue(); + expect($response->headers->has('X-RateLimit-Remaining'))->toBeTrue(); + expect($response->headers->has('X-RateLimit-Reset'))->toBeTrue(); + expect($response->headers->has('Retry-After'))->toBeTrue(); + } + }); + + it('shows zero remaining in error response', function () { + $request = createMockRequest(); + + // Exhaust the rate limit + for ($i = 0; $i < 60; $i++) { + $this->middleware->handle($request, fn () => new Response('OK')); + } + + try { + $this->middleware->handle($request, fn () => new Response('OK')); + } catch (RateLimitExceededException $e) { + $response = $e->render(); + + expect($response->headers->get('X-RateLimit-Remaining'))->toBe('0'); + } + }); +}); + +// ----------------------------------------------------------------------------- +// API Key-Based Rate Limiting +// ----------------------------------------------------------------------------- + +describe('API Key-Based Rate Limiting', function () { + it('uses API key ID in rate limit key', function () { + $apiKey = createApiKeyForWorkspace($this->workspace); + + $request = createMockRequest([ + 'api_key' => $apiKey, + 'workspace' => $this->workspace, + ]); + + $this->middleware->handle($request, fn () => new Response('OK')); + + $cacheKey = "rate_limit:api_key:{$apiKey->id}:ws:{$this->workspace->id}:route:test.route"; + expect(Cache::has($cacheKey))->toBeTrue(); + }); + + it('isolates rate limits between API keys', function () { + $apiKey1 = createApiKeyForWorkspace($this->workspace); + $apiKey2 = createApiKeyForWorkspace($this->workspace); + + Config::set('api.rate_limits.authenticated', [ + 'limit' => 10, + 'window' => 60, + 'burst' => 1.0, + ]); + + $request1 = createMockRequest([ + 'api_key' => $apiKey1, + 'workspace' => $this->workspace, + ]); + $request2 = createMockRequest([ + 'api_key' => $apiKey2, + 'workspace' => $this->workspace, + ]); + + // Exhaust rate limit for API key 1 + for ($i = 0; $i < 10; $i++) { + $this->middleware->handle($request1, fn () => new Response('OK')); + } + + // API key 2 should still have full quota + $response = $this->middleware->handle($request2, fn () => new Response('OK')); + + expect($response->getStatusCode())->toBe(200); + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(9); + }); + + it('applies authenticated limits when API key present', function () { + $apiKey = createApiKeyForWorkspace($this->workspace); + + $request = createMockRequest([ + 'api_key' => $apiKey, + ]); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // Authenticated limit is 1000 with 1.2 burst = 1200 effective, so 1199 remaining + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(1199); + }); +}); + +// ----------------------------------------------------------------------------- +// IP-Based Rate Limiting (Unauthenticated) +// ----------------------------------------------------------------------------- + +describe('IP-Based Rate Limiting', function () { + it('uses IP address for unauthenticated requests', function () { + $request = createMockRequest(); + + $this->middleware->handle($request, fn () => new Response('OK')); + + $cacheKey = 'rate_limit:ip:127.0.0.1:route:test.route'; + expect(Cache::has($cacheKey))->toBeTrue(); + }); + + it('isolates rate limits between IP addresses', function () { + Config::set('api.rate_limits.default', [ + 'limit' => 10, + 'window' => 60, + 'burst' => 1.0, + ]); + + $request1 = createMockRequest([], '192.168.1.1'); + $request2 = createMockRequest([], '192.168.1.2'); + + // Exhaust rate limit for IP 1 + for ($i = 0; $i < 10; $i++) { + $this->middleware->handle($request1, fn () => new Response('OK')); + } + + // IP 2 should still have full quota + $response = $this->middleware->handle($request2, fn () => new Response('OK')); + + expect($response->getStatusCode())->toBe(200); + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(9); + }); + + it('applies default limits for unauthenticated requests', function () { + $request = createMockRequest(); + + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-RateLimit-Limit'))->toBe('60'); + }); +}); + +// ----------------------------------------------------------------------------- +// Per-Endpoint Rate Limits +// ----------------------------------------------------------------------------- + +describe('Per-Endpoint Rate Limits', function () { + it('applies endpoint-specific rate limit from config', function () { + Config::set('api.rate_limits.endpoints.test.route', [ + 'limit' => 5, + 'window' => 60, + 'burst' => 1.0, + ]); + + $request = createMockRequest(); + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + expect($response->headers->get('X-RateLimit-Limit'))->toBe('5'); + }); + + it('isolates rate limits between endpoints', function () { + Config::set('api.rate_limits.default', [ + 'limit' => 5, + 'window' => 60, + 'burst' => 1.0, + ]); + + $request1 = createMockRequest([], '127.0.0.1', 'api.users.index'); + $request2 = createMockRequest([], '127.0.0.1', 'api.posts.index'); + + // Exhaust rate limit for endpoint 1 + for ($i = 0; $i < 5; $i++) { + $this->middleware->handle($request1, fn () => new Response('OK')); + } + + // Endpoint 2 should still have full quota + $response = $this->middleware->handle($request2, fn () => new Response('OK')); + + expect($response->getStatusCode())->toBe(200); + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(4); + }); +}); + +// ----------------------------------------------------------------------------- +// Rate Limit Bypass for Trusted Clients +// ----------------------------------------------------------------------------- + +describe('Rate Limit Bypass', function () { + it('bypasses rate limiting when disabled globally', function () { + Config::set('api.rate_limits.enabled', false); + + $request = createMockRequest(); + + // Make many requests without hitting a limit + for ($i = 0; $i < 1000; $i++) { + $response = $this->middleware->handle($request, fn () => new Response('OK')); + expect($response->getStatusCode())->toBe(200); + } + }); + + it('does not add rate limit headers when disabled', function () { + Config::set('api.rate_limits.enabled', false); + + $request = createMockRequest(); + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // Headers should not be present when rate limiting is disabled + expect($response->headers->has('X-RateLimit-Limit'))->toBeFalse(); + }); + + it('enterprise tier has very high effective limit with burst', function () { + $workspace = createWorkspaceWithTier('enterprise'); + $request = createMockRequest(['workspace' => $workspace]); + + // Enterprise: 100000 * 2.0 burst = 200000 effective requests per minute + $response = $this->middleware->handle($request, fn () => new Response('OK')); + + // Should be able to make many requests without hitting limit + expect($response->getStatusCode())->toBe(200); + expect((int) $response->headers->get('X-RateLimit-Remaining'))->toBe(199999); + }); +}); + +// ----------------------------------------------------------------------------- +// Helper Functions +// ----------------------------------------------------------------------------- + +function createMockRequest(array $attributes = [], string $ip = '127.0.0.1', string $routeName = 'test.route'): Request +{ + $request = Request::create('/api/test', 'GET'); + $request->server->set('REMOTE_ADDR', $ip); + + // Create a mock route + $route = new Route(['GET'], '/api/test', ['as' => $routeName]); + $request->setRouteResolver(fn () => $route); + + // Set request attributes + foreach ($attributes as $key => $value) { + $request->attributes->set($key, $value); + } + + return $request; +} + +/** + * Create a mock workspace object with a tier property. + * + * Uses MockTieredWorkspace because the middleware uses property_exists() + * which requires the property to be defined on the class. + */ +function createWorkspaceWithTier(string $tier): MockTieredWorkspace +{ + static $counter = 1000; + + return new MockTieredWorkspace($counter++, $tier); +} + +function createApiKeyForWorkspace(Workspace $workspace): ApiKey +{ + $user = User::factory()->create(); + $result = ApiKey::generate( + $workspace->id, + $user->id, + 'Test API Key' + ); + + return $result['api_key']; +} diff --git a/src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php b/src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php new file mode 100644 index 0000000..9cf7f44 --- /dev/null +++ b/src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php @@ -0,0 +1,770 @@ +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(); + }); +}); diff --git a/src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php b/src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php new file mode 100644 index 0000000..0b67ff5 --- /dev/null +++ b/src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php @@ -0,0 +1,353 @@ +
+ {{-- Header --}} +
+
+ Webhook templates + + Reusable templates for customising webhook payload shapes. + +
+ + + Create template + +
+ + {{-- Filters and Search --}} +
+
+ + + + + + + +
+ +
+ +
+
+ + {{-- Templates List --}} + + @if($this->templates->isEmpty()) +
+ + No templates found + + @if($search) + No templates match your search criteria. + @else + Create a custom template or use one of the built-in templates. + @endif + +
+ @else +
+ @foreach($this->templates as $template) +
+
+ {{-- Icon --}} +
+ +
+ + {{-- Info --}} +
+
+ {{ $template->name }} + @if($template->isBuiltin()) + Built-in + @endif + @if($template->is_default) + Default + @endif + @if(!$template->is_active) + Inactive + @endif +
+

+ {{ $template->description ?? 'No description' }} +

+

+ Format: {{ $template->format->label() }} +

+
+
+ + {{-- Actions --}} +
+ + Edit + + + + + + + @if(!$template->is_default) + + Set as default + + @endif + + + Duplicate + + + + {{ $template->is_active ? 'Disable' : 'Enable' }} + + + @if(!$template->isBuiltin()) + + + Delete + + @endif + + +
+
+ @endforeach +
+ + {{-- Pagination --}} + @if($this->templates->hasPages()) +
+ {{ $this->templates->links() }} +
+ @endif + @endif +
+ + {{-- Delete Confirmation Modal --}} + @if($deletingId) + + Delete template + + Are you sure you want to delete this template? This action cannot be undone. + + +
+ + Cancel + + + Delete + +
+
+ @endif + + {{-- Editor Modal --}} + @if($showEditor) + +
+ + {{ $editingId ? 'Edit template' : 'Create template' }} + + +
+ {{-- Main editor (2/3 width) --}} +
+ {{-- Name --}} +
+ Template name + + @error('name') + {{ $message }} + @enderror +
+ + {{-- Description --}} +
+ Description + +
+ + {{-- Format selector --}} +
+ Template format + + @foreach($this->templateFormats as $value => $label) + + @endforeach + + + {{ $this->templateFormatDescriptions[$format] ?? '' }} + +
+ + {{-- Template textarea --}} +
+ Template content + + @error('template') + {{ $message }} + @enderror +
+ + {{-- Template errors --}} + @if($templateErrors) +
+

Template errors:

+
    + @foreach($templateErrors as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + + {{-- Action buttons --}} +
+ + Preview output + + + Validate + + + Reset to default + +
+ + {{-- Preview output --}} + @if($templatePreview) +
+

Preview output:

+
{{ json_encode($templatePreview, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif + + {{-- Options --}} +
+ + +
+
+ + {{-- Sidebar (1/3 width) --}} +
+ {{-- Load from builtin --}} +
+ Load from built-in template +
+ @foreach($this->builtinTemplates as $type => $info) + + @endforeach +
+
+ + {{-- Available variables --}} +
+ Available variables +
+ @foreach($this->availableVariables as $variable => $info) + + @endforeach +
+

Click to insert at cursor position.

+
+ + {{-- Available filters --}} +
+ Available filters +
+ @foreach($this->availableFilters as $filter => $description) +
+ | {{ $filter }} + {{ $description }} +
+ @endforeach +
+
+ + {{-- Syntax help --}} +
+

Syntax reference

+
+

@{{ '{{variable}}' }} - Simple value

+

@{{ '{{data.nested}}' }} - Nested value

+

@{{ '{{value | filter}}' }} - With filter

+ @if($format === 'mustache') +

@{{ '{{#if var}}...{{/if}}' }} - Conditional

+

@{{ '{{#each arr}}...{{/each}}' }} - Loop

+ @endif +
+
+
+
+ + {{-- Footer actions --}} +
+ + Cancel + + + {{ $editingId ? 'Update template' : 'Create template' }} + +
+
+
+ @endif +
diff --git a/src/php/src/Api/View/Modal/Admin/WebhookTemplateManager.php b/src/php/src/Api/View/Modal/Admin/WebhookTemplateManager.php new file mode 100644 index 0000000..0f49a9f --- /dev/null +++ b/src/php/src/Api/View/Modal/Admin/WebhookTemplateManager.php @@ -0,0 +1,442 @@ +user()?->defaultHostWorkspace(); + } + + #[Computed] + public function templates() + { + if (! $this->workspace) { + return collect(); + } + + $query = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id); + + // Apply search + if ($this->search) { + $escapedSearch = $this->escapeLikeWildcards($this->search); + $query->where(function ($q) use ($escapedSearch) { + $q->where('name', 'like', "%{$escapedSearch}%") + ->orWhere('description', 'like', "%{$escapedSearch}%"); + }); + } + + // Apply filter + $query = match ($this->filter) { + 'custom' => $query->custom(), + 'builtin' => $query->builtin(), + 'active' => $query->active(), + 'inactive' => $query->where('is_active', false), + default => $query, + }; + + return $query + ->ordered() + ->paginate(20); + } + + #[Computed] + public function templateFormats(): array + { + return [ + 'simple' => WebhookTemplateFormat::SIMPLE->label(), + 'mustache' => WebhookTemplateFormat::MUSTACHE->label(), + 'json' => WebhookTemplateFormat::JSON->label(), + ]; + } + + #[Computed] + public function templateFormatDescriptions(): array + { + return [ + 'simple' => WebhookTemplateFormat::SIMPLE->description(), + 'mustache' => WebhookTemplateFormat::MUSTACHE->description(), + 'json' => WebhookTemplateFormat::JSON->description(), + ]; + } + + #[Computed] + public function availableVariables(): array + { + $service = app(WebhookTemplateService::class); + + return $service->getAvailableVariables($this->previewEventType); + } + + #[Computed] + public function availableFilters(): array + { + $service = app(WebhookTemplateService::class); + + return $service->getAvailableFilters(); + } + + #[Computed] + public function builtinTemplates(): array + { + $service = app(WebhookTemplateService::class); + + return $service->getBuiltinTemplates(); + } + + public function mount(): void + { + // Ensure builtin templates exist for this workspace + if ($this->workspace) { + WebhookPayloadTemplate::createBuiltinTemplates( + $this->workspace->id, + $this->workspace->default_namespace_id ?? null + ); + } + } + + // ------------------------------------------------------------------------- + // List Actions + // ------------------------------------------------------------------------- + + public function confirmDelete(string $uuid): void + { + $this->deletingId = $uuid; + } + + public function cancelDelete(): void + { + $this->deletingId = null; + } + + public function delete(): void + { + if (! $this->deletingId || ! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $this->deletingId) + ->first(); + + if ($template) { + // Don't allow deleting builtin templates + if ($template->isBuiltin()) { + $this->dispatch('notify', type: 'error', message: 'Built-in templates cannot be deleted.'); + $this->deletingId = null; + + return; + } + + $template->delete(); + $this->dispatch('notify', type: 'success', message: 'Template deleted.'); + unset($this->templates); + } + + $this->deletingId = null; + } + + public function toggleActive(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $template->update(['is_active' => ! $template->is_active]); + unset($this->templates); + $this->dispatch('notify', type: 'success', message: $template->is_active ? 'Template enabled.' : 'Template disabled.'); + } + } + + public function setDefault(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $template->setAsDefault(); + unset($this->templates); + $this->dispatch('notify', type: 'success', message: 'Default template updated.'); + } + } + + public function duplicate(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $template->duplicate(); + unset($this->templates); + $this->dispatch('notify', type: 'success', message: 'Template duplicated.'); + } + } + + // ------------------------------------------------------------------------- + // Editor Actions + // ------------------------------------------------------------------------- + + public function create(): void + { + $this->resetEditor(); + $this->template = $this->getDefaultTemplateContent(); + $this->showEditor = true; + } + + public function edit(string $uuid): void + { + if (! $this->workspace) { + return; + } + + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $uuid) + ->first(); + + if ($template) { + $this->editingId = $uuid; + $this->name = $template->name; + $this->description = $template->description ?? ''; + $this->format = $template->format->value; + $this->template = $template->template; + $this->isDefault = $template->is_default; + $this->isActive = $template->is_active; + $this->templatePreview = null; + $this->templateErrors = null; + $this->showEditor = true; + } + } + + public function closeEditor(): void + { + $this->showEditor = false; + $this->resetEditor(); + } + + public function save(): void + { + // Validate template first + $this->validateTemplate(); + if (! empty($this->templateErrors)) { + return; + } + + $this->validate(); + + if (! $this->workspace) { + return; + } + + $data = [ + 'name' => $this->name, + 'description' => $this->description ?: null, + 'format' => $this->format, + 'template' => $this->template, + 'is_default' => $this->isDefault, + 'is_active' => $this->isActive, + ]; + + if ($this->editingId) { + $template = WebhookPayloadTemplate::where('workspace_id', $this->workspace->id) + ->where('uuid', $this->editingId) + ->first(); + + if ($template) { + // Don't allow modifying builtin templates' core properties + if ($template->isBuiltin()) { + unset($data['format']); + } + + $template->update($data); + $this->dispatch('notify', type: 'success', message: 'Template updated.'); + } + } else { + $data['uuid'] = Str::uuid()->toString(); + $data['workspace_id'] = $this->workspace->id; + $data['namespace_id'] = $this->workspace->default_namespace_id ?? null; + + WebhookPayloadTemplate::create($data); + $this->dispatch('notify', type: 'success', message: 'Template created.'); + } + + unset($this->templates); + $this->closeEditor(); + } + + public function validateTemplate(): void + { + $this->templateErrors = null; + + if (empty($this->template)) { + $this->templateErrors = ['Template cannot be empty.']; + + return; + } + + $service = app(WebhookTemplateService::class); + $format = WebhookTemplateFormat::tryFrom($this->format) ?? WebhookTemplateFormat::SIMPLE; + + $result = $service->validateTemplate($this->template, $format); + + if (! $result['valid']) { + $this->templateErrors = $result['errors']; + } + } + + public function previewTemplate(): void + { + $this->templatePreview = null; + $this->templateErrors = null; + + if (empty($this->template)) { + $this->templateErrors = ['Template cannot be empty.']; + + return; + } + + $service = app(WebhookTemplateService::class); + $format = WebhookTemplateFormat::tryFrom($this->format) ?? WebhookTemplateFormat::SIMPLE; + + $result = $service->previewPayload($this->template, $format, $this->previewEventType); + + if ($result['success']) { + $this->templatePreview = $result['output']; + $this->templateErrors = null; + } else { + $this->templatePreview = null; + $this->templateErrors = $result['errors']; + } + } + + public function insertVariable(string $variable): void + { + $this->dispatch('insert-variable', variable: '{{'.$variable.'}}'); + } + + public function loadBuiltinTemplate(string $type): void + { + $builtinType = BuiltinTemplateType::tryFrom($type); + if ($builtinType) { + $this->template = $builtinType->template(); + $this->format = $builtinType->format()->value; + $this->templatePreview = null; + $this->templateErrors = null; + } + } + + public function resetTemplate(): void + { + $this->template = $this->getDefaultTemplateContent(); + $this->templatePreview = null; + $this->templateErrors = null; + } + + public function render() + { + return view('api::admin.webhook-template-manager'); + } + + // ------------------------------------------------------------------------- + // Protected Methods + // ------------------------------------------------------------------------- + + protected function resetEditor(): void + { + $this->editingId = null; + $this->name = ''; + $this->description = ''; + $this->format = 'json'; + $this->template = ''; + $this->isDefault = false; + $this->isActive = true; + $this->templatePreview = null; + $this->templateErrors = null; + } + + protected function getDefaultTemplateContent(): string + { + return <<<'JSON' +{ + "event": "{{event.type}}", + "timestamp": "{{timestamp}}", + "data": {{data | json}} +} +JSON; + } + + protected function escapeLikeWildcards(string $value): string + { + return str_replace(['%', '_'], ['\\%', '\\_'], $value); + } +} diff --git a/src/php/src/Api/config.php b/src/php/src/Api/config.php new file mode 100644 index 0000000..701ee76 --- /dev/null +++ b/src/php/src/Api/config.php @@ -0,0 +1,237 @@ + 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, + ], + +]; diff --git a/src/php/src/Front/Api/ApiVersionService.php b/src/php/src/Front/Api/ApiVersionService.php new file mode 100644 index 0000000..5b889d2 --- /dev/null +++ b/src/php/src/Front/Api/ApiVersionService.php @@ -0,0 +1,253 @@ +versions->isV2($request)) { + * return $this->indexV2($request); + * } + * return $this->indexV1($request); + * } + * } + * ``` + * + * ## Version Negotiation + * + * The service supports version negotiation where controllers can provide + * different responses based on the requested version: + * + * ```php + * return $this->versions->negotiate($request, [ + * 1 => fn() => $this->responseV1(), + * 2 => fn() => $this->responseV2(), + * ]); + * ``` + */ +class ApiVersionService +{ + /** + * Get the current API version from the request. + * + * Returns null if no version middleware has processed the request. + */ + public function current(?Request $request = null): ?int + { + $request ??= request(); + + return $request->attributes->get('api_version'); + } + + /** + * Get the current API version as a string (e.g., 'v1'). + */ + public function currentString(?Request $request = null): ?string + { + $request ??= request(); + + return $request->attributes->get('api_version_string'); + } + + /** + * Check if the request is for a specific version. + */ + public function is(int $version, ?Request $request = null): bool + { + return $this->current($request) === $version; + } + + /** + * Check if the request is for version 1. + */ + public function isV1(?Request $request = null): bool + { + return $this->is(1, $request); + } + + /** + * Check if the request is for version 2. + */ + public function isV2(?Request $request = null): bool + { + return $this->is(2, $request); + } + + /** + * Check if the request version is at least the given version. + */ + public function isAtLeast(int $version, ?Request $request = null): bool + { + $current = $this->current($request); + + return $current !== null && $current >= $version; + } + + /** + * Check if the current version is deprecated. + */ + public function isDeprecated(?Request $request = null): bool + { + $current = $this->current($request); + $deprecated = config('api.versioning.deprecated', []); + + return $current !== null && in_array($current, $deprecated, true); + } + + /** + * Get the configured default version. + */ + public function defaultVersion(): int + { + return (int) config('api.versioning.default', 1); + } + + /** + * Get the current/latest version. + */ + public function latestVersion(): int + { + return (int) config('api.versioning.current', 1); + } + + /** + * Get all supported versions. + * + * @return array + */ + public function supportedVersions(): array + { + return config('api.versioning.supported', [1]); + } + + /** + * Get all deprecated versions. + * + * @return array + */ + public function deprecatedVersions(): array + { + return config('api.versioning.deprecated', []); + } + + /** + * Get sunset dates for versions. + * + * @return array + */ + public function sunsetDates(): array + { + return config('api.versioning.sunset', []); + } + + /** + * Check if a version is supported. + */ + public function isSupported(int $version): bool + { + return in_array($version, $this->supportedVersions(), true); + } + + /** + * Negotiate response based on API version. + * + * Calls the appropriate handler based on the request's API version. + * Falls back to lower version handlers if exact match not found. + * + * ```php + * return $versions->negotiate($request, [ + * 1 => fn() => ['format' => 'v1'], + * 2 => fn() => ['format' => 'v2', 'extra' => 'field'], + * ]); + * ``` + * + * @param array $handlers Version handlers keyed by version number + * @return mixed Result from the appropriate handler + * + * @throws \InvalidArgumentException If no suitable handler found + */ + public function negotiate(Request $request, array $handlers): mixed + { + $version = $this->current($request) ?? $this->defaultVersion(); + + // Try exact match first + if (isset($handlers[$version])) { + return $handlers[$version](); + } + + // Fall back to highest version that's <= requested version + krsort($handlers); + foreach ($handlers as $handlerVersion => $handler) { + if ($handlerVersion <= $version) { + return $handler(); + } + } + + // No suitable handler found + throw new \InvalidArgumentException( + "No handler found for API version {$version}. Available versions: ".implode(', ', array_keys($handlers)) + ); + } + + /** + * Transform response data based on API version. + * + * Useful for removing or adding fields based on version. + * + * ```php + * return $versions->transform($request, $data, [ + * 1 => fn($data) => Arr::except($data, ['new_field']), + * 2 => fn($data) => $data, + * ]); + * ``` + * + * @param array $transformers Version transformers + */ + public function transform(Request $request, mixed $data, array $transformers): mixed + { + $version = $this->current($request) ?? $this->defaultVersion(); + + // Try exact match first + if (isset($transformers[$version])) { + return $transformers[$version]($data); + } + + // Fall back to highest version that's <= requested version + krsort($transformers); + foreach ($transformers as $transformerVersion => $transformer) { + if ($transformerVersion <= $version) { + return $transformer($data); + } + } + + // No transformer, return data unchanged + return $data; + } +} diff --git a/src/php/src/Front/Api/Boot.php b/src/php/src/Front/Api/Boot.php new file mode 100644 index 0000000..545bfe6 --- /dev/null +++ b/src/php/src/Front/Api/Boot.php @@ -0,0 +1,111 @@ + [ + * 'default' => 1, // Default version when none specified + * 'current' => 1, // Current/latest version + * 'supported' => [1], // List of supported versions + * 'deprecated' => [], // Deprecated but still supported versions + * 'sunset' => [], // Sunset dates: [1 => '2025-06-01'] + * ], + * ``` + * + * @see ApiVersion Middleware for version parsing + * @see ApiVersionService Service for programmatic version checks + * @see VersionedRoutes Helper for version-based route registration + */ +class Boot extends ServiceProvider +{ + /** + * Configure api middleware group. + */ + public static function middleware(Middleware $middleware): void + { + $middleware->group('api', [ + \Illuminate\Routing\Middleware\ThrottleRequests::class.':api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, + ]); + + // Register versioning middleware aliases + $middleware->alias([ + 'api.version' => ApiVersion::class, + 'api.sunset' => ApiSunset::class, + ]); + } + + public function register(): void + { + // Merge API configuration + $this->mergeConfigFrom(__DIR__.'/config.php', 'api'); + + // Register API version service as singleton + $this->app->singleton(ApiVersionService::class); + } + + public function boot(): void + { + $this->configureRateLimiting(); + $this->registerMiddlewareAliases(); + + // Fire ApiRoutesRegistering event for lazy-loaded modules + LifecycleEventProvider::fireApiRoutes(); + } + + /** + * Register middleware aliases via router. + * + * This ensures aliases are available even if the static middleware() + * method isn't called (e.g., in testing or custom bootstrap). + */ + protected function registerMiddlewareAliases(): void + { + /** @var Router $router */ + $router = $this->app->make(Router::class); + + $router->aliasMiddleware('api.version', ApiVersion::class); + $router->aliasMiddleware('api.sunset', ApiSunset::class); + } + + /** + * Configure API rate limiting. + */ + protected function configureRateLimiting(): void + { + RateLimiter::for('api', function (Request $request) { + return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); + }); + } +} diff --git a/src/php/src/Front/Api/Middleware/ApiSunset.php b/src/php/src/Front/Api/Middleware/ApiSunset.php new file mode 100644 index 0000000..c853f9a --- /dev/null +++ b/src/php/src/Front/Api/Middleware/ApiSunset.php @@ -0,0 +1,112 @@ +group(function () { + * Route::get('/legacy-endpoint', LegacyController::class); + * }); + * ``` + * + * Or with a replacement link: + * + * ```php + * Route::middleware('api.sunset:2025-06-01,/api/v2/new-endpoint')->group(function () { + * Route::get('/old-endpoint', OldController::class); + * }); + * ``` + * + * ## Response Headers + * + * The middleware adds these headers: + * - Sunset: + * - Deprecation: true + * - Link: ; rel="successor-version" (if replacement provided) + * + * @see https://datatracker.ietf.org/doc/html/rfc8594 RFC 8594: The "Sunset" HTTP Header Field + */ +class ApiSunset +{ + /** + * Handle an incoming request. + * + * @param string $sunsetDate The sunset date (YYYY-MM-DD or RFC7231 format) + * @param string|null $replacement Optional replacement endpoint URL + */ + public function handle(Request $request, Closure $next, string $sunsetDate, ?string $replacement = null): Response + { + /** @var Response $response */ + $response = $next($request); + + // Convert date to RFC7231 format if needed + $formattedDate = $this->formatSunsetDate($sunsetDate); + + // Add Sunset header + $response->headers->set('Sunset', $formattedDate); + + // Add Deprecation header + $response->headers->set('Deprecation', 'true'); + + // Add warning header + $version = $request->attributes->get('api_version', 'unknown'); + $response->headers->set( + 'X-API-Warn', + "This endpoint is deprecated and will be removed on {$sunsetDate}." + ); + + // Add Link header for replacement if provided + if ($replacement !== null) { + $response->headers->set('Link', "<{$replacement}>; rel=\"successor-version\""); + } + + return $response; + } + + /** + * Format the sunset date to RFC7231 format. + * + * Accepts dates in YYYY-MM-DD format or already-formatted RFC7231 dates. + */ + protected function formatSunsetDate(string $date): string + { + // Check if already in RFC7231 format (contains comma, day name) + if (str_contains($date, ',')) { + return $date; + } + + try { + return (new \DateTimeImmutable($date)) + ->setTimezone(new \DateTimeZone('GMT')) + ->format(\DateTimeInterface::RFC7231); + } catch (\Exception) { + // If parsing fails, return as-is + return $date; + } + } +} diff --git a/src/php/src/Front/Api/Middleware/ApiVersion.php b/src/php/src/Front/Api/Middleware/ApiVersion.php new file mode 100644 index 0000000..52c659b --- /dev/null +++ b/src/php/src/Front/Api/Middleware/ApiVersion.php @@ -0,0 +1,246 @@ +attributes->get('api_version') - returns integer (e.g., 1, 2) + * - $request->attributes->get('api_version_string') - returns string (e.g., 'v1', 'v2') + * + * ## Configuration + * + * Configure in config/api.php: + * ```php + * 'versioning' => [ + * 'default' => 1, // Default version when none specified + * 'current' => 1, // Current/latest version + * 'supported' => [1], // List of supported versions + * 'deprecated' => [], // List of deprecated (but still supported) versions + * 'sunset' => [], // Versions with sunset dates: [1 => '2025-06-01'] + * ], + * ``` + * + * ## Usage in Routes + * + * ```php + * // Apply to specific routes + * Route::middleware('api.version')->group(function () { + * Route::get('/users', [UserController::class, 'index']); + * }); + * + * // Or with version constraint + * Route::middleware('api.version:2')->group(function () { + * // Only accepts v2 requests + * }); + * ``` + * + * ## Deprecation Headers + * + * When a request uses a deprecated API version, the response includes: + * - Deprecation: true + * - Sunset: (if configured) + * - X-API-Warn: "API version X is deprecated..." + * + * @see ApiVersionService For programmatic version checks + */ +class ApiVersion +{ + /** + * Handle an incoming request. + * + * @param int|null $requiredVersion Minimum version required (optional) + */ + public function handle(Request $request, Closure $next, ?int $requiredVersion = null): Response + { + $version = $this->resolveVersion($request); + $versionConfig = config('api.versioning', []); + + $default = $versionConfig['default'] ?? 1; + $current = $versionConfig['current'] ?? 1; + $supported = $versionConfig['supported'] ?? [1]; + $deprecated = $versionConfig['deprecated'] ?? []; + $sunset = $versionConfig['sunset'] ?? []; + + // Use default if no version specified + if ($version === null) { + $version = $default; + } + + // Validate version is supported + if (! in_array($version, $supported, true)) { + return $this->unsupportedVersion($version, $supported, $current); + } + + // Check minimum version requirement + if ($requiredVersion !== null && $version < $requiredVersion) { + return $this->versionTooLow($version, $requiredVersion); + } + + // Store version in request + $request->attributes->set('api_version', $version); + $request->attributes->set('api_version_string', "v{$version}"); + + /** @var Response $response */ + $response = $next($request); + + // Add version header to response + $response->headers->set('X-API-Version', (string) $version); + + // Add deprecation headers if applicable + if (in_array($version, $deprecated, true)) { + $response->headers->set('Deprecation', 'true'); + $response->headers->set('X-API-Warn', "API version {$version} is deprecated. Please upgrade to v{$current}."); + + // Add Sunset header if configured + if (isset($sunset[$version])) { + $sunsetDate = $sunset[$version]; + // Convert to HTTP date format if not already + if (! str_contains($sunsetDate, ',')) { + $sunsetDate = (new \DateTimeImmutable($sunsetDate))->format(\DateTimeInterface::RFC7231); + } + $response->headers->set('Sunset', $sunsetDate); + } + } + + return $response; + } + + /** + * Resolve the API version from the request. + * + * Priority order: + * 1. URL path (/api/v1/...) + * 2. Accept-Version header + * 3. Accept header vendor type + */ + protected function resolveVersion(Request $request): ?int + { + // 1. Check URL path for version prefix + $version = $this->versionFromPath($request); + if ($version !== null) { + return $version; + } + + // 2. Check Accept-Version header + $version = $this->versionFromHeader($request); + if ($version !== null) { + return $version; + } + + // 3. Check Accept header for vendor type + return $this->versionFromAcceptHeader($request); + } + + /** + * Extract version from URL path. + * + * Matches: /api/v1/..., /api/v2/... + */ + protected function versionFromPath(Request $request): ?int + { + $path = $request->path(); + + // Match /api/v{n}/ or /v{n}/ at the start + if (preg_match('#^(?:api/)?v(\d+)(?:/|$)#', $path, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Extract version from Accept-Version header. + * + * Accepts: v1, v2, 1, 2 + */ + protected function versionFromHeader(Request $request): ?int + { + $header = $request->header('Accept-Version'); + + if ($header === null) { + return null; + } + + // Strip 'v' prefix if present + $version = ltrim($header, 'vV'); + + if (is_numeric($version)) { + return (int) $version; + } + + return null; + } + + /** + * Extract version from Accept header vendor type. + * + * Matches: application/vnd.hosthub.v1+json + */ + protected function versionFromAcceptHeader(Request $request): ?int + { + $accept = $request->header('Accept', ''); + + // Match vendor media type: application/vnd.{name}.v{n}+json + if (preg_match('#application/vnd\.[^.]+\.v(\d+)\+#', $accept, $matches)) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Return 400 response for unsupported version. + * + * @param array $supported + */ + protected function unsupportedVersion(int $requested, array $supported, int $current): Response + { + return response()->json([ + 'error' => 'unsupported_api_version', + 'message' => "API version {$requested} is not supported.", + 'requested_version' => $requested, + 'supported_versions' => $supported, + 'current_version' => $current, + 'hint' => 'Use Accept-Version header or URL prefix (e.g., /api/v1/) to specify version.', + ], 400, [ + 'X-API-Version' => (string) $current, + ]); + } + + /** + * Return 400 response when version is too low. + */ + protected function versionTooLow(int $requested, int $required): Response + { + return response()->json([ + 'error' => 'api_version_too_low', + 'message' => "This endpoint requires API version {$required} or higher.", + 'requested_version' => $requested, + 'minimum_version' => $required, + ], 400, [ + 'X-API-Version' => (string) $requested, + ]); + } +} diff --git a/src/php/src/Front/Api/README.md b/src/php/src/Front/Api/README.md new file mode 100644 index 0000000..45689c6 --- /dev/null +++ b/src/php/src/Front/Api/README.md @@ -0,0 +1,266 @@ +# API Versioning + +Core PHP Framework provides built-in API versioning support with deprecation handling and sunset headers. + +## Quick Start + +### 1. Configure Versions + +Add to your `config/api.php`: + +```php +'versioning' => [ + 'default' => 1, // Version when none specified + 'current' => 2, // Latest/current version + 'supported' => [1, 2], // All supported versions + 'deprecated' => [1], // Deprecated but still working + 'sunset' => [ // Removal dates + 1 => '2025-12-31', + ], +], +``` + +### 2. Apply Middleware + +The `api.version` middleware is automatically available. Apply it to routes: + +```php +// Version-agnostic routes (uses default version) +Route::middleware('api.version')->group(function () { + Route::get('/status', StatusController::class); +}); + +// Version-specific routes with URL prefix +use Core\Front\Api\VersionedRoutes; + +VersionedRoutes::v1(function () { + Route::get('/users', [UserController::class, 'indexV1']); +}); + +VersionedRoutes::v2(function () { + Route::get('/users', [UserController::class, 'indexV2']); +}); +``` + +### 3. Version Negotiation in Controllers + +```php +use Core\Front\Api\ApiVersionService; + +class UserController +{ + public function __construct( + protected ApiVersionService $versions + ) {} + + public function index(Request $request) + { + return $this->versions->negotiate($request, [ + 1 => fn() => $this->indexV1(), + 2 => fn() => $this->indexV2(), + ]); + } +} +``` + +## Version Resolution + +The middleware resolves the API version from (in priority order): + +1. **URL Path**: `/api/v1/users` or `/v2/users` +2. **Accept-Version Header**: `Accept-Version: v1` or `Accept-Version: 2` +3. **Accept Header**: `Accept: application/vnd.hosthub.v1+json` +4. **Default**: Falls back to configured default version + +## Response Headers + +Successful responses include: + +``` +X-API-Version: 2 +``` + +Deprecated versions also include: + +``` +Deprecation: true +X-API-Warn: API version 1 is deprecated. Please upgrade to v2. +Sunset: Wed, 31 Dec 2025 00:00:00 GMT +``` + +## Error Responses + +### Unsupported Version (400) + +```json +{ + "error": "unsupported_api_version", + "message": "API version 99 is not supported.", + "requested_version": 99, + "supported_versions": [1, 2], + "current_version": 2, + "hint": "Use Accept-Version header or URL prefix (e.g., /api/v1/) to specify version." +} +``` + +### Version Too Low (400) + +```json +{ + "error": "api_version_too_low", + "message": "This endpoint requires API version 2 or higher.", + "requested_version": 1, + "minimum_version": 2 +} +``` + +## Versioned Routes Helper + +The `VersionedRoutes` class provides a fluent API for registering version-specific routes: + +```php +use Core\Front\Api\VersionedRoutes; + +// Simple version registration +VersionedRoutes::v1(function () { + Route::get('/users', UserController::class); +}); + +// With URL prefix (default) +VersionedRoutes::v2(function () { + Route::get('/users', UserControllerV2::class); +}); // Accessible at /api/v2/users + +// Header-only versioning (no URL prefix) +VersionedRoutes::version(2) + ->withoutPrefix() + ->routes(function () { + Route::get('/users', UserControllerV2::class); + }); // Accessible at /api/users with Accept-Version: 2 + +// Multiple versions for the same routes +VersionedRoutes::versions([1, 2], function () { + Route::get('/health', HealthController::class); +}); + +// Deprecated version with sunset +VersionedRoutes::v1() + ->deprecated('2025-06-01') + ->routes(function () { + Route::get('/legacy', LegacyController::class); + }); +``` + +## ApiVersionService + +Inject `ApiVersionService` for programmatic version checks: + +```php +use Core\Front\Api\ApiVersionService; + +class UserController +{ + public function __construct( + protected ApiVersionService $versions + ) {} + + public function show(Request $request, User $user) + { + $data = $user->toArray(); + + // Version-specific transformations + return $this->versions->transform($request, $data, [ + 1 => fn($d) => Arr::except($d, ['created_at', 'metadata']), + 2 => fn($d) => $d, + ]); + } +} +``` + +### Available Methods + +| Method | Description | +|--------|-------------| +| `current($request)` | Get version number (e.g., 1, 2) | +| `currentString($request)` | Get version string (e.g., 'v1') | +| `is($version, $request)` | Check exact version | +| `isV1($request)` | Check if version 1 | +| `isV2($request)` | Check if version 2 | +| `isAtLeast($version, $request)` | Check minimum version | +| `isDeprecated($request)` | Check if version is deprecated | +| `defaultVersion()` | Get configured default | +| `latestVersion()` | Get current/latest version | +| `supportedVersions()` | Get all supported versions | +| `deprecatedVersions()` | Get deprecated versions | +| `sunsetDates()` | Get sunset dates map | +| `isSupported($version)` | Check if version is supported | +| `negotiate($request, $handlers)` | Call version-specific handler | +| `transform($request, $data, $transformers)` | Transform data per version | + +## Sunset Middleware + +For endpoint-specific deprecation, use the `api.sunset` middleware: + +```php +Route::middleware('api.sunset:2025-06-01')->group(function () { + Route::get('/legacy-endpoint', LegacyController::class); +}); + +// With replacement hint +Route::middleware('api.sunset:2025-06-01,/api/v2/new-endpoint')->group(function () { + Route::get('/old-endpoint', OldController::class); +}); +``` + +Adds headers: + +``` +Sunset: Sun, 01 Jun 2025 00:00:00 GMT +Deprecation: true +X-API-Warn: This endpoint is deprecated and will be removed on 2025-06-01. +Link: ; rel="successor-version" +``` + +## Versioning Strategy + +### Guidelines + +1. **Add, don't remove**: New fields can be added to any version +2. **New version for breaking changes**: Removing/renaming fields requires new version +3. **Deprecate before removal**: Give clients time to migrate +4. **Document changes**: Maintain changelog per version + +### Version Lifecycle + +``` +v1: Active -> Deprecated (with sunset) -> Removed from supported +v2: Active (current) +v3: Future +``` + +### Environment Variables + +```env +API_VERSION_DEFAULT=1 +API_VERSION_CURRENT=2 +API_VERSIONS_SUPPORTED=1,2 +API_VERSIONS_DEPRECATED=1 +``` + +## Testing + +Test versioned endpoints by setting the Accept-Version header: + +```php +$response = $this->withHeaders([ + 'Accept-Version' => 'v2', +])->getJson('/api/users'); + +$response->assertHeader('X-API-Version', '2'); +``` + +Or use URL prefix: + +```php +$response = $this->getJson('/api/v2/users'); +``` diff --git a/src/php/src/Front/Api/VersionedRoutes.php b/src/php/src/Front/Api/VersionedRoutes.php new file mode 100644 index 0000000..5ebe22f --- /dev/null +++ b/src/php/src/Front/Api/VersionedRoutes.php @@ -0,0 +1,248 @@ +withoutPrefix() + * ->routes(function () { + * Route::get('/users', ...); // Accessible at /api/users with Accept-Version: 1 + * }); + * ``` + * + * ## Multiple Versions + * + * Register the same routes for multiple versions: + * + * ```php + * VersionedRoutes::versions([1, 2], function () { + * Route::get('/status', [StatusController::class, 'index']); + * }); + * ``` + * + * ## Deprecation + * + * Mark a version as deprecated with custom sunset date: + * + * ```php + * VersionedRoutes::v1() + * ->deprecated('2025-06-01') + * ->routes(function () { + * Route::get('/legacy', ...); + * }); + * ``` + */ +class VersionedRoutes +{ + protected int $version; + + protected bool $usePrefix = true; + + protected ?string $sunsetDate = null; + + protected bool $isDeprecated = false; + + /** + * @var array + */ + protected array $middleware = []; + + /** + * Create a new versioned routes instance. + */ + public function __construct(int $version) + { + $this->version = $version; + } + + /** + * Create routes for version 1. + */ + public static function v1(?callable $routes = null): static + { + $instance = new static(1); + + if ($routes !== null) { + $instance->routes($routes); + } + + return $instance; + } + + /** + * Create routes for version 2. + */ + public static function v2(?callable $routes = null): static + { + $instance = new static(2); + + if ($routes !== null) { + $instance->routes($routes); + } + + return $instance; + } + + /** + * Create routes for a specific version. + */ + public static function version(int $version): static + { + return new static($version); + } + + /** + * Register routes for multiple versions. + * + * @param array $versions + */ + public static function versions(array $versions, callable $routes): void + { + foreach ($versions as $version) { + (new static($version))->routes($routes); + } + } + + /** + * Don't use URL prefix for this version. + * + * Routes will be accessible without /v{n} prefix but will + * still require version header for version-specific behaviour. + */ + public function withoutPrefix(): static + { + $this->usePrefix = false; + + return $this; + } + + /** + * Use URL prefix for this version. + * + * This is the default behaviour. + */ + public function withPrefix(): static + { + $this->usePrefix = true; + + return $this; + } + + /** + * Mark this version as deprecated. + * + * @param string|null $sunsetDate Optional sunset date (YYYY-MM-DD or RFC7231 format) + */ + public function deprecated(?string $sunsetDate = null): static + { + $this->isDeprecated = true; + $this->sunsetDate = $sunsetDate; + + return $this; + } + + /** + * Add additional middleware to the version routes. + * + * @param array|string $middleware + */ + public function middleware(array|string $middleware): static + { + $this->middleware = array_merge( + $this->middleware, + is_array($middleware) ? $middleware : [$middleware] + ); + + return $this; + } + + /** + * Register the routes for this version. + */ + public function routes(callable $routes): void + { + $attributes = $this->buildRouteAttributes(); + + Route::group($attributes, $routes); + } + + /** + * Build the route group attributes. + * + * @return array + */ + protected function buildRouteAttributes(): array + { + $attributes = [ + 'middleware' => $this->buildMiddleware(), + ]; + + if ($this->usePrefix) { + $attributes['prefix'] = "v{$this->version}"; + } + + return $attributes; + } + + /** + * Build the middleware stack for this version. + * + * @return array + */ + protected function buildMiddleware(): array + { + $middleware = ["api.version:{$this->version}"]; + + if ($this->isDeprecated && $this->sunsetDate) { + $middleware[] = "api.sunset:{$this->sunsetDate}"; + } + + return array_merge($middleware, $this->middleware); + } +} diff --git a/src/php/src/Front/Api/config.php b/src/php/src/Front/Api/config.php new file mode 100644 index 0000000..852acc3 --- /dev/null +++ b/src/php/src/Front/Api/config.php @@ -0,0 +1,78 @@ + [ + // Default version when no version specified in request + // Clients should always specify version explicitly + 'default' => (int) env('API_VERSION_DEFAULT', 1), + + // Current/latest API version + // Used in deprecation warnings to suggest upgrade path + 'current' => (int) env('API_VERSION_CURRENT', 1), + + // Supported API versions (all still functional) + // Remove versions from this list to disable them entirely + 'supported' => array_map('intval', array_filter( + explode(',', env('API_VERSIONS_SUPPORTED', '1')) + )), + + // Deprecated versions (still work but warn clients) + // Responses include Deprecation: true header + 'deprecated' => array_map('intval', array_filter( + explode(',', env('API_VERSIONS_DEPRECATED', '')) + )), + + // Sunset dates for deprecated versions + // Format: [version => 'YYYY-MM-DD'] + // After this date, version should be removed from 'supported' + 'sunset' => [ + // Example: 1 => '2025-12-31', + ], + ], + + /* + |-------------------------------------------------------------------------- + | Response Headers + |-------------------------------------------------------------------------- + | + | Standard headers added to API responses. + | + */ + 'headers' => [ + // Add X-API-Version header to all responses + 'include_version' => true, + + // Add deprecation warnings for old versions + 'include_deprecation' => true, + ], +]; diff --git a/src/php/src/Website/.DS_Store b/src/php/src/Website/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..67deef52f06d8b4eccb8bd75d38f488d39e56c25 GIT binary patch literal 6148 zcmeH~JqiLr422W55Nx)zoW=uqgHhHKcmYuxK~NC;Il3=DjjOdR@&d^>$!yr&SL|#= zM7PiLTBH+^Iov2K3jUk#-V_H9}3)9lP&0<4g?uRzEqg^zI56wHPO))TycF}?arq#hf1*pJ4fobHOo&P)dxA}k2!juY7fj?6~ zXZ^n4;-&Iz{dhgAAF^ue1_%9egtwmnBz6=p;cnP3wg79g1yOregisterViews(); + $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'); + } +} diff --git a/src/php/src/Website/Api/Controllers/DocsController.php b/src/php/src/Website/Api/Controllers/DocsController.php new file mode 100644 index 0000000..b1140da --- /dev/null +++ b/src/php/src/Website/Api/Controllers/DocsController.php @@ -0,0 +1,72 @@ +json($generator->generate()); + } +} diff --git a/src/php/src/Website/Api/Routes/web.php b/src/php/src/Website/Api/Routes/web.php new file mode 100644 index 0000000..a545b4a --- /dev/null +++ b/src/php/src/Website/Api/Routes/web.php @@ -0,0 +1,34 @@ +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'); diff --git a/src/php/src/Website/Api/Services/OpenApiGenerator.php b/src/php/src/Website/Api/Services/OpenApiGenerator.php new file mode 100644 index 0000000..93d74ca --- /dev/null +++ b/src/php/src/Website/Api/Services/OpenApiGenerator.php @@ -0,0 +1,348 @@ +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', + ], + ], + ]; + } +} diff --git a/src/php/src/Website/Api/View/Blade/docs.blade.php b/src/php/src/Website/Api/View/Blade/docs.blade.php new file mode 100644 index 0000000..b397fc2 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/docs.blade.php @@ -0,0 +1,111 @@ + + + + + + {{ config('core.app.name', 'Core PHP') }} API Documentation + + + + +
+

+ + + + {{ config('core.app.name', 'Core PHP') }} API +

+ +
+ +
+ + + + + + diff --git a/src/php/src/Website/Api/View/Blade/guides/authentication.blade.php b/src/php/src/Website/Api/View/Blade/guides/authentication.blade.php new file mode 100644 index 0000000..94f9bd7 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/guides/authentication.blade.php @@ -0,0 +1,187 @@ +@extends('api::layouts.docs') + +@section('title', 'Authentication') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Authentication

+

+ Learn how to authenticate your API requests using API keys. +

+ + {{-- Overview --}} +
+

Overview

+

+ The API uses API keys for authentication. Each API key is scoped to a specific workspace and has configurable permissions. +

+

+ API keys are prefixed with hk_ to make them easily identifiable. +

+
+ + {{-- API Keys --}} +
+

API Keys

+

+ To create an API key: +

+
    +
  1. Log in to your account
  2. +
  3. Navigate to Settings → API Keys
  4. +
  5. Click Create API Key
  6. +
  7. Enter a descriptive name (e.g., "Production", "Development")
  8. +
  9. Select the required scopes
  10. +
  11. Copy the generated key immediately
  12. +
+ +
+
+ + + +

+ Important: API keys are only shown once when created. Store them securely as they cannot be retrieved later. +

+
+
+
+ + {{-- Using Keys --}} +
+

Using API Keys

+

+ Include your API key in the Authorization header as a Bearer token: +

+ +
+
+ HTTP Header +
+
Authorization: Bearer hk_your_api_key_here
+
+ +

+ Example request with cURL: +

+ +
+
+ cURL +
+
curl --request GET \
+  --url 'https://api.lthn.ai/v1/brain/recall' \
+  --header 'Authorization: Bearer hk_your_api_key'
+
+
+ + {{-- Scopes --}} +
+

Scopes

+

+ API keys can have different scopes to limit their permissions: +

+ +
+ + + + + + + + + + + + + + + + + + + + + +
ScopeDescription
readRead access to resources (GET requests)
writeCreate and update resources (POST, PUT requests)
deleteDelete resources (DELETE requests)
+
+
+ + {{-- Security --}} +
+

Security Best Practices

+
    +
  • Never commit API keys to version control
  • +
  • Use environment variables to store keys
  • +
  • Rotate keys periodically
  • +
  • Use the minimum required scopes
  • +
  • Revoke unused keys immediately
  • +
  • Never expose keys in client-side code
  • +
+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/php/src/Website/Api/View/Blade/guides/errors.blade.php b/src/php/src/Website/Api/View/Blade/guides/errors.blade.php new file mode 100644 index 0000000..b83e0d8 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/guides/errors.blade.php @@ -0,0 +1,211 @@ +@extends('api::layouts.docs') + +@section('title', 'Error Handling') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Error Handling

+

+ Understand API error codes and how to handle them gracefully. +

+ + {{-- Overview --}} +
+

Overview

+

+ 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. +

+
+ + {{-- HTTP Codes --}} +
+

HTTP Status Codes

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
CodeMeaning
200Success - Request completed successfully
201Created - Resource was created successfully
400Bad Request - Invalid request parameters
401Unauthorised - Invalid or missing API key
403Forbidden - Insufficient permissions
404Not Found - Resource doesn't exist
422Unprocessable - Validation failed
429Too Many Requests - Rate limit exceeded
500Server Error - Something went wrong on our end
+
+
+ + {{-- Error Format --}} +
+

Error Format

+

+ Error responses include a JSON body with details: +

+ +
+
{
+  "message": "The given data was invalid.",
+  "errors": {
+    "url": [
+      "The url has already been taken."
+    ]
+  }
+}
+
+
+ + {{-- Common Errors --}} +
+

Common Errors

+ +
+
+

Invalid API Key

+

+ Returned when the API key is missing, malformed, or revoked. +

+ 401 Unauthorised +
+ +
+

Resource Not Found

+

+ The requested resource (biolink, workspace, etc.) doesn't exist or you don't have access. +

+ 404 Not Found +
+ +
+

Validation Failed

+

+ Request data failed validation. Check the errors object for specific fields. +

+ 422 Unprocessable Entity +
+
+
+ + {{-- Rate Limiting --}} +
+

Rate Limiting

+

+ API requests are rate limited to ensure fair usage. Rate limit headers are included in all responses: +

+ +
+
X-RateLimit-Limit: 60
+X-RateLimit-Remaining: 58
+X-RateLimit-Reset: 1705320000
+
+ +

+ When rate limited, you'll receive a 429 response. Wait until the reset timestamp before retrying. +

+ +
+

+ Tip: 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. +

+
+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/php/src/Website/Api/View/Blade/guides/index.blade.php b/src/php/src/Website/Api/View/Blade/guides/index.blade.php new file mode 100644 index 0000000..22ee8fd --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/guides/index.blade.php @@ -0,0 +1,66 @@ +@extends('api::layouts.docs') + +@section('title', 'Guides') + +@section('content') + +@endsection diff --git a/src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php b/src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php new file mode 100644 index 0000000..188a937 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php @@ -0,0 +1,202 @@ +@extends('api::layouts.docs') + +@section('title', 'QR Code Generation') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

QR Code Generation

+

+ Generate customisable QR codes for your biolinks or any URL. +

+ + {{-- Overview --}} +
+

Overview

+

+ The API provides two ways to generate QR codes: +

+
    +
  • Biolink QR codes - Generate QR codes for your existing biolinks
  • +
  • Custom URL QR codes - Generate QR codes for any URL
  • +
+
+ + {{-- Biolink QR --}} + + + {{-- Custom QR --}} +
+

Custom URL QR Codes

+

+ Generate a QR code for any URL: +

+ +
+
+ cURL +
+
curl --request POST \
+  --url 'https://api.lthn.ai/api/v1/qr/generate' \
+  --header 'Authorization: Bearer YOUR_API_KEY' \
+  --header 'Content-Type: application/json' \
+  --data '{
+    "url": "https://example.com",
+    "format": "svg",
+    "size": 300
+  }'
+
+
+ + {{-- Options --}} +
+

Customisation Options

+

+ Available customisation parameters: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ParameterTypeDescription
formatstringOutput format: svg or png
sizeintegerSize in pixels (100-2000)
colorstringForeground colour (hex)
backgroundstringBackground colour (hex)
+
+
+ + {{-- Download --}} +
+

Download Formats

+

+ Download QR codes directly as image files: +

+ +
+
+ cURL +
+
curl --request GET \
+  --url 'https://api.lthn.ai/api/v1/bio/1/qr/download?format=png&size=500' \
+  --header 'Authorization: Bearer YOUR_API_KEY' \
+  --output qrcode.png
+
+ +

+ The response is binary image data with appropriate Content-Type header. +

+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php b/src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php new file mode 100644 index 0000000..0db729f --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php @@ -0,0 +1,192 @@ +@extends('api::layouts.docs') + +@section('title', 'Quick Start') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Quick Start

+

+ Get up and running with the API in under 5 minutes. +

+ + {{-- Prerequisites --}} +
+

Prerequisites

+

+ Before you begin, you'll need: +

+
    +
  • An account with API access
  • +
  • A workspace (created automatically on signup)
  • +
  • cURL or any HTTP client
  • +
+
+ + {{-- Create API Key --}} +
+

Create an API Key

+

+ Navigate to your workspace settings and create a new API key: +

+
    +
  1. Go to Settings → API Keys
  2. +
  3. Click Create API Key
  4. +
  5. Give it a name (e.g., "Development")
  6. +
  7. Select the scopes you need (read, write, delete)
  8. +
  9. Copy the key — it won't be shown again!
  10. +
+ + {{-- Note box --}} +
+
+ + + +

+ Important: Store your API key securely. Never commit it to version control or expose it in client-side code. +

+
+
+
+ + {{-- First Request --}} +
+

Make Your First Request

+

+ Let's verify your API key by searching agent memories: +

+ +
+
+ cURL +
+
curl --request POST \
+  --url 'https://api.lthn.ai/v1/brain/recall' \
+  --header 'Authorization: Bearer hk_your_api_key' \
+  --header 'Content-Type: application/json' \
+  --data '{"query": "hello world"}'
+
+ +

+ You should receive a response like: +

+ +
+
+ Response +
+
{
+  "memories": [],
+  "scores": {}
+}
+
+
+ + {{-- Store Memory --}} +
+

Store a Memory

+

+ Now let's store your first memory in the brain: +

+ +
+
+ cURL +
+
curl --request POST \
+  --url 'https://api.lthn.ai/v1/brain/remember' \
+  --header 'Authorization: Bearer hk_your_api_key' \
+  --header 'Content-Type: application/json' \
+  --data '{
+    "content": "Go uses structural typing",
+    "type": "fact",
+    "tags": ["go", "typing"]
+  }'
+
+ +

+ This stores a fact in the vector database. You can then recall it with a semantic query. +

+
+ + {{-- Next Steps --}} +
+

Next Steps

+

+ Now that you've made your first API calls, explore more: +

+ + +
+ +
+
+ +
+@endsection diff --git a/src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php b/src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php new file mode 100644 index 0000000..bb4c04b --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php @@ -0,0 +1,586 @@ +@extends('api::layouts.docs') + +@section('title', 'Webhooks') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ + {{-- Breadcrumb --}} + + +

Webhooks

+

+ Receive real-time notifications for events in your workspace with cryptographically signed payloads. +

+ + {{-- Overview --}} +
+

Overview

+

+ 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. +

+

+ 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. +

+
+
+ + + +

+ Security: Always verify webhook signatures before processing. Never trust unverified webhook requests. +

+
+
+
+ + {{-- Setup --}} +
+

Setup

+

+ To configure webhooks: +

+
    +
  1. Go to Settings → Webhooks in your workspace
  2. +
  3. Click Add Webhook
  4. +
  5. Enter your endpoint URL (must be HTTPS in production)
  6. +
  7. Select the events you want to receive
  8. +
  9. Save and securely store your webhook secret
  10. +
+
+
+ + + +

+ Your webhook secret is only shown once when you create the endpoint. Store it securely - you'll need it to verify incoming webhooks. +

+
+
+
+ + {{-- Events --}} +
+

Event Types

+

+ Available webhook events: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
EventDescription
bio.createdA new biolink was created
bio.updatedA biolink was updated
bio.deletedA biolink was deleted
link.createdA new link was created
link.clickedA link was clicked (high volume)
qrcode.createdA QR code was generated
qrcode.scannedA QR code was scanned (high volume)
*Subscribe to all events (wildcard)
+
+
+ + {{-- Payload --}} +
+

Payload Format

+

+ Webhook payloads are sent as JSON with a consistent structure: +

+ +
+
{
+  "id": "evt_abc123xyz456",
+  "type": "bio.created",
+  "created_at": "2024-01-15T10:30:00Z",
+  "workspace_id": 1,
+  "data": {
+    "id": 123,
+    "url": "mypage",
+    "type": "biolink"
+  }
+}
+
+
+ + {{-- Headers --}} +
+

Request Headers

+

+ Every webhook request includes the following headers: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HeaderDescription
X-Webhook-SignatureHMAC-SHA256 signature for verification
X-Webhook-TimestampUnix timestamp when the webhook was sent
X-Webhook-EventThe event type (e.g., bio.created)
X-Webhook-IdUnique delivery ID for idempotency
Content-TypeAlways application/json
+
+
+ + {{-- Verification --}} +
+

Signature Verification

+

+ 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. +

+ +

Verification Algorithm

+
    +
  1. Extract X-Webhook-Signature and X-Webhook-Timestamp headers
  2. +
  3. Concatenate: timestamp + "." + raw_request_body
  4. +
  5. Compute: HMAC-SHA256(concatenated_string, your_webhook_secret)
  6. +
  7. Compare using timing-safe comparison (prevents timing attacks)
  8. +
  9. Verify timestamp is within 5 minutes of current time (prevents replay attacks)
  10. +
+ + {{-- PHP Example --}} +

PHP

+
+
+ webhook-handler.php +
+
<?php
+
+// Get request data
+$payload = file_get_contents('php://input');
+$signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
+$timestamp = $_SERVER['HTTP_X_WEBHOOK_TIMESTAMP'] ?? '';
+$secret = getenv('WEBHOOK_SECRET');
+
+// Verify timestamp (5 minute tolerance)
+$tolerance = 300;
+if (abs(time() - (int)$timestamp) > $tolerance) {
+    http_response_code(401);
+    die('Webhook timestamp expired');
+}
+
+// Compute expected signature
+$signedPayload = $timestamp . '.' . $payload;
+$expectedSignature = hash_hmac('sha256', $signedPayload, $secret);
+
+// Verify signature (timing-safe comparison)
+if (!hash_equals($expectedSignature, $signature)) {
+    http_response_code(401);
+    die('Invalid webhook signature');
+}
+
+// Signature valid - process the webhook
+$event = json_decode($payload, true);
+processWebhook($event);
+
+ + {{-- Node.js Example --}} +

Node.js

+
+
+ webhook-handler.js +
+
const crypto = require('crypto');
+const express = require('express');
+
+const app = express();
+app.use(express.raw({ type: 'application/json' }));
+
+const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
+const TOLERANCE = 300; // 5 minutes
+
+app.post('/webhook', (req, res) => {
+    const signature = req.headers['x-webhook-signature'];
+    const timestamp = req.headers['x-webhook-timestamp'];
+    const payload = req.body;
+
+    // Verify timestamp
+    const now = Math.floor(Date.now() / 1000);
+    if (Math.abs(now - parseInt(timestamp)) > TOLERANCE) {
+        return res.status(401).send('Webhook timestamp expired');
+    }
+
+    // Compute expected signature
+    const signedPayload = `${timestamp}.${payload}`;
+    const expectedSignature = crypto
+        .createHmac('sha256', WEBHOOK_SECRET)
+        .update(signedPayload)
+        .digest('hex');
+
+    // Verify signature (timing-safe comparison)
+    if (!crypto.timingSafeEqual(
+        Buffer.from(expectedSignature),
+        Buffer.from(signature)
+    )) {
+        return res.status(401).send('Invalid webhook signature');
+    }
+
+    // Signature valid - process the webhook
+    const event = JSON.parse(payload);
+    processWebhook(event);
+    res.status(200).send('OK');
+});
+
+ + {{-- Python Example --}} +

Python

+
+
+ webhook_handler.py +
+
import hmac
+import hashlib
+import time
+import os
+from flask import Flask, request, abort
+
+app = Flask(__name__)
+WEBHOOK_SECRET = os.environ['WEBHOOK_SECRET']
+TOLERANCE = 300  # 5 minutes
+
+@app.route('/webhook', methods=['POST'])
+def webhook():
+    signature = request.headers.get('X-Webhook-Signature', '')
+    timestamp = request.headers.get('X-Webhook-Timestamp', '')
+    payload = request.get_data(as_text=True)
+
+    # Verify timestamp
+    if abs(time.time() - int(timestamp)) > TOLERANCE:
+        abort(401, 'Webhook timestamp expired')
+
+    # Compute expected signature
+    signed_payload = f'{timestamp}.{payload}'
+    expected_signature = hmac.new(
+        WEBHOOK_SECRET.encode(),
+        signed_payload.encode(),
+        hashlib.sha256
+    ).hexdigest()
+
+    # Verify signature (timing-safe comparison)
+    if not hmac.compare_digest(expected_signature, signature):
+        abort(401, 'Invalid webhook signature')
+
+    # Signature valid - process the webhook
+    event = request.get_json()
+    process_webhook(event)
+    return 'OK', 200
+
+ + {{-- Ruby Example --}} +

Ruby

+
+
+ webhook_handler.rb +
+
require 'sinatra'
+require 'openssl'
+require 'json'
+
+WEBHOOK_SECRET = ENV['WEBHOOK_SECRET']
+TOLERANCE = 300  # 5 minutes
+
+post '/webhook' do
+  signature = request.env['HTTP_X_WEBHOOK_SIGNATURE'] || ''
+  timestamp = request.env['HTTP_X_WEBHOOK_TIMESTAMP'] || ''
+  payload = request.body.read
+
+  # Verify timestamp
+  if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE
+    halt 401, 'Webhook timestamp expired'
+  end
+
+  # Compute expected signature
+  signed_payload = "#{timestamp}.#{payload}"
+  expected_signature = OpenSSL::HMAC.hexdigest(
+    'sha256',
+    WEBHOOK_SECRET,
+    signed_payload
+  )
+
+  # Verify signature (timing-safe comparison)
+  unless Rack::Utils.secure_compare(expected_signature, signature)
+    halt 401, 'Invalid webhook signature'
+  end
+
+  # Signature valid - process the webhook
+  event = JSON.parse(payload)
+  process_webhook(event)
+  200
+end
+
+ + {{-- Go Example --}} +

Go

+
+
+ webhook_handler.go +
+
package main
+
+import (
+    "crypto/hmac"
+    "crypto/sha256"
+    "crypto/subtle"
+    "encoding/hex"
+    "io"
+    "math"
+    "net/http"
+    "os"
+    "strconv"
+    "time"
+)
+
+const tolerance = 300 // 5 minutes
+
+func webhookHandler(w http.ResponseWriter, r *http.Request) {
+    signature := r.Header.Get("X-Webhook-Signature")
+    timestamp := r.Header.Get("X-Webhook-Timestamp")
+    secret := os.Getenv("WEBHOOK_SECRET")
+
+    payload, _ := io.ReadAll(r.Body)
+
+    // Verify timestamp
+    ts, _ := strconv.ParseInt(timestamp, 10, 64)
+    if math.Abs(float64(time.Now().Unix()-ts)) > tolerance {
+        http.Error(w, "Webhook timestamp expired", 401)
+        return
+    }
+
+    // Compute expected signature
+    signedPayload := timestamp + "." + string(payload)
+    mac := hmac.New(sha256.New, []byte(secret))
+    mac.Write([]byte(signedPayload))
+    expectedSignature := hex.EncodeToString(mac.Sum(nil))
+
+    // Verify signature (timing-safe comparison)
+    if subtle.ConstantTimeCompare(
+        []byte(expectedSignature),
+        []byte(signature),
+    ) != 1 {
+        http.Error(w, "Invalid webhook signature", 401)
+        return
+    }
+
+    // Signature valid - process the webhook
+    processWebhook(payload)
+    w.WriteHeader(http.StatusOK)
+}
+
+
+ + {{-- Retry Policy --}} +
+

Retry Policy

+

+ If your endpoint returns a non-2xx status code or times out, we'll retry with exponential backoff: +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AttemptDelay
1 (initial)Immediate
21 minute
35 minutes
430 minutes
5 (final)2 hours
+
+ +

+ 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. +

+
+ + {{-- Best Practices --}} +
+

Best Practices

+
    +
  • + + + + Always verify signatures - Never process webhooks without verification +
  • +
  • + + + + Respond quickly - Return 200 within 30 seconds to avoid timeouts +
  • +
  • + + + + Process asynchronously - Queue webhook processing for long-running tasks +
  • +
  • + + + + Handle duplicates - Use X-Webhook-Id for idempotency +
  • +
  • + + + + Use HTTPS - Always use HTTPS endpoints in production +
  • +
  • + + + + Rotate secrets regularly - Rotate your webhook secret periodically +
  • +
+
+ + {{-- Next steps --}} + + +
+
+ +
+@endsection diff --git a/src/php/src/Website/Api/View/Blade/index.blade.php b/src/php/src/Website/Api/View/Blade/index.blade.php new file mode 100644 index 0000000..b8ab82b --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/index.blade.php @@ -0,0 +1,132 @@ +@extends('api::layouts.docs') + +@section('title', 'API Documentation') +@section('description', 'Build powerful integrations with the API. Access brain memory, content scoring, and more.') + +@section('content') +
+ + {{-- Hero --}} +
+
+ Developer Documentation +
+

Build with the API

+

+ Store and retrieve agent memories, score content for AI patterns, + and integrate intelligent tooling into your applications. +

+ +
+ + {{-- Features grid --}} +
+ + {{-- Brain Memory --}} +
+
+ +
+

Brain Memory

+

+ Store and retrieve agent memories with vector search. Powered by Qdrant for semantic retrieval. +

+ + Learn more → + +
+ + {{-- Content Scoring --}} +
+
+ +
+

Content Scoring

+

+ Score text for AI-generated patterns and analyse linguistic imprints via the EaaS scoring engine. +

+ + Learn more → + +
+ + {{-- Authentication --}} +
+
+ +
+

Authentication

+

+ Secure API key authentication with scoped permissions. Generate keys from your workspace settings. +

+ + Learn more → + +
+ +
+ + {{-- Quick start code example --}} +
+

Quick Start

+
+
+ cURL + +
+
curl --request POST \
+  --url 'https://api.lthn.ai/v1/brain/recall' \
+  --header 'Authorization: Bearer hk_your_api_key' \
+  --header 'Content-Type: application/json' \
+  --data '{"query": "hello world"}'
+
+ + +
+ + {{-- API endpoints preview --}} +
+

API Endpoints

+
+ @foreach([ + ['method' => 'POST', 'path' => '/v1/brain/remember', 'desc' => 'Store a memory'], + ['method' => 'POST', 'path' => '/v1/brain/recall', 'desc' => 'Search memories by query'], + ['method' => 'DELETE', 'path' => '/v1/brain/forget/{id}', 'desc' => 'Delete a memory'], + ['method' => 'POST', 'path' => '/v1/score/content', 'desc' => 'Score text for AI patterns'], + ['method' => 'POST', 'path' => '/v1/score/imprint', 'desc' => 'Linguistic imprint analysis'], + ['method' => 'GET', 'path' => '/v1/score/health', 'desc' => 'Scoring engine health check'], + ] as $endpoint) + + + {{ $endpoint['method'] }} + +
+ {{ $endpoint['path'] }} + {{ $endpoint['desc'] }} +
+
+ @endforeach +
+ + +
+ +
+@endsection diff --git a/src/php/src/Website/Api/View/Blade/layouts/docs.blade.php b/src/php/src/Website/Api/View/Blade/layouts/docs.blade.php new file mode 100644 index 0000000..5500522 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/layouts/docs.blade.php @@ -0,0 +1,158 @@ +@php + $appName = config('core.app.name', 'Core PHP'); +@endphp + + + + + @yield('title', 'API Documentation') - {{ $appName }} + + + + + + @include('layouts::partials.fonts') + + + + + + @vite(['resources/css/app.css', 'resources/js/app.js']) + @fluxAppearance + + @stack('head') + + + +
+ + {{-- Site header --}} +
+ +
+
+ + {{-- Site branding --}} +
+
+ {{-- Logo --}} + + + + + {{ $appName }} API + + + {{-- Search --}} +
+ + + {{-- Search modal placeholder --}} + +
+
+
+ + {{-- Desktop nav --}} + + +
+
+
+ + {{-- Page content --}} +
+ @yield('content') +
+ + {{-- Site footer --}} + + +
+ + @fluxScripts + @stack('scripts') + + diff --git a/src/php/src/Website/Api/View/Blade/partials/endpoint.blade.php b/src/php/src/Website/Api/View/Blade/partials/endpoint.blade.php new file mode 100644 index 0000000..7c5d9e5 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/partials/endpoint.blade.php @@ -0,0 +1,37 @@ +@props(['method', 'path', 'description', 'body' => null, 'response']) + +
+ {{-- Header --}} +
+ + {{ $method }} + + {{ $path }} +
+ + {{-- Body --}} +
+

{{ $description }}

+ + @if($body) +
+

Request Body

+
+
{{ $body }}
+
+
+ @endif + +
+

Response

+
+
{{ $response }}
+
+
+
+
diff --git a/src/php/src/Website/Api/View/Blade/redoc.blade.php b/src/php/src/Website/Api/View/Blade/redoc.blade.php new file mode 100644 index 0000000..d281c6f --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/redoc.blade.php @@ -0,0 +1,73 @@ + + + + + + API Reference - {{ config('core.app.name', 'Core PHP') }} + + + + + + + +
+ + + + diff --git a/src/php/src/Website/Api/View/Blade/reference.blade.php b/src/php/src/Website/Api/View/Blade/reference.blade.php new file mode 100644 index 0000000..81478bb --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/reference.blade.php @@ -0,0 +1,149 @@ +@extends('api::layouts.docs') + +@section('title', 'API Reference') + +@section('content') +
+ + {{-- Sidebar --}} + + + {{-- Main content --}} +
+
+ +

API Reference

+

+ Complete reference for all API endpoints. +

+

+ Base URL: https://api.lthn.ai/v1 +

+ + {{-- Brain Memory --}} +
+

Brain Memory

+

+ Store and retrieve agent memories with vector search. Powered by Qdrant for semantic retrieval. +

+ + @include('api::partials.endpoint', [ + 'method' => 'POST', + 'path' => '/brain/remember', + 'description' => 'Store a new memory in the vector database.', + 'body' => '{"content": "Go uses structural typing", "type": "fact", "project": "go-agentic", "tags": ["go", "typing"]}', + 'response' => '{"id": "mem-abc-123", "type": "fact", "project": "go-agentic", "created_at": "2026-03-03T12:00:00+00:00"}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'POST', + 'path' => '/brain/recall', + 'description' => 'Search memories by semantic query. Returns ranked results with confidence scores.', + 'body' => '{"query": "how does typing work in Go", "top_k": 5, "project": "go-agentic"}', + 'response' => '{"memories": [{"id": "mem-abc-123", "type": "fact", "content": "Go uses structural typing", "confidence": 0.95}], "scores": {"mem-abc-123": 0.87}}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'DELETE', + 'path' => '/brain/forget/{id}', + 'description' => 'Delete a specific memory by ID.', + 'response' => '{"deleted": true}' + ]) +
+ + {{-- Content Scoring --}} +
+

Content Scoring

+

+ Score text for AI patterns and analyse linguistic imprints via the EaaS scoring engine. +

+ + @include('api::partials.endpoint', [ + 'method' => 'POST', + 'path' => '/score/content', + 'description' => 'Score text for AI-generated content patterns. Returns a score (0-1), confidence, and label.', + 'body' => '{"text": "The text to analyse for AI patterns", "prompt": "Optional scoring prompt"}', + 'response' => '{"score": 0.23, "confidence": 0.91, "label": "human"}' + ]) + + @include('api::partials.endpoint', [ + 'method' => 'POST', + 'path' => '/score/imprint', + 'description' => 'Perform linguistic imprint analysis on text. Returns a unique imprint fingerprint.', + 'body' => '{"text": "The text to analyse for linguistic patterns"}', + 'response' => '{"imprint": "abc123def456", "confidence": 0.88}' + ]) +
+ + {{-- Collections --}} +
+

Collections

+

+ Manage vector database collections for brain memory storage. +

+ + @include('api::partials.endpoint', [ + 'method' => 'POST', + 'path' => '/brain/collections', + 'description' => 'Ensure the workspace collection exists in the vector database. Creates it if missing.', + 'response' => '{"status": "ok"}' + ]) +
+ + {{-- Health --}} +
+

Health

+

+ Health check endpoints. These do not require authentication. +

+ + @include('api::partials.endpoint', [ + 'method' => 'GET', + 'path' => '/score/health', + 'description' => 'Check the health of the scoring engine and its upstream services.', + 'response' => '{"status": "healthy", "upstream_status": 200}' + ]) +
+ + {{-- CTA --}} +
+

Try it out

+

Test endpoints interactively with Swagger UI.

+ + Open Swagger UI + +
+ +
+
+ +
+@endsection diff --git a/src/php/src/Website/Api/View/Blade/scalar.blade.php b/src/php/src/Website/Api/View/Blade/scalar.blade.php new file mode 100644 index 0000000..310ba49 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/scalar.blade.php @@ -0,0 +1,71 @@ + + + + + + API Reference - {{ config('core.app.name', 'Core PHP') }} + + + + + + +
+ + +
+ + diff --git a/src/php/src/Website/Api/View/Blade/swagger.blade.php b/src/php/src/Website/Api/View/Blade/swagger.blade.php new file mode 100644 index 0000000..a1cf7c1 --- /dev/null +++ b/src/php/src/Website/Api/View/Blade/swagger.blade.php @@ -0,0 +1,58 @@ +@extends('api::layouts.docs') + +@section('title', 'Swagger UI') + +@push('head') + + +@endpush + +@section('content') +
+
+

Swagger UI

+

+ Interactive API explorer. Try out endpoints directly from your browser. +

+
+ +
+
+@endsection + +@push('scripts') + + + +@endpush diff --git a/src/php/tests/Feature/.gitkeep b/src/php/tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/php/tests/TestCase.php b/src/php/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/src/php/tests/TestCase.php @@ -0,0 +1,10 @@ + query parameter subscribes the client to a specific channel. +func (b *SSEBroker) Handler() gin.HandlerFunc { + return func(c *gin.Context) { + channel := c.Query("channel") + + client := &sseClient{ + channel: channel, + events: make(chan sseEvent, 64), + done: make(chan struct{}), + } + + b.mu.Lock() + b.clients[client] = struct{}{} + b.mu.Unlock() + + defer func() { + close(client.done) + b.mu.Lock() + delete(b.clients, client) + b.mu.Unlock() + }() + + // Set SSE headers. + c.Writer.Header().Set("Content-Type", "text/event-stream") + c.Writer.Header().Set("Cache-Control", "no-cache") + c.Writer.Header().Set("Connection", "keep-alive") + c.Writer.Header().Set("X-Accel-Buffering", "no") + c.Status(http.StatusOK) + c.Writer.Flush() + + // Stream events until client disconnects. + ctx := c.Request.Context() + for { + select { + case <-ctx.Done(): + return + case evt := <-client.events: + _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", evt.Event, evt.Data) + if err != nil { + return + } + // Flush to ensure the event is sent immediately. + if f, ok := c.Writer.(http.Flusher); ok { + f.Flush() + } + } + } + } +} + +// ClientCount returns the number of currently connected SSE clients. +func (b *SSEBroker) ClientCount() int { + b.mu.RLock() + defer b.mu.RUnlock() + return len(b.clients) +} + +// Drain closes all connected clients by writing an empty response. +// Useful for graceful shutdown. +func (b *SSEBroker) Drain() { + b.mu.Lock() + defer b.mu.Unlock() + for client := range b.clients { + select { + case <-client.done: + default: + // Write EOF to trigger client disconnect via their event loop. + close(client.events) + } + } +} diff --git a/sse_test.go b/sse_test.go new file mode 100644 index 0000000..e4829bf --- /dev/null +++ b/sse_test.go @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "bufio" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── SSE endpoint ──────────────────────────────────────────────────────── + +func TestWithSSE_Good_EndpointExists(t *testing.T) { + gin.SetMode(gin.TestMode) + + broker := api.NewSSEBroker() + e, err := api.New(api.WithSSE(broker)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/events") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("expected Content-Type starting with text/event-stream, got %q", ct) + } +} + +func TestWithSSE_Good_ReceivesPublishedEvent(t *testing.T) { + gin.SetMode(gin.TestMode) + + broker := api.NewSSEBroker() + e, err := api.New(api.WithSSE(broker)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/events") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Wait for the client to register before publishing. + waitForClients(t, broker, 1) + + // Publish an event on the default channel. + broker.Publish("test", "greeting", map[string]string{"msg": "hello"}) + + // Read SSE lines from the response body. + scanner := bufio.NewScanner(resp.Body) + var eventLine, dataLine string + + deadline := time.After(3 * time.Second) + done := make(chan struct{}) + + go func() { + defer close(done) + for scanner.Scan() { + line := scanner.Text() + if after, ok := strings.CutPrefix(line, "event: "); ok { + eventLine = after + } + if after, ok := strings.CutPrefix(line, "data: "); ok { + dataLine = after + return + } + } + }() + + select { + case <-done: + case <-deadline: + t.Fatal("timed out waiting for SSE event") + } + + if eventLine != "greeting" { + t.Fatalf("expected event=%q, got %q", "greeting", eventLine) + } + if !strings.Contains(dataLine, `"msg":"hello"`) { + t.Fatalf("expected data containing msg:hello, got %q", dataLine) + } +} + +func TestWithSSE_Good_ChannelFiltering(t *testing.T) { + gin.SetMode(gin.TestMode) + + broker := api.NewSSEBroker() + e, err := api.New(api.WithSSE(broker)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + // Subscribe to channel "foo" only. + resp, err := http.Get(srv.URL + "/events?channel=foo") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Wait for client to register. + waitForClients(t, broker, 1) + + // Publish to "bar" (should not be received), then to "foo" (should be received). + broker.Publish("bar", "ignore", "bar-data") + // Small delay to ensure ordering. + time.Sleep(50 * time.Millisecond) + broker.Publish("foo", "match", "foo-data") + + // Read the first event from the stream. + scanner := bufio.NewScanner(resp.Body) + var eventLine string + + deadline := time.After(3 * time.Second) + done := make(chan struct{}) + + go func() { + defer close(done) + for scanner.Scan() { + line := scanner.Text() + if after, ok := strings.CutPrefix(line, "event: "); ok { + eventLine = after + // Read past the data and blank line. + scanner.Scan() // data line + return + } + } + }() + + select { + case <-done: + case <-deadline: + t.Fatal("timed out waiting for SSE event") + } + + // The first event received should be "match" (from channel foo), not "ignore" (from bar). + if eventLine != "match" { + t.Fatalf("expected event=%q, got %q (channel filtering failed)", "match", eventLine) + } +} + +func TestWithSSE_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + broker := api.NewSSEBroker() + e, err := api.New( + api.WithRequestID(), + api.WithSSE(broker), + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/events") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + // RequestID middleware should have injected the header. + reqID := resp.Header.Get("X-Request-ID") + if reqID == "" { + t.Fatal("expected X-Request-ID header from RequestID middleware") + } + + ct := resp.Header.Get("Content-Type") + if !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("expected Content-Type starting with text/event-stream, got %q", ct) + } +} + +func TestWithSSE_Good_MultipleClients(t *testing.T) { + gin.SetMode(gin.TestMode) + + broker := api.NewSSEBroker() + e, err := api.New(api.WithSSE(broker)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + // Connect two clients. + resp1, err := http.Get(srv.URL + "/events") + if err != nil { + t.Fatalf("client 1 request failed: %v", err) + } + defer resp1.Body.Close() + + resp2, err := http.Get(srv.URL + "/events") + if err != nil { + t.Fatalf("client 2 request failed: %v", err) + } + defer resp2.Body.Close() + + // Wait for both clients to register. + waitForClients(t, broker, 2) + + // Publish a single event. + broker.Publish("broadcast", "ping", "pong") + + // Both clients should receive it. + var wg sync.WaitGroup + wg.Add(2) + + readEvent := func(name string, resp *http.Response) { + defer wg.Done() + scanner := bufio.NewScanner(resp.Body) + deadline := time.After(3 * time.Second) + done := make(chan string, 1) + + go func() { + for scanner.Scan() { + line := scanner.Text() + if after, ok := strings.CutPrefix(line, "event: "); ok { + done <- after + return + } + } + }() + + select { + case evt := <-done: + if evt != "ping" { + t.Errorf("%s: expected event=%q, got %q", name, "ping", evt) + } + case <-deadline: + t.Errorf("%s: timed out waiting for SSE event", name) + } + } + + go readEvent("client1", resp1) + go readEvent("client2", resp2) + + wg.Wait() +} + +// ── No SSE broker ──────────────────────────────────────────────────────── + +func TestNoSSEBroker_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Without WithSSE, GET /events should return 404. + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/events", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /events without broker, got %d", w.Code) + } +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +// waitForClients polls the broker until the expected number of clients +// are connected or the timeout expires. +func waitForClients(t *testing.T, broker *api.SSEBroker, want int) { + t.Helper() + deadline := time.After(2 * time.Second) + for { + if broker.ClientCount() >= want { + return + } + select { + case <-deadline: + t.Fatalf("timed out waiting for %d SSE clients (have %d)", want, broker.ClientCount()) + default: + time.Sleep(10 * time.Millisecond) + } + } +} diff --git a/static_test.go b/static_test.go new file mode 100644 index 0000000..e66f2ce --- /dev/null +++ b/static_test.go @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── WithStatic ────────────────────────────────────────────────────────── + +func TestWithStatic_Good_ServesFile(t *testing.T) { + gin.SetMode(gin.TestMode) + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "hello.txt"), []byte("hello world"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + e, _ := api.New(api.WithStatic("/assets", dir)) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/assets/hello.txt", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if body != "hello world" { + t.Fatalf("expected body=%q, got %q", "hello world", body) + } +} + +func TestWithStatic_Good_Returns404ForMissing(t *testing.T) { + gin.SetMode(gin.TestMode) + + dir := t.TempDir() + + e, _ := api.New(api.WithStatic("/assets", dir)) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/assets/nonexistent.txt", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d", w.Code) + } +} + +func TestWithStatic_Good_ServesIndex(t *testing.T) { + gin.SetMode(gin.TestMode) + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("

Welcome

"), 0644); err != nil { + t.Fatalf("failed to write index.html: %v", err) + } + + e, _ := api.New(api.WithStatic("/docs", dir)) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/docs/", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + body := w.Body.String() + if body != "

Welcome

" { + t.Fatalf("expected body=%q, got %q", "

Welcome

", body) + } +} + +func TestWithStatic_Good_CombinesWithRouteGroups(t *testing.T) { + gin.SetMode(gin.TestMode) + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('ok')"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + e, _ := api.New(api.WithStatic("/static", dir)) + e.Register(&stubGroup{}) + + h := e.Handler() + + // Static file should be served. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodGet, "/static/app.js", nil) + h.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("static: expected 200, got %d", w1.Code) + } + if w1.Body.String() != "console.log('ok')" { + t.Fatalf("static: unexpected body %q", w1.Body.String()) + } + + // API route should also work. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("api: expected 200, got %d", w2.Code) + } +} + +func TestWithStatic_Good_MultipleStaticDirs(t *testing.T) { + gin.SetMode(gin.TestMode) + + dir1 := t.TempDir() + if err := os.WriteFile(filepath.Join(dir1, "sdk.zip"), []byte("sdk-data"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + dir2 := t.TempDir() + if err := os.WriteFile(filepath.Join(dir2, "style.css"), []byte("body{}"), 0644); err != nil { + t.Fatalf("failed to write test file: %v", err) + } + + e, _ := api.New( + api.WithStatic("/downloads", dir1), + api.WithStatic("/css", dir2), + ) + + h := e.Handler() + + // First static directory. + w1 := httptest.NewRecorder() + req1, _ := http.NewRequest(http.MethodGet, "/downloads/sdk.zip", nil) + h.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("downloads: expected 200, got %d", w1.Code) + } + if w1.Body.String() != "sdk-data" { + t.Fatalf("downloads: expected body=%q, got %q", "sdk-data", w1.Body.String()) + } + + // Second static directory. + w2 := httptest.NewRecorder() + req2, _ := http.NewRequest(http.MethodGet, "/css/style.css", nil) + h.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("css: expected 200, got %d", w2.Code) + } + if w2.Body.String() != "body{}" { + t.Fatalf("css: expected body=%q, got %q", "body{}", w2.Body.String()) + } +} diff --git a/swagger.go b/swagger.go new file mode 100644 index 0000000..65b45c5 --- /dev/null +++ b/swagger.go @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "fmt" + "sync" + "sync/atomic" + + "github.com/gin-gonic/gin" + swaggerFiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + "github.com/swaggo/swag" +) + +// swaggerSeq provides unique instance names so multiple Engine instances +// (common in tests) do not collide in the global swag registry. +var swaggerSeq atomic.Uint64 + +// swaggerSpec wraps SpecBuilder to satisfy the swag.Spec interface. +// The spec is built once on first access and cached. +type swaggerSpec struct { + builder *SpecBuilder + groups []RouteGroup + once sync.Once + doc string +} + +// ReadDoc returns the OpenAPI 3.1 JSON document for this spec. +func (s *swaggerSpec) ReadDoc() string { + s.once.Do(func() { + data, err := s.builder.Build(s.groups) + if err != nil { + s.doc = `{"openapi":"3.1.0","info":{"title":"error","version":"0.0.0"},"paths":{}}` + return + } + s.doc = string(data) + }) + return s.doc +} + +// registerSwagger mounts the Swagger UI and doc.json endpoint. +func registerSwagger(g *gin.Engine, title, description, version string, groups []RouteGroup) { + spec := &swaggerSpec{ + builder: &SpecBuilder{ + Title: title, + Description: description, + Version: version, + }, + groups: groups, + } + name := fmt.Sprintf("swagger_%d", swaggerSeq.Add(1)) + swag.Register(name, spec) + g.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.NewHandler(), ginSwagger.InstanceName(name))) +} diff --git a/swagger_test.go b/swagger_test.go new file mode 100644 index 0000000..987b9a0 --- /dev/null +++ b/swagger_test.go @@ -0,0 +1,319 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// ── Swagger endpoint ──────────────────────────────────────────────────── + +func TestSwaggerEndpoint_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithSwagger("Test API", "A test API service", "1.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Use a real test server because gin-swagger reads RequestURI + // which is not populated by httptest.NewRecorder. + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/swagger/doc.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + if len(body) == 0 { + t.Fatal("expected non-empty response body") + } + + // Verify the body is valid JSON with expected fields. + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("expected valid JSON, got unmarshal error: %v", err) + } + + info, ok := doc["info"].(map[string]any) + if !ok { + t.Fatal("expected 'info' object in swagger doc") + } + if info["title"] != "Test API" { + t.Fatalf("expected title=%q, got %q", "Test API", info["title"]) + } + if info["version"] != "1.0.0" { + t.Fatalf("expected version=%q, got %q", "1.0.0", info["version"]) + } +} + +func TestSwaggerDisabledByDefault_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Without WithSwagger, GET /swagger/doc.json should return 404. + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/swagger/doc.json", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /swagger/doc.json without WithSwagger, got %d", w.Code) + } +} + +func TestSwagger_Good_SpecNotEmpty(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithSwagger("Test API", "Test", "1.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Register a describable group so paths has more than just /health. + bridge := api.NewToolBridge("/tools") + bridge.Add(api.ToolDescriptor{ + Name: "file_read", + Description: "Read a file", + Group: "files", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + e.Register(bridge) + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/swagger/doc.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths, ok := doc["paths"].(map[string]any) + if !ok { + t.Fatal("expected 'paths' object in spec") + } + + // Must have more than just /health since we registered a tool. + if len(paths) < 2 { + t.Fatalf("expected at least 2 paths (got %d): /health + tool endpoint", len(paths)) + } + + if _, ok := paths["/tools/file_read"]; !ok { + t.Fatal("expected /tools/file_read path in spec") + } +} + +func TestSwagger_Good_WithToolBridge(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithSwagger("Tool API", "Tool test", "1.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + bridge := api.NewToolBridge("/api/tools") + bridge.Add(api.ToolDescriptor{ + Name: "metrics_query", + Description: "Query metrics data", + Group: "metrics", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + }, + }, + }, func(c *gin.Context) { + c.JSON(http.StatusOK, api.OK("ok")) + }) + e.Register(bridge) + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/swagger/doc.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + paths := doc["paths"].(map[string]any) + if _, ok := paths["/api/tools/metrics_query"]; !ok { + t.Fatal("expected /api/tools/metrics_query path in spec") + } + + // Verify the operation has the expected summary. + toolPath := paths["/api/tools/metrics_query"].(map[string]any) + postOp := toolPath["post"].(map[string]any) + if postOp["summary"] != "Query metrics data" { + t.Fatalf("expected summary=%q, got %v", "Query metrics data", postOp["summary"]) + } +} + +func TestSwagger_Good_CachesSpec(t *testing.T) { + spec := &swaggerSpecHelper{ + title: "Cache Test", + desc: "Testing cache", + version: "0.1.0", + } + + first := spec.ReadDoc() + second := spec.ReadDoc() + + if first != second { + t.Fatal("expected ReadDoc() to return the same string on repeated calls") + } + + if first == "" { + t.Fatal("expected non-empty spec from ReadDoc()") + } +} + +func TestSwagger_Good_InfoFromOptions(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithSwagger("MyTitle", "MyDesc", "2.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/swagger/doc.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + info := doc["info"].(map[string]any) + if info["title"] != "MyTitle" { + t.Fatalf("expected title=%q, got %v", "MyTitle", info["title"]) + } + if info["description"] != "MyDesc" { + t.Fatalf("expected description=%q, got %v", "MyDesc", info["description"]) + } + if info["version"] != "2.0.0" { + t.Fatalf("expected version=%q, got %v", "2.0.0", info["version"]) + } +} + +func TestSwagger_Good_ValidOpenAPI(t *testing.T) { + gin.SetMode(gin.TestMode) + + e, err := api.New(api.WithSwagger("OpenAPI Test", "Verify version", "1.0.0")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/swagger/doc.json") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("failed to read body: %v", err) + } + + var doc map[string]any + if err := json.Unmarshal(body, &doc); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + + if doc["openapi"] != "3.1.0" { + t.Fatalf("expected openapi=%q, got %v", "3.1.0", doc["openapi"]) + } +} + +// swaggerSpecHelper exercises the caching behaviour of swaggerSpec +// without depending on unexported internals. It creates a SpecBuilder +// inline and uses sync.Once the same way the real swaggerSpec does. +type swaggerSpecHelper struct { + title, desc, version string + called int + cache string +} + +func (h *swaggerSpecHelper) ReadDoc() string { + if h.cache != "" { + return h.cache + } + h.called++ + sb := &api.SpecBuilder{ + Title: h.title, + Description: h.desc, + Version: h.version, + } + data, err := sb.Build(nil) + if err != nil { + h.cache = `{"openapi":"3.1.0","info":{"title":"error","version":"0.0.0"},"paths":{}}` + return h.cache + } + h.cache = string(data) + return h.cache +} diff --git a/timeout_test.go b/timeout_test.go new file mode 100644 index 0000000..06e010c --- /dev/null +++ b/timeout_test.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + api "forge.lthn.ai/core/api" +) + +// skipIfRaceDetector skips the test when the race detector is enabled. +// gin-contrib/timeout@v1.1.0 has a known data race on Context.index +// between the timeout goroutine and the handler goroutine. +func skipIfRaceDetector(t *testing.T) { + t.Helper() + if raceDetectorEnabled { + t.Skip("skipping: gin-contrib/timeout has known data race (upstream bug)") + } +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +// slowGroup provides a route that sleeps longer than the test timeout. +type slowGroup struct{} + +func (s *slowGroup) Name() string { return "slow" } +func (s *slowGroup) BasePath() string { return "/v1/slow" } +func (s *slowGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/wait", func(c *gin.Context) { + time.Sleep(200 * time.Millisecond) + c.JSON(http.StatusOK, api.OK("done")) + }) +} + +// ── WithTimeout ───────────────────────────────────────────────────────── + +func TestWithTimeout_Good_FastRequestSucceeds(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithTimeout(500 * time.Millisecond)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if !resp.Success { + t.Fatal("expected Success=true") + } + if resp.Data != "pong" { + t.Fatalf("expected Data=%q, got %q", "pong", resp.Data) + } +} + +func TestWithTimeout_Good_SlowRequestTimesOut(t *testing.T) { + skipIfRaceDetector(t) + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithTimeout(50 * time.Millisecond)) + e.Register(&slowGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/slow/wait", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusGatewayTimeout { + t.Fatalf("expected 504, got %d", w.Code) + } +} + +func TestWithTimeout_Good_TimeoutResponseEnvelope(t *testing.T) { + skipIfRaceDetector(t) + gin.SetMode(gin.TestMode) + e, _ := api.New(api.WithTimeout(50 * time.Millisecond)) + e.Register(&slowGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/v1/slow/wait", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusGatewayTimeout { + t.Fatalf("expected 504, got %d", w.Code) + } + + var resp api.Response[any] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Success { + t.Fatal("expected Success=false") + } + if resp.Error == nil { + t.Fatal("expected Error to be non-nil") + } + if resp.Error.Code != "timeout" { + t.Fatalf("expected error code=%q, got %q", "timeout", resp.Error.Code) + } + if resp.Error.Message != "Request timed out" { + t.Fatalf("expected error message=%q, got %q", "Request timed out", resp.Error.Message) + } +} + +func TestWithTimeout_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + e, _ := api.New( + api.WithRequestID(), + api.WithTimeout(500*time.Millisecond), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // WithRequestID should still set the header. + id := w.Header().Get("X-Request-ID") + if id == "" { + t.Fatal("expected X-Request-ID header to be set") + } + + var resp api.Response[string] + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal error: %v", err) + } + if resp.Data != "pong" { + t.Fatalf("expected Data=%q, got %q", "pong", resp.Data) + } +} + +func TestWithTimeout_Ugly_ZeroDurationDoesNotPanic(t *testing.T) { + skipIfRaceDetector(t) + gin.SetMode(gin.TestMode) + + defer func() { + if r := recover(); r != nil { + t.Fatalf("WithTimeout(0) panicked: %v", r) + } + }() + + e, err := api.New(api.WithTimeout(0)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + // We only care that it did not panic. Status may vary with zero timeout. +} diff --git a/tracing.go b/tracing.go new file mode 100644 index 0000000..5fecb2f --- /dev/null +++ b/tracing.go @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +// WithTracing adds OpenTelemetry distributed tracing middleware via otelgin. +// Each incoming request produces a span tagged with the HTTP method, route, +// and status code. Trace context is propagated using W3C traceparent headers. +// +// The serviceName identifies this service in distributed traces. If a +// TracerProvider has not been configured globally via otel.SetTracerProvider, +// the middleware uses the global default (which is a no-op until set). +// +// Typical setup in main: +// +// tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exporter)) +// otel.SetTracerProvider(tp) +// otel.SetTextMapPropagator(propagation.TraceContext{}) +// +// engine, _ := api.New(api.WithTracing("my-service")) +func WithTracing(serviceName string, opts ...otelgin.Option) Option { + return func(e *Engine) { + e.middlewares = append(e.middlewares, otelgin.Middleware(serviceName, opts...)) + } +} + +// NewTracerProvider creates a TracerProvider configured with the given +// SpanExporter and returns it. The caller is responsible for calling +// Shutdown on the returned provider when the application exits. +// +// This is a convenience helper for tests and simple deployments. +// Production setups should build their own TracerProvider with batching, +// resource attributes, and appropriate exporters. +func NewTracerProvider(exporter sdktrace.SpanExporter) *sdktrace.TracerProvider { + tp := sdktrace.NewTracerProvider( + sdktrace.WithSyncer(exporter), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + return tp +} diff --git a/tracing_test.go b/tracing_test.go new file mode 100644 index 0000000..a72b062 --- /dev/null +++ b/tracing_test.go @@ -0,0 +1,252 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/sdk/trace/tracetest" + "go.opentelemetry.io/otel/trace" + + api "forge.lthn.ai/core/api" +) + +// setupTracing creates an in-memory span exporter, wires it into a +// synchronous TracerProvider, and installs it as the global provider. +// The returned cleanup function restores the previous global state. +func setupTracing(t *testing.T) (*tracetest.InMemoryExporter, func()) { + t.Helper() + + exporter := tracetest.NewInMemoryExporter() + tp := sdktrace.NewTracerProvider( + sdktrace.WithSyncer(exporter), + ) + + prevTP := otel.GetTracerProvider() + prevProp := otel.GetTextMapPropagator() + + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + + cleanup := func() { + _ = tp.Shutdown(context.Background()) + otel.SetTracerProvider(prevTP) + otel.SetTextMapPropagator(prevProp) + } + + return exporter, cleanup +} + +// hasAttribute returns true if the span stub's attributes contain a +// matching key (and optionally value). +func hasAttribute(attrs []attribute.KeyValue, key attribute.Key) (attribute.KeyValue, bool) { + for _, a := range attrs { + if a.Key == key { + return a, true + } + } + return attribute.KeyValue{}, false +} + +// ── WithTracing ───────────────────────────────────────────────────────── + +func TestWithTracing_Good_CreatesSpan(t *testing.T) { + gin.SetMode(gin.TestMode) + + exporter, cleanup := setupTracing(t) + defer cleanup() + + e, _ := api.New(api.WithTracing("test-service")) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("expected at least one span, got none") + } + + // The span name should contain the route. + span := spans[0] + if span.Name == "" { + t.Fatal("expected span to have a name") + } +} + +func TestWithTracing_Good_SpanHasHTTPAttributes(t *testing.T) { + gin.SetMode(gin.TestMode) + + exporter, cleanup := setupTracing(t) + defer cleanup() + + e, _ := api.New(api.WithTracing("test-service")) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("expected at least one span") + } + + span := spans[0] + attrs := span.Attributes + + // Check http.request.method attribute. + if kv, ok := hasAttribute(attrs, attribute.Key("http.request.method")); !ok { + t.Error("expected span to have http.request.method attribute") + } else if kv.Value.AsString() != "GET" { + t.Errorf("expected http.request.method=GET, got %q", kv.Value.AsString()) + } + + // Check http.route attribute. + if _, ok := hasAttribute(attrs, attribute.Key("http.route")); !ok { + t.Error("expected span to have http.route attribute") + } + + // Check http.response.status_code attribute. + if kv, ok := hasAttribute(attrs, attribute.Key("http.response.status_code")); !ok { + t.Error("expected span to have http.response.status_code attribute") + } else if kv.Value.AsInt64() != 200 { + t.Errorf("expected http.response.status_code=200, got %d", kv.Value.AsInt64()) + } +} + +func TestWithTracing_Good_PropagatesTraceContext(t *testing.T) { + gin.SetMode(gin.TestMode) + + exporter, cleanup := setupTracing(t) + defer cleanup() + + e, _ := api.New(api.WithTracing("test-service")) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + + // Inject a W3C traceparent header to simulate an upstream service. + // Format: version-traceID-spanID-flags + req.Header.Set("traceparent", "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01") + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("expected at least one span") + } + + span := spans[0] + + // The span should have a parent with the trace ID from the traceparent header. + parentTraceID := span.Parent.TraceID() + expectedTraceID, _ := trace.TraceIDFromHex("4bf92f3577b34da6a3ce929d0e0e4736") + if parentTraceID != expectedTraceID { + t.Errorf("expected parent trace ID %s, got %s", expectedTraceID, parentTraceID) + } + + // The span should also share the same trace ID (trace propagation). + spanTraceID := span.SpanContext.TraceID() + if spanTraceID != expectedTraceID { + t.Errorf("expected span trace ID %s to match parent %s", spanTraceID, expectedTraceID) + } + + // The parent span ID should match what was in the traceparent header. + parentSpanID := span.Parent.SpanID() + expectedSpanID, _ := trace.SpanIDFromHex("00f067aa0ba902b7") + if parentSpanID != expectedSpanID { + t.Errorf("expected parent span ID %s, got %s", expectedSpanID, parentSpanID) + } +} + +func TestWithTracing_Good_CombinesWithOtherMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + exporter, cleanup := setupTracing(t) + defer cleanup() + + e, _ := api.New( + api.WithTracing("test-service"), + api.WithRequestID(), + ) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + // Tracing should produce spans. + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("expected at least one span from WithTracing") + } + + // WithRequestID should set the X-Request-ID header. + if w.Header().Get("X-Request-ID") == "" { + t.Fatal("expected X-Request-ID header from WithRequestID") + } +} + +func TestWithTracing_Good_ServiceNameInSpan(t *testing.T) { + gin.SetMode(gin.TestMode) + + exporter, cleanup := setupTracing(t) + defer cleanup() + + const serviceName = "my-awesome-api" + e, _ := api.New(api.WithTracing(serviceName)) + e.Register(&stubGroup{}) + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + spans := exporter.GetSpans() + if len(spans) == 0 { + t.Fatal("expected at least one span") + } + + span := spans[0] + + // otelgin uses the serviceName as the server.address attribute. + if kv, ok := hasAttribute(span.Attributes, attribute.Key("server.address")); !ok { + t.Error("expected span to have server.address attribute for service name") + } else if kv.Value.AsString() != serviceName { + t.Errorf("expected server.address=%q, got %q", serviceName, kv.Value.AsString()) + } +} diff --git a/websocket.go b/websocket.go new file mode 100644 index 0000000..8eb7a33 --- /dev/null +++ b/websocket.go @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// wrapWSHandler adapts a standard http.Handler to a Gin handler for the /ws route. +// The underlying handler is responsible for upgrading the connection to WebSocket. +func wrapWSHandler(h http.Handler) gin.HandlerFunc { + return func(c *gin.Context) { + h.ServeHTTP(c.Writer, c.Request) + } +} diff --git a/websocket_test.go b/websocket_test.go new file mode 100644 index 0000000..c8a2623 --- /dev/null +++ b/websocket_test.go @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package api_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + + api "forge.lthn.ai/core/api" +) + +// ── Stub groups ───────────────────────────────────────────────────────── + +// wsStubGroup is a basic RouteGroup for WebSocket tests. +type wsStubGroup struct{} + +func (s *wsStubGroup) Name() string { return "wsstub" } +func (s *wsStubGroup) BasePath() string { return "/v1/wsstub" } +func (s *wsStubGroup) RegisterRoutes(rg *gin.RouterGroup) { + rg.GET("/ping", func(c *gin.Context) { + c.JSON(200, api.OK("pong")) + }) +} + +// wsStubStreamGroup embeds wsStubGroup and implements StreamGroup. +type wsStubStreamGroup struct{ wsStubGroup } + +func (s *wsStubStreamGroup) Channels() []string { + return []string{"wsstub.events", "wsstub.updates"} +} + +// ── WebSocket endpoint ────────────────────────────────────────────────── + +func TestWSEndpoint_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Create a WebSocket upgrader that writes "hello" to every connection. + upgrader := websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { return true }, + } + wsHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Logf("upgrade error: %v", err) + return + } + defer conn.Close() + _ = conn.WriteMessage(websocket.TextMessage, []byte("hello")) + }) + + e, err := api.New(api.WithWSHandler(wsHandler)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + srv := httptest.NewServer(e.Handler()) + defer srv.Close() + + // Dial the WebSocket endpoint. + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws" + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + t.Fatalf("failed to dial WebSocket: %v", err) + } + defer conn.Close() + + _, msg, err := conn.ReadMessage() + if err != nil { + t.Fatalf("failed to read message: %v", err) + } + if string(msg) != "hello" { + t.Fatalf("expected message=%q, got %q", "hello", string(msg)) + } +} + +func TestNoWSHandler_Good(t *testing.T) { + gin.SetMode(gin.TestMode) + + // Without WithWSHandler, GET /ws should return 404. + e, _ := api.New() + + h := e.Handler() + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodGet, "/ws", nil) + h.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404 for /ws without handler, got %d", w.Code) + } +} + +// ── Channel listing ───────────────────────────────────────────────────── + +func TestChannelListing_Good(t *testing.T) { + e, _ := api.New() + + // Register a plain RouteGroup (no channels) and a StreamGroup. + e.Register(&wsStubGroup{}) + e.Register(&wsStubStreamGroup{}) + + channels := e.Channels() + if len(channels) != 2 { + t.Fatalf("expected 2 channels, got %d", len(channels)) + } + if channels[0] != "wsstub.events" { + t.Fatalf("expected channels[0]=%q, got %q", "wsstub.events", channels[0]) + } + if channels[1] != "wsstub.updates" { + t.Fatalf("expected channels[1]=%q, got %q", "wsstub.updates", channels[1]) + } +}