diff --git a/GOAL.md b/GOAL.md
new file mode 100644
index 0000000..d19a297
--- /dev/null
+++ b/GOAL.md
@@ -0,0 +1,797 @@
+# Sonar sweeps — core-api findings
+
+707 findings across 26 rules. One rule per commit; fix every line listed under each rule.
+
+## BLOCKER
+
+### php:S2068 — Credentials should not be hard-coded (2×, vulnerability)
+
+- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:152` — Detected URI with password, review this potentially hardcoded credential.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:310` — Detected URI with password, review this potentially hardcoded credential.
+
+### php:S6418 — Secrets should not be hard-coded (1×, vulnerability)
+
+- `src/php/src/Api/Documentation/Examples/CommonExamples.php:169` — 'API-Key' detected in this expression, review this potentially hard-coded secret.
+
+## CRITICAL
+
+### go:S1192 — String literals should not be duplicated (371×, code smell)
+
+- `api_describable_test.go:126` — Define a constant instead of duplicating this literal "/api/widgets" 4 times.
+- `api_describable_test.go:150` — Define a constant instead of duplicating this literal "expected tags array, got %T" 3 times.
+- `api_renderable_test.go:72` — Define a constant instead of duplicating this literal "/api/widgets" 4 times.
+- `api_renderable_test.go:107` — Define a constant instead of duplicating this literal "x-render-hints" 6 times.
+- `api_test.go:24` — Define a constant instead of duplicating this literal "health-extra" 3 times.
+- `api_test.go:132` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times.
+- `api_test.go:137` — Define a constant instead of duplicating this literal "unmarshal error: %v" 3 times.
+- `authentik_integration_test.go:149` — Define a constant instead of duplicating this literal "/v1/whoami" 4 times.
+- `authentik_test.go:21` — Define a constant instead of duplicating this literal "alice@example.com" 4 times.
+- `authentik_test.go:22` — Define a constant instead of duplicating this literal "Alice Smith" 3 times.
+- `authentik_test.go:23` — Define a constant instead of duplicating this literal "abc-123" 3 times.
+- `authentik_test.go:26` — Define a constant instead of duplicating this literal "tok.en.here" 3 times.
+- `authentik_test.go:30` — Define a constant instead of duplicating this literal "expected Username=%q, got %q" 3 times.
+- `authentik_test.go:33` — Define a constant instead of duplicating this literal "expected Email=%q, got %q" 3 times.
+- `authentik_test.go:75` — Define a constant instead of duplicating this literal "https://auth.example.com" 3 times.
+- `authentik_test.go:76` — Define a constant instead of duplicating this literal "my-client" 3 times.
+- `authentik_test.go:101` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times.
+- `authentik_test.go:147` — Define a constant instead of duplicating this literal "/v1/check" 6 times.
+- `authentik_test.go:148` — Define a constant instead of duplicating this literal "X-authentik-username" 7 times.
+- `authentik_test.go:149` — Define a constant instead of duplicating this literal "bob@example.com" 3 times.
+- `authentik_test.go:149` — Define a constant instead of duplicating this literal "X-authentik-email" 4 times.
+- `authentik_test.go:150` — Define a constant instead of duplicating this literal "Bob Jones" 3 times.
+- `authentik_test.go:151` — Define a constant instead of duplicating this literal "uid-456" 3 times.
+- `authentik_test.go:152` — Define a constant instead of duplicating this literal "jwt.tok.en" 3 times.
+- `authentik_test.go:153` — Define a constant instead of duplicating this literal "X-authentik-groups" 4 times.
+- `authentik_test.go:158` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times.
+- `authentik_test.go:359` — Define a constant instead of duplicating this literal "carol@example.com" 3 times.
+- `authentik_test.go:420` — Define a constant instead of duplicating this literal "/v1/protected/data" 3 times.
+- `authz_test.go:67` — Define a constant instead of duplicating this literal "/stub/*" 5 times.
+- `authz_test.go:75` — Define a constant instead of duplicating this literal "/stub/ping" 6 times.
+- `bridge.go:389` — Define a constant instead of duplicating this literal "ToolBridge.Validate" 3 times.
+- `bridge.go:420` — Define a constant instead of duplicating this literal "ToolBridge.ValidateResponse" 4 times.
+- `bridge.go:467` — Define a constant instead of duplicating this literal "ToolBridge.ValidateSchema" 18 times.
+- `bridge_test.go:24` — Define a constant instead of duplicating this literal "/tools" 32 times.
+- `bridge_test.go:45` — Define a constant instead of duplicating this literal "/tools/file_read" 8 times.
+- `bridge_test.go:53` — Define a constant instead of duplicating this literal "unmarshal error: %v" 23 times.
+- `bridge_test.go:56` — Define a constant instead of duplicating this literal "expected Data=%q, got %q" 3 times.
+- `bridge_test.go:77` — Define a constant instead of duplicating this literal "/api/v1/tools" 5 times.
+- `bridge_test.go:252` — Define a constant instead of duplicating this literal "Read a file from disk" 12 times.
+- `bridge_test.go:378` — Define a constant instead of duplicating this literal "expected 200, got %d" 8 times.
+- `bridge_test.go:385` — Define a constant instead of duplicating this literal "/tmp/file.txt" 3 times.
+- `bridge_test.go:426` — Define a constant instead of duplicating this literal "expected Success=true" 4 times.
+- `bridge_test.go:469` — Define a constant instead of duplicating this literal "expected Success=false" 11 times.
+- `bridge_test.go:493` — Define a constant instead of duplicating this literal "should not run" 9 times.
+- `bridge_test.go:504` — Define a constant instead of duplicating this literal "expected 400, got %d" 8 times.
+- `bridge_test.go:515` — Define a constant instead of duplicating this literal "expected invalid_request_body error, got %#v" 13 times.
+- `bridge_test.go:646` — Define a constant instead of duplicating this literal "Publish an item" 3 times.
+- `bridge_test.go:666` — Define a constant instead of duplicating this literal "/tools/publish_item" 3 times.
+- `bridge_test.go:738` — Define a constant instead of duplicating this literal "^[A-Z]+$" 3 times.
+- `bridge_test.go:1015` — Define a constant instead of duplicating this literal "/v1/tools" 4 times.
+- `bridge_test.go:1135` — Define a constant instead of duplicating this literal "Validate array input" 3 times.
+- `bridge_test.go:1154` — Define a constant instead of duplicating this literal "/tools/tags" 3 times.
+- `bridge_test.go:1259` — Define a constant instead of duplicating this literal "Validate numeric input" 3 times.
+- `bridge_test.go:1277` — Define a constant instead of duplicating this literal "/tools/score" 3 times.
+- `brotli.go:59` — Define a constant instead of duplicating this literal "Content-Encoding" 3 times.
+- `brotli.go:75` — Define a constant instead of duplicating this literal "Content-Length" 3 times.
+- `brotli_test.go:24` — Define a constant instead of duplicating this literal "/stub/ping" 5 times.
+- `brotli_test.go:25` — Define a constant instead of duplicating this literal "Accept-Encoding" 4 times.
+- `brotli_test.go:29` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times.
+- `brotli_test.go:32` — Define a constant instead of duplicating this literal "Content-Encoding" 5 times.
+- `cache.go:240` — Define a constant instead of duplicating this literal "X-Request-ID" 3 times.
+- `cache_control_test.go:27` — Define a constant instead of duplicating this literal "/items/{id}" 4 times.
+- `cache_control_test.go:28` — Define a constant instead of duplicating this literal "public, max-age=60" 9 times.
+- `cache_control_test.go:39` — Define a constant instead of duplicating this literal "GET /v1/items/:id" 5 times.
+- `cache_control_test.go:123` — Define a constant instead of duplicating this literal "/v1/items/:id" 4 times.
+- `cache_control_test.go:128` — Define a constant instead of duplicating this literal "/v1/items/123" 4 times.
+- `cache_control_test.go:131` — Define a constant instead of duplicating this literal "Cache-Control" 6 times.
+- `cache_test.go:27` — Define a constant instead of duplicating this literal "/cache" 3 times.
+- `cache_test.go:72` — Define a constant instead of duplicating this literal "/cache/counter" 17 times.
+- `cache_test.go:76` — Define a constant instead of duplicating this literal "expected 200, got %d" 11 times.
+- `cache_test.go:80` — Define a constant instead of duplicating this literal "call-1" 12 times.
+- `cache_test.go:81` — Define a constant instead of duplicating this literal "expected body to contain %q, got %q" 5 times.
+- `cache_test.go:98` — Define a constant instead of duplicating this literal "X-Cache" 6 times.
+- `cache_test.go:100` — Define a constant instead of duplicating this literal "expected X-Cache=HIT, got %q" 3 times.
+- `cache_test.go:158` — Define a constant instead of duplicating this literal "unmarshal error: %v" 5 times.
+- `cache_test.go:179` — Define a constant instead of duplicating this literal "expected counter=2, got %d" 3 times.
+- `cache_test.go:207` — Define a constant instead of duplicating this literal "other-2" 4 times.
+- `cache_test.go:252` — Define a constant instead of duplicating this literal "X-Request-ID" 8 times.
+- `cache_test.go:277` — Define a constant instead of duplicating this literal "first-request-id" 6 times.
+- `cache_test.go:288` — Define a constant instead of duplicating this literal "second-request-id" 10 times.
+- `chat_completions.go:348` — Define a constant instead of duplicating this literal "models.yaml" 3 times.
+- `chat_completions.go:737` — Define a constant instead of duplicating this literal "chat.completion.chunk" 3 times.
+- `chat_completions.go:751` — Define a constant instead of duplicating this literal "data: %s\n\n" 3 times.
+- `chat_completions_internal_test.go:76` — Define a constant instead of duplicating this literal "unexpected error: %v" 9 times.
+- `chat_completions_internal_test.go:203` — Define a constant instead of duplicating this literal "<|channel>thought planning... " 3 times.
+- `chat_completions_internal_test.go:214` — Define a constant instead of duplicating this literal " planning... " 3 times.
+- `chat_completions_internal_test.go:278` — Define a constant instead of duplicating this literal "Content-Type" 3 times.
+- `chat_completions_internal_test.go:297` — Define a constant instead of duplicating this literal "expected %s, got %s" 3 times.
+- `chat_completions_internal_test.go:380` — Define a constant instead of duplicating this literal "expected %q, got %q" 3 times.
+- `chat_completions_internal_test.go:385` — Define a constant instead of duplicating this literal "hello world" 4 times.
+- `chat_completions_test.go:32` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times.
+- `chat_completions_test.go:35` — Define a constant instead of duplicating this literal "/v1/chat/completions" 4 times.
+- `chat_completions_test.go:39` — Define a constant instead of duplicating this literal "Content-Type" 4 times.
+- `chat_completions_test.go:39` — Define a constant instead of duplicating this literal "application/json" 4 times.
+- `client.go:301` — Define a constant instead of duplicating this literal "OpenAPIClient.Call" 4 times.
+- `client.go:335` — Define a constant instead of duplicating this literal "application/json" 3 times.
+- `client.go:411` — Define a constant instead of duplicating this literal "OpenAPIClient.loadSpec" 4 times.
+- `client.go:505` — Define a constant instead of duplicating this literal "OpenAPIClient.buildURL" 3 times.
+- `client.go:1026` — Define a constant instead of duplicating this literal "OpenAPIClient.validateOpenAPISchema" 3 times.
+- `client.go:1045` — Define a constant instead of duplicating this literal "OpenAPIClient.validateOpenAPIResponse" 3 times.
+- `client_test.go:53` — Define a constant instead of duplicating this literal "/hello" 3 times.
+- `client_test.go:55` — Define a constant instead of duplicating this literal "expected GET, got %s" 5 times.
+- `client_test.go:64` — Define a constant instead of duplicating this literal "Content-Type" 13 times.
+- `client_test.go:64` — Define a constant instead of duplicating this literal "application/json" 13 times.
+- `client_test.go:113` — Define a constant instead of duplicating this literal "unexpected error: %v" 12 times.
+- `client_test.go:123` — Define a constant instead of duplicating this literal "expected map result, got %T" 7 times.
+- `client_test.go:336` — Define a constant instead of duplicating this literal "https://api.example.com" 3 times.
+- `client_test.go:530` — Define a constant instead of duplicating this literal "expected ok=true, got %#v" 3 times.
+- `client_test.go:651` — Define a constant instead of duplicating this literal "expected validation to fail before the HTTP call" 3 times.
+- `cmd/api/cmd_args_test.go:18` — Define a constant instead of duplicating this literal "expected %v, got %v" 4 times.
+- `cmd/api/cmd_args_test.go:26` — Define a constant instead of duplicating this literal "expected nil, got %v" 3 times.
+- `cmd/api/cmd_spec_test.go:145` — Define a constant instead of duplicating this literal "/api/v1/openapi.json" 7 times.
+- `cmd/api/cmd_spec_test.go:147` — Define a constant instead of duplicating this literal "/api/v1/chat/completions" 7 times.
+- `cmd/api/cmd_spec_test.go:180` — Define a constant instead of duplicating this literal "unexpected error: %v" 4 times.
+- `codegen.go:68` — Define a constant instead of duplicating this literal "SDKGenerator.Generate" 11 times.
+- `codegen_test.go:34` — Define a constant instead of duplicating this literal "spec.json" 4 times.
+- `codegen_test.go:80` — Define a constant instead of duplicating this literal "failed to write spec file: %v" 3 times.
+- `export_test.go:24` — Define a constant instead of duplicating this literal "Test API" 8 times.
+- `export_test.go:28` — Define a constant instead of duplicating this literal "unexpected error: %v" 7 times.
+- `export_test.go:33` — Define a constant instead of duplicating this literal "output is not valid JSON: %v" 3 times.
+- `export_test.go:37` — Define a constant instead of duplicating this literal "expected openapi=3.1.0, got %v" 5 times.
+- `expvar_test.go:24` — Define a constant instead of duplicating this literal "unexpected error: %v" 4 times.
+- `expvar_test.go:30` — Define a constant instead of duplicating this literal "/debug/vars" 5 times.
+- `expvar_test.go:32` — Define a constant instead of duplicating this literal "request failed: %v" 4 times.
+- `graphql_test.go:58` — Define a constant instead of duplicating this literal "unexpected error: %v" 8 times.
+- `graphql_test.go:65` — Define a constant instead of duplicating this literal "/graphql" 5 times.
+- `graphql_test.go:65` — Define a constant instead of duplicating this literal "application/json" 7 times.
+- `graphql_test.go:67` — Define a constant instead of duplicating this literal "request failed: %v" 7 times.
+- `graphql_test.go:72` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times.
+- `graphql_test.go:77` — Define a constant instead of duplicating this literal "failed to read body: %v" 4 times.
+- `graphql_test.go:81` — Define a constant instead of duplicating this literal "expected response containing name:test, got %q" 3 times.
+- `graphql_test.go:96` — Define a constant instead of duplicating this literal "/graphql/playground" 4 times.
+- `graphql_test.go:175` — Define a constant instead of duplicating this literal "playground request failed: %v" 4 times.
+- `group_test.go:41` — Define a constant instead of duplicating this literal "expected Name=%q, got %q" 3 times.
+- `group_test.go:126` — Define a constant instead of duplicating this literal "List items" 3 times.
+- `gzip_test.go:25` — Define a constant instead of duplicating this literal "/stub/ping" 5 times.
+- `gzip_test.go:26` — Define a constant instead of duplicating this literal "Accept-Encoding" 4 times.
+- `gzip_test.go:30` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times.
+- `gzip_test.go:33` — Define a constant instead of duplicating this literal "Content-Encoding" 5 times.
+- `httpsign_test.go:53` — Define a constant instead of duplicating this literal "(request-target)" 6 times.
+- `httpsign_test.go:97` — Define a constant instead of duplicating this literal "/stub/ping" 5 times.
+- `i18n_test.go:66` — Define a constant instead of duplicating this literal "/i18n/locale" 5 times.
+- `i18n_test.go:67` — Define a constant instead of duplicating this literal "Accept-Language" 8 times.
+- `i18n_test.go:71` — Define a constant instead of duplicating this literal "expected 200, got %d" 9 times.
+- `i18n_test.go:76` — Define a constant instead of duplicating this literal "unmarshal error: %v" 9 times.
+- `i18n_test.go:79` — Define a constant instead of duplicating this literal "expected locale=%q, got %q" 7 times.
+- `i18n_test.go:215` — Define a constant instead of duplicating this literal "/i18n/greeting" 4 times.
+- `location_test.go:49` — Define a constant instead of duplicating this literal "/loc/info" 5 times.
+- `location_test.go:50` — Define a constant instead of duplicating this literal "X-Forwarded-Host" 3 times.
+- `location_test.go:50` — Define a constant instead of duplicating this literal "api.example.com" 3 times.
+- `location_test.go:54` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times.
+- `location_test.go:59` — Define a constant instead of duplicating this literal "unmarshal error: %v" 5 times.
+- `location_test.go:62` — Define a constant instead of duplicating this literal "expected host=%q, got %q" 3 times.
+- `location_test.go:132` — Define a constant instead of duplicating this literal "proxy.example.com" 3 times.
+- `location_test.go:163` — Define a constant instead of duplicating this literal "secure.example.com" 3 times.
+- `middleware_test.go:25` — Define a constant instead of duplicating this literal "/secret" 3 times.
+- `middleware_test.go:108` — Define a constant instead of duplicating this literal "/v1/secret" 4 times.
+- `middleware_test.go:117` — Define a constant instead of duplicating this literal "unmarshal error: %v" 7 times.
+- `middleware_test.go:160` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times.
+- `middleware_test.go:178` — Define a constant instead of duplicating this literal "/health" 6 times.
+- `middleware_test.go:230` — Define a constant instead of duplicating this literal "X-Request-ID" 9 times.
+- `middleware_test.go:247` — Define a constant instead of duplicating this literal "client-id-abc" 3 times.
+- `middleware_test.go:266` — Define a constant instead of duplicating this literal "client-id-xyz" 3 times.
+- `middleware_test.go:289` — Define a constant instead of duplicating this literal "client-id-meta" 3 times.
+- `middleware_test.go:301` — Define a constant instead of duplicating this literal "expected Meta to be present" 4 times.
+- `middleware_test.go:304` — Define a constant instead of duplicating this literal "expected request_id=%q, got %q" 4 times.
+- `middleware_test.go:307` — Define a constant instead of duplicating this literal "expected duration to be populated" 4 times.
+- `middleware_test.go:325` — Define a constant instead of duplicating this literal "client-id-auto-meta" 5 times.
+- `middleware_test.go:364` — Define a constant instead of duplicating this literal "client-id-auto-error-meta" 3 times.
+- `middleware_test.go:400` — Define a constant instead of duplicating this literal "client-id-plus-json-meta" 3 times.
+- `middleware_test.go:436` — Define a constant instead of duplicating this literal "Access-Control-Request-Method" 3 times.
+- `middleware_test.go:444` — Define a constant instead of duplicating this literal "Access-Control-Allow-Origin" 3 times.
+- `middleware_test.go:462` — Define a constant instead of duplicating this literal "https://app.example.com" 4 times.
+- `modernization_test.go:25` — Define a constant instead of duplicating this literal "health-extra" 3 times.
+- `modernization_test.go:99` — Define a constant instead of duplicating this literal "https://auth.example.com" 3 times.
+- `modernization_test.go:102` — Define a constant instead of duplicating this literal "/public" 6 times.
+- `openapi.go:302` — Define a constant instead of duplicating this literal "/health" 4 times.
+- `openapi.go:363` — Define a constant instead of duplicating this literal "/debug/pprof" 3 times.
+- `openapi.go:371` — Define a constant instead of duplicating this literal "/debug/vars" 3 times.
+- `openapi.go:466` — Define a constant instead of duplicating this literal "application/json" 56 times.
+- `openapi.go:593` — Define a constant instead of duplicating this literal "Bad request" 3 times.
+- `openapi.go:602` — Define a constant instead of duplicating this literal "Too many requests" 7 times.
+- `openapi.go:611` — Define a constant instead of duplicating this literal "Gateway timeout" 7 times.
+- `openapi.go:620` — Define a constant instead of duplicating this literal "Internal server error" 7 times.
+- `openapi_test.go:154` — Define a constant instead of duplicating this literal "unexpected error: %v" 66 times.
+- `openapi_test.go:159` — Define a constant instead of duplicating this literal "invalid JSON: %v" 66 times.
+- `openapi_test.go:172` — Define a constant instead of duplicating this literal "/health" 7 times.
+- `openapi_test.go:173` — Define a constant instead of duplicating this literal "expected /health path in spec" 3 times.
+- `openapi_test.go:191` — Define a constant instead of duplicating this literal "X-Request-ID" 6 times.
+- `openapi_test.go:194` — Define a constant instead of duplicating this literal "X-RateLimit-Limit" 6 times.
+- `openapi_test.go:197` — Define a constant instead of duplicating this literal "X-RateLimit-Remaining" 6 times.
+- `openapi_test.go:200` — Define a constant instead of duplicating this literal "X-RateLimit-Reset" 6 times.
+- `openapi_test.go:219` — Define a constant instead of duplicating this literal "X-Cache" 3 times.
+- `openapi_test.go:444` — Define a constant instead of duplicating this literal "Test API" 4 times.
+- `openapi_test.go:456` — Define a constant instead of duplicating this literal "https://example.com/terms" 3 times.
+- `openapi_test.go:460` — Define a constant instead of duplicating this literal "API Support" 3 times.
+- `openapi_test.go:463` — Define a constant instead of duplicating this literal "https://example.com/support" 3 times.
+- `openapi_test.go:466` — Define a constant instead of duplicating this literal "support@example.com" 3 times.
+- `openapi_test.go:470` — Define a constant instead of duplicating this literal "EUPL-1.2" 3 times.
+- `openapi_test.go:473` — Define a constant instead of duplicating this literal "https://eupl.eu/1.2/en/" 3 times.
+- `openapi_test.go:477` — Define a constant instead of duplicating this literal "Developer guide" 3 times.
+- `openapi_test.go:480` — Define a constant instead of duplicating this literal "https://example.com/docs" 3 times.
+- `openapi_test.go:483` — Define a constant instead of duplicating this literal "x-swagger-ui-path" 3 times.
+- `openapi_test.go:587` — Define a constant instead of duplicating this literal "/graphql" 9 times.
+- `openapi_test.go:650` — Define a constant instead of duplicating this literal "application/json" 8 times.
+- `openapi_test.go:669` — Define a constant instead of duplicating this literal "/graphql/playground" 4 times.
+- `openapi_test.go:784` — Define a constant instead of duplicating this literal "x-chat-completions-path" 3 times.
+- `openapi_test.go:784` — Define a constant instead of duplicating this literal "/v1/chat/completions" 5 times.
+- `openapi_test.go:949` — Define a constant instead of duplicating this literal "/v1/openapi.json" 5 times.
+- `openapi_test.go:1053` — Define a constant instead of duplicating this literal "/events" 4 times.
+- `openapi_test.go:1357` — Define a constant instead of duplicating this literal "/api/items" 3 times.
+- `openapi_test.go:1374` — Define a constant instead of duplicating this literal "Create item" 4 times.
+- `openapi_test.go:1471` — Define a constant instead of duplicating this literal "/status" 10 times.
+- `openapi_test.go:1907` — Define a constant instead of duplicating this literal "/public" 3 times.
+- `openapi_test.go:1908` — Define a constant instead of duplicating this literal "Public endpoint" 3 times.
+- `openapi_test.go:1945` — Define a constant instead of duplicating this literal "/api/public" 4 times.
+- `openapi_test.go:2218` — Define a constant instead of duplicating this literal "/api/users/{id}" 4 times.
+- `openapi_test.go:2244` — Define a constant instead of duplicating this literal "/resources/{id}" 3 times.
+- `openapi_test.go:2271` — Define a constant instead of duplicating this literal "/api/resources/{id}" 3 times.
+- `openapi_test.go:2338` — Define a constant instead of duplicating this literal "Example resource" 4 times.
+- `openapi_test.go:2437` — Define a constant instead of duplicating this literal "Content-Disposition" 3 times.
+- `openapi_test.go:2502` — Define a constant instead of duplicating this literal "Get user" 4 times.
+- `openapi_test.go:2831` — Define a constant instead of duplicating this literal "Check status" 4 times.
+- `openapi_test.go:2852` — Define a constant instead of duplicating this literal "expected tags array, got %T" 5 times.
+- `openapi_test.go:3358` — Define a constant instead of duplicating this literal "https://api.example.com" 6 times.
+- `pkg/provider/cache_control_test.go:28` — Define a constant instead of duplicating this literal "Cache-Control" 5 times.
+- `pkg/provider/proxy_internal_test.go:8` — Define a constant instead of duplicating this literal "/api/v1/cool-widget" 4 times.
+- `pkg/provider/proxy_test.go:21` — Define a constant instead of duplicating this literal "cool-widget" 5 times.
+- `pkg/provider/proxy_test.go:22` — Define a constant instead of duplicating this literal "/api/v1/cool-widget" 5 times.
+- `pkg/provider/proxy_test.go:23` — Define a constant instead of duplicating this literal "http://127.0.0.1:9999" 5 times.
+- `pkg/provider/proxy_test.go:69` — Define a constant instead of duplicating this literal "Content-Type" 3 times.
+- `pkg/provider/proxy_test.go:69` — Define a constant instead of duplicating this literal "application/json" 3 times.
+- `pkg/provider/registry_test.go:25` — Define a constant instead of duplicating this literal "stub.event" 6 times.
+- `pkg/provider/registry_test.go:38` — Define a constant instead of duplicating this literal "core-stub-panel" 4 times.
+- `pkg/provider/registry_test.go:53` — Define a constant instead of duplicating this literal "/api/full" 3 times.
+- `pkg/provider/registry_test.go:60` — Define a constant instead of duplicating this literal "core-full-panel" 3 times.
+- `pkg/provider/registry_test.go:316` — Define a constant instead of duplicating this literal "/tmp/a.yaml" 3 times.
+- `pkg/stream/stream_group_test.go:22` — Define a constant instead of duplicating this literal "/events" 8 times.
+- `pkg/stream/stream_group_test.go:23` — Define a constant instead of duplicating this literal "text/event-stream" 7 times.
+- `pkg/stream/stream_group_test.go:152` — Define a constant instead of duplicating this literal "/tenant/socket" 3 times.
+- `pprof_test.go:22` — Define a constant instead of duplicating this literal "unexpected error: %v" 4 times.
+- `pprof_test.go:28` — Define a constant instead of duplicating this literal "/debug/pprof/" 3 times.
+- `pprof_test.go:30` — Define a constant instead of duplicating this literal "request failed: %v" 4 times.
+- `ratelimit_internal_test.go:28` — Define a constant instead of duplicating this literal "X-API-Key" 3 times.
+- `ratelimit_internal_test.go:30` — Define a constant instead of duplicating this literal "203.0.113.10:1234" 3 times.
+- `ratelimit_internal_test.go:79` — Define a constant instead of duplicating this literal "X-RateLimit-Remaining" 3 times.
+- `ratelimit_test.go:37` — Define a constant instead of duplicating this literal "/rate/ping" 21 times.
+- `ratelimit_test.go:38` — Define a constant instead of duplicating this literal "203.0.113.10:1234" 4 times.
+- `ratelimit_test.go:43` — Define a constant instead of duplicating this literal "X-RateLimit-Limit" 3 times.
+- `ratelimit_test.go:130` — Define a constant instead of duplicating this literal "203.0.113.20:1234" 3 times.
+- `ratelimit_test.go:131` — Define a constant instead of duplicating this literal "X-API-Key" 5 times.
+- `ratelimit_test.go:165` — Define a constant instead of duplicating this literal "203.0.113.30:1234" 3 times.
+- `ratelimit_test.go:166` — Define a constant instead of duplicating this literal "Bearer token-a" 3 times.
+- `ratelimit_test.go:195` — Define a constant instead of duplicating this literal "X-Principal" 3 times.
+- `ratelimit_test.go:233` — Define a constant instead of duplicating this literal "X-User-ID" 4 times.
+- `ratelimit_test.go:246` — Define a constant instead of duplicating this literal "203.0.113.42:1234" 3 times.
+- `response_meta_test.go:91` — Define a constant instead of duplicating this literal "X-Preexisting" 4 times.
+- `response_meta_test.go:100` — Define a constant instead of duplicating this literal "application/json" 3 times.
+- `response_test.go:32` — Define a constant instead of duplicating this literal "expected Success=true" 3 times.
+- `response_test.go:63` — Define a constant instead of duplicating this literal "marshal error: %v" 4 times.
+- `response_test.go:68` — Define a constant instead of duplicating this literal "unmarshal error: %v" 7 times.
+- `response_test.go:88` — Define a constant instead of duplicating this literal "resource not found" 3 times.
+- `response_test.go:226` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times.
+- `response_test.go:236` — Define a constant instead of duplicating this literal "/v1/meta" 3 times.
+- `response_test.go:237` — Define a constant instead of duplicating this literal "client-id-meta" 6 times.
+- `response_test.go:241` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times.
+- `secure_test.go:24` — Define a constant instead of duplicating this literal "/health" 7 times.
+- `secure_test.go:28` — Define a constant instead of duplicating this literal "expected 200, got %d" 4 times.
+- `secure_test.go:52` — Define a constant instead of duplicating this literal "X-Frame-Options" 4 times.
+- `secure_test.go:83` — Define a constant instead of duplicating this literal "strict-origin-when-cross-origin" 3 times.
+- `servers_test.go:11` — Define a constant instead of duplicating this literal "https://api.example.com" 5 times.
+- `sessions_test.go:42` — Define a constant instead of duplicating this literal "test-secret-key!" 4 times.
+- `sessions_test.go:47` — Define a constant instead of duplicating this literal "/sess/set" 4 times.
+- `sessions_test.go:51` — Define a constant instead of duplicating this literal "expected 200, got %d" 4 times.
+- `slog_test.go:30` — Define a constant instead of duplicating this literal "/stub/ping" 3 times.
+- `slog_test.go:34` — Define a constant instead of duplicating this literal "expected 200, got %d" 4 times.
+- `slog_test.go:58` — Define a constant instead of duplicating this literal "/health" 3 times.
+- `spec_builder_helper_test.go:22` — Define a constant instead of duplicating this literal "Engine API" 11 times.
+- `spec_builder_helper_test.go:22` — Define a constant instead of duplicating this literal "Engine metadata" 11 times.
+- `spec_builder_helper_test.go:23` — Define a constant instead of duplicating this literal "Engine overview" 6 times.
+- `spec_builder_helper_test.go:25` — Define a constant instead of duplicating this literal "https://example.com/terms" 6 times.
+- `spec_builder_helper_test.go:26` — Define a constant instead of duplicating this literal "support@example.com" 3 times.
+- `spec_builder_helper_test.go:26` — Define a constant instead of duplicating this literal "API Support" 5 times.
+- `spec_builder_helper_test.go:26` — Define a constant instead of duplicating this literal "https://example.com/support" 3 times.
+- `spec_builder_helper_test.go:27` — Define a constant instead of duplicating this literal "https://api.example.com" 7 times.
+- `spec_builder_helper_test.go:28` — Define a constant instead of duplicating this literal "https://eupl.eu/1.2/en/" 3 times.
+- `spec_builder_helper_test.go:28` — Define a constant instead of duplicating this literal "EUPL-1.2" 5 times.
+- `spec_builder_helper_test.go:33` — Define a constant instead of duplicating this literal "X-API-Key" 6 times.
+- `spec_builder_helper_test.go:36` — Define a constant instead of duplicating this literal "Developer guide" 3 times.
+- `spec_builder_helper_test.go:36` — Define a constant instead of duplicating this literal "https://example.com/docs" 5 times.
+- `spec_builder_helper_test.go:43` — Define a constant instead of duplicating this literal "https://auth.example.com" 3 times.
+- `spec_builder_helper_test.go:44` — Define a constant instead of duplicating this literal "core-client" 3 times.
+- `spec_builder_helper_test.go:46` — Define a constant instead of duplicating this literal "/public" 4 times.
+- `spec_builder_helper_test.go:48` — Define a constant instead of duplicating this literal "/socket" 7 times.
+- `spec_builder_helper_test.go:52` — Define a constant instead of duplicating this literal "/events" 7 times.
+- `spec_builder_helper_test.go:57` — Define a constant instead of duplicating this literal "unexpected error: %v" 27 times.
+- `spec_builder_helper_test.go:68` — Define a constant instead of duplicating this literal "invalid JSON: %v" 8 times.
+- `spec_builder_helper_test.go:88` — Define a constant instead of duplicating this literal "x-swagger-ui-path" 3 times.
+- `spec_builder_helper_test.go:567` — Define a constant instead of duplicating this literal "/api/v1/openapi.json" 3 times.
+- `spec_registry_test.go:31` — Define a constant instead of duplicating this literal "/alpha" 11 times.
+- `sse_test.go:29` — Define a constant instead of duplicating this literal "unexpected error: %v" 12 times.
+- `sse_test.go:35` — Define a constant instead of duplicating this literal "/events" 11 times.
+- `sse_test.go:37` — Define a constant instead of duplicating this literal "request failed: %v" 11 times.
+- `sse_test.go:42` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times.
+- `sse_test.go:45` — Define a constant instead of duplicating this literal "Content-Type" 5 times.
+- `sse_test.go:46` — Define a constant instead of duplicating this literal "text/event-stream" 5 times.
+- `sse_test.go:47` — Define a constant instead of duplicating this literal "expected Content-Type starting with text/event-stream, got %q" 5 times.
+- `sse_test.go:63` — Define a constant instead of duplicating this literal "/v1/events" 3 times.
+- `sse_test.go:208` — Define a constant instead of duplicating this literal "event: " 4 times.
+- `static_test.go:23` — Define a constant instead of duplicating this literal "hello world" 3 times.
+- `static_test.go:24` — Define a constant instead of duplicating this literal "failed to write test file: %v" 4 times.
+- `static_test.go:65` — Define a constant instead of duplicating this literal "
Welcome
" 3 times.
+- `static_test.go:125` — Define a constant instead of duplicating this literal "sdk-data" 3 times.
+- `static_test.go:130` — Define a constant instead of duplicating this literal "body{}" 3 times.
+- `sunset_test.go:20` — Define a constant instead of duplicating this literal "/status" 3 times.
+- `sunset_test.go:31` — Define a constant instead of duplicating this literal "; rel=\"help\"" 3 times.
+- `sunset_test.go:44` — Define a constant instead of duplicating this literal "X-API-Warn" 3 times.
+- `sunset_test.go:53` — Define a constant instead of duplicating this literal "/api/v2/status" 4 times.
+- `sunset_test.go:53` — Define a constant instead of duplicating this literal "2025-06-01" 3 times.
+- `sunset_test.go:55` — Define a constant instead of duplicating this literal "unexpected error: %v" 3 times.
+- `sunset_test.go:64` — Define a constant instead of duplicating this literal "expected 200, got %d" 3 times.
+- `sunset_test.go:75` — Define a constant instead of duplicating this literal "API-Suggested-Replacement" 8 times.
+- `sunset_test.go:94` — Define a constant instead of duplicating this literal "Thu, 30 Apr 2026 23:59:59 GMT" 3 times.
+- `sunset_test.go:105` — Define a constant instead of duplicating this literal "POST /api/v2/billing/invoices" 4 times.
+- `sunset_test.go:109` — Define a constant instead of duplicating this literal "/billing" 12 times.
+- `sunset_test.go:118` — Define a constant instead of duplicating this literal "; rel=\"successor-version\"" 3 times.
+- `sunset_test.go:131` — Define a constant instead of duplicating this literal "2026-04-30" 5 times.
+- `swagger_test.go:23` — Define a constant instead of duplicating this literal "Test API" 13 times.
+- `swagger_test.go:23` — Define a constant instead of duplicating this literal "A test API service" 8 times.
+- `swagger_test.go:25` — Define a constant instead of duplicating this literal "unexpected error: %v" 23 times.
+- `swagger_test.go:33` — Define a constant instead of duplicating this literal "/swagger/doc.json" 16 times.
+- `swagger_test.go:35` — Define a constant instead of duplicating this literal "request failed: %v" 24 times.
+- `swagger_test.go:40` — Define a constant instead of duplicating this literal "expected 200, got %d" 5 times.
+- `swagger_test.go:45` — Define a constant instead of duplicating this literal "failed to read body: %v" 18 times.
+- `swagger_test.go:267` — Define a constant instead of duplicating this literal "invalid JSON: %v" 16 times.
+- `swagger_test.go:293` — Define a constant instead of duplicating this literal "/api/tools" 3 times.
+- `swagger_test.go:296` — Define a constant instead of duplicating this literal "Query metrics data" 3 times.
+- `swagger_test.go:535` — Define a constant instead of duplicating this literal "https://eupl.eu/1.2/en/" 5 times.
+- `swagger_test.go:535` — Define a constant instead of duplicating this literal "EUPL-1.2" 5 times.
+- `swagger_test.go:578` — Define a constant instead of duplicating this literal "support@example.com" 5 times.
+- `swagger_test.go:578` — Define a constant instead of duplicating this literal "https://example.com/support" 5 times.
+- `swagger_test.go:578` — Define a constant instead of duplicating this literal "API Support" 5 times.
+- `swagger_test.go:624` — Define a constant instead of duplicating this literal "https://example.com/terms" 5 times.
+- `swagger_test.go:660` — Define a constant instead of duplicating this literal "https://example.com/docs" 5 times.
+- `swagger_test.go:660` — Define a constant instead of duplicating this literal "Developer guide" 5 times.
+- `swagger_test.go:781` — Define a constant instead of duplicating this literal "https://api.example.com" 6 times.
+- `swagger_test.go:950` — Define a constant instead of duplicating this literal "/v1/openapi.json" 5 times.
+- `timeout_test.go:50` — Define a constant instead of duplicating this literal "/stub/ping" 3 times.
+- `timeout_test.go:59` — Define a constant instead of duplicating this literal "unmarshal error: %v" 4 times.
+- `timeout_test.go:65` — Define a constant instead of duplicating this literal "expected Data=%q, got %q" 3 times.
+- `tracing_test.go:86` — Define a constant instead of duplicating this literal "/trace" 3 times.
+- `tracing_test.go:123` — Define a constant instead of duplicating this literal "test-service" 4 times.
+- `tracing_test.go:128` — Define a constant instead of duplicating this literal "/stub/ping" 5 times.
+- `tracing_test.go:132` — Define a constant instead of duplicating this literal "expected 200, got %d" 8 times.
+- `tracing_test.go:167` — Define a constant instead of duplicating this literal "expected at least one span" 5 times.
+- `tracing_test.go:329` — Define a constant instead of duplicating this literal "tracing-test" 3 times.
+- `transport_client_test.go:50` — Define a constant instead of duplicating this literal "Bearer secret" 8 times.
+- `transport_client_test.go:103` — Define a constant instead of duplicating this literal "ws://example.invalid/ws" 4 times.
+- `transport_client_test.go:194` — Define a constant instead of duplicating this literal "http://example.invalid/events" 3 times.
+- `transport_client_test.go:204` — Define a constant instead of duplicating this literal "X-Request-ID" 5 times.
+- `transport_client_test.go:231` — Define a constant instead of duplicating this literal "Content-Type" 3 times.
+- `transport_client_test.go:231` — Define a constant instead of duplicating this literal "text/event-stream" 4 times.
+- `webhook_test.go:404` — Define a constant instead of duplicating this literal "https://hooks.example.test/inbox" 4 times.
+- `websocket_test.go:34` — Define a constant instead of duplicating this literal "wsstub.updates" 3 times.
+- `websocket_test.go:34` — Define a constant instead of duplicating this literal "wsstub.events" 3 times.
+- `websocket_test.go:49` — Define a constant instead of duplicating this literal "upgrade error: %v" 6 times.
+- `websocket_test.go:58` — Define a constant instead of duplicating this literal "unexpected error: %v" 8 times.
+- `websocket_test.go:68` — Define a constant instead of duplicating this literal "failed to dial WebSocket: %v" 3 times.
+- `websocket_test.go:74` — Define a constant instead of duplicating this literal "failed to read message: %v" 5 times.
+- `websocket_test.go:77` — Define a constant instead of duplicating this literal "expected message=%q, got %q" 6 times.
+- `websocket_test.go:263` — Define a constant instead of duplicating this literal "gin-hello" 3 times.
+
+### php:S1192 — String literals should not be duplicated (58×, code smell)
+
+- `src/php/src/Api/Boot.php:206` — Define a constant instead of duplicating this literal "/Routes/api.php" 4 times.
+- `src/php/src/Api/Boot.php:283` — Define a constant instead of duplicating this literal "/authorize" 3 times.
+- `src/php/src/Api/Controllers/Api/WebhookSecretController.php:85` — Define a constant instead of duplicating this literal "Webhook endpoint" 4 times.
+- `src/php/src/Api/Controllers/McpApiController.php:109` — Define a constant instead of duplicating this literal "The selected server id is invalid." 7 times.
+- `src/php/src/Api/Controllers/McpApiController.php:346` — Define a constant instead of duplicating this literal "The selected tool name is invalid." 5 times.
+- `src/php/src/Api/Database/Factories/ApiKeyFactory.php:52` — Define a constant instead of duplicating this literal " API Key" 3 times.
+- `src/php/src/Api/Documentation/DocumentationServiceProvider.php:26` — Define a constant instead of duplicating this literal "/config.php" 5 times.
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:456` — Define a constant instead of duplicating this literal "Bio Links" 4 times.
+- `src/php/src/Api/Models/WebhookEndpoint.php:227` — Define a constant instead of duplicating this literal "The webhook URL must resolve to a public IP address." 3 times.
+- `src/php/src/Api/Routes/api.php:134` — Define a constant instead of duplicating this literal "/{workspace}" 4 times.
+- `src/php/src/Api/Routes/api.php:161` — Define a constant instead of duplicating this literal "/{id}" 12 times.
+- `src/php/src/Api/Services/SeoReportService.php:511` — Define a constant instead of duplicating this literal "The supplied URL could not be resolved to any address." 4 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:31` — Define a constant instead of duplicating this literal "192.168.1.1" 19 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:35` — Define a constant instead of duplicating this literal "10.0.0.1" 13 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:43` — Define a constant instead of duplicating this literal "192.168.1.0/24" 11 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:67` — Define a constant instead of duplicating this literal "10.0.0.0/8" 4 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:89` — Define a constant instead of duplicating this literal "2001:db8::1" 9 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php:112` — Define a constant instead of duplicating this literal "2001:db8::/32" 4 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyTest.php:386` — Define a constant instead of duplicating this literal "Active Key" 3 times.
+- `src/php/src/Api/Tests/Feature/ApiKeyTest.php:719` — Define a constant instead of duplicating this literal "/api/mcp/servers" 3 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:44` — Define a constant instead of duplicating this literal "Read Only Key" 5 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:64` — Define a constant instead of duplicating this literal "/api/test-scope/write" 4 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:81` — Define a constant instead of duplicating this literal "/api/test-scope/delete" 6 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:100` — Define a constant instead of duplicating this literal "Read/Write Key" 4 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:243` — Define a constant instead of duplicating this literal "Posts Admin Key" 3 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:244` — Define a constant instead of duplicating this literal "posts:*" 7 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:303` — Define a constant instead of duplicating this literal "*:read" 5 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:524` — Define a constant instead of duplicating this literal "/test-explicit/posts" 3 times.
+- `src/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php:541` — Define a constant instead of duplicating this literal "/api/test-explicit/posts" 8 times.
+- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:37` — Define a constant instead of duplicating this literal "/api/v1/workspaces" 4 times.
+- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:83` — Define a constant instead of duplicating this literal "/api/v1/test" 8 times.
+- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:192` — Define a constant instead of duplicating this literal "/api/v1/old" 3 times.
+- `src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php:46` — Define a constant instead of duplicating this literal "/api/test-auth/scoped" 4 times.
+- `src/php/src/Api/Tests/Feature/DocumentationControllerTest.php:102` — Define a constant instead of duplicating this literal "/api/docs" 3 times.
+- `src/php/src/Api/Tests/Feature/McpResourceTest.php:78` — Define a constant instead of duplicating this literal "test-resource-server://documents/welcome" 4 times.
+- `src/php/src/Api/Tests/Feature/McpServerAccessTest.php:51` — Define a constant instead of duplicating this literal "/allowed-server.yaml" 6 times.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:108` — Define a constant instead of duplicating this literal "/test-scan/items/{id}" 4 times.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:118` — Define a constant instead of duplicating this literal "api/*" 18 times.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:674` — Define a constant instead of duplicating this literal "Custom Tag" 3 times.
+- `src/php/src/Api/Tests/Feature/PixelEndpointTest.php:16` — Define a constant instead of duplicating this literal "/api/pixel/abc12345" 3 times.
+- `src/php/src/Api/Tests/Feature/PixelEndpointTest.php:17` — Define a constant instead of duplicating this literal "https://example.com" 6 times.
+- `src/php/src/Api/Tests/Feature/PublicApiCorsTest.php:48` — Define a constant instead of duplicating this literal "https://example.com" 5 times.
+- `src/php/src/Api/Tests/Feature/RateLimitingTest.php:706` — Define a constant instead of duplicating this literal "127.0.0.1" 3 times.
+- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:45` — Define a constant instead of duplicating this literal "https://1.1.1.1/article" 5 times.
+- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:75` — Define a constant instead of duplicating this literal "text/html; charset=utf-8" 4 times.
+- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:87` — Define a constant instead of duplicating this literal "Example Product Landing Page" 3 times.
+- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:88` — Define a constant instead of duplicating this literal "A concise example description for the landing page." 3 times.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:69` — Define a constant instead of duplicating this literal "{"event":"test"}" 13 times.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:331` — Define a constant instead of duplicating this literal "https://1.1.1.1/webhook" 16 times.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:460` — Define a constant instead of duplicating this literal "https://example.com/webhook" 13 times.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:535` — Define a constant instead of duplicating this literal "Server Error" 3 times.
+- `src/php/src/Website/Api/Services/OpenApiGenerator.php:88` — Define a constant instead of duplicating this literal "Chat Widget" 3 times.
+- `src/php/tests/Feature/ApiSunsetTest.php:14` — Define a constant instead of duplicating this literal "/legacy-endpoint" 10 times.
+- `src/php/tests/Feature/ApiSunsetTest.php:44` — Define a constant instead of duplicating this literal "2025-06-01" 7 times.
+- `src/php/tests/Feature/ApiSunsetTest.php:46` — Define a constant instead of duplicating this literal "; rel="successor-version"" 4 times.
+- `src/php/tests/Feature/ApiSunsetTest.php:57` — Define a constant instead of duplicating this literal "/api/v2/users" 9 times.
+- `src/php/tests/Feature/ApiVersionServiceTest.php:47` — Define a constant instead of duplicating this literal "/api/users" 6 times.
+- `src/php/tests/Feature/AuthenticationGuideTest.php:21` — Define a constant instead of duplicating this literal "API keys are prefixed with" 3 times.
+
+### go:S3776 — Cognitive Complexity of functions should not be too high (39×, code smell)
+
+- `api.go:253` — Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.
+- `authentik.go:171` — Refactor this method to reduce its Cognitive Complexity from 38 to the 15 allowed.
+- `authentik_integration_test.go:89` — Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed.
+- `bridge.go:451` — Refactor this method to reduce its Cognitive Complexity from 97 to the 15 allowed.
+- `bridge.go:566` — Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed.
+- `cache.go:90` — Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.
+- `cache.go:191` — Refactor this method to reduce its Cognitive Complexity from 41 to the 15 allowed.
+- `chat_completions.go:375` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `chat_completions.go:716` — Refactor this method to reduce its Cognitive Complexity from 33 to the 15 allowed.
+- `client.go:181` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `client.go:291` — Refactor this method to reduce its Cognitive Complexity from 37 to the 15 allowed.
+- `client.go:398` — Refactor this method to reduce its Cognitive Complexity from 37 to the 15 allowed.
+- `client.go:502` — Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed.
+- `client.go:570` — Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.
+- `client.go:775` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `client_test.go:749` — Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
+- `cmd/api/cmd_sdk.go:31` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `i18n.go:159` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.
+- `openapi.go:85` — Refactor this method to reduce its Cognitive Complexity from 49 to the 15 allowed.
+- `openapi.go:297` — Refactor this method to reduce its Cognitive Complexity from 88 to the 15 allowed.
+- `openapi.go:943` — Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed.
+- `openapi.go:1983` — Refactor this method to reduce its Cognitive Complexity from 32 to the 15 allowed.
+- `openapi.go:2214` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `openapi.go:2750` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `openapi_test.go:145` — Refactor this method to reduce its Cognitive Complexity from 28 to the 15 allowed.
+- `openapi_test.go:582` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `openapi_test.go:1722` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.
+- `openapi_test.go:2075` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `openapi_test.go:2914` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `pkg/provider/registry.go:213` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `pkg/stream/stream_group_test.go:168` — Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `ratelimit.go:63` — Refactor this method to reduce its Cognitive Complexity from 37 to the 15 allowed.
+- `runtime_config_test.go:15` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `spec_builder_helper.go:238` — Refactor this method to reduce its Cognitive Complexity from 26 to the 15 allowed.
+- `spec_builder_helper_test.go:17` — Refactor this method to reduce its Cognitive Complexity from 57 to the 15 allowed.
+- `spec_builder_helper_test.go:247` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.
+- `spec_builder_helper_test.go:347` — Refactor this method to reduce its Cognitive Complexity from 21 to the 15 allowed.
+- `sse.go:149` — Refactor this method to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `transport_client.go:264` — Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.
+
+### go:S1186 — Functions should not be empty (31×, code smell)
+
+- `api_describable_test.go:23` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `api_renderable_test.go:23` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge.go:804` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:132` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:198` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:266` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:277` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:334` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:967` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:968` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:969` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:1026` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `bridge_test.go:1031` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `cache_control_test.go:19` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `cmd/api/cmd_sdk_test.go:166` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `cmd/api/cmd_spec_test.go:21` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `cmd/api/spec_groups_iter.go:51` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `openapi_test.go:28` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `openapi_test.go:36` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `openapi_test.go:46` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `openapi_test.go:66` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `openapi_test.go:81` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `openapi_test.go:102` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `openapi_test.go:140` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `pkg/provider/registry_test.go:21` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `pkg/stream/stream_group_test.go:83` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `spec_builder_helper_test.go:49` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `spec_builder_helper_test.go:436` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `spec_registry_test.go:21` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `swagger_internal_test.go:20` — Add a nested comment explaining why this function is empty or complete the implementation.
+- `tracing_test.go:111` — Add a nested comment explaining why this function is empty or complete the implementation.
+
+### php:S1186 — Methods should not be empty (17×, code smell)
+
+- `src/php/src/Api/Tests/Feature/McpApiControllerTest.php:167` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/McpApiControllerTest.php:176` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:170` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:180` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:189` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1197` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1202` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1206` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1211` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1215` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1226` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1234` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1242` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1244` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1253` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1264` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+- `src/php/src/Api/Tests/Feature/RateLimitTest.php:257` — Add a nested comment explaining why this method is empty, throw an Exception or complete the implementation.
+
+### php:S3776 — Cognitive Complexity of functions should not be too high (17×, code smell)
+
+- `src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php:125` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:411` — Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:598` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:754` — Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:944` — Refactor this function to reduce its Cognitive Complexity from 89 to the 15 allowed.
+- `src/php/src/Api/Documentation/Extensions/SunsetExtension.php:76` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `src/php/src/Api/Documentation/Extensions/SunsetExtension.php:139` — Refactor this function to reduce its Cognitive Complexity from 19 to the 15 allowed.
+- `src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php:22` — Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.
+- `src/php/src/Api/Middleware/AuthenticateApiKey.php:120` — Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.
+- `src/php/src/Api/Models/ApiKey.php:162` — Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.
+- `src/php/src/Api/Models/WebhookEndpoint.php:178` — Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.
+- `src/php/src/Api/Services/SeoReportService.php:456` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `src/php/src/Api/Services/SeoReportService.php:536` — Refactor this function to reduce its Cognitive Complexity from 17 to the 15 allowed.
+- `src/php/src/Api/Services/WebhookSecretRotationService.php:274` — Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.
+- `src/php/src/Api/Tests/Feature/RateLimitingTest.php:459` — Refactor this function to reduce its Cognitive Complexity from 24 to the 15 allowed.
+- `src/php/src/Front/Api/Middleware/ApiVersion.php:75` — Refactor this function to reduce its Cognitive Complexity from 22 to the 15 allowed.
+- `src/php/src/Front/Api/VersionedRoutes.php:252` — Refactor this function to reduce its Cognitive Complexity from 20 to the 15 allowed.
+
+## MAJOR
+
+### php:S1142 — Functions should not contain too many return statements (62×, code smell)
+
+- `src/php/src/Api/Concerns/ResolvesWorkspace.php:27` — This method has 6 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Console/Commands/CheckApiUsageAlerts.php:259` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/Api/ApiKeyController.php:59` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/Api/PaymentMethodController.php:84` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/Api/WebhookTemplateController.php:133` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/Api/WebhookTemplateController.php:190` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/Api/WorkspaceMemberController.php:92` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:105` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:154` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:221` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:373` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:411` — This method has 8 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:666` — This method has 6 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:711` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:754` — This method has 9 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:1308` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:1362` — This method has 6 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:1429` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:1498` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Controllers/McpApiController.php:1520` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/Extensions/RateLimitExtension.php:152` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/Extensions/RateLimitExtension.php:188` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/Extensions/RateLimitExtension.php:234` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/Extensions/VersionExtension.php:98` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/Middleware/ProtectDocumentation.php:22` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:356` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:492` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:749` — This method has 8 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:959` — This method has 6 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:1103` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/ApiCacheControl.php:23` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/AuthenticateApiKey.php:31` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/AuthenticateApiKey.php:77` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/AuthenticateApiKey.php:120` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/AuthenticateApiKey.php:181` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/RateLimitApi.php:82` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/RateLimitApi.php:134` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/RateLimitApi.php:192` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/RateLimitApi.php:313` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Middleware/RateLimitApi.php:345` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Models/ApiKey.php:162` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Models/ApiKey.php:331` — This method has 6 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Models/WebhookDelivery.php:203` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Models/WebhookEndpoint.php:296` — This method has 7 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Models/WebhookEndpoint.php:424` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/RateLimit/RateLimitService.php:208` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/RateLimit/RateLimitService.php:264` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/RateLimit/RateLimitService.php:342` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/IpRestrictionService.php:26` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/IpRestrictionService.php:66` — This method has 6 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/IpRestrictionService.php:128` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/IpRestrictionService.php:208` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/IpRestrictionService.php:234` — This method has 6 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/SeoReportService.php:591` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/WebhookSecretRotationService.php:85` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/WebhookSecretRotationService.php:274` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/WebhookTemplateService.php:214` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/WebhookTemplateService.php:547` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Services/WebhookTemplateService.php:568` — This method has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Api/Tests/Feature/WebhookEndpointTest.php:6` — This function has 5 returns, which is more than the 3 allowed.
+- `src/php/src/Front/Api/Middleware/ApiSunset.php:105` — This method has 4 returns, which is more than the 3 allowed.
+- `src/php/src/Website/Api/Services/OpenApiGenerator.php:308` — This method has 4 returns, which is more than the 3 allowed.
+
+### php:S112 — Generic exceptions ErrorException, RuntimeException and Exception should not be thrown (33×, code smell)
+
+- `src/php/src/Api/Controllers/McpApiController.php:854` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:863` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:867` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:903` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:914` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:931` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:935` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:960` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:970` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:980` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:1018` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:1028` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:1052` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:1069` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:1096` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Controllers/McpApiController.php:1102` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Models/WebhookDelivery.php:87` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Models/WebhookDelivery.php:184` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/ApiKeyService.php:51` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/ApiKeyService.php:90` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/ApiKeyService.php:97` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/ApiKeyService.php:133` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/SeoReportService.php:43` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/SeoReportService.php:50` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/SeoReportService.php:134` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/SeoReportService.php:141` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/SeoReportService.php:148` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Services/SeoReportService.php:154` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Tests/Feature/ApiKeyTest.php:279` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php:106` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Tests/Feature/McpApiControllerTest.php:164` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Tests/Feature/RateLimitTest.php:281` — Define and throw a dedicated exception instead of using a generic one.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:443` — Define and throw a dedicated exception instead of using a generic one.
+
+### php:S1172 — Unused function parameters should be removed (29×, code smell)
+
+- `src/php/src/Api/Database/Factories/ApiKeyFactory.php:139` — Remove the unused function parameter "$attributes".
+- `src/php/src/Api/Documentation/DocumentationController.php:45` — Remove the unused function parameter "$request".
+- `src/php/src/Api/Documentation/DocumentationController.php:58` — Remove the unused function parameter "$request".
+- `src/php/src/Api/Documentation/DocumentationController.php:71` — Remove the unused function parameter "$request".
+- `src/php/src/Api/Documentation/DocumentationController.php:81` — Remove the unused function parameter "$request".
+- `src/php/src/Api/Documentation/DocumentationController.php:94` — Remove the unused function parameter "$request".
+- `src/php/src/Api/Documentation/DocumentationController.php:105` — Remove the unused function parameter "$request".
+- `src/php/src/Api/Documentation/DocumentationController.php:120` — Remove the unused function parameter "$request".
+- `src/php/src/Api/Documentation/DocumentationServiceProvider.php:40` — Remove the unused function parameter "$app".
+- `src/php/src/Api/Documentation/Examples/CommonExamples.php:121` — Remove the unused function parameter "$status".
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:525` — Remove the unused function parameter "$config".
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:836` — Remove the unused function parameter "$value".
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:907` — Remove the unused function parameter "$route".
+- `src/php/src/Api/Services/WebhookTemplateService.php:547` — Remove the unused function parameter "$arg".
+- `src/php/src/Api/Services/WebhookTemplateService.php:568` — Remove the unused function parameter "$arg".
+- `src/php/src/Api/Services/WebhookTemplateService.php:596` — Remove the unused function parameter "$arg".
+- `src/php/src/Api/Services/WebhookTemplateService.php:601` — Remove the unused function parameter "$arg".
+- `src/php/src/Api/Services/WebhookTemplateService.php:606` — Remove the unused function parameter "$arg".
+- `src/php/src/Api/Services/WebhookTemplateService.php:632` — Remove the unused function parameter "$arg".
+- `src/php/src/Api/Services/WebhookTemplateService.php:637` — Remove the unused function parameter "$arg".
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:72` — Remove the unused function parameter "$serverId".
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:72` — Remove the unused function parameter "$toolName".
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:143` — Remove the unused function parameter "$server".
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:143` — Remove the unused function parameter "$version".
+- `src/php/src/Api/Tests/Feature/McpServerDetailTest.php:143` — Remove the unused function parameter "$tool".
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1204` — Remove the unused function parameter "$id".
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1213` — Remove the unused function parameter "$id".
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1217` — Remove the unused function parameter "$id".
+- `src/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php:1255` — Remove the unused function parameter "$id".
+
+### Web:S5255 — "aria-label" or "aria-labelledby" attributes should be used to differentiate similar elements (12×, code smell)
+
+- `src/php/src/Website/Api/View/Blade/guides/authentication.blade.php:12` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/authentication.blade.php:49` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/errors.blade.php:11` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/errors.blade.php:48` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php:11` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/qrcodes.blade.php:48` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php:12` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/quickstart.blade.php:49` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/rate-limits.blade.php:10` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/rate-limits.blade.php:23` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php:11` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+- `src/php/src/Website/Api/View/Blade/guides/webhooks.blade.php:63` — Add an "aria-label" or "aria-labbelledby" attribute to this element.
+
+### php:S1448 — Classes should not have too many methods (8×, code smell)
+
+- `src/php/src/Api/Controllers/McpApiController.php:27` — Class "McpApiController" has 37 methods, which is greater than 20 authorized. Split it into smaller classes.
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:31` — Class "OpenApiBuilder" has 38 methods, which is greater than 20 authorized. Split it into smaller classes.
+- `src/php/src/Api/Models/ApiKey.php:26` — Class "ApiKey" has 35 methods, which is greater than 20 authorized. Split it into smaller classes.
+- `src/php/src/Api/Models/WebhookEndpoint.php:32` — Class "WebhookEndpoint" has 25 methods, which is greater than 20 authorized. Split it into smaller classes.
+- `src/php/src/Api/Models/WebhookPayloadTemplate.php:41` — Class "WebhookPayloadTemplate" has 24 methods, which is greater than 20 authorized. Split it into smaller classes.
+- `src/php/src/Api/Services/ApiSnippetService.php:12` — Class "ApiSnippetService" has 21 methods, which is greater than 20 authorized. Split it into smaller classes.
+- `src/php/src/Api/Services/WebhookTemplateService.php:22` — Class "WebhookTemplateService" has 28 methods, which is greater than 20 authorized. Split it into smaller classes.
+- `src/php/src/Api/View/Modal/Admin/WebhookTemplateManager.php:20` — Class "WebhookTemplateManager" has 27 methods, which is greater than 20 authorized. Split it into smaller classes.
+
+### php:S3358 — Ternary operators should not be nested (2×, code smell)
+
+- `src/php/src/Api/Models/WebhookEndpoint.php:198` — Extract this nested ternary operation into an independent statement.
+- `src/php/src/Api/Services/SeoReportService.php:473` — Extract this nested ternary operation into an independent statement.
+
+### php:S138 — Functions should not have too many lines of code (2×, code smell)
+
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:51` — This function expression has 158 lines, which is greater than the 150 lines authorized. Split it into smaller functions.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:550` — This function expression has 215 lines, which is greater than the 150 lines authorized. Split it into smaller functions.
+
+### Web:S6853 — Label elements should have a text label and an associated control (2×, code smell)
+
+- `src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php:264` — A form label must be associated with a control and have accessible text.
+- `src/php/src/Api/View/Blade/admin/webhook-template-manager.blade.php:268` — A form label must be associated with a control and have accessible text.
+
+### php:S3011 — Reflection should not be used to increase accessibility of classes, methods, or fields (2×, code smell)
+
+- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:40` — Make sure that this accessibility bypass is safe here.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:36` — Make sure that this accessibility bypass is safe here.
+
+### php:S107 — Functions should not have too many parameters (1×, code smell)
+
+- `src/php/src/Api/Services/ApiUsageService.php:22` — This function has 10 parameters, which is greater than the 7 authorized.
+
+### php:S1066 — Mergeable "if" statements should be combined (1×, code smell)
+
+- `src/php/src/Api/Services/SeoReportService.php:297` — Merge this if statement with the enclosing one.
+
+### go:S107 — Functions should not have too many parameters (1×, code smell)
+
+- `openapi.go:554` — This function has 12 parameters, which is greater than the 7 authorized.
+
+### php:S1068 — Unused "private" fields should be removed (1×, code smell)
+
+- `src/php/src/Api/Services/WebhookSignature.php:55` — Remove this unused "SECRET_LENGTH" private field.
+
+## MINOR
+
+### php:S1481 — Unused local variables should be removed (7×, code smell)
+
+- `src/php/src/Api/Documentation/OpenApiBuilder.php:452` — Remove this unused "$name" local variable.
+- `src/php/src/Api/Tests/Feature/ApiKeyRotationTest.php:218` — Remove this unused "$key1" local variable.
+- `src/php/src/Api/Tests/Feature/ApiUsageTest.php:189` — Remove this unused "$usage" local variable.
+- `src/php/src/Api/Tests/Feature/RateLimitTest.php:606` — Remove this unused "$tier" local variable.
+- `src/php/src/Api/Tests/Feature/RateLimitingTest.php:360` — Remove this unused "$apiKey2" local variable.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:433` — Remove this unused "$endpoint" local variable.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:474` — Remove this unused "$endpoint" local variable.
+
+### php:S100 — Function names should comply with a naming convention (3×, code smell)
+
+- `src/php/src/Api/Tests/Feature/SeoReportServiceTest.php:6` — Rename function "dns_get_record" to match the regular expression ^[a-z][a-zA-Z0-9]*$.
+- `src/php/src/Api/Tests/Feature/WebhookDeliveryTest.php:6` — Rename function "dns_get_record" to match the regular expression ^[a-z][a-zA-Z0-9]*$.
+- `src/php/src/Api/Tests/Feature/WebhookEndpointTest.php:6` — Rename function "dns_get_record" to match the regular expression ^[a-z][a-zA-Z0-9]*$.
+
+### go:S1940 — Boolean checks should not be inverted (2×, code smell)
+
+- `client.go:687` — Use the opposite operator ("!=") instead.
+- `client.go:729` — Use the opposite operator ("!=") instead.
+
+### php:S6353 — Regular expression quantifiers and character classes should be used concisely (2×, code smell)
+
+- `src/php/src/Api/Services/WebhookTemplateService.php:343` — Use concise character class syntax '\w' instead of '[a-zA-Z0-9_]'.
+- `src/php/src/Api/Services/WebhookTemplateService.php:486` — Use concise character class syntax '\w' instead of '[a-zA-Z0-9_]'.
+
+### php:S1488 — Local variables should not be declared and then immediately returned or thrown (1×, code smell)
+
+- `src/php/src/Api/Services/WebhookTemplateService.php:139` — Immediately return this expression instead of assigning it to the temporary variable "$variables".
+
diff --git a/docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md b/docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md
new file mode 100644
index 0000000..7c1c540
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-06-chat-completions-remote-backend.md
@@ -0,0 +1,1482 @@
+# Chat Completions — Remote Backend + Format Adapters Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `/v1/chat/completions` serve remote models (OpenAI-compatible passthrough + per-model Ollama/Anthropic adapters) alongside the existing local in-process path, choosing local-first by model name.
+
+**Architecture:** The chat handler gains an optional remote backend. Local-first: if `ModelResolver.Knows(model)` → existing in-process path; else → remote via the upstream router's `upstreamBalancer`+`upstreamTransport` (weighted RR + failover, reused unchanged). Passthrough is default (verbatim bytes both ways); a per-model `ChatFormatAdapter` maps non-OpenAI formats (Ollama-native, Anthropic) including per-chunk streaming transcoders. Off-loopback access is an opt-in gated by a configured bearer.
+
+**Tech Stack:** Go 1.26, gin, `dappco.re/go` (core), `dappco.re/go/inference`, the existing `chat_completions.go` + `upstream_*.go` (router). Spec: `docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md`.
+
+**Conventions:** SPDX header on every file. UK English in strings. `_Good/_Bad/_Ugly` test suffixes. Run from `core/api/go` with `GOWORK=off go test ./ ...`. Commit `Co-Authored-By: Virgil `.
+
+**Reused symbols (already in package `api`, do NOT redefine):** `UpstreamRegistry`/`NewUpstreamRegistry`/`AllowPrivateUpstreams`/`Upstream`/`.resolve`, `upstreamBalancer`/`newUpstreamBalancer`, `upstreamTransport`, `routerError`, `poolCtxKey`/`keyCtxKey`, `defaultFailoverStatuses`, `defaultUpstreamCooldown`, `maxUpstreamResponseBytes`, `maxToolRequestBodyBytes`. Chat: `ChatCompletionRequest/Response/Chunk/ChatMessage/ChatChoice/ChatUsage/ChatChunkChoice/ChatMessageDelta`, `isLoopbackRequest`, `writeChatCompletionError(c,status,errType,param,message,code)`, `mapResolverError`, `newChatCompletionID`, `decodeJSONBody`, `validateChatRequest`, `defaultChatCompletionsPath`, `chatDefaultMaxTokens`. Engine: `e.bearerConfigured`, `e.chatCompletionsResolver`, `e.chatCompletionsPath`.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|------|----------------|
+| `go/chat_completions.go` (modify) | Add `ModelResolver.Knows`; extend `chatCompletionsHandler` (resolver?+remote?+allowRemote+bearerConfigured), bind guard, local-first dispatch |
+| `go/chat_remote.go` (create) | `chatRemoteConfig`, `dispatchRemote`, response delivery (passthrough/adapter), `*routerError`→OpenAI mapping |
+| `go/chat_adapter.go` (create) | `ChatFormatAdapter`, `ChatStreamTranscoder`, `ChatStreamMeta` interfaces + small shared SSE helpers |
+| `go/chat_adapter_ollama.go` (create) | `OllamaAdapter` |
+| `go/chat_adapter_anthropic.go` (create) | `AnthropicAdapter` |
+| `go/options.go` (modify) | `WithChatCompletionsRemote`, `WithChatModelAdapter`, `WithChatRemoteFailover`, `WithChatRemoteTransport`, `WithChatCompletionsAllowRemoteClients` |
+| `go/api.go` (modify) | Engine fields `chatRemote *chatRemoteConfig`, `chatAllowRemote bool`; pass into handler in `build()` |
+
+---
+
+## Task 1: `ModelResolver.Knows()` — cheap local existence check
+
+**Files:**
+- Modify: `go/chat_completions.go`
+- Test: `go/chat_remote_internal_test.go` (create; `package api`)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `go/chat_remote_internal_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import "testing"
+
+func TestModelResolver_Knows_Good(t *testing.T) {
+ r := NewModelResolver()
+ // Seed the loaded-by-name cache directly (internal test) to simulate a known model.
+ r.loadedByName["lemer"] = nil
+ if !r.Knows("lemer") {
+ t.Fatal("Knows(lemer) = false, want true (cache hit)")
+ }
+}
+
+func TestModelResolver_Knows_Bad(t *testing.T) {
+ r := NewModelResolver()
+ if r.Knows("does-not-exist") {
+ t.Fatal("Knows(does-not-exist) = true, want false")
+ }
+ if r.Knows("") {
+ t.Fatal("Knows(empty) = true, want false")
+ }
+ var nilR *ModelResolver
+ if nilR.Knows("x") {
+ t.Fatal("nil resolver Knows = true, want false")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestModelResolver_Knows`
+Expected: FAIL — `r.Knows undefined`.
+
+- [ ] **Step 3: Implement `Knows`**
+
+In `go/chat_completions.go`, add after `ResolveModel` (around line 300):
+
+```go
+// Knows reports whether the resolver can serve name WITHOUT loading it — a hit
+// in the loaded-model cache, the models.yaml mapping, or the discovery set. It
+// mirrors ResolveModel's three resolution sources so a false result means
+// ResolveModel could not have served the model either. Used by the chat handler
+// to route local-vs-remote without triggering a model load (see chat_remote.go).
+func (r *ModelResolver) Knows(name string) bool {
+ if r == nil || core.Trim(name) == "" {
+ return false
+ }
+ r.mu.RLock()
+ _, cached := r.loadedByName[name]
+ r.mu.RUnlock()
+ if cached {
+ return true
+ }
+ if _, ok := r.lookupModelPath(name); ok {
+ return true
+ }
+ if _, ok := r.resolveDiscoveredPath(name); ok {
+ return true
+ }
+ return false
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestModelResolver_Knows -race`
+Expected: PASS (both tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/chat_completions.go go/chat_remote_internal_test.go
+git commit -m "$(printf 'feat(api): ModelResolver.Knows — no-load local existence check\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 2: Remote backend core — config, options, dispatch (passthrough), wiring
+
+**Files:**
+- Create: `go/chat_adapter.go`, `go/chat_remote.go`
+- Modify: `go/options.go`, `go/api.go`, `go/chat_completions.go`
+- Test: `go/chat_remote_test.go` (create; `package api_test`)
+
+- [ ] **Step 1: Write the adapter interfaces (`chat_adapter.go`)**
+
+Create `go/chat_adapter.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import "io" // Note: AX-6 — io.Writer/Reader are the transcoder stream boundary.
+
+// ChatFormatAdapter maps between the OpenAI chat shape and a non-OpenAI upstream.
+// OpenAI-compatible upstreams need NO adapter — passthrough is the default.
+type ChatFormatAdapter interface {
+ // Name identifies the adapter, e.g. "ollama", "anthropic".
+ Name() string
+ // UpstreamPath is the path under the upstream base URL, e.g. "/api/chat".
+ UpstreamPath() string
+ // BuildRequest maps the OpenAI request into the upstream body + protocol
+ // headers (Content-Type, anthropic-version). Operator secrets (x-api-key)
+ // belong in Upstream.Headers, not here.
+ BuildRequest(req ChatCompletionRequest) (body []byte, headers map[string]string, err error)
+ // DecodeResponse maps a complete (non-streaming) upstream body into the
+ // OpenAI response.
+ DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error)
+ // Transcoder converts the upstream stream into OpenAI chunk SSE; nil means
+ // the adapter supports non-streaming only.
+ Transcoder() ChatStreamTranscoder
+}
+
+// ChatStreamTranscoder converts an upstream response stream into OpenAI
+// chat.completion.chunk SSE events written to w (flushing via flush as it goes).
+// It emits the terminating "data: [DONE]". Returns on upstream EOF or error.
+type ChatStreamTranscoder interface {
+ Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error
+}
+
+// ChatStreamMeta carries the OpenAI identity fields a transcoder stamps on every chunk.
+type ChatStreamMeta struct {
+ ID string
+ Model string
+ Created int64
+}
+```
+
+- [ ] **Step 2: Write the config + options + engine wiring**
+
+Create `go/chat_remote.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "strconv"
+ "time"
+
+ core "dappco.re/go"
+
+ "github.com/gin-gonic/gin"
+)
+
+// chatRemoteConfig is the remote backend attached to /v1/chat/completions via
+// WithChatCompletionsRemote. It reuses the upstream router's balancer/transport.
+type chatRemoteConfig struct {
+ reg *UpstreamRegistry
+ adapters map[string]ChatFormatAdapter
+ maxAttempts int
+ cooldown time.Duration
+ failover map[int]bool
+ transport http.RoundTripper
+ rt *upstreamTransport // built in finalise
+}
+
+func (cfg *chatRemoteConfig) finalise() {
+ if cfg.cooldown <= 0 {
+ cfg.cooldown = defaultUpstreamCooldown
+ }
+ if cfg.failover == nil {
+ cfg.failover = defaultFailoverStatuses()
+ }
+ if cfg.transport == nil {
+ cfg.transport = http.DefaultTransport.(*http.Transport).Clone()
+ }
+ balancer := newUpstreamBalancer(cfg.cooldown, time.Now)
+ cfg.rt = &upstreamTransport{
+ base: cfg.transport,
+ balancer: balancer,
+ maxAttempts: cfg.maxAttempts,
+ failover: cfg.failover,
+ }
+}
+
+// dispatchRemote proxies a chat request to the resolved remote pool, applying the
+// per-model adapter (or verbatim passthrough when adapter == nil).
+func (h *chatCompletionsHandler) dispatchRemote(c *gin.Context, req ChatCompletionRequest, raw []byte, pool []Upstream, adapter ChatFormatAdapter) {
+ // Stream-capability check BEFORE dispatch (so we can still send an error body).
+ if req.Stream && adapter != nil && adapter.Transcoder() == nil {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stream", "the adapter for this model does not support streaming", "")
+ return
+ }
+
+ path := defaultChatCompletionsPath
+ body := raw
+ var hdrs map[string]string
+ if adapter != nil {
+ b, hh, err := adapter.BuildRequest(req)
+ if err != nil {
+ writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "inference_error")
+ return
+ }
+ path, body, hdrs = adapter.UpstreamPath(), b, hh
+ }
+
+ outReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, path, bytes.NewReader(body))
+ if err != nil {
+ writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "inference_error")
+ return
+ }
+ bound := body
+ outReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(bound)), nil }
+ outReq.ContentLength = int64(len(bound))
+ outReq.Header.Set("Content-Type", "application/json")
+ for k, v := range hdrs {
+ outReq.Header.Set(k, v)
+ }
+ ctx := context.WithValue(outReq.Context(), poolCtxKey, pool)
+ ctx = context.WithValue(ctx, keyCtxKey, req.Model)
+ outReq = outReq.WithContext(ctx)
+
+ resp, err := h.remote.rt.RoundTrip(outReq)
+ if err != nil {
+ status, code := http.StatusServiceUnavailable, "upstream_unavailable"
+ var re *routerError
+ if core.As(err, &re) {
+ status, code = re.status, re.code
+ }
+ if status == http.StatusServiceUnavailable {
+ c.Header("Retry-After", strconv.Itoa(int(h.remote.cooldown.Seconds())))
+ }
+ writeChatCompletionError(c, status, "invalid_request_error", "model", "upstream request failed", code)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ h.deliverRemote(c, req, adapter, resp)
+}
+
+func (h *chatCompletionsHandler) deliverRemote(c *gin.Context, req ChatCompletionRequest, adapter ChatFormatAdapter, resp *http.Response) {
+ // Non-2xx: passthrough copies verbatim; adapter wraps in the OpenAI error shape.
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamResponseBytes))
+ if adapter == nil {
+ c.Header("Content-Type", "application/json")
+ c.Status(resp.StatusCode)
+ _, _ = c.Writer.Write(body)
+ return
+ }
+ writeChatCompletionError(c, resp.StatusCode, "invalid_request_error", "model", "upstream error: "+string(body), "upstream_error")
+ return
+ }
+
+ if req.Stream {
+ c.Header("Content-Type", "text/event-stream")
+ c.Header("Cache-Control", "no-cache")
+ c.Header("Connection", "keep-alive")
+ c.Status(http.StatusOK)
+ flush := c.Writer.Flush
+ if adapter == nil {
+ copyFlushing(c.Writer, resp.Body, flush)
+ return
+ }
+ meta := ChatStreamMeta{ID: newChatCompletionID(), Model: req.Model, Created: time.Now().Unix()}
+ _ = adapter.Transcoder().Transcode(c.Writer, flush, resp.Body, meta)
+ return
+ }
+
+ // Non-streaming.
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamResponseBytes))
+ if adapter == nil {
+ c.Header("Content-Type", "application/json")
+ c.Status(http.StatusOK)
+ _, _ = c.Writer.Write(body)
+ return
+ }
+ out, err := adapter.DecodeResponse(req.Model, body)
+ if err != nil {
+ writeChatCompletionError(c, http.StatusBadGateway, "invalid_request_error", "model", "could not decode upstream response", "invalid_upstream_response")
+ return
+ }
+ c.JSON(http.StatusOK, out)
+}
+
+// copyFlushing streams src to dst, flushing after each read so SSE chunks reach
+// the client immediately.
+func copyFlushing(dst io.Writer, src io.Reader, flush func()) {
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := src.Read(buf)
+ if n > 0 {
+ if _, werr := dst.Write(buf[:n]); werr != nil {
+ return
+ }
+ if flush != nil {
+ flush()
+ }
+ }
+ if err != nil {
+ return
+ }
+ }
+}
+```
+
+- [ ] **Step 3: Add the options (`options.go`) + engine fields (`api.go`)**
+
+In `go/options.go`, after `WithChatCompletionsPath` (~line 849):
+
+```go
+// WithChatCompletionsRemote attaches a remote backend to /v1/chat/completions.
+// Compose with WithChatCompletions for hybrid (local-first); use alone for
+// remote-only. Models with no WithChatModelAdapter are forwarded verbatim
+// (OpenAI passthrough); adapters map non-OpenAI upstreams (see chat_adapter.go).
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8"))
+// _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"})
+// api.New(api.WithChatCompletions(local), api.WithChatCompletionsRemote(reg))
+func WithChatCompletionsRemote(reg *UpstreamRegistry, opts ...ChatRemoteOption) Option {
+ return func(e *Engine) {
+ if reg == nil {
+ return
+ }
+ cfg := &chatRemoteConfig{reg: reg, adapters: map[string]ChatFormatAdapter{}}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(cfg)
+ }
+ }
+ cfg.finalise()
+ e.chatRemote = cfg
+ }
+}
+
+// ChatRemoteOption configures the chat remote backend.
+type ChatRemoteOption func(*chatRemoteConfig)
+
+// WithChatModelAdapter maps a model name to a non-OpenAI format adapter.
+func WithChatModelAdapter(model string, a ChatFormatAdapter) ChatRemoteOption {
+ return func(cfg *chatRemoteConfig) {
+ if core.Trim(model) != "" && a != nil {
+ cfg.adapters[model] = a
+ }
+ }
+}
+
+// WithChatRemoteFailover sets max upstream attempts + per-upstream cooldown for
+// the remote backend (default: len(pool), 10s).
+func WithChatRemoteFailover(maxAttempts int, cooldown time.Duration) ChatRemoteOption {
+ return func(cfg *chatRemoteConfig) {
+ cfg.maxAttempts = maxAttempts
+ if cooldown > 0 {
+ cfg.cooldown = cooldown
+ }
+ }
+}
+
+// WithChatRemoteTransport sets the base RoundTripper for remote dispatch.
+func WithChatRemoteTransport(rt http.RoundTripper) ChatRemoteOption {
+ return func(cfg *chatRemoteConfig) { cfg.transport = rt }
+}
+
+// WithChatCompletionsAllowRemoteClients permits non-loopback clients on the chat
+// endpoint, but ONLY when a bearer is configured (WithBearerAuth) — mirrors the
+// engine's ErrPublicBindNoBearer invariant. Without it, the endpoint stays
+// loopback-only. Pair with an auth-guarded route for real enforcement.
+func WithChatCompletionsAllowRemoteClients() Option {
+ return func(e *Engine) { e.chatAllowRemote = true }
+}
+```
+
+Confirm `options.go` already imports `time`, `net/http`, `core` (it does — used by other options).
+
+In `go/api.go`, add to the `Engine` struct (after `upstreamRouter *upstreamRouterConfig`):
+
+```go
+ // chatRemote, when set via WithChatCompletionsRemote, adds a remote backend
+ // to the chat completions endpoint (local-first dispatch).
+ chatRemote *chatRemoteConfig
+ // chatAllowRemote permits non-loopback chat clients when a bearer is set.
+ chatAllowRemote bool
+```
+
+- [ ] **Step 4: Wire the handler (`chat_completions.go` + `api.go` build)**
+
+In `go/chat_completions.go`, replace the `chatCompletionsHandler` struct + constructor + `ServeHTTP` head with:
+
+```go
+type chatCompletionsHandler struct {
+ resolver *ModelResolver
+ remote *chatRemoteConfig
+ allowRemote bool
+ bearerConfigured bool
+}
+
+func newChatCompletionsHandler(resolver *ModelResolver, remote *chatRemoteConfig, allowRemote, bearerConfigured bool) *chatCompletionsHandler {
+ return &chatCompletionsHandler{
+ resolver: resolver,
+ remote: remote,
+ allowRemote: allowRemote,
+ bearerConfigured: bearerConfigured,
+ }
+}
+
+func (h *chatCompletionsHandler) ServeHTTP(c *gin.Context) {
+ if h == nil || (h.resolver == nil && h.remote == nil) {
+ writeChatCompletionError(c, http.StatusServiceUnavailable, "invalid_request_error", "model", "chat handler is not configured", "service_unavailable")
+ return
+ }
+
+ if !isLoopbackRequest(c.Request) && !(h.allowRemote && h.bearerConfigured) {
+ writeChatCompletionError(c, http.StatusForbidden, "invalid_request_error", "request", "chat completions is only available on loopback interfaces", "")
+ return
+ }
+
+ raw, ok := readChatBody(c)
+ if !ok {
+ return
+ }
+ var req ChatCompletionRequest
+ if err := decodeJSONBody(bytes.NewReader(raw), &req); err != nil {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", "invalid request body", "")
+ return
+ }
+ if err := validateChatRequest(&req); err != nil {
+ chatErr, isChatErr := err.(*chatCompletionRequestError)
+ if !isChatErr {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", err.Error(), "")
+ return
+ }
+ writeChatCompletionError(c, chatErr.Status, chatErr.Type, chatErr.Param, chatErr.Message, chatErr.Code)
+ return
+ }
+
+ // PURE-LOCAL: unchanged current behaviour (no Knows gate).
+ if h.remote == nil {
+ h.serveLocal(c, req)
+ return
+ }
+ // HYBRID: local-first if the resolver knows the model; else remote.
+ if h.resolver != nil && h.resolver.Knows(req.Model) {
+ h.serveLocal(c, req)
+ return
+ }
+ pool, found := h.remote.reg.resolve(req.Model)
+ if !found {
+ writeChatCompletionError(c, http.StatusNotFound, "invalid_request_error", "model", "model not found: "+req.Model, "model_not_found")
+ return
+ }
+ h.dispatchRemote(c, req, raw, pool, h.remote.adapters[req.Model])
+}
+
+// readChatBody reads the bounded request body once (so it can drive both the
+// selector and a verbatim upstream forward).
+func readChatBody(c *gin.Context) ([]byte, bool) {
+ limited := http.MaxBytesReader(c.Writer, c.Request.Body, maxToolRequestBodyBytes)
+ body, err := io.ReadAll(limited)
+ if err != nil {
+ if err.Error() == "http: request body too large" {
+ writeChatCompletionError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "body", "request body too large", "")
+ return nil, false
+ }
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", "unable to read request body", "")
+ return nil, false
+ }
+ return body, true
+}
+```
+
+Then refactor the existing local logic (resolve → options → serve) from the old `ServeHTTP` body into a new method `serveLocal` (move lines that were after the decode/validate block — `resolver.ResolveModel`, `chatRequestOptions`, `normalizedStopSequences`, message conversion, stream dispatch):
+
+```go
+func (h *chatCompletionsHandler) serveLocal(c *gin.Context, req ChatCompletionRequest) {
+ if h.resolver == nil {
+ writeChatCompletionError(c, http.StatusNotFound, "invalid_request_error", "model", "model not found: "+req.Model, "model_not_found")
+ return
+ }
+ model, err := h.resolver.ResolveModel(req.Model)
+ if err != nil {
+ status, errType, errCode, errParam := mapResolverError(err)
+ writeChatCompletionError(c, status, errType, errParam, err.Error(), errCode)
+ return
+ }
+ reqForOptions := req
+ reqForOptions.Stop = nil
+ options, err := chatRequestOptions(&reqForOptions)
+ if err != nil {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stop", err.Error(), "")
+ return
+ }
+ stopSequences, err := normalizedStopSequences(req.Stop)
+ if err != nil {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stop", err.Error(), "")
+ return
+ }
+ messages := make([]inference.Message, 0, len(req.Messages))
+ for _, msg := range req.Messages {
+ messages = append(messages, inference.Message{Role: msg.Role, Content: msg.Content})
+ }
+ if req.Stream {
+ h.serveStreaming(c, model, req, messages, stopSequences, options...)
+ return
+ }
+ h.serveNonStreaming(c, model, req, messages, stopSequences, options...)
+}
+```
+
+Add `"bytes"` and `"io"` to `chat_completions.go` imports if not present (`io` likely is not — add both).
+
+> Confirm `decodeJSONBody(reader any, dest any)` accepts an `io.Reader` — the original `ServeHTTP` called it with `c.Request.Body` (an `io.Reader`), so `bytes.NewReader(raw)` is compatible. If it type-asserts to `io.ReadCloser` specifically, wrap with `io.NopCloser(bytes.NewReader(raw))`.
+
+In `go/api.go` `build()`, replace the chat-completions mount block:
+
+```go
+ // Mount the OpenAI-compatible chat completion endpoint when a local resolver
+ // and/or a remote backend is configured.
+ if e.chatCompletionsResolver != nil || e.chatRemote != nil {
+ path := e.chatCompletionsPath
+ if core.Trim(path) == "" {
+ path = defaultChatCompletionsPath
+ }
+ h := newChatCompletionsHandler(e.chatCompletionsResolver, e.chatRemote, e.chatAllowRemote, e.bearerConfigured)
+ r.POST(path, h.ServeHTTP)
+ }
+```
+
+And in `New()` (api.go ~138), broaden the default-path guard so remote-only also gets the default path:
+
+```go
+ if (e.chatCompletionsResolver != nil || e.chatRemote != nil) && core.Trim(e.chatCompletionsPath) == "" {
+ e.chatCompletionsPath = defaultChatCompletionsPath
+ }
+```
+
+- [ ] **Step 5: Write integration tests (`chat_remote_test.go`)**
+
+Create `go/chat_remote_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+// chatPost sends a chat request from a loopback client.
+func chatPost(t *testing.T, base, body string) *http.Response {
+ t.Helper()
+ resp, err := http.Post(base+"/v1/chat/completions", "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ return resp
+}
+
+func TestChatRemote_Passthrough_Good(t *testing.T) {
+ var gotBody string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ _, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}`)
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ // Send an unmodelled field (tools) to prove verbatim passthrough fidelity.
+ resp := chatPost(t, srv.URL, `{"model":"gpt-x","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function"}]}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(gotBody, `"tools"`) {
+ t.Errorf("upstream did not receive verbatim body (tools dropped): %s", gotBody)
+ }
+ if !strings.Contains(string(out), `"content":"hi"`) {
+ t.Errorf("client did not get upstream response: %s", out)
+ }
+}
+
+func TestChatRemote_UnknownModel_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("known", api.Upstream{URL: "http://127.0.0.1:1"}) // no default
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"nope","messages":[{"role":"user","content":"x"}]}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(body), "model_not_found") {
+ t.Errorf("want model_not_found, got %s", body)
+ }
+}
+
+func TestChatRemote_Failover_Good(t *testing.T) {
+ dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }))
+ defer dead.Close()
+ live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
+ }))
+ defer live.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: dead.URL}, api.Upstream{URL: live.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}]}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (failed over)", resp.StatusCode)
+ }
+}
+
+func TestChatRemote_StreamingPassthrough_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ f, _ := w.(http.Flusher)
+ for _, ch := range []string{"data: {\"x\":1}\n\n", "data: [DONE]\n\n"} {
+ _, _ = io.WriteString(w, ch)
+ if f != nil {
+ f.Flush()
+ }
+ }
+ }))
+ defer up.Close()
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}],"stream":true}`)
+ defer resp.Body.Close()
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
+ t.Fatalf("Content-Type = %q, want SSE", ct)
+ }
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(out), "[DONE]") {
+ t.Errorf("stream not passed through: %s", out)
+ }
+}
+
+func TestChatRemote_BindOptIn_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:1"})
+ // No allow-remote, no bearer: non-loopback would be rejected. We assert the
+ // guard logic via a loopback request still works (positive) and that the
+ // option+bearer path is constructed without error.
+ e, _ := api.New(
+ api.WithBearerAuth("secret"),
+ api.WithChatCompletionsAllowRemoteClients(),
+ api.WithChatCompletionsRemote(reg),
+ )
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+ // Loopback client is always allowed regardless of opt-in.
+ resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}]}`)
+ defer resp.Body.Close()
+ // httptest client is loopback → not 403. (Off-loopback 403 is covered by the
+ // internal guard unit test below.)
+ if resp.StatusCode == http.StatusForbidden {
+ t.Fatalf("loopback client got 403, want allowed")
+ }
+}
+```
+
+> The off-loopback 403 path is hard to exercise via httptest (always 127.0.0.1). Add an internal guard unit test in `chat_remote_internal_test.go` that calls the guard directly:
+
+```go
+func TestChatHandler_BindGuard_Ugly(t *testing.T) {
+ // non-loopback remote addr, no opt-in → must be rejected.
+ h := newChatCompletionsHandler(nil, &chatRemoteConfig{}, false, false)
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"x"}]}`))
+ req.RemoteAddr = "203.0.113.7:5555"
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ c.Request = req
+ h.ServeHTTP(c)
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("non-loopback w/o opt-in: code = %d, want 403", w.Code)
+ }
+ // With opt-in + bearer configured → not 403 (proceeds to dispatch/404 etc.).
+ h2 := newChatCompletionsHandler(nil, &chatRemoteConfig{reg: NewUpstreamRegistry()}, true, true)
+ w2 := httptest.NewRecorder()
+ c2, _ := gin.CreateTestContext(w2)
+ r2 := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"x"}]}`))
+ r2.RemoteAddr = "203.0.113.7:5555"
+ c2.Request = r2
+ h2.ServeHTTP(c2)
+ if w2.Code == http.StatusForbidden {
+ t.Fatalf("non-loopback WITH opt-in+bearer: code = 403, want allowed")
+ }
+
+ // Opt-in but NO bearer configured → still 403 (mirrors ErrPublicBindNoBearer).
+ h3 := newChatCompletionsHandler(nil, &chatRemoteConfig{}, true, false)
+ w3 := httptest.NewRecorder()
+ c3, _ := gin.CreateTestContext(w3)
+ r3 := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{"model":"m","messages":[{"role":"user","content":"x"}]}`))
+ r3.RemoteAddr = "203.0.113.7:5555"
+ c3.Request = r3
+ h3.ServeHTTP(c3)
+ if w3.Code != http.StatusForbidden {
+ t.Fatalf("non-loopback opt-in WITHOUT bearer: code = %d, want 403", w3.Code)
+ }
+}
+```
+
+Add imports `net/http`, `net/http/httptest`, `strings`, `github.com/gin-gonic/gin` to `chat_remote_internal_test.go`.
+
+- [ ] **Step 6: Run, verify, commit**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go build ./ && GOWORK=off go test ./ -run 'TestChatRemote|TestChatHandler|TestModelResolver_Knows' -race`
+Expected: PASS.
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/chat_adapter.go go/chat_remote.go go/chat_remote_test.go go/chat_remote_internal_test.go go/options.go go/api.go go/chat_completions.go
+git commit -m "$(printf 'feat(api): chat-completions remote backend — local-first dispatch + OpenAI passthrough\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 3: OllamaAdapter
+
+**Files:**
+- Create: `go/chat_adapter_ollama.go`
+- Test: `go/chat_adapter_ollama_test.go` (`package api_test`)
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `go/chat_adapter_ollama_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+func TestOllamaAdapter_BuildRequest_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ mt := 64
+ body, hdrs, err := a.BuildRequest(api.ChatCompletionRequest{
+ Model: "llama3", Messages: []api.ChatMessage{{Role: "user", Content: "hi"}}, MaxTokens: &mt, Stream: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hdrs["Content-Type"] != "application/json" {
+ t.Errorf("missing content-type header")
+ }
+ var got map[string]any
+ _ = json.Unmarshal(body, &got)
+ if got["model"] != "llama3" || got["stream"] != true {
+ t.Errorf("bad ollama body: %s", body)
+ }
+ opts, _ := got["options"].(map[string]any)
+ if opts["num_predict"].(float64) != 64 {
+ t.Errorf("max_tokens not mapped to num_predict: %s", body)
+ }
+}
+
+func TestOllamaAdapter_DecodeResponse_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ out, err := a.DecodeResponse("llama3", []byte(`{"message":{"role":"assistant","content":"4"},"done":true,"done_reason":"stop","prompt_eval_count":3,"eval_count":1}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.Choices[0].Message.Content != "4" || out.Choices[0].FinishReason != "stop" {
+ t.Errorf("bad decode: %+v", out)
+ }
+ if out.Usage.PromptTokens != 3 || out.Usage.CompletionTokens != 1 {
+ t.Errorf("bad usage: %+v", out.Usage)
+ }
+}
+
+func TestOllamaAdapter_Transcode_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ stream := strings.Join([]string{
+ `{"message":{"role":"assistant","content":"He"},"done":false}`,
+ `{"message":{"role":"assistant","content":"llo"},"done":false}`,
+ `{"message":{"role":"assistant","content":""},"done":true,"done_reason":"stop"}`,
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "llama3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) {
+ t.Errorf("missing deltas: %s", got)
+ }
+ if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing terminal/[DONE]: %s", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOllamaAdapter`
+Expected: FAIL — `api.OllamaAdapter undefined`.
+
+- [ ] **Step 3: Implement `OllamaAdapter`**
+
+Create `go/chat_adapter_ollama.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+
+ core "dappco.re/go"
+)
+
+type ollamaAdapter struct{}
+
+// OllamaAdapter maps OpenAI chat completions to/from Ollama's native /api/chat
+// (JSON request with an "options" block; newline-delimited JSON stream).
+func OllamaAdapter() ChatFormatAdapter { return ollamaAdapter{} }
+
+func (ollamaAdapter) Name() string { return "ollama" }
+func (ollamaAdapter) UpstreamPath() string { return "/api/chat" }
+
+func (ollamaAdapter) BuildRequest(req ChatCompletionRequest) ([]byte, map[string]string, error) {
+ msgs := make([]map[string]string, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ msgs = append(msgs, map[string]string{"role": m.Role, "content": m.Content})
+ }
+ options := map[string]any{}
+ if req.Temperature != nil {
+ options["temperature"] = *req.Temperature
+ }
+ if req.TopP != nil {
+ options["top_p"] = *req.TopP
+ }
+ if req.TopK != nil {
+ options["top_k"] = *req.TopK
+ }
+ if req.MaxTokens != nil {
+ options["num_predict"] = *req.MaxTokens
+ }
+ body := map[string]any{
+ "model": req.Model,
+ "messages": msgs,
+ "stream": req.Stream,
+ }
+ if len(options) > 0 {
+ body["options"] = options
+ }
+ if len(req.Stop) > 0 {
+ body["stop"] = []string(req.Stop)
+ }
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, nil, core.E("ollama", "marshal request", err)
+ }
+ return raw, map[string]string{"Content-Type": "application/json"}, nil
+}
+
+type ollamaResponse struct {
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ Done bool `json:"done"`
+ DoneReason string `json:"done_reason"`
+ PromptEvalCount int `json:"prompt_eval_count"`
+ EvalCount int `json:"eval_count"`
+}
+
+func ollamaFinish(doneReason string) string {
+ if doneReason == "length" {
+ return "length"
+ }
+ return "stop"
+}
+
+func (ollamaAdapter) DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) {
+ var or ollamaResponse
+ if err := json.Unmarshal(upstream, &or); err != nil {
+ return ChatCompletionResponse{}, core.E("ollama", "decode response", err)
+ }
+ return ChatCompletionResponse{
+ ID: newChatCompletionID(),
+ Object: "chat.completion",
+ Model: model,
+ Choices: []ChatChoice{{Index: 0, Message: ChatMessage{Role: "assistant", Content: or.Message.Content}, FinishReason: ollamaFinish(or.DoneReason)}},
+ Usage: ChatUsage{PromptTokens: or.PromptEvalCount, CompletionTokens: or.EvalCount, TotalTokens: or.PromptEvalCount + or.EvalCount},
+ }, nil
+}
+
+func (ollamaAdapter) Transcoder() ChatStreamTranscoder { return ollamaTranscoder{} }
+
+type ollamaTranscoder struct{}
+
+func (ollamaTranscoder) Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error {
+ scanner := bufio.NewScanner(upstream)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ first := true
+ for scanner.Scan() {
+ line := core.Trim(scanner.Text())
+ if line == "" {
+ continue
+ }
+ var or ollamaResponse
+ if err := json.Unmarshal([]byte(line), &or); err != nil {
+ continue // skip malformed line
+ }
+ if or.Done {
+ fr := ollamaFinish(or.DoneReason)
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: ChatMessageDelta{}, FinishReason: &fr}},
+ })
+ break
+ }
+ delta := ChatMessageDelta{Content: or.Message.Content}
+ if first {
+ delta.Role = "assistant"
+ first = false
+ }
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: nil}},
+ })
+ }
+ writeSSEDone(w, flush)
+ return scanner.Err()
+}
+```
+
+Add the shared SSE writers to `go/chat_adapter.go`:
+
+```go
+// writeChatChunk marshals a chunk as one SSE "data:" event and flushes.
+func writeChatChunk(w io.Writer, flush func(), chunk ChatCompletionChunk) {
+ data := core.JSONMarshal(chunk)
+ raw, ok := data.Value.([]byte)
+ if !data.OK || !ok {
+ return
+ }
+ _, _ = io.WriteString(w, "data: ")
+ _, _ = w.Write(raw)
+ _, _ = io.WriteString(w, "\n\n")
+ if flush != nil {
+ flush()
+ }
+}
+
+// writeSSEDone emits the terminating sentinel.
+func writeSSEDone(w io.Writer, flush func()) {
+ _, _ = io.WriteString(w, "data: [DONE]\n\n")
+ if flush != nil {
+ flush()
+ }
+}
+```
+
+Add `core "dappco.re/go"` to `chat_adapter.go` imports.
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestOllamaAdapter' -race`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/chat_adapter_ollama.go go/chat_adapter_ollama_test.go go/chat_adapter.go
+git commit -m "$(printf 'feat(api): OllamaAdapter — OpenAI <-> Ollama-native /api/chat\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 4: AnthropicAdapter
+
+**Files:**
+- Create: `go/chat_adapter_anthropic.go`
+- Test: `go/chat_adapter_anthropic_test.go` (`package api_test`)
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `go/chat_adapter_anthropic_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+func TestAnthropicAdapter_BuildRequest_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ body, hdrs, err := a.BuildRequest(api.ChatCompletionRequest{
+ Model: "claude-3", Messages: []api.ChatMessage{{Role: "system", Content: "be terse"}, {Role: "user", Content: "hi"}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hdrs["anthropic-version"] == "" {
+ t.Errorf("missing anthropic-version header")
+ }
+ var got map[string]any
+ _ = json.Unmarshal(body, &got)
+ if got["system"] != "be terse" {
+ t.Errorf("system not extracted: %s", body)
+ }
+ msgs, _ := got["messages"].([]any)
+ if len(msgs) != 1 { // system removed from messages
+ t.Errorf("system not removed from messages: %s", body)
+ }
+ if _, ok := got["max_tokens"]; !ok {
+ t.Errorf("max_tokens (mandatory) missing: %s", body)
+ }
+}
+
+func TestAnthropicAdapter_DecodeResponse_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ out, err := a.DecodeResponse("claude-3", []byte(`{"content":[{"type":"text","text":"Hi"},{"type":"text","text":" there"}],"stop_reason":"max_tokens","usage":{"input_tokens":5,"output_tokens":2}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.Choices[0].Message.Content != "Hi there" {
+ t.Errorf("text blocks not concatenated: %q", out.Choices[0].Message.Content)
+ }
+ if out.Choices[0].FinishReason != "length" {
+ t.Errorf("max_tokens not mapped to length: %s", out.Choices[0].FinishReason)
+ }
+ if out.Usage.PromptTokens != 5 || out.Usage.CompletionTokens != 2 {
+ t.Errorf("bad usage: %+v", out.Usage)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ // Minimal Anthropic event stream.
+ stream := strings.Join([]string{
+ "event: message_start",
+ `data: {"type":"message_start","message":{"usage":{"input_tokens":5}}}`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"He"}}`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"llo"}}`,
+ "",
+ "event: message_delta",
+ `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}`,
+ "",
+ "event: message_stop",
+ `data: {"type":"message_stop"}`,
+ "",
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) {
+ t.Errorf("missing deltas: %s", got)
+ }
+ if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing terminal/[DONE]: %s", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestAnthropicAdapter`
+Expected: FAIL — `api.AnthropicAdapter undefined`.
+
+- [ ] **Step 3: Implement `AnthropicAdapter`**
+
+Create `go/chat_adapter_anthropic.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+
+ core "dappco.re/go"
+)
+
+const anthropicVersion = "2023-06-01"
+
+type anthropicAdapter struct{}
+
+// AnthropicAdapter maps OpenAI chat completions to/from Anthropic's /v1/messages
+// (top-level system field, mandatory max_tokens, content blocks, SSE event stream).
+func AnthropicAdapter() ChatFormatAdapter { return anthropicAdapter{} }
+
+func (anthropicAdapter) Name() string { return "anthropic" }
+func (anthropicAdapter) UpstreamPath() string { return "/v1/messages" }
+
+func anthropicFinish(stopReason string) string {
+ switch stopReason {
+ case "max_tokens":
+ return "length"
+ default: // end_turn, stop_sequence, etc.
+ return "stop"
+ }
+}
+
+func (anthropicAdapter) BuildRequest(req ChatCompletionRequest) ([]byte, map[string]string, error) {
+ var system string
+ msgs := make([]map[string]string, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ if m.Role == "system" {
+ if system != "" {
+ system += "\n"
+ }
+ system += m.Content
+ continue
+ }
+ msgs = append(msgs, map[string]string{"role": m.Role, "content": m.Content})
+ }
+ maxTokens := chatDefaultMaxTokens
+ if req.MaxTokens != nil {
+ maxTokens = *req.MaxTokens
+ }
+ body := map[string]any{
+ "model": req.Model,
+ "messages": msgs,
+ "max_tokens": maxTokens,
+ "stream": req.Stream,
+ }
+ if system != "" {
+ body["system"] = system
+ }
+ if req.Temperature != nil {
+ body["temperature"] = *req.Temperature
+ }
+ if req.TopP != nil {
+ body["top_p"] = *req.TopP
+ }
+ if req.TopK != nil {
+ body["top_k"] = *req.TopK
+ }
+ if len(req.Stop) > 0 {
+ body["stop_sequences"] = []string(req.Stop)
+ }
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, nil, core.E("anthropic", "marshal request", err)
+ }
+ return raw, map[string]string{"Content-Type": "application/json", "anthropic-version": anthropicVersion}, nil
+}
+
+type anthropicResponse struct {
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ StopReason string `json:"stop_reason"`
+ Usage struct {
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ } `json:"usage"`
+}
+
+func (anthropicAdapter) DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) {
+ var ar anthropicResponse
+ if err := json.Unmarshal(upstream, &ar); err != nil {
+ return ChatCompletionResponse{}, core.E("anthropic", "decode response", err)
+ }
+ var content string
+ for _, b := range ar.Content {
+ if b.Type == "text" {
+ content += b.Text
+ }
+ }
+ return ChatCompletionResponse{
+ ID: newChatCompletionID(),
+ Object: "chat.completion",
+ Model: model,
+ Choices: []ChatChoice{{Index: 0, Message: ChatMessage{Role: "assistant", Content: content}, FinishReason: anthropicFinish(ar.StopReason)}},
+ Usage: ChatUsage{PromptTokens: ar.Usage.InputTokens, CompletionTokens: ar.Usage.OutputTokens, TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens},
+ }, nil
+}
+
+func (anthropicAdapter) Transcoder() ChatStreamTranscoder { return anthropicTranscoder{} }
+
+type anthropicTranscoder struct{}
+
+type anthropicStreamEvent struct {
+ Type string `json:"type"`
+ Delta struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ StopReason string `json:"stop_reason"`
+ } `json:"delta"`
+}
+
+func (anthropicTranscoder) Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error {
+ scanner := bufio.NewScanner(upstream)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ first := true
+ stopReason := "end_turn"
+ for scanner.Scan() {
+ line := core.Trim(scanner.Text())
+ if !core.HasPrefix(line, "data:") {
+ continue // skip "event:" and blank lines; the data line carries type
+ }
+ payload := core.Trim(line[len("data:"):])
+ if payload == "" {
+ continue
+ }
+ var ev anthropicStreamEvent
+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
+ continue
+ }
+ switch ev.Type {
+ case "content_block_delta":
+ if ev.Delta.Type != "text_delta" || ev.Delta.Text == "" {
+ continue
+ }
+ delta := ChatMessageDelta{Content: ev.Delta.Text}
+ if first {
+ delta.Role = "assistant"
+ first = false
+ }
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: nil}},
+ })
+ case "message_delta":
+ if ev.Delta.StopReason != "" {
+ stopReason = ev.Delta.StopReason
+ }
+ case "message_stop":
+ fr := anthropicFinish(stopReason)
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: ChatMessageDelta{}, FinishReason: &fr}},
+ })
+ writeSSEDone(w, flush)
+ return scanner.Err()
+ }
+ }
+ // Stream ended without an explicit message_stop — still terminate cleanly.
+ writeSSEDone(w, flush)
+ return scanner.Err()
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestAnthropicAdapter' -race`
+Expected: PASS (3 tests).
+
+- [ ] **Step 5: End-to-end adapter integration test**
+
+Add to `go/chat_remote_test.go`:
+
+```go
+func TestChatRemote_OllamaAdapter_E2E_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/chat" {
+ t.Errorf("upstream path = %s, want /api/chat", r.URL.Path)
+ }
+ _, _ = io.WriteString(w, `{"message":{"role":"assistant","content":"pong"},"done":true,"done_reason":"stop","prompt_eval_count":2,"eval_count":1}`)
+ }))
+ defer up.Close()
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("llama3", api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg, api.WithChatModelAdapter("llama3", api.OllamaAdapter())))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"llama3","messages":[{"role":"user","content":"ping"}]}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(out), `"content":"pong"`) || !strings.Contains(string(out), `"object":"chat.completion"`) {
+ t.Errorf("ollama not adapted to OpenAI shape: %s", out)
+ }
+}
+
+func TestChatRemote_AnthropicAdapter_E2E_Good(t *testing.T) {
+ var gotVersion string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotVersion = r.Header.Get("anthropic-version")
+ _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":1}}`)
+ }))
+ defer up.Close()
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("claude-3", api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg, api.WithChatModelAdapter("claude-3", api.AnthropicAdapter())))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"claude-3","messages":[{"role":"user","content":"ping"}]}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if gotVersion != "2023-06-01" {
+ t.Errorf("anthropic-version header not sent: %q", gotVersion)
+ }
+ if !strings.Contains(string(out), `"content":"pong"`) {
+ t.Errorf("anthropic not adapted: %s", out)
+ }
+}
+```
+
+- [ ] **Step 6: Run + commit**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestAnthropicAdapter|TestChatRemote' -race`
+Expected: PASS.
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/chat_adapter_anthropic.go go/chat_adapter_anthropic_test.go go/chat_remote_test.go
+git commit -m "$(printf 'feat(api): AnthropicAdapter — OpenAI <-> Anthropic /v1/messages + e2e adapter tests\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 5: Example test + QA gate + final review
+
+**Files:**
+- Create: `go/chat_remote_example_test.go`
+
+- [ ] **Step 1: Example test**
+
+Create `go/chat_remote_example_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "fmt"
+
+ api "dappco.re/go/api"
+)
+
+func ExampleWithChatCompletionsRemote() {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8"))
+ _ = reg.Set("llama3:70b", api.Upstream{URL: "http://10.0.0.5:11434"})
+ _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"}) // OpenAI-compatible — passthrough
+
+ engine, err := api.New(
+ api.WithChatCompletionsRemote(reg,
+ api.WithChatModelAdapter("llama3:70b", api.OllamaAdapter()),
+ ),
+ )
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println(engine.Addr())
+ // Output: :8080
+}
+```
+
+- [ ] **Step 2: Full QA gate**
+
+Run:
+```bash
+cd /Users/snider/Code/core/api/go
+gofmt -l chat_remote.go chat_adapter.go chat_adapter_ollama.go chat_adapter_anthropic.go chat_completions.go chat_remote_test.go chat_remote_internal_test.go chat_adapter_ollama_test.go chat_adapter_anthropic_test.go chat_remote_example_test.go
+GOWORK=off go vet ./
+GOWORK=off go test ./ -race -count=1
+GOWORK=off go build -o /dev/null ./cmd/gateway/
+```
+Expected: `gofmt -l` empty; vet clean; full suite PASS under `-race`; gateway builds.
+
+- [ ] **Step 3: gosec**
+
+Run: `cd /Users/snider/Code/core/api/go && gosec -quiet ./ 2>/dev/null | tail -5 || echo "gosec unavailable"`
+Expected: no new findings in the chat_* files (no `#nosec` needed — the SSRF-bypass annotation lives in `upstream_transport.go`, reused unchanged).
+
+- [ ] **Step 4: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/chat_remote_example_test.go
+git commit -m "$(printf 'test(api): ExampleWithChatCompletionsRemote + QA gate\n\nCo-Authored-By: Virgil ')" || echo "nothing to commit"
+```
+
+---
+
+## Spec coverage check
+
+| Spec section | Task |
+|---|---|
+| §4 `WithChatCompletionsRemote`, `WithChatModelAdapter`, failover/transport opts, `WithChatCompletionsAllowRemoteClients` | Task 2 |
+| §4 `ChatFormatAdapter`/`ChatStreamTranscoder`/`ChatStreamMeta` | Task 2 |
+| §5 dispatch flow (local-first, pure-local unchanged, remote, 404) | Task 2 |
+| §5.1 `ModelResolver.Knows` | Task 1 |
+| §5.2 deliver via gin `c.Writer` | Task 2 |
+| §6.1 OllamaAdapter (request/non-stream/stream) | Task 3 |
+| §6.2 AnthropicAdapter (request/non-stream/stream) | Task 4 |
+| §7 bind opt-in + error taxonomy | Tasks 2 (bind, errors), 3/4 (adapter errors) |
+| §8 testing matrix | Tasks 1–5 |
+| §9 file layout | all |
+
+**Deferred per spec §10 (not in this plan):** generic transcoder registry, tool-calling translation, more adapters, per-model rate limiting, OpenAPI describability.
diff --git a/docs/superpowers/plans/2026-06-06-openapi-inference-describability.md b/docs/superpowers/plans/2026-06-06-openapi-inference-describability.md
new file mode 100644
index 0000000..f49c77e
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-06-openapi-inference-describability.md
@@ -0,0 +1,396 @@
+# OpenAPI Describability for the Inference Surface — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Surface the remote/hybrid chat-completions endpoint and the `WithUpstreamRouter` mounted paths in the generated OpenAPI spec (and therefore SDK gen), which today omits both.
+
+**Architecture:** Extend the existing special-cased-path mechanism in the spec builder — no new abstraction. Widen `ChatCompletionsEnabled` to fire for a remote backend too, and add an `UpstreamRouterPaths` field that flows engine → `TransportConfig` → `SpecBuilder`, where `Build()` emits a minimal honest `POST` proxy item per path, deduped so real items always win.
+
+**Tech Stack:** Go 1.26, the existing `openapi.go` SpecBuilder + `transport.go` + `spec_builder_helper.go`. Spec: `docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md`.
+
+**Conventions:** SPDX header on new files. UK English. `_Good/_Bad/_Ugly` test suffixes. Run from `core/api/go` with `GOWORK=off go test ./ ...`. Commit `Co-Authored-By: Virgil `.
+
+**Reused symbols (already in package `api` — do NOT redefine):** `SpecBuilder`, `(*Engine).OpenAPISpecBuilder()`, `(*SpecBuilder).Build([]RouteGroup)`, `chatCompletionsPathItem`, `openAPISpecPathItem`, `normaliseOpenAPIPath`, `isPublicPathForList`, `makePathItemPublic`, `operationID`, `mergeHeaders`, `standardResponseHeaders`, `rateLimitSuccessHeaders`, `mimeJSON`. Engine fields `e.chatCompletionsResolver`, `e.chatRemote` (set by `WithChatCompletionsRemote`), `e.upstreamRouter` (set by `WithUpstreamRouter`; has a `paths []string` field). `TransportConfig` + `(*Engine).TransportConfig()` in `transport.go`. Test pattern: `e.OpenAPISpecBuilder().Build(nil)` → JSON bytes (see `spec_builder_helper_test.go`).
+
+---
+
+## File Structure
+
+| File | Change |
+|------|--------|
+| `go/transport.go` | `ChatCompletionsEnabled` fires for `e.chatRemote` too; new `UpstreamRouterPaths []string` field + population |
+| `go/spec_builder_helper.go` | `builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths` |
+| `go/openapi.go` | `SpecBuilder.UpstreamRouterPaths`; `upstreamRouterPathItem()`; `Build()` router-path loop with dedup |
+| `go/openapi_inference_test.go` | new — describability tests (`package api_test`) |
+
+---
+
+## Task 1: Chat-completions describability (local / remote / hybrid)
+
+**Files:**
+- Modify: `go/transport.go:53`
+- Test: `go/openapi_inference_test.go` (create)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `go/openapi_inference_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+// specPaths builds the engine's OpenAPI spec and returns its "paths" object.
+func specPaths(t *testing.T, e *api.Engine) map[string]any {
+ t.Helper()
+ data, err := e.OpenAPISpecBuilder().Build(nil)
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ var spec map[string]any
+ if err := json.Unmarshal(data, &spec); err != nil {
+ t.Fatalf("unmarshal spec: %v", err)
+ }
+ paths, ok := spec["paths"].(map[string]any)
+ if !ok {
+ t.Fatalf("spec has no paths object")
+ }
+ return paths
+}
+
+// postTags returns the tags of the POST operation at path, or nil.
+func postTags(paths map[string]any, path string) []string {
+ item, ok := paths[path].(map[string]any)
+ if !ok {
+ return nil
+ }
+ post, ok := item["post"].(map[string]any)
+ if !ok {
+ return nil
+ }
+ raw, _ := post["tags"].([]any)
+ out := make([]string, 0, len(raw))
+ for _, t := range raw {
+ if s, ok := t.(string); ok {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func hasTag(tags []string, want string) bool {
+ for _, t := range tags {
+ if t == want {
+ return true
+ }
+ }
+ return false
+}
+
+func TestOpenAPISpec_ChatCompletions_RemoteOnly_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := api.New(api.WithChatCompletionsRemote(reg))
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ if !hasTag(postTags(paths, "/v1/chat/completions"), "inference") {
+ t.Fatalf("remote-only chat endpoint missing/untagged in spec; paths present: %v", keysOf(paths))
+ }
+}
+
+func TestOpenAPISpec_ChatCompletions_Absent_Good(t *testing.T) {
+ e, err := api.New() // neither local nor remote chat configured
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ if _, exists := paths["/v1/chat/completions"]; exists {
+ t.Fatalf("chat endpoint present in spec with no chat configured")
+ }
+}
+
+func keysOf(m map[string]any) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec_ChatCompletions`
+Expected: `TestOpenAPISpec_ChatCompletions_RemoteOnly_Good` FAILS (chat path absent — `ChatCompletionsEnabled` is false for remote-only); `_Absent_Good` passes.
+
+- [ ] **Step 3: Widen the enabling condition**
+
+In `go/transport.go`, in `TransportConfig()`, change line 53 from:
+
+```go
+ ChatCompletionsEnabled: e.chatCompletionsResolver != nil,
+```
+to:
+```go
+ ChatCompletionsEnabled: e.chatCompletionsResolver != nil || e.chatRemote != nil,
+```
+
+The `ChatCompletionsPath` resolution (line 73-75) already fires for `core.Trim(e.chatCompletionsPath) != ""`, and `New()` sets the default path when a resolver OR remote backend is configured, so the path is already correct — only the enabled flag needed widening.
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec_ChatCompletions -race`
+Expected: both PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/transport.go go/openapi_inference_test.go
+git commit -m "$(printf 'feat(api): OpenAPI spec includes chat-completions for remote/hybrid backends\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 2: Upstream router path items
+
+**Files:**
+- Modify: `go/transport.go` (struct + population), `go/spec_builder_helper.go`, `go/openapi.go`
+- Test: `go/openapi_inference_test.go` (extend)
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `go/openapi_inference_test.go`:
+
+```go
+func TestOpenAPISpec_RouterPaths_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := api.New(api.WithUpstreamRouter(reg, api.WithRouterPaths("/v1/embeddings", "/v1/score")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ for _, p := range []string{"/v1/embeddings", "/v1/score"} {
+ if !hasTag(postTags(paths, p), "proxy") {
+ t.Fatalf("router path %s missing/untagged in spec; paths: %v", p, keysOf(paths))
+ }
+ item := paths[p].(map[string]any)
+ post := item["post"].(map[string]any)
+ responses := post["responses"].(map[string]any)
+ for _, code := range []string{"404", "503"} {
+ if _, ok := responses[code]; !ok {
+ t.Errorf("router path %s missing %s response", p, code)
+ }
+ }
+ }
+}
+
+func TestOpenAPISpec_RouterDedupChat_Ugly(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ // Router mounted at the default chat path AND chat enabled (remote).
+ e, err := api.New(
+ api.WithChatCompletionsRemote(reg),
+ api.WithUpstreamRouter(reg), // default WithRouterPaths == /v1/chat/completions
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ tags := postTags(paths, "/v1/chat/completions")
+ if !hasTag(tags, "inference") {
+ t.Fatalf("chat path lost its inference item to the proxy dedup; tags=%v", tags)
+ }
+ if hasTag(tags, "proxy") {
+ t.Fatalf("chat path was clobbered by the proxy item; tags=%v", tags)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify they fail**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec_Router`
+Expected: `_RouterPaths_Good` FAILS (router paths absent). `_RouterDedupChat_Ugly` passes already (no proxy item exists yet, so the chat item is intact) — it locks in the dedup once Step 3 lands.
+
+- [ ] **Step 3: Add the `UpstreamRouterPaths` field + population (`transport.go`)**
+
+In `go/transport.go`, add to the `TransportConfig` struct (after `OpenAPISpecPath string`):
+
+```go
+ UpstreamRouterPaths []string
+```
+
+In `TransportConfig()`, after the `cfg.OpenAPISpecPath` block (around line 78), add:
+
+```go
+ if e.upstreamRouter != nil {
+ cfg.UpstreamRouterPaths = append([]string(nil), e.upstreamRouter.paths...)
+ }
+```
+
+- [ ] **Step 4: Pass it into the builder (`spec_builder_helper.go`)**
+
+In `go/spec_builder_helper.go`, after `builder.OpenAPISpecPath = runtime.Transport.OpenAPISpecPath` (line 84), add:
+
+```go
+ builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths
+```
+
+- [ ] **Step 5: Add the SpecBuilder field + the path item + the Build loop (`openapi.go`)**
+
+In `go/openapi.go`, add to the `SpecBuilder` struct (after `OpenAPISpecPath string`):
+
+```go
+ UpstreamRouterPaths []string
+```
+
+Add the path-item builder (place it next to `openAPISpecPathItem`):
+
+```go
+// upstreamRouterPathItem documents a WithUpstreamRouter mounted path as a
+// minimal, honest POST proxy operation. The router proxies arbitrary shapes by
+// selector key, so request/response schemas are generic by design; the path is
+// tagged "proxy" to distinguish it from the typed "inference" chat endpoint.
+func upstreamRouterPathItem(path string, operationIDs map[string]int) map[string]any {
+ successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
+ errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
+ genericObject := func() map[string]any {
+ return map[string]any{"type": "object", "additionalProperties": true}
+ }
+
+ return map[string]any{
+ "post": map[string]any{
+ "summary": "Upstream router (selector-routed proxy)",
+ "description": "Selector-routed reverse proxy. The request body must carry the selector field (default \"model\"); the concrete request and response schemas depend on the target upstream/model. Streams Server-Sent Events when the upstream does.",
+ "tags": []string{"proxy"},
+ "operationId": operationID("post", path, operationIDs),
+ "requestBody": map[string]any{
+ "required": true,
+ "content": map[string]any{
+ mimeJSON: map[string]any{"schema": genericObject()},
+ },
+ },
+ "responses": map[string]any{
+ "200": map[string]any{
+ "description": "Proxied upstream response",
+ "content": map[string]any{
+ mimeJSON: map[string]any{"schema": genericObject()},
+ "text/event-stream": map[string]any{"schema": map[string]any{"type": "string"}},
+ },
+ "headers": successHeaders,
+ },
+ "404": map[string]any{
+ "description": "No upstream registered for the selector key",
+ "content": map[string]any{mimeJSON: map[string]any{"schema": genericObject()}},
+ "headers": errorHeaders,
+ },
+ "503": map[string]any{
+ "description": "All upstreams unavailable",
+ "content": map[string]any{mimeJSON: map[string]any{"schema": genericObject()}},
+ "headers": mergeHeaders(errorHeaders, map[string]any{
+ "Retry-After": map[string]any{
+ "description": "Seconds to wait before retrying.",
+ "schema": map[string]any{"type": "integer"},
+ },
+ }),
+ },
+ },
+ },
+ }
+}
+```
+
+In `Build()`, **immediately after the `for _, g := range groups { ... }` loop closes** (so the dedup covers group-contributed paths too), add:
+
+```go
+ for _, rawPath := range sb.UpstreamRouterPaths {
+ routerPath := normaliseOpenAPIPath(rawPath)
+ if routerPath == "" {
+ continue
+ }
+ if _, exists := paths[routerPath]; exists {
+ continue // a real item (chat, spec, swagger, or a group) already documents this path
+ }
+ item := upstreamRouterPathItem(routerPath, operationIDs)
+ if isPublicPathForList(routerPath, publicPaths) {
+ makePathItemPublic(item)
+ }
+ paths[routerPath] = item
+ }
+```
+
+> Note: `upstreamRouterPathItem` does NOT hard-code `"security"`. Public paths get `makePathItemPublic` applied (matching the other items); non-public paths inherit the document's global security — which is the intended "honour configured public paths, don't force-public" behaviour from spec §3.2.
+
+- [ ] **Step 6: Run to verify it passes**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestOpenAPISpec -race`
+Expected: all 4 PASS (`_RemoteOnly_Good`, `_Absent_Good`, `_RouterPaths_Good`, `_RouterDedupChat_Ugly`).
+
+- [ ] **Step 7: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/transport.go go/spec_builder_helper.go go/openapi.go go/openapi_inference_test.go
+git commit -m "$(printf 'feat(api): OpenAPI spec documents WithUpstreamRouter paths (deduped proxy items)\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 3: QA gate + final review
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Full QA gate**
+
+Run:
+```bash
+cd /Users/snider/Code/core/api/go
+gofmt -l transport.go openapi.go spec_builder_helper.go openapi_inference_test.go
+GOWORK=off go vet ./
+GOWORK=off go test ./ -race -count=1
+GOWORK=off go build -o /dev/null ./cmd/gateway/
+```
+Expected: `gofmt -l` empty; vet clean; full suite PASS under `-race` (no regression to the ~1686 existing tests, esp. the existing `openapi_test.go` / `spec_builder_helper_test.go`); gateway builds.
+
+- [ ] **Step 2: OpenAPI 3.1 validity sanity**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run 'TestSpec|TestOpenAPI|TestSwagger' -count=1`
+Expected: PASS — the existing spec-shape/validity tests still hold with the new path items present.
+
+- [ ] **Step 3: Commit any formatting fixes**
+
+```bash
+cd /Users/snider/Code/core/api
+git add -A go/ && git commit -m "$(printf 'chore(api): gofmt pass for inference describability\n\nCo-Authored-By: Virgil ')" || echo "nothing to commit"
+```
+
+---
+
+## Spec coverage check
+
+| Spec section | Task |
+|---|---|
+| §3.1 chat-completions for local/remote/hybrid | Task 1 |
+| §3.2 minimal router proxy item (POST, `proxy` tag, generic schema, 404/503+Retry-After) | Task 2 (Step 5) |
+| §3.3 dedup (real items win; router-at-chat-path → inference item) | Task 2 (Build loop + `_RouterDedupChat_Ugly`) |
+| §4 wiring (transport → spec_builder_helper → openapi.go) | Tasks 1, 2 |
+| §5 testing matrix | Tasks 1, 2 (+ §5 OpenAPI-validity reuse in Task 3) |
+| §6 file layout | all |
+
+**Deferred per spec §7 (not in this plan):** real per-path schemas via consumer `RouteDescription`s, per-model enumeration, broader un-described-route sweep.
diff --git a/docs/superpowers/plans/2026-06-06-upstream-router.md b/docs/superpowers/plans/2026-06-06-upstream-router.md
new file mode 100644
index 0000000..f0d1b66
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-06-upstream-router.md
@@ -0,0 +1,1601 @@
+# Upstream Router (`WithUpstreamRouter`) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a selector-keyed reverse-proxy Option (`WithUpstreamRouter`) to `dappco.re/go/api` that load-balances each request across a runtime-mutable pool of HTTP upstreams, with weighted round-robin + passive failover, hybrid streaming, a decision hook, and composition with the existing TransformerIn/Out layer.
+
+**Architecture:** A copy-on-write `UpstreamRegistry` (key→pool) is the source of truth and validates URLs at registration (block-by-default SSRF, opt-in `AllowPrivateUpstreams`). A pure `upstreamBalancer` does smooth weighted round-robin + cooldown. An `upstreamTransport` (`http.RoundTripper`) owns per-attempt selection + failover. One `httputil.ReverseProxy` per router does streaming (`FlushInterval:-1`), buffered `TransformerOut` (`ModifyResponse`), and clean error envelopes (`ErrorHandler`). Mounted at the gin root by `Engine.build()`, so engine middleware (auth/CORS/rate-limit/tracing) wraps it.
+
+**Tech Stack:** Go 1.26, `net/http/httputil`, `gin`, `dappco.re/go` (core), existing `transformer*.go` / `ssrf_guard.go` / `response.go` helpers. Reference implementation for proxy mechanics: `go/pkg/provider/proxy.go`. Spec: `docs/superpowers/specs/2026-06-06-upstream-router-design.md`.
+
+**Conventions:** SPDX header `// SPDX-License-Identifier: EUPL-1.2` on every file. UK English in strings/docs. `_Good/_Bad/_Ugly` test suffixes. Run tests with `GOWORK=off go test` from `core/api/go`. Commit with `Co-Authored-By: Virgil `.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|------|----------------|
+| `go/upstream_registry.go` | `Upstream`, `UpstreamRegistry` (COW), `RegistryOption`, `AllowPrivateUpstreams`, registration-time validation |
+| `go/upstream_balancer.go` | `upstreamBalancer` — smooth weighted RR + per-URL cooldown, injectable clock |
+| `go/upstream_transport.go` | `upstreamTransport` — `http.RoundTripper` doing per-attempt selection + failover |
+| `go/upstream_router.go` | `Selector`, `RouteFunc`, default selector, `upstreamRouterConfig`, `UpstreamRouterOption`, handler + `httputil.ReverseProxy` assembly, `routerError`, ctx keys |
+| `go/options.go` (modify) | `WithUpstreamRouter` + the `UpstreamRouterOption` helpers |
+| `go/api.go` (modify) | `Engine.upstreamRouter` field; mount in `build()` |
+| Tests | `upstream_registry_test.go`, `upstream_balancer_internal_test.go`, `upstream_transport_internal_test.go`, `upstream_router_test.go`, `upstream_router_example_test.go` |
+
+Error codes are defined as `const` at the top of `upstream_router.go` (not `string_constants.go`, which is for cross-file shared literals — these are router-local).
+
+---
+
+## Task 1: `Upstream` + `UpstreamRegistry` (COW + validation)
+
+**Files:**
+- Create: `go/upstream_registry.go`
+- Test: `go/upstream_registry_test.go`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `go/upstream_registry_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "sync"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+func TestUpstreamRegistry_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ if err := reg.Set("lemma", api.Upstream{URL: "https://a.example.com:8000", Weight: 2}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ if err := reg.Add("lemma", api.Upstream{URL: "https://b.example.com"}); err != nil {
+ t.Fatalf("Add: %v", err)
+ }
+ if err := reg.SetDefault(api.Upstream{URL: "https://fallback.example.com"}); err != nil {
+ t.Fatalf("SetDefault: %v", err)
+ }
+ keys := reg.Keys()
+ if len(keys) != 1 || keys[0] != "lemma" {
+ t.Fatalf("Keys = %v, want [lemma]", keys)
+ }
+}
+
+func TestUpstreamRegistry_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ cases := map[string]string{
+ "scheme": "ftp://a.example.com",
+ "no-host": "http://",
+ "bad-port": "http://a.example.com:99999",
+ "creds": "http://user:pass@a.example.com",
+ "loopback": "http://127.0.0.1:11434",
+ "private": "http://10.0.0.5:8000",
+ "metadata": "http://169.254.169.254",
+ }
+ for name, raw := range cases {
+ if err := reg.Set("k", api.Upstream{URL: raw}); err == nil {
+ t.Errorf("%s: Set(%q) = nil error, want rejection", name, raw)
+ }
+ }
+}
+
+func TestUpstreamRegistry_AllowPrivate_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("local", api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatalf("Set loopback with allow-list: %v", err)
+ }
+ // Metadata stays hard-blocked even with a broad allow-list.
+ reg2 := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("0.0.0.0/0"))
+ if err := reg2.Set("meta", api.Upstream{URL: "http://169.254.169.254"}); err == nil {
+ t.Fatal("metadata host accepted under broad allow-list, want rejection")
+ }
+}
+
+func TestUpstreamRegistry_Ugly_ConcurrentWriteSnapshot(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ _ = reg.Set("k", api.Upstream{URL: "https://a.example.com"})
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(2)
+ go func() { defer wg.Done(); _ = reg.Add("k", api.Upstream{URL: "https://b.example.com"}) }()
+ go func() { defer wg.Done(); _ = reg.Keys() }()
+ }
+ wg.Wait()
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRegistry`
+Expected: FAIL — `undefined: api.NewUpstreamRegistry`, `api.Upstream`, `api.AllowPrivateUpstreams`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `go/upstream_registry.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "net" // Note: AX-6 — net.ParseIP/ParseCIDR are structural for SSRF IP-range checks.
+ "net/url" // Note: AX-6 — url.URL fields are structural for upstream URL validation.
+ "sort"
+ "strconv"
+ "sync"
+ "sync/atomic"
+
+ core "dappco.re/go"
+)
+
+// Upstream is one backend endpoint in a routing pool.
+//
+// Example:
+//
+// api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2}
+type Upstream struct {
+ URL string // http(s) base URL; validated at registration
+ Weight int // weighted round-robin weight; <=0 treated as 1
+ Headers map[string]string // static headers injected on dispatch (e.g. upstream API key)
+}
+
+// registrySnapshot is the immutable read-side view swapped atomically on writes.
+type registrySnapshot struct {
+ pools map[string][]Upstream
+ deflt []Upstream
+}
+
+// UpstreamRegistry is the runtime-mutable, thread-safe pool table consumed by
+// WithUpstreamRouter. Reads are lock-free (atomic snapshot load); writes take a
+// mutex, clone, mutate, and swap (copy-on-write).
+//
+// Example:
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+// _ = reg.Set("lemma", api.Upstream{URL: "http://127.0.0.1:11434"})
+type UpstreamRegistry struct {
+ mu sync.Mutex
+ snap atomic.Pointer[registrySnapshot]
+ allow []*net.IPNet
+ cidrErr error
+}
+
+// RegistryOption configures registration-time validation policy.
+type RegistryOption func(*UpstreamRegistry)
+
+// AllowPrivateUpstreams permits the given private/loopback/reserved CIDRs to
+// pass registration validation. Without it the registry denies loopback,
+// private, link-local, reserved, and metadata destinations by default. Metadata
+// hosts stay hard-blocked regardless of the allow-list.
+//
+// Example:
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8"))
+func AllowPrivateUpstreams(cidrs ...string) RegistryOption {
+ return func(r *UpstreamRegistry) {
+ for _, raw := range cidrs {
+ raw = core.Trim(raw)
+ if raw == "" {
+ continue
+ }
+ _, network, err := net.ParseCIDR(raw)
+ if err != nil {
+ if r.cidrErr == nil {
+ r.cidrErr = core.E("UpstreamRegistry", "invalid AllowPrivateUpstreams CIDR "+raw, err)
+ }
+ continue
+ }
+ r.allow = append(r.allow, network)
+ }
+ }
+}
+
+// NewUpstreamRegistry creates an empty registry. Apply AllowPrivateUpstreams to
+// widen the default-deny validation policy.
+func NewUpstreamRegistry(opts ...RegistryOption) *UpstreamRegistry {
+ r := &UpstreamRegistry{}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(r)
+ }
+ }
+ r.snap.Store(®istrySnapshot{pools: map[string][]Upstream{}})
+ return r
+}
+
+// Set replaces the pool for key. Returns an error (without mutating) if any
+// upstream URL fails validation.
+func (r *UpstreamRegistry) Set(key string, ups ...Upstream) error {
+ if err := r.validateAll(ups); err != nil {
+ return err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ next.pools[key] = cloneUpstreams(ups)
+ r.snap.Store(next)
+ return nil
+}
+
+// Add appends one upstream to the pool for key.
+func (r *UpstreamRegistry) Add(key string, up Upstream) error {
+ if err := r.validate(up); err != nil {
+ return err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ next.pools[key] = append(cloneUpstreams(next.pools[key]), up)
+ r.snap.Store(next)
+ return nil
+}
+
+// Remove drops the pool for key.
+func (r *UpstreamRegistry) Remove(key string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ delete(next.pools, key)
+ r.snap.Store(next)
+}
+
+// SetDefault sets the fallback pool used when a key has no explicit pool.
+func (r *UpstreamRegistry) SetDefault(ups ...Upstream) error {
+ if err := r.validateAll(ups); err != nil {
+ return err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ next.deflt = cloneUpstreams(ups)
+ r.snap.Store(next)
+ return nil
+}
+
+// Keys returns the sorted set of explicitly-registered pool keys.
+func (r *UpstreamRegistry) Keys() []string {
+ snap := r.snap.Load()
+ keys := make([]string, 0, len(snap.pools))
+ for k := range snap.pools {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// resolve returns the pool for key (or the default pool) and whether one exists.
+func (r *UpstreamRegistry) resolve(key string) ([]Upstream, bool) {
+ snap := r.snap.Load()
+ if pool, ok := snap.pools[key]; ok && len(pool) > 0 {
+ return pool, true
+ }
+ if len(snap.deflt) > 0 {
+ return snap.deflt, true
+ }
+ return nil, false
+}
+
+func (r *UpstreamRegistry) clone() *registrySnapshot {
+ cur := r.snap.Load()
+ next := ®istrySnapshot{
+ pools: make(map[string][]Upstream, len(cur.pools)),
+ deflt: cur.deflt,
+ }
+ for k, v := range cur.pools {
+ next.pools[k] = v
+ }
+ return next
+}
+
+func (r *UpstreamRegistry) validateAll(ups []Upstream) error {
+ if len(ups) == 0 {
+ return core.E("UpstreamRegistry", "pool must contain at least one upstream", nil)
+ }
+ for _, up := range ups {
+ if err := r.validate(up); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (r *UpstreamRegistry) validate(up Upstream) error {
+ if r.cidrErr != nil {
+ return r.cidrErr
+ }
+ return validateUpstreamURL(up.URL, r.allow)
+}
+
+// validateUpstreamURL enforces the block-by-default registration policy, reusing
+// the root SSRF primitives (allowedSchemes, metadataHosts, blockedIPReason).
+// Non-metadata hostnames are accepted without registration-time DNS (trusted
+// config). IP literals in a denied range are rejected unless covered by allow.
+func validateUpstreamURL(rawURL string, allow []*net.IPNet) error {
+ rawURL = core.Trim(rawURL)
+ if rawURL == "" {
+ return core.E("UpstreamRegistry", "upstream URL is required", nil)
+ }
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return core.E("UpstreamRegistry", "invalid upstream URL "+rawURL, err)
+ }
+ if u.User != nil {
+ return core.E("UpstreamRegistry", "upstream URL must not include credentials: "+rawURL, nil)
+ }
+ if _, ok := allowedSchemes[core.Lower(u.Scheme)]; !ok {
+ return core.E("UpstreamRegistry", "upstream URL scheme must be http or https: "+rawURL, nil)
+ }
+ host := u.Hostname()
+ if host == "" {
+ return core.E("UpstreamRegistry", "upstream URL must include a host: "+rawURL, nil)
+ }
+ if port := u.Port(); port != "" {
+ n, perr := strconv.Atoi(port)
+ if perr != nil || n < 1 || n > 65535 {
+ return core.E("UpstreamRegistry", "upstream URL port is invalid: "+rawURL, perr)
+ }
+ }
+ if _, ok := metadataHosts[core.Lower(host)]; ok {
+ return core.E("UpstreamRegistry", "metadata host is not permitted: "+host, nil)
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if reason := blockedIPReason(ip); reason != "" && !ipAllowed(ip, allow) {
+ return core.E("UpstreamRegistry", reason+" not permitted (use AllowPrivateUpstreams): "+host, nil)
+ }
+ }
+ return nil
+}
+
+func ipAllowed(ip net.IP, allow []*net.IPNet) bool {
+ for _, network := range allow {
+ if network.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+func cloneUpstreams(ups []Upstream) []Upstream {
+ if len(ups) == 0 {
+ return nil
+ }
+ out := make([]Upstream, len(ups))
+ copy(out, ups)
+ return out
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRegistry -race`
+Expected: PASS (all four tests, no data race).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/upstream_registry.go go/upstream_registry_test.go
+git commit -m "$(printf 'feat(api): UpstreamRegistry — COW pool table + registration SSRF policy\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 2: `upstreamBalancer` (weighted RR + cooldown)
+
+**Files:**
+- Create: `go/upstream_balancer.go`
+- Test: `go/upstream_balancer_internal_test.go`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `go/upstream_balancer_internal_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "testing"
+ "time"
+)
+
+func TestUpstreamBalancer_WeightedSpread_Good(t *testing.T) {
+ b := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ pool := []Upstream{{URL: "a", Weight: 2}, {URL: "b", Weight: 1}}
+ counts := map[string]int{}
+ for i := 0; i < 30; i++ {
+ up, ok := b.pick("k", pool)
+ if !ok {
+ t.Fatal("pick returned !ok with healthy pool")
+ }
+ counts[up.URL]++
+ }
+ if counts["a"] != 20 || counts["b"] != 10 {
+ t.Fatalf("weighted spread = %v, want a:20 b:10", counts)
+ }
+}
+
+func TestUpstreamBalancer_CooldownSkip_Good(t *testing.T) {
+ now := time.Unix(1000, 0)
+ clock := func() time.Time { return now }
+ b := newUpstreamBalancer(10*time.Second, clock)
+ pool := []Upstream{{URL: "a", Weight: 1}, {URL: "b", Weight: 1}}
+
+ b.markFailed("a")
+ for i := 0; i < 5; i++ {
+ up, ok := b.pick("k", pool)
+ if !ok || up.URL != "b" {
+ t.Fatalf("during cooldown got (%v,%v), want b", up.URL, ok)
+ }
+ }
+ now = now.Add(11 * time.Second) // cooldown elapsed
+ seen := map[string]bool{}
+ for i := 0; i < 10; i++ {
+ up, _ := b.pick("k", pool)
+ seen[up.URL] = true
+ }
+ if !seen["a"] {
+ t.Fatal("a not picked after cooldown elapsed")
+ }
+}
+
+func TestUpstreamBalancer_AllCooling_Bad(t *testing.T) {
+ b := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ pool := []Upstream{{URL: "a"}, {URL: "b"}}
+ b.markFailed("a")
+ b.markFailed("b")
+ if _, ok := b.pick("k", pool); ok {
+ t.Fatal("pick returned ok with all upstreams cooling")
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamBalancer`
+Expected: FAIL — `undefined: newUpstreamBalancer`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `go/upstream_balancer.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "sync"
+ "time"
+)
+
+// upstreamBalancer performs smooth weighted round-robin selection over a pool,
+// skipping upstreams in a cooldown window after a failure. State (per-key
+// current weights, per-URL cooldown) is shared across requests behind a mutex —
+// a failed upstream cools for every caller. The now func is injectable for tests.
+type upstreamBalancer struct {
+ mu sync.Mutex
+ current map[string]map[string]int // key -> url -> SWRR current weight
+ cooldown map[string]time.Time // url -> cooling-until (global across keys)
+ cool time.Duration
+ now func() time.Time
+}
+
+func newUpstreamBalancer(cool time.Duration, now func() time.Time) *upstreamBalancer {
+ if now == nil {
+ now = time.Now
+ }
+ return &upstreamBalancer{
+ current: map[string]map[string]int{},
+ cooldown: map[string]time.Time{},
+ cool: cool,
+ now: now,
+ }
+}
+
+// pick selects the next upstream for key via smooth weighted round-robin over the
+// non-cooling members of pool. Returns false when every member is cooling.
+func (b *upstreamBalancer) pick(key string, pool []Upstream) (Upstream, bool) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ t := b.now()
+ cw := b.current[key]
+ if cw == nil {
+ cw = map[string]int{}
+ b.current[key] = cw
+ }
+
+ bestIdx, total := -1, 0
+ for i := range pool {
+ up := pool[i]
+ if until, ok := b.cooldown[up.URL]; ok && t.Before(until) {
+ continue
+ }
+ w := up.Weight
+ if w <= 0 {
+ w = 1
+ }
+ cw[up.URL] += w
+ total += w
+ if bestIdx == -1 || cw[up.URL] > cw[pool[bestIdx].URL] {
+ bestIdx = i
+ }
+ }
+ if bestIdx == -1 {
+ return Upstream{}, false
+ }
+ cw[pool[bestIdx].URL] -= total
+ return pool[bestIdx], true
+}
+
+// markFailed puts url into a cooldown window starting now.
+func (b *upstreamBalancer) markFailed(url string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.cooldown[url] = b.now().Add(b.cool)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamBalancer -race`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/upstream_balancer.go go/upstream_balancer_internal_test.go
+git commit -m "$(printf 'feat(api): upstreamBalancer — smooth weighted RR + cooldown\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 3: `upstreamTransport` (failover RoundTripper)
+
+**Files:**
+- Create: `go/upstream_transport.go`
+- Test: `go/upstream_transport_internal_test.go`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `go/upstream_transport_internal_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+type fakeRoundTripper struct {
+ fn func(*http.Request) (*http.Response, error)
+}
+
+func (f fakeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f.fn(r) }
+
+func newResp(status int) *http.Response {
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(strings.NewReader("ok")),
+ Header: http.Header{},
+ }
+}
+
+func requestWithPool(pool []Upstream, key string) *http.Request {
+ req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}"))
+ req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("{}")), nil }
+ ctx := context.WithValue(req.Context(), poolCtxKey, pool)
+ ctx = context.WithValue(ctx, keyCtxKey, key)
+ return req.WithContext(ctx)
+}
+
+func TestUpstreamTransport_FailoverThenSuccess_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ var hits []string
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ hits = append(hits, r.URL.Host)
+ if r.URL.Host == "a" {
+ return newResp(http.StatusBadGateway), nil
+ }
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a", Weight: 1}, {URL: "http://b", Weight: 1}}
+
+ resp, err := tr.RoundTrip(requestWithPool(pool, "k"))
+ if err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if len(hits) != 2 {
+ t.Fatalf("attempts = %v, want 2 (a then b)", hits)
+ }
+}
+
+func TestUpstreamTransport_HeaderInjection_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ var gotAuth string
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ gotAuth = r.Header.Get("Authorization")
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 1, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a", Headers: map[string]string{"Authorization": "Bearer up-key"}}}
+
+ if _, err := tr.RoundTrip(requestWithPool(pool, "k")); err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if gotAuth != "Bearer up-key" {
+ t.Fatalf("injected auth = %q, want Bearer up-key", gotAuth)
+ }
+}
+
+func TestUpstreamTransport_AllFail_Bad(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ return newResp(http.StatusServiceUnavailable), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a"}, {URL: "http://b"}}
+
+ _, err := tr.RoundTrip(requestWithPool(pool, "k"))
+ var re *routerError
+ if !core.As(err, &re) || re.status != http.StatusServiceUnavailable {
+ t.Fatalf("err = %v, want *routerError status 503", err)
+ }
+}
+```
+
+> Note: `core.As`, `routerError`, `poolCtxKey`, `keyCtxKey`, and `defaultFailoverStatuses` are defined in Task 4's `upstream_router.go`. This test file will not compile until Task 4 lands. Implement Task 3's production file now; if running tests before Task 4, expect a compile error naming those symbols (that IS the failing state). Otherwise reorder to write Task 4's `upstream_router.go` symbol stubs first — the recommended path is to do Steps 3 of Task 3 and Task 4 together, then run both test suites.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamTransport`
+Expected: FAIL — `undefined: upstreamTransport` (and `routerError`/`poolCtxKey`/etc. until Task 4).
+
+- [ ] **Step 3: Write the implementation**
+
+Create `go/upstream_transport.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "net/http"
+ "net/url" // Note: AX-6 — url.URL fields are structural for per-attempt upstream rewriting.
+
+ core "dappco.re/go"
+)
+
+// upstreamTransport is the http.RoundTripper that owns weighted selection and
+// passive failover. The per-request pool and key are read from the request
+// context (bound by the router handler). On a transport error or a failover
+// status it marks the upstream cooling and retries the next, up to maxAttempts.
+//
+// SECURITY: this transport intentionally dispatches to operator-configured
+// upstreams without re-applying the request-time SSRF guard. Upstream URLs are
+// validated once at registration (UpstreamRegistry.validate, default-deny with
+// AllowPrivateUpstreams opt-in), so loopback/private model endpoints are
+// permitted by design. See spec §8.
+type upstreamTransport struct {
+ base http.RoundTripper
+ balancer *upstreamBalancer
+ maxAttempts int
+ failover map[int]bool
+}
+
+func (t *upstreamTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ pool, ok := poolFromContext(req.Context())
+ if !ok || len(pool) == 0 {
+ return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "no upstream pool bound to request"}
+ }
+ key, _ := keyFromContext(req.Context())
+
+ attempts := t.maxAttempts
+ if attempts <= 0 || attempts > len(pool) {
+ attempts = len(pool)
+ }
+
+ var lastErr error
+ for i := 0; i < attempts; i++ {
+ up, ok := t.balancer.pick(key, pool)
+ if !ok {
+ break
+ }
+ target, err := url.Parse(up.URL)
+ if err != nil {
+ t.balancer.markFailed(up.URL)
+ lastErr = err
+ continue
+ }
+
+ out := req.Clone(req.Context())
+ if out.GetBody != nil {
+ if body, berr := out.GetBody(); berr == nil {
+ out.Body = body
+ }
+ }
+ applyUpstream(out, target)
+ for k, v := range up.Headers {
+ out.Header.Set(k, v)
+ }
+
+ //#nosec G107 -- upstream is operator-configured and validated at registration
+ // (UpstreamRegistry default-deny + AllowPrivateUpstreams opt-in); the request-time
+ // SSRF guard is deliberately not re-applied here. See spec §8 / Mantis upstream-router.
+ resp, err := t.base.RoundTrip(out)
+ if err != nil {
+ t.balancer.markFailed(up.URL)
+ lastErr = err
+ continue
+ }
+ if t.failover[resp.StatusCode] {
+ t.balancer.markFailed(up.URL)
+ drainAndClose(resp.Body)
+ lastErr = core.E("upstream", core.Sprintf("upstream %s returned %d", up.URL, resp.StatusCode), nil)
+ continue
+ }
+ return resp, nil
+ }
+
+ if lastErr != nil {
+ // Detail goes to the error (logged by ErrorHandler); the client sees a
+ // generic envelope so upstream URLs never leak.
+ return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "no healthy upstream available", cause: lastErr}
+ }
+ return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "all upstreams cooling"}
+}
+
+// applyUpstream rewrites the outbound request to target the chosen upstream.
+// A base path on the upstream URL is prefixed to the incoming request path.
+func applyUpstream(out *http.Request, target *url.URL) {
+ out.URL.Scheme = target.Scheme
+ out.URL.Host = target.Host
+ out.Host = target.Host
+ if base := trimTrailingSlashes(target.Path); base != "" {
+ out.URL.Path = base + out.URL.Path
+ if out.URL.RawPath != "" {
+ out.URL.RawPath = base + out.URL.RawPath
+ }
+ }
+}
+
+func drainAndClose(body interface{ Close() error }) {
+ if body != nil {
+ _ = body.Close()
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass** (after Task 4 lands the shared symbols)
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamTransport -race`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/upstream_transport.go go/upstream_transport_internal_test.go
+git commit -m "$(printf 'feat(api): upstreamTransport — selection + passive failover RoundTripper\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 4: Router config, options, default selector, engine wiring
+
+**Files:**
+- Create: `go/upstream_router.go`
+- Modify: `go/options.go` (add `WithUpstreamRouter` + option helpers)
+- Modify: `go/api.go` (add `upstreamRouter` field; mount in `build()`)
+
+- [ ] **Step 1: Write the implementation file (`upstream_router.go`)**
+
+Create `go/upstream_router.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httputil" // Note: AX-6 — reverse-proxy mechanics are structural; no core primitive.
+ "net/url" // Note: AX-6 — url.Parse is structural for the Rewrite placeholder target.
+ "strconv"
+ "time"
+
+ core "dappco.re/go"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ defaultUpstreamRouterPath = "/v1/chat/completions"
+ defaultUpstreamCooldown = 10 * time.Second
+
+ errCodeInvalidRequest = "invalid_request"
+ errCodeInvalidRequestBody = "invalid_request_body"
+ errCodeRoutingRejected = "routing_rejected"
+ errCodeNoUpstream = "no_upstream_for_key"
+ errCodeRequestTooLarge = "request_too_large"
+ errCodeUpstreamUnavailable = "upstream_unavailable"
+ errCodeInvalidUpstreamResp = "invalid_upstream_response"
+)
+
+type ctxKey int
+
+const (
+ poolCtxKey ctxKey = iota
+ keyCtxKey
+ ginCtxKey
+)
+
+// Selector resolves the routing key from the request. body holds the (bounded)
+// request body, already read by the handler; it may be empty for bodyless requests.
+type Selector func(c *gin.Context, body []byte) (key string, err error)
+
+// RouteFunc inspects the payload after the selector and may override the key or
+// reject the request. Returning the same key is a no-op; a non-nil error aborts.
+type RouteFunc func(c *gin.Context, key string, body []byte) (newKey string, err error)
+
+// UpstreamRouterOption configures a router built by WithUpstreamRouter.
+type UpstreamRouterOption func(*upstreamRouterConfig)
+
+type upstreamRouterConfig struct {
+ registry *UpstreamRegistry
+ selector Selector
+ hook RouteFunc
+ paths []string
+ inRaw []any
+ outRaw []any
+ in []compiledTransformer
+ out []compiledTransformer
+ maxAttempts int
+ cooldown time.Duration
+ failover map[int]bool
+ transport http.RoundTripper
+}
+
+// routerError carries an HTTP status + envelope code from the transport or
+// ModifyResponse to the ReverseProxy ErrorHandler.
+type routerError struct {
+ status int
+ code string
+ message string
+ cause error
+}
+
+func (e *routerError) Error() string {
+ if e.cause != nil {
+ return e.message + ": " + e.cause.Error()
+ }
+ return e.message
+}
+
+func (e *routerError) Unwrap() error { return e.cause }
+
+// WithSelector overrides the routing-key selector. Default: defaultModelSelector.
+func WithSelector(fn Selector) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.selector = fn }
+}
+
+// WithRouteHook installs a decision hook to inspect the payload and override/reject.
+func WithRouteHook(fn RouteFunc) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.hook = fn }
+}
+
+// WithRouterPaths sets the mounted paths (default ["/v1/chat/completions"]).
+// Each path forwards its own path + query to the chosen upstream.
+func WithRouterPaths(paths ...string) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.paths = paths }
+}
+
+// WithUpstreamTransformerIn adds request-body transformers (reuses the existing
+// TransformerIn machinery; FieldRenamer etc. work). Operates on the raw body.
+func WithUpstreamTransformerIn(t ...any) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.inRaw = append(cfg.inRaw, t...) }
+}
+
+// WithUpstreamTransformerOut adds response-body transformers, applied only to
+// buffered (non-streaming) responses, on the raw upstream body.
+func WithUpstreamTransformerOut(t ...any) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.outRaw = append(cfg.outRaw, t...) }
+}
+
+// WithFailover sets the max upstream attempts (default len(pool), each tried once)
+// and the cooldown applied to a failed upstream (default 10s).
+func WithFailover(maxAttempts int, cooldown time.Duration) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) {
+ cfg.maxAttempts = maxAttempts
+ if cooldown > 0 {
+ cfg.cooldown = cooldown
+ }
+ }
+}
+
+// WithFailoverStatuses overrides which response statuses trigger failover
+// (default: all >= 500). Pass e.g. 429 to also fail over on rate-limit responses.
+func WithFailoverStatuses(statuses ...int) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) {
+ cfg.failover = map[int]bool{}
+ for _, s := range statuses {
+ cfg.failover[s] = true
+ }
+ }
+}
+
+// WithUpstreamTransport sets the base RoundTripper used for dispatch (custom TLS,
+// timeouts). Default: a clone of http.DefaultTransport.
+func WithUpstreamTransport(rt http.RoundTripper) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.transport = rt }
+}
+
+// defaultFailoverStatuses returns the default failover status set: all >= 500.
+func defaultFailoverStatuses() map[int]bool {
+ m := map[int]bool{}
+ for s := 500; s <= 599; s++ {
+ m[s] = true
+ }
+ return m
+}
+
+// defaultModelSelector reads the OpenAI-style "model" field from a JSON body.
+func defaultModelSelector(_ *gin.Context, body []byte) (string, error) {
+ var probe struct {
+ Model string `json:"model"`
+ }
+ if res := core.JSONUnmarshal(body, &probe); !res.OK {
+ return "", core.E("upstream.selector", "request body is not valid JSON", nil)
+ }
+ if core.Trim(probe.Model) == "" {
+ return "", core.E("upstream.selector", "request body has no \"model\" field", nil)
+ }
+ return probe.Model, nil
+}
+
+func poolFromContext(ctx context.Context) ([]Upstream, bool) {
+ pool, ok := ctx.Value(poolCtxKey).([]Upstream)
+ return pool, ok
+}
+
+func keyFromContext(ctx context.Context) (string, bool) {
+ key, ok := ctx.Value(keyCtxKey).(string)
+ return key, ok
+}
+
+// finalise resolves defaults and compiles transformer pipelines. Returns an
+// error if a transformer fails to compile.
+func (cfg *upstreamRouterConfig) finalise() error {
+ if cfg.selector == nil {
+ cfg.selector = defaultModelSelector
+ }
+ if len(cfg.paths) == 0 {
+ cfg.paths = []string{defaultUpstreamRouterPath}
+ }
+ if cfg.cooldown <= 0 {
+ cfg.cooldown = defaultUpstreamCooldown
+ }
+ if cfg.failover == nil {
+ cfg.failover = defaultFailoverStatuses()
+ }
+ if cfg.transport == nil {
+ cfg.transport = http.DefaultTransport
+ }
+ in, err := compileTransformerPipeline(transformerDirectionIn, cfg.inRaw)
+ if err != nil {
+ return err
+ }
+ out, err := compileTransformerPipeline(transformerDirectionOut, cfg.outRaw)
+ if err != nil {
+ return err
+ }
+ cfg.in, cfg.out = in, out
+ return nil
+}
+
+// buildProxy constructs the shared ReverseProxy for the router.
+func (cfg *upstreamRouterConfig) buildProxy() *httputil.ReverseProxy {
+ balancer := newUpstreamBalancer(cfg.cooldown, time.Now)
+ transport := &upstreamTransport{
+ base: cfg.transport,
+ balancer: balancer,
+ maxAttempts: cfg.maxAttempts,
+ failover: cfg.failover,
+ }
+ return &httputil.ReverseProxy{
+ Transport: transport,
+ FlushInterval: -1, // stream SSE / chunked responses through immediately
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ // Placeholder target so the pipeline has a valid URL; the transport
+ // overrides scheme/host/path per attempt for the selected upstream.
+ if pool, ok := poolFromContext(pr.In.Context()); ok && len(pool) > 0 {
+ if target, err := url.Parse(pool[0].URL); err == nil {
+ pr.Out.URL.Scheme = target.Scheme
+ pr.Out.URL.Host = target.Host
+ }
+ }
+ pr.SetXForwarded()
+ },
+ ModifyResponse: cfg.modifyResponse,
+ ErrorHandler: cfg.errorHandler,
+ }
+}
+
+func (cfg *upstreamRouterConfig) modifyResponse(resp *http.Response) error {
+ if len(cfg.out) == 0 {
+ return nil
+ }
+ if isEventStream(resp.Header.Get("Content-Type")) {
+ return nil // streaming: pass through untransformed
+ }
+ body, err := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if err != nil {
+ return &routerError{status: http.StatusBadGateway, code: errCodeInvalidUpstreamResp, message: "could not read upstream response", cause: err}
+ }
+ c, _ := resp.Request.Context().Value(ginCtxKey).(*gin.Context)
+ transformed, err := runTransformerPipeline(c, body, cfg.out)
+ if err != nil {
+ return &routerError{status: http.StatusBadGateway, code: errCodeInvalidUpstreamResp, message: "response transform failed", cause: err}
+ }
+ resp.Body = io.NopCloser(bytes.NewReader(transformed))
+ resp.ContentLength = int64(len(transformed))
+ resp.Header.Set("Content-Length", strconv.Itoa(len(transformed)))
+ return nil
+}
+
+func (cfg *upstreamRouterConfig) errorHandler(w http.ResponseWriter, _ *http.Request, err error) {
+ re := &routerError{status: http.StatusBadGateway, code: errCodeUpstreamUnavailable, message: "upstream request failed"}
+ var got *routerError
+ if core.As(err, &got) {
+ re = got
+ }
+ slog.Warn("upstream router dispatch failed", "code", re.code, "err", err.Error())
+ w.Header().Set("Content-Type", "application/json")
+ if re.status == http.StatusServiceUnavailable {
+ w.Header().Set("Retry-After", strconv.Itoa(int(cfg.cooldown.Seconds())))
+ }
+ w.WriteHeader(re.status)
+ _ = json.NewEncoder(w).Encode(Fail(re.code, re.message))
+}
+
+// handler returns the gin.HandlerFunc mounted at each router path.
+func (cfg *upstreamRouterConfig) handler(proxy *httputil.ReverseProxy) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ body, ok := readUpstreamBody(c)
+ if !ok {
+ return
+ }
+
+ key, err := cfg.selector(c, body)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequest, err.Error()))
+ return
+ }
+ if cfg.hook != nil {
+ newKey, herr := cfg.hook(c, key, body)
+ if herr != nil {
+ c.AbortWithStatusJSON(http.StatusForbidden, Fail(errCodeRoutingRejected, herr.Error()))
+ return
+ }
+ if core.Trim(newKey) != "" {
+ key = newKey
+ }
+ }
+
+ if len(cfg.in) > 0 {
+ body, err = runTransformerPipeline(c, body, cfg.in)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequestBody, err.Error()))
+ return
+ }
+ }
+
+ pool, ok := cfg.registry.resolve(key)
+ if !ok {
+ c.AbortWithStatusJSON(http.StatusNotFound, Fail(errCodeNoUpstream, "no upstream registered for key: "+key))
+ return
+ }
+
+ bound := body // capture for GetBody closure
+ c.Request.Body = io.NopCloser(bytes.NewReader(bound))
+ c.Request.ContentLength = int64(len(bound))
+ c.Request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(bound)), nil }
+
+ ctx := context.WithValue(c.Request.Context(), poolCtxKey, pool)
+ ctx = context.WithValue(ctx, keyCtxKey, key)
+ ctx = context.WithValue(ctx, ginCtxKey, c)
+ c.Request = c.Request.WithContext(ctx)
+
+ proxy.ServeHTTP(upstreamResponseWriter(c), c.Request)
+ }
+}
+
+// upstreamResponseWriter unwraps gin's ResponseWriter to the underlying
+// http.ResponseWriter, which httputil.ReverseProxy requires for flush/cancel.
+func upstreamResponseWriter(c *gin.Context) http.ResponseWriter {
+ var w http.ResponseWriter = c.Writer
+ if uw, ok := w.(interface{ Unwrap() http.ResponseWriter }); ok {
+ w = uw.Unwrap()
+ }
+ return w
+}
+
+func readUpstreamBody(c *gin.Context) ([]byte, bool) {
+ limited := http.MaxBytesReader(c.Writer, c.Request.Body, maxToolRequestBodyBytes)
+ body, err := io.ReadAll(limited)
+ if err != nil {
+ if err.Error() == "http: request body too large" {
+ c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, Fail(errCodeRequestTooLarge, "Request body exceeds the maximum allowed size"))
+ return nil, false
+ }
+ c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequest, "Unable to read request body"))
+ return nil, false
+ }
+ return body, true
+}
+
+func isEventStream(contentType string) bool {
+ return core.HasPrefix(core.Lower(core.Trim(contentType)), "text/event-stream")
+}
+```
+
+> The Rewrite target is only a placeholder to satisfy `httputil.ReverseProxy` (which requires a non-nil `Rewrite`/`Director`); `upstreamTransport.RoundTrip` overrides scheme/host/path per attempt for the actually-selected upstream, so `pool[0]` here is never the real dispatch target.
+
+- [ ] **Step 2: Add the engine field and mount (modify `api.go`)**
+
+In `go/api.go`, add the field to the `Engine` struct (after `noRouteHandler gin.HandlerFunc` at line ~115):
+
+```go
+ // upstreamRouter, when set via WithUpstreamRouter, mounts a selector-keyed
+ // reverse proxy over a pool of HTTP upstreams at the configured paths.
+ upstreamRouter *upstreamRouterConfig
+```
+
+In `go/api.go` `build()`, after the chat-completions mount block (line ~443) add:
+
+```go
+ // Mount the selector-keyed upstream router when configured.
+ if e.upstreamRouter != nil {
+ proxy := e.upstreamRouter.buildProxy()
+ h := e.upstreamRouter.handler(proxy)
+ for _, p := range e.upstreamRouter.paths {
+ r.Any(p, h)
+ }
+ }
+```
+
+- [ ] **Step 3: Add `WithUpstreamRouter` (modify `options.go`)**
+
+In `go/options.go`, after `WithChatCompletionsPath` (line ~849) add:
+
+```go
+// WithUpstreamRouter mounts a selector-keyed reverse proxy that load-balances
+// each request across a runtime-mutable pool of HTTP upstreams (weighted
+// round-robin + passive failover, hybrid streaming, decision hook, transformer
+// composition). The registry is the source of truth for upstreams.
+//
+// Example:
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+// _ = reg.Set("lemma", api.Upstream{URL: "http://127.0.0.1:11434"})
+// engine, _ := api.New(api.WithUpstreamRouter(reg))
+func WithUpstreamRouter(reg *UpstreamRegistry, opts ...UpstreamRouterOption) Option {
+ return func(e *Engine) {
+ if reg == nil {
+ return
+ }
+ cfg := &upstreamRouterConfig{registry: reg}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(cfg)
+ }
+ }
+ if err := cfg.finalise(); err != nil {
+ // Transformer compile errors mirror the panic contract used by
+ // transformerRouteConfigForDescription (transformer_in.go:78).
+ panic(err)
+ }
+ e.upstreamRouter = cfg
+ }
+}
+```
+
+- [ ] **Step 4: Build and run all prior unit suites together**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go build ./ && GOWORK=off go test ./ -run 'TestUpstream' -race`
+Expected: PASS for `TestUpstreamRegistry*`, `TestUpstreamBalancer*`, `TestUpstreamTransport*` (Task 3's tests now compile and pass).
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/upstream_router.go go/options.go go/api.go go/upstream_transport_internal_test.go
+git commit -m "$(printf 'feat(api): WithUpstreamRouter — config, options, default model selector, engine mount\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 5: Integration tests (httptest end-to-end)
+
+**Files:**
+- Create: `go/upstream_router_test.go`
+
+- [ ] **Step 1: Write the failing integration tests**
+
+Create `go/upstream_router_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "bufio"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+ "github.com/gin-gonic/gin"
+)
+
+// newEngine builds a test engine with the router mounted, returning a live server.
+func serve(t *testing.T, reg *api.UpstreamRegistry, opts ...api.UpstreamRouterOption) *httptest.Server {
+ t.Helper()
+ e, err := api.New(api.WithUpstreamRouter(reg, opts...))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return httptest.NewServer(e.Handler())
+}
+
+func post(t *testing.T, base, path, body string) *http.Response {
+ t.Helper()
+ resp, err := http.Post(base+path, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST %s: %v", path, err)
+ }
+ return resp
+}
+
+func TestUpstreamRouter_RoutesByModel_Good(t *testing.T) {
+ upA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"upstream":"A"}`)
+ }))
+ defer upA.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("lemma", api.Upstream{URL: upA.URL}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"lemma"}`)
+ defer resp.Body.Close()
+ got, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(got), `"upstream":"A"`) {
+ t.Fatalf("body = %s, want routed to A", got)
+ }
+}
+
+func TestUpstreamRouter_MissingModel_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ _ = reg.SetDefault(api.Upstream{URL: "https://example.com"})
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", resp.StatusCode)
+ }
+}
+
+func TestUpstreamRouter_Failover_Good(t *testing.T) {
+ dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ defer dead.Close()
+ live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"ok":true}`)
+ }))
+ defer live.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("m", api.Upstream{URL: dead.URL}, api.Upstream{URL: live.URL}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (failed over to live)", resp.StatusCode)
+ }
+}
+
+func TestUpstreamRouter_AllDown_503_Ugly(t *testing.T) {
+ dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadGateway)
+ }))
+ defer dead.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: dead.URL})
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ got, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", resp.StatusCode)
+ }
+ if resp.Header.Get("Retry-After") == "" {
+ t.Error("missing Retry-After header on 503")
+ }
+ if strings.Contains(string(got), dead.URL) {
+ t.Error("upstream URL leaked into client response body")
+ }
+}
+
+func TestUpstreamRouter_StreamingPassthrough_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ f, _ := w.(http.Flusher)
+ for _, chunk := range []string{"data: a\n\n", "data: b\n\n", "data: [DONE]\n\n"} {
+ _, _ = io.WriteString(w, chunk)
+ if f != nil {
+ f.Flush()
+ }
+ }
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: up.URL})
+ // Out transformer present to prove it is NOT applied to streams.
+ srv := serve(t, reg, api.WithUpstreamTransformerOut(api.RenameFields(map[string]string{"x": "y"})))
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
+ t.Fatalf("Content-Type = %q, want text/event-stream", ct)
+ }
+ sc := bufio.NewScanner(resp.Body)
+ var lines int
+ for sc.Scan() {
+ if strings.HasPrefix(sc.Text(), "data:") {
+ lines++
+ }
+ }
+ if lines != 3 {
+ t.Fatalf("got %d data lines, want 3 (stream byte-preserved)", lines)
+ }
+}
+
+func TestUpstreamRouter_TransformInOut_Good(t *testing.T) {
+ var gotBody string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ _, _ = io.WriteString(w, `{"internal_id":42}`)
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: up.URL})
+ srv := serve(t, reg,
+ api.WithUpstreamTransformerIn(api.RenameFields(map[string]string{"q": "prompt"})),
+ api.WithUpstreamTransformerOut(api.RenameFields(map[string]string{"internal_id": "id"})),
+ )
+ defer srv.Close()
+
+ // Selector reads "model" from the original body; the in-transform then renames
+ // q->prompt before dispatch so the upstream sees the translated shape.
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m","q":"hello"}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(gotBody, `"prompt"`) {
+ t.Errorf("upstream body = %s, want renamed q->prompt", gotBody)
+ }
+ if !strings.Contains(string(out), `"id":42`) {
+ t.Errorf("client body = %s, want renamed internal_id->id", out)
+ }
+}
+
+func TestUpstreamRouter_RouteHookOverride_Good(t *testing.T) {
+ upB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"pool":"B"}`)
+ }))
+ defer upB.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("b", api.Upstream{URL: upB.URL})
+ srv := serve(t, reg, api.WithRouteHook(func(_ *gin.Context, _ string, _ []byte) (string, error) {
+ return "b", nil
+ }))
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"anything"}`)
+ defer resp.Body.Close()
+ got, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(got), `"pool":"B"`) {
+ t.Fatalf("body = %s, want hook-overridden pool B", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail then pass**
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRouter -race`
+Expected: after fixing the `*gin.Context` import note, PASS for all seven integration tests.
+
+- [ ] **Step 3: SSRF-posture integration assertion**
+
+Add to `go/upstream_router_test.go`:
+
+```go
+func TestUpstreamRouter_SSRFPosture_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry() // no allow-list
+ if err := reg.Set("m", api.Upstream{URL: "http://127.0.0.1:11434"}); err == nil {
+ t.Fatal("loopback accepted without AllowPrivateUpstreams, want rejection")
+ }
+}
+
+func TestUpstreamRouter_Composition_Middleware_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"ok":true}`)
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: up.URL})
+ // WithSunset adds a Sunset header to every response via engine middleware.
+ e, _ := api.New(api.WithSunset("2026-12-31", "https://api.example.com/v2"), api.WithUpstreamRouter(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ if resp.Header.Get("Sunset") == "" {
+ t.Fatal("Sunset header absent — engine middleware did not wrap the mounted router")
+ }
+}
+```
+
+> Uses `WithSunset` (deterministic per-response header) rather than auth to prove engine middleware wraps the mounted router — the API's bearer middleware is permissive, so a missing token does not reliably 401. Confirm the exact header name is `Sunset` (RFC 8594; see `sunset.go`).
+
+Run: `cd /Users/snider/Code/core/api/go && GOWORK=off go test ./ -run TestUpstreamRouter -race`
+Expected: PASS (all integration tests including SSRF + composition).
+
+- [ ] **Step 4: Write the example test (godoc-facing)**
+
+Create `go/upstream_router_example_test.go`:
+
+```go
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "fmt"
+
+ api "dappco.re/go/api"
+)
+
+func ExampleWithUpstreamRouter() {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8"))
+ _ = reg.Set("lemma",
+ api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2},
+ api.Upstream{URL: "http://10.0.0.6:8000", Weight: 1},
+ )
+ _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"})
+
+ engine, err := api.New(api.WithUpstreamRouter(reg))
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println(engine.Addr())
+ // Output: :8080
+}
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+cd /Users/snider/Code/core/api
+git add go/upstream_router_test.go go/upstream_router_example_test.go
+git commit -m "$(printf 'test(api): upstream router integration — routing, failover, streaming, transforms, SSRF, composition\n\nCo-Authored-By: Virgil ')"
+```
+
+---
+
+## Task 6: Full QA gate
+
+**Files:** none (verification only)
+
+- [ ] **Step 1: Format + vet + full test with race**
+
+Run:
+```bash
+cd /Users/snider/Code/core/api/go
+gofmt -l upstream_registry.go upstream_balancer.go upstream_transport.go upstream_router.go
+GOWORK=off go vet ./
+GOWORK=off go test ./ -race -count=1
+```
+Expected: `gofmt -l` prints nothing (all formatted); `vet` clean; all tests PASS.
+
+- [ ] **Step 2: Lint + security audit (matches repo `core go qa full`)**
+
+Run:
+```bash
+cd /Users/snider/Code/core/api/go
+GOWORK=off go test ./ -run 'Example' -count=1 # godoc examples compile + match Output
+golangci-lint run ./ 2>/dev/null || echo "run golangci-lint if available"
+gosec -quiet ./ 2>/dev/null || echo "run gosec if available"
+```
+Expected: example output matches; lint clean; `gosec` reports only the annotated `#nosec G107` on `upstreamTransport.RoundTrip` (justified — registration-validated operator upstreams).
+
+- [ ] **Step 3: Confirm the gateway binary still builds**
+
+Run: `cd /Users/snider/Code/core/api/go && go build ./cmd/gateway/ && GOWORK=off go build ./...`
+Expected: exit 0 (no regression to existing build).
+
+- [ ] **Step 4: Commit any formatting/lint fixes**
+
+```bash
+cd /Users/snider/Code/core/api
+git add -A go/
+git commit -m "$(printf 'chore(api): gofmt + lint pass for upstream router\n\nCo-Authored-By: Virgil ')" || echo "nothing to commit"
+```
+
+---
+
+## Spec coverage check
+
+| Spec section | Task |
+|---|---|
+| §4 `WithUpstreamRouter`, `Upstream`, `UpstreamRegistry`, `Selector`, `RouteFunc`, options | Tasks 1, 4 |
+| §4 `AllowPrivateUpstreams` registry option | Task 1 |
+| §5 `UpstreamRegistry` / `upstreamBalancer` / `upstreamTransport` / handler units | Tasks 1, 2, 3, 4 |
+| §6 data flow (body→selector→hook→transformIn→pool→proxy→transport→response) | Tasks 4, 5 |
+| §7 error taxonomy (400/403/404/413/502/503 + passthrough) | Tasks 4, 5 |
+| §8 SSRF block-by-default + opt-in + `#nosec` + no URL leak | Tasks 1, 3, 5 |
+| §9 testing matrix (Good/Bad/Ugly, weighted spread, cooldown, streaming, transforms, composition) | Tasks 1–5 |
+| §10 file layout | all |
+
+**Deferred to future extensions (spec §11), not in this plan:** sticky/consistent-hash, active health checks, direct-upstream hook return, per-chunk stream transforms, per-pool rate limits.
diff --git a/docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md b/docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md
new file mode 100644
index 0000000..49db605
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-06-chat-completions-remote-backend-design.md
@@ -0,0 +1,257 @@
+# Chat Completions — Remote Backend + Format Adapters — Design
+
+- **Date:** 2026-06-06
+- **Status:** Design — approved, pending implementation plan
+- **Module:** `dappco.re/go/api` (`core/api/go`)
+- **Author:** Snider + Cladius (brainstorming)
+- **Builds on:** `WithUpstreamRouter` (`docs/superpowers/specs/2026-06-06-upstream-router-design.md`) — reuses `UpstreamRegistry`, `upstreamBalancer`, `upstreamTransport` unchanged.
+- **Related:** `RFC.md` §11 (chat completions), `RFC.providers.md` Open Question 4 (go-ai backend) + §7.3 (PHP-direct anti-pattern).
+
+---
+
+## 1. Context & Problem
+
+`RFC.md` §11 specifies an OpenAI-compatible `POST /v1/chat/completions`. Today `WithChatCompletions(resolver *ModelResolver)` resolves a model name to a **local, in-process** `inference.TextModel` (`chat_completions.go:714`) and is **loopback-only** (`:693`). There is no way for that endpoint to serve a model hosted on a **remote** OpenAI-compatible server (Ollama, LiteLLM, vLLM) or a non-OpenAI server (Ollama-native, Anthropic).
+
+`RFC.providers.md` Open Question 4 — *"does go-ai proxy to Ollama / LiteLLM, run in-process, or hybrid?"* — is flagged there as the highest-leverage architectural decision. This feature answers it: **hybrid**. Local models are served in-process; everything else is routed to a remote pool via the already-built upstream router, with per-model format adapters for non-OpenAI backends.
+
+This also enables the fix for the `RFC.providers.md` §7.3 anti-pattern (PHP calling external model services directly): one stable Go endpoint fronts heterogeneous backends.
+
+## 2. Goals / Non-Goals
+
+**Goals**
+- One `/v1/chat/completions` endpoint that serves **local in-process** models and **remote** models, decided per request by model name.
+- Reuse the upstream router's `UpstreamRegistry` + weighted-RR + passive-failover transport for the remote path.
+- **Passthrough by default** for OpenAI-compatible upstreams (verbatim request + response, preserving fields our struct doesn't model).
+- **Per-model format adapters** for non-OpenAI upstreams: request mapping, non-streaming response mapping, and **per-chunk streaming transcoding**. Built-ins: Ollama-native, Anthropic.
+- Opt-in to expose the endpoint off-loopback, gated by a configured bearer.
+
+**Non-Goals (v1)**
+- A generic/pluggable streaming-transcoder framework beyond the two built-in adapters (consumers can implement `ChatFormatAdapter` themselves, but only Ollama + Anthropic ship).
+- Tool/function-calling translation across formats (passthrough preserves OpenAI `tools`; adapter tool-mapping is a future extension).
+- Embeddings/scoring endpoints (separate go-ai provider work, `RFC.providers.md` §4.1).
+- Changing the local inference path (`serveStreaming`/`serveNonStreaming`) — reused unchanged.
+
+## 3. Settled Decisions
+
+| Fork | Decision |
+|------|----------|
+| Dispatch precedence | **Local-first** (`resolver.Knows(model)`) → else **remote** (per-model pool or `SetDefault`) → else 404 |
+| Bind posture | **Configurable opt-in** (`WithChatCompletionsAllowRemoteClients`), allowed off-loopback only when a bearer is configured |
+| Translation | **Per-pool adapters**: passthrough default; Ollama + Anthropic built-ins with request + non-stream + **streaming** transcoding |
+| Proxy core | **Reuse `upstreamBalancer`+`upstreamTransport` directly** (not `httputil.ReverseProxy`) — ReverseProxy can't rewrite request bodies per-format or stream-transcode |
+| Scope | One spec; internal unit boundaries kept crisp (dispatcher / passthrough / adapter iface / Ollama / Anthropic / bind) |
+
+## 4. Public Surface
+
+```go
+// WithChatCompletionsRemote attaches a remote backend to /v1/chat/completions.
+// Use WITH WithChatCompletions for hybrid (local-first); ALONE for remote-only.
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8"))
+// _ = reg.Set("claude-3-opus", api.Upstream{URL: "https://anthropic-gw.lthn.sh"})
+// _ = reg.Set("llama3:70b", api.Upstream{URL: "http://gpu1:11434"}, api.Upstream{URL: "http://gpu2:11434"})
+// _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"}) // OpenAI-compatible — passthrough
+// engine, _ := api.New(
+// api.WithChatCompletions(localResolver), // local-first (optional)
+// api.WithChatCompletionsRemote(reg,
+// api.WithChatModelAdapter("llama3:70b", api.OllamaAdapter()),
+// api.WithChatModelAdapter("claude-3-opus", api.AnthropicAdapter()),
+// ),
+// )
+func WithChatCompletionsRemote(reg *UpstreamRegistry, opts ...ChatRemoteOption) Option
+
+type ChatRemoteOption func(*chatRemoteConfig)
+func WithChatModelAdapter(model string, a ChatFormatAdapter) ChatRemoteOption // non-OpenAI models only
+func WithChatRemoteFailover(maxAttempts int, cooldown time.Duration) ChatRemoteOption
+func WithChatRemoteTransport(rt http.RoundTripper) ChatRemoteOption
+
+// WithChatCompletionsAllowRemoteClients permits non-loopback clients, but only
+// when a bearer is configured (WithBearerAuth) — mirrors the engine's
+// ErrPublicBindNoBearer invariant. Without it, the endpoint stays loopback-only.
+func WithChatCompletionsAllowRemoteClients() Option
+
+// ChatFormatAdapter maps between the OpenAI chat shape and a non-OpenAI upstream.
+// Passthrough (OpenAI-compatible) upstreams need NO adapter — that is the default.
+type ChatFormatAdapter interface {
+ Name() string // "ollama", "anthropic"
+ UpstreamPath() string // "/api/chat", "/v1/messages"
+ // BuildRequest maps the OpenAI request into the upstream body + protocol headers
+ // (Content-Type, anthropic-version). Operator secrets (x-api-key) live in Upstream.Headers.
+ BuildRequest(req ChatCompletionRequest) (body []byte, headers map[string]string, err error)
+ // DecodeResponse maps a complete (non-streaming) upstream body into the OpenAI response.
+ DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error)
+ // Transcoder converts the upstream stream into OpenAI chunk SSE. nil = non-stream only.
+ Transcoder() ChatStreamTranscoder
+}
+
+// ChatStreamTranscoder converts an upstream response stream into OpenAI
+// chat.completion.chunk SSE events written to w (flushing as it goes); it emits
+// the terminating "data: [DONE]". Returns on upstream EOF or ctx cancellation.
+type ChatStreamTranscoder interface {
+ Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error
+}
+type ChatStreamMeta struct {
+ ID string
+ Model string
+ Created int64
+}
+
+// Built-in adapters (only the non-OpenAI formats need one).
+func OllamaAdapter() ChatFormatAdapter // OpenAI ⇄ Ollama-native /api/chat (NDJSON stream)
+func AnthropicAdapter() ChatFormatAdapter // OpenAI ⇄ Anthropic /v1/messages (event-stream)
+```
+
+**Contract rules**
+- **Passthrough is the default; adapters are per-model exceptions.** A model with no `WithChatModelAdapter` is forwarded verbatim (raw request bytes up, raw response bytes down), preserving fields the `ChatCompletionRequest`/`Response` structs don't model (`tools`, `response_format`, `logprobs`, …).
+- **Composable**: `WithChatCompletions` (local) and `WithChatCompletionsRemote` (remote) each set an Engine field; `build()` mounts one handler holding `resolver` (optional) + `remote` (optional). Local-only, remote-only, and hybrid all fall out.
+- **`WithChatModelAdapter` keys by model** (the registry key); the adapter owns the upstream path + both-direction mapping + protocol headers.
+- Remote failover/transport config reuses the router's machinery; defaults: `maxAttempts = len(pool)`, `cooldown = 10s`, base transport = cloned `http.DefaultTransport`.
+
+## 5. Dispatch Flow
+
+```
+ServeHTTP(c):
+ 1. not-configured: resolver==nil && remote==nil → 503 service_unavailable
+ 2. bind guard: loopback → OK; non-loopback → OK only if allowRemote && bearerConfigured, else 403
+ 3. decode body → req; KEEP raw bytes; invalid → 400 invalid_request_error (param body)
+ 4. validate(req) (existing); invalid → mapped 400
+ 5. LOCAL:
+ - PURE-LOCAL (remote == nil): model := resolver.ResolveModel(req.Model) directly
+ → existing serveStreaming / serveNonStreaming. This is the CURRENT behaviour,
+ unchanged — no Knows() gate, so no risk of a loadable model 404ing.
+ - HYBRID (remote != nil): if resolver != nil && resolver.Knows(req.Model):
+ model := resolver.ResolveModel(req.Model) // load; err → mapResolverError
+ → existing serveStreaming / serveNonStreaming; return
+ else fall through to remote (avoids loading a remote-only model locally).
+ 6. REMOTE (remote != nil): pool, ok := remote.reg.resolve(req.Model); !ok → 404 model_not_found
+ adapter := remote.adapters[req.Model] // nil ⇒ passthrough
+ dispatchRemote(c, req, raw, pool, adapter); return
+ 7. else → 404 model_not_found
+
+Note: `resolve` returns the default pool (if `SetDefault` was called) for ANY unmatched
+model, so with a default pool set the 404 in step 6 fires only when no default exists —
+unknown models are proxied to the default upstream (which returns its own model_not_found).
+`Knows()` MUST mirror `ResolveModel`'s resolution sources exactly (cache ∪ models.yaml ∪
+discovery) so a Knows()-false model is genuinely one ResolveModel couldn't serve.
+
+dispatchRemote(c, req, raw, pool, adapter):
+ a. build upstream request:
+ passthrough (adapter==nil): path "/v1/chat/completions", body = raw
+ adapter: path = adapter.UpstreamPath(); body, hdrs = adapter.BuildRequest(req)
+ outReq := POST(path, body); set GetBody (replay); apply hdrs
+ b. bind {pool, key} on ctx; resp, err := transport.RoundTrip(outReq) // weighted pick + failover (reused)
+ err (*routerError) → OpenAI error shape (503 upstream_unavailable + Retry-After / 502)
+ c. deliver:
+ upstream non-2xx: passthrough → copy status+body verbatim; adapter → wrap into OpenAI error shape
+ req.Stream: passthrough → SSE headers; flushing io.Copy(resp.Body → c.Writer)
+ adapter → tr := adapter.Transcoder(); tr==nil → 400 (param stream);
+ else SSE headers; tr.Transcode(c.Writer, flush, resp.Body, meta)
+ non-stream: passthrough → copy resp body verbatim (200, application/json)
+ adapter → out := adapter.DecodeResponse(model, body); err → 502; c.JSON(200, out)
+```
+
+### 5.1 `ModelResolver.Knows(name) bool` (new)
+
+`ResolveModel` *loads* the model (`inference.LoadModel`), so it cannot be used as a cheap local-vs-remote test — it would load a remote-only model locally. The spec adds a **no-load existence check**:
+
+```go
+// Knows reports whether the resolver can serve name without loading it: a hit in
+// the loaded-model cache, the models.yaml mapping, or the (cached) discovery set.
+func (r *ModelResolver) Knows(name string) bool
+```
+
+Uses internals it already has (`loadedByName`, `modelsYAMLMapping`, `resolveDiscoveredPath`). Discovery results are cached so `Knows` stays cheap on the hot path.
+
+### 5.2 Delivery writer
+
+Streaming and buffered responses are written through gin's `c.Writer` (it implements `http.Flusher`) — never the unwrapped raw writer. This is the lesson carried from the upstream router: keeps gin's `Written()` tracking correct and avoids the superfluous-`WriteHeader` warning. The transcoder's `flush` callback is `c.Writer.Flush`.
+
+## 6. Format Adapters
+
+### 6.1 OllamaAdapter — OpenAI ⇄ Ollama-native `/api/chat`
+
+| Direction | Mapping |
+|---|---|
+| Request | `{model, messages:[{role,content}], stream, options:{temperature, top_p, top_k, num_predict←max_tokens, stop←stop}}`; headers `Content-Type: application/json`. NOTE: Ollama reads `stop` **inside `options`**, not at the top level. |
+| Non-stream resp | Ollama `{message:{role,content}, done, done_reason, prompt_eval_count, eval_count}` → content=`message.content`; `usage{prompt_tokens←prompt_eval_count, completion_tokens←eval_count}`; finish_reason=`length` if `done_reason=="length"` else `stop` |
+| Stream (NDJSON) | each line `{message:{content:}, done:false}` → OpenAI chunk `delta.content`; first chunk adds `delta.role:"assistant"`; final line `{done:true, done_reason, eval_count}` → final chunk `finish_reason`, then `data: [DONE]`. Flush per line. |
+
+### 6.2 AnthropicAdapter — OpenAI ⇄ Anthropic `/v1/messages`
+
+| Direction | Mapping |
+|---|---|
+| Request | OpenAI `role:"system"` messages → top-level `system`; rest → `messages:[{role,content}]`; `max_tokens` (mandatory — default if absent), `temperature, top_p, top_k, stop_sequences←stop, stream`; headers `anthropic-version: 2023-06-01`, `Content-Type: application/json` |
+| Non-stream resp | `{content:[{type:"text",text}], stop_reason, usage:{input_tokens,output_tokens}}` → content=concat text blocks; `usage{prompt_tokens←input_tokens, completion_tokens←output_tokens}`; finish_reason=map(`end_turn`→stop, `max_tokens`→length, `stop_sequence`→stop) |
+| Stream (event-stream) | parse named SSE events: `message_start` (seed id/usage), `content_block_delta`+`text_delta` → OpenAI `delta.content` (first adds `delta.role:"assistant"`), `message_delta` (capture `stop_reason`), `message_stop` → final chunk `finish_reason`, then `data: [DONE]`. Flush per delta. |
+
+Each adapter is an isolated unit (own file + tests). The Anthropic streaming transcoder is the fiddliest piece and gets the most adversarial coverage (fixture-driven).
+
+## 7. Bind Opt-in + Error Taxonomy
+
+**Bind.** The handler is constructed with `allowRemote` + `bearerConfigured` from the engine. Per-request guard: loopback always OK; non-loopback OK only if `allowRemote && bearerConfigured`, else 403. Mirrors `ErrPublicBindNoBearer` at the request layer. Documented caveat: `WithBearerAuth` is permissive, so operators must pair this with an auth-guarded route (`RequireAuth`) for true enforcement; the handler gate is the structural "don't expose local inference off-box without a configured bearer" guard.
+
+**Errors** — OpenAI shape (`{"error":{message,type,param,code}}`) via the existing `writeChatCompletionError`; upstream URLs never leak (details → logs).
+
+| Condition | HTTP | code |
+|---|---|---|
+| Not configured | 503 | service_unavailable |
+| Non-loopback w/o allowRemote+bearer | 403 | — |
+| Body decode / validation fail | 400 | (existing) |
+| Local load error | mapped | `mapResolverError` (`model_not_found`/`model_loading`/`inference_error`) |
+| Known neither locally nor remotely | 404 | `model_not_found` |
+| `adapter.BuildRequest` fail | 500 | `inference_error` |
+| All upstreams failed/cooling | 503 | `upstream_unavailable` + `Retry-After` |
+| `adapter.DecodeResponse` fail | 502 | `invalid_upstream_response` |
+| Stream requested, adapter non-streaming | 400 | — (param `stream`) |
+| Upstream non-2xx, passthrough | verbatim | upstream's OpenAI-ish error copied through |
+| Upstream non-2xx, adapter | mapped | upstream status/body wrapped into OpenAI error shape |
+
+## 8. Testing Strategy
+
+Reuses the router's tested `balancer`/`transport` (no re-test). Convention: `_Good/_Bad/_Ugly`, example test, `-race`, `GOWORK=off`.
+
+**Per-unit**
+- `ModelResolver.Knows()` — `_Good`: cache / `models.yaml` / discovered hits → true **without loading** (sentinel resolver asserts no load); `_Bad`: unknown → false.
+- Dispatcher (fake resolver + httptest remote): `Knows`-true → local; registered remote → proxied; default-pool → proxied; unknown → 404 `model_not_found`.
+- OllamaAdapter — table-driven `BuildRequest` / `DecodeResponse`; `Transcoder` fed a captured NDJSON fixture → OpenAI chunks, role-on-first, finish_reason, `data: [DONE]`.
+- AnthropicAdapter — `BuildRequest` (system extraction, mandatory `max_tokens` default, sampling, `anthropic-version`), `DecodeResponse` (text-block concat, `stop_reason` map, usage), `Transcoder` fed a captured event-stream fixture → OpenAI SSE + `[DONE]`. Most adversarial coverage.
+
+**Integration (`httptest` upstreams)**
+- Hybrid: local-first model in-process + remote model proxied on one endpoint.
+- Passthrough remote: request forwarded verbatim incl. an unmodelled field (`tools`) — fidelity; response verbatim; SSE passthrough.
+- Ollama e2e: upstream speaking `/api/chat` (non-stream + NDJSON stream) → client gets OpenAI shape.
+- Anthropic e2e: upstream speaking `/v1/messages` (non-stream + event-stream) → client gets OpenAI shape; `anthropic-version` sent.
+- Failover (reuses transport): dead+live upstreams → fails over.
+- Bind: non-loopback → 403 by default; with `WithChatCompletionsAllowRemoteClients`+`WithBearerAuth` → allowed; opt-in **without** bearer → still 403.
+- Errors: unknown → 404 `model_not_found`; stream+non-streaming-adapter → 400; all-down → 503 `upstream_unavailable`+`Retry-After`+no-URL-leak; upstream 4xx passthrough verbatim.
+
+**Gates:** `GOWORK=off go test ./ -race`; vet; gofmt; gosec.
+
+## 9. File Layout
+
+```
+go/chat_remote.go chatRemoteConfig, WithChatCompletionsRemote + opts, dispatchRemote, bind opt-in (+ _test, _example_test)
+go/chat_adapter.go ChatFormatAdapter / ChatStreamTranscoder / ChatStreamMeta
+go/chat_adapter_ollama.go OllamaAdapter (+ _test, testdata NDJSON fixture)
+go/chat_adapter_anthropic.go AnthropicAdapter (+ _test, testdata event-stream fixture)
+go/chat_completions.go (mod) handler holds resolver?+remote?+allowRemote+bearerConfigured; bind guard; local-first dispatch; ModelResolver.Knows
+go/options.go (mod) WithChatCompletionsRemote, WithChatModelAdapter, WithChatRemoteFailover, WithChatRemoteTransport, WithChatCompletionsAllowRemoteClients
+go/api.go (mod) Engine fields (chatRemote *chatRemoteConfig, chatAllowRemote bool); build wiring
+```
+
+## 10. Future Extensions (out of v1)
+
+- Generic/pluggable streaming-transcoder registry beyond the two built-ins.
+- Tool/function-calling translation across non-OpenAI formats.
+- Additional adapters (Gemini, Cohere, …) implementing `ChatFormatAdapter`.
+- Per-model rate limiting (ties to `RFC.md` §5 + go-ratelimit; shared with the router's deferred per-pool limits).
+- Surfacing the remote/adapter routes in the generated OpenAPI spec (the broader describability gap).
+
+## 11. Open Implementation Notes
+
+- Confirm `ModelResolver` internals (`loadedByName`, `modelsYAMLMapping`, `resolveDiscoveredPath`) at implementation time and build `Knows` to reuse them with no load.
+- Confirm `isLoopbackRequest`, `writeChatCompletionError`, `mapResolverError`, `ChatCompletionRequest/Response/Chunk`, `newChatCompletionID`, `NewThinkingExtractor` signatures (all in `chat_completions.go`) and reuse verbatim.
+- Reuse `upstreamTransport` via its context-bound pool/key contract (`poolCtxKey`/`keyCtxKey`); construct the balancer+transport in `chatRemoteConfig.finalise()` mirroring the router's `buildProxy`.
+- Capture small representative Ollama NDJSON and Anthropic event-stream samples as `testdata/` fixtures (or inline consts) for the transcoder tests.
+- `BuildRequest` returning headers is a refinement of the interface beyond the router's transformer shape — keep operator secrets in `Upstream.Headers`, adapter contributes only protocol headers.
diff --git a/docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md b/docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md
new file mode 100644
index 0000000..b245490
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-06-openapi-inference-describability-design.md
@@ -0,0 +1,105 @@
+# OpenAPI Describability for the Inference Surface — Design
+
+- **Date:** 2026-06-06
+- **Status:** Design — approved, pending implementation plan
+- **Module:** `dappco.re/go/api` (`core/api/go`)
+- **Author:** Snider + Cladius (brainstorming)
+- **Builds on:** `WithUpstreamRouter` + `WithChatCompletionsRemote` (the two prior specs in this directory).
+- **Related:** `RFC.md` §7 (SDK generation), `RFC.documentation.md` (OpenAPI/SDK tooling — the framework's headline value-prop).
+
+---
+
+## 1. Context & Problem
+
+The framework auto-generates `/v1/openapi.json` from `DescribableGroup.Describe()` plus a few special-cased path items (`chatCompletionsPathItem`, `openAPISpecPathItem`) gated by `runtime.Transport.*` flags (`openapi.go` `Build()`). SDK generation (`RFC.md` §7, `RFC.documentation.md`) consumes that spec.
+
+Two parts of the inference surface we just built are **invisible to the spec/SDKs**:
+
+1. **Remote / hybrid chat-completions.** The rich `chatCompletionsPathItem` (request/response/SSE/error schemas, tag `inference`) already exists, but `transport.go:53` sets `ChatCompletionsEnabled: e.chatCompletionsResolver != nil` — only the **local** resolver flips it. A `WithChatCompletionsRemote`-only (or hybrid) engine serves `/v1/chat/completions` but it never appears in the spec.
+2. **`WithUpstreamRouter` mounted paths.** The router mounts via `r.Any` at the engine root — not a `DescribableGroup`, not special-cased — so its paths are absent entirely.
+
+Shipping a live inference surface that SDK consumers can't see is incoherent with the framework's purpose.
+
+## 2. Goals / Non-Goals
+
+**Goals**
+- The chat-completions path item appears in the spec whenever a local resolver **or** a remote backend is configured (local / remote / hybrid).
+- Every `WithUpstreamRouter` mounted path appears in the spec as a minimal, honest `POST` proxy item.
+- De-dupe: a real item (chat, openapi-spec, swagger, or a `DescribableGroup` path) always wins over the minimal proxy item at the same path.
+- Follow the existing special-cased-path mechanism — no new abstraction.
+
+**Non-Goals**
+- Inferring real request/response schemas for generic router paths (the router proxies arbitrary shapes — the minimal item is deliberately loose).
+- Documenting all HTTP methods the router's `r.Any` accepts (POST only — see §3).
+- Surfacing runtime routing data (model→pool table, adapters) in the static spec.
+- Changing how `DescribableGroup` or `chatCompletionsPathItem` themselves work.
+
+## 3. What Appears in the Spec
+
+### 3.1 Chat-completions (local / remote / hybrid)
+The existing `chatCompletionsPathItem` (full OpenAI request/response/SSE/error schemas, tag `inference`) is emitted whenever chat is configured by either path. No schema change — only the enabling condition widens. The remote backend is OpenAI-shaped (passthrough or adapted), so the existing schema remains accurate.
+
+### 3.2 Upstream router paths (minimal proxy item)
+Each `WithUpstreamRouter` mounted path (from `WithRouterPaths`, default `["/v1/chat/completions"]`) gets a minimal but honest `POST` item:
+
+- **Method:** `POST` only. The router uses `r.Any`, but documenting all seven methods with freeform bodies is misleading noise; `POST` matches the inference convention and the default path.
+- **Tag:** `proxy` (distinct from the real `inference` chat item, so consumers can tell a generic proxy path from the typed chat endpoint).
+- **Request body:** generic `object` (`additionalProperties: true`), `required: true`, with the description: *"Selector-routed proxy. The request body must carry the selector field (default `model`); the concrete request/response schema depends on the target upstream/model."*
+- **Responses:** `200` with `application/json` (generic `object`) **and** `text/event-stream` (the router streams); `404` (`no_upstream_for_key`); `503` (`upstream_unavailable`, with a `Retry-After` response header) — matching the router's real envelopes.
+- **Security:** same `isPublicPathForList` treatment as the other path items (no forced-public; honours configured public paths).
+
+### 3.3 De-dup rule
+The router-path loop runs **after** the chat/openapi-spec special items and the `DescribableGroup` loop. For each router path, normalise it and skip if the `paths` map already has that key. So:
+- Router mounted at `/v1/chat/completions` while chat is enabled → only the `inference` chat item (real schema) appears, never a duplicate `proxy` item.
+- A router path colliding with the openapi-spec/swagger/group path → skipped.
+
+## 4. Wiring (4 files)
+
+1. **`transport.go`** — `TransportConfig`:
+ - `ChatCompletionsEnabled: e.chatCompletionsResolver != nil || e.chatRemote != nil`.
+ - New field `UpstreamRouterPaths []string`; in `TransportConfig()`, set from `e.upstreamRouter.paths` when `e.upstreamRouter != nil`, else nil.
+2. **`runtime_config.go`** — no change (`Transport: e.TransportConfig()` already carries the new field).
+3. **`spec_builder_helper.go`** — `builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths` (beside the existing `ChatCompletionsEnabled`/`Path` assignments).
+4. **`openapi.go`**:
+ - `SpecBuilder` struct gains `UpstreamRouterPaths []string`.
+ - New `upstreamRouterPathItem(path string, operationIDs map[string]int) map[string]any` — the §3.2 item.
+ - `Build()`: after the chat/openapi-spec items and the group loop, iterate `sb.UpstreamRouterPaths`; normalise; `if _, exists := paths[norm]; exists { continue }`; else add `upstreamRouterPathItem`, applying the `isPublicPathForList` security treatment.
+ - Optional `x-upstream-router-paths` extension (informational, symmetric with `x-chat-completions-*`).
+
+The data already exists statically at spec-build time: `e.upstreamRouter.paths` (set by `WithRouterPaths`) and `e.chatRemote` (set by `WithChatCompletionsRemote`). No runtime/dynamic lookup.
+
+## 5. Testing
+
+Internal spec-builder tests (mirror `openapi_test.go`'s build/parse pattern — construct the `SpecBuilder` from the engine's runtime config, or fetch `/v1/openapi.json`):
+
+- **Chat in spec — remote-only:** `api.New(WithChatCompletionsRemote(reg))` → `/v1/chat/completions` POST present with the `inference` request/response/SSE schema. `_Good` (the core gap).
+- **Chat in spec — hybrid + local:** both still present (local regression guard). `_Good`
+- **Chat absent** when neither local nor remote configured. `_Good`
+- **Router paths in spec:** `WithUpstreamRouter(reg, WithRouterPaths("/v1/embeddings", "/v1/score"))` → both appear as `POST`, tag `proxy`, generic schema, `404` + `503` responses. `_Good`
+- **De-dup (key case):** router mounted at `/v1/chat/completions` with chat enabled → exactly one item at that path, and it's the `inference` chat item (assert tag `inference` / the chat request schema, NOT `proxy`). `_Ugly`
+- **De-dup vs spec/swagger path:** a router path colliding with the openapi-spec or swagger path is skipped (real item retained). `_Good`
+- **OpenAPI 3.1 validity:** the produced spec still parses/validates (reuse the existing spec-validation test harness).
+
+Gates: `_Good/_Bad/_Ugly`, `GOWORK=off go test ./ -race`, `go vet ./`, `gofmt`.
+
+## 6. File Layout
+
+```
+go/transport.go (mod) ChatCompletionsEnabled |= chatRemote; + UpstreamRouterPaths field + population
+go/openapi.go (mod) SpecBuilder.UpstreamRouterPaths; upstreamRouterPathItem(); Build() router loop + dedup; optional x-extension
+go/spec_builder_helper.go (mod) builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths
+go/openapi_inference_test.go (new, or extend openapi_test.go) describability tests
+```
+
+## 7. Future Extensions (out of v1)
+
+- Real per-path schemas for the generic router via consumer-supplied `RouteDescription`s (the considered-but-deferred option (b) from brainstorming).
+- Per-model documentation (enumerate registry keys) — runtime data, deliberately excluded from the static contract.
+- Surfacing the MCP HTTP bridge + other un-described engine routes (broader describability sweep).
+
+## 8. Open Implementation Notes
+
+- Confirm `e.upstreamRouter` exposes `.paths` and `e.chatRemote` is the field name set by `WithChatCompletionsRemote` (both from the prior specs) at implementation time.
+- Confirm `chatCompletionsPathItem`, `isPublicPathForList`, `normaliseOpenAPIPath`, `operationID`, the `paths` map population order, and the `mimeJSON` constant — reuse verbatim.
+- Place the router-path loop after the `DescribableGroup` loop so the dedup covers group-contributed paths too.
+- The minimal item's schema is generic on BOTH request and response: `{"type":"object","additionalProperties":true}` for the JSON request and JSON response. For the `text/event-stream` response use a generic schema too (`{"type":"string"}` or a free-form object) — do NOT reuse `chatCompletionsStreamSchema()`, which would falsely imply OpenAI chunk shape on a generic proxy whose stream format depends on the upstream.
diff --git a/docs/superpowers/specs/2026-06-06-upstream-router-design.md b/docs/superpowers/specs/2026-06-06-upstream-router-design.md
new file mode 100644
index 0000000..26ad9e3
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-06-upstream-router-design.md
@@ -0,0 +1,309 @@
+# Upstream Router (`WithUpstreamRouter`) — Design
+
+- **Date:** 2026-06-06
+- **Status:** Design — approved, pending implementation plan
+- **Module:** `dappco.re/go/api` (`core/api/go`)
+- **Author:** Snider + Cladius (brainstorming)
+- **Related:** `RFC.md` §11 (chat completions), `RFC.providers.md` (gateway), `transformer*.go` (translation), `ssrf_guard.go` (outbound policy)
+
+---
+
+## 1. Context & Problem
+
+`core/api` has a list-of-endpoints problem: consumers hold a set of upstream model
+endpoints (local Ollama, LAN GPU boxes, hosted inference) and have **no first-class
+way to load-balance or route across them by a selector key** (typically the `model`
+name, but any value).
+
+What already exists and is reused, not rebuilt:
+
+- **Translation layer** — `TransformerIn[I,O]` / `TransformerOut[I,O]`, chainable
+ pipelines, `FieldRenamer`, schema validation (`transformer.go`,
+ `transformer_in.go`, `transformer_out.go`).
+- **Single-target outbound** — `OpenAPIClient` (one base URL), `SSEClient`,
+ `WebSocketClient`, all funnelled through the SSRF-guarded `doHTTPClientRequest`
+ (`transport_client.go`).
+- **Selector pattern, wrong target** — `ModelResolver` maps `name → backend` but
+ resolves to **local in-process `inference.TextModel`**, not remote HTTP, and is
+ loopback-only (`chat_completions.go`).
+- **Rate limiting** — `go-ratelimit` (separate module) and `WithRateLimit`.
+
+`go-proxy` is **not** reusable here — it is a stratum mining proxy
+(workers/miners/shares), not an HTTP reverse proxy.
+
+The missing piece is a **selector-keyed reverse proxy over a pool of HTTP upstreams**,
+composing with the existing translators so any consuming package gets transparent
+routing: accept a foreign request shape → route by key → translate → dispatch →
+translate the response back.
+
+## 2. Goals / Non-Goals
+
+**Goals**
+- An `api.Option` (`WithUpstreamRouter`) that mounts a router on the Engine and
+ inherits its auth/CORS/rate-limit/tracing middleware — drop-in for any consumer.
+- Route by a pluggable selector key; default reads the JSON `model` field.
+- Load-balance within a per-key pool (weighted round-robin) with passive failover.
+- Runtime-mutable pool table (hot reconfigure without restart).
+- A decision hook to inspect the payload and override/reject routing.
+- Stream SSE / `stream:true` responses through untouched; buffer + translate
+ non-streaming responses.
+
+**Non-Goals (v1)**
+- Active health-check goroutines (failover is passive/inline).
+- Sticky/consistent-hash routing (noted future extension).
+- Direct upstream selection from the hook bypassing the registry (key-only in v1).
+- Per-chunk transformation of live streams (transformers apply to buffered responses
+ only).
+- Mid-stream failover (impossible once response bytes are flowing; documented).
+
+## 3. Settled Decisions
+
+| Fork | Decision |
+|------|----------|
+| Selector source | Pluggable `Selector func`; **default reads JSON body `model`** |
+| Streaming | **Hybrid** — stream-through for `text/event-stream`, buffer otherwise |
+| LB strategy | **Weighted round-robin + passive failover** (cooldown on failure) |
+| Routing seam | **Decision hook + runtime-mutable pool registry** |
+| Proxy core | stdlib `net/http/httputil.ReverseProxy` + custom `RoundTripper` that owns selection/failover |
+| SSRF | **Block-by-default at registration** — reject loopback/private/link-local/reserved IP literals + metadata hosts via `ssrf_guard.go` primitives; opt-in `AllowPrivateUpstreams(cidrs...)` registry option widens acceptance for local Ollama / LAN. No request-time guard (validation is one-shot at registration). |
+
+## 4. Public Surface
+
+```go
+// WithUpstreamRouter mounts a selector-keyed reverse proxy on the Engine.
+// Mirrors WithChatCompletions: the option sets a field; the Engine mounts at build.
+//
+// reg := api.NewUpstreamRegistry()
+// _ = reg.Set("lemma", api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2},
+// api.Upstream{URL: "http://10.0.0.6:8000", Weight: 1})
+// _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}) // local Ollama fallback
+// engine, _ := api.New(api.WithUpstreamRouter(reg))
+func WithUpstreamRouter(reg *UpstreamRegistry, opts ...UpstreamRouterOption) Option
+
+// Upstream is one backend endpoint in a pool.
+type Upstream struct {
+ URL string // http(s) base URL; validated once at registration
+ Weight int // weighted RR weight; <=0 treated as 1
+ Headers map[string]string // static headers injected on dispatch (e.g. upstream API key)
+}
+
+// UpstreamRegistry is the runtime-mutable, thread-safe pool table (key -> pool).
+// Copy-on-write: writes swap an immutable snapshot under a write mutex; reads are
+// lock-free via atomic load.
+type UpstreamRegistry struct { /* atomic.Pointer[registrySnapshot] + write mutex */ }
+
+func NewUpstreamRegistry(opts ...RegistryOption) *UpstreamRegistry
+func (r *UpstreamRegistry) Set(key string, ups ...Upstream) error // replace pool; validates URL + IP policy
+func (r *UpstreamRegistry) Add(key string, up Upstream) error // append one; validates URL + IP policy
+func (r *UpstreamRegistry) Remove(key string) // drop a pool
+func (r *UpstreamRegistry) SetDefault(ups ...Upstream) error // fallback for unmatched keys
+func (r *UpstreamRegistry) Keys() []string // introspection (sorted)
+
+// RegistryOption configures registration-time validation policy.
+type RegistryOption func(*UpstreamRegistry)
+
+// AllowPrivateUpstreams permits the given private/loopback/reserved CIDRs to pass
+// registration validation (default-deny otherwise). Metadata hosts stay hard-blocked.
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8"))
+func AllowPrivateUpstreams(cidrs ...string) RegistryOption
+
+// Selector resolves the routing key from the request. body may be nil if unread.
+type Selector func(c *gin.Context, body []byte) (key string, err error)
+
+// RouteFunc inspects the payload and may override the key or reject the request.
+// Returning the same key is a no-op; a non-nil error aborts (default 400).
+type RouteFunc func(c *gin.Context, key string, body []byte) (newKey string, err error)
+
+// Router options.
+func WithSelector(fn Selector) UpstreamRouterOption // default: JSON body "model"
+func WithRouteHook(fn RouteFunc) UpstreamRouterOption // the "add logic later" seam
+func WithRouterPaths(paths ...string) UpstreamRouterOption // default ["/v1/chat/completions"]
+func WithUpstreamTransformerIn(t ...any) UpstreamRouterOption // reuses compileTransformerPipeline
+func WithUpstreamTransformerOut(t ...any) UpstreamRouterOption // buffered (non-stream) responses only
+func WithFailover(maxAttempts int, cooldown time.Duration) UpstreamRouterOption // default: len(pool) (each tried once), 10s
+func WithFailoverStatuses(statuses ...int) UpstreamRouterOption // default: >=500; 429 opt-in
+func WithUpstreamTransport(rt http.RoundTripper) UpstreamRouterOption // custom TLS/timeouts base
+```
+
+**Contract rules**
+- The **registry is the single source of truth** for endpoints; the hook returns a
+ *key*, the registry resolves it → all LB stays in one place.
+- `Set`/`Add`/`SetDefault` **return `error`** — validation happens here, once, never
+ per request: URL shape (http(s) scheme, host present, port in range) **and** IP
+ policy. Loopback/private/link-local/reserved IP literals and metadata hosts are
+ **rejected by default**; `AllowPrivateUpstreams(cidrs...)` widens acceptance.
+ Non-metadata hostnames are accepted as trusted config without registration-time DNS.
+- Transformers reuse `compileTransformerPipeline`/`runTransformerPipeline`, so
+ `FieldRenamer` and any `TransformerIn[I,O]`/`TransformerOut[I,O]` work unchanged —
+ but on the router they operate on the **raw upstream JSON body**, *not* the
+ `{success,data}` OK-envelope (upstream responses are foreign; no unwrap).
+- **Same-path forwarding**: each path in `WithRouterPaths` forwards its own
+ path + query to the chosen upstream base URL. One registry keyed by `model` serves
+ all OpenAI-shaped paths (`/v1/chat/completions`, `/v1/embeddings`, …).
+- The router mounts on the Engine **root router**, so global engine middleware
+ (auth/CORS/rate-limit/tracing) wraps it. It is **not** a `RouteGroup`, so the
+ group-transformer middleware does not apply — the router's own transformers do.
+
+## 5. Components
+
+Each unit has one purpose and is testable in isolation.
+
+| Unit | File | Responsibility | Depends on | gin/HTTP? |
+|------|------|----------------|------------|-----------|
+| `UpstreamRegistry` | `upstream_registry.go` | Copy-on-write pool table; URL validation on write | `sync/atomic`, `net/url` | no |
+| `upstreamBalancer` | `upstream_balancer.go` | Weighted-RR pick over a pool; shared per-key cursors + per-upstream cooldown; `markFailed`; injectable `now()` | registry types | no |
+| `upstreamTransport` | `upstream_transport.go` | `http.RoundTripper`: pick → rewrite host → inject headers → base.RoundTrip → failover retry | balancer, base `RoundTripper` | http only |
+| `upstreamRouterHandler` | `upstream_router.go` | gin handler orchestration + config + default `model` selector; owns one `*httputil.ReverseProxy` | all above + transformer machinery | yes |
+| Engine wiring | `options.go`, `api.go` | `WithUpstreamRouter` sets `e.upstreamRouter`; build mounts each path | — | — |
+
+**State ownership:** cooldown timestamps and RR cursors are **shared, not
+per-request** (a dead upstream must stay cooling for all callers). They live in the
+balancer behind its own mutex — cursors keyed by selector key, cooldown keyed by
+upstream URL. The per-request pool is stashed on `req.Context()` so a single
+`ReverseProxy`/transport instance serves every request (no per-request proxy alloc).
+
+## 6. Data Flow (one request)
+
+```
+hits mounted path (engine auth/CORS/ratelimit/tracing already ran)
+ 1. read body once — MaxBytesReader(maxToolRequestBodyBytes) -> 413 on overflow
+ 2. Selector(c, body) -> key (default: JSON "model"; empty -> 400)
+ 3. RouteHook(c, key, body) -> finalKey (inspect/override/reject -> 400/403)
+ 4. TransformerIn pipeline -> rewrite outbound body + ContentLength (400 on err)
+ 5. registry snapshot -> pool[finalKey] else default (none -> 404 no_upstream_for_key)
+ 6. bind {finalKey, pool} to ctx -> ReverseProxy.ServeHTTP
+
+ upstreamTransport.RoundTrip (loop <= maxAttempts)
+ balancer.pick(finalKey, pool) -> up (all cooling -> stop)
+ clone req; set URL.Scheme/Host=up; inject up.Headers
+ base.RoundTrip
+ err or status in failoverStatuses -> balancer.markFailed(up, cooldown); retry next
+ else -> return resp
+
+ response:
+ text/event-stream -> FlushInterval:-1 streams through; ModifyResponse passes untouched
+ else + TransformerOut -> ModifyResponse buffers, transforms raw body, drops Content-Length
+
+ ErrorHandler (all upstreams failed/cooling) -> 503 upstream_unavailable + Retry-After
+ tracing span attrs: key, upstream.url, retry.count, stream(bool), status
+```
+
+**Inherent limit:** failover is **pre-response only**. Once a 2xx returns and the
+proxy starts copying (especially a live stream), upstreams cannot be switched — a
+mid-stream upstream death surfaces to the client. True of every streaming proxy.
+
+## 7. Error Taxonomy
+
+Our errors use the framework `Fail`/`FailWithDetails` envelope; backend errors pass
+through verbatim. Dividing line is client-error vs infra-error.
+
+| Condition | Status | Code | Body |
+|-----------|--------|------|------|
+| Body exceeds `maxToolRequestBodyBytes` | 413 | `request_too_large` | `Fail` |
+| Selector can't resolve key (no `model`) | 400 | `invalid_request` | `Fail` |
+| Route hook rejects | hook's (default 400) | `routing_rejected` | `Fail` |
+| `TransformerIn` fails | 400 | `invalid_request_body` | `Fail` |
+| No pool for key **and** no default | 404 | `no_upstream_for_key` | `Fail` |
+| Upstream 4xx (non-failover, incl. 429 unless opted-in) | passthrough | upstream's | upstream body verbatim |
+| Upstream transport-error / status in failover set | → failover (retry next) | — | — |
+| All upstreams failed/cooling | 503 | `upstream_unavailable` | `Fail` + `Retry-After` |
+| `TransformerOut` fails | 502 | `invalid_upstream_response` | `Fail` |
+| Bad URL at `Set/Add/SetDefault` | — | Go `error` at **config time** | never hits request path |
+
+- **Failover set is configurable** (`WithFailoverStatuses`); default = transport errors
+ + status ≥ 500, with 429 opt-in. A non-429 4xx is a deterministic client error →
+ passed straight through, no retry.
+- **Upstream URLs never leak to the client.** The 503 body is generic; selected
+ upstream, error, and attempt count go to **logs (warn) + trace attributes** only.
+
+## 8. Security Notes
+
+- **SSRF posture — block-by-default + explicit opt-in** (aligned with
+ `pkg/provider/proxy.go`, not bypassed). At registration, `Set`/`Add`/`SetDefault`
+ reject loopback/private/link-local/reserved IP literals and metadata hosts using the
+ root `ssrf_guard.go` primitives (`blockedIPReason`). Local Ollama / LAN boxes are
+ enabled by an explicit `AllowPrivateUpstreams(cidrs...)` registry option (code-level
+ intent — no env reliance). Non-metadata hostnames are accepted without
+ registration-time DNS (trusted config). There is **no request-time guard** —
+ validation is one-shot at registration, so the hot path stays allocation-free. The
+ dispatch `RoundTrip` carries a **scoped `#nosec` with justification** (upstreams are
+ registration-validated operator config), mirroring `transport_client.go:493`.
+- **No URL leakage** to clients (see §7).
+- **Bounded request bodies** via `MaxBytesReader(maxToolRequestBodyBytes)`, reusing
+ the transformer constant.
+- Header injection is per-upstream static config (e.g. upstream API keys) — never
+ derived from the incoming request, so a client cannot inject upstream auth.
+
+## 9. Testing Strategy
+
+Convention: `_Good` / `_Bad` / `_Ugly` suffixes, example tests, `-race`, `GOWORK=off`.
+
+**Per-unit (pure, fast)**
+- `UpstreamRegistry` — Good: http/https + loopback/private accepted; Bad: `ftp://`,
+ missing host, bad port, `javascript:` rejected at write; Ugly: concurrent
+ `Set`+snapshot under `-race`, snapshot-before-write provably unaffected (COW).
+- `upstreamBalancer` — weighted spread within tolerance over N picks; cooled upstream
+ skipped until **fake clock** passes cooldown; all-cooling → `pick` returns `!ok`;
+ `weight<=0`→1; concurrent `pick`/`markFailed` under `-race`.
+- `upstreamTransport` — **fake base RoundTripper**: success returns resp;
+ transport-error → `markFailed` + retry-next → success; status-in-set fails over,
+ 4xx passes through; all-fail returns last err; asserts header injection + correct
+ scheme/host rewrite with path preserved.
+
+**Integration (`httptest` upstreams)**
+- Weighted spread roughly matches weights over many requests.
+- Failover: A always 503, B 200 → client gets 200, A cooling.
+- Streaming: SSE upstream with flushes → client receives chunks incrementally, body
+ byte-identical, `TransformerOut` not applied.
+- Non-stream + `FieldRenamer` out → fields renamed, `Content-Length` corrected;
+ `FieldRenamer` in → upstream sees renamed body.
+- Selector default routes by `model`; missing `model` → 400. Hook overrides key →
+ different pool; hook reject → 403.
+- **SSRF posture**: `127.0.0.1` upstream **rejected at config time by default**;
+ accepted after `AllowPrivateUpstreams("127.0.0.0/8")`; non-metadata hostname accepted;
+ metadata host `169.254.169.254` rejected even with a broad allow-list; `ftp://` and
+ missing-host rejected. Integration: an allowed `127.0.0.1` httptest upstream serves
+ end-to-end (proves no request-time guard blocks it).
+- All-down → 503 + `Retry-After`; assert upstream URL absent from client body.
+- Multiple mounted paths each forward their own path.
+- Composition: `WithBearerAuth` in front → 401 without token.
+
+**Gates:** `GOWORK=off go test -race ./...` green; gosec clean (scoped `#nosec`).
+
+## 10. File Layout
+
+```
+go/upstream_registry.go + _test.go + _example_test.go
+go/upstream_balancer.go + _internal_test.go
+go/upstream_transport.go + _internal_test.go
+go/upstream_router.go + _test.go + _example_test.go (handler, config, default selector)
+go/options.go (+ WithUpstreamRouter, UpstreamRouterOption helpers, e.upstreamRouter field)
+go/api.go (+ build-time mount of each path)
+go/string_constants.go (+ error codes)
+```
+
+## 11. Future Extensions (out of v1 scope)
+
+- Sticky / consistent-hash routing as a selectable strategy.
+- Active health checks with a background prober (passive failover stays the default).
+- Direct upstream selection from the hook (bypass registry) for advanced cases.
+- Per-chunk streaming transformers (translate a foreign SSE format → OpenAI SSE).
+- Path rewrite (strip/replace prefix) per upstream.
+- Per-pool rate limits via `go-ratelimit` integration.
+
+## 12. Open Implementation Notes
+
+- Confirm `maxToolRequestBodyBytes`, `Fail`, `FailWithDetails`,
+ `compileTransformerPipeline`, `runTransformerPipeline` signatures at implementation
+ time and reuse verbatim (no forks).
+- `ReverseProxy.Rewrite` (Go 1.20+) preferred over the deprecated `Director`; set only
+ path/query preservation there — the host is set inside `upstreamTransport.RoundTrip`
+ per attempt.
+- `ModifyResponse` must distinguish streaming by response `Content-Type`
+ (`text/event-stream`) — not by request flags — so an upstream that streams
+ unexpectedly is still passed through.
+- Decide the failover-status default constant set in `string_constants.go`.
+- `Upstream.URL` may include a base path (e.g. `http://host/inference`); the incoming
+ request path is appended to it. Document this in the `Upstream.URL` godoc so the
+ forwarding rule is unambiguous.
diff --git a/external/go b/external/go
index b48b896..f7a84db 160000
--- a/external/go
+++ b/external/go
@@ -1 +1 @@
-Subproject commit b48b896b1e6216e95c8f1dfc6490b1763eedd8fb
+Subproject commit f7a84db6ce08722dc3d42ad72ed9094621fca992
diff --git a/external/go-inference b/external/go-inference
index b9f4d46..e05c165 160000
--- a/external/go-inference
+++ b/external/go-inference
@@ -1 +1 @@
-Subproject commit b9f4d46f637750dc298a1f1c0625fbc90c8175e0
+Subproject commit e05c165c6012870e0bdbc461da7d8b3363862378
diff --git a/external/go-io b/external/go-io
index 40f5452..24333e1 160000
--- a/external/go-io
+++ b/external/go-io
@@ -1 +1 @@
-Subproject commit 40f545248bb8c095b55673afb86cb0baf680a724
+Subproject commit 24333e1cfad37de4889cdffaeca0598240496d97
diff --git a/external/go-log b/external/go-log
index abafd06..96c2e47 160000
--- a/external/go-log
+++ b/external/go-log
@@ -1 +1 @@
-Subproject commit abafd065af5c919160d4e2d4ed26accd105b27c9
+Subproject commit 96c2e4700d50e0a48a6c41b112a4fc62fe1a6525
diff --git a/go.work.sum b/go.work.sum
index 9f86cc5..18257cb 100644
--- a/go.work.sum
+++ b/go.work.sum
@@ -1,9 +1,17 @@
-github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
-rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/go/api.go b/go/api.go
index 870d4e5..97a0d7e 100644
--- a/go/api.go
+++ b/go/api.go
@@ -7,6 +7,7 @@ package api
import (
"context"
"iter"
+ "net" // Note: AX-6 — net.SplitHostPort/ParseIP are structural for loopback bind classification; no core primitive
"net/http" // Note: AX-6 — structural HTTP boundary for Handler/WebSocket contracts; no core primitive
"reflect" // Note: AX-6 — reflect is structural for runtime nil-pointer detection in handler binding; no core primitive
"slices"
@@ -22,6 +23,17 @@ import (
const defaultAddr = ":8080"
+var (
+ // ErrNonLoopbackBind is returned by Serve under strict bind mode when the
+ // configured listen address is not loopback and WithPublicBind was not
+ // set. Strict mode is opt-in via WithStrictBind / WithLoopbackOnly.
+ ErrNonLoopbackBind = core.NewError("api: strict bind rejects non-loopback address without WithPublicBind")
+ // ErrPublicBindNoBearer is returned by Serve under strict bind mode when a
+ // public (non-loopback) bind is requested without a bearer credential
+ // supplied via WithBearerAuth.
+ ErrPublicBindNoBearer = core.NewError("api: strict bind rejects public address without WithBearerAuth")
+)
+
// shutdownTimeout is the maximum duration to wait for in-flight requests
// to complete during graceful shutdown.
const shutdownTimeout = 10 * time.Second
@@ -83,10 +95,37 @@ type Engine struct {
i18nConfig I18nConfig
openAPISpecEnabled bool
openAPISpecPath string
+ // strictBind, when set via WithStrictBind / WithLoopbackOnly, makes
+ // Serve refuse a non-loopback listen address unless publicBind is also
+ // set, and refuse to serve a public bind without a bearer credential.
+ // Default false preserves the historic permissive behaviour so existing
+ // consumers (go-ml, go-ai, desktop, core-agent) are not broken.
+ strictBind bool
+ // publicBind, when set via WithPublicBind, is the explicit opt-in that
+ // allows a non-loopback bind under strict mode. It carries no effect
+ // when strictBind is false. A public bind still requires a bearer.
+ publicBind bool
+ // bearerConfigured records that a bearer credential was supplied via
+ // WithBearerAuth. Strict mode refuses to serve a public listener
+ // without one.
+ bearerConfigured bool
+ // bearerToken is the static bearer credential supplied via WithBearerAuth.
+ // The chat endpoint's off-loopback gate validates the inbound request
+ // against this token directly so it fails closed independently of the
+ // bearer middleware's path coverage.
+ bearerToken string
// noRouteHandler is the SPA / fallback handler invoked when no
// registered route matches the request. Set via WithNoRoute; nil
// means gin returns 404 with its default body.
- noRouteHandler gin.HandlerFunc
+ noRouteHandler gin.HandlerFunc
+ // upstreamRouter, when set via WithUpstreamRouter, mounts a selector-keyed
+ // reverse proxy over a pool of HTTP upstreams at the configured paths.
+ upstreamRouter *upstreamRouterConfig
+ // chatRemote, when set via WithChatCompletionsRemote, adds a remote backend
+ // to the chat completions endpoint (local-first dispatch).
+ chatRemote *chatRemoteConfig
+ // chatAllowRemote permits non-loopback chat clients when a bearer is set.
+ chatAllowRemote bool
}
// New creates an Engine with the given options.
@@ -109,7 +148,7 @@ func New(opts ...Option) (
opt(e)
}
// Apply calibrated defaults for optional subsystems.
- if e.chatCompletionsResolver != nil && core.Trim(e.chatCompletionsPath) == "" {
+ if (e.chatCompletionsResolver != nil || e.chatRemote != nil) && core.Trim(e.chatCompletionsPath) == "" {
e.chatCompletionsPath = defaultChatCompletionsPath
}
return e, nil
@@ -247,6 +286,10 @@ func (e *Engine) Handler() http.Handler {
func (e *Engine) Serve(ctx context.Context) (
_ error,
) {
+ if err := e.validateBind(); err != nil {
+ return err
+ }
+
srv := &http.Server{
Addr: e.addr,
Handler: e.build(),
@@ -289,6 +332,83 @@ func (e *Engine) Serve(ctx context.Context) (
return <-errCh
}
+// validateBind enforces the strict bind invariants before Serve binds a
+// listener. It is a no-op unless WithStrictBind / WithLoopbackOnly was set, so
+// existing consumers that bind non-loopback addresses are unaffected.
+//
+// Under strict mode:
+// - a loopback address always serves;
+// - a non-loopback address is rejected unless WithPublicBind is set;
+// - a non-loopback address with WithPublicBind set still requires a bearer
+// credential (WithBearerAuth), and is otherwise rejected.
+//
+// Example:
+//
+// e, _ := api.New(api.WithAddr("0.0.0.0:8787"), api.WithStrictBind())
+// err := e.Serve(ctx) // err == api.ErrNonLoopbackBind
+func (e *Engine) validateBind() (
+ _ error,
+) {
+ if !e.strictBind {
+ return nil
+ }
+ if addrIsLoopback(e.addr) {
+ return nil
+ }
+ if !e.publicBind {
+ return core.E("api.bind", e.addr, ErrNonLoopbackBind)
+ }
+ if !e.bearerConfigured {
+ return core.E("api.bind", e.addr, ErrPublicBindNoBearer)
+ }
+ return nil
+}
+
+// addrIsLoopback reports whether a listen address binds only the loopback
+// interface. The host portion is parsed from "host:port"; a bare ":port" or an
+// unspecified host ("0.0.0.0", "::", empty) is treated as non-loopback because
+// it binds all interfaces. The textual host "localhost" is treated as loopback.
+//
+// Example:
+//
+// addrIsLoopback("127.0.0.1:8787") // true
+// addrIsLoopback("[::1]:8787") // true
+// addrIsLoopback("localhost:8787") // true
+// addrIsLoopback("0.0.0.0:8787") // false
+// addrIsLoopback(":8787") // false
+func addrIsLoopback(addr string) bool {
+ addr = core.Trim(addr)
+ if addr == "" {
+ return false
+ }
+
+ host, _, err := net.SplitHostPort(addr)
+ if err != nil {
+ // No port separator (or malformed); treat the whole string as the host.
+ host = addr
+ }
+
+ host = core.Trim(host)
+ if host == "" {
+ // Bare ":port" — binds every interface, not loopback.
+ return false
+ }
+ if host == "localhost" {
+ return true
+ }
+
+ ip := net.ParseIP(host)
+ if ip == nil {
+ // A named host other than localhost is not a loopback guarantee.
+ return false
+ }
+ if ip.IsUnspecified() {
+ // 0.0.0.0 / :: bind all interfaces.
+ return false
+ }
+ return ip.IsLoopback()
+}
+
// SetNoRoute attaches or replaces the fallback handler invoked when
// no registered route matches the incoming request. Mirrors the
// WithNoRoute Option but is callable after construction — useful
@@ -329,10 +449,24 @@ func (e *Engine) build() *gin.Engine {
c.JSON(http.StatusOK, OK("healthy"))
})
- // Mount the local OpenAI-compatible chat completion endpoint when configured.
- if e.chatCompletionsResolver != nil {
- h := newChatCompletionsHandler(e.chatCompletionsResolver)
- r.POST(e.chatCompletionsPath, h.ServeHTTP)
+ // Mount the OpenAI-compatible chat completion endpoint when a local resolver
+ // and/or a remote backend is configured.
+ if e.chatCompletionsResolver != nil || e.chatRemote != nil {
+ path := e.chatCompletionsPath
+ if core.Trim(path) == "" {
+ path = defaultChatCompletionsPath
+ }
+ h := newChatCompletionsHandler(e.chatCompletionsResolver, e.chatRemote, e.chatAllowRemote, bearerValidator(e.bearerToken))
+ r.POST(path, h.ServeHTTP)
+ }
+
+ // Mount the selector-keyed upstream router when configured.
+ if e.upstreamRouter != nil {
+ proxy := e.upstreamRouter.buildProxy()
+ h := e.upstreamRouter.handler(proxy)
+ for _, p := range e.upstreamRouter.paths {
+ r.Any(p, h)
+ }
}
if e.sdkGenEnabled {
diff --git a/go/api_describable_test.go b/go/api_describable_test.go
index cc5f9e2..2451efc 100644
--- a/go/api_describable_test.go
+++ b/go/api_describable_test.go
@@ -17,10 +17,12 @@ type describableSpecGroup struct {
descs []api.RouteDescription
}
-func (g *describableSpecGroup) Name() string { return g.name }
-func (g *describableSpecGroup) BasePath() string { return g.basePath }
-func (g *describableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {}
-func (g *describableSpecGroup) Describe() []api.RouteDescription { return g.descs }
+func (g *describableSpecGroup) Name() string { return g.name }
+func (g *describableSpecGroup) BasePath() string { return g.basePath }
+func (g *describableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; routes are registered through the Describe path only.
+}
+func (g *describableSpecGroup) Describe() []api.RouteDescription { return g.descs }
type describableHandler struct {
desc api.RouteDescription
@@ -75,12 +77,12 @@ func buildDescribableOperation(t *testing.T, group api.RouteGroup, path, method
data, err := builder.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -146,7 +148,7 @@ func TestDescribable_Good_HandlerMetadataFlowsToOpenAPI(t *testing.T) {
tags, ok := operation["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", operation["tags"])
+ t.Fatalf(fmtTestExpectedTags, operation["tags"])
}
if len(tags) != 2 || tags[0] != "widgets" || tags[1] != "catalog" {
t.Fatalf("expected handler tags, got %v", tags)
@@ -154,7 +156,7 @@ func TestDescribable_Good_HandlerMetadataFlowsToOpenAPI(t *testing.T) {
requestBody := operation["requestBody"].(map[string]any)
content := requestBody["content"].(map[string]any)
- schema := content["application/json"].(map[string]any)["schema"].(map[string]any)
+ schema := content[mimeJSON].(map[string]any)["schema"].(map[string]any)
properties := schema["properties"].(map[string]any)
if _, ok := properties["name"]; !ok {
t.Fatal("expected request body schema from handler Describe")
@@ -173,7 +175,7 @@ func TestDescribable_Bad_MissingHandlerMetadataFallsBackSafely(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
- Path: "/status",
+ Path: pathStatus,
Summary: "Widget status",
Description: "Returns widget availability.",
Tags: []string{"status"},
@@ -196,7 +198,7 @@ func TestDescribable_Bad_MissingHandlerMetadataFallsBackSafely(t *testing.T) {
tags, ok := operation["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", operation["tags"])
+ t.Fatalf(fmtTestExpectedTags, operation["tags"])
}
if len(tags) != 1 || tags[0] != "status" {
t.Fatalf("expected route tag fallback, got %v", tags)
@@ -210,7 +212,7 @@ func TestDescribable_Ugly_NilHandlerIsIgnored(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
- Path: "/status",
+ Path: pathStatus,
Handler: (*describableHandler)(nil),
},
},
@@ -224,7 +226,7 @@ func TestDescribable_Ugly_NilHandlerIsIgnored(t *testing.T) {
tags, ok := operation["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", operation["tags"])
+ t.Fatalf(fmtTestExpectedTags, operation["tags"])
}
if len(tags) != 1 || tags[0] != "widgets" {
t.Fatalf("expected group-name tag fallback, got %v", tags)
diff --git a/go/api_renderable_test.go b/go/api_renderable_test.go
index 322f82b..ff1433a 100644
--- a/go/api_renderable_test.go
+++ b/go/api_renderable_test.go
@@ -17,10 +17,12 @@ type renderableSpecGroup struct {
descs []api.RouteDescription
}
-func (g *renderableSpecGroup) Name() string { return g.name }
-func (g *renderableSpecGroup) BasePath() string { return g.basePath }
-func (g *renderableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {}
-func (g *renderableSpecGroup) Describe() []api.RouteDescription { return g.descs }
+func (g *renderableSpecGroup) Name() string { return g.name }
+func (g *renderableSpecGroup) BasePath() string { return g.basePath }
+func (g *renderableSpecGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; routes are registered through the Describe path only.
+}
+func (g *renderableSpecGroup) Describe() []api.RouteDescription { return g.descs }
type renderableHandler struct {
hints api.RenderHints
@@ -43,12 +45,12 @@ func buildRenderableOperation(t *testing.T, group api.RouteGroup, path, method s
data, err := builder.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -144,7 +146,7 @@ func TestRenderable_Bad_EmptyHintsAreOmittedSafely(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
- Path: "/status",
+ Path: pathStatus,
Handler: &renderableHandler{},
},
},
@@ -164,7 +166,7 @@ func TestRenderable_Ugly_NilHandlerIsIgnored(t *testing.T) {
descs: []api.RouteDescription{
{
Method: http.MethodGet,
- Path: "/status",
+ Path: pathStatus,
Handler: (*renderableHandler)(nil),
},
},
diff --git a/go/api_test.go b/go/api_test.go
index cea821b..e859d8b 100644
--- a/go/api_test.go
+++ b/go/api_test.go
@@ -43,7 +43,7 @@ func (p *panicGroup) RegisterRoutes(rg *gin.RouterGroup) {
func TestNew_Good(t *testing.T) {
e, err := api.New()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
if e == nil {
t.Fatal("expected non-nil Engine")
@@ -53,7 +53,7 @@ func TestNew_Good(t *testing.T) {
func TestNew_Good_WithAddr(t *testing.T) {
e, err := api.New(api.WithAddr(":9090"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
if e.Addr() != ":9090" {
t.Fatalf("expected addr=%q, got %q", ":9090", e.Addr())
@@ -124,22 +124,22 @@ func TestHandler_Good_HealthEndpoint(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if resp.Data != "healthy" {
- t.Fatalf("expected Data=%q, got %q", "healthy", resp.Data)
+ t.Fatalf(fmtTestExpectedData, "healthy", resp.Data)
}
}
@@ -154,15 +154,15 @@ func TestHandler_Good_RegisteredRoutes(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data != "echo" {
- t.Fatalf("expected Data=%q, got %q", "echo", resp.Data)
+ t.Fatalf(fmtTestExpectedData, "echo", resp.Data)
}
}
@@ -196,10 +196,10 @@ func TestHandler_Bad_PanicReturnsEnvelope(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil {
t.Fatal("expected Error to be non-nil")
@@ -210,7 +210,7 @@ func TestHandler_Bad_PanicReturnsEnvelope(t *testing.T) {
if resp.Error.Message != "Internal server error" {
t.Fatalf("expected error message=%q, got %q", "Internal server error", resp.Error.Message)
}
- if got := w.Header().Get("X-Request-ID"); got == "" {
+ if got := w.Header().Get(hdrXRequestID); got == "" {
t.Fatal("expected X-Request-ID header to survive panic recovery")
}
}
@@ -247,13 +247,13 @@ func TestServe_Good_GracefulShutdown(t *testing.T) {
}
// Verify the server responds.
- resp, err := http.Get("http://" + addr + "/health")
+ resp, err := http.Get("http://" + addr + pathHealth)
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)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
// Cancel context to trigger graceful shutdown.
diff --git a/go/authentik_integration_test.go b/go/authentik_integration_test.go
index 6b2c3e7..c5a438e 100644
--- a/go/authentik_integration_test.go
+++ b/go/authentik_integration_test.go
@@ -21,7 +21,7 @@ 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) {
+ rg.GET(pathPublic, func(c *gin.Context) {
c.JSON(200, api.OK("public"))
})
rg.GET("/whoami", api.RequireAuth(), func(c *gin.Context) {
@@ -129,7 +129,7 @@ func TestAuthentikIntegration(t *testing.T) {
accessToken, _ := getClientCredentialsToken(t, issuer, clientID, clientSecret)
t.Run("Health_NoAuth", func(t *testing.T) {
- resp := get(t, ts.URL+"/health", "")
+ resp := get(t, ts.URL+pathHealth, "")
assertStatus(t, resp, 200)
body := readBody(t, resp)
t.Logf("health: %s", body)
diff --git a/go/authentik_test.go b/go/authentik_test.go
index b8f5795..29138fc 100644
--- a/go/authentik_test.go
+++ b/go/authentik_test.go
@@ -33,7 +33,7 @@ func TestAuthentikUser_Good(t *testing.T) {
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)
+ t.Fatalf(fmtTestExpectedName, "Alice Smith", u.Name)
}
if u.UID != "abc-123" {
t.Fatalf("expected UID=%q, got %q", "abc-123", u.UID)
@@ -75,7 +75,7 @@ func TestAuthentikConfig_Good(t *testing.T) {
Issuer: "https://auth.example.com",
ClientID: "my-client",
TrustedProxy: true,
- PublicPaths: []string{"/public", "/docs"},
+ PublicPaths: []string{pathPublic, "/docs"},
}
if cfg.Issuer != "https://auth.example.com" {
@@ -98,7 +98,7 @@ func TestAuthentikConfig_Ugly_BlankPublicPathsCollapseToNil(t *testing.T) {
PublicPaths: []string{" ", "\t", ""},
}))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.AuthentikConfig()
@@ -113,7 +113,7 @@ func TestAuthentikConfig_Ugly_RootPublicPathIsPreserved(t *testing.T) {
PublicPaths: []string{" / ", "/docs/", "/docs"},
}))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.AuthentikConfig()
@@ -155,7 +155,7 @@ func TestForwardAuthHeaders_Good(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if gotUser == nil {
t.Fatal("expected GetUser to return a user, got nil")
@@ -167,7 +167,7 @@ func TestForwardAuthHeaders_Good(t *testing.T) {
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)
+ t.Fatalf(fmtTestExpectedName, "Bob Jones", gotUser.Name)
}
if gotUser.UID != "uid-456" {
t.Fatalf("expected UID=%q, got %q", "uid-456", gotUser.UID)
@@ -207,7 +207,7 @@ func TestForwardAuthHeaders_Good_NoHeaders(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil without headers, got %+v", gotUser)
@@ -234,7 +234,7 @@ func TestForwardAuthHeaders_Bad_NotTrusted(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil when TrustedProxy=false, got %+v", gotUser)
@@ -249,7 +249,7 @@ func TestHealthBypassesAuthentik_Good(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -262,7 +262,7 @@ func TestPublicPaths_Good_SimilarPrefixDoesNotBypassAuth(t *testing.T) {
cfg := api.AuthentikConfig{
TrustedProxy: true,
- PublicPaths: []string{"/public"},
+ PublicPaths: []string{pathPublic},
}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&publicPrefixGroup{})
@@ -296,7 +296,7 @@ func TestGetUser_Good_NilContext(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if gotUser != nil {
t.Fatalf("expected GetUser to return nil without middleware, got %+v", gotUser)
@@ -363,7 +363,7 @@ func TestBearerAndAuthentikCoexist_Good(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if gotUser == nil {
t.Fatal("expected GetUser to return a user, got nil")
@@ -389,7 +389,7 @@ func TestAuthentik_Good_CustomSwaggerPathBypassesAuth(t *testing.T) {
api.WithSwaggerPath("/docs"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -397,7 +397,7 @@ func TestAuthentik_Good_CustomSwaggerPathBypassesAuth(t *testing.T) {
resp, err := http.Get(srv.URL + "/docs/doc.json")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
diff --git a/go/authz_test.go b/go/authz_test.go
index 699fa7c..a4f10aa 100644
--- a/go/authz_test.go
+++ b/go/authz_test.go
@@ -72,7 +72,7 @@ func TestWithAuthz_Good_AllowsPermittedRequest(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
@@ -95,7 +95,7 @@ func TestWithAuthz_Bad_DeniesUnpermittedRequest(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
setBasicAuth(req, "bob", "secret")
h.ServeHTTP(w, req)
@@ -120,7 +120,7 @@ func TestWithAuthz_Good_DifferentMethodsEvaluatedSeparately(t *testing.T) {
// GET should succeed.
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
@@ -130,7 +130,7 @@ func TestWithAuthz_Good_DifferentMethodsEvaluatedSeparately(t *testing.T) {
// DELETE should be denied (no policy for DELETE).
w = httptest.NewRecorder()
- req, _ = http.NewRequest(http.MethodDelete, "/stub/ping", nil)
+ req, _ = http.NewRequest(http.MethodDelete, pathStubPing, nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
@@ -154,17 +154,17 @@ func TestWithAuthz_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
setBasicAuth(req, "alice", "secret")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Both authz (allowed) and request ID should be active.
- if w.Header().Get("X-Request-ID") == "" {
+ if w.Header().Get(hdrXRequestID) == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
@@ -211,7 +211,7 @@ func TestWithAuthz_Ugly_WildcardPolicyAllowsAll(t *testing.T) {
// 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)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
setBasicAuth(req, user, "secret")
h.ServeHTTP(w, req)
diff --git a/go/bridge.go b/go/bridge.go
index 071ca7a..2963de5 100644
--- a/go/bridge.go
+++ b/go/bridge.go
@@ -411,12 +411,12 @@ func (v *toolInputValidator) Validate(body []byte) (
_ error,
) {
if core.Trim(string(body)) == "" {
- return core.E("ToolBridge.Validate", "request body is required", nil)
+ return core.E(errBridgeValidate, "request body is required", nil)
}
payload, err := decodeJSONValuePreserveNumbers(body)
if err != nil {
- return core.E("ToolBridge.Validate", "invalid JSON", err)
+ return core.E(errBridgeValidate, "invalid JSON", err)
}
return validateSchemaNode(payload, v.schema, "")
@@ -431,16 +431,16 @@ func (v *toolInputValidator) ValidateResponse(body []byte) (
decoded, err := decodeJSONValuePreserveNumbers(body)
if err != nil {
- return core.E("ToolBridge.ValidateResponse", "invalid JSON response", err)
+ return core.E(errBridgeValidateResp, "invalid JSON response", err)
}
envelope, ok := decoded.(map[string]any)
if !ok {
- return core.E("ToolBridge.ValidateResponse", "response envelope must be an object", nil)
+ return core.E(errBridgeValidateResp, "response envelope must be an object", nil)
}
success, _ := envelope["success"].(bool)
if !success {
- return core.E("ToolBridge.ValidateResponse", "response is missing a successful envelope", nil)
+ return core.E(errBridgeValidateResp, "response is missing a successful envelope", nil)
}
// data is serialised with omitempty, so a nil/zero-value payload from
@@ -453,12 +453,12 @@ func (v *toolInputValidator) ValidateResponse(body []byte) (
encoded, err := marshalCoreJSON(data)
if err != nil {
- return core.E("ToolBridge.ValidateResponse", "encode response data", err)
+ return core.E(errBridgeValidateResp, "encode response data", err)
}
payload, err := decodeJSONValuePreserveNumbers(encoded)
if err != nil {
- return core.E("ToolBridge.ValidateResponse", "decode response data", err)
+ return core.E(errBridgeValidateResp, "decode response data", err)
}
return validateSchemaNode(payload, v.schema, "")
@@ -482,7 +482,7 @@ func validateSchemaNode(value any, schema map[string]any, path string) (
for _, name := range stringList(schema["required"]) {
if _, ok := obj[name]; !ok {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s is missing required field %q", displayPath(path), name), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s is missing required field %q", displayPath(path), name), nil)
}
}
@@ -508,7 +508,7 @@ func validateSchemaNode(value any, schema map[string]any, path string) (
continue
}
}
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s contains unknown field %q", displayPath(path), name), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s contains unknown field %q", displayPath(path), name), nil)
}
}
if err := validateObjectConstraints(obj, schema, path); err != nil {
@@ -570,7 +570,7 @@ func validateSchemaNode(value any, schema map[string]any, path string) (
if rawEnum, ok := schema["enum"]; ok {
if !enumContains(value, rawEnum) {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be one of the declared enum values", displayPath(path)), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must be one of the declared enum values", displayPath(path)), nil)
}
}
@@ -598,7 +598,7 @@ func validateSchemaCombinators(value any, schema map[string]any, path string) (
goto anyOfMatched
}
}
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must match at least one schema in anyOf", displayPath(path)), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must match at least one schema in anyOf", displayPath(path)), nil)
}
anyOfMatched:
@@ -611,15 +611,15 @@ anyOfMatched:
}
if matches != 1 {
if matches == 0 {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must match exactly one schema in oneOf", displayPath(path)), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must match exactly one schema in oneOf", displayPath(path)), nil)
}
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s matches multiple schemas in oneOf", displayPath(path)), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s matches multiple schemas in oneOf", displayPath(path)), nil)
}
}
if subschema, ok := schema["not"].(map[string]any); ok && subschema != nil {
if err := validateSchemaNode(value, subschema, path); err == nil {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must not match the forbidden schema", displayPath(path)), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must not match the forbidden schema", displayPath(path)), nil)
}
}
@@ -631,18 +631,18 @@ func validateStringConstraints(value string, schema map[string]any, path string)
) {
length := core.RuneCount(value)
if minLength, ok := schemaInt(schema["minLength"]); ok && length < minLength {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be at least %d characters long", displayPath(path), minLength), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must be at least %d characters long", displayPath(path), minLength), nil)
}
if maxLength, ok := schemaInt(schema["maxLength"]); ok && length > maxLength {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be at most %d characters long", displayPath(path), maxLength), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must be at most %d characters long", displayPath(path), maxLength), nil)
}
if pattern, ok := schema["pattern"].(string); ok && pattern != "" {
re, err := compiledPattern(pattern)
if err != nil {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s has an invalid pattern %q", displayPath(path), pattern), err)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s has an invalid pattern %q", displayPath(path), pattern), err)
}
if !re.MatchString(value) {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s does not match pattern %q", displayPath(path), pattern), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s does not match pattern %q", displayPath(path), pattern), nil)
}
}
return nil
@@ -652,10 +652,10 @@ func validateNumericConstraints(value any, schema map[string]any, path string) (
_ error,
) {
if minimum, ok := schemaFloat(schema["minimum"]); ok && numericLessThan(value, minimum) {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be greater than or equal to %v", displayPath(path), minimum), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must be greater than or equal to %v", displayPath(path), minimum), nil)
}
if maximum, ok := schemaFloat(schema["maximum"]); ok && numericGreaterThan(value, maximum) {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be less than or equal to %v", displayPath(path), maximum), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must be less than or equal to %v", displayPath(path), maximum), nil)
}
return nil
}
@@ -664,10 +664,10 @@ func validateArrayConstraints(value []any, schema map[string]any, path string) (
_ error,
) {
if minItems, ok := schemaInt(schema["minItems"]); ok && len(value) < minItems {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at least %d items", displayPath(path), minItems), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must contain at least %d items", displayPath(path), minItems), nil)
}
if maxItems, ok := schemaInt(schema["maxItems"]); ok && len(value) > maxItems {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at most %d items", displayPath(path), maxItems), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must contain at most %d items", displayPath(path), maxItems), nil)
}
return nil
}
@@ -676,10 +676,10 @@ func validateObjectConstraints(value map[string]any, schema map[string]any, path
_ error,
) {
if minProps, ok := schemaInt(schema["minProperties"]); ok && len(value) < minProps {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at least %d properties", displayPath(path), minProps), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must contain at least %d properties", displayPath(path), minProps), nil)
}
if maxProps, ok := schemaInt(schema["maxProperties"]); ok && len(value) > maxProps {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must contain at most %d properties", displayPath(path), maxProps), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must contain at most %d properties", displayPath(path), maxProps), nil)
}
return nil
}
@@ -901,7 +901,7 @@ func (w *toolResponseRecorder) writeErrorResponse(status int, resp Response[any]
if w.headers == nil {
w.headers = make(http.Header)
}
- w.headers.Set("Content-Type", "application/json")
+ w.headers.Set(hdrContentType, mimeJSON)
w.body = append(w.body[:0], data...)
w.commit()
}
@@ -909,7 +909,7 @@ func (w *toolResponseRecorder) writeErrorResponse(status int, resp Response[any]
func typeError(path, want string, value any) (
_ error,
) {
- return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must be %s, got %s", displayPath(path), want, describeJSONValue(value)), nil)
+ return core.E(errBridgeValidateSchema, core.Sprintf("%s must be %s, got %s", displayPath(path), want, describeJSONValue(value)), nil)
}
func displayPath(path string) string {
diff --git a/go/bridge_test.go b/go/bridge_test.go
index 5229227..a1a64a5 100644
--- a/go/bridge_test.go
+++ b/go/bridge_test.go
@@ -13,13 +13,34 @@ import (
api "dappco.re/go/api"
)
+// ── Test constants ─────────────────────────────────────────────────────
+
+const (
+ pathTools = "/tools"
+ pathAPITools = "/api/v1/tools"
+ pathV1Tools = "/v1/tools"
+ pathTmpFile = "/tmp/file.txt"
+ descReadFile = "Read a file from disk"
+ descPublishItem = "Publish an item"
+ descValidateArr = "Validate array input"
+ descValidateNum = "Validate numeric input"
+ patternUpper = "^[A-Z]+$"
+
+ fmtBridgeInvalidBody = "expected invalid_request_body error, got %#v"
+ msgShouldNotRun = "should not run"
+)
+
// ── ToolBridge ─────────────────────────────────────────────────────────
+func noopTestHandler(*gin.Context) {
+ // Test-only no-op handler for tools that only contribute OpenAPI descriptions.
+}
+
func TestBridge_Good_RegisterAndServe(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file",
@@ -40,7 +61,7 @@ func TestBridge_Good_RegisterAndServe(t *testing.T) {
// POST /tools/file_read
w1 := httptest.NewRecorder()
- req1, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil)
+ req1, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", nil)
engine.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
@@ -48,10 +69,10 @@ func TestBridge_Good_RegisterAndServe(t *testing.T) {
}
var resp1 api.Response[string]
if err := coreJSONUnmarshal(w1.Body.Bytes(), &resp1); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp1.Data != "result1" {
- t.Fatalf("expected Data=%q, got %q", "result1", resp1.Data)
+ t.Fatalf(fmtTestExpectedData, "result1", resp1.Data)
}
// POST /tools/file_write
@@ -64,29 +85,29 @@ func TestBridge_Good_RegisterAndServe(t *testing.T) {
}
var resp2 api.Response[string]
if err := coreJSONUnmarshal(w2.Body.Bytes(), &resp2); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp2.Data != "result2" {
- t.Fatalf("expected Data=%q, got %q", "result2", resp2.Data)
+ t.Fatalf(fmtTestExpectedData, "result2", resp2.Data)
}
}
func TestBridge_Good_BasePath(t *testing.T) {
- bridge := api.NewToolBridge("/api/v1/tools")
+ bridge := api.NewToolBridge(pathAPITools)
- if bridge.BasePath() != "/api/v1/tools" {
- t.Fatalf("expected BasePath=%q, got %q", "/api/v1/tools", bridge.BasePath())
+ if bridge.BasePath() != pathAPITools {
+ t.Fatalf("expected BasePath=%q, got %q", pathAPITools, bridge.BasePath())
}
if bridge.Name() != "tools" {
- t.Fatalf("expected Name=%q, got %q", "tools", bridge.Name())
+ t.Fatalf(fmtTestExpectedName, "tools", bridge.Name())
}
}
func TestBridge_Good_NormalisesConfiguredBasePath(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.BasePath() != pathAPITools {
+ t.Fatalf("expected BasePath=%q, got %q", pathAPITools, bridge.BasePath())
}
}
@@ -116,7 +137,7 @@ func TestBridge_Ugly_RootBasePathFallsBackToRoot(t *testing.T) {
func TestBridge_Bad_RejectsUnsafeToolNames(t *testing.T) {
gin.SetMode(gin.TestMode)
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
defer func() {
if recover() == nil {
@@ -127,7 +148,7 @@ func TestBridge_Bad_RejectsUnsafeToolNames(t *testing.T) {
bridge.Add(api.ToolDescriptor{
Name: "../health",
Description: "Invalid tool name",
- }, func(c *gin.Context) {})
+ }, noopTestHandler)
}
func TestBridge_Good_AcceptsSafeToolNames(t *testing.T) {
@@ -144,7 +165,7 @@ func TestBridge_Good_AcceptsSafeToolNames(t *testing.T) {
for _, name := range cases {
t.Run(name, func(t *testing.T) {
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: name,
Description: "Safe tool name",
@@ -182,7 +203,7 @@ func TestBridge_Ugly_RejectsUnsafeToolNameForms(t *testing.T) {
for _, name := range cases {
t.Run(name, func(t *testing.T) {
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
defer func() {
if recover() == nil {
@@ -193,7 +214,7 @@ func TestBridge_Ugly_RejectsUnsafeToolNameForms(t *testing.T) {
bridge.Add(api.ToolDescriptor{
Name: name,
Description: "Invalid tool name",
- }, func(c *gin.Context) {})
+ }, noopTestHandler)
})
}
}
@@ -244,10 +265,10 @@ func TestBridge_MCPServerID_Bad_RejectsMalformedIDs(t *testing.T) {
}
func TestBridge_Good_Describe(t *testing.T) {
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
InputSchema: map[string]any{
"type": "object",
@@ -261,7 +282,7 @@ func TestBridge_Good_Describe(t *testing.T) {
"content": map[string]any{"type": "string"},
},
},
- }, func(c *gin.Context) {})
+ }, noopTestHandler)
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics data",
@@ -272,7 +293,7 @@ func TestBridge_Good_Describe(t *testing.T) {
"name": map[string]any{"type": "string"},
},
},
- }, func(c *gin.Context) {})
+ }, noopTestHandler)
// Verify DescribableGroup interface satisfaction.
var dg api.DescribableGroup = bridge
@@ -298,8 +319,8 @@ func TestBridge_Good_Describe(t *testing.T) {
if descs[1].Path != "/file_read" {
t.Fatalf("expected descs[1].Path=%q, got %q", "/file_read", descs[1].Path)
}
- if descs[1].Summary != "Read a file from disk" {
- t.Fatalf("expected descs[1].Summary=%q, got %q", "Read a file from disk", descs[1].Summary)
+ if descs[1].Summary != descReadFile {
+ t.Fatalf("expected descs[1].Summary=%q, got %q", descReadFile, descs[1].Summary)
}
if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "files" {
t.Fatalf("expected descs[1].Tags=[files], got %v", descs[1].Tags)
@@ -324,12 +345,12 @@ func TestBridge_Good_Describe(t *testing.T) {
}
func TestBridge_Good_DescribeTrimsBlankGroup(t *testing.T) {
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: " ",
- }, func(c *gin.Context) {})
+ }, noopTestHandler)
descs := bridge.Describe()
// Describe() returns the GET listing plus one tool description.
@@ -345,10 +366,10 @@ func TestBridge_Good_ValidatesRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
InputSchema: map[string]any{
"type": "object",
@@ -369,18 +390,18 @@ func TestBridge_Good_ValidatesRequestBody(t *testing.T) {
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", core.NewBufferString("{\""+`path`+"\":\"/tmp/file.txt\"}"))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", core.NewBufferString("{\""+`path`+"\":\"/tmp/file.txt\"}"))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
- if resp.Data != "/tmp/file.txt" {
+ if resp.Data != pathTmpFile {
t.Fatalf("expected validated payload to reach handler, got %q", resp.Data)
}
}
@@ -389,10 +410,10 @@ func TestBridge_Good_ValidatesResponseBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
OutputSchema: map[string]any{
"type": "object",
@@ -402,28 +423,28 @@ func TestBridge_Good_ValidatesResponseBody(t *testing.T) {
"required": []any{`path`},
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK(map[string]any{`path`: "/tmp/file.txt"}))
+ c.JSON(http.StatusOK, api.OK(map[string]any{`path`: pathTmpFile}))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", core.NewBufferString(""))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", core.NewBufferString(""))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[map[string]any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
- if resp.Data[`path`] != "/tmp/file.txt" {
+ if resp.Data[`path`] != pathTmpFile {
t.Fatalf("expected validated response data to reach client, got %v", resp.Data[`path`])
}
}
@@ -432,10 +453,10 @@ func TestBridge_Bad_InvalidResponseBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
OutputSchema: map[string]any{
"type": "object",
@@ -452,7 +473,7 @@ func TestBridge_Bad_InvalidResponseBody(t *testing.T) {
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil)
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
@@ -461,10 +482,10 @@ func TestBridge_Bad_InvalidResponseBody(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_tool_response" {
t.Fatalf("expected invalid_tool_response error, got %#v", resp.Error)
@@ -475,10 +496,10 @@ func TestBridge_Bad_InvalidRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
InputSchema: map[string]any{
"type": "object",
@@ -488,29 +509,29 @@ func TestBridge_Bad_InvalidRequestBody(t *testing.T) {
"required": []any{`path`},
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", core.NewBufferString("{\""+`path`+"\":123}"))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", core.NewBufferString("{\""+`path`+"\":123}"))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -518,10 +539,10 @@ func TestBridge_Bad_RejectsWhitespaceOnlyRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
InputSchema: map[string]any{
"type": "object",
@@ -531,14 +552,14 @@ func TestBridge_Bad_RejectsWhitespaceOnlyRequestBody(t *testing.T) {
"required": []any{`path`},
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", core.NewBufferString(" "))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", core.NewBufferString(" "))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
@@ -547,10 +568,10 @@ func TestBridge_Bad_RejectsWhitespaceOnlyRequestBody(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -558,10 +579,10 @@ func TestBridge_Ugly_RejectsMalformedJSONRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
InputSchema: map[string]any{
"type": "object",
@@ -571,14 +592,14 @@ func TestBridge_Ugly_RejectsMalformedJSONRequestBody(t *testing.T) {
"required": []any{`path`},
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", core.NewBufferString("{\""+`path`+"\":"))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", core.NewBufferString("{\""+`path`+"\":"))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
@@ -587,10 +608,10 @@ func TestBridge_Ugly_RejectsMalformedJSONRequestBody(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -598,10 +619,10 @@ func TestBridge_Ugly_RejectsOversizedRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
InputSchema: map[string]any{
"type": "object",
@@ -611,14 +632,14 @@ func TestBridge_Ugly_RejectsOversizedRequestBody(t *testing.T) {
"required": []any{`path`},
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", core.NewBuffer(coreBytesRepeat([]byte("a"), 10<<20+1)))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/file_read", core.NewBuffer(coreBytesRepeat([]byte("a"), 10<<20+1)))
engine.ServeHTTP(w, req)
if w.Code != http.StatusRequestEntityTooLarge {
@@ -627,10 +648,10 @@ func TestBridge_Ugly_RejectsOversizedRequestBody(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -638,10 +659,10 @@ func TestBridge_Good_ValidatesEnumValues(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "publish_item",
- Description: "Publish an item",
+ Description: descPublishItem,
Group: "items",
InputSchema: map[string]any{
"type": "object",
@@ -661,11 +682,11 @@ func TestBridge_Good_ValidatesEnumValues(t *testing.T) {
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", core.NewBufferString(`{"status":"published"}`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/publish_item", core.NewBufferString(`{"status":"published"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
}
@@ -673,10 +694,10 @@ func TestBridge_Bad_RejectsInvalidEnumValues(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "publish_item",
- Description: "Publish an item",
+ Description: descPublishItem,
Group: "items",
InputSchema: map[string]any{
"type": "object",
@@ -696,22 +717,22 @@ func TestBridge_Bad_RejectsInvalidEnumValues(t *testing.T) {
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", core.NewBufferString(`{"status":"archived"}`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/publish_item", core.NewBufferString(`{"status":"archived"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -719,7 +740,7 @@ func TestBridge_Good_ValidatesSchemaCombinators(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "route_choice",
Description: "Choose a route",
@@ -733,7 +754,7 @@ func TestBridge_Good_ValidatesSchemaCombinators(t *testing.T) {
"type": "string",
"allOf": []any{
map[string]any{"minLength": 2},
- map[string]any{"pattern": "^[A-Z]+$"},
+ map[string]any{"pattern": patternUpper},
},
},
map[string]any{
@@ -757,7 +778,7 @@ func TestBridge_Good_ValidatesSchemaCombinators(t *testing.T) {
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
}
@@ -765,7 +786,7 @@ func TestBridge_Bad_RejectsAmbiguousOneOfMatches(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "route_choice",
Description: "Choose a route",
@@ -779,7 +800,7 @@ func TestBridge_Bad_RejectsAmbiguousOneOfMatches(t *testing.T) {
"type": "string",
"allOf": []any{
map[string]any{"minLength": 1},
- map[string]any{"pattern": "^[A-Z]+$"},
+ map[string]any{"pattern": patternUpper},
},
},
map[string]any{
@@ -803,18 +824,18 @@ func TestBridge_Bad_RejectsAmbiguousOneOfMatches(t *testing.T) {
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -822,10 +843,10 @@ func TestBridge_Bad_RejectsAdditionalProperties(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "publish_item",
- Description: "Publish an item",
+ Description: descPublishItem,
Group: "items",
InputSchema: map[string]any{
"type": "object",
@@ -843,22 +864,22 @@ func TestBridge_Bad_RejectsAdditionalProperties(t *testing.T) {
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", core.NewBufferString(`{"status":"published","unexpected":true}`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/publish_item", core.NewBufferString(`{"status":"published","unexpected":true}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -866,7 +887,7 @@ func TestBridge_Good_EnforcesStringConstraints(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "publish_code",
Description: "Publish a code",
@@ -878,7 +899,7 @@ func TestBridge_Good_EnforcesStringConstraints(t *testing.T) {
"type": "string",
"minLength": 3,
"maxLength": 5,
- "pattern": "^[A-Z]+$",
+ "pattern": patternUpper,
},
},
"required": []any{"code"},
@@ -895,7 +916,7 @@ func TestBridge_Good_EnforcesStringConstraints(t *testing.T) {
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
}
@@ -903,7 +924,7 @@ func TestBridge_Bad_RejectsNumericAndCollectionConstraints(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "quota_check",
Description: "Check quotas",
@@ -950,21 +971,21 @@ func TestBridge_Bad_RejectsNumericAndCollectionConstraints(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
func TestBridge_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) {})
+ bridge := api.NewToolBridge(pathTools)
+ bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, noopTestHandler)
+ bridge.Add(api.ToolDescriptor{Name: "beta", Description: "Tool B", Group: "b"}, noopTestHandler)
+ bridge.Add(api.ToolDescriptor{Name: "gamma", Description: "Tool C", Group: "c"}, noopTestHandler)
tools := bridge.Tools()
if len(tools) != 3 {
@@ -981,7 +1002,7 @@ func TestBridge_Good_ToolsAccessor(t *testing.T) {
func TestBridge_Bad_EmptyBridge(t *testing.T) {
gin.SetMode(gin.TestMode)
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
// RegisterRoutes should not panic with no tools.
engine := gin.New()
@@ -1010,10 +1031,10 @@ func TestBridge_Bad_EmptyBridge(t *testing.T) {
func TestBridge_Good_ListsRegisteredTools(t *testing.T) {
gin.SetMode(gin.TestMode)
- bridge := api.NewToolBridge("/v1/tools")
+ bridge := api.NewToolBridge(pathV1Tools)
bridge.Add(api.ToolDescriptor{
Name: "file_read",
- Description: "Read a file from disk",
+ Description: descReadFile,
Group: "files",
InputSchema: map[string]any{
"type": "object",
@@ -1021,19 +1042,19 @@ func TestBridge_Good_ListsRegisteredTools(t *testing.T) {
`path`: map[string]any{"type": "string"},
},
},
- }, func(c *gin.Context) {})
+ }, noopTestHandler)
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics data",
Group: "metrics",
- }, func(c *gin.Context) {})
+ }, noopTestHandler)
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/v1/tools", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathV1Tools, nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -1042,7 +1063,7 @@ func TestBridge_Good_ListsRegisteredTools(t *testing.T) {
var resp api.Response[[]api.ToolDescriptor]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
t.Fatal("expected Success=true for tool listing")
@@ -1063,13 +1084,13 @@ func TestBridge_Good_ListsRegisteredTools(t *testing.T) {
func TestBridge_Bad_ListingRoutesWhenEmpty(t *testing.T) {
gin.SetMode(gin.TestMode)
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/tools", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathTools, nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -1078,7 +1099,7 @@ func TestBridge_Bad_ListingRoutesWhenEmpty(t *testing.T) {
var resp api.Response[[]api.ToolDescriptor]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
t.Fatal("expected Success=true from empty listing")
@@ -1094,7 +1115,7 @@ func TestBridge_Bad_ListingRoutesWhenEmpty(t *testing.T) {
func TestBridge_Ugly_ListingCoexistsWithToolEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
- bridge := api.NewToolBridge("/v1/tools")
+ bridge := api.NewToolBridge(pathV1Tools)
bridge.Add(api.ToolDescriptor{
Name: "ping",
Description: "Ping tool",
@@ -1107,7 +1128,7 @@ func TestBridge_Ugly_ListingCoexistsWithToolEndpoint(t *testing.T) {
bridge.RegisterRoutes(rg)
// Listing still answers at the base path.
- listReq, _ := http.NewRequest(http.MethodGet, "/v1/tools", nil)
+ listReq, _ := http.NewRequest(http.MethodGet, pathV1Tools, nil)
listW := httptest.NewRecorder()
engine.ServeHTTP(listW, listReq)
if listW.Code != http.StatusOK {
@@ -1127,10 +1148,10 @@ func TestBridge_Good_ValidatesArrayInputSchema(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "tags",
- Description: "Validate array input",
+ Description: descValidateArr,
InputSchema: map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
@@ -1149,19 +1170,19 @@ func TestBridge_Good_ValidatesArrayInputSchema(t *testing.T) {
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/tags", core.NewBufferString(`["alpha","beta"]`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/tags", core.NewBufferString(`["alpha","beta"]`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[[]string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if len(resp.Data) != 2 || resp.Data[0] != "alpha" || resp.Data[1] != "beta" {
t.Fatalf("expected validated array payload to round-trip, got %v", resp.Data)
@@ -1172,39 +1193,39 @@ func TestBridge_Bad_RejectsTooSmallArrayInputSchema(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "tags",
- Description: "Validate array input",
+ Description: descValidateArr,
InputSchema: map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
"minItems": 2,
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/tags", core.NewBufferString(`["alpha"]`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/tags", core.NewBufferString(`["alpha"]`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -1212,38 +1233,38 @@ func TestBridge_Ugly_RejectsWrongArrayElementType(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "tags",
- Description: "Validate array input",
+ Description: descValidateArr,
InputSchema: map[string]any{
"type": "array",
"items": map[string]any{"type": "string"},
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/tags", core.NewBufferString(`["alpha",123]`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/tags", core.NewBufferString(`["alpha",123]`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -1251,10 +1272,10 @@ func TestBridge_Good_ValidatesNumericBounds(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "score",
- Description: "Validate numeric input",
+ Description: descValidateNum,
InputSchema: map[string]any{
"type": "number",
"minimum": 1,
@@ -1272,19 +1293,19 @@ func TestBridge_Good_ValidatesNumericBounds(t *testing.T) {
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/score", core.NewBufferString(`5.5`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/score", core.NewBufferString(`5.5`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[float64]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if resp.Data != 5.5 {
t.Fatalf("expected validated numeric payload to round-trip, got %v", resp.Data)
@@ -1295,7 +1316,7 @@ func TestBridge_Bad_RejectsLargeIntegerAboveMaximum(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "quota",
Description: "Validate large integer input",
@@ -1304,7 +1325,7 @@ func TestBridge_Bad_RejectsLargeIntegerAboveMaximum(t *testing.T) {
"maximum": 9007199254740992,
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
@@ -1320,13 +1341,13 @@ func TestBridge_Bad_RejectsLargeIntegerAboveMaximum(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -1334,38 +1355,38 @@ func TestBridge_Bad_RejectsNumericInputBelowMinimum(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "score",
- Description: "Validate numeric input",
+ Description: descValidateNum,
InputSchema: map[string]any{
"type": "number",
"minimum": 1,
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/score", core.NewBufferString(`0`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/score", core.NewBufferString(`0`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -1373,37 +1394,37 @@ func TestBridge_Ugly_RejectsNonNumericInput(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "score",
- Description: "Validate numeric input",
+ Description: descValidateNum,
InputSchema: map[string]any{
"type": "number",
},
}, func(c *gin.Context) {
- c.JSON(http.StatusOK, api.OK("should not run"))
+ c.JSON(http.StatusOK, api.OK(msgShouldNotRun))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/tools/score", core.NewBufferString(`"oops"`))
+ req, _ := http.NewRequest(http.MethodPost, pathTools+"/score", core.NewBufferString(`"oops"`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
- t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
+ t.Fatalf(fmtBridgeInvalidBody, resp.Error)
}
}
@@ -1411,10 +1432,10 @@ func TestBridge_Good_IntegrationWithEngine(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
- bridge := api.NewToolBridge("/tools")
+ bridge := api.NewToolBridge(pathTools)
bridge.Add(api.ToolDescriptor{
Name: "ping",
Description: "Ping tool",
@@ -1431,17 +1452,17 @@ func TestBridge_Good_IntegrationWithEngine(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if resp.Data != "pong" {
- t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
+ t.Fatalf(fmtTestExpectedData, "pong", resp.Data)
}
}
diff --git a/go/brotli.go b/go/brotli.go
index 68403a3..9642f05 100644
--- a/go/brotli.go
+++ b/go/brotli.go
@@ -56,7 +56,7 @@ func (h *brotliHandler) Handle(c *gin.Context) {
w := h.pool.Get().(*brotli.Writer)
w.Reset(c.Writer)
- c.Header("Content-Encoding", "br")
+ c.Header(hdrContentEncoding, "br")
c.Writer.Header().Add("Vary", "Accept-Encoding")
bw := &brotliWriter{ResponseWriter: c.Writer, writer: w}
@@ -130,7 +130,7 @@ func (b *brotliWriter) Write(data []byte) (
}
if b.status >= http.StatusBadRequest {
- b.Header().Del("Content-Encoding")
+ b.Header().Del(hdrContentEncoding)
b.Header().Del("Vary")
return b.ResponseWriter.Write(data)
}
@@ -157,7 +157,7 @@ func (b *brotliWriter) WriteHeader(code int) {
b.statusWritten = true
b.Header().Del("Content-Length")
if code >= http.StatusBadRequest {
- b.Header().Del("Content-Encoding")
+ b.Header().Del(hdrContentEncoding)
b.Header().Del("Vary")
}
b.ResponseWriter.WriteHeader(code)
@@ -177,7 +177,7 @@ func (b *brotliWriter) WriteHeaderNow() {
}
b.Header().Del("Content-Length")
if b.status >= http.StatusBadRequest {
- b.Header().Del("Content-Encoding")
+ b.Header().Del(hdrContentEncoding)
b.Header().Del("Vary")
}
b.ResponseWriter.WriteHeaderNow()
@@ -207,7 +207,7 @@ func (b *brotliWriter) release(pool *sync.Pool) {
b.released = true
if b.status >= http.StatusBadRequest {
- b.Header().Del("Content-Encoding")
+ b.Header().Del(hdrContentEncoding)
b.Header().Del("Vary")
b.writer.Reset(io.Discard)
} else if b.Size() < 0 {
diff --git a/go/brotli_test.go b/go/brotli_test.go
index 9c6959b..4c29b57 100644
--- a/go/brotli_test.go
+++ b/go/brotli_test.go
@@ -27,15 +27,15 @@ func TestWithBrotli_Good_CompressesResponse(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "br")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q, got %q", "br", ce)
}
@@ -48,15 +48,15 @@ func TestWithBrotli_Good_NoCompressionWithoutAcceptHeader(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
// Deliberately not setting Accept-Encoding header.
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce == "br" {
t.Fatal("expected no br Content-Encoding when client does not request it")
}
@@ -90,15 +90,15 @@ func TestWithBrotli_Good_AcceptEncodingTokenParsing(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", tt.acceptEncoding)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, tt.acceptEncoding)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- gotBrotli := w.Header().Get("Content-Encoding") == "br"
+ gotBrotli := w.Header().Get(hdrContentEnc) == "br"
if gotBrotli != tt.wantBrotli {
t.Fatalf("expected brotli=%v for Accept-Encoding %q, got %v", tt.wantBrotli, tt.acceptEncoding, gotBrotli)
}
@@ -115,15 +115,15 @@ func TestWithBrotli_Good_DefaultLevel(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "br")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q with default level, got %q", "br", ce)
}
@@ -137,15 +137,15 @@ func TestWithBrotli_Good_CustomLevel(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "br")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q with BestSpeed, got %q", "br", ce)
}
@@ -161,21 +161,21 @@ func TestWithBrotli_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "br")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "br")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Both brotli compression and request ID should be present.
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "br" {
t.Fatalf("expected Content-Encoding=%q, got %q", "br", ce)
}
- rid := w.Header().Get("X-Request-ID")
+ rid := w.Header().Get(hdrXRequestID)
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
@@ -207,7 +207,7 @@ func TestWithBrotli_Good_DropsLateWritesAfterHandlerReturn(t *testing.T) {
w1 := httptest.NewRecorder()
req1 := httptest.NewRequest(http.MethodGet, "/brotli-late/leaky", nil)
- req1.Header.Set("Accept-Encoding", "br")
+ req1.Header.Set(hdrAcceptEnc, "br")
h.ServeHTTP(w1, req1)
select {
@@ -222,13 +222,13 @@ func TestWithBrotli_Good_DropsLateWritesAfterHandlerReturn(t *testing.T) {
w2 := httptest.NewRecorder()
req2 := httptest.NewRequest(http.MethodGet, "/brotli-late/target", nil)
- req2.Header.Set("Accept-Encoding", "br")
+ req2.Header.Set(hdrAcceptEnc, "br")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected second request status 200, got %d", w2.Code)
}
- if ce := w2.Header().Get("Content-Encoding"); ce != "br" {
+ if ce := w2.Header().Get(hdrContentEnc); ce != "br" {
t.Fatalf("expected second response Content-Encoding=%q, got %q", "br", ce)
}
diff --git a/go/cache_test.go b/go/cache_test.go
index fbc60b7..fc62a3e 100644
--- a/go/cache_test.go
+++ b/go/cache_test.go
@@ -71,7 +71,7 @@ func TestWithCache_Good_CachesGETResponse(t *testing.T) {
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w1.Code)
+ t.Fatalf(fmtTestExpected200, w1.Code)
}
body1 := w1.Body.String()
@@ -85,7 +85,7 @@ func TestWithCache_Good_CachesGETResponse(t *testing.T) {
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w2.Code)
+ t.Fatalf(fmtTestExpected200, w2.Code)
}
body2 := w2.Body.String()
@@ -93,7 +93,7 @@ func TestWithCache_Good_CachesGETResponse(t *testing.T) {
t.Fatalf("expected cached body %q, got %q", body1, body2)
}
- cacheHeader := w2.Header().Get("X-Cache")
+ cacheHeader := w2.Header().Get(hdrXCache)
if cacheHeader != "HIT" {
t.Fatalf("expected X-Cache=HIT, got %q", cacheHeader)
}
@@ -116,17 +116,17 @@ func TestWithCacheLimits_Good_CachesGETResponse(t *testing.T) {
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)
+ t.Fatalf(fmtTestExpected200, w1.Code)
}
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)
+ t.Fatalf(fmtTestExpected200, w2.Code)
}
- if got := w2.Header().Get("X-Cache"); got != "HIT" {
+ if got := w2.Header().Get(hdrXCache); got != "HIT" {
t.Fatalf("expected X-Cache=HIT, got %q", got)
}
if grp.counter.Load() != 1 {
@@ -148,15 +148,15 @@ func TestWithCache_Good_POSTNotCached(t *testing.T) {
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w1.Code)
+ t.Fatalf(fmtTestExpected200, w1.Code)
}
var resp1 api.Response[string]
if err := coreJSONUnmarshal(w1.Body.Bytes(), &resp1); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp1.Data != "post-1" {
- t.Fatalf("expected Data=%q, got %q", "post-1", resp1.Data)
+ t.Fatalf(fmtTestExpectedData, "post-1", resp1.Data)
}
// Second POST request — should NOT be cached, counter increments.
@@ -166,10 +166,10 @@ func TestWithCache_Good_POSTNotCached(t *testing.T) {
var resp2 api.Response[string]
if err := coreJSONUnmarshal(w2.Body.Bytes(), &resp2); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp2.Data != "post-2" {
- t.Fatalf("expected Data=%q, got %q", "post-2", resp2.Data)
+ t.Fatalf(fmtTestExpectedData, "post-2", resp2.Data)
}
// Counter should be 2 — both POST requests hit the handler.
@@ -243,11 +243,11 @@ func TestWithCache_Good_CombinesWithOtherMiddleware(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// RequestID middleware should still set X-Request-ID.
- rid := w.Header().Get("X-Request-ID")
+ rid := w.Header().Get(hdrXRequestID)
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
@@ -272,33 +272,33 @@ func TestWithCache_Good_PreservesCurrentRequestIDOnHit(t *testing.T) {
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
- req1.Header.Set("X-Request-ID", "first-request-id")
+ req1.Header.Set(hdrXRequestID, "first-request-id")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w1.Code)
+ t.Fatalf(fmtTestExpected200, w1.Code)
}
- if got := w1.Header().Get("X-Request-ID"); got != "first-request-id" {
+ if got := w1.Header().Get(hdrXRequestID); got != "first-request-id" {
t.Fatalf("expected first response request ID %q, got %q", "first-request-id", got)
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
- req2.Header.Set("X-Request-ID", "second-request-id")
+ req2.Header.Set(hdrXRequestID, "second-request-id")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w2.Code)
+ t.Fatalf(fmtTestExpected200, w2.Code)
}
- if got := w2.Header().Get("X-Request-ID"); got != "second-request-id" {
+ if got := w2.Header().Get(hdrXRequestID); got != "second-request-id" {
t.Fatalf("expected cached response to preserve current request ID %q, got %q", "second-request-id", got)
}
- if got := w2.Header().Get("X-Cache"); got != "HIT" {
+ if got := w2.Header().Get(hdrXCache); got != "HIT" {
t.Fatalf("expected X-Cache=HIT, got %q", got)
}
var resp2 api.Response[string]
if err := coreJSONUnmarshal(w2.Body.Bytes(), &resp2); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp2.Data != "call-1" {
t.Fatalf("expected cached response data %q, got %q", "call-1", resp2.Data)
@@ -326,15 +326,15 @@ func TestWithCache_Good_PreservesCurrentRequestMetaOnHit(t *testing.T) {
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
- req1.Header.Set("X-Request-ID", "first-request-id")
+ req1.Header.Set(hdrXRequestID, "first-request-id")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w1.Code)
+ t.Fatalf(fmtTestExpected200, w1.Code)
}
var resp1 api.Response[string]
if err := coreJSONUnmarshal(w1.Body.Bytes(), &resp1); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp1.Meta == nil {
t.Fatal("expected meta on first response")
@@ -345,15 +345,15 @@ func TestWithCache_Good_PreservesCurrentRequestMetaOnHit(t *testing.T) {
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
- req2.Header.Set("X-Request-ID", "second-request-id")
+ req2.Header.Set(hdrXRequestID, "second-request-id")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w2.Code)
+ t.Fatalf(fmtTestExpected200, w2.Code)
}
var resp2 api.Response[string]
if err := coreJSONUnmarshal(w2.Body.Bytes(), &resp2); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp2.Meta == nil {
t.Fatal("expected meta on cached response")
@@ -367,7 +367,7 @@ func TestWithCache_Good_PreservesCurrentRequestMetaOnHit(t *testing.T) {
if resp2.Meta.Page != 1 || resp2.Meta.PerPage != 25 || resp2.Meta.Total != 100 {
t.Fatalf("expected pagination metadata to remain intact, got %+v", resp2.Meta)
}
- if got := w2.Header().Get("X-Request-ID"); got != "second-request-id" {
+ if got := w2.Header().Get(hdrXRequestID); got != "second-request-id" {
t.Fatalf("expected response header X-Request-ID=%q, got %q", "second-request-id", got)
}
}
@@ -395,7 +395,7 @@ func TestWithCache_Good_PreservesMultiValueHeadersOnHit(t *testing.T) {
req1, _ := http.NewRequest(http.MethodGet, "/cache/multi", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w1.Code)
+ t.Fatalf(fmtTestExpected200, w1.Code)
}
w2 := httptest.NewRecorder()
@@ -432,7 +432,7 @@ func TestWithCache_Ugly_NonPositiveTTLDisablesMiddleware(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected request %d to succeed with disabled cache, got %d", i+1, w.Code)
}
- if got := w.Header().Get("X-Cache"); got != "" {
+ if got := w.Header().Get(hdrXCache); got != "" {
t.Fatalf("expected no X-Cache header with disabled cache, got %q", got)
}
}
@@ -460,7 +460,7 @@ func TestWithCache_Ugly_ExplicitZeroLimitsDisableMiddleware(t *testing.T) {
if w.Code != http.StatusOK {
t.Fatalf("expected request %d to succeed with disabled cache, got %d", i+1, w.Code)
}
- if got := w.Header().Get("X-Cache"); got != "" {
+ if got := w.Header().Get(hdrXCache); got != "" {
t.Fatalf("expected no X-Cache header with disabled cache, got %q", got)
}
}
@@ -570,7 +570,7 @@ func TestWithCache_Good_EvictsWhenSizeLimitReached(t *testing.T) {
t.Fatalf("expected size-limited cache to evict the oldest entry, got %q", w3.Body.String())
}
- if got := w3.Header().Get("X-Cache"); got != "" {
+ if got := w3.Header().Get(hdrXCache); got != "" {
t.Fatalf("expected re-executed response to miss the cache, got X-Cache=%q", got)
}
diff --git a/go/chat_adapter.go b/go/chat_adapter.go
new file mode 100644
index 0000000..563a68b
--- /dev/null
+++ b/go/chat_adapter.go
@@ -0,0 +1,65 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "io" // Note: AX-6 — io.Writer/Reader are the transcoder stream boundary.
+
+ core "dappco.re/go"
+)
+
+// ChatFormatAdapter maps between the OpenAI chat shape and a non-OpenAI upstream.
+// OpenAI-compatible upstreams need NO adapter — passthrough is the default.
+type ChatFormatAdapter interface {
+ // Name identifies the adapter, e.g. "ollama", "anthropic".
+ Name() string
+ // UpstreamPath is the path under the upstream base URL, e.g. "/api/chat".
+ UpstreamPath() string
+ // BuildRequest maps the OpenAI request into the upstream body + protocol
+ // headers (Content-Type, anthropic-version). Operator secrets (x-api-key)
+ // belong in Upstream.Headers, not here.
+ BuildRequest(req ChatCompletionRequest) (body []byte, headers map[string]string, err error)
+ // DecodeResponse maps a complete (non-streaming) upstream body into the
+ // OpenAI response.
+ DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error)
+ // Transcoder converts the upstream stream into OpenAI chunk SSE; nil means
+ // the adapter supports non-streaming only.
+ Transcoder() ChatStreamTranscoder
+}
+
+// ChatStreamTranscoder converts an upstream response stream into OpenAI
+// chat.completion.chunk SSE events written to w (flushing via flush as it goes).
+// It emits the terminating "data: [DONE]". Returns on upstream EOF or error.
+type ChatStreamTranscoder interface {
+ Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error
+}
+
+// ChatStreamMeta carries the OpenAI identity fields a transcoder stamps on every chunk.
+type ChatStreamMeta struct {
+ ID string
+ Model string
+ Created int64
+}
+
+// writeChatChunk marshals a chunk as one SSE "data:" event and flushes.
+func writeChatChunk(w io.Writer, flush func(), chunk ChatCompletionChunk) {
+ data := core.JSONMarshal(chunk)
+ raw, ok := data.Value.([]byte)
+ if !data.OK || !ok {
+ return
+ }
+ _, _ = io.WriteString(w, "data: ")
+ _, _ = w.Write(raw)
+ _, _ = io.WriteString(w, "\n\n")
+ if flush != nil {
+ flush()
+ }
+}
+
+// writeSSEDone emits the terminating sentinel.
+func writeSSEDone(w io.Writer, flush func()) {
+ _, _ = io.WriteString(w, "data: [DONE]\n\n")
+ if flush != nil {
+ flush()
+ }
+}
diff --git a/go/chat_adapter_anthropic.go b/go/chat_adapter_anthropic.go
new file mode 100644
index 0000000..a7577d7
--- /dev/null
+++ b/go/chat_adapter_anthropic.go
@@ -0,0 +1,183 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+
+ core "dappco.re/go"
+)
+
+const anthropicVersion = "2023-06-01"
+
+type anthropicAdapter struct{}
+
+// AnthropicAdapter maps OpenAI chat completions to/from Anthropic's /v1/messages
+// (top-level system field, mandatory max_tokens, content blocks, SSE event stream).
+func AnthropicAdapter() ChatFormatAdapter { return anthropicAdapter{} }
+
+func (anthropicAdapter) Name() string { return "anthropic" }
+func (anthropicAdapter) UpstreamPath() string { return "/v1/messages" }
+
+func anthropicFinish(stopReason string) string {
+ switch stopReason {
+ case "max_tokens":
+ return "length"
+ default: // end_turn, stop_sequence, etc.
+ return "stop"
+ }
+}
+
+func (anthropicAdapter) BuildRequest(req ChatCompletionRequest) ([]byte, map[string]string, error) {
+ var system string
+ msgs := make([]map[string]string, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ if m.Role == "system" {
+ if system != "" {
+ system += "\n"
+ }
+ system += m.Content
+ continue
+ }
+ msgs = append(msgs, map[string]string{"role": m.Role, "content": m.Content})
+ }
+ maxTokens := chatDefaultMaxTokens
+ if req.MaxTokens != nil {
+ maxTokens = *req.MaxTokens
+ }
+ body := map[string]any{
+ "model": req.Model,
+ "messages": msgs,
+ "max_tokens": maxTokens,
+ "stream": req.Stream,
+ }
+ if system != "" {
+ body["system"] = system
+ }
+ if req.Temperature != nil {
+ body["temperature"] = *req.Temperature
+ }
+ if req.TopP != nil {
+ body["top_p"] = *req.TopP
+ }
+ if req.TopK != nil {
+ body["top_k"] = *req.TopK
+ }
+ if len(req.Stop) > 0 {
+ body["stop_sequences"] = []string(req.Stop)
+ }
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, nil, core.E("anthropic", "marshal request", err)
+ }
+ return raw, map[string]string{"Content-Type": "application/json", "anthropic-version": anthropicVersion}, nil
+}
+
+type anthropicResponse struct {
+ Content []struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ } `json:"content"`
+ StopReason string `json:"stop_reason"`
+ Usage struct {
+ InputTokens int `json:"input_tokens"`
+ OutputTokens int `json:"output_tokens"`
+ } `json:"usage"`
+}
+
+func (anthropicAdapter) DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) {
+ var ar anthropicResponse
+ if err := json.Unmarshal(upstream, &ar); err != nil {
+ return ChatCompletionResponse{}, core.E("anthropic", "decode response", err)
+ }
+ var content string
+ for _, b := range ar.Content {
+ if b.Type == "text" {
+ content += b.Text
+ }
+ }
+ return ChatCompletionResponse{
+ ID: newChatCompletionID(),
+ Object: "chat.completion",
+ Model: model,
+ Choices: []ChatChoice{{Index: 0, Message: ChatMessage{Role: "assistant", Content: content}, FinishReason: anthropicFinish(ar.StopReason)}},
+ Usage: ChatUsage{PromptTokens: ar.Usage.InputTokens, CompletionTokens: ar.Usage.OutputTokens, TotalTokens: ar.Usage.InputTokens + ar.Usage.OutputTokens},
+ }, nil
+}
+
+func (anthropicAdapter) Transcoder() ChatStreamTranscoder { return anthropicTranscoder{} }
+
+type anthropicTranscoder struct{}
+
+type anthropicStreamEvent struct {
+ Type string `json:"type"`
+ Delta struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+ StopReason string `json:"stop_reason"`
+ } `json:"delta"`
+}
+
+func (anthropicTranscoder) Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error {
+ scanner := bufio.NewScanner(upstream)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ first := true
+ stopReason := "end_turn"
+ for scanner.Scan() {
+ line := core.Trim(scanner.Text())
+ if !core.HasPrefix(line, "data:") {
+ continue // skip "event:" and blank lines; the data line carries type
+ }
+ payload := core.Trim(line[len("data:"):])
+ if payload == "" {
+ continue
+ }
+ var ev anthropicStreamEvent
+ if err := json.Unmarshal([]byte(payload), &ev); err != nil {
+ continue
+ }
+ switch ev.Type {
+ case "content_block_delta":
+ if ev.Delta.Type != "text_delta" || ev.Delta.Text == "" {
+ continue
+ }
+ delta := ChatMessageDelta{Content: ev.Delta.Text}
+ if first {
+ delta.Role = "assistant"
+ first = false
+ }
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: nil}},
+ })
+ case "message_delta":
+ if ev.Delta.StopReason != "" {
+ stopReason = ev.Delta.StopReason
+ }
+ case "message_stop":
+ fr := anthropicFinish(stopReason)
+ delta := ChatMessageDelta{}
+ if first { // empty/no-text stream — still prime the assistant role
+ delta.Role = "assistant"
+ first = false
+ }
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: &fr}},
+ })
+ if err := scanner.Err(); err != nil {
+ return err // truncated stream signals incomplete; do NOT emit [DONE]
+ }
+ writeSSEDone(w, flush)
+ return nil
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return err // truncated stream signals incomplete; do NOT emit [DONE]
+ }
+ // Stream ended without an explicit message_stop — still terminate cleanly.
+ writeSSEDone(w, flush)
+ return nil
+}
diff --git a/go/chat_adapter_anthropic_test.go b/go/chat_adapter_anthropic_test.go
new file mode 100644
index 0000000..dbea4d7
--- /dev/null
+++ b/go/chat_adapter_anthropic_test.go
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+// errAfterReader yields data once, then errors — simulating a truncated upstream
+// stream (e.g. connection reset mid-response).
+type errAfterReader struct {
+ data []byte
+ err error
+ done bool
+}
+
+func (r *errAfterReader) Read(p []byte) (int, error) {
+ if !r.done {
+ r.done = true
+ n := copy(p, r.data)
+ return n, nil
+ }
+ return 0, r.err
+}
+
+func TestAnthropicAdapter_BuildRequest_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ body, hdrs, err := a.BuildRequest(api.ChatCompletionRequest{
+ Model: "claude-3", Messages: []api.ChatMessage{{Role: "system", Content: "be terse"}, {Role: "user", Content: "hi"}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hdrs["anthropic-version"] == "" {
+ t.Errorf("missing anthropic-version header")
+ }
+ var got map[string]any
+ _ = json.Unmarshal(body, &got)
+ if got["system"] != "be terse" {
+ t.Errorf("system not extracted: %s", body)
+ }
+ msgs, _ := got["messages"].([]any)
+ if len(msgs) != 1 { // system removed from messages
+ t.Errorf("system not removed from messages: %s", body)
+ }
+ if _, ok := got["max_tokens"]; !ok {
+ t.Errorf("max_tokens (mandatory) missing: %s", body)
+ }
+}
+
+func TestAnthropicAdapter_DecodeResponse_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ out, err := a.DecodeResponse("claude-3", []byte(`{"content":[{"type":"text","text":"Hi"},{"type":"text","text":" there"}],"stop_reason":"max_tokens","usage":{"input_tokens":5,"output_tokens":2}}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.Choices[0].Message.Content != "Hi there" {
+ t.Errorf("text blocks not concatenated: %q", out.Choices[0].Message.Content)
+ }
+ if out.Choices[0].FinishReason != "length" {
+ t.Errorf("max_tokens not mapped to length: %s", out.Choices[0].FinishReason)
+ }
+ if out.Usage.PromptTokens != 5 || out.Usage.CompletionTokens != 2 {
+ t.Errorf("bad usage: %+v", out.Usage)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ // Minimal Anthropic event stream.
+ stream := strings.Join([]string{
+ "event: message_start",
+ `data: {"type":"message_start","message":{"usage":{"input_tokens":5}}}`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"He"}}`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"llo"}}`,
+ "",
+ "event: message_delta",
+ `data: {"type":"message_delta","delta":{"stop_reason":"end_turn"}}`,
+ "",
+ "event: message_stop",
+ `data: {"type":"message_stop"}`,
+ "",
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) {
+ t.Errorf("missing deltas: %s", got)
+ }
+ if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing terminal/[DONE]: %s", got)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_EmptyStream_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ stream := strings.Join([]string{
+ "event: message_start",
+ `data: {"type":"message_start","message":{"usage":{"input_tokens":5}}}`,
+ "",
+ "event: message_stop",
+ `data: {"type":"message_stop"}`,
+ "",
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"role":"assistant"`) {
+ t.Errorf("empty stream did not prime assistant role: %s", got)
+ }
+ if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing finish chunk/[DONE]: %s", got)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_Truncated_Ugly(t *testing.T) {
+ a := api.AnthropicAdapter()
+ r := &errAfterReader{
+ data: []byte(`data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"He"}}` + "\n\n"),
+ err: errors.New("reset"),
+ }
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, r, api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err == nil {
+ t.Fatal("truncated stream: want non-nil error, got nil")
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) {
+ t.Errorf("partial content delta not emitted: %s", got)
+ }
+ if strings.Contains(got, "data: [DONE]") {
+ t.Errorf("truncated stream must NOT emit [DONE]: %s", got)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_MalformedLineSkipped_Ugly(t *testing.T) {
+ a := api.AnthropicAdapter()
+ stream := strings.Join([]string{
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"He"}}`,
+ "",
+ "event: content_block_delta",
+ `data: {not valid json`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"llo"}}`,
+ "",
+ "event: message_stop",
+ `data: {"type":"message_stop"}`,
+ "",
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) {
+ t.Errorf("malformed line aborted the stream — deltas lost: %s", got)
+ }
+ if !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing [DONE]: %s", got)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_UnknownEvent_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ stream := strings.Join([]string{
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"He"}}`,
+ "",
+ "event: ping",
+ `data: {"type":"ping"}`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"llo"}}`,
+ "",
+ "event: message_stop",
+ `data: {"type":"message_stop"}`,
+ "",
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) {
+ t.Errorf("unknown event disrupted deltas: %s", got)
+ }
+ if !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing [DONE]: %s", got)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_MultiBlock_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ stream := strings.Join([]string{
+ "event: content_block_start",
+ `data: {"type":"content_block_start","index":0}`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"first"}}`,
+ "",
+ "event: content_block_stop",
+ `data: {"type":"content_block_stop","index":0}`,
+ "",
+ "event: content_block_start",
+ `data: {"type":"content_block_start","index":1}`,
+ "",
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"second"}}`,
+ "",
+ "event: content_block_stop",
+ `data: {"type":"content_block_stop","index":1}`,
+ "",
+ "event: message_stop",
+ `data: {"type":"message_stop"}`,
+ "",
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ firstIdx := strings.Index(got, `"content":"first"`)
+ secondIdx := strings.Index(got, `"content":"second"`)
+ if firstIdx < 0 || secondIdx < 0 {
+ t.Errorf("multi-block deltas missing: %s", got)
+ }
+ if firstIdx > secondIdx {
+ t.Errorf("multi-block deltas out of order: %s", got)
+ }
+ if !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing [DONE]: %s", got)
+ }
+}
+
+func TestAnthropicAdapter_Transcode_StopWithoutMessageDelta_Good(t *testing.T) {
+ a := api.AnthropicAdapter()
+ stream := strings.Join([]string{
+ "event: content_block_delta",
+ `data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Hi"}}`,
+ "",
+ "event: message_stop",
+ `data: {"type":"message_stop"}`,
+ "",
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "claude-3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"finish_reason":"stop"`) {
+ t.Errorf("message_stop without message_delta should default finish_reason to stop: %s", got)
+ }
+ if !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing [DONE]: %s", got)
+ }
+}
diff --git a/go/chat_adapter_ollama.go b/go/chat_adapter_ollama.go
new file mode 100644
index 0000000..0f5e0d5
--- /dev/null
+++ b/go/chat_adapter_ollama.go
@@ -0,0 +1,143 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bufio"
+ "encoding/json"
+ "io"
+
+ core "dappco.re/go"
+)
+
+type ollamaAdapter struct{}
+
+// OllamaAdapter maps OpenAI chat completions to/from Ollama's native /api/chat
+// (JSON request with an "options" block; newline-delimited JSON stream).
+func OllamaAdapter() ChatFormatAdapter { return ollamaAdapter{} }
+
+func (ollamaAdapter) Name() string { return "ollama" }
+func (ollamaAdapter) UpstreamPath() string { return "/api/chat" }
+
+func (ollamaAdapter) BuildRequest(req ChatCompletionRequest) ([]byte, map[string]string, error) {
+ msgs := make([]map[string]string, 0, len(req.Messages))
+ for _, m := range req.Messages {
+ msgs = append(msgs, map[string]string{"role": m.Role, "content": m.Content})
+ }
+ options := map[string]any{}
+ if req.Temperature != nil {
+ options["temperature"] = *req.Temperature
+ }
+ if req.TopP != nil {
+ options["top_p"] = *req.TopP
+ }
+ if req.TopK != nil {
+ options["top_k"] = *req.TopK
+ }
+ if req.MaxTokens != nil {
+ options["num_predict"] = *req.MaxTokens
+ }
+ // Ollama's native /api/chat reads stop sequences inside the options block; a
+ // top-level "stop" is silently ignored.
+ if len(req.Stop) > 0 {
+ options["stop"] = []string(req.Stop)
+ }
+ body := map[string]any{
+ "model": req.Model,
+ "messages": msgs,
+ "stream": req.Stream,
+ }
+ if len(options) > 0 {
+ body["options"] = options
+ }
+ raw, err := json.Marshal(body)
+ if err != nil {
+ return nil, nil, core.E("ollama", "marshal request", err)
+ }
+ return raw, map[string]string{"Content-Type": "application/json"}, nil
+}
+
+type ollamaResponse struct {
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ Done bool `json:"done"`
+ DoneReason string `json:"done_reason"`
+ PromptEvalCount int `json:"prompt_eval_count"`
+ EvalCount int `json:"eval_count"`
+}
+
+func ollamaFinish(doneReason string) string {
+ if doneReason == "length" {
+ return "length"
+ }
+ return "stop"
+}
+
+func (ollamaAdapter) DecodeResponse(model string, upstream []byte) (ChatCompletionResponse, error) {
+ var or ollamaResponse
+ if err := json.Unmarshal(upstream, &or); err != nil {
+ return ChatCompletionResponse{}, core.E("ollama", "decode response", err)
+ }
+ return ChatCompletionResponse{
+ ID: newChatCompletionID(),
+ Object: "chat.completion",
+ Model: model,
+ Choices: []ChatChoice{{Index: 0, Message: ChatMessage{Role: "assistant", Content: or.Message.Content}, FinishReason: ollamaFinish(or.DoneReason)}},
+ Usage: ChatUsage{PromptTokens: or.PromptEvalCount, CompletionTokens: or.EvalCount, TotalTokens: or.PromptEvalCount + or.EvalCount},
+ }, nil
+}
+
+func (ollamaAdapter) Transcoder() ChatStreamTranscoder { return ollamaTranscoder{} }
+
+type ollamaTranscoder struct{}
+
+func (ollamaTranscoder) Transcode(w io.Writer, flush func(), upstream io.Reader, meta ChatStreamMeta) error {
+ scanner := bufio.NewScanner(upstream)
+ scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+ first := true
+ for scanner.Scan() {
+ line := core.Trim(scanner.Text())
+ if line == "" {
+ continue
+ }
+ var or ollamaResponse
+ if err := json.Unmarshal([]byte(line), &or); err != nil {
+ continue // skip malformed line
+ }
+ if or.Done {
+ if or.Message.Content != "" || first {
+ delta := ChatMessageDelta{Content: or.Message.Content}
+ if first {
+ delta.Role = "assistant"
+ first = false
+ }
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: nil}},
+ })
+ }
+ fr := ollamaFinish(or.DoneReason)
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: ChatMessageDelta{}, FinishReason: &fr}},
+ })
+ break
+ }
+ delta := ChatMessageDelta{Content: or.Message.Content}
+ if first {
+ delta.Role = "assistant"
+ first = false
+ }
+ writeChatChunk(w, flush, ChatCompletionChunk{
+ ID: meta.ID, Object: "chat.completion.chunk", Created: meta.Created, Model: meta.Model,
+ Choices: []ChatChunkChoice{{Index: 0, Delta: delta, FinishReason: nil}},
+ })
+ }
+ if err := scanner.Err(); err != nil {
+ return err // truncated stream signals incomplete; do NOT emit [DONE]
+ }
+ writeSSEDone(w, flush)
+ return nil
+}
diff --git a/go/chat_adapter_ollama_test.go b/go/chat_adapter_ollama_test.go
new file mode 100644
index 0000000..13b05e1
--- /dev/null
+++ b/go/chat_adapter_ollama_test.go
@@ -0,0 +1,178 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+func TestOllamaAdapter_BuildRequest_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ mt := 64
+ body, hdrs, err := a.BuildRequest(api.ChatCompletionRequest{
+ Model: "llama3", Messages: []api.ChatMessage{{Role: "user", Content: "hi"}}, MaxTokens: &mt, Stream: true,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hdrs["Content-Type"] != "application/json" {
+ t.Errorf("missing content-type header")
+ }
+ var got map[string]any
+ _ = json.Unmarshal(body, &got)
+ if got["model"] != "llama3" || got["stream"] != true {
+ t.Errorf("bad ollama body: %s", body)
+ }
+ opts, _ := got["options"].(map[string]any)
+ if opts["num_predict"].(float64) != 64 {
+ t.Errorf("max_tokens not mapped to num_predict: %s", body)
+ }
+}
+
+func TestOllamaAdapter_BuildRequest_Stop_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ body, _, err := a.BuildRequest(api.ChatCompletionRequest{
+ Model: "llama3", Messages: []api.ChatMessage{{Role: "user", Content: "hi"}}, Stop: []string{"\n\n", "END"},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got map[string]any
+ _ = json.Unmarshal(body, &got)
+ // Ollama reads stop INSIDE options; a top-level "stop" is silently ignored.
+ if _, ok := got["stop"]; ok {
+ t.Errorf("top-level stop key present (Ollama ignores it): %s", body)
+ }
+ opts, _ := got["options"].(map[string]any)
+ stop, ok := opts["stop"].([]any)
+ if !ok || len(stop) != 2 || stop[0] != "\n\n" || stop[1] != "END" {
+ t.Errorf("stop not placed inside options: %s", body)
+ }
+}
+
+func TestOllamaAdapter_DecodeResponse_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ out, err := a.DecodeResponse("llama3", []byte(`{"message":{"role":"assistant","content":"4"},"done":true,"done_reason":"stop","prompt_eval_count":3,"eval_count":1}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.Choices[0].Message.Content != "4" || out.Choices[0].FinishReason != "stop" {
+ t.Errorf("bad decode: %+v", out)
+ }
+ if out.Usage.PromptTokens != 3 || out.Usage.CompletionTokens != 1 {
+ t.Errorf("bad usage: %+v", out.Usage)
+ }
+}
+
+func TestOllamaAdapter_Transcode_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ stream := strings.Join([]string{
+ `{"message":{"role":"assistant","content":"He"},"done":false}`,
+ `{"message":{"role":"assistant","content":"llo"},"done":false}`,
+ `{"message":{"role":"assistant","content":""},"done":true,"done_reason":"stop"}`,
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "llama3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) {
+ t.Errorf("missing deltas: %s", got)
+ }
+ if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing terminal/[DONE]: %s", got)
+ }
+}
+
+func TestOllamaAdapter_Transcode_EmptyStream_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ stream := `{"done":true,"done_reason":"stop"}`
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "llama3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"role":"assistant"`) {
+ t.Errorf("missing role-priming chunk: %s", got)
+ }
+ if !strings.Contains(got, `"finish_reason":"stop"`) || !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing finish/[DONE]: %s", got)
+ }
+}
+
+func TestOllamaAdapter_Transcode_MalformedLineSkipped_Ugly(t *testing.T) {
+ a := api.OllamaAdapter()
+ stream := strings.Join([]string{
+ `{"message":{"role":"assistant","content":"He"},"done":false}`,
+ `{not json`,
+ `{"message":{"role":"assistant","content":"llo"},"done":false}`,
+ `{"message":{"role":"assistant","content":""},"done":true,"done_reason":"stop"}`,
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "llama3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"He"`) || !strings.Contains(got, `"content":"llo"`) {
+ t.Errorf("malformed line aborted stream, deltas missing: %s", got)
+ }
+ if !strings.Contains(got, "data: [DONE]") {
+ t.Errorf("missing [DONE] after malformed line skip: %s", got)
+ }
+}
+
+func TestOllamaAdapter_Transcode_DoneWithContent_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ stream := strings.Join([]string{
+ `{"message":{"role":"assistant","content":"Hi"},"done":false}`,
+ `{"message":{"role":"assistant","content":"!"},"done":true,"done_reason":"stop"}`,
+ }, "\n")
+ var buf bytes.Buffer
+ err := a.Transcoder().Transcode(&buf, func() {}, strings.NewReader(stream), api.ChatStreamMeta{ID: "id", Model: "llama3", Created: 1})
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := buf.String()
+ if !strings.Contains(got, `"content":"!"`) {
+ t.Errorf("trailing content on done line dropped: %s", got)
+ }
+ finishIdx := strings.Index(got, `"finish_reason":"stop"`)
+ contentIdx := strings.Index(got, `"content":"!"`)
+ if finishIdx < 0 || contentIdx < 0 || contentIdx > finishIdx {
+ t.Errorf("trailing content must precede finish chunk: %s", got)
+ }
+}
+
+func TestOllamaAdapter_DecodeResponse_Length_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ out, err := a.DecodeResponse("llama3", []byte(`{"message":{"role":"assistant","content":"x"},"done":true,"done_reason":"length"}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out.Choices[0].FinishReason != "length" {
+ t.Errorf("done_reason length not mapped: %s", out.Choices[0].FinishReason)
+ }
+}
+
+func TestOllamaAdapter_BuildRequest_NoOptions_Good(t *testing.T) {
+ a := api.OllamaAdapter()
+ body, _, err := a.BuildRequest(api.ChatCompletionRequest{
+ Model: "llama3", Messages: []api.ChatMessage{{Role: "user", Content: "hi"}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got map[string]any
+ _ = json.Unmarshal(body, &got)
+ if _, ok := got["options"]; ok {
+ t.Errorf("options key present when no sampling params set: %s", body)
+ }
+}
diff --git a/go/chat_completions.go b/go/chat_completions.go
index 0ecae7c..a77b566 100644
--- a/go/chat_completions.go
+++ b/go/chat_completions.go
@@ -3,6 +3,9 @@
package api
import (
+ "bytes"
+ "crypto/subtle" // Note: AX-6 — constant-time bearer comparison for the off-loopback gate
+ "io"
"math/rand" // Note: AX-6 — non-security display/correlation ID suffix; core.RandIntN unavailable
"net" // Note: AX-6 — structural IP parsing for loopback-only HTTP boundary
"net/http" // Note: AX-6 — structural HTTP server boundary for request/status handling
@@ -301,6 +304,40 @@ func (r *ModelResolver) ResolveModel(name string) (
}
}
+// Knows reports whether the resolver can serve name WITHOUT loading it — a hit
+// in the loaded-model cache, the models.yaml mapping, or the discovery set. It
+// mirrors ResolveModel's three resolution sources so a false result means
+// ResolveModel could not have served the model either. Used by the chat handler
+// to route local-vs-remote without triggering a model load (see chat_remote.go).
+func (r *ModelResolver) Knows(name string) bool {
+ if r == nil {
+ return false
+ }
+ requested := core.Trim(name)
+ if requested == "" {
+ return false
+ }
+ r.mu.RLock()
+ _, cached := r.loadedByName[requested]
+ if !cached {
+ if norm := core.Lower(requested); norm != requested {
+ _, cached = r.loadedByName[norm]
+ }
+ }
+ r.mu.RUnlock()
+ if cached {
+ return true
+ }
+ normalized := core.Lower(requested)
+ if _, ok := r.lookupModelPath(normalized); ok {
+ return true
+ }
+ if _, ok := r.resolveDiscoveredPath(normalized); ok {
+ return true
+ }
+ return false
+}
+
func (r *ModelResolver) loadByPath(name, path string) (
inference.TextModel,
error,
@@ -674,36 +711,79 @@ func parseChannelName(s string) (string, int) {
return core.Lower(s[:count]), count
}
+// bearerValidator returns a request validator for a static bearer token, or nil
+// when no token is configured. It checks the Authorization: Bearer header in
+// constant time so the chat endpoint's off-loopback gate fails closed
+// independently of any auth middleware.
+func bearerValidator(token string) func(*http.Request) bool {
+ if core.Trim(token) == "" {
+ return nil
+ }
+ want := []byte(token)
+ return func(r *http.Request) bool {
+ parts := core.SplitN(r.Header.Get("Authorization"), " ", 2)
+ if len(parts) != 2 || core.Lower(parts[0]) != "bearer" {
+ return false
+ }
+ return subtle.ConstantTimeCompare([]byte(parts[1]), want) == 1
+ }
+}
+
type chatCompletionsHandler struct {
- resolver *ModelResolver
+ resolver *ModelResolver
+ remote *chatRemoteConfig
+ allowRemote bool
+ validateBearer func(*http.Request) bool
}
-func newChatCompletionsHandler(resolver *ModelResolver) *chatCompletionsHandler {
+func newChatCompletionsHandler(resolver *ModelResolver, remote *chatRemoteConfig, allowRemote bool, validateBearer func(*http.Request) bool) *chatCompletionsHandler {
return &chatCompletionsHandler{
- resolver: resolver,
+ resolver: resolver,
+ remote: remote,
+ allowRemote: allowRemote,
+ validateBearer: validateBearer,
}
}
func (h *chatCompletionsHandler) ServeHTTP(c *gin.Context) {
- if h == nil || h.resolver == nil {
- writeChatCompletionError(c, http.StatusServiceUnavailable, "invalid_request_error", "model", "chat handler is not configured", "model")
+ if h == nil || (h.resolver == nil && h.remote == nil) {
+ writeChatCompletionError(c, http.StatusServiceUnavailable, "invalid_request_error", "model", "chat handler is not configured", "service_unavailable")
return
}
if !isLoopbackRequest(c.Request) {
- writeChatCompletionError(c, http.StatusForbidden, "invalid_request_error", "request", "chat completions is only available on loopback interfaces", "")
+ if !h.allowRemote || h.validateBearer == nil || !h.validateBearer(c.Request) {
+ writeChatCompletionError(c, http.StatusForbidden, "invalid_request_error", "request", "chat completions is only available on loopback interfaces", "")
+ return
+ }
+ }
+
+ raw, ok := readChatBody(c)
+ if !ok {
return
}
+ // For the remote path we need a lenient decode (upstream may send unknown
+ // fields such as "tools"). decodeJSONBody applies strict field rejection for
+ // *ChatCompletionRequest, so use it only for the local path; for routing
+ // purposes we do a plain unmarshal here.
var req ChatCompletionRequest
- if err := decodeJSONBody(c.Request.Body, &req); err != nil {
- writeChatCompletionError(c, 400, "invalid_request_error", "body", "invalid request body", "")
- return
+ if h.remote != nil {
+ result := core.JSONUnmarshalString(string(raw), &req)
+ if !result.OK {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", "invalid request body", "")
+ return
+ }
+ } else {
+ if err := decodeJSONBody(bytes.NewReader(raw), &req); err != nil {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", "invalid request body", "")
+ return
+ }
}
if err := validateChatRequest(&req); err != nil {
- chatErr, ok := err.(*chatCompletionRequestError)
- if !ok {
+ chatErr, isChatErr := err.(*chatCompletionRequestError)
+ if !isChatErr {
writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", err.Error(), "")
return
}
@@ -711,34 +791,67 @@ func (h *chatCompletionsHandler) ServeHTTP(c *gin.Context) {
return
}
+ // PURE-LOCAL: unchanged current behaviour (no Knows gate).
+ if h.remote == nil {
+ h.serveLocal(c, req)
+ return
+ }
+ // HYBRID: local-first if the resolver knows the model; else remote.
+ if h.resolver != nil && h.resolver.Knows(req.Model) {
+ h.serveLocal(c, req)
+ return
+ }
+ pool, found := h.remote.reg.resolve(req.Model)
+ if !found {
+ writeChatCompletionError(c, http.StatusNotFound, "invalid_request_error", "model", "model not found: "+req.Model, "model_not_found")
+ return
+ }
+ h.dispatchRemote(c, req, raw, pool, h.remote.adapters[req.Model])
+}
+
+// readChatBody reads the bounded request body once (so it can drive both the
+// selector and a verbatim upstream forward).
+func readChatBody(c *gin.Context) ([]byte, bool) {
+ limited := http.MaxBytesReader(c.Writer, c.Request.Body, maxToolRequestBodyBytes)
+ body, err := io.ReadAll(limited)
+ if err != nil {
+ if err.Error() == "http: request body too large" {
+ writeChatCompletionError(c, http.StatusRequestEntityTooLarge, "invalid_request_error", "body", "request body too large", "")
+ return nil, false
+ }
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", "unable to read request body", "")
+ return nil, false
+ }
+ return body, true
+}
+
+func (h *chatCompletionsHandler) serveLocal(c *gin.Context, req ChatCompletionRequest) {
+ if h.resolver == nil {
+ writeChatCompletionError(c, http.StatusNotFound, "invalid_request_error", "model", "model not found: "+req.Model, "model_not_found")
+ return
+ }
model, err := h.resolver.ResolveModel(req.Model)
if err != nil {
status, errType, errCode, errParam := mapResolverError(err)
writeChatCompletionError(c, status, errType, errParam, err.Error(), errCode)
return
}
-
reqForOptions := req
reqForOptions.Stop = nil
options, err := chatRequestOptions(&reqForOptions)
if err != nil {
- writeChatCompletionError(c, 400, "invalid_request_error", "stop", err.Error(), "")
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stop", err.Error(), "")
return
}
stopSequences, err := normalizedStopSequences(req.Stop)
if err != nil {
- writeChatCompletionError(c, 400, "invalid_request_error", "stop", err.Error(), "")
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stop", err.Error(), "")
return
}
-
messages := make([]inference.Message, 0, len(req.Messages))
for _, msg := range req.Messages {
- messages = append(messages, inference.Message{
- Role: msg.Role,
- Content: msg.Content,
- })
+ messages = append(messages, inference.Message{Role: msg.Role, Content: msg.Content})
}
-
if req.Stream {
h.serveStreaming(c, model, req, messages, stopSequences, options...)
return
@@ -812,7 +925,7 @@ func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference.
return
}
- c.Header("Content-Type", "text/event-stream")
+ c.Header(hdrContentType, "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(200)
@@ -1233,7 +1346,7 @@ func writeChatCompletionError(c *gin.Context, status int, errType, param, messag
Code: codeOrDefault(code, errType),
},
}
- c.Header("Content-Type", "application/json")
+ c.Header(hdrContentType, mimeJSON)
if status == http.StatusServiceUnavailable {
// Retry-After must be set BEFORE c.JSON commits headers to the
// wire. RFC 9110 §10.2.3 allows either seconds or an HTTP-date;
diff --git a/go/chat_completions_internal_test.go b/go/chat_completions_internal_test.go
index 97fbfab..ebafdd5 100644
--- a/go/chat_completions_internal_test.go
+++ b/go/chat_completions_internal_test.go
@@ -271,14 +271,14 @@ func newChatLoopbackRequest(t *testing.T, body string) *http.Request {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", core.NewReader(body))
req.RemoteAddr = "127.0.0.1:1234"
- req.Header.Set("Content-Type", "application/json")
+ req.Header.Set(hdrContentType, mimeJSON)
return req
}
func newChatHandlerWithModel(model inference.TextModel) *chatCompletionsHandler {
resolver := NewModelResolver()
resolver.loadedByName["lemer"] = model
- return newChatCompletionsHandler(resolver)
+ return newChatCompletionsHandler(resolver, nil, false, nil)
}
func TestChatCompletions_ChatMessageDelta_MarshalJSON_Good_PreservesRoleAndContent(t *testing.T) {
@@ -656,7 +656,7 @@ func TestChatCompletions_ServeHTTP_Good_StreamingResponseEmitsSSEChunks(t *testi
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (%s)", rec.Code, rec.Body.String())
}
- if got := rec.Header().Get("Content-Type"); !core.HasPrefix(got, "text/event-stream") {
+ if got := rec.Header().Get(hdrContentType); !core.HasPrefix(got, "text/event-stream") {
t.Fatalf("expected SSE content type, got %q", got)
}
if got := rec.Header().Get("Cache-Control"); got != "no-cache" {
@@ -705,7 +705,7 @@ func TestChatCompletions_ServeHTTP_Bad_StreamingModelLoadingReturnsErrorBeforeBy
if got := rec.Header().Get("Retry-After"); got != "10" {
t.Fatalf("expected Retry-After=10, got %q", got)
}
- if got := rec.Header().Get("Content-Type"); got != "application/json" {
+ if got := rec.Header().Get(hdrContentType); got != mimeJSON {
t.Fatalf("expected JSON error content type, got %q", got)
}
diff --git a/go/chat_completions_test.go b/go/chat_completions_test.go
index ab2a5b2..67740c7 100644
--- a/go/chat_completions_test.go
+++ b/go/chat_completions_test.go
@@ -28,14 +28,14 @@ func TestChatCompletions_WithChatCompletions_Good(t *testing.T) {
resolver := api.NewModelResolver()
engine, err := api.New(api.WithChatCompletions(resolver))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
- req := newLoopbackRequest(http.MethodPost, "/v1/chat/completions", `{
+ req := newLoopbackRequest(http.MethodPost, pathChatComplet, `{
"model": "missing-model",
"messages": [{"role":"user","content":"hi"}]
}`)
- req.Header.Set("Content-Type", "application/json")
+ req.Header.Set(hdrContentType, mimeJSON)
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
@@ -74,14 +74,14 @@ func TestChatCompletions_RejectsNonLoopback(t *testing.T) {
resolver := api.NewModelResolver()
engine, err := api.New(api.WithChatCompletions(resolver))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
- req := newLoopbackRequest(http.MethodPost, "/v1/chat/completions", `{
+ req := newLoopbackRequest(http.MethodPost, pathChatComplet, `{
"model": "missing-model",
"messages": [{"role":"user","content":"hi"}]
}`)
- req.Header.Set("Content-Type", "application/json")
+ req.Header.Set(hdrContentType, mimeJSON)
req.RemoteAddr = "8.8.8.8:1234"
rec := httptest.NewRecorder()
@@ -102,14 +102,14 @@ func TestChatCompletions_WithChatCompletionsPath_Good(t *testing.T) {
api.WithChatCompletionsPath("/chat"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
req := newLoopbackRequest(http.MethodPost, "/chat", `{
"model": "missing-model",
"messages": [{"role":"user","content":"hi"}]
}`)
- req.Header.Set("Content-Type", "application/json")
+ req.Header.Set(hdrContentType, mimeJSON)
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
@@ -145,8 +145,8 @@ func TestChatCompletionsValidateRequestBadPayload(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
- req := newLoopbackRequest(http.MethodPost, "/v1/chat/completions", tc.body)
- req.Header.Set("Content-Type", "application/json")
+ req := newLoopbackRequest(http.MethodPost, pathChatComplet, tc.body)
+ req.Header.Set(hdrContentType, mimeJSON)
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
@@ -178,7 +178,7 @@ func TestChatCompletionsNoResolverNotMounted(t *testing.T) {
engine, _ := api.New()
- req := newLoopbackRequest(http.MethodPost, "/v1/chat/completions", `{}`)
+ req := newLoopbackRequest(http.MethodPost, pathChatComplet, `{}`)
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
diff --git a/go/chat_remote.go b/go/chat_remote.go
new file mode 100644
index 0000000..af2318a
--- /dev/null
+++ b/go/chat_remote.go
@@ -0,0 +1,164 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bytes"
+ "context"
+ "io"
+ "net/http"
+ "time"
+
+ core "dappco.re/go"
+
+ "github.com/gin-gonic/gin"
+)
+
+// chatRemoteConfig is the remote backend attached to /v1/chat/completions via
+// WithChatCompletionsRemote. It reuses the upstream router's balancer/transport.
+type chatRemoteConfig struct {
+ reg *UpstreamRegistry
+ adapters map[string]ChatFormatAdapter
+ maxAttempts int
+ cooldown time.Duration
+ failover map[int]bool
+ transport http.RoundTripper
+ rt *upstreamTransport // built in finalise
+}
+
+func (cfg *chatRemoteConfig) finalise() {
+ if cfg.cooldown <= 0 {
+ cfg.cooldown = defaultUpstreamCooldown
+ }
+ if cfg.failover == nil {
+ cfg.failover = defaultFailoverStatuses()
+ }
+ if cfg.transport == nil {
+ cfg.transport = http.DefaultTransport.(*http.Transport).Clone()
+ }
+ balancer := newUpstreamBalancer(cfg.cooldown, time.Now)
+ cfg.rt = &upstreamTransport{
+ base: cfg.transport,
+ balancer: balancer,
+ maxAttempts: cfg.maxAttempts,
+ failover: cfg.failover,
+ }
+}
+
+// dispatchRemote proxies a chat request to the resolved remote pool, applying the
+// per-model adapter (or verbatim passthrough when adapter == nil).
+func (h *chatCompletionsHandler) dispatchRemote(c *gin.Context, req ChatCompletionRequest, raw []byte, pool []Upstream, adapter ChatFormatAdapter) {
+ // Stream-capability check BEFORE dispatch (so we can still send an error body).
+ if req.Stream && adapter != nil && adapter.Transcoder() == nil {
+ writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "stream", "the adapter for this model does not support streaming", "")
+ return
+ }
+
+ path := defaultChatCompletionsPath
+ body := raw
+ var hdrs map[string]string
+ if adapter != nil {
+ b, hh, err := adapter.BuildRequest(req)
+ if err != nil {
+ writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "inference_error")
+ return
+ }
+ path, body, hdrs = adapter.UpstreamPath(), b, hh
+ }
+
+ outReq, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, path, bytes.NewReader(body))
+ if err != nil {
+ writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "inference_error")
+ return
+ }
+ bound := body
+ outReq.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(bound)), nil }
+ outReq.ContentLength = int64(len(bound))
+ outReq.Header.Set(hdrContentType, mimeJSON)
+ for k, v := range hdrs {
+ outReq.Header.Set(k, v)
+ }
+ ctx := context.WithValue(outReq.Context(), poolCtxKey, pool)
+ ctx = context.WithValue(ctx, keyCtxKey, req.Model)
+ outReq = outReq.WithContext(ctx)
+
+ resp, err := h.remote.rt.RoundTrip(outReq)
+ if err != nil {
+ status, code := http.StatusServiceUnavailable, "upstream_unavailable"
+ var re *routerError
+ if core.As(err, &re) {
+ status, code = re.status, re.code
+ }
+ // writeChatCompletionError owns the 503 Retry-After header.
+ writeChatCompletionError(c, status, "invalid_request_error", "model", "upstream request failed", code)
+ return
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ h.deliverRemote(c, req, adapter, resp)
+}
+
+func (h *chatCompletionsHandler) deliverRemote(c *gin.Context, req ChatCompletionRequest, adapter ChatFormatAdapter, resp *http.Response) {
+ // Non-2xx: passthrough copies verbatim; adapter wraps in the OpenAI error shape.
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamResponseBytes))
+ if adapter == nil {
+ c.Header(hdrContentType, mimeJSON)
+ c.Status(resp.StatusCode)
+ _, _ = c.Writer.Write(body)
+ return
+ }
+ writeChatCompletionError(c, resp.StatusCode, "invalid_request_error", "model", "upstream error: "+string(body), "upstream_error")
+ return
+ }
+
+ if req.Stream {
+ c.Header("Content-Type", "text/event-stream")
+ c.Header("Cache-Control", "no-cache")
+ c.Header("Connection", "keep-alive")
+ c.Status(http.StatusOK)
+ flush := c.Writer.Flush
+ if adapter == nil {
+ copyFlushing(c.Writer, resp.Body, flush)
+ return
+ }
+ meta := ChatStreamMeta{ID: newChatCompletionID(), Model: req.Model, Created: time.Now().Unix()}
+ _ = adapter.Transcoder().Transcode(c.Writer, flush, resp.Body, meta)
+ return
+ }
+
+ // Non-streaming.
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamResponseBytes))
+ if adapter == nil {
+ c.Header(hdrContentType, mimeJSON)
+ c.Status(http.StatusOK)
+ _, _ = c.Writer.Write(body)
+ return
+ }
+ out, err := adapter.DecodeResponse(req.Model, body)
+ if err != nil {
+ writeChatCompletionError(c, http.StatusBadGateway, "invalid_request_error", "model", "could not decode upstream response", "invalid_upstream_response")
+ return
+ }
+ c.JSON(http.StatusOK, out)
+}
+
+// copyFlushing streams src to dst, flushing after each read so SSE chunks reach
+// the client immediately.
+func copyFlushing(dst io.Writer, src io.Reader, flush func()) {
+ buf := make([]byte, 32*1024)
+ for {
+ n, err := src.Read(buf)
+ if n > 0 {
+ if _, werr := dst.Write(buf[:n]); werr != nil {
+ return
+ }
+ if flush != nil {
+ flush()
+ }
+ }
+ if err != nil {
+ return
+ }
+ }
+}
diff --git a/go/chat_remote_example_test.go b/go/chat_remote_example_test.go
new file mode 100644
index 0000000..41a102d
--- /dev/null
+++ b/go/chat_remote_example_test.go
@@ -0,0 +1,26 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "fmt"
+
+ api "dappco.re/go/api"
+)
+
+func ExampleWithChatCompletionsRemote() {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8"))
+ _ = reg.Set("llama3:70b", api.Upstream{URL: "http://10.0.0.5:11434"})
+ _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"}) // OpenAI-compatible — passthrough
+
+ engine, err := api.New(
+ api.WithChatCompletionsRemote(reg,
+ api.WithChatModelAdapter("llama3:70b", api.OllamaAdapter()),
+ ),
+ )
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println(engine.Addr())
+ // Output: :8080
+}
diff --git a/go/chat_remote_internal_test.go b/go/chat_remote_internal_test.go
new file mode 100644
index 0000000..4e319f1
--- /dev/null
+++ b/go/chat_remote_internal_test.go
@@ -0,0 +1,90 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestModelResolver_Knows_Good(t *testing.T) {
+ r := NewModelResolver()
+ // Seed the loaded-by-name cache directly (internal test) to simulate a known model.
+ r.loadedByName["lemer"] = nil
+ if !r.Knows("lemer") {
+ t.Fatal("Knows(lemer) = false, want true (cache hit)")
+ }
+}
+
+func TestModelResolver_Knows_CaseInsensitive_Good(t *testing.T) {
+ r := NewModelResolver()
+ // Cache stores the lowercased name; Knows must mirror ResolveModel's
+ // normalisation so a mixed-case request still hits the known model.
+ r.loadedByName["gpt-4"] = nil
+ if !r.Knows("GPT-4") {
+ t.Fatal("Knows(GPT-4) = false, want true (case-insensitive cache hit)")
+ }
+}
+
+func TestModelResolver_Knows_Bad(t *testing.T) {
+ r := NewModelResolver()
+ if r.Knows("does-not-exist") {
+ t.Fatal("Knows(does-not-exist) = true, want false")
+ }
+ if r.Knows("") {
+ t.Fatal("Knows(empty) = true, want false")
+ }
+ var nilR *ModelResolver
+ if nilR.Knows("x") {
+ t.Fatal("nil resolver Knows = true, want false")
+ }
+}
+
+func TestChatHandler_BindGuard_Ugly(t *testing.T) {
+ const offLoopback = "203.0.113.7:5555"
+ body := `{"model":"m","messages":[{"role":"user","content":"x"}]}`
+ // serve dispatches a non-loopback request through the handler and returns the
+ // recorded status code. bearer, when non-empty, is sent as a Bearer header.
+ serve := func(h *chatCompletionsHandler, bearer string) int {
+ w := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(w)
+ req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
+ req.RemoteAddr = offLoopback
+ if bearer != "" {
+ req.Header.Set("Authorization", "Bearer "+bearer)
+ }
+ c.Request = req
+ h.ServeHTTP(c)
+ return w.Code
+ }
+
+ // (a) non-loopback, no opt-in → 403 regardless of any bearer.
+ hNoOptIn := newChatCompletionsHandler(nil, &chatRemoteConfig{}, false, bearerValidator("secret"))
+ if code := serve(hNoOptIn, "secret"); code != http.StatusForbidden {
+ t.Fatalf("(a) non-loopback w/o opt-in: code = %d, want 403", code)
+ }
+
+ // (b) non-loopback, opt-in + validator that accepts the matching bearer → NOT 403.
+ hAccept := newChatCompletionsHandler(nil, &chatRemoteConfig{reg: NewUpstreamRegistry()}, true, bearerValidator("secret"))
+ if code := serve(hAccept, "secret"); code == http.StatusForbidden {
+ t.Fatalf("(b) non-loopback opt-in + valid bearer: code = 403, want allowed")
+ }
+
+ // (c) non-loopback, opt-in + validator but request has NO/wrong bearer → 403.
+ if code := serve(hAccept, ""); code != http.StatusForbidden {
+ t.Fatalf("(c) non-loopback opt-in + missing bearer: code = %d, want 403", code)
+ }
+ if code := serve(hAccept, "wrong"); code != http.StatusForbidden {
+ t.Fatalf("(c) non-loopback opt-in + wrong bearer: code = %d, want 403", code)
+ }
+
+ // (d) non-loopback, opt-in but validateBearer == nil (no bearer configured) → 403.
+ hNoBearer := newChatCompletionsHandler(nil, &chatRemoteConfig{}, true, bearerValidator(""))
+ if code := serve(hNoBearer, "secret"); code != http.StatusForbidden {
+ t.Fatalf("(d) non-loopback opt-in WITHOUT configured bearer: code = %d, want 403", code)
+ }
+}
diff --git a/go/chat_remote_test.go b/go/chat_remote_test.go
new file mode 100644
index 0000000..d2efcf2
--- /dev/null
+++ b/go/chat_remote_test.go
@@ -0,0 +1,187 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+// chatPost sends a chat request from a loopback client.
+func chatPost(t *testing.T, base, body string) *http.Response {
+ t.Helper()
+ resp, err := http.Post(base+"/v1/chat/completions", "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ return resp
+}
+
+func TestChatRemote_Passthrough_Good(t *testing.T) {
+ var gotBody string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ _, _ = io.WriteString(w, `{"id":"x","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}]}`)
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ // Send an unmodelled field (tools) to prove verbatim passthrough fidelity.
+ resp := chatPost(t, srv.URL, `{"model":"gpt-x","messages":[{"role":"user","content":"hi"}],"tools":[{"type":"function"}]}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(gotBody, `"tools"`) {
+ t.Errorf("upstream did not receive verbatim body (tools dropped): %s", gotBody)
+ }
+ if !strings.Contains(string(out), `"content":"hi"`) {
+ t.Errorf("client did not get upstream response: %s", out)
+ }
+}
+
+func TestChatRemote_UnknownModel_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("known", api.Upstream{URL: "http://127.0.0.1:1"}) // no default
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"nope","messages":[{"role":"user","content":"x"}]}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", resp.StatusCode)
+ }
+ body, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(body), "model_not_found") {
+ t.Errorf("want model_not_found, got %s", body)
+ }
+}
+
+func TestChatRemote_Failover_Good(t *testing.T) {
+ dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(503) }))
+ defer dead.Close()
+ live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}]}`)
+ }))
+ defer live.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: dead.URL}, api.Upstream{URL: live.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}]}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (failed over)", resp.StatusCode)
+ }
+}
+
+func TestChatRemote_StreamingPassthrough_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ f, _ := w.(http.Flusher)
+ for _, ch := range []string{"data: {\"x\":1}\n\n", "data: [DONE]\n\n"} {
+ _, _ = io.WriteString(w, ch)
+ if f != nil {
+ f.Flush()
+ }
+ }
+ }))
+ defer up.Close()
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}],"stream":true}`)
+ defer resp.Body.Close()
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
+ t.Fatalf("Content-Type = %q, want SSE", ct)
+ }
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(out), "[DONE]") {
+ t.Errorf("stream not passed through: %s", out)
+ }
+}
+
+func TestChatRemote_OllamaAdapter_E2E_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/api/chat" {
+ t.Errorf("upstream path = %s, want /api/chat", r.URL.Path)
+ }
+ _, _ = io.WriteString(w, `{"message":{"role":"assistant","content":"pong"},"done":true,"done_reason":"stop","prompt_eval_count":2,"eval_count":1}`)
+ }))
+ defer up.Close()
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("llama3", api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg, api.WithChatModelAdapter("llama3", api.OllamaAdapter())))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"llama3","messages":[{"role":"user","content":"ping"}]}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(out), `"content":"pong"`) || !strings.Contains(string(out), `"object":"chat.completion"`) {
+ t.Errorf("ollama not adapted to OpenAI shape: %s", out)
+ }
+}
+
+func TestChatRemote_AnthropicAdapter_E2E_Good(t *testing.T) {
+ var gotVersion string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotVersion = r.Header.Get("anthropic-version")
+ _, _ = io.WriteString(w, `{"content":[{"type":"text","text":"pong"}],"stop_reason":"end_turn","usage":{"input_tokens":2,"output_tokens":1}}`)
+ }))
+ defer up.Close()
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("claude-3", api.Upstream{URL: up.URL})
+ e, _ := api.New(api.WithChatCompletionsRemote(reg, api.WithChatModelAdapter("claude-3", api.AnthropicAdapter())))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := chatPost(t, srv.URL, `{"model":"claude-3","messages":[{"role":"user","content":"ping"}]}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if gotVersion != "2023-06-01" {
+ t.Errorf("anthropic-version header not sent: %q", gotVersion)
+ }
+ if !strings.Contains(string(out), `"content":"pong"`) {
+ t.Errorf("anthropic not adapted: %s", out)
+ }
+}
+
+func TestChatRemote_BindOptIn_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:1"})
+ // No allow-remote, no bearer: non-loopback would be rejected. We assert the
+ // guard logic via a loopback request still works (positive) and that the
+ // option+bearer path is constructed without error.
+ e, _ := api.New(
+ api.WithBearerAuth("secret"),
+ api.WithChatCompletionsAllowRemoteClients(),
+ api.WithChatCompletionsRemote(reg),
+ )
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+ // Loopback client is always allowed regardless of opt-in.
+ resp := chatPost(t, srv.URL, `{"model":"m","messages":[{"role":"user","content":"x"}]}`)
+ defer resp.Body.Close()
+ // httptest client is loopback → not 403. (Off-loopback 403 is covered by the
+ // internal guard unit test below.)
+ if resp.StatusCode == http.StatusForbidden {
+ t.Fatalf("loopback client got 403, want allowed")
+ }
+}
diff --git a/go/client.go b/go/client.go
index 807d1d7..2f100c1 100644
--- a/go/client.go
+++ b/go/client.go
@@ -316,7 +316,7 @@ func (c *OpenAPIClient) Call(operationID string, params any) (
op, ok := c.operations[operationID]
if !ok {
- return nil, core.E("OpenAPIClient.Call", core.Sprintf("operation %q not found in OpenAPI spec", operationID), nil)
+ return nil, core.E(errClientCall, core.Sprintf("operation %q not found in OpenAPI spec", operationID), nil)
}
merged, err := normaliseParams(params)
@@ -350,7 +350,7 @@ func (c *OpenAPIClient) Call(operationID string, params any) (
return nil, err
}
if bodyReader != nil {
- req.Header.Set("Content-Type", "application/json")
+ req.Header.Set(hdrContentType, mimeJSON)
}
if c.bearerToken != "" {
req.Header.Set("Authorization", "Bearer "+c.bearerToken)
@@ -373,7 +373,7 @@ func (c *OpenAPIClient) Call(operationID string, params any) (
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- return nil, core.E("OpenAPIClient.Call", core.Sprintf("openapi call %s returned %s: %s", operationID, resp.Status, core.Trim(string(payload))), nil)
+ return nil, core.E(errClientCall, core.Sprintf("openapi call %s returned %s: %s", operationID, resp.Status, core.Trim(string(payload))), nil)
}
if op.responseSchema != nil && len(core.Trim(string(payload))) > 0 {
@@ -395,9 +395,9 @@ func (c *OpenAPIClient) Call(operationID string, params any) (
if success, ok := envelope["success"].(bool); ok {
if !success {
if errObj, ok := envelope["error"].(map[string]any); ok {
- return nil, core.E("OpenAPIClient.Call", core.Sprintf("openapi call %s failed: %v", operationID, errObj), nil)
+ return nil, core.E(errClientCall, core.Sprintf("openapi call %s failed: %v", operationID, errObj), nil)
}
- return nil, core.E("OpenAPIClient.Call", core.Sprintf("openapi call %s failed", operationID), nil)
+ return nil, core.E(errClientCall, core.Sprintf("openapi call %s failed", operationID), nil)
}
if data, ok := envelope["data"]; ok {
return data, nil
@@ -432,20 +432,20 @@ func (c *OpenAPIClient) loadSpec() (
cfs := (&core.Fs{}).NewUnrestricted()
r := cfs.Read(c.specPath)
if !r.OK {
- return core.E("OpenAPIClient.loadSpec", "read spec", r.Value.(error))
+ return core.E(errClientLoadSpec, "read spec", r.Value.(error))
}
data = []byte(r.Value.(string))
default:
- return core.E("OpenAPIClient.loadSpec", "spec path or reader is required", nil)
+ return core.E(errClientLoadSpec, "spec path or reader is required", nil)
}
if err != nil {
- return core.E("OpenAPIClient.loadSpec", "read spec", err)
+ return core.E(errClientLoadSpec, "read spec", err)
}
var spec map[string]any
if err := yaml.Unmarshal(data, &spec); err != nil {
- return core.E("OpenAPIClient.loadSpec", "parse spec", err)
+ return core.E(errClientLoadSpec, "parse spec", err)
}
operations := make(map[string]openAPIOperation)
@@ -532,7 +532,7 @@ func (c *OpenAPIClient) buildURL(op openAPIOperation, params map[string]any) (
base = core.TrimSuffix(base, "/")
}
if base == "" {
- return "", core.E("OpenAPIClient.buildURL", "base URL is required", nil)
+ return "", core.E(errClientBuildURL, "base URL is required", nil)
}
path := op.pathTemplate
@@ -557,7 +557,7 @@ func (c *OpenAPIClient) buildURL(op openAPIOperation, params map[string]any) (
}
if core.Contains(path, "{") {
- return "", core.E("OpenAPIClient.buildURL", core.Sprintf("missing path parameters for %q", op.pathTemplate), nil)
+ return "", core.E(errClientBuildURL, core.Sprintf("missing path parameters for %q", op.pathTemplate), nil)
}
fullURL, err := url.JoinPath(base, path)
@@ -863,7 +863,7 @@ func validateRequiredParameters(op openAPIOperation, params map[string]any, path
if parameterProvided(params, param.name, param.in) {
continue
}
- return core.E("OpenAPIClient.buildURL", core.Sprintf("missing required %s parameter %q", param.in, param.name), nil)
+ return core.E(errClientBuildURL, core.Sprintf("missing required %s parameter %q", param.in, param.name), nil)
}
return nil
}
@@ -1032,7 +1032,7 @@ func requestBodySchema(operation map[string]any) map[string]any {
return nil
}
- rawJSON, ok := content["application/json"].(map[string]any)
+ rawJSON, ok := content[mimeJSON].(map[string]any)
if !ok {
return nil
}
@@ -1056,7 +1056,7 @@ func firstSuccessResponseSchema(operation map[string]any) map[string]any {
if !ok {
continue
}
- rawJSON, ok := content["application/json"].(map[string]any)
+ rawJSON, ok := content[mimeJSON].(map[string]any)
if !ok {
continue
}
@@ -1078,11 +1078,11 @@ func validateOpenAPISchema(body []byte, schema map[string]any, label string) (
payload, err := decodeJSONValuePreserveNumbers(body)
if err != nil {
- return core.E("OpenAPIClient.validateOpenAPISchema", core.Sprintf("validate %s: invalid JSON", label), err)
+ return core.E(errClientValidateSchema, core.Sprintf("validate %s: invalid JSON", label), err)
}
if err := validateSchemaNode(payload, schema, ""); err != nil {
- return core.E("OpenAPIClient.validateOpenAPISchema", core.Sprintf("validate %s", label), err)
+ return core.E(errClientValidateSchema, core.Sprintf("validate %s", label), err)
}
return nil
@@ -1093,11 +1093,11 @@ func validateOpenAPIResponse(payload []byte, schema map[string]any, operationID
) {
decoded, err := decodeJSONValuePreserveNumbers(payload)
if err != nil {
- return core.E("OpenAPIClient.validateOpenAPIResponse", core.Sprintf("openapi call %s returned invalid JSON", operationID), err)
+ return core.E(errClientValidateResponse, core.Sprintf("openapi call %s returned invalid JSON", operationID), err)
}
if err := validateSchemaNode(decoded, schema, ""); err != nil {
- return core.E("OpenAPIClient.validateOpenAPIResponse", core.Sprintf("openapi call %s response does not match spec", operationID), err)
+ return core.E(errClientValidateResponse, core.Sprintf("openapi call %s response does not match spec", operationID), err)
}
return nil
diff --git a/go/client_test.go b/go/client_test.go
index ce36474..5257d53 100644
--- a/go/client_test.go
+++ b/go/client_test.go
@@ -89,7 +89,7 @@ func TestOpenAPIClient_Good_CallOperationByID(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`))
})
mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) {
@@ -103,7 +103,7 @@ func TestOpenAPIClient_Good_CallOperationByID(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"123","name":"Ada"}}`))
})
@@ -139,7 +139,7 @@ paths:
"name": "Ada",
})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
case err := <-errCh:
@@ -167,7 +167,7 @@ paths:
},
})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
case err := <-errCh:
@@ -196,7 +196,7 @@ func TestOpenAPIClient_Good_LoadsSpecFromReader(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"pong"}}`))
})
@@ -219,7 +219,7 @@ paths:
result, err := client.Call("ping", nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
case err := <-errCh:
@@ -310,7 +310,7 @@ paths:
operations, err := client.Operations()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
if len(operations) != 1 {
t.Fatalf("expected 1 operation, got %d", len(operations))
@@ -361,9 +361,9 @@ paths: {}
servers, err := client.Servers()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
- if !slices.Equal(servers, []string{"https://api.example.com", "/relative"}) {
+ if !slices.Equal(servers, []string{apiBaseURL, "/relative"}) {
t.Fatalf("expected server snapshot to preserve order, got %v", servers)
}
@@ -372,7 +372,7 @@ paths: {}
if err != nil {
t.Fatalf("unexpected error on re-read: %v", err)
}
- if !slices.Equal(again, []string{"https://api.example.com", "/relative"}) {
+ if !slices.Equal(again, []string{apiBaseURL, "/relative"}) {
t.Fatalf("expected server snapshot to be cloned, got %v", again)
}
}
@@ -404,7 +404,7 @@ paths:
operations, err := client.OperationsIter()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var operationIDs []string
@@ -417,14 +417,14 @@ paths:
servers, err := client.ServersIter()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var serverURLs []string
for server := range servers {
serverURLs = append(serverURLs, server)
}
- if !slices.Equal(serverURLs, []string{"https://api.example.com"}) {
+ if !slices.Equal(serverURLs, []string{apiBaseURL}) {
t.Fatalf("expected iterator to preserve server snapshots, got %v", serverURLs)
}
}
@@ -454,7 +454,7 @@ func TestOpenAPIClient_Good_CallHeadOperationWithRequestBody(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
w.WriteHeader(http.StatusOK)
})
@@ -487,7 +487,7 @@ paths:
"name": "Ada",
})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
case err := <-errCh:
@@ -518,7 +518,7 @@ func TestOpenAPIClient_Good_CallOperationWithRepeatedQueryValues(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
@@ -546,7 +546,7 @@ paths:
"page": 2,
})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
case err := <-errCh:
@@ -588,7 +588,7 @@ func TestOpenAPIClient_Good_UsesTopLevelQueryParametersOnPost(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
@@ -625,7 +625,7 @@ paths:
"name": "Ada",
})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
case err := <-errCh:
@@ -647,7 +647,7 @@ func TestOpenAPIClient_Bad_MissingRequiredQueryParameter(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/submit", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
@@ -692,7 +692,7 @@ func TestOpenAPIClient_Bad_ValidatesQueryParameterAgainstSchema(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
@@ -738,7 +738,7 @@ func TestOpenAPIClient_Bad_ValidatesPathParameterAgainstSchema(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
@@ -823,7 +823,7 @@ func TestOpenAPIClient_Good_UsesHeaderAndCookieParameters(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
@@ -862,7 +862,7 @@ paths:
},
})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
@@ -889,7 +889,7 @@ func TestOpenAPIClient_Good_UsesFirstAbsoluteServer(t *testing.T) {
w.WriteHeader(http.StatusInternalServerError)
return
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`))
})
@@ -917,7 +917,7 @@ paths:
result, err := client.Call("get_hello", nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
select {
case err := <-errCh:
@@ -939,7 +939,7 @@ func TestOpenAPIClient_Bad_ValidatesRequestBodyAgainstSchema(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"123"}}`))
})
@@ -1003,7 +1003,7 @@ paths:
func TestOpenAPIClient_Bad_ValidatesResponseAgainstSchema(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
_, _ = w.Write([]byte(`{"success":true,"data":{"id":123}}`))
})
diff --git a/go/cmd/api/cmd_spec_test.go b/go/cmd/api/cmd_spec_test.go
index f71abb9..bd8b3b1 100644
--- a/go/cmd/api/cmd_spec_test.go
+++ b/go/cmd/api/cmd_spec_test.go
@@ -12,6 +12,11 @@ import (
api "dappco.re/go/api"
)
+const (
+ testOpenAPISpecPath = "/api/v1/openapi.json"
+ testChatCompletionsPath = "/api/v1/chat/completions"
+)
+
type specCmdStubGroup struct{}
func (specCmdStubGroup) Name() string { return "registered" }
@@ -140,9 +145,9 @@ func TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved(t *testing.T) {
func TestCmdSpec_SpecConfigFromOptions_Good_OpenAPIAndChatFlagsPreserved(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "openapi-spec", Value: true},
- core.Option{Key: "openapi-spec-path", Value: "/api/v1/openapi.json"},
+ core.Option{Key: "openapi-spec-path", Value: testOpenAPISpecPath},
core.Option{Key: "chat-completions", Value: true},
- core.Option{Key: "chat-completions-path", Value: "/api/v1/chat/completions"},
+ core.Option{Key: "chat-completions-path", Value: testChatCompletionsPath},
)
cfg := specConfigFromOptions(opts)
@@ -150,14 +155,14 @@ func TestCmdSpec_SpecConfigFromOptions_Good_OpenAPIAndChatFlagsPreserved(t *test
if !cfg.openAPISpecEnabled {
t.Fatal("expected openAPISpecEnabled=true")
}
- if cfg.openAPISpecPath != "/api/v1/openapi.json" {
- t.Fatalf("expected openAPISpecPath=%q, got %q", "/api/v1/openapi.json", cfg.openAPISpecPath)
+ if cfg.openAPISpecPath != testOpenAPISpecPath {
+ t.Fatalf("expected openAPISpecPath=%q, got %q", testOpenAPISpecPath, cfg.openAPISpecPath)
}
if !cfg.chatCompletionsEnabled {
t.Fatal("expected chatCompletionsEnabled=true")
}
- if cfg.chatCompletionsPath != "/api/v1/chat/completions" {
- t.Fatalf("expected chatCompletionsPath=%q, got %q", "/api/v1/chat/completions", cfg.chatCompletionsPath)
+ if cfg.chatCompletionsPath != testChatCompletionsPath {
+ t.Fatalf("expected chatCompletionsPath=%q, got %q", testChatCompletionsPath, cfg.chatCompletionsPath)
}
}
@@ -168,9 +173,9 @@ func TestCmdSpec_NewSpecBuilder_Good_PropagatesNewFlags(t *testing.T) {
title: "Test",
version: "1.0.0",
openAPISpecEnabled: true,
- openAPISpecPath: "/api/v1/openapi.json",
+ openAPISpecPath: testOpenAPISpecPath,
chatCompletionsEnabled: true,
- chatCompletionsPath: "/api/v1/chat/completions",
+ chatCompletionsPath: testChatCompletionsPath,
}
builder, err := newSpecBuilder(cfg)
@@ -181,14 +186,14 @@ func TestCmdSpec_NewSpecBuilder_Good_PropagatesNewFlags(t *testing.T) {
if !builder.OpenAPISpecEnabled {
t.Fatal("expected OpenAPISpecEnabled=true on builder")
}
- if builder.OpenAPISpecPath != "/api/v1/openapi.json" {
- t.Fatalf("expected OpenAPISpecPath=%q, got %q", "/api/v1/openapi.json", builder.OpenAPISpecPath)
+ if builder.OpenAPISpecPath != testOpenAPISpecPath {
+ t.Fatalf("expected OpenAPISpecPath=%q, got %q", testOpenAPISpecPath, builder.OpenAPISpecPath)
}
if !builder.ChatCompletionsEnabled {
t.Fatal("expected ChatCompletionsEnabled=true on builder")
}
- if builder.ChatCompletionsPath != "/api/v1/chat/completions" {
- t.Fatalf("expected ChatCompletionsPath=%q, got %q", "/api/v1/chat/completions", builder.ChatCompletionsPath)
+ if builder.ChatCompletionsPath != testChatCompletionsPath {
+ t.Fatalf("expected ChatCompletionsPath=%q, got %q", testChatCompletionsPath, builder.ChatCompletionsPath)
}
}
@@ -199,8 +204,8 @@ func TestCmdSpec_NewSpecBuilder_Ugly_PathImpliesEnabled(t *testing.T) {
cfg := specBuilderConfig{
title: "Test",
version: "1.0.0",
- openAPISpecPath: "/api/v1/openapi.json",
- chatCompletionsPath: "/api/v1/chat/completions",
+ openAPISpecPath: testOpenAPISpecPath,
+ chatCompletionsPath: testChatCompletionsPath,
}
builder, err := newSpecBuilder(cfg)
diff --git a/go/cmd/api/spec_groups_iter.go b/go/cmd/api/spec_groups_iter.go
index e16c1c6..2dc8d63 100644
--- a/go/cmd/api/spec_groups_iter.go
+++ b/go/cmd/api/spec_groups_iter.go
@@ -48,7 +48,9 @@ func specToolBridge(basePath string) *goapi.ToolBridge {
return bridge
}
-func noopToolHandler(*gin.Context) {}
+func noopToolHandler(*gin.Context) {
+ // Placeholder handler for spec-only tools that contribute OpenAPI descriptions without runtime logic.
+}
func isNilRouteGroup(group goapi.RouteGroup) bool {
if group == nil {
diff --git a/go/codegen.go b/go/codegen.go
index c0ec283..e16e3a0 100644
--- a/go/codegen.go
+++ b/go/codegen.go
@@ -65,43 +65,43 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) (
_ error,
) {
if g == nil {
- return coreerr.E("SDKGenerator.Generate", "generator is nil", nil)
+ return coreerr.E(errSDKGenerate, "generator is nil", nil)
}
if ctx == nil {
- return coreerr.E("SDKGenerator.Generate", "context is nil", nil)
+ return coreerr.E(errSDKGenerate, "context is nil", nil)
}
language = core.Trim(language)
generator, ok := supportedLanguages[language]
if !ok {
- return coreerr.E("SDKGenerator.Generate", core.Sprintf("unsupported language %q: supported languages are %v", language, SupportedLanguages()), nil)
+ return coreerr.E(errSDKGenerate, core.Sprintf("unsupported language %q: supported languages are %v", language, SupportedLanguages()), nil)
}
specPath := core.Trim(g.SpecPath)
if specPath == "" {
- return coreerr.E("SDKGenerator.Generate", "spec path is required", nil)
+ return coreerr.E(errSDKGenerate, "spec path is required", nil)
}
localFS := (&core.Fs{}).NewUnrestricted()
if result := localFS.Stat(specPath); !result.OK {
err, _ := result.Value.(error)
if core.Is(err, fs.ErrNotExist) {
- return coreerr.E("SDKGenerator.Generate", "spec file not found: "+specPath, nil)
+ return coreerr.E(errSDKGenerate, "spec file not found: "+specPath, nil)
}
- return coreerr.E("SDKGenerator.Generate", "stat spec file", err)
+ return coreerr.E(errSDKGenerate, "stat spec file", err)
}
outputBase := core.Trim(g.OutputDir)
if outputBase == "" {
- return coreerr.E("SDKGenerator.Generate", "output directory is required", nil)
+ return coreerr.E(errSDKGenerate, "output directory is required", nil)
}
if g.PackageName != "" && !packageNameRe.MatchString(g.PackageName) {
- return coreerr.E("SDKGenerator.Generate",
+ return coreerr.E(errSDKGenerate,
core.Sprintf("package name %q rejected: must match %s", g.PackageName, packageNameRe.String()), nil)
}
if !g.Available() {
- return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli not installed", nil)
+ return coreerr.E(errSDKGenerate, "openapi-generator-cli not installed", nil)
}
outputDir := core.Path(outputBase, language)
@@ -110,7 +110,7 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) (
}
if result := localFS.EnsureDir(outputDir); !result.OK {
err, _ := result.Value.(error)
- return coreerr.E("SDKGenerator.Generate", "create output directory", err)
+ return coreerr.E(errSDKGenerate, "create output directory", err)
}
args := g.buildArgs(specPath, generator, outputDir)
@@ -128,7 +128,7 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) (
WithStderr(core.Stderr())
if result := cmd.Run(); !result.OK {
err, _ := result.Value.(error)
- return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli failed for "+language, err)
+ return coreerr.E(errSDKGenerate, "openapi-generator-cli failed for "+language, err)
}
return nil
diff --git a/go/codegen_test.go b/go/codegen_test.go
index bebfe97..f772ef0 100644
--- a/go/codegen_test.go
+++ b/go/codegen_test.go
@@ -266,4 +266,3 @@ func TestSDKGenerator_Generate_PackageNameAccepted_Good(t *testing.T) {
})
}
}
-
diff --git a/go/entitlements.go b/go/entitlements.go
index 3fd5c01..38e844e 100644
--- a/go/entitlements.go
+++ b/go/entitlements.go
@@ -94,7 +94,7 @@ func (b *EntitlementBridge) Check(ctx context.Context, workspaceID, feature stri
if err != nil {
return false, core.E(op, "build entitlement request", err)
}
- req.Header.Set("Accept", "application/json")
+ req.Header.Set("Accept", mimeJSON)
applyEntitlementHeaders(req.Header, headers, b.token, workspaceID)
resp, err := b.client.Do(req)
diff --git a/go/entitlements_test.go b/go/entitlements_test.go
index 9f86497..3716e8f 100644
--- a/go/entitlements_test.go
+++ b/go/entitlements_test.go
@@ -25,7 +25,7 @@ func TestEntitlementBridge_Good_CallbackChecksWorkspaceEndpoint(t *testing.T) {
if got := r.Header.Get("X-Workspace-Id"); got != "42" {
t.Fatalf("expected workspace header 42, got %q", got)
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
w.Write([]byte(`{"workspace_id":42,"feature":"premium.feature","entitlement":{"allowed":true}}`))
}))
defer srv.Close()
@@ -49,7 +49,7 @@ func TestEntitlementBridge_Good_CallbackForRequestUsesCurrentWorkspaceEndpoint(t
if got := r.Header.Get("Cookie"); got != "session=abc" {
t.Fatalf("expected forwarded cookie, got %q", got)
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set(hdrContentType, mimeJSON)
w.Write([]byte(`{"entitlement":{"can":true}}`))
}))
defer srv.Close()
diff --git a/go/export_test.go b/go/export_test.go
index f596428..1acdc84 100644
--- a/go/export_test.go
+++ b/go/export_test.go
@@ -21,7 +21,7 @@ func TestExportSpec_Good_JSON(t *testing.T) {
buf := core.NewBuffer()
if err := api.ExportSpec(buf, "json", builder, nil); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
@@ -44,7 +44,7 @@ func TestExportSpec_Good_YAML(t *testing.T) {
buf := core.NewBuffer()
if err := api.ExportSpec(buf, "yaml", builder, nil); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
output := buf.String()
@@ -67,7 +67,7 @@ func TestExportSpec_Good_NormalisesFormatInput(t *testing.T) {
buf := core.NewBuffer()
if err := api.ExportSpec(buf, " YAML ", builder, nil); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
@@ -100,7 +100,7 @@ func TestExportSpecToFile_Good_CreatesFile(t *testing.T) {
path := core.PathJoin(dir, "subdir", "spec.json")
if err := api.ExportSpecToFile(path, "json", builder, nil); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
data, err := coreReadFile(path)
@@ -144,7 +144,7 @@ func TestExportSpecToFileIter_Good_CreatesFileFromIterator(t *testing.T) {
path := core.PathJoin(dir, "subdir", "spec.json")
if err := api.ExportSpecToFileIter(path, "json", builder, groups); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
data, err := coreReadFile(path)
@@ -197,7 +197,7 @@ func TestExportSpec_Good_WithToolBridge(t *testing.T) {
buf := core.NewBuffer()
if err := api.ExportSpec(buf, "json", builder, []api.RouteGroup{bridge}); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
output := buf.String()
@@ -248,7 +248,7 @@ func TestExportSpecIter_Good_WithGroupIterator(t *testing.T) {
buf := core.NewBuffer()
if err := api.ExportSpecIter(buf, "json", builder, groups); err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
diff --git a/go/expvar_test.go b/go/expvar_test.go
index e45cca5..3d3356a 100644
--- a/go/expvar_test.go
+++ b/go/expvar_test.go
@@ -21,15 +21,15 @@ func TestWithExpvar_Good_EndpointReturnsJSON(t *testing.T) {
e, err := api.New(api.WithExpvar())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/debug/vars")
+ resp, err := http.Get(srv.URL + pathDebugVars)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -37,8 +37,8 @@ func TestWithExpvar_Good_EndpointReturnsJSON(t *testing.T) {
t.Fatalf("expected 200 for /debug/vars, got %d", resp.StatusCode)
}
- ct := resp.Header.Get("Content-Type")
- if !core.Contains(ct, "application/json") {
+ ct := resp.Header.Get(hdrContentType)
+ if !core.Contains(ct, mimeJSON) {
t.Fatalf("expected application/json content type, got %q", ct)
}
}
@@ -48,21 +48,21 @@ func TestWithExpvar_Good_ContainsMemstats(t *testing.T) {
e, err := api.New(api.WithExpvar())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/debug/vars")
+ resp, err := http.Get(srv.URL + pathDebugVars)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if !core.Contains(string(body), "memstats") {
@@ -75,21 +75,21 @@ func TestWithExpvar_Good_ContainsCmdline(t *testing.T) {
e, err := api.New(api.WithExpvar())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/debug/vars")
+ resp, err := http.Get(srv.URL + pathDebugVars)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if !core.Contains(string(body), "cmdline") {
@@ -102,15 +102,15 @@ func TestWithExpvar_Good_CombinesWithOtherMiddleware(t *testing.T) {
e, err := api.New(api.WithRequestID(), api.WithExpvar())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/debug/vars")
+ resp, err := http.Get(srv.URL + pathDebugVars)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -119,7 +119,7 @@ func TestWithExpvar_Good_CombinesWithOtherMiddleware(t *testing.T) {
}
// Verify the request ID middleware is still active.
- rid := resp.Header.Get("X-Request-ID")
+ rid := resp.Header.Get(hdrXRequestID)
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID middleware")
}
@@ -132,7 +132,7 @@ func TestWithExpvar_Bad_NotMountedWithoutOption(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/debug/vars", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathDebugVars, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
diff --git a/go/go.mod b/go/go.mod
index 94319cd..10362d2 100644
--- a/go/go.mod
+++ b/go/go.mod
@@ -3,7 +3,7 @@ module dappco.re/go/api
go 1.26.2
require (
- dappco.re/go v0.9.0
+ dappco.re/go v0.10.3
dappco.re/go/inference v0.9.0
dappco.re/go/io v0.9.0
dappco.re/go/log v0.9.0
diff --git a/go/go.sum b/go/go.sum
index bac825c..53349ba 100644
--- a/go/go.sum
+++ b/go/go.sum
@@ -1,5 +1,7 @@
dappco.re/go v0.9.0 h1:4ruZRNqKDDva8o6g65tYggjGVe42E6/lMZfVKXtr3p0=
dappco.re/go v0.9.0/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ=
+dappco.re/go v0.10.3 h1:aViRNxdg2jG84P6RsiD+aSta+GcFJwGXMNQPjFPbJ9g=
+dappco.re/go v0.10.3/go.mod h1:xapr7fLK4/9Pu2iSCr4qZuIuatmtx1j56zS/oPDbGyQ=
dappco.re/go/inference v0.9.0 h1:6eD49KTjj4xrowWdltobEWZYLPY+zbiyDiq+Hv2nkmc=
dappco.re/go/inference v0.9.0/go.mod h1:eu0je5UqOQyoG6eaJ1IqY5eORev+PfmsRXSNCanqBkk=
dappco.re/go/io v0.9.0 h1:TyHUuUJdZ73CXQlBpqx47SNyFFzgwA5OPSKu4Twb2f0=
diff --git a/go/graphql_config_test.go b/go/graphql_config_test.go
index e39be60..6051dc3 100644
--- a/go/graphql_config_test.go
+++ b/go/graphql_config_test.go
@@ -17,7 +17,7 @@ func TestEngine_GraphQLConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath(" /gql/ ")),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.GraphQLConfig()
diff --git a/go/graphql_test.go b/go/graphql_test.go
index ba36a6d..7759955 100644
--- a/go/graphql_test.go
+++ b/go/graphql_test.go
@@ -55,26 +55,26 @@ func TestWithGraphQL_Good_EndpointResponds(t *testing.T) {
e, err := api.New(api.WithGraphQL(newTestSchema()))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
- resp, err := http.Post(srv.URL+"/graphql", "application/json", core.NewReader(body))
+ resp, err := http.Post(srv.URL+pathGraphQL, mimeJSON, core.NewReader(body))
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if !core.Contains(string(respBody), `"name":"test"`) {
@@ -87,30 +87,30 @@ func TestWithGraphQL_Good_PlaygroundServesHTML(t *testing.T) {
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithPlayground()))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/graphql/playground")
+ resp, err := http.Get(srv.URL + pathGraphQLPlay)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
- ct := resp.Header.Get("Content-Type")
+ ct := resp.Header.Get(hdrContentType)
if !core.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)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if !core.Contains(string(body), "GraphQL") {
@@ -124,12 +124,12 @@ func TestWithGraphQL_Good_NoPlaygroundByDefault(t *testing.T) {
// Without WithPlayground(), /graphql/playground should return 404.
e, err := api.New(api.WithGraphQL(newTestSchema()))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/graphql/playground", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathGraphQLPlay, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
@@ -142,7 +142,7 @@ func TestWithGraphQL_Good_CustomPath(t *testing.T) {
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath("/gql"), api.WithPlayground()))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -150,9 +150,9 @@ func TestWithGraphQL_Good_CustomPath(t *testing.T) {
// Query endpoint should be at /gql.
body := `{"query":"{ name }"}`
- resp, err := http.Post(srv.URL+"/gql", "application/json", core.NewReader(body))
+ resp, err := http.Post(srv.URL+"/gql", mimeJSON, core.NewReader(body))
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -162,7 +162,7 @@ func TestWithGraphQL_Good_CustomPath(t *testing.T) {
respBody, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if !core.Contains(string(respBody), `"name":"test"`) {
@@ -181,7 +181,7 @@ func TestWithGraphQL_Good_CustomPath(t *testing.T) {
}
// The default path should not exist.
- defaultResp, err := http.Post(srv.URL+"/graphql", "application/json", core.NewReader(body))
+ defaultResp, err := http.Post(srv.URL+pathGraphQL, mimeJSON, core.NewReader(body))
if err != nil {
t.Fatalf("default path request failed: %v", err)
}
@@ -197,16 +197,16 @@ func TestWithGraphQL_Good_NormalisesCustomPath(t *testing.T) {
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath(" /gql/ "), api.WithPlayground()))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
- resp, err := http.Post(srv.URL+"/gql", "application/json", core.NewReader(body))
+ resp, err := http.Post(srv.URL+"/gql", mimeJSON, core.NewReader(body))
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -230,16 +230,16 @@ func TestWithGraphQL_Good_DefaultPathWhenEmptyCustomPath(t *testing.T) {
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath(""), api.WithPlayground()))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
- resp, err := http.Post(srv.URL+"/graphql", "application/json", core.NewReader(body))
+ resp, err := http.Post(srv.URL+pathGraphQL, mimeJSON, core.NewReader(body))
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -247,7 +247,7 @@ func TestWithGraphQL_Good_DefaultPathWhenEmptyCustomPath(t *testing.T) {
t.Fatalf("expected 200 at default /graphql, got %d", resp.StatusCode)
}
- pgResp, err := http.Get(srv.URL + "/graphql/playground")
+ pgResp, err := http.Get(srv.URL + pathGraphQLPlay)
if err != nil {
t.Fatalf("playground request failed: %v", err)
}
@@ -263,16 +263,16 @@ func TestWithGraphQL_Ugly_RootPathFallsBackToDefault(t *testing.T) {
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath(" / "), api.WithPlayground()))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
- resp, err := http.Post(srv.URL+"/graphql", "application/json", core.NewReader(body))
+ resp, err := http.Post(srv.URL+pathGraphQL, mimeJSON, core.NewReader(body))
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -280,7 +280,7 @@ func TestWithGraphQL_Ugly_RootPathFallsBackToDefault(t *testing.T) {
t.Fatalf("expected 200 at default /graphql after root path normalisation, got %d", resp.StatusCode)
}
- pgResp, err := http.Get(srv.URL + "/graphql/playground")
+ pgResp, err := http.Get(srv.URL + pathGraphQLPlay)
if err != nil {
t.Fatalf("playground request failed: %v", err)
}
@@ -299,32 +299,32 @@ func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
api.WithGraphQL(newTestSchema()),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
- resp, err := http.Post(srv.URL+"/graphql", "application/json", core.NewReader(body))
+ resp, err := http.Post(srv.URL+pathGraphQL, mimeJSON, core.NewReader(body))
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
// RequestID middleware should have injected the header.
- reqID := resp.Header.Get("X-Request-ID")
+ reqID := resp.Header.Get(hdrXRequestID)
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)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if !core.Contains(string(respBody), `"name":"test"`) {
diff --git a/go/group_test.go b/go/group_test.go
index de7179e..dde7f55 100644
--- a/go/group_test.go
+++ b/go/group_test.go
@@ -38,7 +38,7 @@ 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())
+ t.Fatalf(fmtTestExpectedName, "stub", g.Name())
}
if g.BasePath() != "/stub" {
t.Fatalf("expected BasePath=%q, got %q", "/stub", g.BasePath())
@@ -54,7 +54,7 @@ func TestRouteGroup_Good_RegisterRoutes(t *testing.T) {
g.RegisterRoutes(rg)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -85,7 +85,7 @@ func TestStreamGroup_Good_AlsoSatisfiesRouteGroup(t *testing.T) {
// 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())
+ t.Fatalf(fmtTestExpectedName, "stub", rg.Name())
}
}
@@ -107,7 +107,7 @@ func TestDescribableGroup_Good_ImplementsRouteGroup(t *testing.T) {
// Must satisfy DescribableGroup.
var dg api.DescribableGroup = stub
if dg.Name() != "stub" {
- t.Fatalf("expected Name=%q, got %q", "stub", dg.Name())
+ t.Fatalf(fmtTestExpectedName, "stub", dg.Name())
}
// Must also satisfy RouteGroup since DescribableGroup embeds it.
@@ -203,7 +203,7 @@ func TestDescribableGroup_Bad_NilSchemas(t *testing.T) {
descriptions: []api.RouteDescription{
{
Method: "GET",
- Path: "/health",
+ Path: pathHealth,
Summary: "Health check",
RequestBody: nil,
Response: nil,
diff --git a/go/gzip_test.go b/go/gzip_test.go
index 01178c6..27a3305 100644
--- a/go/gzip_test.go
+++ b/go/gzip_test.go
@@ -22,15 +22,15 @@ func TestWithGzip_Good_CompressesResponse(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "gzip")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce)
}
@@ -43,15 +43,15 @@ func TestWithGzip_Good_NoCompressionWithoutAcceptHeader(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
// Deliberately not setting Accept-Encoding header.
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce == "gzip" {
t.Fatal("expected no gzip Content-Encoding when client does not request it")
}
@@ -66,15 +66,15 @@ func TestWithGzip_Good_DefaultLevel(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "gzip")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q with default level, got %q", "gzip", ce)
}
@@ -88,15 +88,15 @@ func TestWithGzip_Good_CustomLevel(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "gzip")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q with BestSpeed, got %q", "gzip", ce)
}
@@ -112,21 +112,21 @@ func TestWithGzip_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
- req.Header.Set("Accept-Encoding", "gzip")
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
+ req.Header.Set(hdrAcceptEnc, "gzip")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Both gzip compression and request ID should be present.
- ce := w.Header().Get("Content-Encoding")
+ ce := w.Header().Get(hdrContentEnc)
if ce != "gzip" {
t.Fatalf("expected Content-Encoding=%q, got %q", "gzip", ce)
}
- rid := w.Header().Get("X-Request-ID")
+ rid := w.Header().Get(hdrXRequestID)
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
diff --git a/go/httpsign_test.go b/go/httpsign_test.go
index 45c9fa6..f3c106f 100644
--- a/go/httpsign_test.go
+++ b/go/httpsign_test.go
@@ -93,7 +93,7 @@ func TestWithHTTPSign_Good_ValidSignatureAccepted(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
@@ -117,7 +117,7 @@ func TestWithHTTPSign_Bad_InvalidSignatureRejected(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
// Sign with the wrong secret so the signature is invalid.
signRequest(req, testKeyID, "wrong-secret-key", requiredHeaders)
@@ -145,7 +145,7 @@ func TestWithHTTPSign_Bad_MissingSignatureRejected(t *testing.T) {
w := httptest.NewRecorder()
// Send a request with no signature at all.
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
req.Header.Set("Date", time.Now().UTC().Format(http.TimeFormat))
h.ServeHTTP(w, req)
@@ -172,7 +172,7 @@ func TestWithHTTPSign_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
signRequest(req, testKeyID, testSecretKey, requiredHeaders)
h.ServeHTTP(w, req)
@@ -182,7 +182,7 @@ func TestWithHTTPSign_Good_CombinesWithOtherMiddleware(t *testing.T) {
}
// Verify that WithRequestID also ran.
- if w.Header().Get("X-Request-ID") == "" {
+ if w.Header().Get(hdrXRequestID) == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
@@ -201,7 +201,7 @@ func TestWithHTTPSign_Ugly_UnknownKeyIDRejected(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
// Sign with an unknown key ID that does not exist in the secrets map.
unknownKeyID := httpsign.KeyID("unknown-client")
diff --git a/go/i18n_test.go b/go/i18n_test.go
index d1f5106..7a26445 100644
--- a/go/i18n_test.go
+++ b/go/i18n_test.go
@@ -67,12 +67,12 @@ func TestWithI18n_Good_DetectsLocaleFromHeader(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp i18nLocaleResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data["locale"] != "fr" {
t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"])
@@ -94,12 +94,12 @@ func TestWithI18n_Good_FallsBackToDefault(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp i18nLocaleResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data["locale"] != "en" {
t.Fatalf("expected locale=%q, got %q", "en", resp.Data["locale"])
@@ -121,12 +121,12 @@ func TestWithI18n_Good_QualityWeighting(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp i18nLocaleResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data["locale"] != "fr" {
t.Fatalf("expected locale=%q, got %q", "fr", resp.Data["locale"])
@@ -148,12 +148,12 @@ func TestWithI18n_Good_PreservesMatchedLocaleTag(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp i18nLocaleResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data["locale"] != "fr-CA" {
t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data["locale"])
@@ -177,20 +177,20 @@ func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// i18n middleware should detect French.
var resp i18nLocaleResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, 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") == "" {
+ if w.Header().Get(hdrXRequestID) == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
@@ -216,12 +216,12 @@ func TestWithI18n_Good_LooksUpMessage(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp i18nMessageResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data.Locale != "fr" {
t.Fatalf("expected locale=%q, got %q", "fr", resp.Data.Locale)
@@ -240,12 +240,12 @@ func TestWithI18n_Good_LooksUpMessage(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var respEn i18nMessageResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &respEn); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if respEn.Data.Message != "Hello" {
t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message)
@@ -271,12 +271,12 @@ func TestWithI18n_Good_FallsBackToParentLocaleMessage(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp i18nMessageResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data.Locale != "fr-CA" {
t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data.Locale)
@@ -359,12 +359,12 @@ func TestWithI18n_Good_SnapshotsMutableInputs(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp i18nMessageResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data.Message != "Bonjour" {
t.Fatalf("expected cloned greeting %q, got %q", "Bonjour", resp.Data.Message)
diff --git a/go/location_test.go b/go/location_test.go
index c4ff146..e2d517a 100644
--- a/go/location_test.go
+++ b/go/location_test.go
@@ -50,12 +50,12 @@ func TestWithLocation_Good_DetectsForwardedHost(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp locationResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data["host"] != "api.example.com" {
t.Fatalf("expected host=%q, got %q", "api.example.com", resp.Data["host"])
@@ -74,12 +74,12 @@ func TestWithLocation_Good_DetectsForwardedProto(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp locationResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data["scheme"] != "https" {
t.Fatalf("expected scheme=%q, got %q", "https", resp.Data["scheme"])
@@ -98,12 +98,12 @@ func TestWithLocation_Good_FallsBackToRequestHost(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp locationResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
// Without forwarded headers the middleware falls back to its default
@@ -132,20 +132,20 @@ func TestWithLocation_Good_CombinesWithOtherMiddleware(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Location middleware should populate the detected host.
var resp locationResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, 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") == "" {
+ if w.Header().Get(hdrXRequestID) == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
@@ -163,12 +163,12 @@ func TestWithLocation_Good_BothHeadersCombined(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp locationResponse
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data["scheme"] != "https" {
t.Fatalf("expected scheme=%q, got %q", "https", resp.Data["scheme"])
diff --git a/go/middleware.go b/go/middleware.go
index 2173b79..ab8ee59 100644
--- a/go/middleware.go
+++ b/go/middleware.go
@@ -30,7 +30,7 @@ func recoveryMiddleware() gin.HandlerFunc {
}
c.AbortWithStatusJSON(http.StatusInternalServerError, Fail(
"internal_server_error",
- "Internal server error",
+ msgInternalSrvErr,
))
})
}
diff --git a/go/middleware_test.go b/go/middleware_test.go
index 780cc7f..eae5b90 100644
--- a/go/middleware_test.go
+++ b/go/middleware_test.go
@@ -89,7 +89,7 @@ func (g plusJSONResponseMetaTestGroup) Name() string { return "plus-json-res
func (g plusJSONResponseMetaTestGroup) BasePath() string { return "/v1" }
func (g plusJSONResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/plus-json", func(c *gin.Context) {
- c.Header("Content-Type", "application/problem+json")
+ c.Header(hdrContentType, "application/problem+json")
c.Status(http.StatusOK)
_, _ = c.Writer.Write([]byte(`{"success":true,"data":"ok"}`))
})
@@ -113,7 +113,7 @@ func TestBearerAuth_Bad_MissingToken(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Error == nil || resp.Error.Code != "unauthorised" {
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
@@ -137,7 +137,7 @@ func TestBearerAuth_Bad_WrongToken(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Error == nil || resp.Error.Code != "unauthorised" {
t.Fatalf("expected error code=%q, got %+v", "unauthorised", resp.Error)
@@ -156,15 +156,15 @@ func TestBearerAuth_Good_CorrectToken(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data != "classified" {
- t.Fatalf("expected Data=%q, got %q", "classified", resp.Data)
+ t.Fatalf(fmtTestExpectedData, "classified", resp.Data)
}
}
@@ -174,7 +174,7 @@ func TestBearerAuth_Good_HealthBypassesAuth(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
// No Authorization header.
h.ServeHTTP(w, req)
@@ -192,7 +192,7 @@ func TestBearerAuth_Good_OpenAPISpecBypassesAuth(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/v1/openapi.json", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathOpenAPIJSON, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -223,10 +223,10 @@ func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
- id := w.Header().Get("X-Request-ID")
+ id := w.Header().Get(hdrXRequestID)
if id == "" {
t.Fatal("expected X-Request-ID header to be set")
}
@@ -242,11 +242,11 @@ func TestRequestID_Good_PreservesClientID(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
- req.Header.Set("X-Request-ID", "client-id-abc")
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
+ req.Header.Set(hdrXRequestID, "client-id-abc")
h.ServeHTTP(w, req)
- id := w.Header().Get("X-Request-ID")
+ id := w.Header().Get(hdrXRequestID)
if id != "client-id-abc" {
t.Fatalf("expected X-Request-ID=%q, got %q", "client-id-abc", id)
}
@@ -262,11 +262,11 @@ func TestRequestID_Good_ContextAccessor(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil)
- req.Header.Set("X-Request-ID", "client-id-xyz")
+ req.Header.Set(hdrXRequestID, "client-id-xyz")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if gotID == "" {
@@ -285,16 +285,16 @@ func TestRequestID_Good_RequestMetaHelper(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
- req.Header.Set("X-Request-ID", "client-id-meta")
+ req.Header.Set(hdrXRequestID, "client-id-meta")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
@@ -321,16 +321,16 @@ func TestResponseMeta_Good_AttachesMetaAutomatically(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
- req.Header.Set("X-Request-ID", "client-id-auto-meta")
+ req.Header.Set(hdrXRequestID, "client-id-auto-meta")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
@@ -344,7 +344,7 @@ func TestResponseMeta_Good_AttachesMetaAutomatically(t *testing.T) {
if resp.Meta.Page != 1 || resp.Meta.PerPage != 25 || resp.Meta.Total != 100 {
t.Fatalf("expected pagination metadata to be preserved, got %+v", resp.Meta)
}
- if got := w.Header().Get("X-Request-ID"); got != "client-id-auto-meta" {
+ if got := w.Header().Get(hdrXRequestID); got != "client-id-auto-meta" {
t.Fatalf("expected response header X-Request-ID=%q, got %q", "client-id-auto-meta", got)
}
}
@@ -360,16 +360,16 @@ func TestResponseMeta_Good_AttachesMetaToErrorResponses(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/error", nil)
- req.Header.Set("X-Request-ID", "client-id-auto-error-meta")
+ req.Header.Set(hdrXRequestID, "client-id-auto-error-meta")
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
@@ -396,20 +396,20 @@ func TestResponseMeta_Good_AttachesMetaToPlusJSONContentType(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/plus-json", nil)
- req.Header.Set("X-Request-ID", "client-id-plus-json-meta")
+ req.Header.Set(hdrXRequestID, "client-id-plus-json-meta")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
- if got := w.Header().Get("Content-Type"); got != "application/problem+json" {
+ if got := w.Header().Get(hdrContentType); got != "application/problem+json" {
t.Fatalf("expected Content-Type to be preserved, got %q", got)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
@@ -430,7 +430,7 @@ func TestCORS_Good_PreflightAllOrigins(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodOptions, "/health", nil)
+ req, _ := http.NewRequest(http.MethodOptions, pathHealth, nil)
req.Header.Set("Origin", "https://example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
req.Header.Set("Access-Control-Request-Headers", "Authorization")
@@ -462,7 +462,7 @@ func TestCORS_Good_SpecificOrigin(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodOptions, "/health", nil)
+ req, _ := http.NewRequest(http.MethodOptions, pathHealth, nil)
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
h.ServeHTTP(w, req)
@@ -479,7 +479,7 @@ func TestCORS_Bad_DisallowedOrigin(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodOptions, "/health", nil)
+ req, _ := http.NewRequest(http.MethodOptions, pathHealth, nil)
req.Header.Set("Origin", "https://evil.example.com")
req.Header.Set("Access-Control-Request-Method", "GET")
h.ServeHTTP(w, req)
diff --git a/go/modernization_test.go b/go/modernization_test.go
index 3e8798b..f420dae 100644
--- a/go/modernization_test.go
+++ b/go/modernization_test.go
@@ -99,7 +99,7 @@ func TestEngine_AuthentikConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
Issuer: "https://auth.example.com",
ClientID: "client",
TrustedProxy: true,
- PublicPaths: []string{"/public", "/docs"},
+ PublicPaths: []string{pathPublic, "/docs"},
}))
cfg := e.AuthentikConfig()
@@ -112,13 +112,13 @@ func TestEngine_AuthentikConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
if !cfg.TrustedProxy {
t.Fatal("expected trusted proxy to be enabled")
}
- if !slices.Equal(cfg.PublicPaths, []string{"/public", "/docs"}) {
+ if !slices.Equal(cfg.PublicPaths, []string{pathPublic, "/docs"}) {
t.Fatalf("expected public paths [/public /docs], got %v", cfg.PublicPaths)
}
}
func TestEngine_AuthentikConfig_Good_ClonesPublicPaths(t *testing.T) {
- publicPaths := []string{"/public", "/docs"}
+ publicPaths := []string{pathPublic, "/docs"}
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.example.com",
PublicPaths: publicPaths,
@@ -127,18 +127,18 @@ func TestEngine_AuthentikConfig_Good_ClonesPublicPaths(t *testing.T) {
cfg := e.AuthentikConfig()
publicPaths[0] = "/mutated"
- if cfg.PublicPaths[0] != "/public" {
+ if cfg.PublicPaths[0] != pathPublic {
t.Fatalf("expected snapshot to preserve original public paths, got %v", cfg.PublicPaths)
}
}
func TestEngine_AuthentikConfig_Good_NormalisesPublicPaths(t *testing.T) {
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
- PublicPaths: []string{" /public/ ", "docs", "/public"},
+ PublicPaths: []string{" /public/ ", "docs", pathPublic},
}))
cfg := e.AuthentikConfig()
- expected := []string{"/public", "/docs"}
+ expected := []string{pathPublic, "/docs"}
if !slices.Equal(cfg.PublicPaths, expected) {
t.Fatalf("expected normalised public paths %v, got %v", expected, cfg.PublicPaths)
}
diff --git a/go/openapi.go b/go/openapi.go
index f08b99f..5a39822 100644
--- a/go/openapi.go
+++ b/go/openapi.go
@@ -52,6 +52,7 @@ type SpecBuilder struct {
ChatCompletionsPath string
OpenAPISpecEnabled bool
OpenAPISpecPath string
+ UpstreamRouterPaths []string
CacheEnabled bool
CacheTTL string
CacheMaxEntries int
@@ -420,7 +421,20 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
"summary": rd.Summary,
"description": rd.Description,
"operationId": resolvedOperationID(rd, method, fullPath, operationIDs),
- "responses": operationResponses(method, rd.StatusCode, rd.Response, rd.ResponseExample, rd.ResponseHeaders, security, deprecated, rd.SunsetDate, replacement, deprecationHeaders, sb.CacheEnabled, rd.CacheControl),
+ "responses": operationResponses(operationRespParams{
+ method: method,
+ statusCode: rd.StatusCode,
+ dataSchema: rd.Response,
+ example: rd.ResponseExample,
+ responseHeaders: rd.ResponseHeaders,
+ security: security,
+ deprecated: deprecated,
+ sunsetDate: rd.SunsetDate,
+ replacement: replacement,
+ deprecationHeaders: deprecationHeaders,
+ cacheEnabled: sb.CacheEnabled,
+ cacheControl: rd.CacheControl,
+ }),
}
if deprecated {
operation["deprecated"] = true
@@ -466,7 +480,7 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
operation["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
- "application/json": requestMediaType,
+ mimeJSON: requestMediaType,
},
}
}
@@ -485,6 +499,21 @@ func (sb *SpecBuilder) buildPaths(groups []preparedRouteGroup) map[string]any {
}
}
+ for _, rawPath := range sb.UpstreamRouterPaths {
+ routerPath := normaliseOpenAPIPath(rawPath)
+ if routerPath == "" {
+ continue
+ }
+ if _, exists := paths[routerPath]; exists {
+ continue // a real item (chat, spec, swagger, or a group) already documents this path
+ }
+ item := upstreamRouterPathItem(routerPath, operationIDs)
+ if isPublicPathForList(routerPath, publicPaths) {
+ makePathItemPublic(item)
+ }
+ paths[routerPath] = item
+ }
+
// The built-in health check remains public, so override the inherited
// default security requirement with an explicit empty array.
if health, ok := paths["/health"].(map[string]any); ok {
@@ -551,25 +580,41 @@ func normaliseOpenAPIPath(path string) string {
return "/" + core.Join("/", cleaned...)
}
+// operationRespParams bundles the parameters for operationResponses.
+type operationRespParams struct {
+ method string
+ statusCode int
+ dataSchema map[string]any
+ example any
+ responseHeaders map[string]string
+ security []map[string][]string
+ deprecated bool
+ sunsetDate string
+ replacement string
+ deprecationHeaders map[string]any
+ cacheEnabled bool
+ cacheControl string
+}
+
// operationResponses builds the standard response set for a documented API
// operation. The framework always exposes the common envelope responses, plus
// middleware-driven 429 and 504 errors.
-func operationResponses(method string, statusCode int, dataSchema map[string]any, example any, responseHeaders map[string]string, security []map[string][]string, deprecated bool, sunsetDate, replacement string, deprecationHeaders map[string]any, cacheEnabled bool, cacheControl string) map[string]any {
- documentedHeaders := documentedResponseHeaders(responseHeaders)
- successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders, documentedHeaders)
- if method == "get" && cacheEnabled {
+func operationResponses(p operationRespParams) map[string]any {
+ documentedHeaders := documentedResponseHeaders(p.responseHeaders)
+ successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), p.deprecationHeaders, documentedHeaders)
+ if p.method == "get" && p.cacheEnabled {
successHeaders = mergeHeaders(successHeaders, cacheSuccessHeaders())
}
- if cacheControl = core.Trim(cacheControl); cacheControl != "" {
- successHeaders = mergeHeaders(successHeaders, cacheControlHeaders(cacheControl))
+ if p.cacheControl = core.Trim(p.cacheControl); p.cacheControl != "" {
+ successHeaders = mergeHeaders(successHeaders, cacheControlHeaders(p.cacheControl))
}
- isPublic := security != nil && len(security) == 0
- errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), deprecationHeaders, documentedHeaders)
+ isPublic := p.security != nil && len(p.security) == 0
+ errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders(), p.deprecationHeaders, documentedHeaders)
- code := successStatusCode(statusCode)
- if dataSchema == nil && example != nil {
- dataSchema = map[string]any{}
+ code := successStatusCode(p.statusCode)
+ if p.dataSchema == nil && p.example != nil {
+ p.dataSchema = map[string]any{}
}
successResponse := map[string]any{
"description": successResponseDescription(code),
@@ -577,52 +622,52 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
}
if !isNoContentStatus(code) {
content := map[string]any{
- "schema": envelopeSchema(dataSchema),
+ "schema": envelopeSchema(p.dataSchema),
}
- if example != nil {
+ if p.example != nil {
// Example payloads are optional, but when a route provides one we
// expose it alongside the schema so generated docs stay useful.
- content["example"] = example
+ content["example"] = p.example
}
successResponse["content"] = map[string]any{
- "application/json": content,
+ mimeJSON: content,
}
}
responses := map[string]any{
core.Itoa(code): successResponse,
"400": map[string]any{
- "description": "Bad request",
+ "description": msgBadRequest,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
"headers": errorHeaders,
},
"429": map[string]any{
- "description": "Too many requests",
+ "description": msgTooManyRequests,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
- "headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders(), deprecationHeaders, documentedHeaders),
+ "headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders(), p.deprecationHeaders, documentedHeaders),
},
"504": map[string]any{
- "description": "Gateway timeout",
+ "description": msgGatewayTimeout,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
"headers": errorHeaders,
},
"500": map[string]any{
- "description": "Internal server error",
+ "description": msgInternalSrvErr,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -630,11 +675,11 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
},
}
- if deprecated && (core.Trim(sunsetDate) != "" || core.Trim(replacement) != "") {
+ if p.deprecated && (core.Trim(p.sunsetDate) != "" || core.Trim(p.replacement) != "") {
responses["410"] = map[string]any{
"description": "Gone",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -646,7 +691,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
responses["401"] = map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -655,7 +700,7 @@ func operationResponses(method string, statusCode int, dataSchema map[string]any
responses["403"] = map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -724,34 +769,34 @@ func healthResponses(cacheEnabled bool) map[string]any {
"200": map[string]any{
"description": "Server is healthy",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(map[string]any{"type": "string"}),
},
},
"headers": successHeaders,
},
"429": map[string]any{
- "description": "Too many requests",
+ "description": msgTooManyRequests,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
},
"504": map[string]any{
- "description": "Gateway timeout",
+ "description": msgGatewayTimeout,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders()),
},
"500": map[string]any{
- "description": "Internal server error",
+ "description": msgInternalSrvErr,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -854,9 +899,9 @@ func deprecationHeaderComponents() map[string]any {
func responseComponents() map[string]any {
return map[string]any{
"BadRequest": map[string]any{
- "description": "Bad request",
+ "description": msgBadRequest,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -865,7 +910,7 @@ func responseComponents() map[string]any {
"Unauthorized": map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -874,34 +919,34 @@ func responseComponents() map[string]any {
"Forbidden": map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
"headers": standardResponseHeaders(),
},
"RateLimitExceeded": map[string]any{
- "description": "Too many requests",
+ "description": msgTooManyRequests,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
},
"GatewayTimeout": map[string]any{
- "description": "Gateway timeout",
+ "description": msgGatewayTimeout,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
"headers": standardResponseHeaders(),
},
"InternalServerError": map[string]any{
- "description": "Internal server error",
+ "description": msgInternalSrvErr,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -910,7 +955,7 @@ func responseComponents() map[string]any {
"Gone": map[string]any{
"description": "Gone",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": envelopeSchema(nil),
},
},
@@ -981,6 +1026,14 @@ func (sb *SpecBuilder) buildTags(groups []preparedRouteGroup) []map[string]any {
seen["inference"] = true
}
+ if len(sb.UpstreamRouterPaths) > 0 && !seen["proxy"] {
+ tags = append(tags, map[string]any{
+ "name": "proxy",
+ "description": "Selector-routed upstream proxy endpoints",
+ })
+ seen["proxy"] = true
+ }
+
for _, g := range groups {
name := core.Trim(g.name)
if name != "" && !seen[name] {
@@ -1065,7 +1118,7 @@ func graphqlPathItem(path string, operationIDs map[string]int, cacheEnabled bool
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": graphqlRequestSchema(),
},
},
@@ -1153,7 +1206,7 @@ func wsResponses() map[string]any {
"401": map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1165,7 +1218,7 @@ func wsResponses() map[string]any {
"403": map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1175,9 +1228,9 @@ func wsResponses() map[string]any {
"headers": errorHeaders,
},
"429": map[string]any{
- "description": "Too many requests",
+ "description": msgTooManyRequests,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1187,9 +1240,9 @@ func wsResponses() map[string]any {
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
},
"500": map[string]any{
- "description": "Internal server error",
+ "description": msgInternalSrvErr,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1199,9 +1252,9 @@ func wsResponses() map[string]any {
"headers": errorHeaders,
},
"504": map[string]any{
- "description": "Gateway timeout",
+ "description": msgGatewayTimeout,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1266,7 +1319,7 @@ func pprofPathItem(operationIDs map[string]int) map[string]any {
"401": map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1278,7 +1331,7 @@ func pprofPathItem(operationIDs map[string]int) map[string]any {
"403": map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1311,7 +1364,7 @@ func expvarPathItem(operationIDs map[string]int) map[string]any {
"200": map[string]any{
"description": "Runtime metrics",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1323,7 +1376,7 @@ func expvarPathItem(operationIDs map[string]int) map[string]any {
"401": map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1335,7 +1388,7 @@ func expvarPathItem(operationIDs map[string]int) map[string]any {
"403": map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1370,7 +1423,7 @@ func openAPISpecPathItem(path string, operationIDs map[string]int) map[string]an
"200": map[string]any{
"description": "OpenAPI 3.1 JSON document",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1382,7 +1435,7 @@ func openAPISpecPathItem(path string, operationIDs map[string]int) map[string]an
"500": map[string]any{
"description": "Failed to render specification",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1396,6 +1449,66 @@ func openAPISpecPathItem(path string, operationIDs map[string]int) map[string]an
}
}
+// upstreamRouterPathItem documents a WithUpstreamRouter mounted path as a
+// minimal, honest POST proxy operation. The router proxies arbitrary shapes by
+// selector key, so request/response schemas are generic by design; the path is
+// tagged "proxy" to distinguish it from the typed "inference" chat endpoint.
+func upstreamRouterPathItem(path string, operationIDs map[string]int) map[string]any {
+ successHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
+ errorHeaders := mergeHeaders(standardResponseHeaders(), rateLimitSuccessHeaders())
+ genericObject := func() map[string]any {
+ return map[string]any{"type": "object", "additionalProperties": true}
+ }
+
+ return map[string]any{
+ "post": map[string]any{
+ "summary": "Upstream router (selector-routed proxy)",
+ "description": "Selector-routed reverse proxy. The request body must carry the selector field (default \"model\"); the concrete request and response schemas depend on the target upstream/model. Streams Server-Sent Events when the upstream does.",
+ "tags": []string{"proxy"},
+ "operationId": operationID("post", path, operationIDs),
+ // The router is a network gateway under engine auth. Default to
+ // bearerAuth (mirroring graphqlPathItem and the group-loop items);
+ // makePathItemPublic overrides this to [] for configured public paths.
+ "security": []any{
+ map[string]any{
+ "bearerAuth": []any{},
+ },
+ },
+ "requestBody": map[string]any{
+ "required": true,
+ "content": map[string]any{
+ mimeJSON: map[string]any{"schema": genericObject()},
+ },
+ },
+ "responses": map[string]any{
+ "200": map[string]any{
+ "description": "Proxied upstream response",
+ "content": map[string]any{
+ mimeJSON: map[string]any{"schema": genericObject()},
+ "text/event-stream": map[string]any{"schema": map[string]any{"type": "string"}},
+ },
+ "headers": successHeaders,
+ },
+ "404": map[string]any{
+ "description": "No upstream registered for the selector key",
+ "content": map[string]any{mimeJSON: map[string]any{"schema": genericObject()}},
+ "headers": errorHeaders,
+ },
+ "503": map[string]any{
+ "description": "All upstreams unavailable",
+ "content": map[string]any{mimeJSON: map[string]any{"schema": genericObject()}},
+ "headers": mergeHeaders(errorHeaders, map[string]any{
+ "Retry-After": map[string]any{
+ "description": "Seconds to wait before retrying.",
+ "schema": map[string]any{"type": "integer"},
+ },
+ }),
+ },
+ },
+ },
+ }
+}
+
// chatCompletionsPathItem returns the OpenAPI path item describing the
// OpenAI-compatible chat completions endpoint (RFC §11). The path documents
// the streaming and non-streaming response shapes, the Gemma 4 calibrated
@@ -1417,7 +1530,7 @@ func chatCompletionsPathItem(path string, operationIDs map[string]int) map[strin
"requestBody": map[string]any{
"required": true,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": chatCompletionsRequestSchema(),
},
},
@@ -1426,7 +1539,7 @@ func chatCompletionsPathItem(path string, operationIDs map[string]int) map[strin
"200": map[string]any{
"description": "Chat completion response",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": chatCompletionsResponseSchema(),
},
"text/event-stream": map[string]any{
@@ -1438,7 +1551,7 @@ func chatCompletionsPathItem(path string, operationIDs map[string]int) map[strin
"400": map[string]any{
"description": "Invalid request",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": chatCompletionsErrorSchema(),
},
},
@@ -1447,7 +1560,7 @@ func chatCompletionsPathItem(path string, operationIDs map[string]int) map[strin
"404": map[string]any{
"description": "Model not found",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": chatCompletionsErrorSchema(),
},
},
@@ -1456,7 +1569,7 @@ func chatCompletionsPathItem(path string, operationIDs map[string]int) map[strin
"503": map[string]any{
"description": "Model loading or unavailable",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": chatCompletionsErrorSchema(),
},
},
@@ -1465,7 +1578,7 @@ func chatCompletionsPathItem(path string, operationIDs map[string]int) map[strin
"500": map[string]any{
"description": "Inference error",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": chatCompletionsErrorSchema(),
},
},
@@ -1650,7 +1763,7 @@ func graphqlResponses(cacheEnabled bool) map[string]any {
"200": map[string]any{
"description": "GraphQL response",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1660,9 +1773,9 @@ func graphqlResponses(cacheEnabled bool) map[string]any {
"headers": successHeaders,
},
"400": map[string]any{
- "description": "Bad request",
+ "description": msgBadRequest,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1674,7 +1787,7 @@ func graphqlResponses(cacheEnabled bool) map[string]any {
"401": map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1686,7 +1799,7 @@ func graphqlResponses(cacheEnabled bool) map[string]any {
"403": map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1696,9 +1809,9 @@ func graphqlResponses(cacheEnabled bool) map[string]any {
"headers": errorHeaders,
},
"429": map[string]any{
- "description": "Too many requests",
+ "description": msgTooManyRequests,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1708,9 +1821,9 @@ func graphqlResponses(cacheEnabled bool) map[string]any {
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
},
"500": map[string]any{
- "description": "Internal server error",
+ "description": msgInternalSrvErr,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1720,9 +1833,9 @@ func graphqlResponses(cacheEnabled bool) map[string]any {
"headers": errorHeaders,
},
"504": map[string]any{
- "description": "Gateway timeout",
+ "description": msgGatewayTimeout,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1753,7 +1866,7 @@ func graphqlPlaygroundResponses() map[string]any {
"401": map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1765,7 +1878,7 @@ func graphqlPlaygroundResponses() map[string]any {
"403": map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1775,9 +1888,9 @@ func graphqlPlaygroundResponses() map[string]any {
"headers": errorHeaders,
},
"429": map[string]any{
- "description": "Too many requests",
+ "description": msgTooManyRequests,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1787,9 +1900,9 @@ func graphqlPlaygroundResponses() map[string]any {
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
},
"500": map[string]any{
- "description": "Internal server error",
+ "description": msgInternalSrvErr,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1799,9 +1912,9 @@ func graphqlPlaygroundResponses() map[string]any {
"headers": errorHeaders,
},
"504": map[string]any{
- "description": "Gateway timeout",
+ "description": msgGatewayTimeout,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1836,7 +1949,7 @@ func sseResponses() map[string]any {
"401": map[string]any{
"description": "Unauthorised",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1848,7 +1961,7 @@ func sseResponses() map[string]any {
"403": map[string]any{
"description": "Forbidden",
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1858,9 +1971,9 @@ func sseResponses() map[string]any {
"headers": errorHeaders,
},
"429": map[string]any{
- "description": "Too many requests",
+ "description": msgTooManyRequests,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1870,9 +1983,9 @@ func sseResponses() map[string]any {
"headers": mergeHeaders(standardResponseHeaders(), rateLimitHeaders()),
},
"500": map[string]any{
- "description": "Internal server error",
+ "description": msgInternalSrvErr,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
@@ -1882,9 +1995,9 @@ func sseResponses() map[string]any {
"headers": errorHeaders,
},
"504": map[string]any{
- "description": "Gateway timeout",
+ "description": msgGatewayTimeout,
"content": map[string]any{
- "application/json": map[string]any{
+ mimeJSON: map[string]any{
"schema": map[string]any{
"type": "object",
"additionalProperties": true,
diff --git a/go/openapi_inference_test.go b/go/openapi_inference_test.go
new file mode 100644
index 0000000..b090489
--- /dev/null
+++ b/go/openapi_inference_test.go
@@ -0,0 +1,220 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "encoding/json"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+// specObject builds the engine's OpenAPI spec and returns the whole document.
+func specObject(t *testing.T, e *api.Engine) map[string]any {
+ t.Helper()
+ data, err := e.OpenAPISpecBuilder().Build(nil)
+ if err != nil {
+ t.Fatalf("Build: %v", err)
+ }
+ var spec map[string]any
+ if err := json.Unmarshal(data, &spec); err != nil {
+ t.Fatalf("unmarshal spec: %v", err)
+ }
+ return spec
+}
+
+// specPaths builds the engine's OpenAPI spec and returns its "paths" object.
+func specPaths(t *testing.T, e *api.Engine) map[string]any {
+ t.Helper()
+ paths, ok := specObject(t, e)["paths"].(map[string]any)
+ if !ok {
+ t.Fatalf("spec has no paths object")
+ }
+ return paths
+}
+
+// postTags returns the tags of the POST operation at path, or nil.
+func postTags(paths map[string]any, path string) []string {
+ item, ok := paths[path].(map[string]any)
+ if !ok {
+ return nil
+ }
+ post, ok := item["post"].(map[string]any)
+ if !ok {
+ return nil
+ }
+ raw, _ := post["tags"].([]any)
+ out := make([]string, 0, len(raw))
+ for _, t := range raw {
+ if s, ok := t.(string); ok {
+ out = append(out, s)
+ }
+ }
+ return out
+}
+
+func hasTag(tags []string, want string) bool {
+ for _, t := range tags {
+ if t == want {
+ return true
+ }
+ }
+ return false
+}
+
+func TestOpenAPISpec_ChatCompletions_RemoteOnly_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := api.New(api.WithChatCompletionsRemote(reg))
+ if err != nil {
+ t.Fatal(err)
+ }
+ spec := specObject(t, e)
+
+ // The typed chat path must surface for a remote-only backend.
+ paths, ok := spec["paths"].(map[string]any)
+ if !ok {
+ t.Fatalf("spec has no paths object")
+ }
+ if !hasTag(postTags(paths, "/v1/chat/completions"), "inference") {
+ t.Fatalf("remote-only chat endpoint missing/untagged in spec; paths present: %v", keysOf(paths))
+ }
+
+ // The top-level capability flag must report chat as enabled. This is the
+ // load-bearing assertion for ChatCompletionsEnabled honouring e.chatRemote:
+ // it fails without the transport.go change and passes with it.
+ if enabled, _ := spec["x-chat-completions-enabled"].(bool); !enabled {
+ t.Fatalf("x-chat-completions-enabled missing/false for a remote-only chat engine")
+ }
+}
+
+func TestOpenAPISpec_ChatCompletions_Absent_Good(t *testing.T) {
+ e, err := api.New() // neither local nor remote chat configured
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ if _, exists := paths["/v1/chat/completions"]; exists {
+ t.Fatalf("chat endpoint present in spec with no chat configured")
+ }
+}
+
+func keysOf(m map[string]any) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
+
+func TestOpenAPISpec_RouterPaths_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ e, err := api.New(api.WithUpstreamRouter(reg, api.WithRouterPaths("/v1/embeddings", "/v1/score")))
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ for _, p := range []string{"/v1/embeddings", "/v1/score"} {
+ if !hasTag(postTags(paths, p), "proxy") {
+ t.Fatalf("router path %s missing/untagged in spec; paths: %v", p, keysOf(paths))
+ }
+ item := paths[p].(map[string]any)
+ post := item["post"].(map[string]any)
+ responses := post["responses"].(map[string]any)
+ for _, code := range []string{"404", "503"} {
+ if _, ok := responses[code]; !ok {
+ t.Errorf("router path %s missing %s response", p, code)
+ }
+ }
+ // Not public and no special-cased auth: the proxy POST is a network
+ // gateway under engine auth, so SDK gen must emit an authenticated
+ // client. Assert the operation carries a non-empty security
+ // requirement (bearerAuth, mirroring the GraphQL/group-loop items).
+ security, ok := post["security"].([]any)
+ if !ok || len(security) == 0 {
+ t.Errorf("router path %s proxy POST missing/empty security; got %v", p, post["security"])
+ }
+ }
+}
+
+func TestOpenAPISpec_RouterDedupChat_Ugly(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ // Router mounted at the default chat path AND chat enabled (remote).
+ e, err := api.New(
+ api.WithChatCompletionsRemote(reg),
+ api.WithUpstreamRouter(reg), // default WithRouterPaths == /v1/chat/completions
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ tags := postTags(paths, "/v1/chat/completions")
+ if !hasTag(tags, "inference") {
+ t.Fatalf("chat path lost its inference item to the proxy dedup; tags=%v", tags)
+ }
+ if hasTag(tags, "proxy") {
+ t.Fatalf("chat path was clobbered by the proxy item; tags=%v", tags)
+ }
+}
+
+func TestOpenAPISpec_RouterDedupSpecPath_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ // Router mounted at the OpenAPI spec path: the real spec GET item must win,
+ // and the proxy POST must be skipped by the dedup.
+ e, err := api.New(
+ api.WithOpenAPISpecPath("/v1/openapi.json"),
+ api.WithUpstreamRouter(reg, api.WithRouterPaths("/v1/openapi.json")),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ paths := specPaths(t, e)
+ item, ok := paths["/v1/openapi.json"].(map[string]any)
+ if !ok {
+ t.Fatalf("spec path missing from paths; paths: %v", keysOf(paths))
+ }
+ if _, ok := item["get"].(map[string]any); !ok {
+ t.Errorf("spec path lost its real GET item to the proxy dedup; item: %v", keysOf(item))
+ }
+ if _, ok := item["post"]; ok {
+ t.Errorf("spec path was clobbered by the proxy POST item; item: %v", keysOf(item))
+ }
+}
+
+func TestOpenAPISpec_ChatCompletions_Hybrid_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatal(err)
+ }
+ // Both a local resolver AND a remote backend configured.
+ e, err := api.New(
+ api.WithChatCompletions(api.NewModelResolver()),
+ api.WithChatCompletionsRemote(reg),
+ )
+ if err != nil {
+ t.Fatal(err)
+ }
+ spec := specObject(t, e)
+
+ paths, ok := spec["paths"].(map[string]any)
+ if !ok {
+ t.Fatalf("spec has no paths object")
+ }
+ if !hasTag(postTags(paths, "/v1/chat/completions"), "inference") {
+ t.Fatalf("hybrid chat endpoint missing/untagged in spec; paths present: %v", keysOf(paths))
+ }
+ if enabled, _ := spec["x-chat-completions-enabled"].(bool); !enabled {
+ t.Fatalf("x-chat-completions-enabled missing/false for a hybrid (local+remote) chat engine")
+ }
+}
diff --git a/go/openapi_test.go b/go/openapi_test.go
index 949edae..61b4c21 100644
--- a/go/openapi_test.go
+++ b/go/openapi_test.go
@@ -22,17 +22,21 @@ type specStubGroup struct {
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 }
-func (s *specStubGroup) Hidden() bool { return s.hidden }
+func (s *specStubGroup) Name() string { return s.name }
+func (s *specStubGroup) BasePath() string { return s.basePath }
+func (s *specStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; routes are described through the Describe path.
+}
+func (s *specStubGroup) Describe() []api.RouteDescription { return s.descs }
+func (s *specStubGroup) Hidden() bool { return s.hidden }
type plainStubGroup struct{}
-func (plainStubGroup) Name() string { return "plain" }
-func (plainStubGroup) BasePath() string { return "/plain" }
-func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
+func (plainStubGroup) Name() string { return "plain" }
+func (plainStubGroup) BasePath() string { return "/plain" }
+func (plainStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; minimal stub for spec builder tests.
+}
type iterStubGroup struct {
name string
@@ -40,10 +44,12 @@ type iterStubGroup struct {
descs []api.RouteDescription
}
-func (s *iterStubGroup) Name() string { return s.name }
-func (s *iterStubGroup) BasePath() string { return s.basePath }
-func (s *iterStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
-func (s *iterStubGroup) Describe() []api.RouteDescription { return nil }
+func (s *iterStubGroup) Name() string { return s.name }
+func (s *iterStubGroup) BasePath() string { return s.basePath }
+func (s *iterStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; routes are described through the DescribeIter path.
+}
+func (s *iterStubGroup) Describe() []api.RouteDescription { return nil }
func (s *iterStubGroup) DescribeIter() iter.Seq[api.RouteDescription] {
return func(yield func(api.RouteDescription) bool) {
for _, rd := range s.descs {
@@ -60,10 +66,12 @@ type iterNilFallbackGroup struct {
descs []api.RouteDescription
}
-func (s *iterNilFallbackGroup) Name() string { return s.name }
-func (s *iterNilFallbackGroup) BasePath() string { return s.basePath }
-func (s *iterNilFallbackGroup) RegisterRoutes(rg *gin.RouterGroup) {}
-func (s *iterNilFallbackGroup) Describe() []api.RouteDescription { return s.descs }
+func (s *iterNilFallbackGroup) Name() string { return s.name }
+func (s *iterNilFallbackGroup) BasePath() string { return s.basePath }
+func (s *iterNilFallbackGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; spec builder tests the nil-iterator fallback path.
+}
+func (s *iterNilFallbackGroup) Describe() []api.RouteDescription { return s.descs }
func (s *iterNilFallbackGroup) DescribeIter() iter.Seq[api.RouteDescription] {
return nil
}
@@ -75,10 +83,12 @@ type countingIterGroup struct {
describeCalls int
}
-func (s *countingIterGroup) Name() string { return s.name }
-func (s *countingIterGroup) BasePath() string { return s.basePath }
-func (s *countingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {}
-func (s *countingIterGroup) Describe() []api.RouteDescription { return nil }
+func (s *countingIterGroup) Name() string { return s.name }
+func (s *countingIterGroup) BasePath() string { return s.basePath }
+func (s *countingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; routes are described through the DescribeIter path.
+}
+func (s *countingIterGroup) Describe() []api.RouteDescription { return nil }
func (s *countingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] {
s.describeCalls++
return func(yield func(api.RouteDescription) bool) {
@@ -96,10 +106,12 @@ type mutatingIterGroup struct {
descs []api.RouteDescription
}
-func (s *mutatingIterGroup) Name() string { return s.name }
-func (s *mutatingIterGroup) BasePath() string { return s.basePath }
-func (s *mutatingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {}
-func (s *mutatingIterGroup) Describe() []api.RouteDescription { return nil }
+func (s *mutatingIterGroup) Name() string { return s.name }
+func (s *mutatingIterGroup) BasePath() string { return s.basePath }
+func (s *mutatingIterGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; routes are described through the DescribeIter path.
+}
+func (s *mutatingIterGroup) Describe() []api.RouteDescription { return nil }
func (s *mutatingIterGroup) DescribeIter() iter.Seq[api.RouteDescription] {
return func(yield func(api.RouteDescription) bool) {
for i, rd := range s.descs {
@@ -136,8 +148,10 @@ func (s *snapshottingGroup) BasePath() string {
return "/beta"
}
-func (s *snapshottingGroup) RegisterRoutes(rg *gin.RouterGroup) {}
-func (s *snapshottingGroup) Describe() []api.RouteDescription { return s.descs }
+func (s *snapshottingGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; tests the snapshotting/identity semantics.
+}
+func (s *snapshottingGroup) Describe() []api.RouteDescription { return s.descs }
// ── SpecBuilder tests ─────────────────────────────────────────────────────
@@ -150,12 +164,12 @@ func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
// Verify OpenAPI version.
@@ -168,10 +182,10 @@ func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
// Verify /health path exists.
paths := spec["paths"].(map[string]any)
- if _, ok := paths["/health"]; !ok {
+ if _, ok := paths[pathHealth]; !ok {
t.Fatal("expected /health path in spec")
}
- health := paths["/health"].(map[string]any)["get"].(map[string]any)
+ health := paths[pathHealth].(map[string]any)["get"].(map[string]any)
healthResponses := health["responses"].(map[string]any)
if _, ok := healthResponses["429"]; !ok {
t.Fatal("expected 429 response on /health")
@@ -187,35 +201,35 @@ func TestSpecBuilder_Good_EmptyGroups(t *testing.T) {
if _, ok := headers["Retry-After"]; !ok {
t.Fatal("expected Retry-After header on /health 429 response")
}
- if _, ok := headers["X-Request-ID"]; !ok {
+ if _, ok := headers[hdrXRequestID]; !ok {
t.Fatal("expected X-Request-ID header on /health 429 response")
}
- if _, ok := headers["X-RateLimit-Limit"]; !ok {
+ if _, ok := headers[hdrRateLimit]; !ok {
t.Fatal("expected X-RateLimit-Limit header on /health 429 response")
}
- if _, ok := headers["X-RateLimit-Remaining"]; !ok {
+ if _, ok := headers[hdrRateRemaining]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on /health 429 response")
}
- if _, ok := headers["X-RateLimit-Reset"]; !ok {
+ if _, ok := headers[hdrRateReset]; !ok {
t.Fatal("expected X-RateLimit-Reset header on /health 429 response")
}
health504 := healthResponses["504"].(map[string]any)
health504Headers := health504["headers"].(map[string]any)
- if _, ok := health504Headers["X-Request-ID"]; !ok {
+ if _, ok := health504Headers[hdrXRequestID]; !ok {
t.Fatal("expected X-Request-ID header on /health 504 response")
}
- if _, ok := health504Headers["X-RateLimit-Limit"]; !ok {
+ if _, ok := health504Headers[hdrRateLimit]; !ok {
t.Fatal("expected X-RateLimit-Limit header on /health 504 response")
}
- if _, ok := health504Headers["X-RateLimit-Remaining"]; !ok {
+ if _, ok := health504Headers[hdrRateRemaining]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on /health 504 response")
}
- if _, ok := health504Headers["X-RateLimit-Reset"]; !ok {
+ if _, ok := health504Headers[hdrRateReset]; !ok {
t.Fatal("expected X-RateLimit-Reset header on /health 504 response")
}
health200 := health["responses"].(map[string]any)["200"].(map[string]any)
health200Headers := health200["headers"].(map[string]any)
- if _, ok := health200Headers["X-Cache"]; ok {
+ if _, ok := health200Headers[hdrXCache]; ok {
t.Fatal("expected /health 200 response to omit X-Cache when cache is disabled")
}
@@ -282,19 +296,19 @@ func TestSpecBuilder_Good_IncludesCacheControlResponseHeader(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
getOp := paths["/cache/items/{id}"].(map[string]any)["get"].(map[string]any)
success := getOp["responses"].(map[string]any)["200"].(map[string]any)
headers := success["headers"].(map[string]any)
- header, ok := headers["Cache-Control"].(map[string]any)
+ header, ok := headers[hdrCacheControl].(map[string]any)
if !ok {
t.Fatal("expected Cache-Control response header in OpenAPI spec")
}
@@ -309,12 +323,12 @@ func TestSpecBuilder_Good_NilReceiverIsZeroValueSafe(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if spec["openapi"] != "3.1.0" {
@@ -325,7 +339,7 @@ func TestSpecBuilder_Good_NilReceiverIsZeroValueSafe(t *testing.T) {
if !ok {
t.Fatalf("expected paths object, got %T", spec["paths"])
}
- if _, ok := paths["/health"]; !ok {
+ if _, ok := paths[pathHealth]; !ok {
t.Fatal("expected /health path to be present")
}
}
@@ -338,19 +352,19 @@ func TestSpecBuilder_Good_CustomSecuritySchemesAreMerged(t *testing.T) {
"apiKeyAuth": map[string]any{
"type": "apiKey",
"in": "header",
- "name": "X-API-Key",
+ "name": apiKeyHeader,
},
},
}
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
components := spec["components"].(map[string]any)
@@ -374,7 +388,7 @@ func TestSpecBuilder_Good_CustomSecuritySchemesAreMerged(t *testing.T) {
if apiKeyAuth["in"] != "header" {
t.Fatalf("expected apiKeyAuth.in=header, got %v", apiKeyAuth["in"])
}
- if apiKeyAuth["name"] != "X-API-Key" {
+ if apiKeyAuth["name"] != apiKeyHeader {
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
}
}
@@ -387,12 +401,12 @@ func TestSpecBuilder_Good_CommonResponseComponentsArePublished(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
components := spec["components"].(map[string]any)
@@ -431,12 +445,12 @@ func TestSpecBuilder_Good_NormalisesMetadataAtBuild(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := spec["info"].(map[string]any)
@@ -494,12 +508,12 @@ func TestSpecBuilder_Good_SwaggerUIPathExtension(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-swagger-ui-path"]; got != "/docs" {
@@ -522,12 +536,12 @@ func TestSpecBuilder_Good_CacheAndI18nExtensions(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-cache-enabled"]; got != true {
@@ -565,12 +579,12 @@ func TestSpecBuilder_Good_OmitsNonPositiveCacheTTLExtension(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if _, ok := spec["x-cache-ttl"]; ok {
@@ -583,18 +597,18 @@ func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) {
Title: "Test",
Description: "GraphQL test",
Version: "1.0.0",
- GraphQLPath: "/graphql",
+ GraphQLPath: pathGraphQL,
CacheEnabled: true,
}
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags := spec["tags"].([]any)
@@ -614,7 +628,7 @@ func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) {
}
paths := spec["paths"].(map[string]any)
- pathItem, ok := paths["/graphql"].(map[string]any)
+ pathItem, ok := paths[pathGraphQL].(map[string]any)
if !ok {
t.Fatal("expected /graphql path in spec")
}
@@ -641,12 +655,12 @@ func TestSpecBuilder_Good_GraphQLEndpoint(t *testing.T) {
responses := postOp["responses"].(map[string]any)
successHeaders := responses["200"].(map[string]any)["headers"].(map[string]any)
- if _, ok := successHeaders["X-Cache"]; !ok {
+ if _, ok := successHeaders[hdrXCache]; !ok {
t.Fatal("expected X-Cache header on GraphQL 200 response")
}
requestBody := postOp["requestBody"].(map[string]any)
- schema := requestBody["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
+ schema := requestBody["content"].(map[string]any)[mimeJSON].(map[string]any)["schema"].(map[string]any)
properties := schema["properties"].(map[string]any)
if _, ok := properties["query"]; !ok {
t.Fatal("expected GraphQL request schema to include query field")
@@ -663,23 +677,23 @@ func TestSpecBuilder_Good_GraphQLPlaygroundEndpoint(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
- GraphQLPath: "/graphql",
+ GraphQLPath: pathGraphQL,
GraphQLPlayground: true,
- GraphQLPlaygroundPath: "/graphql/playground",
+ GraphQLPlaygroundPath: pathGraphQLPlay,
}
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
- pathItem, ok := paths["/graphql/playground"].(map[string]any)
+ pathItem, ok := paths[pathGraphQLPlay].(map[string]any)
if !ok {
t.Fatal("expected /graphql/playground path in spec")
}
@@ -688,7 +702,7 @@ func TestSpecBuilder_Good_GraphQLPlaygroundEndpoint(t *testing.T) {
if getOp["operationId"] != "get_graphql_playground" {
t.Fatalf("expected playground operationId to be get_graphql_playground, got %v", getOp["operationId"])
}
- if got := spec["x-graphql-playground-path"]; got != "/graphql/playground" {
+ if got := spec["x-graphql-playground-path"]; got != pathGraphQLPlay {
t.Fatalf("expected x-graphql-playground-path=/graphql/playground, got %v", got)
}
@@ -709,19 +723,19 @@ func TestSpecBuilder_Good_GraphQLPlaygroundDefaultsToGraphQLPath(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
- if _, ok := paths["/graphql"].(map[string]any); !ok {
+ if _, ok := paths[pathGraphQL].(map[string]any); !ok {
t.Fatal("expected default /graphql path when playground is enabled")
}
- if _, ok := paths["/graphql/playground"].(map[string]any); !ok {
+ if _, ok := paths[pathGraphQLPlay].(map[string]any); !ok {
t.Fatal("expected default /graphql/playground path when playground is enabled")
}
}
@@ -735,12 +749,12 @@ func TestSpecBuilder_Good_GraphQLPlaygroundDefaultsToGraphQLTag(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags := spec["tags"].([]any)
@@ -769,18 +783,18 @@ func TestSpecBuilder_Good_ChatCompletionsEndpointExtension(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-chat-completions-enabled"]; got != true {
t.Fatalf("expected x-chat-completions-enabled=true, got %v", got)
}
- if got := spec["x-chat-completions-path"]; got != "/v1/chat/completions" {
+ if got := spec["x-chat-completions-path"]; got != pathChatComplet {
t.Fatalf("expected default chat completions path, got %v", got)
}
}
@@ -797,12 +811,12 @@ func TestSpecBuilder_Good_ChatCompletionsHonoursCustomPath(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-chat-completions-path"]; got != "/chat" {
@@ -820,12 +834,12 @@ func TestSpecBuilder_Good_ChatCompletionsOmittedWhenDisabled(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if _, ok := spec["x-chat-completions-enabled"]; ok {
@@ -848,21 +862,21 @@ func TestSpecBuilder_Good_ChatCompletionsPathAppearsInPaths(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths, ok := spec["paths"].(map[string]any)
if !ok {
t.Fatalf("expected paths object, got %T", spec["paths"])
}
- item, ok := paths["/v1/chat/completions"].(map[string]any)
+ item, ok := paths[pathChatComplet].(map[string]any)
if !ok {
- t.Fatalf("expected /v1/chat/completions path item, got %T", paths["/v1/chat/completions"])
+ t.Fatalf("expected /v1/chat/completions path item, got %T", paths[pathChatComplet])
}
if _, ok := item["post"]; !ok {
t.Fatal("expected POST operation on /v1/chat/completions")
@@ -879,16 +893,16 @@ func TestSpecBuilder_Bad_ChatCompletionsPathAbsentWhenDisabled(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
- if _, ok := paths["/v1/chat/completions"]; ok {
+ if _, ok := paths[pathChatComplet]; ok {
t.Fatal("expected /v1/chat/completions path item to be absent when disabled")
}
}
@@ -905,19 +919,19 @@ func TestSpecBuilder_Ugly_ChatCompletionsPathCustomOverrideHonoured(t *testing.T
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
if _, ok := paths["/api/v1/chat"].(map[string]any); !ok {
t.Fatalf("expected custom chat completions path in paths object, got %v", paths)
}
- if _, ok := paths["/v1/chat/completions"]; ok {
+ if _, ok := paths[pathChatComplet]; ok {
t.Fatal("expected default chat completions path to be absent when overridden")
}
}
@@ -934,25 +948,25 @@ func TestSpecBuilder_Good_OpenAPISpecEndpointAppearsInPaths(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-openapi-spec-enabled"]; got != true {
t.Fatalf("expected x-openapi-spec-enabled=true, got %v", got)
}
- if got := spec["x-openapi-spec-path"]; got != "/v1/openapi.json" {
+ if got := spec["x-openapi-spec-path"]; got != pathOpenAPIJSON {
t.Fatalf("expected default openapi spec path, got %v", got)
}
paths := spec["paths"].(map[string]any)
- item, ok := paths["/v1/openapi.json"].(map[string]any)
+ item, ok := paths[pathOpenAPIJSON].(map[string]any)
if !ok {
- t.Fatalf("expected /v1/openapi.json path item, got %T", paths["/v1/openapi.json"])
+ t.Fatalf("expected /v1/openapi.json path item, got %T", paths[pathOpenAPIJSON])
}
get, ok := item["get"].(map[string]any)
if !ok {
@@ -974,16 +988,16 @@ func TestSpecBuilder_Bad_OpenAPISpecEndpointAbsentWhenDisabled(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
- if _, ok := paths["/v1/openapi.json"]; ok {
+ if _, ok := paths[pathOpenAPIJSON]; ok {
t.Fatal("expected /v1/openapi.json path item to be absent when disabled")
}
if _, ok := spec["x-openapi-spec-enabled"]; ok {
@@ -1003,19 +1017,19 @@ func TestSpecBuilder_Ugly_OpenAPISpecPathCustomOverrideHonoured(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
if _, ok := paths["/api/v1/openapi.json"].(map[string]any); !ok {
t.Fatalf("expected custom openapi spec path in paths object, got %v", paths)
}
- if _, ok := paths["/v1/openapi.json"]; ok {
+ if _, ok := paths[pathOpenAPIJSON]; ok {
t.Fatal("expected default openapi spec path to be absent when overridden")
}
}
@@ -1032,29 +1046,29 @@ func TestSpecBuilder_Good_EnabledTransportsUseDefaultPaths(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-swagger-ui-path"]; got != "/swagger" {
t.Fatalf("expected default swagger path, got %v", got)
}
- if got := spec["x-graphql-path"]; got != "/graphql" {
+ if got := spec["x-graphql-path"]; got != pathGraphQL {
t.Fatalf("expected default graphql path, got %v", got)
}
if got := spec["x-ws-path"]; got != "/ws" {
t.Fatalf("expected default websocket path, got %v", got)
}
- if got := spec["x-sse-path"]; got != "/events" {
+ if got := spec["x-sse-path"]; got != pathEvents {
t.Fatalf("expected default sse path, got %v", got)
}
paths := spec["paths"].(map[string]any)
- for _, path := range []string{"/graphql", "/ws", "/events"} {
+ for _, path := range []string{pathGraphQL, "/ws", pathEvents} {
if _, ok := paths[path].(map[string]any); !ok {
t.Fatalf("expected %s path in spec", path)
}
@@ -1089,12 +1103,12 @@ func TestSpecBuilder_Good_WebSocketEndpoint(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags := spec["tags"].([]any)
@@ -1137,17 +1151,17 @@ func TestSpecBuilder_Good_ServerSentEventsEndpoint(t *testing.T) {
sb := &api.SpecBuilder{
Title: "Test",
Version: "1.0.0",
- SSEPath: "/events",
+ SSEPath: pathEvents,
}
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags := spec["tags"].([]any)
@@ -1164,7 +1178,7 @@ func TestSpecBuilder_Good_ServerSentEventsEndpoint(t *testing.T) {
}
paths := spec["paths"].(map[string]any)
- pathItem, ok := paths["/events"].(map[string]any)
+ pathItem, ok := paths[pathEvents].(map[string]any)
if !ok {
t.Fatal("expected /events path in spec")
}
@@ -1186,11 +1200,11 @@ func TestSpecBuilder_Good_ServerSentEventsEndpoint(t *testing.T) {
responses := getOp["responses"].(map[string]any)
success := responses["200"].(map[string]any)
content := success["content"].(map[string]any)
- if _, ok := content["text/event-stream"]; !ok {
+ if _, ok := content[mimeEventStream]; !ok {
t.Fatal("expected text/event-stream content type for SSE response")
}
headers := success["headers"].(map[string]any)
- for _, name := range []string{"Cache-Control", "Connection", "X-Accel-Buffering"} {
+ for _, name := range []string{hdrCacheControl, "Connection", "X-Accel-Buffering"} {
if _, ok := headers[name]; !ok {
t.Fatalf("expected %s header in SSE response", name)
}
@@ -1208,12 +1222,12 @@ func TestSpecBuilder_Good_InfoIncludesLicenseMetadata(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := spec["info"].(map[string]any)
@@ -1239,12 +1253,12 @@ func TestSpecBuilder_Good_InfoIncludesSummary(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := spec["info"].(map[string]any)
@@ -1265,12 +1279,12 @@ func TestSpecBuilder_Good_InfoIncludesContactMetadata(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := spec["info"].(map[string]any)
@@ -1299,12 +1313,12 @@ func TestSpecBuilder_Good_InfoIncludesTermsOfService(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := spec["info"].(map[string]any)
@@ -1324,12 +1338,12 @@ func TestSpecBuilder_Good_InfoIncludesExternalDocs(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
externalDocs, ok := spec["externalDocs"].(map[string]any)
@@ -1397,12 +1411,12 @@ func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -1442,14 +1456,14 @@ func TestSpecBuilder_Good_WithDescribableGroup(t *testing.T) {
t.Fatal("expected requestBody on POST /api/items/create")
}
requestBody := postOp.(map[string]any)["requestBody"].(map[string]any)
- appJSON := requestBody["content"].(map[string]any)["application/json"].(map[string]any)
+ appJSON := requestBody["content"].(map[string]any)[mimeJSON].(map[string]any)
if appJSON["example"].(map[string]any)["name"] != "Widget" {
t.Fatalf("expected request example to be preserved, got %v", appJSON["example"])
}
responses := postOp.(map[string]any)["responses"].(map[string]any)
created := responses["200"].(map[string]any)
- createdJSON := created["content"].(map[string]any)["application/json"].(map[string]any)
+ createdJSON := created["content"].(map[string]any)[mimeJSON].(map[string]any)
if createdJSON["example"].(map[string]any)["id"] != float64(42) {
t.Fatalf("expected response example to be preserved, got %v", createdJSON["example"])
}
@@ -1467,7 +1481,7 @@ func TestSpecBuilder_Good_DescribeIterGroup(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Iter status",
Tags: []string{"iter"},
Response: map[string]any{
@@ -1479,12 +1493,12 @@ func TestSpecBuilder_Good_DescribeIterGroup(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/iter/status"].(map[string]any)["get"].(map[string]any)
@@ -1509,7 +1523,7 @@ func TestSpecBuilder_Good_DescribeIterSnapshotOnce(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Counted status",
Tags: []string{"counted"},
Response: map[string]any{
@@ -1521,12 +1535,12 @@ func TestSpecBuilder_Good_DescribeIterSnapshotOnce(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if group.describeCalls != 1 {
@@ -1551,7 +1565,7 @@ func TestSpecBuilder_Good_DescribeIterNilFallsBackToDescribe(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Fallback status",
Tags: []string{"fallback-iter"},
Response: map[string]any{
@@ -1563,12 +1577,12 @@ func TestSpecBuilder_Good_DescribeIterNilFallsBackToDescribe(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/fallback-iter/status"].(map[string]any)["get"].(map[string]any)
@@ -1587,7 +1601,7 @@ func TestSpecBuilder_Good_GroupMetadataIsSnapshottedOnce(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Snapshot status",
Response: map[string]any{
"type": "object",
@@ -1598,12 +1612,12 @@ func TestSpecBuilder_Good_GroupMetadataIsSnapshottedOnce(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -1683,23 +1697,23 @@ func TestSpecBuilder_Good_DeepClonesRouteMetadata(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/items"].(map[string]any)["post"].(map[string]any)
- requestSchema := op["requestBody"].(map[string]any)["content"].(map[string]any)["application/json"].(map[string]any)["schema"].(map[string]any)
+ requestSchema := op["requestBody"].(map[string]any)["content"].(map[string]any)[mimeJSON].(map[string]any)["schema"].(map[string]any)
if _, ok := requestSchema["mutated"]; ok {
t.Fatal("did not expect request body mutation to leak into the spec")
}
responses := op["responses"].(map[string]any)
resp201 := responses["200"].(map[string]any)
- appJSON := resp201["content"].(map[string]any)["application/json"].(map[string]any)
+ appJSON := resp201["content"].(map[string]any)[mimeJSON].(map[string]any)
responseSchema := appJSON["schema"].(map[string]any)["properties"].(map[string]any)["data"].(map[string]any)
if _, ok := responseSchema["mutated"]; ok {
t.Fatal("did not expect response mutation to leak into the spec")
@@ -1741,12 +1755,12 @@ func TestSpecBuilder_Good_SecuredResponses(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
responses := spec["paths"].(map[string]any)["/api/private"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)
@@ -1770,31 +1784,31 @@ func TestSpecBuilder_Good_SecuredResponses(t *testing.T) {
if _, ok := headers["Retry-After"]; !ok {
t.Fatal("expected Retry-After header in secured operation 429 response")
}
- if _, ok := headers["X-Request-ID"]; !ok {
+ if _, ok := headers[hdrXRequestID]; !ok {
t.Fatal("expected X-Request-ID header in secured operation 429 response")
}
- if _, ok := headers["X-RateLimit-Limit"]; !ok {
+ if _, ok := headers[hdrRateLimit]; !ok {
t.Fatal("expected X-RateLimit-Limit header in secured operation 429 response")
}
- if _, ok := headers["X-RateLimit-Remaining"]; !ok {
+ if _, ok := headers[hdrRateRemaining]; !ok {
t.Fatal("expected X-RateLimit-Remaining header in secured operation 429 response")
}
- if _, ok := headers["X-RateLimit-Reset"]; !ok {
+ if _, ok := headers[hdrRateReset]; !ok {
t.Fatal("expected X-RateLimit-Reset header in secured operation 429 response")
}
for _, code := range []string{"400", "401", "403", "504", "500"} {
resp := responses[code].(map[string]any)
respHeaders := resp["headers"].(map[string]any)
- if _, ok := respHeaders["X-Request-ID"]; !ok {
+ if _, ok := respHeaders[hdrXRequestID]; !ok {
t.Fatalf("expected X-Request-ID header in secured operation %s response", code)
}
- if _, ok := respHeaders["X-RateLimit-Limit"]; !ok {
+ if _, ok := respHeaders[hdrRateLimit]; !ok {
t.Fatalf("expected X-RateLimit-Limit header in secured operation %s response", code)
}
- if _, ok := respHeaders["X-RateLimit-Remaining"]; !ok {
+ if _, ok := respHeaders[hdrRateRemaining]; !ok {
t.Fatalf("expected X-RateLimit-Remaining header in secured operation %s response", code)
}
- if _, ok := respHeaders["X-RateLimit-Reset"]; !ok {
+ if _, ok := respHeaders[hdrRateReset]; !ok {
t.Fatalf("expected X-RateLimit-Reset header in secured operation %s response", code)
}
}
@@ -1824,12 +1838,12 @@ func TestSpecBuilder_Good_CustomSuccessStatusCode(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
responses := spec["paths"].(map[string]any)["/api/items"].(map[string]any)["post"].(map[string]any)["responses"].(map[string]any)
@@ -1873,12 +1887,12 @@ func TestSpecBuilder_Good_NoContentSuccessStatusCode(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
responses := spec["paths"].(map[string]any)["/api/items/{id}"].(map[string]any)["delete"].(map[string]any)["responses"].(map[string]any)
@@ -1903,7 +1917,7 @@ func TestSpecBuilder_Good_RouteSecurityOverrides(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/public",
+ Path: pathPublic,
Summary: "Public endpoint",
Security: []map[string][]string{},
Response: map[string]any{
@@ -1931,12 +1945,12 @@ func TestSpecBuilder_Good_RouteSecurityOverrides(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -1988,7 +2002,7 @@ func TestSpecBuilder_Good_AuthentikPublicPathsMakeMatchingOperationsPublic(t *te
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/public",
+ Path: pathPublic,
Summary: "Public endpoint",
Security: []map[string][]string{{"bearerAuth": []string{}}},
Response: map[string]any{
@@ -2000,12 +2014,12 @@ func TestSpecBuilder_Good_AuthentikPublicPathsMakeMatchingOperationsPublic(t *te
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/public"].(map[string]any)["get"].(map[string]any)
@@ -2026,7 +2040,7 @@ func TestSpecBuilder_Good_AuthentikPublicPathsMakeMatchingOperationsPublic(t *te
}
paths := spec["x-authentik-public-paths"].([]any)
- if len(paths) == 0 || paths[0] != "/health" {
+ if len(paths) == 0 || paths[0] != pathHealth {
t.Fatalf("expected public path extension to include /health first, got %v", paths)
}
}
@@ -2036,21 +2050,21 @@ func TestSpecBuilder_Good_AuthentikPublicPathsMakeBuiltInEndpointsPublic(t *test
Title: "Test",
Version: "1.0.0",
GraphQLEnabled: true,
- GraphQLPath: "/graphql",
- AuthentikPublicPaths: []string{"/graphql"},
+ GraphQLPath: pathGraphQL,
+ AuthentikPublicPaths: []string{pathGraphQL},
}
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
- pathItem := spec["paths"].(map[string]any)["/graphql"].(map[string]any)
+ pathItem := spec["paths"].(map[string]any)[pathGraphQL].(map[string]any)
for _, method := range []string{"get", "post"} {
op := pathItem[method].(map[string]any)
security, ok := op["security"].([]any)
@@ -2099,12 +2113,12 @@ func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -2113,23 +2127,23 @@ func TestSpecBuilder_Good_EnvelopeWrapping(t *testing.T) {
responses := getOp["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
headers := resp200["headers"].(map[string]any)
- if _, ok := headers["X-Request-ID"]; !ok {
+ if _, ok := headers[hdrXRequestID]; !ok {
t.Fatal("expected X-Request-ID header on 200 response")
}
- if _, ok := headers["X-RateLimit-Limit"]; !ok {
+ if _, ok := headers[hdrRateLimit]; !ok {
t.Fatal("expected X-RateLimit-Limit header on 200 response")
}
- if _, ok := headers["X-RateLimit-Remaining"]; !ok {
+ if _, ok := headers[hdrRateRemaining]; !ok {
t.Fatal("expected X-RateLimit-Remaining header on 200 response")
}
- if _, ok := headers["X-RateLimit-Reset"]; !ok {
+ if _, ok := headers[hdrRateReset]; !ok {
t.Fatal("expected X-RateLimit-Reset header on 200 response")
}
- if _, ok := headers["X-Cache"]; !ok {
+ if _, ok := headers[hdrXCache]; !ok {
t.Fatal("expected X-Cache header on 200 response")
}
content := resp200["content"].(map[string]any)
- appJSON := content["application/json"].(map[string]any)
+ appJSON := content[mimeJSON].(map[string]any)
schema := appJSON["schema"].(map[string]any)
if getOp["operationId"] != "get_data_fetch" {
t.Fatalf("expected operationId='get_data_fetch', got %v", getOp["operationId"])
@@ -2205,12 +2219,12 @@ func TestSpecBuilder_Good_OperationIDPreservesPathParams(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -2258,12 +2272,12 @@ func TestSpecBuilder_Good_RequestBodyOnDelete(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -2303,12 +2317,12 @@ func TestSpecBuilder_Good_RequestBodyOnHead(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -2345,17 +2359,17 @@ func TestSpecBuilder_Good_RequestExampleWithoutSchema(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
postOp := spec["paths"].(map[string]any)["/api/resources"].(map[string]any)["post"].(map[string]any)
requestBody := postOp["requestBody"].(map[string]any)
- appJSON := requestBody["content"].(map[string]any)["application/json"].(map[string]any)
+ appJSON := requestBody["content"].(map[string]any)[mimeJSON].(map[string]any)
if appJSON["example"].(map[string]any)["name"] != "Example resource" {
t.Fatalf("expected request example to be preserved, got %v", appJSON["example"])
@@ -2391,18 +2405,18 @@ func TestSpecBuilder_Good_ResponseExampleWithoutSchema(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
getOp := spec["paths"].(map[string]any)["/api/resources/{id}"].(map[string]any)["get"].(map[string]any)
responses := getOp["responses"].(map[string]any)
resp200 := responses["200"].(map[string]any)
- appJSON := resp200["content"].(map[string]any)["application/json"].(map[string]any)
+ appJSON := resp200["content"].(map[string]any)[mimeJSON].(map[string]any)
if appJSON["example"].(map[string]any)["name"] != "Example resource" {
t.Fatalf("expected response example to be preserved, got %v", appJSON["example"])
@@ -2433,8 +2447,8 @@ func TestSpecBuilder_Good_ResponseHeaders(t *testing.T) {
Path: "/exports/{id}",
Summary: "Download export",
ResponseHeaders: map[string]string{
- "Content-Disposition": "Download filename suggested by the server",
- "X-Export-ID": "Identifier for the generated export",
+ hdrContentDisp: "Download filename suggested by the server",
+ "X-Export-ID": "Identifier for the generated export",
},
Response: map[string]any{
"type": "object",
@@ -2445,12 +2459,12 @@ func TestSpecBuilder_Good_ResponseHeaders(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
responses := spec["paths"].(map[string]any)["/api/exports/{id}"].(map[string]any)["get"].(map[string]any)["responses"].(map[string]any)
@@ -2460,7 +2474,7 @@ func TestSpecBuilder_Good_ResponseHeaders(t *testing.T) {
t.Fatalf("expected headers map, got %T", resp200["headers"])
}
- header, ok := headers["Content-Disposition"].(map[string]any)
+ header, ok := headers[hdrContentDisp].(map[string]any)
if !ok {
t.Fatal("expected Content-Disposition response header to be documented")
}
@@ -2477,7 +2491,7 @@ func TestSpecBuilder_Good_ResponseHeaders(t *testing.T) {
if !ok {
t.Fatalf("expected 500 headers map, got %T", errorResp["headers"])
}
- if _, ok := errorHeaders["Content-Disposition"]; !ok {
+ if _, ok := errorHeaders[hdrContentDisp]; !ok {
t.Fatal("expected route-specific headers on error responses too")
}
if _, ok := errorHeaders["X-Export-ID"]; !ok {
@@ -2508,12 +2522,12 @@ func TestSpecBuilder_Good_PathParameters(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/users/{id}/{slug}"].(map[string]any)["get"].(map[string]any)
@@ -2565,12 +2579,12 @@ func TestSpecBuilder_Good_PathNormalisation(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -2610,12 +2624,12 @@ func TestSpecBuilder_Good_GinPathParameters(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -2680,12 +2694,12 @@ func TestSpecBuilder_Good_ExplicitParameters(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/users/{id}"].(map[string]any)["get"].(map[string]any)
@@ -2728,12 +2742,12 @@ func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{plainStubGroup{}})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
// Verify plainStubGroup appears in tags.
@@ -2755,10 +2769,10 @@ func TestSpecBuilder_Good_NonDescribableGroup(t *testing.T) {
if len(paths) != 1 {
t.Fatalf("expected 1 path (/health only), got %d", len(paths))
}
- if _, ok := paths["/health"]; !ok {
+ if _, ok := paths[pathHealth]; !ok {
t.Fatal("expected /health path in spec")
}
- health := paths["/health"].(map[string]any)["get"].(map[string]any)
+ health := paths[pathHealth].(map[string]any)["get"].(map[string]any)
if health["operationId"] != "get_health" {
t.Fatalf("expected operationId='get_health', got %v", health["operationId"])
}
@@ -2784,12 +2798,12 @@ func TestSpecBuilder_Good_EmptyDescribableGroupStillAddsTag(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags := spec["tags"].([]any)
@@ -2809,7 +2823,7 @@ func TestSpecBuilder_Good_EmptyDescribableGroupStillAddsTag(t *testing.T) {
if len(paths) != 1 {
t.Fatalf("expected only /health path, got %d paths", len(paths))
}
- if _, ok := paths["/health"]; !ok {
+ if _, ok := paths[pathHealth]; !ok {
t.Fatal("expected /health path in spec")
}
}
@@ -2826,7 +2840,7 @@ func TestSpecBuilder_Good_DefaultTagsFromGroupName(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Check status",
Response: map[string]any{
"type": "object",
@@ -2837,18 +2851,18 @@ func TestSpecBuilder_Good_DefaultTagsFromGroupName(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
operation := spec["paths"].(map[string]any)["/api/fallback/status"].(map[string]any)["get"].(map[string]any)
tags, ok := operation["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", operation["tags"])
+ t.Fatalf(fmtTestExpectedTags, operation["tags"])
}
if len(tags) != 1 || tags[0] != "fallback" {
t.Fatalf("expected fallback tag from group name, got %v", operation["tags"])
@@ -2867,7 +2881,7 @@ func TestSpecBuilder_Good_TagsAreSortedDeterministically(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Check status",
Tags: []string{"zeta", "alpha", "beta"},
Response: map[string]any{
@@ -2879,17 +2893,17 @@ func TestSpecBuilder_Good_TagsAreSortedDeterministically(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags, ok := spec["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", spec["tags"])
+ t.Fatalf(fmtTestExpectedTags, spec["tags"])
}
names := make([]string, 0, len(tags))
@@ -2922,7 +2936,7 @@ func TestSpecBuilder_Good_DeprecatedOperation(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Check legacy status",
Deprecated: true,
SunsetDate: "2025-06-01",
@@ -2936,12 +2950,12 @@ func TestSpecBuilder_Good_DeprecatedOperation(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/legacy/status"].(map[string]any)["get"].(map[string]any)
@@ -3005,7 +3019,7 @@ func TestSpecBuilder_Good_BlankTagsAreIgnored(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Check status",
Tags: []string{"", " ", "data", "data"},
Response: map[string]any{
@@ -3017,12 +3031,12 @@ func TestSpecBuilder_Good_BlankTagsAreIgnored(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags := spec["tags"].([]any)
@@ -3044,7 +3058,7 @@ func TestSpecBuilder_Good_BlankTagsAreIgnored(t *testing.T) {
op := spec["paths"].(map[string]any)["/api/blank/status"].(map[string]any)["get"].(map[string]any)
opTags, ok := op["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", op["tags"])
+ t.Fatalf(fmtTestExpectedTags, op["tags"])
}
if len(opTags) != 1 || opTags[0] != "data" {
t.Fatalf("expected operation tags to be cleaned to [data], got %v", opTags)
@@ -3063,7 +3077,7 @@ func TestSpecBuilder_Good_BlankRouteTagsFallBackToGroupName(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Check status",
Tags: []string{"", " "},
Response: map[string]any{
@@ -3075,18 +3089,18 @@ func TestSpecBuilder_Good_BlankRouteTagsFallBackToGroupName(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{group})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
op := spec["paths"].(map[string]any)["/api/fallback/status"].(map[string]any)["get"].(map[string]any)
tags, ok := op["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", op["tags"])
+ t.Fatalf(fmtTestExpectedTags, op["tags"])
}
if len(tags) != 1 || tags[0] != "fallback" {
t.Fatalf("expected blank route tags to fall back to group name, got %v", tags)
@@ -3105,7 +3119,7 @@ func TestSpecBuilder_Good_HiddenRoutesAreOmitted(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/public",
+ Path: pathPublic,
Summary: "Public endpoint",
Tags: []string{"public"},
Response: map[string]any{
@@ -3132,7 +3146,7 @@ func TestSpecBuilder_Good_HiddenRoutesAreOmitted(t *testing.T) {
descs: []api.RouteDescription{
{
Method: "GET",
- Path: "/status",
+ Path: pathStatus,
Summary: "Hidden group endpoint",
Tags: []string{"hidden"},
Response: map[string]any{
@@ -3144,12 +3158,12 @@ func TestSpecBuilder_Good_HiddenRoutesAreOmitted(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{visible, hidden})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
@@ -3247,17 +3261,17 @@ func TestSpecBuilder_Good_ToolBridgeIntegration(t *testing.T) {
data, err := sb.Build([]api.RouteGroup{bridge})
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
tags, ok := spec["tags"].([]any)
if !ok {
- t.Fatalf("expected tags array, got %T", spec["tags"])
+ t.Fatalf(fmtTestExpectedTags, spec["tags"])
}
expectedTags := map[string]bool{
"system": true,
@@ -3326,12 +3340,12 @@ func TestSpecBuilder_Bad_InfoFields(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := spec["info"].(map[string]any)
@@ -3354,18 +3368,18 @@ func TestSpecBuilder_Good_Servers(t *testing.T) {
" https://api.example.com ",
"/",
"",
- "https://api.example.com",
+ apiBaseURL,
},
}
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
servers, ok := spec["servers"].([]any)
@@ -3377,8 +3391,8 @@ func TestSpecBuilder_Good_Servers(t *testing.T) {
}
first := servers[0].(map[string]any)
- if first["url"] != "https://api.example.com" {
- t.Fatalf("expected first server url=%q, got %v", "https://api.example.com", first["url"])
+ if first["url"] != apiBaseURL {
+ t.Fatalf("expected first server url=%q, got %v", apiBaseURL, first["url"])
}
second := servers[1].(map[string]any)
if second["url"] != "/" {
@@ -3392,7 +3406,7 @@ func TestSpecBuilder_Good_ServersCollapseTrailingSlashes(t *testing.T) {
Version: "1.0.0",
Servers: []string{
"https://api.example.com/",
- "https://api.example.com",
+ apiBaseURL,
"/api/",
"/api",
},
@@ -3400,12 +3414,12 @@ func TestSpecBuilder_Good_ServersCollapseTrailingSlashes(t *testing.T) {
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
servers, ok := spec["servers"].([]any)
@@ -3417,8 +3431,8 @@ func TestSpecBuilder_Good_ServersCollapseTrailingSlashes(t *testing.T) {
}
first := servers[0].(map[string]any)
- if first["url"] != "https://api.example.com" {
- t.Fatalf("expected first server url=%q, got %v", "https://api.example.com", first["url"])
+ if first["url"] != apiBaseURL {
+ t.Fatalf("expected first server url=%q, got %v", apiBaseURL, first["url"])
}
second := servers[1].(map[string]any)
if second["url"] != "/api" {
@@ -3436,22 +3450,22 @@ func TestSpecBuilder_Good_RuntimeDebugEndpointsDocumentRateLimitHeaders(t *testi
data, err := sb.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := spec["paths"].(map[string]any)
- for _, path := range []string{"/debug/pprof", "/debug/vars"} {
+ for _, path := range []string{pathDebugPprof, pathDebugVars} {
item := paths[path].(map[string]any)
op := item["get"].(map[string]any)
for _, code := range []string{"200", "401", "403"} {
resp := op["responses"].(map[string]any)[code].(map[string]any)
headers := resp["headers"].(map[string]any)
- for _, name := range []string{"X-Request-ID", "X-RateLimit-Limit", "X-RateLimit-Remaining", "X-RateLimit-Reset"} {
+ for _, name := range []string{hdrXRequestID, hdrRateLimit, hdrRateRemaining, hdrRateReset} {
if _, ok := headers[name]; !ok {
t.Fatalf("expected %s header on %s %s response", name, path, code)
}
diff --git a/go/openapitools.json b/go/openapitools.json
new file mode 100644
index 0000000..0f7e625
--- /dev/null
+++ b/go/openapitools.json
@@ -0,0 +1,7 @@
+{
+ "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
+ "spaces": 2,
+ "generator-cli": {
+ "version": "7.22.0"
+ }
+}
diff --git a/go/options.go b/go/options.go
index b6e96e2..39844dd 100644
--- a/go/options.go
+++ b/go/options.go
@@ -45,6 +45,66 @@ func WithAddr(addr string) Option {
}
}
+// WithStrictBind enables strict bind enforcement at Serve time. It is opt-in
+// and OFF by default, so existing consumers that bind non-loopback addresses
+// keep their historic behaviour. When strict mode is on, Serve:
+//
+// - serves a loopback address unconditionally;
+// - rejects a non-loopback address with ErrNonLoopbackBind unless
+// WithPublicBind is also set;
+// - rejects a public (non-loopback) bind with ErrPublicBindNoBearer unless a
+// bearer credential was supplied via WithBearerAuth.
+//
+// The check runs before the listener opens, so a misconfigured strict engine
+// fails fast rather than exposing an unauthenticated public listener.
+//
+// Example:
+//
+// engine, _ := api.New(
+// api.WithAddr("127.0.0.1:8787"),
+// api.WithStrictBind(),
+// )
+func WithStrictBind() Option {
+ return func(e *Engine) {
+ e.strictBind = true
+ }
+}
+
+// WithLoopbackOnly is an alias for WithStrictBind without WithPublicBind: it
+// turns on strict mode so any non-loopback bind is rejected. Use it when a
+// consumer must never serve off the loopback interface.
+//
+// Example:
+//
+// engine, _ := api.New(
+// api.WithAddr("127.0.0.1:8787"),
+// api.WithLoopbackOnly(),
+// )
+func WithLoopbackOnly() Option {
+ return func(e *Engine) {
+ e.strictBind = true
+ }
+}
+
+// WithPublicBind is the explicit opt-in that allows a non-loopback bind under
+// strict mode. It has no effect unless WithStrictBind / WithLoopbackOnly is
+// also set. A public bind still requires a bearer credential via
+// WithBearerAuth — WithPublicBind alone does not relax that requirement.
+//
+// Example:
+//
+// engine, _ := api.New(
+// api.WithAddr("0.0.0.0:8787"),
+// api.WithStrictBind(),
+// api.WithPublicBind(),
+// api.WithBearerAuth(token),
+// )
+func WithPublicBind() Option {
+ return func(e *Engine) {
+ e.publicBind = true
+ }
+}
+
// WithHTTP3 enables HTTP/3 advertisement and configures the QUIC listen
// address used by ServeH3. Pass an empty address to reuse the main HTTP
// address at serve time.
@@ -90,6 +150,10 @@ func WithNoRoute(h gin.HandlerFunc) Option {
// api.New(api.WithBearerAuth("secret"))
func WithBearerAuth(token string) Option {
return func(e *Engine) {
+ if core.Trim(token) != "" {
+ e.bearerConfigured = true
+ e.bearerToken = token
+ }
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, func() []string {
skip := []string{"/health"}
if swaggerPath := resolveSwaggerPath(e.swaggerPath); swaggerPath != "" {
@@ -141,7 +205,7 @@ 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"},
+ AllowHeaders: []string{"Authorization", hdrContentType, "X-Request-ID"},
MaxAge: 12 * time.Hour,
}
@@ -785,6 +849,47 @@ func WithChatCompletionsPath(path string) Option {
}
}
+// WithUpstreamRouter mounts a selector-keyed reverse proxy that load-balances
+// each request across a runtime-mutable pool of HTTP upstreams (weighted
+// round-robin + passive failover, hybrid streaming, decision hook, transformer
+// composition). The registry is the source of truth for upstreams.
+//
+// v1 caveat: the balancer retains one small state entry per distinct routing key
+// it sees, so with a default pool set (SetDefault) attacker-chosen keys can grow
+// that map unbounded; a bounded/LRU keyspace is a future hardening.
+//
+// Middleware composition: engine middleware that runs BEFORE the handler
+// (authentication, rate limiting, CORS, request validation) composes normally —
+// it gates the request before the proxy dispatches. Middleware that mutates
+// RESPONSE headers AFTER the handler (post-c.Next(), e.g. ApiSunset / WithSunset)
+// does NOT apply to proxied responses, because the reverse proxy writes the full
+// response during the handler, before the post-Next phase runs.
+//
+// Example:
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+// _ = reg.Set("lemma", api.Upstream{URL: "http://127.0.0.1:11434"})
+// engine, _ := api.New(api.WithUpstreamRouter(reg))
+func WithUpstreamRouter(reg *UpstreamRegistry, opts ...UpstreamRouterOption) Option {
+ return func(e *Engine) {
+ if reg == nil {
+ return
+ }
+ cfg := &upstreamRouterConfig{registry: reg}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(cfg)
+ }
+ }
+ if err := cfg.finalise(); err != nil {
+ // Transformer compile errors mirror the panic contract used by
+ // transformerRouteConfigForDescription (transformer_in.go:78).
+ panic(err)
+ }
+ e.upstreamRouter = cfg
+ }
+}
+
// WithSDKGen mounts POST /v1/sdk/generate. The endpoint exposes the RFC SDK
// generation contract and currently returns 501 until an artifact backend is
// configured around SDKGenerator.
@@ -794,6 +899,66 @@ func WithSDKGen() Option {
}
}
+// WithChatCompletionsRemote attaches a remote backend to /v1/chat/completions.
+// Compose with WithChatCompletions for hybrid (local-first); use alone for
+// remote-only. Models with no WithChatModelAdapter are forwarded verbatim
+// (OpenAI passthrough); adapters map non-OpenAI upstreams (see chat_adapter.go).
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("10.0.0.0/8"))
+// _ = reg.SetDefault(api.Upstream{URL: "https://llm.lthn.sh"})
+// api.New(api.WithChatCompletions(local), api.WithChatCompletionsRemote(reg))
+func WithChatCompletionsRemote(reg *UpstreamRegistry, opts ...ChatRemoteOption) Option {
+ return func(e *Engine) {
+ if reg == nil {
+ return
+ }
+ cfg := &chatRemoteConfig{reg: reg, adapters: map[string]ChatFormatAdapter{}}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(cfg)
+ }
+ }
+ cfg.finalise()
+ e.chatRemote = cfg
+ }
+}
+
+// ChatRemoteOption configures the chat remote backend.
+type ChatRemoteOption func(*chatRemoteConfig)
+
+// WithChatModelAdapter maps a model name to a non-OpenAI format adapter.
+func WithChatModelAdapter(model string, a ChatFormatAdapter) ChatRemoteOption {
+ return func(cfg *chatRemoteConfig) {
+ if core.Trim(model) != "" && a != nil {
+ cfg.adapters[model] = a
+ }
+ }
+}
+
+// WithChatRemoteFailover sets max upstream attempts + per-upstream cooldown for
+// the remote backend (default: len(pool), 10s).
+func WithChatRemoteFailover(maxAttempts int, cooldown time.Duration) ChatRemoteOption {
+ return func(cfg *chatRemoteConfig) {
+ cfg.maxAttempts = maxAttempts
+ if cooldown > 0 {
+ cfg.cooldown = cooldown
+ }
+ }
+}
+
+// WithChatRemoteTransport sets the base RoundTripper for remote dispatch.
+func WithChatRemoteTransport(rt http.RoundTripper) ChatRemoteOption {
+ return func(cfg *chatRemoteConfig) { cfg.transport = rt }
+}
+
+// WithChatCompletionsAllowRemoteClients permits non-loopback clients on the chat
+// endpoint, but ONLY when a bearer is configured (WithBearerAuth) — mirrors the
+// engine's ErrPublicBindNoBearer invariant. Without it, the endpoint stays
+// loopback-only. Pair with an auth-guarded route for real enforcement.
+func WithChatCompletionsAllowRemoteClients() Option {
+ return func(e *Engine) { e.chatAllowRemote = true }
+}
+
// WithOpenAPISpec mounts a standalone JSON document endpoint at
// "/v1/openapi.json" (RFC.endpoints.md — "GET /v1/openapi.json"). The generated
// spec mirrors the document surfaced by the Swagger UI but is served
diff --git a/go/pkg/provider/cache_control_test.go b/go/pkg/provider/cache_control_test.go
index fbe9aa4..d58d8c0 100644
--- a/go/pkg/provider/cache_control_test.go
+++ b/go/pkg/provider/cache_control_test.go
@@ -12,6 +12,11 @@ import (
"github.com/gin-gonic/gin"
)
+const (
+ cacheControlHeader = "Cache-Control"
+ cacheItemsPath = "/items/:id"
+)
+
type cacheControlProvider struct {
basePath string
withDescriptions bool
@@ -22,9 +27,9 @@ func (p *cacheControlProvider) Name() string { return "cache-control" }
func (p *cacheControlProvider) BasePath() string { return p.basePath }
func (p *cacheControlProvider) RegisterRoutes(rg *gin.RouterGroup) {
- rg.GET("/items/:id", func(c *gin.Context) {
+ rg.GET(cacheItemsPath, func(c *gin.Context) {
if p.overrideCacheControl != "" {
- c.Header("Cache-Control", p.overrideCacheControl)
+ c.Header(cacheControlHeader, p.overrideCacheControl)
}
c.String(http.StatusOK, "ok")
})
@@ -41,7 +46,7 @@ func (p *cacheControlProvider) Describe() []api.RouteDescription {
return []api.RouteDescription{
{
Method: http.MethodGet,
- Path: "/items/{id}",
+ Path: cacheItemsPath,
Summary: "Fetch an item",
CacheControl: "public, max-age=300",
},
@@ -63,7 +68,7 @@ func (p *undescribedCacheControlProvider) Name() string { return "plain-cach
func (p *undescribedCacheControlProvider) BasePath() string { return p.basePath }
func (p *undescribedCacheControlProvider) RegisterRoutes(rg *gin.RouterGroup) {
- rg.GET("/items/:id", func(c *gin.Context) {
+ rg.GET(cacheItemsPath, func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
}
@@ -93,12 +98,12 @@ func TestCacheControl_MountAll_Good_AppliesDescribedPolicies(t *T) {
getRec := httptest.NewRecorder()
getReq := httptest.NewRequest(http.MethodGet, "/api/cache/items/123", nil)
handler.ServeHTTP(getRec, getReq)
- AssertEqual(t, "public, max-age=300", getRec.Header().Get("Cache-Control"))
+ AssertEqual(t, "public, max-age=300", getRec.Header().Get(cacheControlHeader))
postRec := httptest.NewRecorder()
postReq := httptest.NewRequest(http.MethodPost, "/api/cache/sessions", nil)
handler.ServeHTTP(postRec, postReq)
- AssertEqual(t, "no-store", postRec.Header().Get("Cache-Control"))
+ AssertEqual(t, "no-store", postRec.Header().Get(cacheControlHeader))
}
func TestCacheControl_MountAll_Bad_SkipsProvidersWithoutDescriptions(t *T) {
@@ -111,7 +116,7 @@ func TestCacheControl_MountAll_Bad_SkipsProvidersWithoutDescriptions(t *T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/plain/items/123", nil)
handler.ServeHTTP(rec, req)
- AssertEqual(t, "", rec.Header().Get("Cache-Control"))
+ AssertEqual(t, "", rec.Header().Get(cacheControlHeader))
}
func TestCacheControl_MountAll_Ugly_PreservesExplicitHandlerHeaders(t *T) {
@@ -126,5 +131,5 @@ func TestCacheControl_MountAll_Ugly_PreservesExplicitHandlerHeaders(t *T) {
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/override/items/123", nil)
handler.ServeHTTP(rec, req)
- AssertEqual(t, "private, no-store", rec.Header().Get("Cache-Control"))
+ AssertEqual(t, "private, no-store", rec.Header().Get(cacheControlHeader))
}
diff --git a/go/pkg/provider/discovery_test.go b/go/pkg/provider/discovery_test.go
index 7c55734..5e6e718 100644
--- a/go/pkg/provider/discovery_test.go
+++ b/go/pkg/provider/discovery_test.go
@@ -11,6 +11,11 @@ import (
"dappco.re/go/api/pkg/provider"
)
+const (
+ providersDirName = "providers"
+ coreDirName = ".core"
+)
+
func TestDiscover_Good_LoadsYAMLProxyProvider(t *T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -18,7 +23,7 @@ func TestDiscover_Good_LoadsYAMLProxyProvider(t *T) {
}))
defer upstream.Close()
- dir := PathJoin(t.TempDir(), ".core", "providers")
+ dir := PathJoin(t.TempDir(), coreDirName, providersDirName)
RequireNoError(t, coreMkdirAll(dir, 0755))
specPath := PathJoin(PathDir(dir), "specs", "openapi.yaml")
RequireNoError(t, coreMkdirAll(PathDir(specPath), 0755))
@@ -67,13 +72,13 @@ element:
}
func TestDiscover_Good_MissingDirIsEmpty(t *T) {
- providers, err := provider.Discover(PathJoin(t.TempDir(), ".core", "providers"))
+ providers, err := provider.Discover(PathJoin(t.TempDir(), coreDirName, providersDirName))
RequireNoError(t, err)
AssertEmpty(t, providers)
}
func TestDiscover_Good_LoadsYAMLProvidersFromCleanDir(t *T) {
- dir := PathJoin(t.TempDir(), ".core", "providers")
+ dir := PathJoin(t.TempDir(), coreDirName, providersDirName)
RequireNoError(t, coreMkdirAll(dir, 0755))
upstream := newDiscoveryUpstream(t)
@@ -90,18 +95,18 @@ func TestDiscover_Good_LoadsYAMLProvidersFromCleanDir(t *T) {
func TestDiscover_Good_DirWithDotDotSegmentResolves(t *T) {
root := t.TempDir()
- dir := PathJoin(root, "providers")
+ dir := PathJoin(root, providersDirName)
RequireNoError(t, coreMkdirAll(dir, 0755))
writeProviderManifest(t, dir, "dotdot", newDiscoveryUpstream(t))
- providers, err := provider.Discover(PathJoin(root, "other", "..", "providers"))
+ providers, err := provider.Discover(PathJoin(root, "other", "..", providersDirName))
RequireNoError(t, err)
AssertLen(t, providers, 1)
AssertEqual(t, "dotdot", providers[0].Name())
}
func TestDiscover_Bad_InvalidManifest(t *T) {
- dir := PathJoin(t.TempDir(), ".core", "providers")
+ dir := PathJoin(t.TempDir(), coreDirName, providersDirName)
RequireNoError(t, coreMkdirAll(dir, 0755))
RequireNoError(t, coreWriteFile(PathJoin(dir, "broken.yaml"), []byte(`
name: broken
@@ -117,7 +122,7 @@ basePath: /api/broken
func TestDiscover_Bad_SymlinkedDirRefused(t *T) {
root := t.TempDir()
realDir := PathJoin(root, "real-providers")
- linkDir := PathJoin(root, "providers")
+ linkDir := PathJoin(root, providersDirName)
RequireNoError(t, coreMkdirAll(realDir, 0755))
if err := coreSymlink(realDir, linkDir); err != nil {
t.Skipf("symlink unavailable: %v", err)
@@ -131,7 +136,7 @@ func TestDiscover_Bad_SymlinkedDirRefused(t *T) {
func TestDiscover_Bad_SymlinkManifestOutsideDirRefused(t *T) {
root := t.TempDir()
- dir := PathJoin(root, "providers")
+ dir := PathJoin(root, providersDirName)
RequireNoError(t, coreMkdirAll(dir, 0755))
outside := PathJoin(root, "outside.yaml")
RequireNoError(t, coreWriteFile(outside, []byte("not: loaded\n"), 0644))
@@ -146,7 +151,7 @@ func TestDiscover_Bad_SymlinkManifestOutsideDirRefused(t *T) {
}
func TestDiscover_Bad_SymlinkManifestWithinDirRefused(t *T) {
- dir := PathJoin(t.TempDir(), "providers")
+ dir := PathJoin(t.TempDir(), providersDirName)
RequireNoError(t, coreMkdirAll(dir, 0755))
realManifest := writeProviderManifest(t, dir, "real", newDiscoveryUpstream(t))
if err := coreSymlink(realManifest, PathJoin(dir, "alias.yaml")); err != nil {
diff --git a/go/pkg/provider/proxy_test.go b/go/pkg/provider/proxy_test.go
index a848df6..13bb815 100644
--- a/go/pkg/provider/proxy_test.go
+++ b/go/pkg/provider/proxy_test.go
@@ -12,16 +12,25 @@ import (
"dappco.re/go/api/pkg/provider"
)
-func TestMain(m *testing.M) {
- const env = "CORE_PROVIDER_UPSTREAM_ALLOW"
+const (
+ proxyCoolWidgetName = "cool-widget"
+ proxyCoolWidgetPath = "/api/v1/cool-widget"
+ proxyLoopbackURL = "http://127.0.0.1:9999"
+ proxyTestName = "test-proxy"
+ proxyTestPath = "/api/v1/test-proxy"
+ proxyBlockedName = "blocked"
+ proxyJSONContentType = "application/json"
+ envUpstreamAllow = "CORE_PROVIDER_UPSTREAM_ALLOW"
+)
- previous, hadPrevious := LookupEnv(env)
- _ = coreSetenv(env, "127.0.0.0/8,::1/128")
+func TestMain(m *testing.M) {
+ previous, hadPrevious := LookupEnv(envUpstreamAllow)
+ _ = coreSetenv(envUpstreamAllow, "127.0.0.0/8,::1/128")
code := m.Run()
if hadPrevious {
- _ = coreSetenv(env, previous)
+ _ = coreSetenv(envUpstreamAllow, previous)
} else {
- _ = coreUnsetenv(env)
+ _ = coreUnsetenv(envUpstreamAllow)
}
Exit(code)
}
@@ -30,20 +39,20 @@ func TestMain(m *testing.M) {
func TestProxyProvider_Name_Good(t *T) {
p := provider.NewProxy(provider.ProxyConfig{
- Name: "cool-widget",
- BasePath: "/api/v1/cool-widget",
- Upstream: "http://127.0.0.1:9999",
+ Name: proxyCoolWidgetName,
+ BasePath: proxyCoolWidgetPath,
+ Upstream: proxyLoopbackURL,
})
- AssertEqual(t, "cool-widget", p.Name())
+ AssertEqual(t, proxyCoolWidgetName, p.Name())
}
func TestProxyProvider_BasePath_Good(t *T) {
p := provider.NewProxy(provider.ProxyConfig{
- Name: "cool-widget",
- BasePath: "/api/v1/cool-widget",
- Upstream: "http://127.0.0.1:9999",
+ Name: proxyCoolWidgetName,
+ BasePath: proxyCoolWidgetPath,
+ Upstream: proxyLoopbackURL,
})
- AssertEqual(t, "/api/v1/cool-widget", p.BasePath())
+ AssertEqual(t, proxyCoolWidgetPath, p.BasePath())
}
func TestProxyProvider_Element_Good(t *T) {
@@ -52,9 +61,9 @@ func TestProxyProvider_Element_Good(t *T) {
Source: "/assets/cool-widget.js",
}
p := provider.NewProxy(provider.ProxyConfig{
- Name: "cool-widget",
- BasePath: "/api/v1/cool-widget",
- Upstream: "http://127.0.0.1:9999",
+ Name: proxyCoolWidgetName,
+ BasePath: proxyCoolWidgetPath,
+ Upstream: proxyLoopbackURL,
Element: elem,
})
AssertEqual(t, "core-cool-widget", p.Element().Tag)
@@ -63,9 +72,9 @@ func TestProxyProvider_Element_Good(t *T) {
func TestProxyProvider_SpecFile_Good(t *T) {
p := provider.NewProxy(provider.ProxyConfig{
- Name: "cool-widget",
- BasePath: "/api/v1/cool-widget",
- Upstream: "http://127.0.0.1:9999",
+ Name: proxyCoolWidgetName,
+ BasePath: proxyCoolWidgetPath,
+ Upstream: proxyLoopbackURL,
SpecFile: "/tmp/openapi.json",
})
AssertEqual(t, "/tmp/openapi.json", p.SpecFile())
@@ -78,7 +87,7 @@ func TestProxyProviderProxyForwards(t *T) {
`path`: r.URL.Path,
"method": r.Method,
}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Type", proxyJSONContentType)
coreJSONEncode(w, resp)
}))
defer upstream.Close()
@@ -116,7 +125,7 @@ func TestProxyProviderProxyForwards(t *T) {
func TestProxyProviderProxyRootForwards(t *T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
resp := map[string]string{`path`: r.URL.Path}
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Type", proxyJSONContentType)
coreJSONEncode(w, resp)
}))
defer upstream.Close()
@@ -149,7 +158,7 @@ func TestProxyProviderProxyRootForwards(t *T) {
func TestProxyProviderHealthPassthrough(t *T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
- w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Content-Type", proxyJSONContentType)
w.Write([]byte(`{"status":"ok"}`))
return
}
@@ -182,7 +191,7 @@ func TestProxyProvider_Renderable_Good(t *T) {
p := provider.NewProxy(provider.ProxyConfig{
Name: "renderable-proxy",
BasePath: "/api/v1/renderable",
- Upstream: "http://127.0.0.1:9999",
+ Upstream: proxyLoopbackURL,
Element: provider.ElementSpec{Tag: "core-test-panel", Source: "/assets/test.js"},
})
@@ -226,7 +235,7 @@ func TestProxyProvider_Ugly_InvalidUpstream(t *T) {
}
func TestProxyProvider_NewProxy_Good_PublicUpstream(t *T) {
- t.Setenv("CORE_PROVIDER_UPSTREAM_ALLOW", "")
+ t.Setenv(envUpstreamAllow, "")
p := provider.NewProxy(provider.ProxyConfig{
Name: "public",
@@ -239,28 +248,28 @@ func TestProxyProvider_NewProxy_Good_PublicUpstream(t *T) {
}
func TestProxyProvider_NewProxy_Bad_BlocksMetadataIP(t *T) {
- t.Setenv("CORE_PROVIDER_UPSTREAM_ALLOW", "")
+ t.Setenv(envUpstreamAllow, "")
err := assertProviderUpstreamBlocked(t, "http://169.254.169.254/x")
- AssertContains(t, err.Error(), "blocked")
+ AssertContains(t, err.Error(), proxyBlockedName)
}
func TestProxyProvider_NewProxy_Bad_BlocksLoopback(t *T) {
- t.Setenv("CORE_PROVIDER_UPSTREAM_ALLOW", "")
+ t.Setenv(envUpstreamAllow, "")
err := assertProviderUpstreamBlocked(t, "http://127.0.0.1:5432/")
- AssertContains(t, err.Error(), "blocked")
+ AssertContains(t, err.Error(), proxyBlockedName)
}
func TestProxyProvider_NewProxy_Bad_BlocksRFC1918(t *T) {
- t.Setenv("CORE_PROVIDER_UPSTREAM_ALLOW", "")
+ t.Setenv(envUpstreamAllow, "")
err := assertProviderUpstreamBlocked(t, "http://10.0.0.1/x")
- AssertContains(t, err.Error(), "blocked")
+ AssertContains(t, err.Error(), proxyBlockedName)
}
func TestProxyProvider_NewProxy_Good_AllowListPermitsLoopback(t *T) {
- t.Setenv("CORE_PROVIDER_UPSTREAM_ALLOW", "127.0.0.0/8")
+ t.Setenv(envUpstreamAllow, "127.0.0.0/8")
p := provider.NewProxy(provider.ProxyConfig{
Name: "allowed-loopback",
@@ -273,24 +282,24 @@ func TestProxyProvider_NewProxy_Good_AllowListPermitsLoopback(t *T) {
}
func TestProxyProvider_NewProxy_Bad_AllowListDoesNotPermitOtherPrivateCIDRs(t *T) {
- t.Setenv("CORE_PROVIDER_UPSTREAM_ALLOW", "127.0.0.0/8")
+ t.Setenv(envUpstreamAllow, "127.0.0.0/8")
err := assertProviderUpstreamBlocked(t, "http://10.0.0.1/")
- AssertContains(t, err.Error(), "blocked")
+ AssertContains(t, err.Error(), proxyBlockedName)
}
func TestProxyProvider_NewProxy_Bad_BlocksHostnameResolvingToLoopback(t *T) {
- t.Setenv("CORE_PROVIDER_UPSTREAM_ALLOW", "")
+ t.Setenv(envUpstreamAllow, "")
err := assertProviderUpstreamBlocked(t, "http://localhost:5432/")
- AssertContains(t, err.Error(), "blocked")
+ AssertContains(t, err.Error(), proxyBlockedName)
}
func assertProviderUpstreamBlocked(t *T, upstream string) error {
t.Helper()
p := provider.NewProxy(provider.ProxyConfig{
- Name: "blocked",
+ Name: proxyBlockedName,
BasePath: "/api/v1/blocked",
Upstream: upstream,
})
diff --git a/go/pkg/provider/registry_test.go b/go/pkg/provider/registry_test.go
index bd03cc8..3148fb4 100644
--- a/go/pkg/provider/registry_test.go
+++ b/go/pkg/provider/registry_test.go
@@ -9,30 +9,38 @@ import (
"github.com/gin-gonic/gin"
)
+const (
+ stubName = "stub"
+ stubEventChannel = "stub.event"
+ stubElementTag = "core-stub-panel"
+ fullName = "full"
+ fullElementTag = "core-full-panel"
+)
+
// -- Test helpers (minimal providers) -----------------------------------------
type stubProvider struct{}
-func (s *stubProvider) Name() string { return "stub" }
+func (s *stubProvider) Name() string { return stubName }
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"} }
+func (s *streamableProvider) Channels() []string { return []string{stubEventChannel} }
type describableProvider struct{ stubProvider }
func (d *describableProvider) Describe() []api.RouteDescription {
return []api.RouteDescription{
- {Method: "GET", Path: "/items", Summary: "List items", Tags: []string{"stub"}},
+ {Method: "GET", Path: "/items", Summary: "List items", Tags: []string{stubName}},
}
}
type renderableProvider struct{ stubProvider }
func (r *renderableProvider) Element() provider.ElementSpec {
- return provider.ElementSpec{Tag: "core-stub-panel", Source: "/assets/stub.js"}
+ return provider.ElementSpec{Tag: stubElementTag, Source: "/assets/stub.js"}
}
type specFileProvider struct {
@@ -46,15 +54,15 @@ type fullProvider struct {
streamableProvider
}
-func (f *fullProvider) Name() string { return "full" }
+func (f *fullProvider) Name() string { return fullName }
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"}},
+ {Method: "GET", Path: "/status", Summary: "Status", Tags: []string{fullName}},
}
}
func (f *fullProvider) Element() provider.ElementSpec {
- return provider.ElementSpec{Tag: "core-full-panel", Source: "/assets/full.js"}
+ return provider.ElementSpec{Tag: fullElementTag, Source: "/assets/full.js"}
}
// -- Tests --------------------------------------------------------------------
@@ -74,9 +82,9 @@ func TestRegistry_Get_Good(t *T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
- p := reg.Get("stub")
+ p := reg.Get(stubName)
AssertNotNil(t, p)
- AssertEqual(t, "stub", p.Name())
+ AssertEqual(t, stubName, p.Name())
}
func TestRegistry_Get_Bad(t *T) {
@@ -113,7 +121,7 @@ func TestRegistry_Streamable_Good(t *T) {
s := reg.Streamable()
AssertLen(t, s, 1)
- AssertEqual(t, []string{"stub.event"}, s[0].Channels())
+ AssertEqual(t, []string{stubEventChannel}, s[0].Channels())
}
func TestRegistry_StreamableIter_Good(t *T) {
@@ -193,7 +201,7 @@ func TestRegistry_Renderable_Good(t *T) {
r := reg.Renderable()
AssertLen(t, r, 1)
- AssertEqual(t, "core-stub-panel", r[0].Element().Tag)
+ AssertEqual(t, stubElementTag, r[0].Element().Tag)
}
func TestRegistry_RenderableIter_Good(t *T) {
@@ -207,7 +215,7 @@ func TestRegistry_RenderableIter_Good(t *T) {
}
AssertLen(t, renderables, 1)
- AssertEqual(t, "core-stub-panel", renderables[0].Element().Tag)
+ AssertEqual(t, stubElementTag, renderables[0].Element().Tag)
}
func TestRegistry_RenderableIter_Good_SnapshotCurrentProviders(t *T) {
@@ -223,7 +231,7 @@ func TestRegistry_RenderableIter_Good_SnapshotCurrentProviders(t *T) {
}
AssertLen(t, renderables, 1)
- AssertEqual(t, "core-stub-panel", renderables[0].Element().Tag)
+ AssertEqual(t, stubElementTag, renderables[0].Element().Tag)
}
func TestRegistry_Info_Good(t *T) {
@@ -234,11 +242,11 @@ func TestRegistry_Info_Good(t *T) {
AssertLen(t, infos, 1)
info := infos[0]
- AssertEqual(t, "full", info.Name)
+ AssertEqual(t, fullName, info.Name)
AssertEqual(t, "/api/full", info.BasePath)
- AssertEqual(t, []string{"stub.event"}, info.Channels)
+ AssertEqual(t, []string{stubEventChannel}, info.Channels)
AssertNotNil(t, info.Element)
- AssertEqual(t, "core-full-panel", info.Element.Tag)
+ AssertEqual(t, fullElementTag, info.Element.Tag)
}
func TestRegistry_Info_Good_ProxyMetadata(t *T) {
@@ -271,11 +279,11 @@ func TestRegistry_InfoIter_Good(t *T) {
AssertLen(t, infos, 1)
info := infos[0]
- AssertEqual(t, "full", info.Name)
+ AssertEqual(t, fullName, info.Name)
AssertEqual(t, "/api/full", info.BasePath)
- AssertEqual(t, []string{"stub.event"}, info.Channels)
+ AssertEqual(t, []string{stubEventChannel}, info.Channels)
AssertNotNil(t, info.Element)
- AssertEqual(t, "core-full-panel", info.Element.Tag)
+ AssertEqual(t, fullElementTag, info.Element.Tag)
}
func TestRegistry_InfoIter_Good_SnapshotCurrentProviders(t *T) {
@@ -291,7 +299,7 @@ func TestRegistry_InfoIter_Good_SnapshotCurrentProviders(t *T) {
}
AssertLen(t, infos, 1)
- AssertEqual(t, "full", infos[0].Name)
+ AssertEqual(t, fullName, infos[0].Name)
}
func TestRegistry_Iter_Good(t *T) {
diff --git a/go/pkg/stream/stream_group_test.go b/go/pkg/stream/stream_group_test.go
index c857d87..520f9a4 100644
--- a/go/pkg/stream/stream_group_test.go
+++ b/go/pkg/stream/stream_group_test.go
@@ -14,15 +14,21 @@ import (
"github.com/gin-gonic/gin"
)
+const (
+ sseContentType = "text/event-stream"
+ eventsPath = "/events"
+ wsPath = "/ws"
+)
+
func TestStreamGroup_Good_RoundTrip(t *testing.T) {
gin.SetMode(gin.TestMode)
group := stream.NewGroup(
"events",
- stream.SSE("/events", func(c *gin.Context) {
- c.Data(http.StatusOK, "text/event-stream", []byte("data: ready\n\n"))
+ stream.SSE(eventsPath, func(c *gin.Context) {
+ c.Data(http.StatusOK, sseContentType, []byte("data: ready\n\n"))
}),
- stream.WebSocket("/ws", func(c *gin.Context) {
+ stream.WebSocket(wsPath, func(c *gin.Context) {
c.Header("Upgrade", "websocket")
c.Status(http.StatusSwitchingProtocols)
}),
@@ -38,32 +44,32 @@ func TestStreamGroup_Good_RoundTrip(t *testing.T) {
if handlers[0].Method != http.MethodGet {
t.Fatalf("expected first method %q, got %q", http.MethodGet, handlers[0].Method)
}
- if handlers[0].Path != "/events" {
- t.Fatalf("expected first path %q, got %q", "/events", handlers[0].Path)
+ if handlers[0].Path != eventsPath {
+ t.Fatalf("expected first path %q, got %q", eventsPath, handlers[0].Path)
}
if handlers[1].Protocol != stream.ProtocolWebSocket {
t.Fatalf("expected second protocol %q, got %q", stream.ProtocolWebSocket, handlers[1].Protocol)
}
- if handlers[1].Path != "/ws" {
- t.Fatalf("expected second path %q, got %q", "/ws", handlers[1].Path)
+ if handlers[1].Path != wsPath {
+ t.Fatalf("expected second path %q, got %q", wsPath, handlers[1].Path)
}
router := gin.New()
group.Register(router)
sseRecorder := httptest.NewRecorder()
- sseReq, _ := http.NewRequest(http.MethodGet, "/events", nil)
+ sseReq, _ := http.NewRequest(http.MethodGet, eventsPath, nil)
router.ServeHTTP(sseRecorder, sseReq)
if sseRecorder.Code != http.StatusOK {
t.Fatalf("expected SSE status 200, got %d", sseRecorder.Code)
}
- if got := sseRecorder.Header().Get("Content-Type"); got != "text/event-stream" {
- t.Fatalf("expected SSE content type %q, got %q", "text/event-stream", got)
+ if got := sseRecorder.Header().Get("Content-Type"); got != sseContentType {
+ t.Fatalf("expected SSE content type %q, got %q", sseContentType, got)
}
wsRecorder := httptest.NewRecorder()
- wsReq, _ := http.NewRequest(http.MethodGet, "/ws", nil)
+ wsReq, _ := http.NewRequest(http.MethodGet, wsPath, nil)
router.ServeHTTP(wsRecorder, wsReq)
if wsRecorder.Code != http.StatusSwitchingProtocols {
@@ -85,10 +91,10 @@ func TestStreamGroup_Bad_DropsInvalidHandlersAndClonesMetadata(t *testing.T) {
stream.Handler{
Protocol: stream.ProtocolWebSocket,
Method: http.MethodGet,
- Path: "/ws",
+ Path: wsPath,
Handle: nil,
},
- stream.SSE("/events", func(c *gin.Context) {
+ stream.SSE(eventsPath, func(c *gin.Context) {
c.Status(http.StatusNoContent)
}),
)
@@ -104,15 +110,15 @@ func TestStreamGroup_Bad_DropsInvalidHandlersAndClonesMetadata(t *testing.T) {
if len(fresh) != 1 {
t.Fatalf("expected 1 fresh handler, got %d", len(fresh))
}
- if fresh[0].Path != "/events" {
- t.Fatalf("expected cloned handler path %q, got %q", "/events", fresh[0].Path)
+ if fresh[0].Path != eventsPath {
+ t.Fatalf("expected cloned handler path %q, got %q", eventsPath, fresh[0].Path)
}
router := gin.New()
group.Register(router)
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/events", nil)
+ req, _ := http.NewRequest(http.MethodGet, eventsPath, nil)
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
@@ -176,13 +182,13 @@ func TestEngineRegisterStreamGroup_Good_MultiTenantRegistration(t *testing.T) {
engine.RegisterStreamGroup(stream.NewGroup(
"tenant-a",
stream.SSE("/tenants/a/events", func(c *gin.Context) {
- c.Data(http.StatusOK, "text/event-stream", []byte("data: tenant-a\n\n"))
+ c.Data(http.StatusOK, sseContentType, []byte("data: tenant-a\n\n"))
}),
))
engine.RegisterStreamGroup(stream.NewGroup(
"tenant-b",
stream.SSE("/tenants/b/events", func(c *gin.Context) {
- c.Data(http.StatusOK, "text/event-stream", []byte("data: tenant-b\n\n"))
+ c.Data(http.StatusOK, sseContentType, []byte("data: tenant-b\n\n"))
}),
))
@@ -207,8 +213,8 @@ func TestEngineRegisterStreamGroup_Good_MultiTenantRegistration(t *testing.T) {
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: expected status 200, got %d", tc.path, resp.StatusCode)
}
- if got := resp.Header.Get("Content-Type"); got != "text/event-stream" {
- t.Fatalf("%s: expected content type %q, got %q", tc.path, "text/event-stream", got)
+ if got := resp.Header.Get("Content-Type"); got != sseContentType {
+ t.Fatalf("%s: expected content type %q, got %q", tc.path, sseContentType, got)
}
body, readErr := io.ReadAll(resp.Body)
diff --git a/go/pprof_test.go b/go/pprof_test.go
index 696e53b..3291845 100644
--- a/go/pprof_test.go
+++ b/go/pprof_test.go
@@ -19,15 +19,15 @@ func TestWithPprof_Good_IndexAccessible(t *testing.T) {
e, err := api.New(api.WithPprof())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/debug/pprof/")
+ resp, err := http.Get(srv.URL + pathDebugPprof + "/")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -41,7 +41,7 @@ func TestWithPprof_Good_ProfileEndpointExists(t *testing.T) {
e, err := api.New(api.WithPprof())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -49,7 +49,7 @@ func TestWithPprof_Good_ProfileEndpointExists(t *testing.T) {
resp, err := http.Get(srv.URL + "/debug/pprof/heap")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -63,15 +63,15 @@ func TestWithPprof_Good_CombinesWithOtherMiddleware(t *testing.T) {
e, err := api.New(api.WithRequestID(), api.WithPprof())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/debug/pprof/")
+ resp, err := http.Get(srv.URL + pathDebugPprof + "/")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -80,7 +80,7 @@ func TestWithPprof_Good_CombinesWithOtherMiddleware(t *testing.T) {
}
// Verify the request ID middleware is still active.
- rid := resp.Header.Get("X-Request-ID")
+ rid := resp.Header.Get(hdrXRequestID)
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID middleware")
}
@@ -93,7 +93,7 @@ func TestWithPprof_Bad_NotMountedWithoutOption(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/debug/pprof/", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathDebugPprof+"/", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
@@ -106,7 +106,7 @@ func TestWithPprof_Good_CmdlineEndpointExists(t *testing.T) {
e, err := api.New(api.WithPprof())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -114,7 +114,7 @@ func TestWithPprof_Good_CmdlineEndpointExists(t *testing.T) {
resp, err := http.Get(srv.URL + "/debug/pprof/cmdline")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
diff --git a/go/ratelimit.go b/go/ratelimit.go
index 57e587e..fd73152 100644
--- a/go/ratelimit.go
+++ b/go/ratelimit.go
@@ -175,7 +175,7 @@ func rateLimitMiddleware(limit int) gin.HandlerFunc {
c.Header("Retry-After", core.Itoa(secs))
c.AbortWithStatusJSON(http.StatusTooManyRequests, Fail(
"rate_limit_exceeded",
- "Too many requests",
+ msgTooManyRequests,
))
return
}
diff --git a/go/ratelimit_test.go b/go/ratelimit_test.go
index 2460db3..8c2594f 100644
--- a/go/ratelimit_test.go
+++ b/go/ratelimit_test.go
@@ -39,13 +39,13 @@ func TestWithRateLimit_Good_AllowsBurstThenRejects(t *testing.T) {
if w1.Code != http.StatusOK {
t.Fatalf("expected first request to succeed, got %d", w1.Code)
}
- if got := w1.Header().Get("X-RateLimit-Limit"); got != "2" {
+ if got := w1.Header().Get(hdrRateLimit); got != "2" {
t.Fatalf("expected X-RateLimit-Limit=2, got %q", got)
}
- if got := w1.Header().Get("X-RateLimit-Remaining"); got != "1" {
+ if got := w1.Header().Get(hdrRateRemaining); got != "1" {
t.Fatalf("expected X-RateLimit-Remaining=1, got %q", got)
}
- if got := w1.Header().Get("X-RateLimit-Reset"); got == "" {
+ if got := w1.Header().Get(hdrRateReset); got == "" {
t.Fatal("expected X-RateLimit-Reset on successful response")
}
@@ -68,19 +68,19 @@ func TestWithRateLimit_Good_AllowsBurstThenRejects(t *testing.T) {
if got := w3.Header().Get("Retry-After"); got == "" {
t.Fatal("expected Retry-After header on 429 response")
}
- if got := w3.Header().Get("X-RateLimit-Limit"); got != "2" {
+ if got := w3.Header().Get(hdrRateLimit); got != "2" {
t.Fatalf("expected X-RateLimit-Limit=2 on 429, got %q", got)
}
- if got := w3.Header().Get("X-RateLimit-Remaining"); got != "0" {
+ if got := w3.Header().Get(hdrRateRemaining); got != "0" {
t.Fatalf("expected X-RateLimit-Remaining=0 on 429, got %q", got)
}
- if got := w3.Header().Get("X-RateLimit-Reset"); got == "" {
+ if got := w3.Header().Get(hdrRateReset); got == "" {
t.Fatal("expected X-RateLimit-Reset on 429 response")
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w3.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
t.Fatal("expected Success=false for rate limited response")
@@ -104,7 +104,7 @@ func TestWithRateLimit_Good_IsolatesPerIP(t *testing.T) {
if w1.Code != http.StatusOK {
t.Fatalf("expected first IP to succeed, got %d", w1.Code)
}
- if got := w1.Header().Get("X-RateLimit-Limit"); got != "1" {
+ if got := w1.Header().Get(hdrRateLimit); got != "1" {
t.Fatalf("expected X-RateLimit-Limit=1, got %q", got)
}
@@ -127,7 +127,7 @@ func TestWithRateLimit_Good_IsolatesPerAPIKey(t *testing.T) {
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req1.RemoteAddr = "203.0.113.20:1234"
- req1.Header.Set("X-API-Key", "key-a")
+ req1.Header.Set(apiKeyHeader, "key-a")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected first API key request to succeed, got %d", w1.Code)
@@ -136,7 +136,7 @@ func TestWithRateLimit_Good_IsolatesPerAPIKey(t *testing.T) {
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req2.RemoteAddr = "203.0.113.20:1234"
- req2.Header.Set("X-API-Key", "key-b")
+ req2.Header.Set(apiKeyHeader, "key-b")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected second API key to have its own bucket, got %d", w2.Code)
@@ -145,7 +145,7 @@ func TestWithRateLimit_Good_IsolatesPerAPIKey(t *testing.T) {
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req3.RemoteAddr = "203.0.113.20:1234"
- req3.Header.Set("X-API-Key", "key-a")
+ req3.Header.Set(apiKeyHeader, "key-a")
h.ServeHTTP(w3, req3)
if w3.Code != http.StatusTooManyRequests {
t.Fatalf("expected repeated API key to be rate limited, got %d", w3.Code)
@@ -206,7 +206,7 @@ func TestWithRateLimit_Good_PrioritisesPrincipalOverCredentialHeaders(t *testing
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req1.RemoteAddr = "203.0.113.40:1234"
req1.Header.Set("X-Principal", "workspace-1")
- req1.Header.Set("X-API-Key", "key-a")
+ req1.Header.Set(apiKeyHeader, "key-a")
req1.Header.Set("Authorization", "Bearer token-a")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
@@ -217,7 +217,7 @@ func TestWithRateLimit_Good_PrioritisesPrincipalOverCredentialHeaders(t *testing
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req2.RemoteAddr = "203.0.113.41:1234"
req2.Header.Set("X-Principal", "workspace-1")
- req2.Header.Set("X-API-Key", "key-b")
+ req2.Header.Set(apiKeyHeader, "key-b")
req2.Header.Set("Authorization", "Bearer token-b")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusTooManyRequests {
diff --git a/go/response_meta.go b/go/response_meta.go
index 5273ded..89b2cdb 100644
--- a/go/response_meta.go
+++ b/go/response_meta.go
@@ -213,7 +213,7 @@ func responseMetaMiddleware() gin.HandlerFunc {
}
body := recorder.body.Bytes()
- if meta := GetRequestMeta(c); meta != nil && shouldAttachResponseMeta(recorder.Header().Get("Content-Type"), body) {
+ if meta := GetRequestMeta(c); meta != nil && shouldAttachResponseMeta(recorder.Header().Get(hdrContentType), body) {
if refreshed := refreshResponseMetaBody(body, meta); refreshed != nil {
body = refreshed
}
@@ -302,7 +302,7 @@ func isJSONContentType(contentType string) bool {
}
mediaType = core.Lower(mediaType)
- return mediaType == "application/json" ||
+ return mediaType == mimeJSON ||
core.HasSuffix(mediaType, "+json") ||
core.HasSuffix(mediaType, "/json")
}
diff --git a/go/response_meta_test.go b/go/response_meta_test.go
index cde5ab0..b92cd28 100644
--- a/go/response_meta_test.go
+++ b/go/response_meta_test.go
@@ -96,7 +96,7 @@ func TestResponseMetaRecorder_Good_BuffersAndCommits(t *testing.T) {
t.Fatalf("expected header snapshot to be isolated, got %q", got)
}
- rec.Header().Set("Content-Type", "application/json")
+ rec.Header().Set(hdrContentType, mimeJSON)
rec.WriteHeader(http.StatusCreated)
rec.WriteHeaderNow()
if !rec.Written() {
@@ -144,7 +144,7 @@ func TestResponseMetaRecorder_Bad_RejectsNonJSONPayloads(t *testing.T) {
if got := shouldAttachResponseMeta("text/plain", []byte(`{"success":true}`)); got {
t.Fatal("expected text/plain to be rejected")
}
- if got := shouldAttachResponseMeta("application/json", []byte(`[]`)); got {
+ if got := shouldAttachResponseMeta(mimeJSON, []byte(`[]`)); got {
t.Fatal("expected array body to be rejected")
}
@@ -173,7 +173,7 @@ func TestResponseMetaRecorder_Bad_RejectsNonJSONPayloads(t *testing.T) {
func TestResponseMetaRecorder_Ugly_HandlesMalformedBodiesAndHijack(t *testing.T) {
base := newResponseMetaWriterStub()
rec := newResponseMetaRecorder(base)
- rec.Header().Set("Content-Type", "application/json")
+ rec.Header().Set(hdrContentType, mimeJSON)
if got := refreshResponseMetaBody([]byte(`not-json`), &Meta{RequestID: "x"}); string(got) != "not-json" {
t.Fatalf("expected malformed JSON to be returned unchanged, got %q", got)
diff --git a/go/response_test.go b/go/response_test.go
index 0e2f2b1..83a3e0a 100644
--- a/go/response_test.go
+++ b/go/response_test.go
@@ -28,10 +28,10 @@ func TestOK_Good(t *testing.T) {
r := api.OK("hello")
if !r.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if r.Data != "hello" {
- t.Fatalf("expected Data=%q, got %q", "hello", r.Data)
+ t.Fatalf(fmtTestExpectedData, "hello", r.Data)
}
if r.Error != nil {
t.Fatal("expected Error to be nil")
@@ -48,7 +48,7 @@ func TestOK_Good_StructData(t *testing.T) {
r := api.OK(user{Name: "Ada"})
if !r.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if r.Data.Name != "Ada" {
t.Fatalf("expected Data.Name=%q, got %q", "Ada", r.Data.Name)
@@ -64,7 +64,7 @@ func TestOK_Good_JSONOmitsErrorAndMeta(t *testing.T) {
var raw map[string]any
if err := coreJSONUnmarshal(b, &raw); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if _, ok := raw["error"]; ok {
@@ -87,7 +87,7 @@ func TestFail_Good(t *testing.T) {
r := api.Fail("NOT_FOUND", "resource not found")
if r.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if r.Error == nil {
t.Fatal("expected Error to be non-nil")
@@ -112,7 +112,7 @@ func TestFail_Good_JSONOmitsData(t *testing.T) {
var raw map[string]any
if err := coreJSONUnmarshal(b, &raw); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if _, ok := raw["data"]; ok {
@@ -130,7 +130,7 @@ func TestFailWithDetails_Good(t *testing.T) {
r := api.FailWithDetails("VALIDATION", "validation failed", details)
if r.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if r.Error == nil {
t.Fatal("expected Error to be non-nil")
@@ -152,7 +152,7 @@ func TestFailWithDetails_Good_JSONIncludesDetails(t *testing.T) {
var raw map[string]any
if err := coreJSONUnmarshal(b, &raw); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
errObj, ok := raw["error"].(map[string]any)
@@ -171,7 +171,7 @@ func TestPaginated_Good(t *testing.T) {
r := api.Paginated(items, 2, 25, 100)
if !r.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if len(r.Data) != 3 {
t.Fatalf("expected 3 items, got %d", len(r.Data))
@@ -199,7 +199,7 @@ func TestPaginated_Good_JSONIncludesMeta(t *testing.T) {
var raw map[string]any
if err := coreJSONUnmarshal(b, &raw); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if _, ok := raw["meta"]; !ok {
@@ -222,7 +222,7 @@ func TestResponse_AttachRequestMeta_Good_FillsMetaFromRequestIDMiddleware(t *tes
e, err := api.New(api.WithRequestID())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(attachRequestMetaTestGroup{
handler: func(c *gin.Context) {
@@ -233,16 +233,16 @@ func TestResponse_AttachRequestMeta_Good_FillsMetaFromRequestIDMiddleware(t *tes
rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
- req.Header.Set("X-Request-ID", "client-id-meta")
+ req.Header.Set(hdrXRequestID, "client-id-meta")
e.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", rec.Code)
+ t.Fatalf(fmtTestExpected200, rec.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
@@ -263,7 +263,7 @@ func TestResponse_AttachRequestMeta_Bad_ReturnsResponseUnchangedWithoutRequestMe
e, err := api.New()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(attachRequestMetaTestGroup{
handler: func(c *gin.Context) {
@@ -277,12 +277,12 @@ func TestResponse_AttachRequestMeta_Bad_ReturnsResponseUnchangedWithoutRequestMe
e.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", rec.Code)
+ t.Fatalf(fmtTestExpected200, rec.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Meta != nil {
t.Fatalf("expected Meta to remain nil, got %+v", resp.Meta)
@@ -294,7 +294,7 @@ func TestResponse_AttachRequestMeta_Ugly_PreservesExistingMetaFields(t *testing.
e, err := api.New(api.WithRequestID())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(attachRequestMetaTestGroup{
handler: func(c *gin.Context) {
@@ -306,16 +306,16 @@ func TestResponse_AttachRequestMeta_Ugly_PreservesExistingMetaFields(t *testing.
rec := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
- req.Header.Set("X-Request-ID", "client-id-meta")
+ req.Header.Set(hdrXRequestID, "client-id-meta")
e.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", rec.Code)
+ t.Fatalf(fmtTestExpected200, rec.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(rec.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
diff --git a/go/runtime_config_test.go b/go/runtime_config_test.go
index 235357a..e1311bc 100644
--- a/go/runtime_config_test.go
+++ b/go/runtime_config_test.go
@@ -25,16 +25,16 @@ func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
}),
api.WithWSPath("/socket"),
api.WithSSE(broker),
- api.WithSSEPath("/events"),
+ api.WithSSEPath(pathEvents),
api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.example.com",
ClientID: "runtime-client",
TrustedProxy: true,
- PublicPaths: []string{"/public", "/docs"},
+ PublicPaths: []string{pathPublic, "/docs"},
}),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.RuntimeConfig()
@@ -48,7 +48,7 @@ func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
if cfg.Transport.SwaggerPath != "/docs" {
t.Fatalf("expected transport swagger path /docs, got %q", cfg.Transport.SwaggerPath)
}
- if cfg.Transport.GraphQLPlaygroundPath != "/graphql/playground" {
+ if cfg.Transport.GraphQLPlaygroundPath != pathGraphQLPlay {
t.Fatalf("expected transport graphql playground path /graphql/playground, got %q", cfg.Transport.GraphQLPlaygroundPath)
}
if !cfg.Cache.Enabled || cfg.Cache.TTL != 5*time.Minute {
@@ -57,13 +57,13 @@ func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
if !cfg.GraphQL.Enabled {
t.Fatal("expected GraphQL snapshot to be enabled")
}
- if cfg.GraphQL.Path != "/graphql" {
+ if cfg.GraphQL.Path != pathGraphQL {
t.Fatalf("expected GraphQL path /graphql, got %q", cfg.GraphQL.Path)
}
if !cfg.GraphQL.Playground {
t.Fatal("expected GraphQL playground snapshot to be enabled")
}
- if cfg.GraphQL.PlaygroundPath != "/graphql/playground" {
+ if cfg.GraphQL.PlaygroundPath != pathGraphQLPlay {
t.Fatalf("expected GraphQL playground path /graphql/playground, got %q", cfg.GraphQL.PlaygroundPath)
}
if cfg.I18n.DefaultLocale != "en-GB" {
@@ -81,7 +81,7 @@ func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
if !cfg.Authentik.TrustedProxy {
t.Fatal("expected Authentik trusted proxy to be enabled")
}
- if !slices.Equal(cfg.Authentik.PublicPaths, []string{"/public", "/docs"}) {
+ if !slices.Equal(cfg.Authentik.PublicPaths, []string{pathPublic, "/docs"}) {
t.Fatalf("expected Authentik public paths [/public /docs], got %v", cfg.Authentik.PublicPaths)
}
}
diff --git a/go/secure_test.go b/go/secure_test.go
index 8c9f906..2b009b3 100644
--- a/go/secure_test.go
+++ b/go/secure_test.go
@@ -21,11 +21,11 @@ func TestWithSecure_Good_SetsHSTSHeader(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
sts := w.Header().Get("Strict-Transport-Security")
@@ -46,10 +46,10 @@ func TestWithSecure_Good_SetsFrameOptionsDeny(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
- xfo := w.Header().Get("X-Frame-Options")
+ xfo := w.Header().Get(hdrXFrameOptions)
if xfo != "DENY" {
t.Fatalf("expected X-Frame-Options=%q, got %q", "DENY", xfo)
}
@@ -61,7 +61,7 @@ func TestWithSecure_Good_SetsContentTypeNosniff(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
cto := w.Header().Get("X-Content-Type-Options")
@@ -76,7 +76,7 @@ func TestWithSecure_Good_SetsReferrerPolicy(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
rp := w.Header().Get("Referrer-Policy")
@@ -92,16 +92,16 @@ func TestWithSecure_Good_AllHeadersPresent(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Verify all security headers are present on a regular route.
checks := map[string]string{
- "X-Frame-Options": "DENY",
+ hdrXFrameOptions: "DENY",
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
}
@@ -128,18 +128,18 @@ func TestWithSecure_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Both secure headers and request ID should be present.
- if w.Header().Get("X-Frame-Options") != "DENY" {
+ if w.Header().Get(hdrXFrameOptions) != "DENY" {
t.Fatal("expected X-Frame-Options header from WithSecure")
}
- if w.Header().Get("X-Request-ID") == "" {
+ if w.Header().Get(hdrXRequestID) == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
@@ -152,7 +152,7 @@ func TestWithSecure_Bad_NoSSLRedirect(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
// Should get 200, not a 301/302 redirect.
@@ -171,15 +171,15 @@ func TestWithSecure_Ugly_DoubleSecureDoesNotPanic(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Headers should still be correctly set.
- if w.Header().Get("X-Frame-Options") != "DENY" {
+ if w.Header().Get(hdrXFrameOptions) != "DENY" {
t.Fatal("expected X-Frame-Options=DENY after double WithSecure")
}
}
diff --git a/go/service.go b/go/service.go
index 0c509f6..6145af5 100644
--- a/go/service.go
+++ b/go/service.go
@@ -49,7 +49,7 @@ type Service struct {
// registration and Serve calls stay on this handle since neither
// crosses an IPC boundary.
// Usage example: `svc.Engine.Register(myProvider)`
- Engine *Engine
+ Engine *Engine
registrations core.Once
}
diff --git a/go/sessions_test.go b/go/sessions_test.go
index 7265777..87e4fdf 100644
--- a/go/sessions_test.go
+++ b/go/sessions_test.go
@@ -47,7 +47,7 @@ func TestWithSessions_Good_SetsSessionCookie(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
cookies := w.Result().Cookies()
@@ -103,7 +103,7 @@ func TestWithSessions_Good_SessionPersistsAcrossRequests(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w2.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
data, ok := resp.Data.(string)
@@ -123,12 +123,12 @@ func TestWithSessions_Good_EmptySessionReturnsNil(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data != nil {
@@ -150,7 +150,7 @@ func TestWithSessions_Good_CombinesWithOtherMiddleware(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Session cookie should be present.
@@ -166,7 +166,7 @@ func TestWithSessions_Good_CombinesWithOtherMiddleware(t *testing.T) {
}
// Request ID should also be present.
- rid := w.Header().Get("X-Request-ID")
+ rid := w.Header().Get(hdrXRequestID)
if rid == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
@@ -181,7 +181,7 @@ func TestWithSessions_Ugly_DoubleSessionsDoesNotPanic(t *testing.T) {
api.WithSessions("session", []byte("secret-two-here!")),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(&sessionTestGroup{})
@@ -192,6 +192,6 @@ func TestWithSessions_Ugly_DoubleSessionsDoesNotPanic(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
}
diff --git a/go/slog_test.go b/go/slog_test.go
index ac53de3..4900d57 100644
--- a/go/slog_test.go
+++ b/go/slog_test.go
@@ -27,11 +27,11 @@ func TestWithSlog_Good_LogsRequestFields(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
output := buf.String()
@@ -55,11 +55,11 @@ func TestWithSlog_Good_NilLoggerUsesDefault(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
}
@@ -76,18 +76,18 @@ func TestWithSlog_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, 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") == "" {
+ if w.Header().Get(hdrXRequestID) == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
@@ -132,14 +132,14 @@ func TestWithSlog_Bad_LogsMethodAndPath(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodPost, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodPost, pathStubPing, nil)
h.ServeHTTP(w, req)
output := buf.String()
if !core.Contains(buf.String(), "POST") {
t.Errorf("expected log to contain method POST, got: %s", output)
}
- if !core.Contains(buf.String(), "/stub/ping") {
+ if !core.Contains(buf.String(), pathStubPing) {
t.Errorf("expected log to contain path /stub/ping, got: %s", output)
}
}
@@ -158,10 +158,10 @@ func TestWithSlog_Ugly_DoubleSlogDoesNotPanic(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/health", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathHealth, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
}
diff --git a/go/spec_builder_helper.go b/go/spec_builder_helper.go
index a124fa3..fb99c7b 100644
--- a/go/spec_builder_helper.go
+++ b/go/spec_builder_helper.go
@@ -82,6 +82,7 @@ func (e *Engine) OpenAPISpecBuilder() *SpecBuilder {
builder.ChatCompletionsPath = runtime.Transport.ChatCompletionsPath
builder.OpenAPISpecEnabled = runtime.Transport.OpenAPISpecEnabled
builder.OpenAPISpecPath = runtime.Transport.OpenAPISpecPath
+ builder.UpstreamRouterPaths = runtime.Transport.UpstreamRouterPaths
builder.CacheEnabled = runtime.Cache.Enabled
if runtime.Cache.TTL > 0 {
diff --git a/go/spec_builder_helper_test.go b/go/spec_builder_helper_test.go
index 9b695c0..029ad90 100644
--- a/go/spec_builder_helper_test.go
+++ b/go/spec_builder_helper_test.go
@@ -13,6 +13,10 @@ import (
api "dappco.re/go/api"
)
+func noopWSHandler(w http.ResponseWriter, r *http.Request) {
+ // Placeholder WebSocket handler for spec builder tests; no upgrade is performed.
+}
+
func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -23,13 +27,13 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
api.WithSwaggerPath("/docs"),
api.WithSwaggerTermsOfService("https://example.com/terms"),
api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com"),
- api.WithSwaggerServers("https://api.example.com", "/", "https://api.example.com"),
+ api.WithSwaggerServers(apiBaseURL, "/", apiBaseURL),
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
api.WithSwaggerSecuritySchemes(map[string]any{
"apiKeyAuth": map[string]any{
"type": "apiKey",
"in": "header",
- "name": "X-API-Key",
+ "name": apiKeyHeader,
},
}),
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
@@ -42,29 +46,29 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
Issuer: "https://auth.example.com",
ClientID: "core-client",
TrustedProxy: true,
- PublicPaths: []string{" /public/ ", "docs", "/public"},
+ PublicPaths: []string{" /public/ ", "docs", pathPublic},
}),
api.WithWSPath("/socket"),
- api.WithWSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
+ api.WithWSHandler(http.HandlerFunc(noopWSHandler)),
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")),
api.WithSSE(broker),
- api.WithSSEPath("/events"),
+ api.WithSSEPath(pathEvents),
api.WithPprof(),
api.WithExpvar(),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info, ok := spec["info"].(map[string]any)
@@ -108,7 +112,7 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
if got := spec["x-ws-enabled"]; got != true {
t.Fatalf("expected x-ws-enabled=true, got %v", got)
}
- if got := spec["x-sse-path"]; got != "/events" {
+ if got := spec["x-sse-path"]; got != pathEvents {
t.Fatalf("expected x-sse-path=/events, got %v", got)
}
if got := spec["x-sse-enabled"]; got != true {
@@ -155,7 +159,7 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
if !ok {
t.Fatalf("expected x-authentik-public-paths array, got %T", spec["x-authentik-public-paths"])
}
- if len(publicPaths) != 4 || publicPaths[0] != "/health" || publicPaths[1] != "/swagger" || publicPaths[2] != "/docs" || publicPaths[3] != "/public" {
+ if len(publicPaths) != 4 || publicPaths[0] != pathHealth || publicPaths[1] != "/swagger" || publicPaths[2] != "/docs" || publicPaths[3] != pathPublic {
t.Fatalf("expected public paths [/health /swagger /docs /public], got %v", publicPaths)
}
@@ -193,7 +197,7 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
if apiKeyAuth["in"] != "header" {
t.Fatalf("expected apiKeyAuth.in=header, got %v", apiKeyAuth["in"])
}
- if apiKeyAuth["name"] != "X-API-Key" {
+ if apiKeyAuth["name"] != apiKeyHeader {
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
}
@@ -212,7 +216,7 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
if len(servers) != 2 {
t.Fatalf("expected 2 normalised servers, got %d", len(servers))
}
- if servers[0].(map[string]any)["url"] != "https://api.example.com" {
+ if servers[0].(map[string]any)["url"] != apiBaseURL {
t.Fatalf("expected first server to be https://api.example.com, got %v", servers[0])
}
if servers[1].(map[string]any)["url"] != "/" {
@@ -232,13 +236,13 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
if _, ok := paths["/socket"]; !ok {
t.Fatal("expected custom WebSocket path from engine metadata in generated spec")
}
- if _, ok := paths["/events"]; !ok {
+ if _, ok := paths[pathEvents]; !ok {
t.Fatal("expected SSE path from engine metadata in generated spec")
}
- if _, ok := paths["/debug/pprof"]; !ok {
+ if _, ok := paths[pathDebugPprof]; !ok {
t.Fatal("expected pprof path from engine metadata in generated spec")
}
- if _, ok := paths["/debug/vars"]; !ok {
+ if _, ok := paths[pathDebugVars]; !ok {
t.Fatal("expected expvar path from engine metadata in generated spec")
}
}
@@ -251,19 +255,19 @@ func TestEngine_Good_SwaggerConfigCarriesEngineMetadata(t *testing.T) {
api.WithSwaggerSummary("Engine overview"),
api.WithSwaggerTermsOfService("https://example.com/terms"),
api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com"),
- api.WithSwaggerServers("https://api.example.com", "/", "https://api.example.com"),
+ api.WithSwaggerServers(apiBaseURL, "/", apiBaseURL),
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
api.WithSwaggerSecuritySchemes(map[string]any{
"apiKeyAuth": map[string]any{
"type": "apiKey",
"in": "header",
- "name": "X-API-Key",
+ "name": apiKeyHeader,
},
}),
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.SwaggerConfig()
@@ -300,7 +304,7 @@ func TestEngine_Good_SwaggerConfigCarriesEngineMetadata(t *testing.T) {
if len(cfg.Servers) != 2 {
t.Fatalf("expected 2 normalised servers, got %d", len(cfg.Servers))
}
- if cfg.Servers[0] != "https://api.example.com" {
+ if cfg.Servers[0] != apiBaseURL {
t.Fatalf("expected first server to be https://api.example.com, got %q", cfg.Servers[0])
}
if cfg.Servers[1] != "/" {
@@ -312,7 +316,7 @@ func TestEngine_Good_SwaggerConfigCarriesEngineMetadata(t *testing.T) {
api.WithSwaggerPath("/docs"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
snap := cfgWithPath.SwaggerConfig()
if snap.Path != "/docs" {
@@ -323,7 +327,7 @@ func TestEngine_Good_SwaggerConfigCarriesEngineMetadata(t *testing.T) {
if !ok {
t.Fatal("expected apiKeyAuth security scheme in Swagger config")
}
- if apiKeyAuth["name"] != "X-API-Key" {
+ if apiKeyAuth["name"] != apiKeyHeader {
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
}
@@ -331,14 +335,14 @@ func TestEngine_Good_SwaggerConfigCarriesEngineMetadata(t *testing.T) {
apiKeyAuth["name"] = "Changed"
reshot := e.SwaggerConfig()
- if reshot.Servers[0] != "https://api.example.com" {
+ if reshot.Servers[0] != apiBaseURL {
t.Fatalf("expected engine servers to be cloned, got %q", reshot.Servers[0])
}
reshotScheme, ok := reshot.SecuritySchemes["apiKeyAuth"].(map[string]any)
if !ok {
t.Fatal("expected apiKeyAuth security scheme in cloned Swagger config")
}
- if reshotScheme["name"] != "X-API-Key" {
+ if reshotScheme["name"] != apiKeyHeader {
t.Fatalf("expected cloned security scheme name X-API-Key, got %v", reshotScheme["name"])
}
}
@@ -357,11 +361,11 @@ func TestEngine_Good_SwaggerConfigTrimsRuntimeMetadata(t *testing.T) {
Issuer: " https://auth.example.com ",
ClientID: " core-client ",
TrustedProxy: true,
- PublicPaths: []string{" /public/ ", " docs ", "/public"},
+ PublicPaths: []string{" /public/ ", " docs ", pathPublic},
}),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
swagger := e.SwaggerConfig()
@@ -397,19 +401,19 @@ func TestEngine_Good_SwaggerConfigTrimsRuntimeMetadata(t *testing.T) {
if auth.ClientID != "core-client" {
t.Fatalf("expected trimmed client ID, got %q", auth.ClientID)
}
- if want := []string{"/public", "/docs"}; !slices.Equal(auth.PublicPaths, want) {
+ if want := []string{pathPublic, "/docs"}; !slices.Equal(auth.PublicPaths, want) {
t.Fatalf("expected trimmed public paths %v, got %v", want, auth.PublicPaths)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info, ok := spec["info"].(map[string]any)
@@ -432,15 +436,15 @@ func TestEngine_Good_TransportConfigCarriesEngineMetadata(t *testing.T) {
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
api.WithSwaggerPath("/docs"),
api.WithWSPath("/socket"),
- api.WithWSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
+ api.WithWSHandler(http.HandlerFunc(noopWSHandler)),
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")),
api.WithSSE(broker),
- api.WithSSEPath("/events"),
+ api.WithSSEPath(pathEvents),
api.WithPprof(),
api.WithExpvar(),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
@@ -468,7 +472,7 @@ func TestEngine_Good_TransportConfigCarriesEngineMetadata(t *testing.T) {
if !cfg.SSEEnabled {
t.Fatal("expected SSE to be enabled")
}
- if cfg.SSEPath != "/events" {
+ if cfg.SSEPath != pathEvents {
t.Fatalf("expected sse path /events, got %q", cfg.SSEPath)
}
if !cfg.PprofEnabled {
@@ -484,7 +488,7 @@ func TestEngine_Good_TransportConfigReportsDisabledSwaggerWithoutUI(t *testing.T
e, err := api.New(api.WithSwaggerPath("/docs"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
@@ -505,14 +509,14 @@ func TestEngine_Good_TransportConfigReportsChatCompletions(t *testing.T) {
resolver := api.NewModelResolver()
e, err := api.New(api.WithChatCompletions(resolver))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
if !cfg.ChatCompletionsEnabled {
t.Fatal("expected chat completions to be enabled")
}
- if cfg.ChatCompletionsPath != "/v1/chat/completions" {
+ if cfg.ChatCompletionsPath != pathChatComplet {
t.Fatalf("expected chat completions path /v1/chat/completions, got %q", cfg.ChatCompletionsPath)
}
}
@@ -528,7 +532,7 @@ func TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride(t *testin
api.WithChatCompletionsPath("/chat"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
@@ -546,14 +550,14 @@ func TestEngine_Good_TransportConfigReportsOpenAPISpec(t *testing.T) {
e, err := api.New(api.WithOpenAPISpec())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
if !cfg.OpenAPISpecEnabled {
t.Fatal("expected OpenAPISpecEnabled=true")
}
- if cfg.OpenAPISpecPath != "/v1/openapi.json" {
+ if cfg.OpenAPISpecPath != pathOpenAPIJSON {
t.Fatalf("expected OpenAPISpecPath=/v1/openapi.json, got %q", cfg.OpenAPISpecPath)
}
}
@@ -565,7 +569,7 @@ func TestEngine_Good_TransportConfigHonoursOpenAPISpecPathOverride(t *testing.T)
e, err := api.New(api.WithOpenAPISpecPath("/api/v1/openapi.json"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
@@ -585,7 +589,7 @@ func TestEngine_Bad_TransportConfigOmitsOpenAPISpecWhenDisabled(t *testing.T) {
e, err := api.New()
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
@@ -605,14 +609,14 @@ func TestEngine_Bad_TransportConfigFallsBackToDefaultOpenAPISpecPathWhenBlank(t
e, err := api.New(api.WithOpenAPISpecPath(" "))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
if !cfg.OpenAPISpecEnabled {
t.Fatal("expected OpenAPISpecEnabled=true from blank override")
}
- if cfg.OpenAPISpecPath != "/v1/openapi.json" {
+ if cfg.OpenAPISpecPath != pathOpenAPIJSON {
t.Fatalf("expected default OpenAPISpecPath=/v1/openapi.json, got %q", cfg.OpenAPISpecPath)
}
}
@@ -625,7 +629,7 @@ func TestEngine_Ugly_TransportConfigNormalisesOpenAPISpecPathOverride(t *testing
e, err := api.New(api.WithOpenAPISpecPath(" api/v1/openapi.json "))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
cfg := e.TransportConfig()
@@ -642,18 +646,18 @@ func TestEngine_Good_OpenAPISpecBuilderExportsDefaultSwaggerPath(t *testing.T) {
e, err := api.New(api.WithSwagger("Engine API", "Engine metadata", "2.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-swagger-ui-path"]; got != "/swagger" {
@@ -666,18 +670,18 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesExplicitSwaggerPathWithoutUI(t *te
e, err := api.New(api.WithSwaggerPath("/docs"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-swagger-ui-path"]; got != "/docs" {
@@ -690,18 +694,18 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredWSPathWithoutHandler(t *
e, err := api.New(api.WithWSPath("/socket"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if got := spec["x-ws-path"]; got != "/socket" {
@@ -712,23 +716,23 @@ func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredWSPathWithoutHandler(t *
func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredSSEPathWithoutBroker(t *testing.T) {
gin.SetMode(gin.TestMode)
- e, err := api.New(api.WithSSE(nil), api.WithSSEPath("/events"))
+ e, err := api.New(api.WithSSE(nil), api.WithSSEPath(pathEvents))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
- if got := spec["x-sse-path"]; got != "/events" {
+ if got := spec["x-sse-path"]; got != pathEvents {
t.Fatalf("expected x-sse-path=/events, got %v", got)
}
}
@@ -753,7 +757,7 @@ func TestEngine_Good_OpenAPISpecBuilderClonesSecuritySchemes(t *testing.T) {
api.WithSwaggerSecuritySchemes(schemes),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
// Mutate the original input after configuration. The builder snapshot should
@@ -763,12 +767,12 @@ func TestEngine_Good_OpenAPISpecBuilderClonesSecuritySchemes(t *testing.T) {
data, err := e.OpenAPISpecBuilder().Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
securitySchemes := spec["components"].(map[string]any)["securitySchemes"].(map[string]any)
@@ -794,7 +798,7 @@ func TestEngine_Ugly_OpenAPISpecBuilderSkipsBlankSecuritySchemeEntries(t *testin
e, err := api.New(api.WithSwagger("Engine API", "Engine metadata", "2.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
api.WithSwaggerSecuritySchemes(nil)(e)
@@ -804,18 +808,18 @@ func TestEngine_Ugly_OpenAPISpecBuilderSkipsBlankSecuritySchemeEntries(t *testin
"apiKeyAuth": map[string]any{
"type": "apiKey",
"in": "header",
- "name": "X-API-Key",
+ "name": apiKeyHeader,
},
})(e)
data, err := e.OpenAPISpecBuilder().Build(nil)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
var spec map[string]any
if err := coreJSONUnmarshal(data, &spec); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
securitySchemes := spec["components"].(map[string]any)["securitySchemes"].(map[string]any)
diff --git a/go/spec_registry_test.go b/go/spec_registry_test.go
index 9435a70..eb85365 100644
--- a/go/spec_registry_test.go
+++ b/go/spec_registry_test.go
@@ -16,9 +16,11 @@ type specRegistryStubGroup struct {
basePath string
}
-func (g *specRegistryStubGroup) Name() string { return g.name }
-func (g *specRegistryStubGroup) BasePath() string { return g.basePath }
-func (g *specRegistryStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
+func (g *specRegistryStubGroup) Name() string { return g.name }
+func (g *specRegistryStubGroup) BasePath() string { return g.basePath }
+func (g *specRegistryStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
+ // Required by RouteGroup; used to test registry deduplication.
+}
func TestRegisterSpecGroups_Good_DeduplicatesByIdentity(t *testing.T) {
snapshot := api.RegisteredSpecGroups()
diff --git a/go/sse.go b/go/sse.go
index 938efe4..3a95c93 100644
--- a/go/sse.go
+++ b/go/sse.go
@@ -171,7 +171,7 @@ func (b *SSEBroker) Handler() gin.HandlerFunc {
}()
// Set SSE headers.
- c.Writer.Header().Set("Content-Type", "text/event-stream")
+ c.Writer.Header().Set(hdrContentType, "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")
diff --git a/go/sse_test.go b/go/sse_test.go
index dc0ddf7..d013e59 100644
--- a/go/sse_test.go
+++ b/go/sse_test.go
@@ -26,24 +26,24 @@ func TestWithSSE_Good_EndpointExists(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/events")
+ resp, err := http.Get(srv.URL + pathEvents)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
- ct := resp.Header.Get("Content-Type")
- if !core.HasPrefix(ct, "text/event-stream") {
+ ct := resp.Header.Get(hdrContentType)
+ if !core.HasPrefix(ct, mimeEventStream) {
t.Fatalf("expected Content-Type starting with text/event-stream, got %q", ct)
}
}
@@ -54,7 +54,7 @@ func TestWithSSE_Good_LegacyVersionedPathExistsByDefault(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -62,7 +62,7 @@ func TestWithSSE_Good_LegacyVersionedPathExistsByDefault(t *testing.T) {
resp, err := http.Get(srv.URL + "/v1/events")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -70,8 +70,8 @@ func TestWithSSE_Good_LegacyVersionedPathExistsByDefault(t *testing.T) {
t.Fatalf("expected 200 from legacy /v1/events alias, got %d", resp.StatusCode)
}
- ct := resp.Header.Get("Content-Type")
- if !core.HasPrefix(ct, "text/event-stream") {
+ ct := resp.Header.Get(hdrContentType)
+ if !core.HasPrefix(ct, mimeEventStream) {
t.Fatalf("expected Content-Type starting with text/event-stream, got %q", ct)
}
}
@@ -82,7 +82,7 @@ func TestWithSSE_Good_CustomPath(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker), api.WithSSEPath("/stream"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -90,20 +90,20 @@ func TestWithSSE_Good_CustomPath(t *testing.T) {
resp, err := http.Get(srv.URL + "/stream")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
- ct := resp.Header.Get("Content-Type")
- if !core.HasPrefix(ct, "text/event-stream") {
+ ct := resp.Header.Get(hdrContentType)
+ if !core.HasPrefix(ct, mimeEventStream) {
t.Fatalf("expected Content-Type starting with text/event-stream, got %q", ct)
}
- notFoundResp, err := http.Get(srv.URL + "/events")
+ notFoundResp, err := http.Get(srv.URL + pathEvents)
if err != nil {
t.Fatalf("request to default SSE path failed: %v", err)
}
@@ -120,7 +120,7 @@ func TestWithSSE_Bad_CustomPathDoesNotExposeLegacyAlias(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker), api.WithSSEPath(" /stream/ "))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -128,7 +128,7 @@ func TestWithSSE_Bad_CustomPathDoesNotExposeLegacyAlias(t *testing.T) {
resp, err := http.Get(srv.URL + "/v1/events")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -143,15 +143,15 @@ func TestWithSSE_Ugly_RootPathFallsBackToDefault(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker), api.WithSSEPath(" / "))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/events")
+ resp, err := http.Get(srv.URL + pathEvents)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -176,15 +176,15 @@ func TestWithSSE_Good_ReceivesPublishedEvent(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/events")
+ resp, err := http.Get(srv.URL + pathEvents)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -235,7 +235,7 @@ func TestWithSSE_Good_ChannelFiltering(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -244,7 +244,7 @@ func TestWithSSE_Good_ChannelFiltering(t *testing.T) {
// Subscribe to channel "foo" only.
resp, err := http.Get(srv.URL + "/events?channel=foo")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -298,30 +298,30 @@ func TestWithSSE_Good_CombinesWithOtherMiddleware(t *testing.T) {
api.WithSSE(broker),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/events")
+ resp, err := http.Get(srv.URL + pathEvents)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
// RequestID middleware should have injected the header.
- reqID := resp.Header.Get("X-Request-ID")
+ reqID := resp.Header.Get(hdrXRequestID)
if reqID == "" {
t.Fatal("expected X-Request-ID header from RequestID middleware")
}
- ct := resp.Header.Get("Content-Type")
- if !core.HasPrefix(ct, "text/event-stream") {
+ ct := resp.Header.Get(hdrContentType)
+ if !core.HasPrefix(ct, mimeEventStream) {
t.Fatalf("expected Content-Type starting with text/event-stream, got %q", ct)
}
}
@@ -336,22 +336,22 @@ func TestWithSSE_Good_WithResponseMetaStillStreamsEvents(t *testing.T) {
api.WithSSE(broker),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/events")
+ resp, err := http.Get(srv.URL + pathEvents)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
- if ct := resp.Header.Get("Content-Type"); !core.HasPrefix(ct, "text/event-stream") {
+ if ct := resp.Header.Get(hdrContentType); !core.HasPrefix(ct, mimeEventStream) {
t.Fatalf("expected Content-Type starting with text/event-stream, got %q", ct)
}
- if reqID := resp.Header.Get("X-Request-ID"); reqID == "" {
+ if reqID := resp.Header.Get(hdrXRequestID); reqID == "" {
t.Fatal("expected X-Request-ID header from RequestID middleware")
}
@@ -393,20 +393,20 @@ func TestWithSSE_Good_MultipleClients(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
// Connect two clients.
- resp1, err := http.Get(srv.URL + "/events")
+ resp1, err := http.Get(srv.URL + pathEvents)
if err != nil {
t.Fatalf("client 1 request failed: %v", err)
}
defer resp1.Body.Close()
- resp2, err := http.Get(srv.URL + "/events")
+ resp2, err := http.Get(srv.URL + pathEvents)
if err != nil {
t.Fatalf("client 2 request failed: %v", err)
}
@@ -460,15 +460,15 @@ func TestWithSSE_Good_DrainDisconnectsClients(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/events")
+ resp, err := http.Get(srv.URL + pathEvents)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
waitForClients(t, broker, 1)
@@ -516,7 +516,7 @@ func TestNoSSEBroker_Good(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/events", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathEvents, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
@@ -538,7 +538,7 @@ func TestWithSSE_Good_EngineShutdownDrainsClients(t *testing.T) {
e, err := api.New(api.WithAddr(addr), api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
ctx, cancel := context.WithCancel(context.Background())
@@ -557,9 +557,9 @@ func TestWithSSE_Good_EngineShutdownDrainsClients(t *testing.T) {
time.Sleep(50 * time.Millisecond)
}
- resp, err := http.Get("http://" + addr + "/events")
+ resp, err := http.Get("http://" + addr + pathEvents)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
diff --git a/go/static_test.go b/go/static_test.go
index 16c48fb..de70a93 100644
--- a/go/static_test.go
+++ b/go/static_test.go
@@ -31,7 +31,7 @@ func TestWithStatic_Good_ServesFile(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
body := w.Body.String()
@@ -73,7 +73,7 @@ func TestWithStatic_Good_ServesIndex(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
body := w.Body.String()
@@ -109,7 +109,7 @@ func TestWithStatic_Good_CombinesWithRouteGroups(t *testing.T) {
// API route should also work.
w2 := httptest.NewRecorder()
- req2, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req2, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
diff --git a/go/strict_bind_test.go b/go/strict_bind_test.go
new file mode 100644
index 0000000..93e36a0
--- /dev/null
+++ b/go/strict_bind_test.go
@@ -0,0 +1,200 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ core "dappco.re/go"
+)
+
+// TestStrictBind_addrIsLoopback_Good asserts the loopback classifier accepts
+// every address shape that binds only the loopback interface.
+func TestStrictBind_addrIsLoopback_Good(t *testing.T) {
+ loopback := []string{
+ "127.0.0.1:8787",
+ "127.0.0.1",
+ "[::1]:8787",
+ "::1",
+ "localhost:8787",
+ "localhost",
+ " 127.0.0.1:8787 ",
+ "127.255.255.254:80",
+ }
+ for _, addr := range loopback {
+ if !addrIsLoopback(addr) {
+ t.Errorf("addrIsLoopback(%q) = false, want true", addr)
+ }
+ }
+}
+
+// TestStrictBind_addrIsLoopback_Bad asserts non-loopback and all-interface
+// addresses are classified as not loopback.
+func TestStrictBind_addrIsLoopback_Bad(t *testing.T) {
+ nonLoopback := []string{
+ "0.0.0.0:8787",
+ ":8787",
+ "::",
+ "[::]:8787",
+ "192.168.1.10:8787",
+ "10.0.0.1:8787",
+ "example.com:8787",
+ "",
+ }
+ for _, addr := range nonLoopback {
+ if addrIsLoopback(addr) {
+ t.Errorf("addrIsLoopback(%q) = true, want false", addr)
+ }
+ }
+}
+
+// TestStrictBind_validateBind_Good asserts strict mode permits loopback binds
+// and that the default (non-strict) engine never rejects any address.
+func TestStrictBind_validateBind_Good(t *testing.T) {
+ // Default engine: strict mode off — every address passes.
+ for _, addr := range []string{"0.0.0.0:8787", ":8787", "192.168.1.10:8787"} {
+ e, err := New(WithAddr(addr))
+ if err != nil {
+ t.Fatalf("New(%q): unexpected error: %v", addr, err)
+ }
+ if err := e.validateBind(); err != nil {
+ t.Errorf("default engine validateBind(%q) = %v, want nil", addr, err)
+ }
+ }
+
+ // Strict mode on, loopback bind — passes with no bearer required.
+ e, err := New(WithAddr("127.0.0.1:8787"), WithStrictBind())
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.validateBind(); err != nil {
+ t.Errorf("strict loopback validateBind = %v, want nil", err)
+ }
+
+ // Strict mode on, public bind, bearer present — passes.
+ e, err = New(
+ WithAddr("0.0.0.0:8787"),
+ WithStrictBind(),
+ WithPublicBind(),
+ WithBearerAuth("secret"),
+ )
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.validateBind(); err != nil {
+ t.Errorf("strict public+bearer validateBind = %v, want nil", err)
+ }
+
+ // WithLoopbackOnly with a loopback bind passes.
+ e, err = New(WithAddr("[::1]:8787"), WithLoopbackOnly())
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.validateBind(); err != nil {
+ t.Errorf("loopback-only validateBind = %v, want nil", err)
+ }
+}
+
+// TestStrictBind_validateBind_Bad asserts strict mode rejects a non-loopback
+// bind without the explicit public opt-in, and rejects a public bind that has
+// no bearer credential.
+func TestStrictBind_validateBind_Bad(t *testing.T) {
+ // Non-loopback bind without WithPublicBind — rejected.
+ e, err := New(WithAddr("0.0.0.0:8787"), WithStrictBind())
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.validateBind(); !core.Is(err, ErrNonLoopbackBind) {
+ t.Errorf("strict public-no-flag validateBind = %v, want ErrNonLoopbackBind", err)
+ }
+
+ // WithLoopbackOnly never permits a public bind even with a bearer.
+ e, err = New(
+ WithAddr("0.0.0.0:8787"),
+ WithLoopbackOnly(),
+ WithBearerAuth("secret"),
+ )
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.validateBind(); !core.Is(err, ErrNonLoopbackBind) {
+ t.Errorf("loopback-only public validateBind = %v, want ErrNonLoopbackBind", err)
+ }
+
+ // Public bind opted in but no bearer — rejected.
+ e, err = New(
+ WithAddr("0.0.0.0:8787"),
+ WithStrictBind(),
+ WithPublicBind(),
+ )
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.validateBind(); !core.Is(err, ErrPublicBindNoBearer) {
+ t.Errorf("strict public-no-bearer validateBind = %v, want ErrPublicBindNoBearer", err)
+ }
+
+ // Public bind opted in with an empty bearer token — still rejected, since
+ // an empty token does not configure a credential.
+ e, err = New(
+ WithAddr("0.0.0.0:8787"),
+ WithStrictBind(),
+ WithPublicBind(),
+ WithBearerAuth(" "),
+ )
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.validateBind(); !core.Is(err, ErrPublicBindNoBearer) {
+ t.Errorf("strict public-empty-bearer validateBind = %v, want ErrPublicBindNoBearer", err)
+ }
+}
+
+// TestStrictBind_Serve_Ugly asserts Serve fails fast on a misconfigured strict
+// engine — the listener never opens — and that a non-strict engine on the same
+// non-loopback address is unaffected (it binds and shuts down cleanly).
+func TestStrictBind_Serve_Ugly(t *testing.T) {
+ // Strict + non-loopback + no public flag: Serve must return the sentinel
+ // immediately, before binding, regardless of context cancellation.
+ e, err := New(WithAddr("0.0.0.0:0"), WithStrictBind())
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.Serve(context.Background()); !core.Is(err, ErrNonLoopbackBind) {
+ t.Fatalf("strict Serve = %v, want ErrNonLoopbackBind", err)
+ }
+
+ // Strict + public flag + no bearer: Serve must return ErrPublicBindNoBearer
+ // without opening a listener.
+ e, err = New(WithAddr("0.0.0.0:0"), WithStrictBind(), WithPublicBind())
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ if err := e.Serve(context.Background()); !core.Is(err, ErrPublicBindNoBearer) {
+ t.Fatalf("strict Serve = %v, want ErrPublicBindNoBearer", err)
+ }
+
+ // Default engine on a non-loopback ephemeral port: must bind and exit
+ // cleanly on context cancellation — strict enforcement is opt-in only.
+ e, err = New(WithAddr("127.0.0.1:0"))
+ if err != nil {
+ t.Fatalf("New: unexpected error: %v", err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ errCh := make(chan error, 1)
+ go func() {
+ errCh <- e.Serve(ctx)
+ }()
+ time.Sleep(50 * time.Millisecond)
+ cancel()
+ select {
+ case err := <-errCh:
+ if err != nil {
+ t.Fatalf("default Serve returned error: %v", err)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("default Serve did not return after cancel")
+ }
+}
diff --git a/go/string_constants.go b/go/string_constants.go
new file mode 100644
index 0000000..bb54f87
--- /dev/null
+++ b/go/string_constants.go
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+// Shared string constants to reduce duplication.
+
+const (
+ hdrContentType = "Content-Type"
+ hdrContentEncoding = "Content-Encoding"
+ mimeJSON = "application/json"
+
+ errBridgeValidate = "ToolBridge.Validate"
+ errBridgeValidateResp = "ToolBridge.ValidateResponse"
+ errBridgeValidateSchema = "ToolBridge.ValidateSchema"
+ errClientCall = "OpenAPIClient.Call"
+ errClientLoadSpec = "OpenAPIClient.loadSpec"
+ errClientBuildURL = "OpenAPIClient.buildURL"
+ errClientValidateSchema = "OpenAPIClient.validateOpenAPISchema"
+ errClientValidateResponse = "OpenAPIClient.validateOpenAPIResponse"
+ errSDKGenerate = "SDKGenerator.Generate"
+
+ msgBadRequest = "Bad request"
+ msgTooManyRequests = "Too many requests"
+ msgGatewayTimeout = "Gateway timeout"
+ msgInternalSrvErr = "Internal server error"
+
+ toolResponsePrefix = "ToolBridge.ValidateResponse"
+ toolSchemaPrefix = "ToolBridge.ValidateSchema"
+)
diff --git a/go/sunset_test.go b/go/sunset_test.go
index de13c2b..8b2f295 100644
--- a/go/sunset_test.go
+++ b/go/sunset_test.go
@@ -17,7 +17,7 @@ type sunsetStubGroup struct{}
func (sunsetStubGroup) Name() string { return "legacy" }
func (sunsetStubGroup) BasePath() string { return "/legacy" }
func (sunsetStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
- rg.GET("/status", func(c *gin.Context) {
+ rg.GET(pathStatus, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("ok"))
})
}
@@ -27,7 +27,7 @@ type sunsetLinkStubGroup struct{}
func (sunsetLinkStubGroup) Name() string { return "legacy-link" }
func (sunsetLinkStubGroup) BasePath() string { return "/legacy-link" }
func (sunsetLinkStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
- rg.GET("/status", func(c *gin.Context) {
+ rg.GET(pathStatus, func(c *gin.Context) {
c.Header("Link", "; rel=\"help\"")
c.JSON(http.StatusOK, api.OK("ok"))
})
@@ -38,7 +38,7 @@ type sunsetHeaderStubGroup struct{}
func (sunsetHeaderStubGroup) Name() string { return "legacy-headers" }
func (sunsetHeaderStubGroup) BasePath() string { return "/legacy-headers" }
func (sunsetHeaderStubGroup) RegisterRoutes(rg *gin.RouterGroup) {
- rg.GET("/status", func(c *gin.Context) {
+ rg.GET(pathStatus, func(c *gin.Context) {
c.Header("Deprecation", "false")
c.Header("Sunset", "Wed, 01 Jan 2025 00:00:00 GMT")
c.Header("X-API-Warn", "Existing warning")
@@ -52,7 +52,7 @@ func TestWithSunset_Good_AddsDeprecationHeaders(t *testing.T) {
e, err := api.New(api.WithSunset("2025-06-01", "/api/v2/status"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(sunsetStubGroup{})
@@ -61,7 +61,7 @@ func TestWithSunset_Good_AddsDeprecationHeaders(t *testing.T) {
e.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if got := w.Header().Get("Deprecation"); got != "true" {
t.Fatalf("expected Deprecation=true, got %q", got)
@@ -273,7 +273,7 @@ func TestWithSunset_Good_PreservesExistingLinkHeaders(t *testing.T) {
e, err := api.New(api.WithSunset("2025-06-01", "/api/v2/status"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(sunsetLinkStubGroup{})
@@ -282,7 +282,7 @@ func TestWithSunset_Good_PreservesExistingLinkHeaders(t *testing.T) {
e.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
links := w.Header().Values("Link")
@@ -302,7 +302,7 @@ func TestWithSunset_Good_PreservesExistingDeprecationHeaders(t *testing.T) {
e, err := api.New(api.WithSunset("2025-06-01", "/api/v2/status"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(sunsetHeaderStubGroup{})
@@ -311,7 +311,7 @@ func TestWithSunset_Good_PreservesExistingDeprecationHeaders(t *testing.T) {
e.Handler().ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if got := w.Header().Values("Deprecation"); len(got) != 2 {
diff --git a/go/swagger.go b/go/swagger.go
index ab1a4ff..423c8cd 100644
--- a/go/swagger.go
+++ b/go/swagger.go
@@ -105,7 +105,7 @@ func registerOpenAPISpec(g *gin.Engine, e *Engine) {
spec := newSwaggerSpec(e.OpenAPISpecBuilder(), e.Groups())
g.GET(path, func(c *gin.Context) {
doc := spec.ReadDoc()
- c.Header("Content-Type", "application/json; charset=utf-8")
+ c.Header(hdrContentType, "application/json; charset=utf-8")
c.String(http.StatusOK, doc)
})
}
diff --git a/go/swagger_test.go b/go/swagger_test.go
index 8f125b5..5541fc1 100644
--- a/go/swagger_test.go
+++ b/go/swagger_test.go
@@ -21,7 +21,7 @@ func TestSwaggerEndpoint_Good(t *testing.T) {
e, err := api.New(api.WithSwagger("Test API", "A test API service", "1.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
// Use a real test server because gin-swagger reads RequestURI
@@ -29,19 +29,19 @@ func TestSwaggerEndpoint_Good(t *testing.T) {
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if len(body) == 0 {
t.Fatal("expected non-empty response body")
@@ -73,7 +73,7 @@ func TestSwaggerEndpoint_Good_CustomPath(t *testing.T) {
api.WithSwaggerPath("/docs"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -81,17 +81,17 @@ func TestSwaggerEndpoint_Good_CustomPath(t *testing.T) {
resp, err := http.Get(srv.URL + "/docs/doc.json")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
if len(body) == 0 {
t.Fatal("expected non-empty response body")
@@ -116,7 +116,7 @@ func TestSwaggerEndpoint_Good_BasePathRedirect(t *testing.T) {
e, err := api.New(api.WithSwagger("Test API", "A test API service", "1.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -130,7 +130,7 @@ func TestSwaggerEndpoint_Good_BasePathRedirect(t *testing.T) {
resp, err := client.Get(srv.URL + "/swagger")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -150,7 +150,7 @@ func TestSwaggerEndpoint_Good_CustomBasePathRedirect(t *testing.T) {
api.WithSwaggerPath("/docs"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -164,7 +164,7 @@ func TestSwaggerEndpoint_Good_CustomBasePathRedirect(t *testing.T) {
resp, err := client.Get(srv.URL + "/docs")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -184,7 +184,7 @@ func TestSwaggerDisabledByDefault_Good(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/swagger/doc.json", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathSwaggerDoc, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
@@ -201,7 +201,7 @@ func TestSwaggerAuth_Good_CustomPathBypassesBearerAuth(t *testing.T) {
api.WithSwaggerPath("/docs"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -209,7 +209,7 @@ func TestSwaggerAuth_Good_CustomPathBypassesBearerAuth(t *testing.T) {
resp, err := http.Get(srv.URL + "/docs/doc.json")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -223,7 +223,7 @@ func TestSwagger_Good_SpecNotEmpty(t *testing.T) {
e, err := api.New(api.WithSwagger("Test API", "Test", "1.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
// Register a describable group so paths has more than just /health.
@@ -246,24 +246,24 @@ func TestSwagger_Good_SpecNotEmpty(t *testing.T) {
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths, ok := doc["paths"].(map[string]any)
@@ -286,7 +286,7 @@ func TestSwagger_Good_WithToolBridge(t *testing.T) {
e, err := api.New(api.WithSwagger("Tool API", "Tool test", "1.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
bridge := api.NewToolBridge("/api/tools")
@@ -308,20 +308,20 @@ func TestSwagger_Good_WithToolBridge(t *testing.T) {
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := doc["paths"].(map[string]any)
@@ -353,30 +353,30 @@ func TestSwagger_Good_IncludesSSEEndpoint(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(api.WithSwagger("SSE API", "SSE test", "1.0.0"), api.WithSSE(broker))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := doc["paths"].(map[string]any)
- pathItem, ok := paths["/events"].(map[string]any)
+ pathItem, ok := paths[pathEvents].(map[string]any)
if !ok {
t.Fatal("expected /events path in swagger doc")
}
@@ -397,33 +397,33 @@ func TestSwagger_Good_UsesCustomSSEPath(t *testing.T) {
api.WithSSEPath("/stream"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths := doc["paths"].(map[string]any)
if _, ok := paths["/stream"]; !ok {
t.Fatal("expected custom SSE path /stream in swagger doc")
}
- if _, ok := paths["/events"]; ok {
+ if _, ok := paths[pathEvents]; ok {
t.Fatal("did not expect default /events path when custom SSE path is configured")
}
}
@@ -452,26 +452,26 @@ func TestSwagger_Good_InfoFromOptions(t *testing.T) {
e, err := api.New(api.WithSwagger("MyTitle", "MyDesc", "2.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := doc["info"].(map[string]any)
@@ -491,37 +491,37 @@ func TestSwagger_Good_IncludesGraphQLEndpoint(t *testing.T) {
e, err := api.New(api.WithGraphQL(newTestSchema()), api.WithSwagger("Graph API", "GraphQL docs", "1.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
paths, ok := doc["paths"].(map[string]any)
if !ok {
t.Fatal("expected paths object in swagger doc")
}
- if _, ok := paths["/graphql"]; !ok {
+ if _, ok := paths[pathGraphQL]; !ok {
t.Fatal("expected /graphql path in swagger doc")
}
}
@@ -534,26 +534,26 @@ func TestSwagger_Good_UsesLicenseMetadata(t *testing.T) {
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := doc["info"].(map[string]any)
@@ -577,26 +577,26 @@ func TestSwagger_Good_UsesContactMetadata(t *testing.T) {
api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := doc["info"].(map[string]any)
@@ -623,26 +623,26 @@ func TestSwagger_Good_UsesTermsOfServiceMetadata(t *testing.T) {
api.WithSwaggerTermsOfService("https://example.com/terms"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := doc["info"].(map[string]any)
@@ -659,26 +659,26 @@ func TestSwagger_Good_UsesExternalDocsMetadata(t *testing.T) {
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
externalDocs, ok := doc["externalDocs"].(map[string]any)
@@ -708,26 +708,26 @@ func TestSwagger_Good_IgnoresBlankMetadataOverrides(t *testing.T) {
api.WithSwaggerExternalDocs("", ""),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
info := doc["info"].(map[string]any)
@@ -777,29 +777,29 @@ func TestSwagger_Good_UsesServerMetadata(t *testing.T) {
e, err := api.New(
api.WithSwagger("Server API", "Server metadata test", "1.0.0"),
- api.WithSwaggerServers(" https://api.example.com ", "/", "", "https://api.example.com"),
+ api.WithSwaggerServers(" https://api.example.com ", "/", "", apiBaseURL),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
servers, ok := doc["servers"].([]any)
@@ -811,8 +811,8 @@ func TestSwagger_Good_UsesServerMetadata(t *testing.T) {
}
first := servers[0].(map[string]any)
- if first["url"] != "https://api.example.com" {
- t.Fatalf("expected first server url=%q, got %v", "https://api.example.com", first["url"])
+ if first["url"] != apiBaseURL {
+ t.Fatalf("expected first server url=%q, got %v", apiBaseURL, first["url"])
}
second := servers[1].(map[string]any)
@@ -826,30 +826,30 @@ func TestSwagger_Good_AppendsServerMetadataAcrossCalls(t *testing.T) {
e, err := api.New(
api.WithSwagger("Server API", "Server metadata test", "1.0.0"),
- api.WithSwaggerServers("https://api.example.com", "/"),
- api.WithSwaggerServers(" https://docs.example.com ", "/", "https://api.example.com"),
+ api.WithSwaggerServers(apiBaseURL, "/"),
+ api.WithSwaggerServers(" https://docs.example.com ", "/", apiBaseURL),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
servers, ok := doc["servers"].([]any)
@@ -860,7 +860,7 @@ func TestSwagger_Good_AppendsServerMetadataAcrossCalls(t *testing.T) {
t.Fatalf("expected 3 normalised servers, got %d", len(servers))
}
- expected := []string{"https://api.example.com", "/", "https://docs.example.com"}
+ expected := []string{apiBaseURL, "/", "https://docs.example.com"}
for i, want := range expected {
got := servers[i].(map[string]any)["url"]
if got != want {
@@ -874,26 +874,26 @@ func TestSwagger_Good_ValidOpenAPI(t *testing.T) {
e, err := api.New(api.WithSwagger("OpenAPI Test", "Verify version", "1.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/swagger/doc.json")
+ resp, err := http.Get(srv.URL + pathSwaggerDoc)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if doc["openapi"] != "3.1.0" {
@@ -940,35 +940,35 @@ func TestOpenAPISpecEndpoint_Good(t *testing.T) {
api.WithOpenAPISpec(),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/v1/openapi.json")
+ resp, err := http.Get(srv.URL + pathOpenAPIJSON)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
- t.Fatalf("expected 200, got %d", resp.StatusCode)
+ t.Fatalf(fmtTestExpected200, resp.StatusCode)
}
- contentType := resp.Header.Get("Content-Type")
- if !core.HasPrefix(contentType, "application/json") {
+ contentType := resp.Header.Get(hdrContentType)
+ if !core.HasPrefix(contentType, mimeJSON) {
t.Fatalf("expected application/json content type, got %q", contentType)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if doc["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", doc["openapi"])
@@ -977,7 +977,7 @@ func TestOpenAPISpecEndpoint_Good(t *testing.T) {
if !ok {
t.Fatalf("expected paths map, got %T", doc["paths"])
}
- if _, ok := paths["/v1/openapi.json"]; !ok {
+ if _, ok := paths[pathOpenAPIJSON]; !ok {
t.Fatal("expected the spec endpoint to describe itself in paths")
}
}
@@ -992,7 +992,7 @@ func TestOpenAPISpecEndpoint_Good_CustomPath(t *testing.T) {
api.WithOpenAPISpecPath("/api/v1/openapi.json"),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -1000,7 +1000,7 @@ func TestOpenAPISpecEndpoint_Good_CustomPath(t *testing.T) {
resp, err := http.Get(srv.URL + "/api/v1/openapi.json")
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -1009,9 +1009,9 @@ func TestOpenAPISpecEndpoint_Good_CustomPath(t *testing.T) {
}
// Default path should 404 when overridden.
- defaultResp, err := http.Get(srv.URL + "/v1/openapi.json")
+ defaultResp, err := http.Get(srv.URL + pathOpenAPIJSON)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer defaultResp.Body.Close()
if defaultResp.StatusCode != http.StatusNotFound {
@@ -1026,15 +1026,15 @@ func TestOpenAPISpecEndpoint_Bad_DisabledByDefault(t *testing.T) {
e, err := api.New(api.WithSwagger("Test API", "A test API service", "1.0.0"))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/v1/openapi.json")
+ resp, err := http.Get(srv.URL + pathOpenAPIJSON)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -1051,15 +1051,15 @@ func TestOpenAPISpecEndpoint_Ugly_WorksWithoutSwagger(t *testing.T) {
e, err := api.New(api.WithOpenAPISpec())
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
- resp, err := http.Get(srv.URL + "/v1/openapi.json")
+ resp, err := http.Get(srv.URL + pathOpenAPIJSON)
if err != nil {
- t.Fatalf("request failed: %v", err)
+ t.Fatalf(fmtTestRequestFailed, err)
}
defer resp.Body.Close()
@@ -1069,11 +1069,11 @@ func TestOpenAPISpecEndpoint_Ugly_WorksWithoutSwagger(t *testing.T) {
body, err := io.ReadAll(resp.Body)
if err != nil {
- t.Fatalf("failed to read body: %v", err)
+ t.Fatalf(fmtTestFailedReadBody, err)
}
var doc map[string]any
if err := coreJSONUnmarshal(body, &doc); err != nil {
- t.Fatalf("invalid JSON: %v", err)
+ t.Fatalf(fmtTestInvalidJSON, err)
}
if doc["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", doc["openapi"])
diff --git a/go/test_constants_test.go b/go/test_constants_test.go
new file mode 100644
index 0000000..0d62e5a
--- /dev/null
+++ b/go/test_constants_test.go
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+// Shared test constants to avoid string literal duplication across test files.
+
+const (
+ fmtTestUnexpectedErr = "unexpected error: %v"
+ fmtTestExpected200 = "expected 200, got %d"
+ fmtTestExpected400 = "expected 400, got %d"
+ fmtTestUnmarshalErr = "unmarshal error: %v"
+ fmtTestRequestFailed = "request failed: %v"
+ fmtTestInvalidJSON = "invalid JSON: %v"
+ fmtTestFailedReadBody = "failed to read body: %v"
+ fmtTestExpectedSuc = "expected Success=true"
+ fmtTestExpectedFail = "expected Success=false"
+ fmtTestExpectedData = "expected Data=%q, got %q"
+ fmtTestExpectedName = "expected Name=%q, got %q"
+ fmtTestExpectedTags = "expected tags array, got %T"
+
+ hdrContentType = "Content-Type"
+ hdrContentEnc = "Content-Encoding"
+ hdrContentDisp = "Content-Disposition"
+ hdrAcceptEnc = "Accept-Encoding"
+ hdrCacheControl = "Cache-Control"
+ hdrXRequestID = "X-Request-ID"
+ hdrXFrameOptions = "X-Frame-Options"
+ hdrXCache = "X-Cache"
+
+ mimeJSON = "application/json"
+ mimeEventStream = "text/event-stream"
+
+ pathHealth = "/health"
+ pathStubPing = "/stub/ping"
+ pathEvents = "/events"
+ pathChatComplet = "/v1/chat/completions"
+ pathOpenAPIJSON = "/v1/openapi.json"
+ pathDebugVars = "/debug/vars"
+ pathDebugPprof = "/debug/pprof"
+ pathGraphQL = "/graphql"
+ pathGraphQLPlay = "/graphql/playground"
+ pathPublic = "/public"
+ pathStatus = "/status"
+ pathSwaggerDoc = "/swagger/doc.json"
+
+ apiKeyHeader = "X-API-Key"
+ apiBaseURL = "https://api.example.com"
+
+ hdrRateLimit = "X-RateLimit-Limit"
+ hdrRateRemaining = "X-RateLimit-Remaining"
+ hdrRateReset = "X-RateLimit-Reset"
+)
diff --git a/go/timeout_test.go b/go/timeout_test.go
index 98d4ac3..6f2203b 100644
--- a/go/timeout_test.go
+++ b/go/timeout_test.go
@@ -46,22 +46,22 @@ func TestWithTimeout_Good_FastRequestSucceeds(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if resp.Data != "pong" {
- t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
+ t.Fatalf(fmtTestExpectedData, "pong", resp.Data)
}
}
@@ -98,10 +98,10 @@ func TestWithTimeout_Good_TimeoutResponseEnvelope(t *testing.T) {
var resp api.Response[any]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil {
t.Fatal("expected Error to be non-nil")
@@ -124,25 +124,25 @@ func TestWithTimeout_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// WithRequestID should still set the header.
- id := w.Header().Get("X-Request-ID")
+ id := w.Header().Get(hdrXRequestID)
if id == "" {
t.Fatal("expected X-Request-ID header to be set")
}
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data != "pong" {
- t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
+ t.Fatalf(fmtTestExpectedData, "pong", resp.Data)
}
}
@@ -151,13 +151,13 @@ func TestWithTimeout_Ugly_ZeroDurationDoesNotPanic(t *testing.T) {
e, err := api.New(api.WithTimeout(0))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
e.Register(&stubGroup{})
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
@@ -166,9 +166,9 @@ func TestWithTimeout_Ugly_ZeroDurationDoesNotPanic(t *testing.T) {
var resp api.Response[string]
if err := coreJSONUnmarshal(w.Body.Bytes(), &resp); err != nil {
- t.Fatalf("unmarshal error: %v", err)
+ t.Fatalf(fmtTestUnmarshalErr, err)
}
if resp.Data != "pong" {
- t.Fatalf("expected Data=%q, got %q", "pong", resp.Data)
+ t.Fatalf(fmtTestExpectedData, "pong", resp.Data)
}
}
diff --git a/go/tracing_test.go b/go/tracing_test.go
index 4631874..d0dc033 100644
--- a/go/tracing_test.go
+++ b/go/tracing_test.go
@@ -108,6 +108,7 @@ func (g *traceEmptyGroup) Name() string { return "trace-empty" }
func (g *traceEmptyGroup) BasePath() string { return "/trace" }
func (g *traceEmptyGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/empty", func(c *gin.Context) {
+ // No-op endpoint; only used to verify that tracing creates a span for empty routes.
})
}
@@ -124,11 +125,11 @@ func TestWithTracing_Good_CreatesSpan(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
spans := exporter.GetSpans()
@@ -154,11 +155,11 @@ func TestWithTracing_Good_SpanHasHTTPAttributes(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
spans := exporter.GetSpans()
@@ -200,7 +201,7 @@ func TestWithTracing_Good_PropagatesTraceContext(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
// Inject a W3C traceparent header to simulate an upstream service.
// Format: version-traceID-spanID-flags
@@ -208,7 +209,7 @@ func TestWithTracing_Good_PropagatesTraceContext(t *testing.T) {
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
spans := exporter.GetSpans()
@@ -253,11 +254,11 @@ func TestWithTracing_Good_CombinesWithOtherMiddleware(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
// Tracing should produce spans.
@@ -267,7 +268,7 @@ func TestWithTracing_Good_CombinesWithOtherMiddleware(t *testing.T) {
}
// WithRequestID should set the X-Request-ID header.
- if w.Header().Get("X-Request-ID") == "" {
+ if w.Header().Get(hdrXRequestID) == "" {
t.Fatal("expected X-Request-ID header from WithRequestID")
}
}
@@ -284,11 +285,11 @@ func TestWithTracing_Good_ServiceNameInSpan(t *testing.T) {
h := e.Handler()
w := httptest.NewRecorder()
- req, _ := http.NewRequest(http.MethodGet, "/stub/ping", nil)
+ req, _ := http.NewRequest(http.MethodGet, pathStubPing, nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
spans := exporter.GetSpans()
@@ -386,11 +387,11 @@ func TestTracing_WithTracing_Good_AttachesDurationAndSizeAttributes(t *testing.T
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/trace/echo", core.NewReader("abc"))
- req.Header.Set("Content-Type", "text/plain")
+ req.Header.Set(hdrContentType, "text/plain")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
spans := exporter.GetSpans()
@@ -436,7 +437,7 @@ func TestTracing_WithTracing_Bad_SkipsAttributesWhenSpanIsNotRecording(t *testin
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
if group.sawRecording {
t.Fatal("expected no-op tracer provider to expose a non-recording span")
@@ -458,7 +459,7 @@ func TestTracing_WithTracing_Ugly_OmitsResponseSizeForEmptyResponses(t *testing.
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
- t.Fatalf("expected 200, got %d", w.Code)
+ t.Fatalf(fmtTestExpected200, w.Code)
}
spans := exporter.GetSpans()
diff --git a/go/transformer_test.go b/go/transformer_test.go
index d7fcfb4..5b30c5e 100644
--- a/go/transformer_test.go
+++ b/go/transformer_test.go
@@ -90,7 +90,7 @@ func TestTransformer_Good_ToolBridgeRemapsInboundAndOutboundDTOs(t *testing.T) {
t.Fatalf("unmarshal response: %v", err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if resp.Data["full_name"] != "Ada Lovelace" {
t.Fatalf("expected external full_name, got %v", resp.Data)
@@ -129,7 +129,7 @@ func TestTransformer_Bad_ToolBridgeValidatesExternalPayloadBeforeTransform(t *te
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
@@ -137,7 +137,7 @@ func TestTransformer_Bad_ToolBridgeValidatesExternalPayloadBeforeTransform(t *te
t.Fatalf("unmarshal response: %v", err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
t.Fatalf("expected invalid_request_body, got %#v", resp.Error)
@@ -208,7 +208,7 @@ func TestTransformer_Good_EngineRouteDescriptionRemapsDTOs(t *testing.T) {
t.Fatalf("unmarshal response: %v", err)
}
if !resp.Success {
- t.Fatal("expected Success=true")
+ t.Fatal(fmtTestExpectedSuc)
}
if resp.Data["full_name"] != "Grace Hopper" {
t.Fatalf("expected outbound field rename, got %v", resp.Data)
@@ -232,7 +232,7 @@ func TestTransformer_Bad_EngineTransformerErrorReturnsBadRequest(t *testing.T) {
engine.Handler().ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("expected 400, got %d", w.Code)
+ t.Fatalf(fmtTestExpected400, w.Code)
}
var resp api.Response[any]
@@ -240,7 +240,7 @@ func TestTransformer_Bad_EngineTransformerErrorReturnsBadRequest(t *testing.T) {
t.Fatalf("unmarshal response: %v", err)
}
if resp.Success {
- t.Fatal("expected Success=false")
+ t.Fatal(fmtTestExpectedFail)
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
t.Fatalf("expected invalid_request_body, got %#v", resp.Error)
diff --git a/go/transport.go b/go/transport.go
index a61ae02..cdebe10 100644
--- a/go/transport.go
+++ b/go/transport.go
@@ -29,6 +29,7 @@ type TransportConfig struct {
ChatCompletionsPath string
OpenAPISpecEnabled bool
OpenAPISpecPath string
+ UpstreamRouterPaths []string
}
// TransportConfig returns the currently configured transport metadata for the engine.
@@ -50,7 +51,7 @@ func (e *Engine) TransportConfig() TransportConfig {
SSEEnabled: e.sseBroker != nil,
PprofEnabled: e.pprofEnabled,
ExpvarEnabled: e.expvarEnabled,
- ChatCompletionsEnabled: e.chatCompletionsResolver != nil,
+ ChatCompletionsEnabled: e.chatCompletionsResolver != nil || e.chatRemote != nil,
OpenAPISpecEnabled: e.openAPISpecEnabled,
}
gql := e.GraphQLConfig()
@@ -76,6 +77,9 @@ func (e *Engine) TransportConfig() TransportConfig {
if e.openAPISpecEnabled || core.Trim(e.openAPISpecPath) != "" {
cfg.OpenAPISpecPath = resolveOpenAPISpecPath(e.openAPISpecPath)
}
+ if e.upstreamRouter != nil {
+ cfg.UpstreamRouterPaths = append([]string(nil), e.upstreamRouter.paths...)
+ }
return cfg
}
diff --git a/go/transport_client_test.go b/go/transport_client_test.go
index 953dfdb..ead40ac 100644
--- a/go/transport_client_test.go
+++ b/go/transport_client_test.go
@@ -390,7 +390,7 @@ func TestTransportClient_Connect_Good_SetsAcceptHeaderAndReturnsResponse(t *test
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawAccept = r.Header.Get("Accept")
sawToken = r.Header.Get("Authorization")
- w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set(hdrContentType, "text/event-stream")
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "event: ping\ndata: hello\n\n")
}))
@@ -474,7 +474,7 @@ func TestTransportClient_Events_Good_ParsesStream(t *testing.T) {
}...)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set(hdrContentType, "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
_, _ = io.WriteString(w, payload)
@@ -508,7 +508,7 @@ func TestTransportClient_Events_Bad_ContextCancelledClosesChannel(t *testing.T)
started := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "text/event-stream")
+ w.Header().Set(hdrContentType, "text/event-stream")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
_, _ = io.WriteString(w, "data: one\n\n")
diff --git a/go/transport_test.go b/go/transport_test.go
index e1288f6..bdb070a 100644
--- a/go/transport_test.go
+++ b/go/transport_test.go
@@ -2,7 +2,10 @@
package api
-import "testing"
+import (
+ "reflect"
+ "testing"
+)
func TestTransport_normaliseChatCompletionsPath_Good_TrimsAndKeepsCustomPath(t *testing.T) {
if got := normaliseChatCompletionsPath(" /chat/ "); got != "/chat" {
@@ -28,7 +31,7 @@ func TestTransport_normaliseChatCompletionsPath_Ugly_FallsBackToDefaultWhenRoot(
func TestTransport_TransportConfig_Ugly_NilEngineReturnsZeroValue(t *testing.T) {
var e *Engine
- if got := e.TransportConfig(); got != (TransportConfig{}) {
+ if got := e.TransportConfig(); !reflect.DeepEqual(got, TransportConfig{}) {
t.Fatalf("expected zero-value transport config for nil engine, got %+v", got)
}
}
diff --git a/go/upstream_balancer.go b/go/upstream_balancer.go
new file mode 100644
index 0000000..f520922
--- /dev/null
+++ b/go/upstream_balancer.go
@@ -0,0 +1,75 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "sync"
+ "time"
+)
+
+// upstreamBalancer performs smooth weighted round-robin selection over a pool,
+// skipping upstreams in a cooldown window after a failure. State (per-key
+// current weights, per-URL cooldown) is shared across requests behind a mutex —
+// a failed upstream cools for every caller. The now func is injectable for tests.
+type upstreamBalancer struct {
+ mu sync.Mutex
+ current map[string]map[string]int // key -> url -> SWRR current weight
+ cooldown map[string]time.Time // url -> cooling-until (global across keys)
+ cool time.Duration
+ now func() time.Time
+}
+
+func newUpstreamBalancer(cool time.Duration, now func() time.Time) *upstreamBalancer {
+ if now == nil {
+ now = time.Now
+ }
+ return &upstreamBalancer{
+ current: map[string]map[string]int{},
+ cooldown: map[string]time.Time{},
+ cool: cool,
+ now: now,
+ }
+}
+
+// pick selects the next upstream for key via smooth weighted round-robin over the
+// non-cooling members of pool. Returns false when every member is cooling.
+func (b *upstreamBalancer) pick(key string, pool []Upstream) (Upstream, bool) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+
+ t := b.now()
+ cw := b.current[key]
+ if cw == nil {
+ cw = map[string]int{}
+ b.current[key] = cw
+ }
+
+ bestIdx, total := -1, 0
+ for i := range pool {
+ up := pool[i]
+ if until, ok := b.cooldown[up.URL]; ok && t.Before(until) {
+ continue
+ }
+ w := up.Weight
+ if w <= 0 {
+ w = 1
+ }
+ cw[up.URL] += w
+ total += w
+ if bestIdx == -1 || cw[up.URL] > cw[pool[bestIdx].URL] {
+ bestIdx = i
+ }
+ }
+ if bestIdx == -1 {
+ return Upstream{}, false
+ }
+ cw[pool[bestIdx].URL] -= total
+ return pool[bestIdx], true
+}
+
+// markFailed puts url into a cooldown window starting now.
+func (b *upstreamBalancer) markFailed(url string) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ b.cooldown[url] = b.now().Add(b.cool)
+}
diff --git a/go/upstream_balancer_internal_test.go b/go/upstream_balancer_internal_test.go
new file mode 100644
index 0000000..b57fcdd
--- /dev/null
+++ b/go/upstream_balancer_internal_test.go
@@ -0,0 +1,74 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+func TestUpstreamBalancer_WeightedSpread_Good(t *testing.T) {
+ b := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ pool := []Upstream{{URL: "a", Weight: 2}, {URL: "b", Weight: 1}}
+ counts := map[string]int{}
+ for i := 0; i < 30; i++ {
+ up, ok := b.pick("k", pool)
+ if !ok {
+ t.Fatal("pick returned !ok with healthy pool")
+ }
+ counts[up.URL]++
+ }
+ if counts["a"] != 20 || counts["b"] != 10 {
+ t.Fatalf("weighted spread = %v, want a:20 b:10", counts)
+ }
+}
+
+func TestUpstreamBalancer_CooldownSkip_Good(t *testing.T) {
+ now := time.Unix(1000, 0)
+ clock := func() time.Time { return now } // now is mutated below; clock() reads it live by closure
+ b := newUpstreamBalancer(10*time.Second, clock)
+ pool := []Upstream{{URL: "a", Weight: 1}, {URL: "b", Weight: 1}}
+
+ b.markFailed("a")
+ for i := 0; i < 5; i++ {
+ up, ok := b.pick("k", pool)
+ if !ok || up.URL != "b" {
+ t.Fatalf("during cooldown got (%v,%v), want b", up.URL, ok)
+ }
+ }
+ now = now.Add(11 * time.Second) // cooldown elapsed
+ seen := map[string]bool{}
+ for i := 0; i < 10; i++ {
+ up, _ := b.pick("k", pool)
+ seen[up.URL] = true
+ }
+ if !seen["a"] {
+ t.Fatal("a not picked after cooldown elapsed")
+ }
+}
+
+func TestUpstreamBalancer_AllCooling_Bad(t *testing.T) {
+ b := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ pool := []Upstream{{URL: "a"}, {URL: "b"}}
+ b.markFailed("a")
+ b.markFailed("b")
+ if _, ok := b.pick("k", pool); ok {
+ t.Fatal("pick returned ok with all upstreams cooling")
+ }
+}
+
+// TestUpstreamBalancer_ConcurrentPickMark_Ugly hammers the shared mutex from
+// many goroutines (Task 3 drives this balancer concurrently). It asserts
+// nothing beyond running clean under -race: no data race, no panic.
+func TestUpstreamBalancer_ConcurrentPickMark_Ugly(t *testing.T) {
+ b := newUpstreamBalancer(time.Minute, time.Now)
+ pool := []Upstream{{URL: "a", Weight: 2}, {URL: "b", Weight: 1}}
+ var wg sync.WaitGroup
+ for i := 0; i < 100; i++ {
+ wg.Add(2)
+ go func() { defer wg.Done(); _, _ = b.pick("k", pool) }()
+ go func() { defer wg.Done(); b.markFailed("a") }()
+ }
+ wg.Wait()
+}
diff --git a/go/upstream_registry.go b/go/upstream_registry.go
new file mode 100644
index 0000000..f9a50d7
--- /dev/null
+++ b/go/upstream_registry.go
@@ -0,0 +1,275 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "net" // Note: AX-6 — net.ParseIP/ParseCIDR are structural for SSRF IP-range checks.
+ "net/url" // Note: AX-6 — url.URL fields are structural for upstream URL validation.
+ "sort"
+ "strconv"
+ "sync"
+ "sync/atomic"
+
+ core "dappco.re/go"
+)
+
+// Upstream is one backend endpoint in a routing pool.
+//
+// Example:
+//
+// api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2}
+type Upstream struct {
+ URL string // http(s) base URL; validated at registration
+ Weight int // weighted round-robin weight; <=0 treated as 1
+ Headers map[string]string // static headers injected on dispatch (e.g. upstream API key)
+}
+
+// registrySnapshot is the immutable read-side view swapped atomically on writes.
+type registrySnapshot struct {
+ pools map[string][]Upstream
+ deflt []Upstream
+}
+
+// UpstreamRegistry is the runtime-mutable, thread-safe pool table consumed by
+// WithUpstreamRouter. Reads are lock-free (atomic snapshot load); writes take a
+// mutex, clone, mutate, and swap (copy-on-write).
+//
+// Example:
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+// _ = reg.Set("lemma", api.Upstream{URL: "http://127.0.0.1:11434"})
+type UpstreamRegistry struct {
+ mu sync.Mutex
+ snap atomic.Pointer[registrySnapshot]
+ allow []*net.IPNet
+ cidrErr error
+}
+
+// RegistryOption configures registration-time validation policy.
+type RegistryOption func(*UpstreamRegistry)
+
+// AllowPrivateUpstreams permits the given private/loopback/reserved CIDRs to
+// pass registration validation. Without it the registry denies loopback,
+// private, link-local, reserved, and metadata destinations by default. Metadata
+// hosts stay hard-blocked regardless of the allow-list.
+//
+// Example:
+//
+// reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8"))
+func AllowPrivateUpstreams(cidrs ...string) RegistryOption {
+ return func(r *UpstreamRegistry) {
+ for _, raw := range cidrs {
+ raw = core.Trim(raw)
+ if raw == "" {
+ continue
+ }
+ _, network, err := net.ParseCIDR(raw)
+ if err != nil {
+ if r.cidrErr == nil {
+ r.cidrErr = core.E("UpstreamRegistry", "invalid AllowPrivateUpstreams CIDR "+raw, err)
+ }
+ continue
+ }
+ r.allow = append(r.allow, network)
+ }
+ }
+}
+
+// NewUpstreamRegistry creates an empty registry. Apply AllowPrivateUpstreams to
+// widen the default-deny validation policy.
+func NewUpstreamRegistry(opts ...RegistryOption) *UpstreamRegistry {
+ r := &UpstreamRegistry{}
+ for _, opt := range opts {
+ if opt != nil {
+ opt(r)
+ }
+ }
+ r.snap.Store(®istrySnapshot{pools: map[string][]Upstream{}})
+ return r
+}
+
+// Set replaces the pool for key. Returns an error (without mutating) if any
+// upstream URL fails validation.
+func (r *UpstreamRegistry) Set(key string, ups ...Upstream) error {
+ if err := r.validateAll(ups); err != nil {
+ return err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ next.pools[key] = cloneUpstreams(ups)
+ r.snap.Store(next)
+ return nil
+}
+
+// Add appends one upstream to the pool for key.
+func (r *UpstreamRegistry) Add(key string, up Upstream) error {
+ if err := r.validate(up); err != nil {
+ return err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ next.pools[key] = append(cloneUpstreams(next.pools[key]), up)
+ r.snap.Store(next)
+ return nil
+}
+
+// Remove drops the pool for key.
+func (r *UpstreamRegistry) Remove(key string) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ delete(next.pools, key)
+ r.snap.Store(next)
+}
+
+// SetDefault sets the fallback pool used when a key has no explicit pool.
+func (r *UpstreamRegistry) SetDefault(ups ...Upstream) error {
+ if len(ups) == 0 {
+ return core.E("UpstreamRegistry", "SetDefault requires at least one upstream", nil)
+ }
+ if err := r.validateEach(ups); err != nil {
+ return err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ next := r.clone()
+ next.deflt = cloneUpstreams(ups)
+ r.snap.Store(next)
+ return nil
+}
+
+// Keys returns the sorted set of explicitly-registered pool keys.
+func (r *UpstreamRegistry) Keys() []string {
+ snap := r.snap.Load()
+ keys := make([]string, 0, len(snap.pools))
+ for k := range snap.pools {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
+
+// resolve returns the pool for key (or the default pool) and whether one exists.
+// The returned slice is the snapshot's own backing slice — callers must treat it
+// as read-only and never mutate its elements or append to it in place.
+func (r *UpstreamRegistry) resolve(key string) ([]Upstream, bool) {
+ snap := r.snap.Load()
+ if pool, ok := snap.pools[key]; ok && len(pool) > 0 {
+ return pool, true
+ }
+ if len(snap.deflt) > 0 {
+ return snap.deflt, true
+ }
+ return nil, false
+}
+
+func (r *UpstreamRegistry) clone() *registrySnapshot {
+ cur := r.snap.Load()
+ next := ®istrySnapshot{
+ pools: make(map[string][]Upstream, len(cur.pools)),
+ deflt: cloneUpstreams(cur.deflt),
+ }
+ for k, v := range cur.pools {
+ next.pools[k] = v
+ }
+ return next
+}
+
+func (r *UpstreamRegistry) validateAll(ups []Upstream) error {
+ if len(ups) == 0 {
+ return core.E("UpstreamRegistry", "pool must contain at least one upstream", nil)
+ }
+ return r.validateEach(ups)
+}
+
+// validateEach validates every upstream in ups without the non-empty check, so
+// callers can supply their own empty-pool message.
+func (r *UpstreamRegistry) validateEach(ups []Upstream) error {
+ for _, up := range ups {
+ if err := r.validate(up); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (r *UpstreamRegistry) validate(up Upstream) error {
+ if r.cidrErr != nil {
+ return r.cidrErr
+ }
+ return validateUpstreamURL(up.URL, r.allow)
+}
+
+// validateUpstreamURL enforces the block-by-default registration policy, reusing
+// the root SSRF primitives (allowedSchemes, metadataHosts, blockedIPReason).
+// Non-metadata hostnames are accepted without registration-time DNS (trusted
+// config). IP literals in a denied range are rejected unless covered by allow.
+func validateUpstreamURL(rawURL string, allow []*net.IPNet) error {
+ rawURL = core.Trim(rawURL)
+ if rawURL == "" {
+ return core.E("UpstreamRegistry", "upstream URL is required", nil)
+ }
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return core.E("UpstreamRegistry", "invalid upstream URL "+rawURL, err)
+ }
+ if u.User != nil {
+ return core.E("UpstreamRegistry", "upstream URL must not include credentials: "+rawURL, nil)
+ }
+ if _, ok := allowedSchemes[core.Lower(u.Scheme)]; !ok {
+ return core.E("UpstreamRegistry", "upstream URL scheme must be http or https: "+rawURL, nil)
+ }
+ host := u.Hostname()
+ if host == "" {
+ return core.E("UpstreamRegistry", "upstream URL must include a host: "+rawURL, nil)
+ }
+ if port := u.Port(); port != "" {
+ n, perr := strconv.Atoi(port)
+ if perr != nil || n < 1 || n > 65535 {
+ return core.E("UpstreamRegistry", "upstream URL port is invalid: "+rawURL, perr)
+ }
+ }
+ if _, ok := metadataHosts[core.Lower(host)]; ok {
+ return core.E("UpstreamRegistry", "metadata host is not permitted: "+host, nil)
+ }
+ if ip := net.ParseIP(host); ip != nil {
+ if reason := blockedIPReason(ip); reason != "" && !ipAllowed(ip, allow) {
+ return core.E("UpstreamRegistry", reason+" not permitted (use AllowPrivateUpstreams): "+host, nil)
+ }
+ }
+ return nil
+}
+
+func ipAllowed(ip net.IP, allow []*net.IPNet) bool {
+ for _, network := range allow {
+ if network.Contains(ip) {
+ return true
+ }
+ }
+ return false
+}
+
+// cloneUpstreams returns a deep copy of ups. The Headers map on each upstream is
+// copied into a fresh map so a caller mutating their original map after a write
+// — or the transport iterating it concurrently — cannot race the stored snapshot.
+func cloneUpstreams(ups []Upstream) []Upstream {
+ if len(ups) == 0 {
+ return nil
+ }
+ out := make([]Upstream, len(ups))
+ copy(out, ups)
+ for i := range out {
+ if len(out[i].Headers) == 0 {
+ out[i].Headers = nil
+ continue
+ }
+ headers := make(map[string]string, len(out[i].Headers))
+ for k, v := range out[i].Headers {
+ headers[k] = v
+ }
+ out[i].Headers = headers
+ }
+ return out
+}
diff --git a/go/upstream_registry_internal_test.go b/go/upstream_registry_internal_test.go
new file mode 100644
index 0000000..545efb9
--- /dev/null
+++ b/go/upstream_registry_internal_test.go
@@ -0,0 +1,107 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "sync"
+ "testing"
+)
+
+// TestUpstreamRegistry_ResolveDefaultFallback_Good covers the unexported resolve
+// path: an unknown key must fall back to the default pool when one is set.
+func TestUpstreamRegistry_ResolveDefaultFallback_Good(t *testing.T) {
+ reg := NewUpstreamRegistry()
+ if err := reg.Set("known", Upstream{URL: "https://known.example.com"}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ if err := reg.SetDefault(Upstream{URL: "https://fallback.example.com"}); err != nil {
+ t.Fatalf("SetDefault: %v", err)
+ }
+
+ pool, ok := reg.resolve("unknown")
+ if !ok {
+ t.Fatal("resolve(unknown) = !ok, want default-pool fallback")
+ }
+ if len(pool) != 1 || pool[0].URL != "https://fallback.example.com" {
+ t.Fatalf("resolve(unknown) = %v, want the default pool", pool)
+ }
+
+ // An explicitly-registered key still resolves to its own pool, not default.
+ pool, ok = reg.resolve("known")
+ if !ok || len(pool) != 1 || pool[0].URL != "https://known.example.com" {
+ t.Fatalf("resolve(known) = (%v,%v), want the known pool", pool, ok)
+ }
+}
+
+// TestUpstreamRegistry_ResolveNoDefault_Bad covers resolve with no matching key
+// and no default pool: it must report !ok.
+func TestUpstreamRegistry_ResolveNoDefault_Bad(t *testing.T) {
+ reg := NewUpstreamRegistry()
+ if _, ok := reg.resolve("missing"); ok {
+ t.Fatal("resolve(missing) = ok with no default pool, want !ok")
+ }
+}
+
+// TestUpstreamRegistry_RemoveThenResolve_Good proves Remove drops a key so that
+// resolve no longer returns its pool and falls through to the default.
+func TestUpstreamRegistry_RemoveThenResolve_Good(t *testing.T) {
+ reg := NewUpstreamRegistry()
+ if err := reg.Set("k", Upstream{URL: "https://a.example.com"}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ if err := reg.SetDefault(Upstream{URL: "https://fallback.example.com"}); err != nil {
+ t.Fatalf("SetDefault: %v", err)
+ }
+
+ reg.Remove("k")
+
+ pool, ok := reg.resolve("k")
+ if !ok || len(pool) != 1 || pool[0].URL != "https://fallback.example.com" {
+ t.Fatalf("resolve(k) after Remove = (%v,%v), want the default pool", pool, ok)
+ }
+}
+
+// TestUpstreamRegistry_Ugly_HeadersDeepCopy proves the registry deep-copies an
+// upstream's Headers map so the stored snapshot does not alias the caller's map.
+// One goroutine mutates the caller's original map; another iterates the stored
+// (cloned) map fetched via resolve. With a shallow copy both touch the same map
+// and the race detector fires (and "concurrent map writes" panics); with the
+// deep copy the stored map is independent, so neither happens.
+func TestUpstreamRegistry_Ugly_HeadersDeepCopy(t *testing.T) {
+ reg := NewUpstreamRegistry()
+ headers := map[string]string{"Authorization": "Bearer up-key"}
+ if err := reg.Set("k", Upstream{URL: "https://a.example.com", Headers: headers}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+
+ stored, ok := reg.resolve("k")
+ if !ok || len(stored) != 1 || stored[0].Headers == nil {
+ t.Fatalf("resolve(k) = (%v,%v), want one upstream with Headers", stored, ok)
+ }
+ storedHeaders := stored[0].Headers
+
+ var wg sync.WaitGroup
+ wg.Add(2)
+ // Single caller-side mutator: serialised writes to the original map, so any
+ // race surfaced is strictly caller-map vs stored-map aliasing, not the test
+ // racing itself.
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 1000; i++ {
+ headers["Authorization"] = "Bearer rotated"
+ }
+ }()
+ // Reader iterating the stored map — must be a distinct map after the deep copy.
+ go func() {
+ defer wg.Done()
+ for i := 0; i < 1000; i++ {
+ for range storedHeaders {
+ }
+ }
+ }()
+ wg.Wait()
+
+ if got := storedHeaders["Authorization"]; got != "Bearer up-key" {
+ t.Fatalf("stored Headers mutated by caller = %q, want unchanged Bearer up-key", got)
+ }
+}
diff --git a/go/upstream_registry_test.go b/go/upstream_registry_test.go
new file mode 100644
index 0000000..3d507fd
--- /dev/null
+++ b/go/upstream_registry_test.go
@@ -0,0 +1,92 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "sync"
+ "testing"
+
+ api "dappco.re/go/api"
+)
+
+func TestUpstreamRegistry_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ if err := reg.Set("lemma", api.Upstream{URL: "https://a.example.com:8000", Weight: 2}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ if err := reg.Add("lemma", api.Upstream{URL: "https://b.example.com"}); err != nil {
+ t.Fatalf("Add: %v", err)
+ }
+ if err := reg.SetDefault(api.Upstream{URL: "https://fallback.example.com"}); err != nil {
+ t.Fatalf("SetDefault: %v", err)
+ }
+ keys := reg.Keys()
+ if len(keys) != 1 || keys[0] != "lemma" {
+ t.Fatalf("Keys = %v, want [lemma]", keys)
+ }
+}
+
+func TestUpstreamRegistry_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ cases := map[string]string{
+ "scheme": "ftp://a.example.com",
+ "no-host": "http://",
+ "bad-port": "http://a.example.com:99999",
+ "creds": "http://user:pass@a.example.com",
+ "loopback": "http://127.0.0.1:11434",
+ "private": "http://10.0.0.5:8000",
+ "metadata": "http://169.254.169.254",
+ }
+ for name, raw := range cases {
+ if err := reg.Set("k", api.Upstream{URL: raw}); err == nil {
+ t.Errorf("%s: Set(%q) = nil error, want rejection", name, raw)
+ }
+ }
+}
+
+func TestUpstreamRegistry_AllowPrivate_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("local", api.Upstream{URL: "http://127.0.0.1:11434"}); err != nil {
+ t.Fatalf("Set loopback with allow-list: %v", err)
+ }
+ // Metadata stays hard-blocked even with a broad allow-list.
+ reg2 := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("0.0.0.0/0"))
+ if err := reg2.Set("meta", api.Upstream{URL: "http://169.254.169.254"}); err == nil {
+ t.Fatal("metadata host accepted under broad allow-list, want rejection")
+ }
+}
+
+func TestUpstreamRegistry_Remove_Good(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ if err := reg.Set("k", api.Upstream{URL: "https://a.example.com"}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ reg.Remove("k")
+ if keys := reg.Keys(); len(keys) != 0 {
+ t.Fatalf("Keys after Remove = %v, want []", keys)
+ }
+}
+
+func TestUpstreamRegistry_BadCIDR_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("not-a-cidr"))
+ // The recorded cidrErr must surface on every subsequent write, even for an
+ // otherwise-valid public upstream.
+ if err := reg.Set("k", api.Upstream{URL: "https://a.example.com"}); err == nil {
+ t.Fatal("Set with recorded bad-CIDR error = nil, want rejection")
+ }
+ if err := reg.Add("k", api.Upstream{URL: "https://b.example.com"}); err == nil {
+ t.Fatal("Add with recorded bad-CIDR error = nil, want rejection")
+ }
+}
+
+func TestUpstreamRegistry_Ugly_ConcurrentWriteSnapshot(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ _ = reg.Set("k", api.Upstream{URL: "https://a.example.com"})
+ var wg sync.WaitGroup
+ for i := 0; i < 50; i++ {
+ wg.Add(2)
+ go func() { defer wg.Done(); _ = reg.Add("k", api.Upstream{URL: "https://b.example.com"}) }()
+ go func() { defer wg.Done(); _ = reg.Keys() }()
+ }
+ wg.Wait()
+}
diff --git a/go/upstream_router.go b/go/upstream_router.go
new file mode 100644
index 0000000..6d9e8f0
--- /dev/null
+++ b/go/upstream_router.go
@@ -0,0 +1,359 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httputil" // Note: AX-6 — reverse-proxy mechanics are structural; no core primitive.
+ "net/url" // Note: AX-6 — url.Parse is structural for the Rewrite placeholder target.
+ "strconv"
+ "time"
+
+ core "dappco.re/go"
+
+ "github.com/gin-gonic/gin"
+)
+
+const (
+ defaultUpstreamRouterPath = "/v1/chat/completions"
+ defaultUpstreamCooldown = 10 * time.Second
+
+ errCodeInvalidRequest = "invalid_request"
+ errCodeInvalidRequestBody = "invalid_request_body"
+ errCodeRoutingRejected = "routing_rejected"
+ errCodeNoUpstream = "no_upstream_for_key"
+ errCodeRequestTooLarge = "request_too_large"
+ errCodeUpstreamUnavailable = "upstream_unavailable"
+ errCodeInvalidUpstreamResp = "invalid_upstream_response"
+)
+
+// maxUpstreamResponseBytes caps the buffered read in modifyResponse so a
+// non-streaming upstream response with out-transformers cannot allocate without
+// bound (the request path is already capped by maxToolRequestBodyBytes). It is a
+// var so tests can lower it without minting a real 10 MiB body, mirroring the
+// resolveHost override idiom in ssrf_guard.go.
+var maxUpstreamResponseBytes = int64(maxToolRequestBodyBytes) // 10 MiB; buffered only for non-stream responses with out-transformers
+
+type ctxKey int
+
+const (
+ poolCtxKey ctxKey = iota
+ keyCtxKey
+ ginCtxKey
+)
+
+// Selector resolves the routing key from the request. body holds the (bounded)
+// request body, already read by the handler; it may be empty for bodyless requests.
+type Selector func(c *gin.Context, body []byte) (key string, err error)
+
+// RouteFunc inspects the payload after the selector and may override the key or
+// reject the request. Returning the same key is a no-op; a non-nil error aborts.
+type RouteFunc func(c *gin.Context, key string, body []byte) (newKey string, err error)
+
+// UpstreamRouterOption configures a router built by WithUpstreamRouter.
+type UpstreamRouterOption func(*upstreamRouterConfig)
+
+type upstreamRouterConfig struct {
+ registry *UpstreamRegistry
+ selector Selector
+ hook RouteFunc
+ paths []string
+ inRaw []any
+ outRaw []any
+ in []compiledTransformer
+ out []compiledTransformer
+ maxAttempts int
+ cooldown time.Duration
+ failover map[int]bool
+ transport http.RoundTripper
+}
+
+// routerError carries an HTTP status + envelope code from the transport or
+// ModifyResponse to the ReverseProxy ErrorHandler.
+type routerError struct {
+ status int
+ code string
+ message string
+ cause error
+}
+
+func (e *routerError) Error() string {
+ if e.cause != nil {
+ return e.message + ": " + e.cause.Error()
+ }
+ return e.message
+}
+
+func (e *routerError) Unwrap() error { return e.cause }
+
+// WithSelector overrides the routing-key selector. Default: defaultModelSelector.
+func WithSelector(fn Selector) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.selector = fn }
+}
+
+// WithRouteHook installs a decision hook to inspect the payload and override/reject.
+func WithRouteHook(fn RouteFunc) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.hook = fn }
+}
+
+// WithRouterPaths sets the mounted paths (default ["/v1/chat/completions"]).
+// Each path forwards its own path + query to the chosen upstream.
+func WithRouterPaths(paths ...string) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.paths = paths }
+}
+
+// WithUpstreamTransformerIn adds request-body transformers (reuses the existing
+// TransformerIn machinery; FieldRenamer etc. work). Operates on the raw body.
+func WithUpstreamTransformerIn(t ...any) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.inRaw = append(cfg.inRaw, t...) }
+}
+
+// WithUpstreamTransformerOut adds response-body transformers, applied only to
+// buffered (non-streaming) responses, on the raw upstream body.
+func WithUpstreamTransformerOut(t ...any) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.outRaw = append(cfg.outRaw, t...) }
+}
+
+// WithFailover sets the max upstream attempts (default len(pool), each tried once)
+// and the cooldown applied to a failed upstream (default 10s).
+func WithFailover(maxAttempts int, cooldown time.Duration) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) {
+ cfg.maxAttempts = maxAttempts
+ if cooldown > 0 {
+ cfg.cooldown = cooldown
+ }
+ }
+}
+
+// WithFailoverStatuses overrides which response statuses trigger failover
+// (default: all >= 500). Pass e.g. 429 to also fail over on rate-limit responses.
+// Passing zero statuses disables status-based failover (transport errors still
+// fail over).
+func WithFailoverStatuses(statuses ...int) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) {
+ cfg.failover = map[int]bool{}
+ for _, s := range statuses {
+ cfg.failover[s] = true
+ }
+ }
+}
+
+// WithUpstreamTransport sets the base RoundTripper used for dispatch (custom TLS,
+// timeouts). Default: a clone of http.DefaultTransport.
+func WithUpstreamTransport(rt http.RoundTripper) UpstreamRouterOption {
+ return func(cfg *upstreamRouterConfig) { cfg.transport = rt }
+}
+
+// defaultFailoverStatuses returns the default failover status set: all >= 500.
+func defaultFailoverStatuses() map[int]bool {
+ m := map[int]bool{}
+ for s := 500; s <= 599; s++ {
+ m[s] = true
+ }
+ return m
+}
+
+// defaultModelSelector reads the OpenAI-style "model" field from a JSON body.
+func defaultModelSelector(_ *gin.Context, body []byte) (string, error) {
+ var probe struct {
+ Model string `json:"model"`
+ }
+ if res := core.JSONUnmarshal(body, &probe); !res.OK {
+ return "", core.E("upstream.selector", "request body is not valid JSON", nil)
+ }
+ if core.Trim(probe.Model) == "" {
+ return "", core.E("upstream.selector", "request body has no \"model\" field", nil)
+ }
+ return probe.Model, nil
+}
+
+func poolFromContext(ctx context.Context) ([]Upstream, bool) {
+ pool, ok := ctx.Value(poolCtxKey).([]Upstream)
+ return pool, ok
+}
+
+func keyFromContext(ctx context.Context) (string, bool) {
+ key, ok := ctx.Value(keyCtxKey).(string)
+ return key, ok
+}
+
+// finalise resolves defaults and compiles transformer pipelines. Returns an
+// error if a transformer fails to compile.
+func (cfg *upstreamRouterConfig) finalise() error {
+ if cfg.selector == nil {
+ cfg.selector = defaultModelSelector
+ }
+ if len(cfg.paths) == 0 {
+ cfg.paths = []string{defaultUpstreamRouterPath}
+ }
+ if cfg.cooldown <= 0 {
+ cfg.cooldown = defaultUpstreamCooldown
+ }
+ if cfg.failover == nil {
+ cfg.failover = defaultFailoverStatuses()
+ }
+ if cfg.transport == nil {
+ // Clone so the router owns an isolated connection pool rather than
+ // mutating/sharing the process-wide http.DefaultTransport.
+ cfg.transport = http.DefaultTransport.(*http.Transport).Clone()
+ }
+ in, err := compileTransformerPipeline(transformerDirectionIn, cfg.inRaw)
+ if err != nil {
+ return err
+ }
+ out, err := compileTransformerPipeline(transformerDirectionOut, cfg.outRaw)
+ if err != nil {
+ return err
+ }
+ cfg.in, cfg.out = in, out
+ return nil
+}
+
+// buildProxy constructs the shared ReverseProxy for the router.
+func (cfg *upstreamRouterConfig) buildProxy() *httputil.ReverseProxy {
+ balancer := newUpstreamBalancer(cfg.cooldown, time.Now)
+ transport := &upstreamTransport{
+ base: cfg.transport,
+ balancer: balancer,
+ maxAttempts: cfg.maxAttempts,
+ failover: cfg.failover,
+ }
+ return &httputil.ReverseProxy{
+ Transport: transport,
+ FlushInterval: -1, // stream SSE / chunked responses through immediately
+ Rewrite: func(pr *httputil.ProxyRequest) {
+ // Placeholder target so the pipeline has a valid URL; the transport
+ // overrides scheme/host/path per attempt for the selected upstream.
+ if pool, ok := poolFromContext(pr.In.Context()); ok && len(pool) > 0 {
+ if target, err := url.Parse(pool[0].URL); err == nil {
+ pr.Out.URL.Scheme = target.Scheme
+ pr.Out.URL.Host = target.Host
+ }
+ }
+ pr.SetXForwarded()
+ },
+ ModifyResponse: cfg.modifyResponse,
+ ErrorHandler: cfg.errorHandler,
+ }
+}
+
+func (cfg *upstreamRouterConfig) modifyResponse(resp *http.Response) error {
+ if len(cfg.out) == 0 {
+ return nil
+ }
+ if isEventStream(resp.Header.Get("Content-Type")) {
+ return nil // streaming: pass through untransformed
+ }
+ body, err := io.ReadAll(io.LimitReader(resp.Body, maxUpstreamResponseBytes+1))
+ _ = resp.Body.Close()
+ if err != nil {
+ return &routerError{status: http.StatusBadGateway, code: errCodeInvalidUpstreamResp, message: "could not read upstream response", cause: err}
+ }
+ if int64(len(body)) > maxUpstreamResponseBytes {
+ return &routerError{status: http.StatusBadGateway, code: errCodeInvalidUpstreamResp, message: "upstream response exceeds maximum buffered size"}
+ }
+ c, _ := resp.Request.Context().Value(ginCtxKey).(*gin.Context)
+ transformed, err := runTransformerPipeline(c, body, cfg.out)
+ if err != nil {
+ return &routerError{status: http.StatusBadGateway, code: errCodeInvalidUpstreamResp, message: "response transform failed", cause: err}
+ }
+ resp.Body = io.NopCloser(bytes.NewReader(transformed))
+ resp.ContentLength = int64(len(transformed))
+ resp.Header.Set("Content-Length", strconv.Itoa(len(transformed)))
+ return nil
+}
+
+func (cfg *upstreamRouterConfig) errorHandler(w http.ResponseWriter, _ *http.Request, err error) {
+ re := &routerError{status: http.StatusBadGateway, code: errCodeUpstreamUnavailable, message: "upstream request failed"}
+ var got *routerError
+ if core.As(err, &got) {
+ re = got
+ }
+ slog.Warn("upstream router dispatch failed", "code", re.code, "err", err.Error())
+ w.Header().Set("Content-Type", "application/json")
+ if re.status == http.StatusServiceUnavailable {
+ w.Header().Set("Retry-After", strconv.Itoa(int(cfg.cooldown.Seconds())))
+ }
+ w.WriteHeader(re.status)
+ _ = json.NewEncoder(w).Encode(Fail(re.code, re.message))
+}
+
+// handler returns the gin.HandlerFunc mounted at each router path.
+func (cfg *upstreamRouterConfig) handler(proxy *httputil.ReverseProxy) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ body, ok := readUpstreamBody(c)
+ if !ok {
+ return
+ }
+
+ key, err := cfg.selector(c, body)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequest, err.Error()))
+ return
+ }
+ if cfg.hook != nil {
+ newKey, herr := cfg.hook(c, key, body)
+ if herr != nil {
+ c.AbortWithStatusJSON(http.StatusForbidden, Fail(errCodeRoutingRejected, herr.Error()))
+ return
+ }
+ if core.Trim(newKey) != "" {
+ key = newKey
+ }
+ }
+
+ if len(cfg.in) > 0 {
+ body, err = runTransformerPipeline(c, body, cfg.in)
+ if err != nil {
+ c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequestBody, err.Error()))
+ return
+ }
+ }
+
+ pool, ok := cfg.registry.resolve(key)
+ if !ok {
+ c.AbortWithStatusJSON(http.StatusNotFound, Fail(errCodeNoUpstream, "no upstream registered for key: "+key))
+ return
+ }
+
+ bound := body // capture for GetBody closure
+ c.Request.Body = io.NopCloser(bytes.NewReader(bound))
+ c.Request.ContentLength = int64(len(bound))
+ c.Request.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(bytes.NewReader(bound)), nil }
+
+ ctx := context.WithValue(c.Request.Context(), poolCtxKey, pool)
+ ctx = context.WithValue(ctx, keyCtxKey, key)
+ ctx = context.WithValue(ctx, ginCtxKey, c)
+ c.Request = c.Request.WithContext(ctx)
+
+ // Write through gin's ResponseWriter (not the unwrapped raw writer):
+ // gin.ResponseWriter implements http.Flusher and http.Hijacker, which is
+ // all httputil.ReverseProxy needs to stream on the Rewrite path. Routing
+ // the response through it keeps gin's Written() tracking correct, avoiding
+ // the "superfluous response.WriteHeader" warning and a split header map.
+ proxy.ServeHTTP(c.Writer, c.Request)
+ }
+}
+
+func readUpstreamBody(c *gin.Context) ([]byte, bool) {
+ limited := http.MaxBytesReader(c.Writer, c.Request.Body, maxToolRequestBodyBytes)
+ body, err := io.ReadAll(limited)
+ if err != nil {
+ if err.Error() == "http: request body too large" {
+ c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge, Fail(errCodeRequestTooLarge, "Request body exceeds the maximum allowed size"))
+ return nil, false
+ }
+ c.AbortWithStatusJSON(http.StatusBadRequest, Fail(errCodeInvalidRequest, "Unable to read request body"))
+ return nil, false
+ }
+ return body, true
+}
+
+func isEventStream(contentType string) bool {
+ return core.HasPrefix(core.Lower(core.Trim(contentType)), "text/event-stream")
+}
diff --git a/go/upstream_router_example_test.go b/go/upstream_router_example_test.go
new file mode 100644
index 0000000..341588a
--- /dev/null
+++ b/go/upstream_router_example_test.go
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "fmt"
+
+ api "dappco.re/go/api"
+)
+
+func ExampleWithUpstreamRouter() {
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8", "10.0.0.0/8"))
+ _ = reg.Set("lemma",
+ api.Upstream{URL: "http://10.0.0.5:8000", Weight: 2},
+ api.Upstream{URL: "http://10.0.0.6:8000", Weight: 1},
+ )
+ _ = reg.SetDefault(api.Upstream{URL: "http://127.0.0.1:11434"})
+
+ engine, err := api.New(api.WithUpstreamRouter(reg))
+ if err != nil {
+ panic(err)
+ }
+ fmt.Println(engine.Addr())
+ // Output: :8080
+}
diff --git a/go/upstream_router_internal_test.go b/go/upstream_router_internal_test.go
new file mode 100644
index 0000000..f057622
--- /dev/null
+++ b/go/upstream_router_internal_test.go
@@ -0,0 +1,95 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+// serveInternal builds a test engine with the upstream router mounted and
+// returns a live server. It mirrors the external serve helper but lives in
+// package api so tests can override unexported package vars.
+func serveInternal(t *testing.T, reg *UpstreamRegistry, opts ...UpstreamRouterOption) *httptest.Server {
+ t.Helper()
+ e, err := New(WithUpstreamRouter(reg, opts...))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return httptest.NewServer(e.Handler())
+}
+
+// TestUpstreamRouter_OversizeBuffered_Bad asserts the non-stream + out-transformer
+// path rejects an upstream body larger than maxUpstreamResponseBytes with a 502
+// invalid_upstream_response, rather than buffering it unbounded. The limit is
+// lowered for the test (save/restore) so no real 10 MiB body is needed.
+func TestUpstreamRouter_OversizeBuffered_Bad(t *testing.T) {
+ prev := maxUpstreamResponseBytes
+ maxUpstreamResponseBytes = 16
+ defer func() { maxUpstreamResponseBytes = prev }()
+
+ oversize := strings.Repeat("x", 64) // > 16-byte cap
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"data":"`+oversize+`"}`)
+ }))
+ defer up.Close()
+
+ reg := NewUpstreamRegistry(AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("m", Upstream{URL: up.URL}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ // An out-transformer is required to reach the buffered branch at all.
+ srv := serveInternal(t, reg, WithUpstreamTransformerOut(RenameFields(map[string]string{"data": "payload"})))
+ defer srv.Close()
+
+ resp, err := http.Post(srv.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"m"}`))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("status = %d, want 502 (oversize buffered response)", resp.StatusCode)
+ }
+ got, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(got), errCodeInvalidUpstreamResp) {
+ t.Fatalf("body = %s, want %s envelope", got, errCodeInvalidUpstreamResp)
+ }
+}
+
+// TestUpstreamRouter_SmallBufferedTransforms_Good is the regression guard: a body
+// at or under the (lowered) cap still transforms cleanly through the same path.
+func TestUpstreamRouter_SmallBufferedTransforms_Good(t *testing.T) {
+ prev := maxUpstreamResponseBytes
+ maxUpstreamResponseBytes = 4096
+ defer func() { maxUpstreamResponseBytes = prev }()
+
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"internal_id":42}`)
+ }))
+ defer up.Close()
+
+ reg := NewUpstreamRegistry(AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("m", Upstream{URL: up.URL}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ srv := serveInternal(t, reg, WithUpstreamTransformerOut(RenameFields(map[string]string{"internal_id": "id"})))
+ defer srv.Close()
+
+ resp, err := http.Post(srv.URL+"/v1/chat/completions", "application/json", strings.NewReader(`{"model":"m"}`))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ got, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(got), `"id":42`) {
+ t.Fatalf("body = %s, want renamed internal_id->id", got)
+ }
+}
diff --git a/go/upstream_router_test.go b/go/upstream_router_test.go
new file mode 100644
index 0000000..d1012ad
--- /dev/null
+++ b/go/upstream_router_test.go
@@ -0,0 +1,267 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api_test
+
+import (
+ "bufio"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ api "dappco.re/go/api"
+ "github.com/gin-gonic/gin"
+)
+
+// serve builds a test engine with the upstream router mounted and returns a live server.
+func serve(t *testing.T, reg *api.UpstreamRegistry, opts ...api.UpstreamRouterOption) *httptest.Server {
+ t.Helper()
+ e, err := api.New(api.WithUpstreamRouter(reg, opts...))
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ return httptest.NewServer(e.Handler())
+}
+
+func post(t *testing.T, base, path, body string) *http.Response {
+ t.Helper()
+ resp, err := http.Post(base+path, "application/json", strings.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST %s: %v", path, err)
+ }
+ return resp
+}
+
+func TestUpstreamRouter_RoutesByModel_Good(t *testing.T) {
+ upA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"upstream":"A"}`)
+ }))
+ defer upA.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("lemma", api.Upstream{URL: upA.URL}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"lemma"}`)
+ defer resp.Body.Close()
+ got, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(got), `"upstream":"A"`) {
+ t.Fatalf("body = %s, want routed to A", got)
+ }
+}
+
+func TestUpstreamRouter_MissingModel_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry()
+ _ = reg.SetDefault(api.Upstream{URL: "https://example.com"})
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", resp.StatusCode)
+ }
+}
+
+func TestUpstreamRouter_Failover_Good(t *testing.T) {
+ dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ defer dead.Close()
+ live := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"ok":true}`)
+ }))
+ defer live.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ if err := reg.Set("m", api.Upstream{URL: dead.URL}, api.Upstream{URL: live.URL}); err != nil {
+ t.Fatalf("Set: %v", err)
+ }
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (failed over to live)", resp.StatusCode)
+ }
+}
+
+func TestUpstreamRouter_AllDown_503_Ugly(t *testing.T) {
+ dead := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadGateway)
+ }))
+ defer dead.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: dead.URL})
+ srv := serve(t, reg)
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ got, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusServiceUnavailable {
+ t.Fatalf("status = %d, want 503", resp.StatusCode)
+ }
+ if resp.Header.Get("Retry-After") == "" {
+ t.Error("missing Retry-After header on 503")
+ }
+ if strings.Contains(string(got), dead.URL) {
+ t.Error("upstream URL leaked into client response body")
+ }
+}
+
+func TestUpstreamRouter_StreamingPassthrough_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "text/event-stream")
+ f, _ := w.(http.Flusher)
+ for _, chunk := range []string{"data: a\n\n", "data: b\n\n", "data: [DONE]\n\n"} {
+ _, _ = io.WriteString(w, chunk)
+ if f != nil {
+ f.Flush()
+ }
+ }
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: up.URL})
+ // Out transformer present to prove it is NOT applied to streams.
+ srv := serve(t, reg, api.WithUpstreamTransformerOut(api.RenameFields(map[string]string{"x": "y"})))
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
+ t.Fatalf("Content-Type = %q, want text/event-stream", ct)
+ }
+ sc := bufio.NewScanner(resp.Body)
+ var lines int
+ for sc.Scan() {
+ if strings.HasPrefix(sc.Text(), "data:") {
+ lines++
+ }
+ }
+ if lines != 3 {
+ t.Fatalf("got %d data lines, want 3 (stream byte-preserved)", lines)
+ }
+}
+
+func TestUpstreamRouter_TransformInOut_Good(t *testing.T) {
+ var gotBody string
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ _, _ = io.WriteString(w, `{"internal_id":42}`)
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: up.URL})
+ srv := serve(t, reg,
+ api.WithUpstreamTransformerIn(api.RenameFields(map[string]string{"q": "prompt"})),
+ api.WithUpstreamTransformerOut(api.RenameFields(map[string]string{"internal_id": "id"})),
+ )
+ defer srv.Close()
+
+ // Selector reads "model" from the original body; the in-transform then renames
+ // q->prompt before dispatch so the upstream sees the translated shape.
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m","q":"hello"}`)
+ defer resp.Body.Close()
+ out, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(gotBody, `"prompt"`) {
+ t.Errorf("upstream body = %s, want renamed q->prompt", gotBody)
+ }
+ if !strings.Contains(string(out), `"id":42`) {
+ t.Errorf("client body = %s, want renamed internal_id->id", out)
+ }
+}
+
+func TestUpstreamRouter_RouteHookOverride_Good(t *testing.T) {
+ upB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"pool":"B"}`)
+ }))
+ defer upB.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("b", api.Upstream{URL: upB.URL})
+ srv := serve(t, reg, api.WithRouteHook(func(_ *gin.Context, _ string, _ []byte) (string, error) {
+ return "b", nil
+ }))
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"anything"}`)
+ defer resp.Body.Close()
+ got, _ := io.ReadAll(resp.Body)
+ if !strings.Contains(string(got), `"pool":"B"`) {
+ t.Fatalf("body = %s, want hook-overridden pool B", got)
+ }
+}
+
+func TestUpstreamRouter_MultiPath_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ _, _ = io.WriteString(w, `{"path":"`+r.URL.Path+`"}`)
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.Set("m", api.Upstream{URL: up.URL})
+ srv := serve(t, reg, api.WithRouterPaths("/v1/chat/completions", "/v1/embeddings"))
+ defer srv.Close()
+
+ for _, path := range []string{"/v1/chat/completions", "/v1/embeddings"} {
+ resp := post(t, srv.URL, path, `{"model":"m"}`)
+ got, _ := io.ReadAll(resp.Body)
+ _ = resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("path %s: status = %d, want 200", path, resp.StatusCode)
+ }
+ if !strings.Contains(string(got), path) {
+ t.Fatalf("path %s: upstream did not receive correct path, body = %s", path, got)
+ }
+ }
+}
+
+func TestUpstreamRouter_SSRFPosture_Bad(t *testing.T) {
+ reg := api.NewUpstreamRegistry() // no allow-list
+ if err := reg.Set("m", api.Upstream{URL: "http://127.0.0.1:11434"}); err == nil {
+ t.Fatal("loopback accepted without AllowPrivateUpstreams, want rejection")
+ }
+}
+
+func TestUpstreamRouter_Composition_PreNextMiddleware_Good(t *testing.T) {
+ up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ _, _ = io.WriteString(w, `{"ok":true}`)
+ }))
+ defer up.Close()
+
+ reg := api.NewUpstreamRegistry(api.AllowPrivateUpstreams("127.0.0.0/8"))
+ _ = reg.SetDefault(api.Upstream{URL: up.URL})
+ // WithRateLimit runs BEFORE the handler (pre-c.Next()), annotating passing
+ // requests with X-RateLimit-Limit. A response written by the proxy during the
+ // handler therefore still carries this header — proving engine middleware
+ // wraps (gates) the mounted router. Post-Next response-header middleware (e.g.
+ // ApiSunset) cannot apply here because the proxy commits the response during
+ // the handler; see the WithUpstreamRouter docs.
+ e, _ := api.New(api.WithRateLimit(100), api.WithUpstreamRouter(reg))
+ srv := httptest.NewServer(e.Handler())
+ defer srv.Close()
+
+ resp := post(t, srv.URL, "/v1/chat/completions", `{"model":"m"}`)
+ defer resp.Body.Close()
+ got, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (request proxied through)", resp.StatusCode)
+ }
+ if !strings.Contains(string(got), `"ok":true`) {
+ t.Fatalf("body = %s, want proxied upstream body", got)
+ }
+ if resp.Header.Get("X-RateLimit-Limit") == "" {
+ t.Fatal("X-RateLimit-Limit absent — engine middleware did not wrap the mounted router")
+ }
+}
diff --git a/go/upstream_transport.go b/go/upstream_transport.go
new file mode 100644
index 0000000..0984261
--- /dev/null
+++ b/go/upstream_transport.go
@@ -0,0 +1,116 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "io"
+ "net/http"
+ "net/url" // Note: AX-6 — url.URL fields are structural for per-attempt upstream rewriting.
+
+ core "dappco.re/go"
+)
+
+// upstreamTransport is the http.RoundTripper that owns weighted selection and
+// passive failover. The per-request pool and key are read from the request
+// context (bound by the router handler). On a transport error or a failover
+// status it marks the upstream cooling and retries the next, up to maxAttempts.
+//
+// SECURITY: this transport intentionally dispatches to operator-configured
+// upstreams without re-applying the request-time SSRF guard. Upstream URLs are
+// validated once at registration (UpstreamRegistry.validate, default-deny with
+// AllowPrivateUpstreams opt-in), so loopback/private model endpoints are
+// permitted by design. See spec §8.
+type upstreamTransport struct {
+ base http.RoundTripper
+ balancer *upstreamBalancer
+ maxAttempts int
+ failover map[int]bool
+}
+
+func (t *upstreamTransport) RoundTrip(req *http.Request) (*http.Response, error) {
+ pool, ok := poolFromContext(req.Context())
+ if !ok || len(pool) == 0 {
+ return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "no upstream pool bound to request"}
+ }
+ key, _ := keyFromContext(req.Context())
+
+ attempts := t.maxAttempts
+ if attempts <= 0 || attempts > len(pool) {
+ attempts = len(pool)
+ }
+
+ var lastErr error
+ for i := 0; i < attempts; i++ {
+ up, ok := t.balancer.pick(key, pool)
+ if !ok {
+ break
+ }
+ target, err := url.Parse(up.URL)
+ if err != nil {
+ t.balancer.markFailed(up.URL)
+ lastErr = err
+ continue
+ }
+
+ out := req.Clone(req.Context())
+ if out.GetBody != nil {
+ body, berr := out.GetBody()
+ if berr != nil {
+ // A failed body replay would dispatch a consumed/empty body to the
+ // upstream; bail to the exhausted-path 503 (which logs the cause).
+ lastErr = berr
+ break
+ }
+ out.Body = body
+ }
+ applyUpstream(out, target)
+ for k, v := range up.Headers {
+ out.Header.Set(k, v)
+ }
+
+ //#nosec G107 -- upstream is operator-configured and validated at registration
+ // (UpstreamRegistry default-deny + AllowPrivateUpstreams opt-in); the request-time
+ // SSRF guard is deliberately not re-applied here. See spec §8 / Mantis upstream-router.
+ resp, err := t.base.RoundTrip(out)
+ if err != nil {
+ t.balancer.markFailed(up.URL)
+ lastErr = err
+ continue
+ }
+ if t.failover[resp.StatusCode] {
+ t.balancer.markFailed(up.URL)
+ drainAndClose(resp.Body)
+ lastErr = core.E("upstream", core.Sprintf("upstream %s returned %d", up.URL, resp.StatusCode), nil)
+ continue
+ }
+ return resp, nil
+ }
+
+ if lastErr != nil {
+ // Detail goes to the error (logged by ErrorHandler); the client sees a
+ // generic envelope so upstream URLs never leak.
+ return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "no healthy upstream available", cause: lastErr}
+ }
+ return nil, &routerError{status: http.StatusServiceUnavailable, code: errCodeUpstreamUnavailable, message: "all upstreams cooling"}
+}
+
+// applyUpstream rewrites the outbound request to target the chosen upstream.
+// A base path on the upstream URL is prefixed to the incoming request path.
+func applyUpstream(out *http.Request, target *url.URL) {
+ out.URL.Scheme = target.Scheme
+ out.URL.Host = target.Host
+ out.Host = target.Host
+ if base := trimTrailingSlashes(target.Path); base != "" {
+ out.URL.Path = base + out.URL.Path
+ if out.URL.RawPath != "" {
+ out.URL.RawPath = base + out.URL.RawPath
+ }
+ }
+}
+
+func drainAndClose(body io.ReadCloser) {
+ if body != nil {
+ _, _ = io.CopyN(io.Discard, body, 4<<10) // bounded drain so the conn is reusable; cap guards a hostile error body
+ _ = body.Close()
+ }
+}
diff --git a/go/upstream_transport_internal_test.go b/go/upstream_transport_internal_test.go
new file mode 100644
index 0000000..d46d64c
--- /dev/null
+++ b/go/upstream_transport_internal_test.go
@@ -0,0 +1,234 @@
+// SPDX-License-Identifier: EUPL-1.2
+
+package api
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ core "dappco.re/go"
+)
+
+type fakeRoundTripper struct {
+ fn func(*http.Request) (*http.Response, error)
+}
+
+func (f fakeRoundTripper) RoundTrip(r *http.Request) (*http.Response, error) { return f.fn(r) }
+
+func newResp(status int) *http.Response {
+ return &http.Response{
+ StatusCode: status,
+ Body: io.NopCloser(strings.NewReader("ok")),
+ Header: http.Header{},
+ }
+}
+
+func requestWithPool(pool []Upstream, key string) *http.Request {
+ req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}"))
+ req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader("{}")), nil }
+ ctx := context.WithValue(req.Context(), poolCtxKey, pool)
+ ctx = context.WithValue(ctx, keyCtxKey, key)
+ return req.WithContext(ctx)
+}
+
+func TestUpstreamTransport_FailoverThenSuccess_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ var hits []string
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ hits = append(hits, r.URL.Host)
+ if r.URL.Host == "a" {
+ return newResp(http.StatusBadGateway), nil
+ }
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a", Weight: 1}, {URL: "http://b", Weight: 1}}
+
+ resp, err := tr.RoundTrip(requestWithPool(pool, "k"))
+ if err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if len(hits) != 2 {
+ t.Fatalf("attempts = %v, want 2 (a then b)", hits)
+ }
+}
+
+func TestUpstreamTransport_HeaderInjection_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ var gotAuth string
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ gotAuth = r.Header.Get("Authorization")
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 1, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a", Headers: map[string]string{"Authorization": "Bearer up-key"}}}
+
+ if _, err := tr.RoundTrip(requestWithPool(pool, "k")); err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if gotAuth != "Bearer up-key" {
+ t.Fatalf("injected auth = %q, want Bearer up-key", gotAuth)
+ }
+}
+
+func TestUpstreamTransport_AllFail_Bad(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ return newResp(http.StatusServiceUnavailable), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a"}, {URL: "http://b"}}
+
+ _, err := tr.RoundTrip(requestWithPool(pool, "k"))
+ var re *routerError
+ if !core.As(err, &re) || re.status != http.StatusServiceUnavailable {
+ t.Fatalf("err = %v, want *routerError status 503", err)
+ }
+}
+
+func TestUpstreamTransport_FourxxPassthrough_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ var hits int
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ hits++
+ return newResp(http.StatusNotFound), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a"}, {URL: "http://b"}}
+
+ resp, err := tr.RoundTrip(requestWithPool(pool, "k"))
+ if err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404 (4xx is not a failover status)", resp.StatusCode)
+ }
+ if hits != 1 {
+ t.Fatalf("attempts = %d, want 1 (no failover, no markFailed on 4xx)", hits)
+ }
+}
+
+func TestUpstreamTransport_TransportErrorRetry_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ var hits []string
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ hits = append(hits, r.URL.Host)
+ if r.URL.Host == "a" {
+ return nil, errors.New("dial tcp: connection refused")
+ }
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a", Weight: 1}, {URL: "http://b", Weight: 1}}
+
+ resp, err := tr.RoundTrip(requestWithPool(pool, "k"))
+ if err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200 (retried past the transport error)", resp.StatusCode)
+ }
+ if len(hits) != 2 || hits[1] != "b" {
+ t.Fatalf("attempts = %v, want [a b] (transport error on a, retried b)", hits)
+ }
+}
+
+func TestUpstreamTransport_BodyReplayedOnRetry_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ const payload = `{"model":"m","prompt":"the full body must survive failover"}`
+ var seen []string
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ b, _ := io.ReadAll(r.Body)
+ seen = append(seen, string(b))
+ if r.URL.Host == "a" {
+ return newResp(http.StatusBadGateway), nil
+ }
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+ pool := []Upstream{{URL: "http://a", Weight: 1}, {URL: "http://b", Weight: 1}}
+
+ req, _ := http.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(payload))
+ req.GetBody = func() (io.ReadCloser, error) { return io.NopCloser(strings.NewReader(payload)), nil }
+ ctx := context.WithValue(req.Context(), poolCtxKey, pool)
+ ctx = context.WithValue(ctx, keyCtxKey, "k")
+
+ resp, err := tr.RoundTrip(req.WithContext(ctx))
+ if err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("status = %d, want 200", resp.StatusCode)
+ }
+ if len(seen) != 2 {
+ t.Fatalf("got %d attempts, want 2", len(seen))
+ }
+ for i, body := range seen {
+ if body != payload {
+ t.Fatalf("attempt %d body = %q, want full payload %q", i, body, payload)
+ }
+ }
+}
+
+func TestUpstreamTransport_AllCooling503_Bad(t *testing.T) {
+ now := time.Unix(1000, 0)
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return now })
+ pool := []Upstream{{URL: "http://a"}, {URL: "http://b"}}
+ // Pre-cool every member so each pick returns !ok and the loop exits with
+ // lastErr == nil — the all-cooling path, distinct from the all-fail path.
+ bal.markFailed("http://a")
+ bal.markFailed("http://b")
+
+ var hits int
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ hits++
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 2, failover: defaultFailoverStatuses()}
+
+ _, err := tr.RoundTrip(requestWithPool(pool, "k"))
+ var re *routerError
+ if !core.As(err, &re) || re.status != http.StatusServiceUnavailable {
+ t.Fatalf("err = %v, want *routerError status 503", err)
+ }
+ if re.cause != nil {
+ t.Fatalf("all-cooling error carried a cause %v, want nil", re.cause)
+ }
+ if hits != 0 {
+ t.Fatalf("base dispatched %d times, want 0 (every member cooling)", hits)
+ }
+}
+
+func TestUpstreamTransport_ApplyUpstreamRewrite_Good(t *testing.T) {
+ bal := newUpstreamBalancer(time.Minute, func() time.Time { return time.Unix(0, 0) })
+ var gotScheme, gotHost, gotPath string
+ base := fakeRoundTripper{fn: func(r *http.Request) (*http.Response, error) {
+ gotScheme, gotHost, gotPath = r.URL.Scheme, r.URL.Host, r.URL.Path
+ return newResp(http.StatusOK), nil
+ }}
+ tr := &upstreamTransport{base: base, balancer: bal, maxAttempts: 1, failover: defaultFailoverStatuses()}
+ // Upstream carries a base path; the incoming /v1/chat/completions must be
+ // prefixed with it after the rewrite.
+ pool := []Upstream{{URL: "https://gw.example.com:8443/proxy"}}
+
+ if _, err := tr.RoundTrip(requestWithPool(pool, "k")); err != nil {
+ t.Fatalf("RoundTrip: %v", err)
+ }
+ if gotScheme != "https" {
+ t.Fatalf("scheme = %q, want https", gotScheme)
+ }
+ if gotHost != "gw.example.com:8443" {
+ t.Fatalf("host = %q, want gw.example.com:8443", gotHost)
+ }
+ if gotPath != "/proxy/v1/chat/completions" {
+ t.Fatalf("path = %q, want /proxy/v1/chat/completions (base-path prefixed)", gotPath)
+ }
+}
diff --git a/go/websocket_test.go b/go/websocket_test.go
index d8b3269..c638cb8 100644
--- a/go/websocket_test.go
+++ b/go/websocket_test.go
@@ -55,7 +55,7 @@ func TestWSEndpoint_Good(t *testing.T) {
e, err := api.New(api.WithWSHandler(wsHandler))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -96,7 +96,7 @@ func TestWSEndpoint_Good_CustomPath(t *testing.T) {
e, err := api.New(api.WithWSPath("/socket"), api.WithWSHandler(wsHandler))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -136,7 +136,7 @@ func TestWSEndpoint_Ugly_RootPathFallsBackToDefault(t *testing.T) {
e, err := api.New(api.WithWSPath(" / "), api.WithWSHandler(wsHandler))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -176,7 +176,7 @@ func TestWSEndpoint_Ugly_NormalisesWhitespaceWrappedPath(t *testing.T) {
e, err := api.New(api.WithWSPath(" /trimmed/ "), api.WithWSHandler(wsHandler))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -220,7 +220,7 @@ func TestWSEndpoint_Good_WithResponseMeta(t *testing.T) {
api.WithWSHandler(wsHandler),
)
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -265,7 +265,7 @@ func TestWithWebSocket_Good_GinHandlerReceivesUpgrade(t *testing.T) {
e, err := api.New(api.WithWebSocket(handler))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
srv := httptest.NewServer(e.Handler())
@@ -294,7 +294,7 @@ func TestWithWebSocket_Bad_NilHandlerNoMount(t *testing.T) {
e, err := api.New(api.WithWebSocket(nil))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
w := httptest.NewRecorder()
@@ -323,7 +323,7 @@ func TestWithWebSocket_Ugly_GinHandlerWinsOverHTTPHandler(t *testing.T) {
e, err := api.New(api.WithWSHandler(httpH), api.WithWebSocket(ginH))
if err != nil {
- t.Fatalf("unexpected error: %v", err)
+ t.Fatalf(fmtTestUnexpectedErr, err)
}
w := httptest.NewRecorder()
diff --git a/php/src/Api/Boot.php b/php/src/Api/Boot.php
index 93240c1..5c26af5 100644
--- a/php/src/Api/Boot.php
+++ b/php/src/Api/Boot.php
@@ -43,6 +43,9 @@
*/
class Boot extends ServiceProvider
{
+ private const ROUTES_API_PATH = '/Routes/api.php';
+ private const OAUTH_AUTHORIZE_PATH = '/authorize';
+
/**
* The module name.
*/
@@ -203,8 +206,8 @@ public function onApiRoutes(ApiRoutesRegistering $event): void
$this->registerMiddlewareAliases();
// Core API routes (SEO, Pixel, Entitlements, MCP)
- if (file_exists(__DIR__.'/Routes/api.php') && ! $this->hasCoreApiRoutesRegistered()) {
- $event->routes(fn () => Route::middleware('api')->group(__DIR__.'/Routes/api.php'));
+ if (file_exists(__DIR__.self::ROUTES_API_PATH) && ! $this->hasCoreApiRoutesRegistered()) {
+ $event->routes(fn () => Route::middleware('api')->group(__DIR__.self::ROUTES_API_PATH));
}
if (class_exists(Passport::class)) {
@@ -248,13 +251,13 @@ protected function registerFallbackApiRoutes(): void
{
$this->registerMiddlewareAliases();
- if (! file_exists(__DIR__.'/Routes/api.php') || $this->hasCoreApiRoutesRegistered()) {
+ if (! file_exists(__DIR__.self::ROUTES_API_PATH) || $this->hasCoreApiRoutesRegistered()) {
return;
}
Route::prefix('api')
->middleware('api')
- ->group(__DIR__.'/Routes/api.php');
+ ->group(__DIR__.self::ROUTES_API_PATH);
if (class_exists(Passport::class) && ! Route::has('passport.token')) {
$this->registerOAuthRoutes();
@@ -280,11 +283,11 @@ protected function registerOAuthRoutes(): void
->name('passport.token');
Route::middleware(['web', 'auth'])->group(function () {
- Route::get('/authorize', [AuthorizationController::class, 'authorize'])
+ Route::get(self::OAUTH_AUTHORIZE_PATH, [AuthorizationController::class, 'authorize'])
->name('passport.authorizations.authorize');
- Route::post('/authorize', [ApproveAuthorizationController::class, 'approve'])
+ Route::post(self::OAUTH_AUTHORIZE_PATH, [ApproveAuthorizationController::class, 'approve'])
->name('passport.authorizations.approve');
- Route::delete('/authorize', [DenyAuthorizationController::class, 'deny'])
+ Route::delete(self::OAUTH_AUTHORIZE_PATH, [DenyAuthorizationController::class, 'deny'])
->name('passport.authorizations.deny');
});
});
diff --git a/php/src/Api/Controllers/Api/WebhookSecretController.php b/php/src/Api/Controllers/Api/WebhookSecretController.php
index 74e52ca..c758781 100644
--- a/php/src/Api/Controllers/Api/WebhookSecretController.php
+++ b/php/src/Api/Controllers/Api/WebhookSecretController.php
@@ -19,6 +19,8 @@ class WebhookSecretController extends Controller
{
use HasApiResponses;
+ private const RESOURCE_NAME = 'Webhook endpoint';
+
public function __construct(
protected WebhookSecretRotationService $rotationService
) {}
@@ -82,7 +84,7 @@ public function rotateContentSecret(Request $request, string $uuid): JsonRespons
->first();
if (! $endpoint) {
- return $this->notFoundResponse('Webhook endpoint');
+ return $this->notFoundResponse(self::RESOURCE_NAME);
}
$validated = $request->validate([
@@ -149,7 +151,7 @@ public function contentSecretStatus(Request $request, string $uuid): JsonRespons
->first();
if (! $endpoint) {
- return $this->notFoundResponse('Webhook endpoint');
+ return $this->notFoundResponse(self::RESOURCE_NAME);
}
return response()->json([
@@ -200,7 +202,7 @@ public function invalidateContentPreviousSecret(Request $request, string $uuid):
->first();
if (! $endpoint) {
- return $this->notFoundResponse('Webhook endpoint');
+ return $this->notFoundResponse(self::RESOURCE_NAME);
}
$this->rotationService->invalidatePreviousSecret($endpoint);
@@ -266,7 +268,7 @@ public function updateContentGracePeriod(Request $request, string $uuid): JsonRe
->first();
if (! $endpoint) {
- return $this->notFoundResponse('Webhook endpoint');
+ return $this->notFoundResponse(self::RESOURCE_NAME);
}
$validated = $request->validate([
diff --git a/php/src/Api/Controllers/McpApiController.php b/php/src/Api/Controllers/McpApiController.php
index 7534d09..77bcfd8 100644
--- a/php/src/Api/Controllers/McpApiController.php
+++ b/php/src/Api/Controllers/McpApiController.php
@@ -28,6 +28,9 @@ class McpApiController extends Controller
{
use HasApiResponses;
+ private const VALIDATION_SERVER_ID_INVALID = 'The selected server id is invalid.';
+ private const VALIDATION_TOOL_NAME_INVALID = 'The selected tool name is invalid.';
+
/**
* Safe MCP server identifier pattern.
*
@@ -106,7 +109,7 @@ public function server(Request $request, string $id): JsonResponse
{
if (! $this->isValidServerId($id)) {
return $this->validationErrorResponse([
- 'id' => ['The selected server id is invalid.'],
+ 'id' => [self::VALIDATION_SERVER_ID_INVALID],
]);
}
@@ -155,7 +158,7 @@ public function tools(Request $request, string $id): JsonResponse
{
if (! $this->isValidServerId($id)) {
return $this->validationErrorResponse([
- 'id' => ['The selected server id is invalid.'],
+ 'id' => [self::VALIDATION_SERVER_ID_INVALID],
]);
}
@@ -222,7 +225,7 @@ public function resources(Request $request, string $id): JsonResponse
{
if (! $this->isValidServerId($id)) {
return $this->validationErrorResponse([
- 'id' => ['The selected server id is invalid.'],
+ 'id' => [self::VALIDATION_SERVER_ID_INVALID],
]);
}
@@ -343,7 +346,7 @@ public function callTool(Request $request): JsonResponse
if (! $this->isValidToolName($validated['tool'])) {
return $this->validationErrorResponse([
- 'tool' => ['The selected tool name is invalid.'],
+ 'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
]);
}
@@ -374,13 +377,13 @@ public function callToolByRoute(Request $request, string $server, string $tool):
{
if (! $this->isValidServerId($server)) {
return $this->validationErrorResponse([
- 'server' => ['The selected server id is invalid.'],
+ 'server' => [self::VALIDATION_SERVER_ID_INVALID],
]);
}
if (! $this->isValidToolName($tool)) {
return $this->validationErrorResponse([
- 'tool' => ['The selected tool name is invalid.'],
+ 'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
]);
}
@@ -418,7 +421,7 @@ protected function executeToolCall(
): JsonResponse {
if (! $this->isValidToolName($tool)) {
return $this->validationErrorResponse([
- 'tool' => ['The selected tool name is invalid.'],
+ 'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
]);
}
@@ -667,13 +670,13 @@ public function toolVersions(Request $request, string $server, string $tool): Js
{
if (! $this->isValidServerId($server)) {
return $this->validationErrorResponse([
- 'server' => ['The selected server id is invalid.'],
+ 'server' => [self::VALIDATION_SERVER_ID_INVALID],
]);
}
if (! $this->isValidToolName($tool)) {
return $this->validationErrorResponse([
- 'tool' => ['The selected tool name is invalid.'],
+ 'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
]);
}
@@ -712,13 +715,13 @@ public function toolVersion(Request $request, string $server, string $tool, stri
{
if (! $this->isValidServerId($server)) {
return $this->validationErrorResponse([
- 'server' => ['The selected server id is invalid.'],
+ 'server' => [self::VALIDATION_SERVER_ID_INVALID],
]);
}
if (! $this->isValidToolName($tool)) {
return $this->validationErrorResponse([
- 'tool' => ['The selected tool name is invalid.'],
+ 'tool' => [self::VALIDATION_TOOL_NAME_INVALID],
]);
}
@@ -767,7 +770,7 @@ public function resource(Request $request, string $uri): JsonResponse
if (! $this->isValidServerId($serverId)) {
return $this->validationErrorResponse([
- 'uri' => ['The selected server id is invalid.'],
+ 'uri' => [self::VALIDATION_SERVER_ID_INVALID],
]);
}
diff --git a/php/src/Api/Database/Factories/ApiKeyFactory.php b/php/src/Api/Database/Factories/ApiKeyFactory.php
index efc9b27..2e1b752 100644
--- a/php/src/Api/Database/Factories/ApiKeyFactory.php
+++ b/php/src/Api/Database/Factories/ApiKeyFactory.php
@@ -21,6 +21,8 @@
*/
class ApiKeyFactory extends Factory
{
+ private const API_KEY_SUFFIX = ' API Key';
+
/**
* The name of the factory's corresponding model.
*
@@ -49,7 +51,7 @@ public function definition(): array
return [
'workspace_id' => Workspace::factory(),
'user_id' => User::factory(),
- 'name' => fake()->words(2, true).' API Key',
+ 'name' => fake()->words(2, true).self::API_KEY_SUFFIX,
'key' => Hash::driver('bcrypt')->make($plainKey),
'hash_algorithm' => ApiKey::HASH_BCRYPT,
'prefix' => $prefix,
@@ -91,7 +93,7 @@ public static function createWithPlainKey(
return ApiKey::generate(
$workspace->id,
$user->id,
- fake()->words(2, true).' API Key',
+ fake()->words(2, true).self::API_KEY_SUFFIX,
$scopes,
$expiresAt
);
@@ -117,7 +119,7 @@ public static function createLegacyKey(
$apiKey = ApiKey::create([
'workspace_id' => $workspace->id,
'user_id' => $user->id,
- 'name' => fake()->words(2, true).' API Key',
+ 'name' => fake()->words(2, true).self::API_KEY_SUFFIX,
'key' => hash('sha256', $plainKey),
'hash_algorithm' => ApiKey::HASH_SHA256,
'prefix' => $prefix,
@@ -136,7 +138,7 @@ public static function createLegacyKey(
*/
public function legacyHash(): static
{
- return $this->state(function (array $attributes) {
+ 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);
diff --git a/php/src/Api/Documentation/DocumentationController.php b/php/src/Api/Documentation/DocumentationController.php
index 8c58681..98b363c 100644
--- a/php/src/Api/Documentation/DocumentationController.php
+++ b/php/src/Api/Documentation/DocumentationController.php
@@ -5,7 +5,6 @@
namespace Core\Api\Documentation;
use Illuminate\Http\JsonResponse;
-use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
use Symfony\Component\Yaml\Yaml;
@@ -27,22 +26,22 @@ public function __construct(
*
* Redirects to the configured default UI.
*/
- public function index(Request $request): View
+ public function index(): View
{
$defaultUi = config('api-docs.ui.default', 'swagger');
return match ($defaultUi) {
- 'swagger' => $this->swagger($request),
- 'redoc' => $this->redoc($request),
- 'stoplight' => $this->stoplight($request),
- default => $this->scalar($request),
+ 'swagger' => $this->swagger(),
+ 'redoc' => $this->redoc(),
+ 'stoplight' => $this->stoplight(),
+ default => $this->scalar(),
};
}
/**
* Show Swagger UI.
*/
- public function swagger(Request $request): View
+ public function swagger(): View
{
$config = config('api-docs.ui.swagger', []);
@@ -55,7 +54,7 @@ public function swagger(Request $request): View
/**
* Show Scalar API Reference.
*/
- public function scalar(Request $request): View
+ public function scalar(): View
{
$config = config('api-docs.ui.scalar', []);
@@ -68,7 +67,7 @@ public function scalar(Request $request): View
/**
* Show ReDoc documentation.
*/
- public function redoc(Request $request): View
+ public function redoc(): View
{
return view('api-docs::redoc', [
'specUrl' => route('api.docs.openapi.json'),
@@ -78,7 +77,7 @@ public function redoc(Request $request): View
/**
* Show Stoplight Elements.
*/
- public function stoplight(Request $request): View
+ public function stoplight(): View
{
$config = config('api-docs.ui.stoplight', []);
@@ -91,7 +90,7 @@ public function stoplight(Request $request): View
/**
* Get OpenAPI specification as JSON.
*/
- public function openApiJson(Request $request): JsonResponse
+ public function openApiJson(): JsonResponse
{
$spec = $this->builder->build();
@@ -102,7 +101,7 @@ public function openApiJson(Request $request): JsonResponse
/**
* Get OpenAPI specification as YAML.
*/
- public function openApiYaml(Request $request): Response
+ public function openApiYaml(): Response
{
$spec = $this->builder->build();
@@ -117,7 +116,7 @@ public function openApiYaml(Request $request): Response
/**
* Clear the documentation cache.
*/
- public function clearCache(Request $request): JsonResponse
+ public function clearCache(): JsonResponse
{
$this->builder->clearCache();
diff --git a/php/src/Api/Documentation/DocumentationServiceProvider.php b/php/src/Api/Documentation/DocumentationServiceProvider.php
index cef25a3..da89b41 100644
--- a/php/src/Api/Documentation/DocumentationServiceProvider.php
+++ b/php/src/Api/Documentation/DocumentationServiceProvider.php
@@ -15,6 +15,7 @@
*/
class DocumentationServiceProvider extends ServiceProvider
{
+ private const CONFIG_FILE = '/config.php';
/**
* Register any application services.
*/
@@ -23,10 +24,10 @@ public function register(): void
// Merge documentation configuration under both the package-local
// `api-docs` namespace and the RFC-facing `scramble` namespace so
// either config file shape can drive the same documentation surface.
- $this->mergeConfigFrom(__DIR__.'/config.php', 'api-docs');
- $this->mergeConfigFrom(__DIR__.'/config.php', 'scramble');
+ $this->mergeConfigFrom(__DIR__.self::CONFIG_FILE, 'api-docs');
+ $this->mergeConfigFrom(__DIR__.self::CONFIG_FILE, 'scramble');
- $baseConfig = require __DIR__.'/config.php';
+ $baseConfig = require __DIR__.self::CONFIG_FILE;
$scrambleConfig = config('scramble', []);
$apiDocsConfig = config('api-docs', []);
$effectiveConfig = array_replace_recursive($baseConfig, $scrambleConfig, $apiDocsConfig);
@@ -37,7 +38,7 @@ public function register(): void
]);
// Register OpenApiBuilder as singleton
- $this->app->singleton(OpenApiBuilder::class, function ($app) {
+ $this->app->singleton(OpenApiBuilder::class, function ($_app) {
return new OpenApiBuilder;
});
}
@@ -58,11 +59,11 @@ public function boot(): void
// Publish configuration
if ($this->app->runningInConsole()) {
$this->publishes([
- __DIR__.'/config.php' => config_path('api-docs.php'),
+ __DIR__.self::CONFIG_FILE => config_path('api-docs.php'),
], 'api-docs-config');
$this->publishes([
- __DIR__.'/config.php' => config_path('scramble.php'),
+ __DIR__.self::CONFIG_FILE => config_path('scramble.php'),
], 'scramble-config');
$this->publishes([
diff --git a/php/src/Api/Documentation/Examples/CommonExamples.php b/php/src/Api/Documentation/Examples/CommonExamples.php
index 4860158..16d549b 100644
--- a/php/src/Api/Documentation/Examples/CommonExamples.php
+++ b/php/src/Api/Documentation/Examples/CommonExamples.php
@@ -118,7 +118,7 @@ public static function paginatedResponse(string $dataExample = '[]'): array
/**
* Get example error response.
*/
- public static function errorResponse(int $status, string $message, ?array $errors = null): array
+ public static function errorResponse(string $message, ?array $errors = null): array
{
$response = ['message' => $message];
@@ -166,7 +166,7 @@ public static function authHeaders(string $type = 'api_key'): array
{
return match ($type) {
'api_key' => [
- 'X-API-Key' => 'YOUR_API_KEY_HERE',
+ 'X-API-Key' => 'YOUR_API_KEY_HERE', // NOSONAR — documentation placeholder, not a real credential
],
'bearer' => [
'Authorization' => 'Bearer YOUR_JWT_TOKEN_HERE',
diff --git a/php/src/Api/Documentation/OpenApiBuilder.php b/php/src/Api/Documentation/OpenApiBuilder.php
index 19544bd..8d818cd 100644
--- a/php/src/Api/Documentation/OpenApiBuilder.php
+++ b/php/src/Api/Documentation/OpenApiBuilder.php
@@ -30,6 +30,8 @@
*/
class OpenApiBuilder
{
+ private const TAG_BIO_LINKS = 'Bio Links';
+
/**
* Registered extensions.
*
@@ -326,14 +328,14 @@ protected function buildOperation(Route $route, string $method, array $config, a
}
// Add parameters
- $parameters = $this->buildParameters($route, $controller, $action, $config);
+ $parameters = $this->buildParameters($route, $controller, $action);
if (! empty($parameters)) {
$operation['parameters'] = $parameters;
}
// Add request body for POST/PUT/PATCH
if (in_array($method, ['post', 'put', 'patch'])) {
- $operation['requestBody'] = $this->buildRequestBody($route, $controller, $action);
+ $operation['requestBody'] = $this->buildRequestBody($controller, $action);
}
// Add security requirements
@@ -449,14 +451,13 @@ protected function buildOperationTags(Route $route, ?string $controller, string
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/bio' => self::TAG_BIO_LINKS,
+ 'api/blocks' => self::TAG_BIO_LINKS,
+ 'api/shortlinks' => self::TAG_BIO_LINKS,
+ 'api/qr' => self::TAG_BIO_LINKS,
'api/commerce' => 'Commerce',
'api/provisioning' => 'Commerce',
'api/workspaces' => 'Workspaces',
@@ -522,7 +523,7 @@ protected function extractDescription(?string $controller, string $action): ?str
/**
* Build parameters for operation.
*/
- protected function buildParameters(Route $route, ?string $controller, string $action, array $config): array
+ protected function buildParameters(Route $route, ?string $controller, string $action): array
{
$parameters = [];
$parameterIndex = [];
@@ -765,7 +766,7 @@ protected function inferValueSchema(mixed $value, ?string $key = null): array
}
if (is_string($value)) {
- return $this->inferStringSchema($value, $key);
+ return $this->inferStringSchema($key);
}
if (is_array($value)) {
@@ -833,7 +834,7 @@ protected function inferNullableSchema(?string $key): array
/**
* Infer a schema for a string value using the field name as a hint.
*/
- protected function inferStringSchema(string $value, ?string $key): array
+ protected function inferStringSchema(?string $key): array
{
if ($key !== null) {
$nullable = $this->inferNullableSchema($key);
@@ -904,7 +905,7 @@ protected function wrapPaginatedSchema(array $itemSchema): array
/**
* Build request body schema.
*/
- protected function buildRequestBody(Route $route, ?string $controller, string $action): array
+ protected function buildRequestBody(?string $controller, string $action): array
{
if ($controller === \Core\Api\Controllers\McpApiController::class && $action === 'callTool') {
return [
diff --git a/php/src/Api/Models/WebhookEndpoint.php b/php/src/Api/Models/WebhookEndpoint.php
index 5d3a40a..d4225ef 100644
--- a/php/src/Api/Models/WebhookEndpoint.php
+++ b/php/src/Api/Models/WebhookEndpoint.php
@@ -34,6 +34,8 @@ class WebhookEndpoint extends Model
use HasFactory;
use SoftDeletes;
+ private const URL_MUST_RESOLVE_TO_PUBLIC_IP = 'The webhook URL must resolve to a public IP address.';
+
/**
* Available webhook events.
*/
@@ -193,9 +195,13 @@ protected static function resolvePublicDestination(string $url): array
}
$host = (string) $parsed['host'];
- $port = isset($parsed['port'])
- ? (int) $parsed['port']
- : ($scheme === 'https' ? 443 : 80);
+ if (isset($parsed['port'])) {
+ $port = (int) $parsed['port'];
+ } elseif ($scheme === 'https') {
+ $port = 443;
+ } else {
+ $port = 80;
+ }
$normalisedHost = ltrim(rtrim($host, ']'), '[');
if (filter_var($normalisedHost, FILTER_VALIDATE_IP) !== false) {
@@ -224,7 +230,7 @@ protected static function resolvePublicDestination(string $url): array
);
if ($resolveEntries === []) {
- throw new \InvalidArgumentException('The webhook URL must resolve to a public IP address.');
+ throw new \InvalidArgumentException(self::URL_MUST_RESOLVE_TO_PUBLIC_IP);
}
return [
@@ -254,11 +260,11 @@ protected static function resolvePublicIps(string $host, array &$visitedHosts =
$normalisedHost = strtolower(rtrim($host, '.'));
if ($normalisedHost === '' || isset($visitedHosts[$normalisedHost])) {
- throw new \InvalidArgumentException('The webhook URL must resolve to a public IP address.');
+ throw new \InvalidArgumentException(self::URL_MUST_RESOLVE_TO_PUBLIC_IP);
}
if ($depth > 8) {
- throw new \InvalidArgumentException('The webhook URL must resolve to a public IP address.');
+ throw new \InvalidArgumentException(self::URL_MUST_RESOLVE_TO_PUBLIC_IP);
}
$visitedHosts[$normalisedHost] = true;
diff --git a/php/src/Api/Routes/api.php b/php/src/Api/Routes/api.php
index 0938fcc..21d3572 100644
--- a/php/src/Api/Routes/api.php
+++ b/php/src/Api/Routes/api.php
@@ -22,6 +22,9 @@
use Core\Mcp\Middleware\McpApiKeyAuth;
use Illuminate\Support\Facades\Route;
+define('API_ROUTE_WORKSPACE', '/{workspace}');
+define('API_ROUTE_ID', '/{id}');
+
/*
|--------------------------------------------------------------------------
| Core API Routes
@@ -134,11 +137,11 @@
Route::get('/', [WorkspaceController::class, 'index'])->name('index')->defaults('api_cache_control', 'cacheable');
Route::get('/current', [WorkspaceController::class, 'current'])->name('current')->defaults('api_cache_control', 'cacheable');
Route::post('/', [WorkspaceController::class, 'store'])->name('store');
- Route::get('/{workspace}', [WorkspaceController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
- Route::put('/{workspace}', [WorkspaceController::class, 'update'])->name('update');
- Route::patch('/{workspace}', [WorkspaceController::class, 'update'])->name('patch');
- Route::delete('/{workspace}', [WorkspaceController::class, 'destroy'])->name('destroy');
- Route::post('/{workspace}/switch', [WorkspaceController::class, 'switch'])->name('switch');
+ Route::get(API_ROUTE_WORKSPACE, [WorkspaceController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
+ Route::put(API_ROUTE_WORKSPACE, [WorkspaceController::class, 'update'])->name('update');
+ Route::patch(API_ROUTE_WORKSPACE, [WorkspaceController::class, 'update'])->name('patch');
+ Route::delete(API_ROUTE_WORKSPACE, [WorkspaceController::class, 'destroy'])->name('destroy');
+ Route::post(API_ROUTE_WORKSPACE . '/switch', [WorkspaceController::class, 'switch'])->name('switch');
Route::prefix('{workspace}/members')
->name('members.')
@@ -161,9 +164,9 @@
->group(function () {
Route::get('/', [BiolinkController::class, 'index'])->name('index')->defaults('api_cache_control', 'cacheable');
Route::post('/', [BiolinkController::class, 'store'])->name('store');
- Route::get('/{id}', [BiolinkController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
- Route::patch('/{id}', [BiolinkController::class, 'update'])->name('update');
- Route::delete('/{id}', [BiolinkController::class, 'destroy'])->name('destroy');
+ Route::get(API_ROUTE_ID, [BiolinkController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
+ Route::patch(API_ROUTE_ID, [BiolinkController::class, 'update'])->name('update');
+ Route::delete(API_ROUTE_ID, [BiolinkController::class, 'destroy'])->name('destroy');
});
Route::prefix('{workspace}/links')
@@ -171,10 +174,10 @@
->group(function () {
Route::get('/', [LinkController::class, 'index'])->name('index')->defaults('api_cache_control', 'cacheable');
Route::post('/', [LinkController::class, 'store'])->name('store');
- Route::get('/{id}', [LinkController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
- Route::patch('/{id}', [LinkController::class, 'update'])->name('update');
- Route::delete('/{id}', [LinkController::class, 'destroy'])->name('destroy');
- Route::get('/{id}/stats', [LinkController::class, 'stats'])->name('stats')->defaults('api_cache_control', 'cacheable');
+ Route::get(API_ROUTE_ID, [LinkController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
+ Route::patch(API_ROUTE_ID, [LinkController::class, 'update'])->name('update');
+ Route::delete(API_ROUTE_ID, [LinkController::class, 'destroy'])->name('destroy');
+ Route::get(API_ROUTE_ID . '/stats', [LinkController::class, 'stats'])->name('stats')->defaults('api_cache_control', 'cacheable');
});
Route::prefix('{workspace}/qr-codes')
@@ -182,8 +185,8 @@
->group(function () {
Route::get('/', [QrCodeController::class, 'index'])->name('index')->defaults('api_cache_control', 'cacheable');
Route::post('/', [QrCodeController::class, 'store'])->name('store');
- Route::get('/{id}', [QrCodeController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
- Route::get('/{id}/download', [QrCodeController::class, 'download'])->name('download')->defaults('api_cache_control', 'cacheable');
+ Route::get(API_ROUTE_ID, [QrCodeController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
+ Route::get(API_ROUTE_ID . '/download', [QrCodeController::class, 'download'])->name('download')->defaults('api_cache_control', 'cacheable');
});
});
@@ -219,7 +222,7 @@
->group(function () {
Route::get('/', [TicketController::class, 'index'])->name('index')->defaults('api_cache_control', 'cacheable');
Route::post('/', [TicketController::class, 'store'])->name('store');
- Route::get('/{id}', [TicketController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
+ Route::get(API_ROUTE_ID, [TicketController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
Route::post('/{id}/reply', [TicketController::class, 'reply'])->name('reply');
});
});
@@ -233,7 +236,7 @@
->group(function () {
Route::get('/', [ApiKeyController::class, 'index'])->name('index');
Route::post('/', [ApiKeyController::class, 'store'])->name('store');
- Route::delete('/{id}', [ApiKeyController::class, 'destroy'])->name('destroy');
+ Route::delete(API_ROUTE_ID, [ApiKeyController::class, 'destroy'])->name('destroy');
});
Route::prefix('webhooks')
@@ -241,9 +244,9 @@
->group(function () {
Route::get('/', [WebhookController::class, 'index'])->name('index')->defaults('api_cache_control', 'cacheable');
Route::post('/', [WebhookController::class, 'store'])->name('store');
- Route::get('/{id}', [WebhookController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
- Route::patch('/{id}', [WebhookController::class, 'update'])->name('update');
- Route::delete('/{id}', [WebhookController::class, 'destroy'])->name('destroy');
+ Route::get(API_ROUTE_ID, [WebhookController::class, 'show'])->name('show')->defaults('api_cache_control', 'cacheable');
+ Route::patch(API_ROUTE_ID, [WebhookController::class, 'update'])->name('update');
+ Route::delete(API_ROUTE_ID, [WebhookController::class, 'destroy'])->name('destroy');
Route::get('/{id}/deliveries', [WebhookController::class, 'deliveries'])->name('deliveries')->defaults('api_cache_control', 'cacheable');
});
});
diff --git a/php/src/Api/Services/SeoReportService.php b/php/src/Api/Services/SeoReportService.php
index ff07bf5..b163d9f 100644
--- a/php/src/Api/Services/SeoReportService.php
+++ b/php/src/Api/Services/SeoReportService.php
@@ -25,6 +25,8 @@ class SeoReportService
*/
protected const MAX_BODY_BYTES = 1_048_576;
+ private const URL_COULD_NOT_BE_RESOLVED = 'The supplied URL could not be resolved to any address.';
+
/**
* Analyse a URL and return a technical SEO report.
*
@@ -293,10 +295,8 @@ protected function extractCharset(DOMXPath $xpath): ?string
// callers receive a bare encoding label (e.g. "utf-8"), not the whole
// content-type string.
$contentType = $this->extractMetaContent($xpath, 'content-type', 'http-equiv');
- if ($contentType !== null) {
- if (preg_match('/charset\s*=\s*["\']?([^\s;"\']+)/i', $contentType, $matches)) {
- return $matches[1];
- }
+ if ($contentType !== null && preg_match('/charset\s*=\s*["\']?([^\s;"\']+)/i', $contentType, $matches)) {
+ return $matches[1];
}
return null;
@@ -468,9 +468,13 @@ protected function prepareUrlForSsrf(string $url): array
}
$host = $parsed['host'];
- $port = isset($parsed['port'])
- ? (int) $parsed['port']
- : ($scheme === 'https' ? 443 : 80);
+ if (isset($parsed['port'])) {
+ $port = (int) $parsed['port'];
+ } elseif ($scheme === 'https') {
+ $port = 443;
+ } else {
+ $port = 80;
+ }
$resolveEntries = [];
if (isset($parsed['user']) || isset($parsed['pass'])) {
@@ -508,7 +512,7 @@ protected function prepareUrlForSsrf(string $url): array
}
if ($resolveEntries === []) {
- throw new \InvalidArgumentException('The supplied URL could not be resolved to any address.');
+ throw new \InvalidArgumentException(self::URL_COULD_NOT_BE_RESOLVED);
}
return [
@@ -538,11 +542,11 @@ protected function resolvePublicIps(string $host, array &$visitedHosts = [], int
$normalisedHost = strtolower(rtrim($host, '.'));
if ($normalisedHost === '' || isset($visitedHosts[$normalisedHost])) {
- throw new \InvalidArgumentException('The supplied URL could not be resolved to any address.');
+ throw new \InvalidArgumentException(self::URL_COULD_NOT_BE_RESOLVED);
}
if ($depth > 8) {
- throw new \InvalidArgumentException('The supplied URL could not be resolved to any address.');
+ throw new \InvalidArgumentException(self::URL_COULD_NOT_BE_RESOLVED);
}
$visitedHosts[$normalisedHost] = true;
@@ -556,7 +560,7 @@ protected function resolvePublicIps(string $host, array &$visitedHosts = [], int
}
if ($records === []) {
- throw new \InvalidArgumentException('The supplied URL could not be resolved to any address.');
+ throw new \InvalidArgumentException(self::URL_COULD_NOT_BE_RESOLVED);
}
$ips = [];
diff --git a/php/src/Api/Services/WebhookSignature.php b/php/src/Api/Services/WebhookSignature.php
index 0befe1d..ad49f96 100644
--- a/php/src/Api/Services/WebhookSignature.php
+++ b/php/src/Api/Services/WebhookSignature.php
@@ -49,11 +49,6 @@
*/
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.
diff --git a/php/src/Api/Services/WebhookTemplateService.php b/php/src/Api/Services/WebhookTemplateService.php
index f7abb91..31d2700 100644
--- a/php/src/Api/Services/WebhookTemplateService.php
+++ b/php/src/Api/Services/WebhookTemplateService.php
@@ -136,7 +136,7 @@ public function validateTemplate(string $template, WebhookTemplateFormat $format
public function getAvailableVariables(?string $eventType = null): array
{
// Base variables available for all events
- $variables = [
+ return [
'event.type' => [
'type' => 'string',
'description' => 'The event identifier',
@@ -178,8 +178,6 @@ public function getAvailableVariables(?string $eventType = null): array
'example' => '550e8400-e29b-41d4-a716-446655440000',
],
];
-
- return $variables;
}
/**
@@ -340,7 +338,7 @@ 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*\}\}/',
+ '/\{\{\s*([\w.]+)(?:\s*\|\s*(\w+)(?::([^\}]+))?)?\s*\}\}/',
function ($matches) use ($context) {
$path = $matches[1];
$filter = $matches[2] ?? null;
@@ -483,7 +481,7 @@ protected function validateSimple(string $template): array
}
// Check for unknown filters
- preg_match_all('/\|\s*([a-zA-Z0-9_]+)/', $template, $filterMatches);
+ preg_match_all('/\|\s*(\w+)/', $template, $filterMatches);
foreach ($filterMatches[1] as $filter) {
if (! isset(self::FILTERS[$filter])) {
$errors[] = "Unknown filter: {$filter}. Available: ".implode(', ', array_keys(self::FILTERS));
@@ -544,7 +542,7 @@ protected function validateJson(string $template): array
// Filter methods
// -------------------------------------------------------------------------
- protected function formatIso8601(mixed $value, ?string $arg = null): string
+ protected function formatIso8601(mixed $value, ?string $_arg = null): string
{
if ($value instanceof Carbon) {
return $value->toIso8601String();
@@ -565,7 +563,7 @@ protected function formatIso8601(mixed $value, ?string $arg = null): string
return (string) $value;
}
- protected function formatTimestamp(mixed $value, ?string $arg = null): int
+ protected function formatTimestamp(mixed $value, ?string $_arg = null): int
{
if ($value instanceof Carbon) {
return $value->timestamp;
@@ -593,17 +591,17 @@ protected function formatCurrency(mixed $value, ?string $arg = null): string
return number_format((float) $value, $decimals);
}
- protected function formatJson(mixed $value, ?string $arg = null): string
+ protected function formatJson(mixed $value, ?string $_arg = null): string
{
return json_encode($value) ?: '""';
}
- protected function formatUpper(mixed $value, ?string $arg = null): string
+ protected function formatUpper(mixed $value, ?string $_arg = null): string
{
return mb_strtoupper((string) $value);
}
- protected function formatLower(mixed $value, ?string $arg = null): string
+ protected function formatLower(mixed $value, ?string $_arg = null): string
{
return mb_strtolower((string) $value);
}
@@ -629,12 +627,12 @@ protected function formatTruncate(mixed $value, ?string $arg = null): string
return mb_substr($string, 0, $length - 3).'...';
}
- protected function formatEscape(mixed $value, ?string $arg = null): string
+ 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
+ protected function formatUrlencode(mixed $value, ?string $_arg = null): string
{
return urlencode((string) $value);
}
diff --git a/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php b/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php
index b67a592..597a74e 100644
--- a/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php
+++ b/php/src/Api/Tests/Feature/ApiKeyIpWhitelistTest.php
@@ -8,6 +8,13 @@
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
+define('IP_192_168_1_1', '192.168.1.1');
+define('IP_10_0_0_1', '10.0.0.1');
+define('CIDR_192_168_1_0_24', '192.168.1.0/24');
+define('CIDR_10_0_0_0_8', '10.0.0.0/8');
+define('IP_2001_DB8_1', '2001:db8::1');
+define('CIDR_2001_DB8_32', '2001:db8::/32');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -28,22 +35,22 @@
describe('IP Restriction Service - IPv4', function () {
it('allows IP when whitelist is empty', function () {
- expect($this->ipService->isIpAllowed('192.168.1.1', []))->toBeTrue();
+ expect($this->ipService->isIpAllowed(IP_192_168_1_1, []))->toBeTrue();
});
it('matches exact IPv4 address', function () {
- $whitelist = ['192.168.1.1', '10.0.0.1'];
+ $whitelist = [IP_192_168_1_1, IP_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(IP_192_168_1_1, $whitelist))->toBeTrue();
+ expect($this->ipService->isIpAllowed(IP_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'];
+ $whitelist = [CIDR_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(IP_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();
});
@@ -51,7 +58,7 @@
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(IP_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();
});
@@ -64,15 +71,15 @@
});
it('matches IPv4 CIDR /8 class A range', function () {
- $whitelist = ['10.0.0.0/8'];
+ $whitelist = [CIDR_10_0_0_0_8];
- expect($this->ipService->isIpAllowed('10.0.0.1', $whitelist))->toBeTrue();
+ expect($this->ipService->isIpAllowed(IP_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'];
+ $whitelist = [CIDR_192_168_1_0_24];
expect($this->ipService->isIpAllowed('invalid', $whitelist))->toBeFalse();
expect($this->ipService->isIpAllowed('256.256.256.256', $whitelist))->toBeFalse();
@@ -86,10 +93,10 @@
describe('IP Restriction Service - IPv6', function () {
it('matches exact IPv6 address', function () {
- $whitelist = ['::1', '2001:db8::1'];
+ $whitelist = ['::1', IP_2001_DB8_1];
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
- expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
+ expect($this->ipService->isIpAllowed(IP_2001_DB8_1, $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db8::2', $whitelist))->toBeFalse();
});
@@ -97,21 +104,21 @@
$whitelist = ['2001:db8:0000:0000:0000:0000:0000:0001'];
// Shortened form should match expanded form
- expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
+ expect($this->ipService->isIpAllowed(IP_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(IP_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'];
+ $whitelist = [CIDR_2001_DB8_32];
- expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeTrue();
+ expect($this->ipService->isIpAllowed(IP_2001_DB8_1, $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db8:ffff::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse();
});
@@ -124,15 +131,15 @@
});
it('does not match IPv4 against IPv6 CIDR', function () {
- $whitelist = ['2001:db8::/32'];
+ $whitelist = [CIDR_2001_DB8_32];
- expect($this->ipService->isIpAllowed('192.168.1.1', $whitelist))->toBeFalse();
+ expect($this->ipService->isIpAllowed(IP_192_168_1_1, $whitelist))->toBeFalse();
});
it('does not match IPv6 against IPv4 CIDR', function () {
- $whitelist = ['192.168.1.0/24'];
+ $whitelist = [CIDR_192_168_1_0_24];
- expect($this->ipService->isIpAllowed('2001:db8::1', $whitelist))->toBeFalse();
+ expect($this->ipService->isIpAllowed(IP_2001_DB8_1, $whitelist))->toBeFalse();
});
});
@@ -142,28 +149,28 @@
describe('IP Restriction Service - Validation', function () {
it('validates correct IPv4 addresses', function () {
- $result = $this->ipService->validateEntry('192.168.1.1');
+ $result = $this->ipService->validateEntry(IP_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');
+ $result = $this->ipService->validateEntry(IP_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');
+ $result = $this->ipService->validateEntry(CIDR_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');
+ $result = $this->ipService->validateEntry(CIDR_2001_DB8_32);
expect($result['valid'])->toBeTrue();
expect($result['error'])->toBeNull();
@@ -202,7 +209,7 @@
$result = $this->ipService->parseWhitelistInput($input);
- expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.0/8', '2001:db8::1']);
+ expect($result['entries'])->toBe([IP_192_168_1_1, CIDR_10_0_0_0_8, IP_2001_DB8_1]);
expect($result['errors'])->toHaveCount(1);
expect($result['errors'][0])->toContain('invalid-ip');
});
@@ -212,7 +219,7 @@
$result = $this->ipService->parseWhitelistInput($input);
- expect($result['entries'])->toBe(['192.168.1.1', '10.0.0.1', '172.16.0.0/12']);
+ expect($result['entries'])->toBe([IP_192_168_1_1, IP_10_0_0_1, '172.16.0.0/12']);
expect($result['errors'])->toBeEmpty();
});
});
@@ -250,11 +257,11 @@
$this->user->id,
'Restricted Key'
);
- $result['api_key']->update(['allowed_ips' => ['192.168.1.0/24']]);
+ $result['api_key']->update(['allowed_ips' => [CIDR_192_168_1_0_24]]);
$key = $result['api_key']->fresh();
expect($key->hasIpRestrictions())->toBeTrue();
- expect($key->getAllowedIps())->toBe(['192.168.1.0/24']);
+ expect($key->getAllowedIps())->toBe([CIDR_192_168_1_0_24]);
});
it('updates allowed IPs', function () {
@@ -264,9 +271,9 @@
'Update IPs Key'
);
- $result['api_key']->updateAllowedIps(['10.0.0.0/8', '192.168.1.1']);
+ $result['api_key']->updateAllowedIps([CIDR_10_0_0_0_8, IP_192_168_1_1]);
- expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.0/8', '192.168.1.1']);
+ expect($result['api_key']->fresh()->getAllowedIps())->toBe([CIDR_10_0_0_0_8, IP_192_168_1_1]);
});
it('adds IP to whitelist', function () {
@@ -275,11 +282,11 @@
$this->user->id,
'Add IP Key'
);
- $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
+ $result['api_key']->update(['allowed_ips' => [IP_192_168_1_1]]);
- $result['api_key']->addAllowedIp('10.0.0.1');
+ $result['api_key']->addAllowedIp(IP_10_0_0_1);
- expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1', '10.0.0.1']);
+ expect($result['api_key']->fresh()->getAllowedIps())->toBe([IP_192_168_1_1, IP_10_0_0_1]);
});
it('does not add duplicate IPs', function () {
@@ -288,11 +295,11 @@
$this->user->id,
'Duplicate IP Key'
);
- $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
+ $result['api_key']->update(['allowed_ips' => [IP_192_168_1_1]]);
- $result['api_key']->addAllowedIp('192.168.1.1');
+ $result['api_key']->addAllowedIp(IP_192_168_1_1);
- expect($result['api_key']->fresh()->getAllowedIps())->toBe(['192.168.1.1']);
+ expect($result['api_key']->fresh()->getAllowedIps())->toBe([IP_192_168_1_1]);
});
it('removes IP from whitelist', function () {
@@ -301,11 +308,11 @@
$this->user->id,
'Remove IP Key'
);
- $result['api_key']->update(['allowed_ips' => ['192.168.1.1', '10.0.0.1']]);
+ $result['api_key']->update(['allowed_ips' => [IP_192_168_1_1, IP_10_0_0_1]]);
- $result['api_key']->removeAllowedIp('192.168.1.1');
+ $result['api_key']->removeAllowedIp(IP_192_168_1_1);
- expect($result['api_key']->fresh()->getAllowedIps())->toBe(['10.0.0.1']);
+ expect($result['api_key']->fresh()->getAllowedIps())->toBe([IP_10_0_0_1]);
});
it('sets allowed_ips to null when removing last IP', function () {
@@ -314,9 +321,9 @@
$this->user->id,
'Remove Last IP Key'
);
- $result['api_key']->update(['allowed_ips' => ['192.168.1.1']]);
+ $result['api_key']->update(['allowed_ips' => [IP_192_168_1_1]]);
- $result['api_key']->removeAllowedIp('192.168.1.1');
+ $result['api_key']->removeAllowedIp(IP_192_168_1_1);
expect($result['api_key']->fresh()->getAllowedIps())->toBeNull();
expect($result['api_key']->fresh()->hasIpRestrictions())->toBeFalse();
@@ -334,11 +341,11 @@
$this->user->id,
'IP Restricted Key'
);
- $result['api_key']->update(['allowed_ips' => ['192.168.1.0/24', '10.0.0.1']]);
+ $result['api_key']->update(['allowed_ips' => [CIDR_192_168_1_0_24, IP_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']);
+ expect($rotated['api_key']->getAllowedIps())->toBe([CIDR_192_168_1_0_24, IP_10_0_0_1]);
});
it('preserves empty IP whitelist during rotation', function () {
@@ -364,11 +371,11 @@
$key = ApiKey::factory()
->for($this->workspace)
->for($this->user)
- ->withAllowedIps(['192.168.1.0/24', '::1'])
+ ->withAllowedIps([CIDR_192_168_1_0_24, '::1'])
->create();
expect($key->hasIpRestrictions())->toBeTrue();
- expect($key->getAllowedIps())->toBe(['192.168.1.0/24', '::1']);
+ expect($key->getAllowedIps())->toBe([CIDR_192_168_1_0_24, '::1']);
});
it('creates keys without IP restrictions by default', function () {
@@ -388,15 +395,15 @@
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'];
+ $whitelist = [CIDR_192_168_1_0_24, CIDR_2001_DB8_32, IP_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(IP_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(IP_2001_DB8_1, $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('::1', $whitelist))->toBeTrue();
expect($this->ipService->isIpAllowed('2001:db9::1', $whitelist))->toBeFalse();
});
diff --git a/php/src/Api/Tests/Feature/ApiKeyRotationTest.php b/php/src/Api/Tests/Feature/ApiKeyRotationTest.php
index 58f2c2a..9a7b2ae 100644
--- a/php/src/Api/Tests/Feature/ApiKeyRotationTest.php
+++ b/php/src/Api/Tests/Feature/ApiKeyRotationTest.php
@@ -215,7 +215,7 @@
});
it('returns workspace key statistics', function () {
- $key1 = $this->service->create($this->workspace->id, $this->user->id, 'Active Key');
+ $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()]);
diff --git a/php/src/Api/Tests/Feature/ApiKeyTest.php b/php/src/Api/Tests/Feature/ApiKeyTest.php
index d96d799..bb69dd6 100644
--- a/php/src/Api/Tests/Feature/ApiKeyTest.php
+++ b/php/src/Api/Tests/Feature/ApiKeyTest.php
@@ -8,6 +8,9 @@
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
+define('KEY_NAME_ACTIVE', 'Active Key');
+define('API_MCP_SERVERS_PATH', '/api/mcp/servers');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -383,7 +386,7 @@ public function update(array $attributes = [], array $options = []): bool
ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Active Key',
+ KEY_NAME_ACTIVE,
[ApiKey::SCOPE_READ],
now()->addDays(30)
);
@@ -509,7 +512,7 @@ public function update(array $attributes = [], array $options = []): bool
ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Active Key'
+ KEY_NAME_ACTIVE
);
// Create and revoke a key
@@ -523,7 +526,7 @@ public function update(array $attributes = [], array $options = []): bool
$keys = ApiKey::forWorkspace($this->workspace->id)->get();
expect($keys)->toHaveCount(1);
- expect($keys->first()->name)->toBe('Active Key');
+ expect($keys->first()->name)->toBe(KEY_NAME_ACTIVE);
});
});
@@ -716,14 +719,14 @@ public function update(array $attributes = [], array $options = []): bool
describe('HTTP Authentication', function () {
it('requires authorization header', function () {
- $response = $this->getJson('/api/mcp/servers');
+ $response = $this->getJson(API_MCP_SERVERS_PATH);
expect($response->status())->toBe(401);
expect($response->json('error'))->toBe('unauthorized');
});
it('rejects invalid API key', function () {
- $response = $this->getJson('/api/mcp/servers', [
+ $response = $this->getJson(API_MCP_SERVERS_PATH, [
'Authorization' => 'Bearer hk_invalid_'.str_repeat('x', 48),
]);
@@ -739,7 +742,7 @@ public function update(array $attributes = [], array $options = []): bool
now()->subDay()
);
- $response = $this->getJson('/api/mcp/servers', [
+ $response = $this->getJson(API_MCP_SERVERS_PATH, [
'Authorization' => "Bearer {$result['plain_key']}",
]);
diff --git a/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php b/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php
index be8a28f..a4cd196 100644
--- a/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php
+++ b/php/src/Api/Tests/Feature/ApiScopeEnforcementTest.php
@@ -8,6 +8,16 @@
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
+define('KEY_NAME_READ_ONLY', 'Read Only Key');
+define('KEY_NAME_READ_WRITE', 'Read/Write Key');
+define('KEY_NAME_POSTS_ADMIN', 'Posts Admin Key');
+define('SCOPE_POSTS_ALL', 'posts:*');
+define('SCOPE_ALL_READ', '*:read');
+define('TEST_SCOPE_WRITE_PATH', '/api/test-scope/write');
+define('TEST_SCOPE_DELETE_PATH', '/api/test-scope/delete');
+define('TEST_EXPLICIT_POSTS_PATH', '/test-explicit/posts');
+define('API_TEST_EXPLICIT_POSTS_PATH', '/api/test-explicit/posts');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -41,7 +51,7 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read Only Key',
+ KEY_NAME_READ_ONLY,
[ApiKey::SCOPE_READ]
);
@@ -57,11 +67,11 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read Only Key',
+ KEY_NAME_READ_ONLY,
[ApiKey::SCOPE_READ]
);
- $response = $this->postJson('/api/test-scope/write', [], [
+ $response = $this->postJson(TEST_SCOPE_WRITE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -74,11 +84,11 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read Only Key',
+ KEY_NAME_READ_ONLY,
[ApiKey::SCOPE_READ]
);
- $response = $this->deleteJson('/api/test-scope/delete', [], [
+ $response = $this->deleteJson(TEST_SCOPE_DELETE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -97,11 +107,11 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read/Write Key',
+ KEY_NAME_READ_WRITE,
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
- $response = $this->postJson('/api/test-scope/write', [], [
+ $response = $this->postJson(TEST_SCOPE_WRITE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -112,7 +122,7 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read/Write Key',
+ KEY_NAME_READ_WRITE,
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
@@ -127,7 +137,7 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read/Write Key',
+ KEY_NAME_READ_WRITE,
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
@@ -142,11 +152,11 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read/Write Key',
+ KEY_NAME_READ_WRITE,
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
- $response = $this->deleteJson('/api/test-scope/delete', [], [
+ $response = $this->deleteJson(TEST_SCOPE_DELETE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -168,7 +178,7 @@
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE, ApiKey::SCOPE_DELETE]
);
- $response = $this->deleteJson('/api/test-scope/delete', [], [
+ $response = $this->deleteJson(TEST_SCOPE_DELETE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -179,11 +189,11 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read Only Key',
+ KEY_NAME_READ_ONLY,
[ApiKey::SCOPE_READ]
);
- $response = $this->deleteJson('/api/test-scope/delete', [], [
+ $response = $this->deleteJson(TEST_SCOPE_DELETE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -208,10 +218,10 @@
$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->postJson(TEST_SCOPE_WRITE_PATH, [], $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);
+ expect($this->deleteJson(TEST_SCOPE_DELETE_PATH, [], $headers)->status())->toBe(200);
});
});
@@ -240,8 +250,8 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Posts Admin Key',
- ['posts:*']
+ KEY_NAME_POSTS_ADMIN,
+ [SCOPE_POSTS_ALL]
);
$apiKey = $result['api_key'];
@@ -258,7 +268,7 @@
$this->workspace->id,
$this->user->id,
'Posts Only Key',
- ['posts:*']
+ [SCOPE_POSTS_ALL]
);
$apiKey = $result['api_key'];
@@ -274,7 +284,7 @@
$this->workspace->id,
$this->user->id,
'Content Admin Key',
- ['posts:*', 'pages:*']
+ [SCOPE_POSTS_ALL, 'pages:*']
);
$apiKey = $result['api_key'];
@@ -300,7 +310,7 @@
$this->workspace->id,
$this->user->id,
'Read Only All Key',
- ['*:read']
+ [SCOPE_ALL_READ]
);
$apiKey = $result['api_key'];
@@ -317,7 +327,7 @@
$this->workspace->id,
$this->user->id,
'Read Only All Key',
- ['*:read']
+ [SCOPE_ALL_READ]
);
$apiKey = $result['api_key'];
@@ -333,7 +343,7 @@
$this->workspace->id,
$this->user->id,
'Read/Write All Key',
- ['*:read', '*:write']
+ [SCOPE_ALL_READ, '*:write']
);
$apiKey = $result['api_key'];
@@ -400,7 +410,7 @@
$this->workspace->id,
$this->user->id,
'Mixed Key',
- ['posts:read', 'posts:*']
+ ['posts:read', SCOPE_POSTS_ALL]
);
$apiKey = $result['api_key'];
@@ -415,7 +425,7 @@
$this->workspace->id,
$this->user->id,
'Mixed Wildcards Key',
- ['posts:*', '*:read']
+ [SCOPE_POSTS_ALL, SCOPE_ALL_READ]
);
$apiKey = $result['api_key'];
@@ -488,8 +498,8 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Posts Admin Key',
- ['posts:*']
+ KEY_NAME_POSTS_ADMIN,
+ [SCOPE_POSTS_ALL]
);
$apiKey = $result['api_key'];
@@ -503,7 +513,7 @@
$this->workspace->id,
$this->user->id,
'Read All Key',
- ['*:read']
+ [SCOPE_ALL_READ]
);
$apiKey = $result['api_key'];
@@ -521,13 +531,13 @@
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']));
+ ->get(TEST_EXPLICIT_POSTS_PATH, fn () => response()->json(['status' => 'ok']));
Route::middleware(['api', 'api.auth', 'api.scope:posts:write'])
- ->post('/test-explicit/posts', fn () => response()->json(['status' => 'ok']));
+ ->post(TEST_EXPLICIT_POSTS_PATH, 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']));
+ ->put(TEST_EXPLICIT_POSTS_PATH, fn () => response()->json(['status' => 'ok']));
});
it('allows request with exact required scope', function () {
@@ -538,7 +548,7 @@
['posts:read']
);
- $response = $this->getJson('/api/test-explicit/posts', [
+ $response = $this->getJson(API_TEST_EXPLICIT_POSTS_PATH, [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -549,11 +559,11 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Posts Admin Key',
- ['posts:*']
+ KEY_NAME_POSTS_ADMIN,
+ [SCOPE_POSTS_ALL]
);
- $response = $this->getJson('/api/test-explicit/posts', [
+ $response = $this->getJson(API_TEST_EXPLICIT_POSTS_PATH, [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -568,7 +578,7 @@
['users:read']
);
- $response = $this->getJson('/api/test-explicit/posts', [
+ $response = $this->getJson(API_TEST_EXPLICIT_POSTS_PATH, [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -586,7 +596,7 @@
);
// Route requires both posts:read AND posts:write
- $response = $this->putJson('/api/test-explicit/posts', [], [
+ $response = $this->putJson(API_TEST_EXPLICIT_POSTS_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -604,9 +614,9 @@
$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);
+ expect($this->getJson(API_TEST_EXPLICIT_POSTS_PATH, $headers)->status())->toBe(200);
+ expect($this->postJson(API_TEST_EXPLICIT_POSTS_PATH, [], $headers)->status())->toBe(200);
+ expect($this->putJson(API_TEST_EXPLICIT_POSTS_PATH, [], $headers)->status())->toBe(200);
});
});
@@ -619,11 +629,11 @@
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
- 'Read Only Key',
+ KEY_NAME_READ_ONLY,
[ApiKey::SCOPE_READ]
);
- $response = $this->postJson('/api/test-scope/write', [], [
+ $response = $this->postJson(TEST_SCOPE_WRITE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -642,7 +652,7 @@
['users:read']
);
- $response = $this->getJson('/api/test-explicit/posts', [
+ $response = $this->getJson(API_TEST_EXPLICIT_POSTS_PATH, [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -660,7 +670,7 @@
['posts:read', 'users:read', 'analytics:read']
);
- $response = $this->deleteJson('/api/test-scope/delete', [], [
+ $response = $this->deleteJson(TEST_SCOPE_DELETE_PATH, [], [
'Authorization' => "Bearer {$result['plain_key']}",
]);
diff --git a/php/src/Api/Tests/Feature/ApiUsageTest.php b/php/src/Api/Tests/Feature/ApiUsageTest.php
index 74ae92b..02bbc6e 100644
--- a/php/src/Api/Tests/Feature/ApiUsageTest.php
+++ b/php/src/Api/Tests/Feature/ApiUsageTest.php
@@ -9,6 +9,10 @@
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
+define('API_V1_WORKSPACES', '/api/v1/workspaces');
+define('API_V1_TEST', '/api/v1/test');
+define('API_V1_OLD', '/api/v1/old');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -34,7 +38,7 @@
$usage = $this->service->record(
apiKeyId: $this->apiKey->id,
workspaceId: $this->workspace->id,
- endpoint: '/api/v1/workspaces',
+ endpoint: API_V1_WORKSPACES,
method: 'GET',
statusCode: 200,
responseTimeMs: 150,
@@ -44,7 +48,7 @@
expect($usage)->toBeInstanceOf(ApiUsage::class);
expect($usage->api_key_id)->toBe($this->apiKey->id);
- expect($usage->endpoint)->toBe('/api/v1/workspaces');
+ 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);
@@ -80,7 +84,7 @@
$this->service->record(
apiKeyId: $this->apiKey->id,
workspaceId: $this->workspace->id,
- endpoint: '/api/v1/test',
+ endpoint: API_V1_TEST,
method: 'GET',
statusCode: 200,
responseTimeMs: 100
@@ -101,7 +105,7 @@
$this->service->record(
apiKeyId: $this->apiKey->id,
workspaceId: $this->workspace->id,
- endpoint: '/api/v1/test',
+ endpoint: API_V1_TEST,
method: 'GET',
statusCode: 200,
responseTimeMs: 100 + ($i * 10)
@@ -113,7 +117,7 @@
$this->service->record(
apiKeyId: $this->apiKey->id,
workspaceId: $this->workspace->id,
- endpoint: '/api/v1/test',
+ endpoint: API_V1_TEST,
method: 'GET',
statusCode: 500,
responseTimeMs: 50
@@ -141,7 +145,7 @@
$this->service->record(
apiKeyId: $this->apiKey->id,
workspaceId: $this->workspace->id,
- endpoint: '/api/v1/workspaces',
+ endpoint: API_V1_WORKSPACES,
method: 'GET',
statusCode: 200,
responseTimeMs: 100 + $i
@@ -152,7 +156,7 @@
$this->service->record(
apiKeyId: $this->apiKey->id,
workspaceId: $this->workspace->id,
- endpoint: '/api/v1/workspaces',
+ endpoint: API_V1_WORKSPACES,
method: 'POST',
statusCode: 422,
responseTimeMs: 50
@@ -186,10 +190,10 @@
it('filters by date range', function () {
// Create usage for 2 days ago with correct timestamp upfront
$oldDate = now()->subDays(2);
- $usage = ApiUsage::create([
+ ApiUsage::create([
'api_key_id' => $this->apiKey->id,
'workspace_id' => $this->workspace->id,
- 'endpoint' => '/api/v1/old',
+ 'endpoint' => API_V1_OLD,
'method' => 'GET',
'status_code' => 200,
'response_time_ms' => 100,
@@ -239,7 +243,7 @@
$usage = ApiUsage::record(
$this->apiKey->id,
$this->workspace->id,
- '/api/v1/test',
+ API_V1_TEST,
'GET',
200,
100
@@ -278,9 +282,9 @@
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);
+ $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);
@@ -292,7 +296,7 @@
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);
+ $this->service->record($key2['api_key']->id, $this->workspace->id, API_V1_TEST, 'GET', 200, 100);
$comparison = $this->service->getKeyComparison($this->workspace->id);
@@ -313,7 +317,7 @@
$usage = ApiUsage::record(
$this->apiKey->id,
$this->workspace->id,
- '/api/v1/old',
+ API_V1_OLD,
'GET',
200,
100
@@ -344,7 +348,7 @@
$usage = ApiUsage::record(
$this->apiKey->id,
$this->workspace->id,
- '/api/v1/old',
+ API_V1_OLD,
'GET',
200,
100
diff --git a/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php b/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php
index 955a4df..bcd4946 100644
--- a/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php
+++ b/php/src/Api/Tests/Feature/AuthenticateApiKeyTest.php
@@ -10,6 +10,8 @@
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;
+define('API_TEST_AUTH_SCOPED', '/api/test-auth/scoped');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -43,7 +45,7 @@
[ApiKey::SCOPE_READ]
);
- $response = $this->getJson('/api/test-auth/scoped', [
+ $response = $this->getJson(API_TEST_AUTH_SCOPED, [
'Authorization' => "Bearer {$result['plain_key']}",
]);
@@ -72,7 +74,7 @@
it('AuthenticateApiKey_handle_Bad rejects scoped bearer tokens without api-key scopes', function () {
$result = $this->user->createToken('Dashboard Token');
- $response = $this->getJson('/api/test-auth/scoped', [
+ $response = $this->getJson(API_TEST_AUTH_SCOPED, [
'Authorization' => "Bearer {$result['token']}",
]);
@@ -83,7 +85,7 @@
});
it('AuthenticateApiKey_handle_Ugly rejects malformed bearer tokens and unauthenticated requests', function () {
- $response = $this->getJson('/api/test-auth/scoped', [
+ $response = $this->getJson(API_TEST_AUTH_SCOPED, [
'Authorization' => 'Bearer hk_invalid_'.str_repeat('x', 48),
]);
@@ -107,7 +109,7 @@ protected function resolveApiKey(string $token): ?ApiKey
}
};
- $request = Request::create('/api/test-auth/scoped', 'GET', server: [
+ $request = Request::create(API_TEST_AUTH_SCOPED, 'GET', server: [
'HTTP_AUTHORIZATION' => 'Bearer hk_lookup_failure_'.str_repeat('x', 48),
]);
diff --git a/php/src/Api/Tests/Feature/DocumentationControllerTest.php b/php/src/Api/Tests/Feature/DocumentationControllerTest.php
index 6a384d6..c5c0036 100644
--- a/php/src/Api/Tests/Feature/DocumentationControllerTest.php
+++ b/php/src/Api/Tests/Feature/DocumentationControllerTest.php
@@ -7,6 +7,8 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
+define('API_DOCS_PATH', '/api/docs');
+
class StubDocumentationBuilder extends OpenApiBuilder
{
public bool $cleared = false;
@@ -99,7 +101,7 @@ public function clearCache(): void
foreach ($cases as $ui => $expectedView) {
config(['api-docs.ui.default' => $ui]);
- $response = $controller->index(Request::create('/api/docs', 'GET'));
+ $response = $controller->index(Request::create(API_DOCS_PATH, 'GET'));
expect($response->name())->toBe($expectedView);
}
@@ -111,7 +113,7 @@ public function clearCache(): void
config(['api-docs.ui.default' => 'unsupported']);
- $response = $controller->index(Request::create('/api/docs', 'GET'));
+ $response = $controller->index(Request::create(API_DOCS_PATH, 'GET'));
expect($response->name())->toBe('api-docs::scalar');
});
@@ -122,7 +124,7 @@ public function clearCache(): void
config(['api-docs.ui.default' => ' ']);
- $response = $controller->index(Request::create('/api/docs', 'GET'));
+ $response = $controller->index(Request::create(API_DOCS_PATH, 'GET'));
expect($response->name())->toBe('api-docs::scalar');
});
diff --git a/php/src/Api/Tests/Feature/McpApiControllerTest.php b/php/src/Api/Tests/Feature/McpApiControllerTest.php
index bb93d55..618079d 100644
--- a/php/src/Api/Tests/Feature/McpApiControllerTest.php
+++ b/php/src/Api/Tests/Feature/McpApiControllerTest.php
@@ -171,6 +171,7 @@ protected function dispatchWebhook(
int $durationMs,
?string $error = null
): void {
+ // Stub — no-op for anonymous controller test double
}
protected function logApiRequest(
@@ -183,6 +184,7 @@ protected function logApiRequest(
?\Core\Api\Models\ApiKey $apiKey,
?string $error = null
): void {
+ // Stub — no-op for anonymous controller test double
}
};
diff --git a/php/src/Api/Tests/Feature/McpResourceTest.php b/php/src/Api/Tests/Feature/McpResourceTest.php
index 9b3199f..def43d0 100644
--- a/php/src/Api/Tests/Feature/McpResourceTest.php
+++ b/php/src/Api/Tests/Feature/McpResourceTest.php
@@ -9,6 +9,8 @@
use Core\Tenant\Models\Workspace;
use Illuminate\Http\Request;
+define('TEST_RESOURCE_URI', 'test-resource-server://documents/welcome');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -75,7 +77,7 @@
});
it('reads a resource from the server definition', function () {
- $encodedUri = rawurlencode('test-resource-server://documents/welcome');
+ $encodedUri = rawurlencode(TEST_RESOURCE_URI);
$response = $this->getJson("/api/mcp/resources/{$encodedUri}", [
'Authorization' => "Bearer {$this->plainKey}",
@@ -83,7 +85,7 @@
$response->assertOk();
$response->assertJson([
- 'uri' => 'test-resource-server://documents/welcome',
+ 'uri' => TEST_RESOURCE_URI,
'server' => 'test-resource-server',
'resource' => 'documents/welcome',
]);
@@ -99,7 +101,7 @@
'server_scopes' => ['another-server'],
]);
- $encodedUri = rawurlencode('test-resource-server://documents/welcome');
+ $encodedUri = rawurlencode(TEST_RESOURCE_URI);
$response = $this->getJson("/api/mcp/resources/{$encodedUri}", [
'Authorization' => "Bearer {$this->plainKey}",
@@ -139,7 +141,7 @@ protected function readResourceViaArtisan(string $server, string $path): mixed
$response->assertOk();
$response->assertJsonPath('server', 'test-resource-server');
$response->assertJsonPath('count', 1);
- $response->assertJsonPath('resources.0.uri', 'test-resource-server://documents/welcome');
+ $response->assertJsonPath('resources.0.uri', TEST_RESOURCE_URI);
$response->assertJsonPath('resources.0.path', 'documents/welcome');
$response->assertJsonPath('resources.0.name', 'welcome');
$response->assertJsonMissingPath('resources.0.content');
diff --git a/php/src/Api/Tests/Feature/McpServerAccessTest.php b/php/src/Api/Tests/Feature/McpServerAccessTest.php
index c61a135..36ca6a7 100644
--- a/php/src/Api/Tests/Feature/McpServerAccessTest.php
+++ b/php/src/Api/Tests/Feature/McpServerAccessTest.php
@@ -7,6 +7,8 @@
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
+define('ALLOWED_SERVER_YAML', '/allowed-server.yaml');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -48,7 +50,7 @@
- id: blocked-server
YAML);
- file_put_contents($this->serverDir.'/allowed-server.yaml', <<serverDir.ALLOWED_SERVER_YAML, <<serverDir)) {
- $paths[] = $this->serverDir.'/allowed-server.yaml';
+ $paths[] = $this->serverDir.ALLOWED_SERVER_YAML;
$paths[] = $this->serverDir.'/blocked-server.yaml';
}
@@ -157,7 +159,7 @@
});
it('returns a not found response when a server definition cannot be parsed', function () {
- file_put_contents($this->serverDir.'/allowed-server.yaml', <<serverDir.ALLOWED_SERVER_YAML, <<serverDir.'/allowed-server.yaml')) {
- unlink($this->serverDir.'/allowed-server.yaml');
+ if (file_exists($this->serverDir.ALLOWED_SERVER_YAML)) {
+ unlink($this->serverDir.ALLOWED_SERVER_YAML);
}
file_put_contents($this->evilServerFile, <<evilServerFile, $this->serverDir.'/allowed-server.yaml');
+ symlink($this->evilServerFile, $this->serverDir.ALLOWED_SERVER_YAML);
$response = $this->getJson('/api/mcp/servers/allowed-server', [
'Authorization' => "Bearer {$this->plainKey}",
diff --git a/php/src/Api/Tests/Feature/McpServerDetailTest.php b/php/src/Api/Tests/Feature/McpServerDetailTest.php
index 485f2ac..03eac0c 100644
--- a/php/src/Api/Tests/Feature/McpServerDetailTest.php
+++ b/php/src/Api/Tests/Feature/McpServerDetailTest.php
@@ -69,7 +69,7 @@
app()->instance(ToolVersionService::class, new class
{
- public function getLatestVersion(string $serverId, string $toolName): object
+ public function getLatestVersion(string $_serverId, string $_toolName): object
{
return (object) [
'version' => '2.1.0',
@@ -140,7 +140,7 @@ public function getLatestVersion(string $serverId, string $toolName): object
it('McpApiController_callToolByRoute_Good_uses_route_parameters_with_a_test_seam', function () {
app()->instance(ToolVersionService::class, new class
{
- public function resolveVersion(string $server, string $tool, ?string $version): array
+ public function resolveVersion(string $_server, string $_tool, ?string $_version): array
{
return [
'version' => null,
@@ -175,6 +175,7 @@ protected function logToolCall(
bool $success,
?string $error = null
): void {
+ // Stub — no-op for anonymous controller test double
}
protected function dispatchWebhook(
@@ -184,6 +185,7 @@ protected function dispatchWebhook(
int $durationMs,
?string $error = null
): void {
+ // Stub — no-op for anonymous controller test double
}
protected function logApiRequest(
@@ -196,6 +198,7 @@ protected function logApiRequest(
?ApiKey $apiKey,
?string $error = null
): void {
+ // Stub — no-op for anonymous controller test double
}
};
diff --git a/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php b/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php
index 06e7cac..0d5af36 100644
--- a/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php
+++ b/php/src/Api/Tests/Feature/OpenApiDocumentationComprehensiveTest.php
@@ -18,6 +18,10 @@
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\Route as RouteFacade;
+define('TEST_SCAN_ITEMS_ID_PATH', '/test-scan/items/{id}');
+define('API_WILDCARD_INCLUDE', 'api/*');
+define('CUSTOM_TAG_NAME', 'Custom Tag');
+
// ─────────────────────────────────────────────────────────────────────────────
// OpenApiBuilder Schema Generation
// ─────────────────────────────────────────────────────────────────────────────
@@ -105,17 +109,17 @@
->group(function () {
RouteFacade::get('/test-scan/items', [TestOpenApiController::class, 'index'])
->name('test-scan.items.index');
- RouteFacade::get('/test-scan/items/{id}', [TestOpenApiController::class, 'show'])
+ RouteFacade::get(TEST_SCAN_ITEMS_ID_PATH, [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'])
+ RouteFacade::put(TEST_SCAN_ITEMS_ID_PATH, [TestOpenApiController::class, 'update'])
->name('test-scan.items.update');
- RouteFacade::delete('/test-scan/items/{id}', [TestOpenApiController::class, 'destroy'])
+ RouteFacade::delete(TEST_SCAN_ITEMS_ID_PATH, [TestOpenApiController::class, 'destroy'])
->name('test-scan.items.destroy');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
config(['api-docs.routes.exclude' => []]);
});
@@ -161,7 +165,7 @@
RouteFacade::get('/duplicate-id/dup_one', fn () => response()->json([]));
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -480,13 +484,13 @@ enum: ['draft', 'published', 'archived']
});
it('infers resource schema fields from JsonResource payloads', function () {
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
config(['api-docs.routes.exclude' => []]);
RouteFacade::prefix('api')
->middleware('api')
->group(function () {
- RouteFacade::get('/test-scan/items/{id}', [TestOpenApiController::class, 'show']);
+ RouteFacade::get(TEST_SCAN_ITEMS_ID_PATH, [TestOpenApiController::class, 'show']);
});
$builder = new OpenApiBuilder;
@@ -603,7 +607,7 @@ enum: ['draft', 'published', 'archived']
->name('hidden-test.internal');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -625,7 +629,7 @@ enum: ['draft', 'published', 'archived']
->name('partial-hidden.hidden');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -665,13 +669,13 @@ enum: ['draft', 'published', 'archived']
->name('tagged.items.index');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
$operation = $spec['paths']['/api/tagged/items']['get'];
- expect($operation['tags'])->toContain('Custom Tag');
+ expect($operation['tags'])->toContain(CUSTOM_TAG_NAME);
});
it('collects discovered tags in tags section', function () {
@@ -682,13 +686,13 @@ enum: ['draft', 'published', 'archived']
->name('tagged.items.index');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
$tagNames = collect($spec['tags'])->pluck('name')->toArray();
- expect($tagNames)->toContain('Custom Tag');
+ expect($tagNames)->toContain(CUSTOM_TAG_NAME);
});
it('infers tags from route prefixes when not specified', function () {
@@ -699,7 +703,7 @@ enum: ['draft', 'published', 'archived']
->name('bio.links.index');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -891,7 +895,7 @@ enum: ['draft', 'published', 'archived']
->name('sunset-test.legacy');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -913,7 +917,7 @@ enum: ['draft', 'published', 'archived']
->name('sunset-test.plain');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -977,7 +981,7 @@ enum: ['draft', 'published', 'archived']
->name('auth-test.protected');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -1000,7 +1004,7 @@ enum: ['draft', 'published', 'archived']
->name('example.create');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -1018,7 +1022,7 @@ enum: ['draft', 'published', 'archived']
->name('example.update');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -1035,7 +1039,7 @@ enum: ['draft', 'published', 'archived']
->name('example.patch');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -1052,7 +1056,7 @@ enum: ['draft', 'published', 'archived']
->name('example.list');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -1069,7 +1073,7 @@ enum: ['draft', 'published', 'archived']
->name('default-response.index');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -1123,7 +1127,7 @@ enum: ['draft', 'published', 'archived']
->name('internal.excluded');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
config(['api-docs.routes.exclude' => ['api/internal/*']]);
$builder = new OpenApiBuilder;
@@ -1141,7 +1145,7 @@ enum: ['draft', 'published', 'archived']
->name('head-test');
});
- config(['api-docs.routes.include' => ['api/*']]);
+ config(['api-docs.routes.include' => [API_WILDCARD_INCLUDE]]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
@@ -1197,24 +1201,39 @@ 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 {}
+ public function index(): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
#[ApiResponse(200, TestJsonResource::class, 'Item details')]
#[ApiResponse(404, null, 'Item not found')]
- public function show(string $id): void {}
+ public function show(string $_id): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
#[ApiSecurity('apiKey', ['write'])]
#[ApiResponse(201, TestJsonResource::class, 'Item created')]
#[ApiResponse(422, null, 'Validation failed')]
- public function store(): void {}
+ public function store(): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
#[ApiSecurity('apiKey', ['write'])]
#[ApiResponse(200, TestJsonResource::class, 'Item updated')]
- public function update(string $id): void {}
+ public function update(string $_id): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
#[ApiSecurity('apiKey', ['delete'])]
#[ApiResponse(204, null, 'Item deleted')]
- public function destroy(string $id): void {}
+ public function destroy(string $_id): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
}
/**
@@ -1223,7 +1242,10 @@ public function destroy(string $id): void {}
#[ApiHidden('Internal use only')]
class TestHiddenController
{
- public function index(): void {}
+ public function index(): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
}
/**
@@ -1231,7 +1253,10 @@ public function index(): void {}
*/
class TestPublicController
{
- public function index(): void {}
+ public function index(): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
}
/**
@@ -1239,10 +1264,16 @@ public function index(): void {}
*/
class TestPartialHiddenController
{
- public function publicMethod(): void {}
+ public function publicMethod(): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
#[ApiHidden]
- public function hiddenMethod(): void {}
+ public function hiddenMethod(): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
}
/**
@@ -1252,16 +1283,22 @@ class TestExplicitPathParameterController
{
#[ApiParameter('id', 'path', 'string', 'Explicit item identifier')]
#[ApiResponse(200, TestJsonResource::class, 'Item details')]
- public function show(string $id): void {}
+ public function show(string $_id): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
}
/**
* Test tagged controller.
*/
-#[ApiTag('Custom Tag', 'Custom tag description')]
+#[ApiTag(CUSTOM_TAG_NAME, 'Custom tag description')]
class TestTaggedController
{
- public function index(): void {}
+ public function index(): void
+ {
+ // Empty — test fixture; behaviour is expressed via attributes only
+ }
}
/**
diff --git a/php/src/Api/Tests/Feature/PixelEndpointTest.php b/php/src/Api/Tests/Feature/PixelEndpointTest.php
index 1e58d75..f2e723f 100644
--- a/php/src/Api/Tests/Feature/PixelEndpointTest.php
+++ b/php/src/Api/Tests/Feature/PixelEndpointTest.php
@@ -4,6 +4,9 @@
use Illuminate\Support\Facades\Cache;
+define('PIXEL_ENDPOINT', '/api/pixel/abc12345');
+define('EXAMPLE_COM', 'https://example.com');
+
beforeEach(function () {
Cache::flush();
});
@@ -13,13 +16,13 @@
});
it('returns a transparent gif for get requests', function () {
- $response = $this->get('/api/pixel/abc12345', [
- 'Origin' => 'https://example.com',
+ $response = $this->get(PIXEL_ENDPOINT, [
+ 'Origin' => EXAMPLE_COM,
]);
$response->assertOk();
$response->assertHeader('Content-Type', 'image/gif');
- $response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
+ $response->assertHeader('Access-Control-Allow-Origin', EXAMPLE_COM);
$response->assertHeader('X-RateLimit-Limit', '10000');
$response->assertHeader('X-RateLimit-Remaining', '9999');
@@ -27,22 +30,22 @@
});
it('accepts post tracking requests without a body', function () {
- $response = $this->post('/api/pixel/abc12345', [], [
- 'Origin' => 'https://example.com',
+ $response = $this->post(PIXEL_ENDPOINT, [], [
+ 'Origin' => EXAMPLE_COM,
]);
$response->assertNoContent();
- $response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
+ $response->assertHeader('Access-Control-Allow-Origin', EXAMPLE_COM);
$response->assertHeader('X-RateLimit-Limit', '10000');
$response->assertHeader('X-RateLimit-Remaining', '9999');
});
it('handles preflight requests for public pixel tracking', function () {
- $response = $this->call('OPTIONS', '/api/pixel/abc12345', [], [], [], [
- 'HTTP_ORIGIN' => 'https://example.com',
+ $response = $this->call('OPTIONS', PIXEL_ENDPOINT, [], [], [], [
+ 'HTTP_ORIGIN' => EXAMPLE_COM,
]);
$response->assertNoContent();
- $response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
+ $response->assertHeader('Access-Control-Allow-Origin', EXAMPLE_COM);
$response->assertHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
});
diff --git a/php/src/Api/Tests/Feature/PublicApiCorsTest.php b/php/src/Api/Tests/Feature/PublicApiCorsTest.php
index 3038d3c..0444e94 100644
--- a/php/src/Api/Tests/Feature/PublicApiCorsTest.php
+++ b/php/src/Api/Tests/Feature/PublicApiCorsTest.php
@@ -6,6 +6,8 @@
use Illuminate\Http\Request;
use Illuminate\Http\Response;
+define('EXAMPLE_COM_CORS', 'https://example.com');
+
// ─────────────────────────────────────────────────────────────────────────────
// OPTIONS Preflight Requests
// ─────────────────────────────────────────────────────────────────────────────
@@ -45,7 +47,7 @@
});
it('includes all required CORS headers on OPTIONS response', function () {
- $request = createCorsRequest('OPTIONS', ['Origin' => 'https://example.com']);
+ $request = createCorsRequest('OPTIONS', ['Origin' => EXAMPLE_COM_CORS]);
$response = $this->middleware->handle($request, fn () => new Response(''));
@@ -66,7 +68,7 @@
});
it('adds CORS headers to GET response', function () {
- $request = createCorsRequest('GET', ['Origin' => 'https://example.com']);
+ $request = createCorsRequest('GET', ['Origin' => EXAMPLE_COM_CORS]);
$response = $this->middleware->handle($request, fn () => new Response('OK'));
@@ -75,7 +77,7 @@
});
it('adds CORS headers to POST response', function () {
- $request = createCorsRequest('POST', ['Origin' => 'https://example.com']);
+ $request = createCorsRequest('POST', ['Origin' => EXAMPLE_COM_CORS]);
$response = $this->middleware->handle($request, fn () => new Response('Created', 201));
@@ -322,7 +324,7 @@
});
it('does not set Access-Control-Allow-Credentials on regular requests', function () {
- $request = createCorsRequest('GET', ['Origin' => 'https://example.com']);
+ $request = createCorsRequest('GET', ['Origin' => EXAMPLE_COM_CORS]);
$response = $this->middleware->handle($request, fn () => new Response('OK'));
@@ -330,7 +332,7 @@
});
it('does not set Access-Control-Allow-Credentials on OPTIONS preflight', function () {
- $request = createCorsRequest('OPTIONS', ['Origin' => 'https://example.com']);
+ $request = createCorsRequest('OPTIONS', ['Origin' => EXAMPLE_COM_CORS]);
$response = $this->middleware->handle($request, fn () => new Response(''));
diff --git a/php/src/Api/Tests/Feature/RateLimitTest.php b/php/src/Api/Tests/Feature/RateLimitTest.php
index 854b1a1..54df2ce 100644
--- a/php/src/Api/Tests/Feature/RateLimitTest.php
+++ b/php/src/Api/Tests/Feature/RateLimitTest.php
@@ -256,6 +256,7 @@ public function get(): bool
public function release(): void
{
+ // Stub — intentionally empty; lock is never acquired in this test
}
};
}
@@ -603,7 +604,7 @@ public function test_config_has_tier_based_limits(): void
$this->assertArrayHasKey('agency', $tiers);
$this->assertArrayHasKey('enterprise', $tiers);
- foreach ($tiers as $tier => $tierConfig) {
+ foreach ($tiers as $_tier => $tierConfig) {
$this->assertArrayHasKey('limit', $tierConfig);
$this->assertArrayHasKey('window', $tierConfig);
$this->assertArrayHasKey('burst', $tierConfig);
diff --git a/php/src/Api/Tests/Feature/RateLimitingTest.php b/php/src/Api/Tests/Feature/RateLimitingTest.php
index 1fc807e..1b31f43 100644
--- a/php/src/Api/Tests/Feature/RateLimitingTest.php
+++ b/php/src/Api/Tests/Feature/RateLimitingTest.php
@@ -15,6 +15,8 @@
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
+define('IP_LOCALHOST', '127.0.0.1');
+
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
@@ -357,7 +359,7 @@ public function __construct(int $id, string $tier)
$workspace2 = Workspace::factory()->create();
$apiKey1 = createApiKeyForWorkspace($workspace1);
- $apiKey2 = createApiKeyForWorkspace($workspace2);
+ createApiKeyForWorkspace($workspace2);
// Use same API key ID to test shared limit
$request1 = createMockRequest([
@@ -643,7 +645,7 @@ public function __construct(int $id, string $tier)
$this->middleware->handle($request, fn () => new Response('OK'));
- $cacheKey = 'rate_limit:ip:127.0.0.1:route:test.route';
+ $cacheKey = 'rate_limit:ip:'.IP_LOCALHOST.':route:test.route';
expect(Cache::has($cacheKey))->toBeTrue();
});
@@ -703,8 +705,8 @@ public function __construct(int $id, string $tier)
'burst' => 1.0,
]);
- $request1 = createMockRequest([], '127.0.0.1', 'api.users.index');
- $request2 = createMockRequest([], '127.0.0.1', 'api.posts.index');
+ $request1 = createMockRequest([], IP_LOCALHOST, 'api.users.index');
+ $request2 = createMockRequest([], IP_LOCALHOST, 'api.posts.index');
// Exhaust rate limit for endpoint 1
for ($i = 0; $i < 5; $i++) {
@@ -763,7 +765,7 @@ public function __construct(int $id, string $tier)
// Helper Functions
// -----------------------------------------------------------------------------
-function createMockRequest(array $attributes = [], string $ip = '127.0.0.1', string $routeName = 'test.route'): Request
+function createMockRequest(array $attributes = [], string $ip = IP_LOCALHOST, string $routeName = 'test.route'): Request
{
$request = Request::create('/api/test', 'GET');
$request->server->set('REMOTE_ADDR', $ip);
diff --git a/php/src/Api/Tests/Feature/SeoReportServiceTest.php b/php/src/Api/Tests/Feature/SeoReportServiceTest.php
index 26166dd..43388b4 100644
--- a/php/src/Api/Tests/Feature/SeoReportServiceTest.php
+++ b/php/src/Api/Tests/Feature/SeoReportServiceTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
namespace Core\Api\Services {
+ // Override built-in for test isolation
function dns_get_record(string $hostname, int $type = DNS_A | DNS_AAAA, mixed ...$args): array|false
{
if ($hostname === 'seo-pinned.example.test') {
@@ -28,6 +29,11 @@ function dns_get_record(string $hostname, int $type = DNS_A | DNS_AAAA, mixed ..
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
+define('SEO_TEST_URL', 'https://1.1.1.1/article');
+define('SEO_CONTENT_TYPE', 'text/html; charset=utf-8');
+define('SEO_PAGE_TITLE', 'Example Product Landing Page');
+define('SEO_PAGE_DESC', 'A concise example description for the landing page.');
+
function seoReportService(): SeoReportService
{
return app(SeoReportService::class);
@@ -42,7 +48,7 @@ function seoPendingRequestOptions(PendingRequest $request): array
it('SeoReportService_analyse_Good_extracts_technical_signals', function () {
Http::fake(function ($request) {
- expect($request->url())->toBe('https://1.1.1.1/article');
+ expect($request->url())->toBe(SEO_TEST_URL);
expect($request->method())->toBe('GET');
expect($request->header('User-Agent')[0])->toContain('SEO Reporter/1.0');
expect($request->header('Accept')[0])->toBe('text/html,application/xhtml+xml');
@@ -72,36 +78,36 @@ function seoPendingRequestOptions(PendingRequest $request): array