Compare commits

...

262 commits
v0.2.0 ... dev

Author SHA1 Message Date
Snider
8796c405c2 feat(api): framework routes — GET /v1/tools + /v1/openapi.json + chat path items
Implement the RFC framework routes listed in RFC.endpoints.md that were
missing from the Go engine:

- GET {basePath}/ on ToolBridge — returns the registered tool catalogue
  (RFC.endpoints.md — "GET /v1/tools List available tools"). The listing
  uses the standard OK envelope so clients can enumerate tools without
  reading the OpenAPI document.
- WithOpenAPISpec / WithOpenAPISpecPath options + GET /v1/openapi.json
  default mount (RFC.endpoints.md — "GET /v1/openapi.json Generated
  OpenAPI spec"). The spec is generated once and served application/json
  so SDK generators can fetch it without loading the Swagger UI bundle.
- OpenAPI path items for /v1/chat/completions and /v1/openapi.json so
  SDK generators can bind to them directly instead of relying solely on
  the x-chat-completions-path / x-openapi-spec-path vendor extensions.

Side effects:

- TransportConfig surfaces the new OpenAPISpecEnabled/OpenAPISpecPath
  fields so callers can discover the endpoint without rebuilding the
  engine.
- SpecBuilder gains OpenAPISpecEnabled / OpenAPISpecPath fields and
  emits the matching x-openapi-spec-* extensions.
- core api spec CLI accepts --openapi-spec, --openapi-spec-path,
  --chat-completions, --chat-completions-path flags so generated specs
  describe the endpoints ahead of runtime activation.
- ToolBridge.Describe / DescribeIter now emit the GET listing as the
  first RouteDescription; existing tests were updated to match.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 17:51:16 +01:00
Snider
fb498f0b88 feat(api): canonical webhook events + chat completions transport discovery
Implements gaps between RFC.md spec and code:

- Export canonical webhook event identifiers (RFC §6) as Go constants:
  WebhookEventWorkspaceCreated, WebhookEventLinkClicked, etc. Plus
  WebhookEvents() and IsKnownWebhookEvent(name) helpers for SDK consumers
  and middleware validation.

- Surface the chat completions endpoint (RFC §11.1) through TransportConfig
  (ChatCompletionsEnabled + ChatCompletionsPath) and the OpenAPI spec
  extensions (x-chat-completions-enabled, x-chat-completions-path) so
  clients can auto-discover the local OpenAI-compatible endpoint.

- Add internal test coverage for chat completions sampling defaults
  (Gemma 4 calibrated temp=1.0, top_p=0.95, top_k=64, max_tokens=2048)
  and the ThinkingExtractor channel routing (RFC §11.6).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 15:02:18 +01:00
Snider
da1839f730 feat(api): webhooks + sunset headers + WithWebSocket + cmd/api migration
- webhook.go: HMAC-SHA256 WebhookSigner matching PHP WebhookSignature —
  sign/verify, X-Webhook-Signature / X-Webhook-Timestamp headers,
  VerifyRequest middleware helper, 5-minute default tolerance,
  secret generator (RFC §6)
- sunset.go: ApiSunsetWith(date, replacement, opts...) + WithSunsetNoticeURL;
  ApiSunset now emits API-Suggested-Replacement when replacement set;
  RouteDescription.NoticeURL surfaces API-Deprecation-Notice-URL (RFC §8)
- options.go + api.go + transport.go: WithWebSocket(gin.HandlerFunc)
  alongside existing WithWSHandler(http.Handler); gin form wins when
  both supplied (RFC §2.2)
- openapi.go: apiSuggestedReplacement + apiDeprecationNoticeURL as
  reusable header components; NoticeURL on a RouteDescription flips
  operation deprecated flag and emits response header doc
- cmd/api/*.go: migrated from Cobra (cli.NewCommand, StringFlag) to
  new path-based CLI API (c.Command + core.Options.String/Int/Bool);
  replaces the 1,422-line Cobra test suite with _Good/_Bad/_Ugly
  triads on the new surface
- webhook_test.go + sunset_test.go + websocket_test.go: full coverage

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 14:51:04 +01:00
Snider
fbb58486c4 feat(api): WithChatCompletions option + bug fixes in chat_completions
- options.go: new WithChatCompletions(resolver) and
  WithChatCompletionsPath(path); api.New(...) now auto-mounts at
  /v1/chat/completions when a resolver is configured (previously the
  resolver could be attached but never mounted, which would have
  panicked Gin)
- chat_completions.go: fixed missing net/http import, dropped
  ModelType during discovery, Retry-After header set after c.JSON
  silently lost, swapped OpenAI error type/code fields, swapped
  validate call site, redundant nil check, builder length read before
  nil-receiver check
- openapi.go: effective*Path helpers surface an explicit path even
  when the corresponding Enabled flag is false so CLI callers still
  get x-*-path extensions; /swagger always in authentik public paths
- chat_completions_test.go: Good/Bad/Ugly coverage for new options,
  validation, no-resolver behaviour
- openapi_test.go: fix stale assertion for CacheEnabled-gated X-Cache
- go.mod: bump dappco.re/go/core/cli to v0.5.2
- Removed local go-io / go-log stubs — replace points to outer
  modules for single source of truth
- Migrated forge.lthn.ai/core/cli imports to dappco.re/go/core/cli
  across cmd/api/*.go + docs

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 14:34:51 +01:00
Snider
996b5a801a feat(api): chat completions endpoint per RFC §11
- chat_completions.go: ChatCompletionRequest/Response/Chunk types,
  POST /v1/chat/completions handler with SSE streaming, ModelResolver,
  ThinkingExtractor, calibrated defaults, OpenAI-compatible error shape
- api.go: wires the chat endpoint into the gateway

From codex spark-medium pass, 851 lines.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 14:00:15 +01:00
Snider
d90a5be936 refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:00 +01:00
Snider
7bcb6d469c fix(pr#2): address CodeRabbit round 4 — newCacheStore fail-closed for unbounded cache
Prevent silent unbounded cache creation when both maxEntries and maxBytes
are non-positive: newCacheStore now returns nil, WithCacheLimits skips
middleware registration, and WithCache defaults to 1 000-entry LRU cap
when called with only a TTL argument.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-07 11:32:28 +01:00
Snider
e27006de30 fix(pr#2): address CodeRabbit round 3 review findings
- cache: fix LRU race — check expiry before MoveToFront within same lock
  critical section; previously a stale entry could be promoted before
  expiry was verified
- cache: drop stale ETag/Content-Md5/Digest headers when body is
  rewritten by refreshCachedResponseMeta to avoid misrepresenting the
  new body to clients
- ratelimit: cap buckets map at 100k entries with stale-eviction fallback
  and shared overflow bucket to prevent unbounded memory growth under
  high-cardinality traffic
- ratelimit: fix clientRateLimitKey comment — credentials are tried
  second (before IP), not as a "last resort"

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-07 09:29:03 +01:00
Snider
372326297e fix(pr#2): address CodeRabbit round 2 review findings
Go:
- cache: fix TOCTOU race in get() — re-verify entry pointer under lock before
  evicting to prevent corrupting s.currentBytes and removing a newly-set entry
- bridge: fix writeErrorResponse recorder out of sync — buffer into w.body/
  w.headers and call commit() so Status(), Header(), Size() reflect error response
- bridge: fix ValidateResponse number precision — use json.Decoder+UseNumber for
  initial envelope decode to preserve large integers (matches Validate path)
- ratelimit: fix unreachable credential branches — move X-API-Key and
  Authorization hashing before IP fallback so NAT'd clients are bucketed by key
- openapi: gate cacheSuccessHeaders on sb.CacheEnabled flag, not just method==get
- openapi: use isNilRouteGroup in prepareRouteGroups to catch typed-nil RouteGroup

PHP:
- RateLimitExceededException: remove ad-hoc CORS handling — let framework CORS
  middleware apply correct headers for all responses including errors
- SeoReportService.extractCharset: parse charset token from Content-Type value
  instead of returning the full "text/html; charset=utf-8" string
- SeoReportService: validate IP literals directly with filter_var before DNS
  lookup so ::ffff:127.0.0.1-style hosts don't bypass private-IP checks
- SeoReportService.isPrivateIp: extract isPrivateIpv4 helper; handle
  IPv4-mapped IPv6 (::ffff::/96) by checking embedded IPv4 against private
  ranges; add 0.0.0.0/8 to private range list

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-07 09:11:05 +01:00
Snider
e54dd2e370 fix(pr#2): address CodeRabbit major/critical review findings
Go:
- codegen: pass trimmed specPath to buildArgs instead of raw g.SpecPath
- cmd/sdk: use local resolvedSpecFile to avoid mutating flag variable per-invocation
- export: write to temp file + atomic rename to prevent destination truncation on failure
- openapi: gate effectiveGraphQLPath/SwaggerPath/WSPath/SSEPath on enable flags; use effectiveSwaggerPath in effectiveAuthentikPublicPaths
- cache: reject oversized replacement before mutating LRU state for existing keys
- ratelimit: move setRateLimitHeaders before c.Next() so headers are sent; hash credential headers with SHA-256 to avoid storing raw secrets; prefer validated principal from context
- response_meta: track size separately from body buffer so Size() is accurate after body rewrites and in passthrough mode
- bridge: limit request body reads with http.MaxBytesReader (10 MiB); allow missing data key in ValidateResponse for nil/zero success responses; update recorder status in writeErrorResponse
- pkg/provider/proxy: validate target scheme and host after url.Parse to catch hostless inputs
- cmd_test: snapshot/restore global spec registry in TestAPISpecCmd_Good_RegisteredSpecGroups

PHP:
- HasApiResponses.php, config.php: add declare(strict_types=1)
- RateLimitExceededException: validate Origin against cors.allowed_origins before reflecting in CORS header
- ApiUsageService: import and use Core\Api\Models\ApiKey instead of fully-qualified Mod\ path
- SeoReportService: add SSRF protection (scheme check, private-IP rejection); add .throw() for HTTP error handling; disable automatic redirects

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-07 08:38:41 +01:00
Snider
194e7f61df fix: migrate module paths from forge.lthn.ai to dappco.re
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:11 +01:00
Virgil
aea902ed28 fix(cmd/api): forward graphql playground path to sdk specs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 05:02:47 +00:00
Virgil
8dd15251ea fix(api): omit disabled graphql playground spec metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 04:56:48 +00:00
Virgil
a3a1c20e7a fix(api): support custom GraphQL playground paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 04:53:30 +00:00
Virgil
3896896090 fix(api): correct OpenAPI iterator examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 04:50:54 +00:00
Virgil
0ec5f20bf5 fix(api): add AX examples to client snapshots 2026-04-03 04:45:03 +00:00
Virgil
8b5e572d1c fix(api): expose OpenAPI client snapshots
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 04:42:14 +00:00
Virgil
76acb4534b fix(api): surface GraphQL playground metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-03 04:38:22 +00:00
Virgil
1491e16f9e fix(api): normalise runtime metadata snapshots
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 16:48:37 +00:00
Virgil
0022931eff fix(openapi): normalise spec builder metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:48:26 +00:00
Virgil
2b71c78c33 fix(openapi): ignore non-positive cache ttl in spec
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:42:01 +00:00
Virgil
be43aa3d72 fix(openapi): deep clone route metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:35:59 +00:00
Virgil
2d09cc5d28 fix(api): add tracing AX examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:29:59 +00:00
Virgil
5971951c87 fix(cmd/api): trim spec metadata inputs 2026-04-02 14:25:59 +00:00
Virgil
d7290c55ec fix(cmd/api): align cache metadata with runtime
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:22:32 +00:00
Virgil
8301d4d1c7 fix(cmd/api): ignore non-positive cache ttl in spec
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:18:31 +00:00
Virgil
579b27d84e refactor(openapi): precompute authentik public paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:09:35 +00:00
Virgil
eb771875e2 fix(openapi): document authentik public paths as public
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:03:16 +00:00
Virgil
0dc9695b91 feat(api): include graphql in runtime snapshots 2026-04-02 13:58:56 +00:00
Virgil
0a299b79c1 fix(api): normalise empty Authentik public paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:55:17 +00:00
Virgil
a6693e1656 feat(api): surface effective Authentik public paths in specs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:51:54 +00:00
Virgil
a07896d88e fix(cmd/api): normalise authentik spec public paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:46:45 +00:00
Virgil
bfef7237cc fix(api): harden SDK generator inputs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:43:07 +00:00
Virgil
f6add24177 fix(api): normalise authentik public paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:38:56 +00:00
Virgil
f234fcba5f feat(api): surface authentik metadata in specs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:25:23 +00:00
Virgil
eb18611dc1 feat(api): snapshot authentik runtime config
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:17:08 +00:00
Virgil
0171f9ad49 refactor(api): assert swagger spec interface
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:12:08 +00:00
Virgil
ec268c8100 feat(api): default enabled transport paths in specs
Treat enabled built-in transports as having their default paths when callers omit an explicit override. This keeps manual SpecBuilder usage aligned with the engine defaults and prevents Swagger, GraphQL, WebSocket, and SSE metadata from disappearing from generated documents.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:07:05 +00:00
Virgil
ef51d9b1c3 refactor(cmd/api): centralize spec flag binding
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:01:45 +00:00
Virgil
ede71e2b1f feat(cmd/api): infer spec transport enablement from flags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:56:59 +00:00
Virgil
655faa1c31 refactor(api): add runtime config snapshot
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:52:06 +00:00
Virgil
3c2f5512a8 feat(api): add GraphQL config snapshot
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:47:42 +00:00
Virgil
814c1b6233 feat(cmd/api): expose cache and i18n spec flags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:43:06 +00:00
Virgil
5c067b3dae refactor(api): normalise config snapshots
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:38:54 +00:00
Virgil
f919e8a3be feat(api): expose cache and i18n OpenAPI metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:29:40 +00:00
Virgil
5de64a0a75 feat(api): add i18n config snapshot
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 12:25:38 +00:00
Virgil
f760ab6c72 feat(api): expose cache config snapshot
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 09:23:02 +00:00
Virgil
592cdd302e fix(api): fail fast on sdk generator availability
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 09:19:19 +00:00
Virgil
c4743a527e refactor(cmd/api): fail fast on sdk generator availability
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 09:15:03 +00:00
Virgil
78d16a75cc docs(api): add AX example for SDK availability check
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 09:12:00 +00:00
Virgil
71c179018d refactor(api): snapshot route metadata during spec build
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 09:08:16 +00:00
Virgil
c383d85923 refactor(cmd/api): centralize spec builder config
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 09:04:30 +00:00
Virgil
4fc93612e4 feat(api): make spec builder nil-safe 2026-04-02 09:01:04 +00:00
Virgil
57ff0d2a48 feat(api): expose swagger and graphql spec flags 2026-04-02 08:57:41 +00:00
Virgil
d40ff2c294 fix(api): remove global openapi bearer security
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:54:24 +00:00
Virgil
192f8331f2 feat(api): expose websocket and sse transport flags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:48:28 +00:00
Virgil
83d12d6024 feat(api): expose swagger enabled transport flag
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:45:20 +00:00
Virgil
51b176c1cf refactor(api): expose GraphQL transport snapshot flag
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:40:28 +00:00
Virgil
4725b39049 docs(api): align cache docs with explicit limits
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:36:59 +00:00
Virgil
5e4cf1fde8 refactor(api): clarify cache limits api
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:34:09 +00:00
Virgil
172a98f73a fix(api): validate path parameter schemas
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:30:44 +00:00
Virgil
152645489b feat(api): validate openapi parameter values
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:27:25 +00:00
Virgil
2fb2c6939f feat(api): expose swagger path in config snapshot
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:23:55 +00:00
Virgil
d06f4957a3 feat(api): expose swagger config snapshot
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:20:13 +00:00
Virgil
d225fd3178 feat(api): add openapi info summary support
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:16:56 +00:00
Virgil
be7616d437 fix(cmd/api): normalise spec export formats
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:12:47 +00:00
Virgil
e6f2d1286b refactor(cmd/api): centralise spec builder config
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:08:56 +00:00
Virgil
8d92ee29d4 docs(cmd/api): add AX usage example to AddAPICommands
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 08:03:24 +00:00
Virgil
8149b0abf2 refactor(api): centralise spec group iterator
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:57:58 +00:00
Virgil
ed5822058d refactor(api): streamline spec export paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:54:27 +00:00
Virgil
ec945970ee docs(api): add AX usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:51:21 +00:00
Virgil
08cb1385d3 fix(api): redirect swagger base path
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:44:55 +00:00
Virgil
bbee19204f refactor(export): reduce spec file writer duplication
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:41:14 +00:00
Virgil
87a973a83e feat(cmd/api): add SDK security scheme parity
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:31:45 +00:00
Virgil
bc6a9ea0a7 feat(cmd): expose spec security schemes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:26:36 +00:00
Virgil
22d600e7a7 fix(api): normalise version config values 2026-04-02 07:22:16 +00:00
Virgil
b0549dc14e fix(api): deep-clone swagger security schemes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:19:32 +00:00
Virgil
69dd16cba6 feat(api): expose reusable OpenAPI response components
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:10:55 +00:00
Virgil
fb7702df67 feat(api): expose swagger security schemes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:06:09 +00:00
Virgil
8e1a424fc8 feat(api): merge custom OpenAPI security schemes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 07:01:41 +00:00
Virgil
920c227e21 chore(api): validate OpenAPI implementation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:56:38 +00:00
Virgil
b99e445436 feat(openapi): document debug endpoint rate-limit headers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:52:40 +00:00
Virgil
30e610686b refactor(cmd/api): remove redundant sdk spec slice helper
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:47:59 +00:00
Virgil
8a23545a67 feat(api): expose transport config snapshot
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:38:05 +00:00
Virgil
3b75dc1701 fix(openapi): preserve example-only response schemas
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:33:24 +00:00
Virgil
dd834211d8 fix(auth): exempt swagger ui path in authentik middleware
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:29:34 +00:00
Virgil
b8fd020bb2 refactor(cmd/api): thread swagger path through sdk spec builder
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:26:01 +00:00
Virgil
428552e58c feat(api): add iterator-based spec group registration
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:22:29 +00:00
Virgil
824fc2cd75 refactor(export): simplify spec writer handling
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:19:23 +00:00
Virgil
fe256147e6 feat(openapi): export configured transport paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:13:15 +00:00
Virgil
9b24a46fd5 feat(openapi): export runtime transport metadata
Expose GraphQL, WebSocket, SSE, and debug endpoint metadata alongside the existing Swagger UI path in generated OpenAPI documents.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:08:44 +00:00
Virgil
29f4c23977 fix(api): preserve streaming response passthrough
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 06:04:06 +00:00
Virgil
d7ef3610f7 fix(response): attach meta to all json responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:38:34 +00:00
Virgil
76aa4c9974 fix(openapi): preserve explicit swagger path metadata
Carry a configured Swagger UI path into OpenAPISpecBuilder even when the UI option itself is not enabled yet, and add a regression test for the path-only case.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:34:04 +00:00
Virgil
5d28b8d83d feat(openapi): export default swagger path metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:30:12 +00:00
Virgil
a469a78b2a feat(openapi): document GraphQL GET queries
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:25:25 +00:00
Virgil
e47b010194 feat(api): add configurable websocket path
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:21:28 +00:00
Virgil
d9ccd7c49a feat(openapi): export swagger ui path metadata
Preserve the Swagger UI mount path in generated OpenAPI output and expose it through the spec and sdk CLI builders.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:16:08 +00:00
Virgil
c3143a5029 feat(openapi): declare json schema dialect
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:09:28 +00:00
Virgil
e8d54797bf feat(openapi): include graphql tag for playground default path
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:04:55 +00:00
Virgil
85d6f6dd6e feat(openapi): default graphql path for playground specs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 03:00:51 +00:00
Virgil
f53617c507 feat(openapi): document sunsetted operations as gone
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:56:13 +00:00
Virgil
13f901b88f fix(api-docs): describe 410 gone responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:51:23 +00:00
Virgil
6ea0b26a13 feat(api-docs): document binary pixel responses 2026-04-02 02:47:02 +00:00
Virgil
8e28b0209c feat(cmd/api): add GraphQL playground spec flag
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:42:42 +00:00
Virgil
f0b2d8b248 feat(cmd/api): expose runtime spec metadata flags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:39:02 +00:00
Virgil
006a065ea0 feat(openapi): document WebSocket endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:33:31 +00:00
Virgil
273bc3d70a feat(openapi): document GraphQL playground endpoint
Adds GraphQL Playground coverage to the generated OpenAPI spec when the GraphQL playground option is enabled, and wires the engine metadata through so runtime docs match the mounted route.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:29:20 +00:00
Virgil
41615bbe47 feat(api): document debug endpoints in OpenAPI spec
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:24:27 +00:00
Virgil
d803ac8f3b feat(api): add engine OpenAPI spec builder
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:19:43 +00:00
Virgil
1fb55c9515 fix(cmd/api): use CLI context for SDK generation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:13:17 +00:00
Virgil
ef641c7547 feat(api): add configurable Swagger path
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:06:45 +00:00
Virgil
39bf094b51 feat(api): add configurable SSE path
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 02:01:43 +00:00
Virgil
b4d414b702 feat(cmd/api): add SSE path spec flags
Wire "--sse-path" through the spec and SDK generators so standalone OpenAPI output can document the SSE endpoint alongside GraphQL.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:55:13 +00:00
Virgil
085c57a06d feat(cache): add byte-bounded eviction
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:49:36 +00:00
Virgil
86c2150a21 feat(openapi): document SSE endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:44:49 +00:00
Virgil
02082db8f4 fix(openapi): document graphql cache headers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:40:05 +00:00
Virgil
50b6a9197f refactor(openapi): remove unused route-group prep state
Simplifies route-group preparation by dropping dead metadata that was no longer used by the OpenAPI builder.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:34:14 +00:00
Virgil
08a2d93776 fix(openapi): fall back to Describe for nil iterators
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 01:27:40 +00:00
Virgil
6b075a207b feat(provider): add registry subset iterators
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:52:21 +00:00
Virgil
e23d8e9780 feat(openapi): sort generated tags deterministically
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:47:51 +00:00
Virgil
c21c3409d7 feat(api): harden version header parsing
Handle Accept-Version parameters and comma-separated Accept values when extracting API versions.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:43:14 +00:00
Virgil
812400f303 feat(openapi): keep empty describable group tags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:39:04 +00:00
Virgil
0bb07f43f0 feat(openapi): hide undocumented routes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:35:17 +00:00
Virgil
68f5abefd0 fix(api): trim tool bridge tags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:31:38 +00:00
Virgil
ffbb6d83d0 fix(api): snapshot swagger groups
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:24:54 +00:00
Virgil
2c87fa02cb feat(cmd/api): add GraphQL path to spec generation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:21:20 +00:00
Virgil
68bf8dcaf8 feat(openapi): document GraphQL endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:17:26 +00:00
Virgil
9b5477c051 fix(api): ignore blank swagger metadata overrides
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:12:13 +00:00
Virgil
ad751fc974 fix(api): preserve multi-value cached headers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:07:01 +00:00
Virgil
68edd770d8 docs(api): add ax usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:03:10 +00:00
Virgil
f67e3fe5de feat(api): validate required openapi parameters
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 00:00:39 +00:00
Virgil
1f43f019b1 feat(api): allow openapi specs from readers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:56:26 +00:00
Virgil
799de22d4d fix(api): preserve sunset middleware headers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:51:05 +00:00
Virgil
159f8d3b9f feat(api-docs): document versioned response headers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:46:29 +00:00
Virgil
93bef3ed85 fix(api): ignore nil route groups
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:40:14 +00:00
Virgil
0f20eaa7b8 fix(api): preserve sunset response headers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:33:52 +00:00
Virgil
9449c195c3 fix(api): preserve existing link headers in sunset middleware
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:29:12 +00:00
Virgil
0984c2f48a docs(api): add AX usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:25:54 +00:00
Virgil
47e8c8a795 feat(openapi): document route headers on all responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:21:56 +00:00
Virgil
14eedd7f91 feat(cmd/api): dedupe sdk spec groups 2026-04-01 23:18:19 +00:00
Virgil
eb7e1e51cb feat(openapi): reuse deprecation header components 2026-04-01 23:12:40 +00:00
Virgil
06f2263b73 fix(api): disable cache middleware for non-positive ttl
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:09:12 +00:00
Virgil
29324b0a0b feat(api): add sunset deprecation middleware
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 23:02:52 +00:00
Virgil
b64c8d3271 docs(api): add AX usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:55:39 +00:00
Virgil
dd74a80b1e fix(api): infer JsonResource schemas in docs 2026-04-01 22:50:21 +00:00
Virgil
cebad9b77b feat(api): honour header toggles for versioning
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:45:03 +00:00
Virgil
ccfbe57faf feat(openapi): document response headers 2026-04-01 22:42:13 +00:00
Virgil
00c20ea6e8 refactor(api): streamline ToolBridge iterator snapshots
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:38:56 +00:00
Virgil
9553808595 feat(api): add counts to MCP server detail
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:34:34 +00:00
Virgil
929b6b97ca fix(api-docs): deduplicate explicit OpenAPI parameters
Explicit ApiParameter metadata now replaces matching auto-generated path parameters instead of producing duplicates, matching the precedence used by the Go OpenAPI builder.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:32:23 +00:00
Virgil
a89a70851f fix(api): deduplicate spec iterator groups
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:28:34 +00:00
Virgil
ec7391cb06 feat(api): add iterator-backed spec export
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:25:07 +00:00
Virgil
f0d25392a8 feat(provider): add iterator for provider info summaries
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:19:56 +00:00
Virgil
1a8fafeec5 feat(api): enrich MCP server details on demand
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:09:22 +00:00
Virgil
2cfa970993 fix(api-docs): align sunset docs with middleware args
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:06:46 +00:00
Virgil
2bdcb55980 feat(api): add ApiSunset middleware
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 22:02:44 +00:00
Virgil
cba25cf9fc feat(api-docs): document sunset middleware in OpenAPI
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:59:01 +00:00
Virgil
93cdb62dfe feat(api): allow deprecation without sunset date
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:56:10 +00:00
Virgil
691ef936d4 feat(api): allow versioned route sunset replacements 2026-04-01 21:50:30 +00:00
Virgil
b2116cc896 feat(openapi): omit auth errors on public routes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:46:06 +00:00
Virgil
2fd17a432c fix(provider): handle invalid proxy upstreams safely
Avoid panicking when a ProxyProvider is constructed with a malformed upstream URL. The provider now records the configuration error and returns a standard 500 envelope when mounted.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:42:04 +00:00
Virgil
475027d716 refactor(api): wrap ToolBridge errors
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:36:58 +00:00
Virgil
2d1ed133f2 refactor(api): align OpenAPI client with AX principles
Use core-style error wrapping in the OpenAPI client, replace direct spec reads with streamed file I/O, and add compile-time interface assertions for ToolBridge.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:32:21 +00:00
Virgil
867221cbb8 fix(api): snapshot tool bridge iterators
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:27:19 +00:00
Virgil
e0bdca7889 fix(api): snapshot engine iterator views
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:23:19 +00:00
Virgil
4ce697189a fix(client): promote declared query params on all methods
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:19:45 +00:00
Virgil
db9daadbce fix(api): return engine groups by copy
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:16:10 +00:00
Virgil
f62933f570 feat(openapi): document example-only request bodies
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:12:14 +00:00
Virgil
b0adb53dec fix(openapi): snapshot describable groups once
Prepare route descriptions once per group before building paths and tags so iterator-backed DescribeIter implementations are consumed safely and deterministically.

Adds a regression test covering a one-shot iterator-backed group.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:07:04 +00:00
Virgil
cb726000a9 feat(api): add iterator for spec registry
Adds RegisteredSpecGroupsIter so CLI consumers can range over a snapshot of the package-level spec registry without copying the slice manually.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 21:03:15 +00:00
Virgil
f2f262a4c2 refactor(api): standardise unauthorised wording
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:59:55 +00:00
Virgil
eceda4e5c1 feat(openapi): support iterator-backed route descriptions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:55:33 +00:00
Virgil
7e4d8eb179 feat(openapi): add route examples to spec
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:51:02 +00:00
Virgil
bb7d88f3ce feat(openapi): add external docs metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:47:38 +00:00
Virgil
4d7f3a9f99 feat(openapi): add terms of service metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:44:01 +00:00
Virgil
071de51bb5 feat(openapi): mark deprecated operations in spec
Expose route-level deprecation in generated OpenAPI operations and cover it with a regression test.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:40:53 +00:00
Virgil
a589d3bac6 feat(api): add OpenAPI contact metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:35:08 +00:00
Virgil
d45ee6598e feat(api): expose swagger licence metadata in CLI 2026-04-01 20:30:02 +00:00
Virgil
b2d3c96ed7 feat(api): expose swagger licence metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:26:55 +00:00
Virgil
0ed1cfa1b1 docs(api): add AX examples to public APIs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:23:41 +00:00
Virgil
d3737974ce feat(openapi): add info license metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:21:06 +00:00
Virgil
b341b4b860 docs(api): add AX usage examples
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:17:46 +00:00
Virgil
e2935ce79e feat(api): dedupe PHP OpenAPI operation IDs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:13:28 +00:00
Virgil
c9627729b5 fix(provider): harden proxy path stripping
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:08:55 +00:00
Virgil
cd4e24d15f feat(api): document custom success statuses 2026-04-01 20:04:34 +00:00
Virgil
6017ac7132 feat(api): collapse equivalent OpenAPI servers
Normalise server metadata so trailing-slash variants deduplicate to a single entry.

Adds a regression test covering both absolute and relative server URLs.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 20:01:34 +00:00
Virgil
6034579c00 feat(openapi): fall back to group tags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:57:10 +00:00
Virgil
408a709a43 feat(openapi): allow route-level security overrides
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:54:13 +00:00
Virgil
ebad4c397d feat(client): support header and cookie parameters
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:50:41 +00:00
Virgil
7c3e8e7ba6 feat(openapi): support gin-style path params
Normalise Gin colon and wildcard route segments into OpenAPI template parameters so documented paths match the framework's route syntax.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:46:07 +00:00
Virgil
13cc93f4f4 fix(openapi): skip blank tags in generated specs
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:35:36 +00:00
Virgil
3f010b855e feat(api): declare explicit OpenAPI tags 2026-04-01 19:27:04 +00:00
Virgil
ea94081231 feat(api): normalise OpenAPI path joins
Normalise the concatenation of BasePath() and RouteDescription paths so trailing or missing slashes do not produce malformed OpenAPI entries.

Add a regression test for mixed slash formatting.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:21:46 +00:00
Virgil
a055781d5d feat(i18n): add locale message fallback
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:17:26 +00:00
Virgil
0ed72c4952 feat(api): document explicit route parameters
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:12:51 +00:00
Virgil
862604dc22 feat(api): expose SDK spec metadata flags
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:09:59 +00:00
Virgil
0144244ccd feat(api): dedupe registered spec groups
Prevent duplicate RouteGroup registrations from appearing multiple times in CLI-generated OpenAPI output. This keeps spec generation stable when packages register the same group more than once during init or tests.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 19:06:03 +00:00
Virgil
69beb451b5 feat(api): expose webhook secret routes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:32:39 +00:00
Virgil
3b26a15048 feat(api): register CLI spec groups 2026-04-01 18:29:45 +00:00
Virgil
edb1cf0c1e feat(openapi): document path parameters
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:25:00 +00:00
Virgil
2f8f8f805e fix(api): scope rate limiting by key
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:22:17 +00:00
Virgil
5da281c431 feat(bridge): support schema composition keywords
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:19:15 +00:00
Virgil
6e878778dc feat(api-docs): document MCP tool call body
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:12:30 +00:00
Virgil
cdef85dcc9 feat(graphql): normalise custom mount paths
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:08:27 +00:00
Virgil
ee83aabca0 fix(api): pass MCP tool version through execution
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 18:03:26 +00:00
Virgil
db787a799b feat(api): document SEO and MCP query parameters
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 17:59:54 +00:00
Virgil
aff54403c6 fix(api): compose swagger server metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 17:57:16 +00:00
Virgil
164a1d4f0e feat(api): document cache hits in OpenAPI
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 17:52:14 +00:00
Virgil
f6349145bc feat(api): validate openapi client requests and responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 17:48:49 +00:00
Virgil
1ec5bf4062 feat(api): attach request meta to error envelopes
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 17:43:37 +00:00
Virgil
c48effb6b7 feat(api): normalise OpenAPI server metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 17:31:45 +00:00
Virgil
5b59a1dd10 feat(api): prefer absolute OpenAPI servers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 17:24:36 +00:00
Virgil
da9bb918f7 fix(api): tighten public path auth bypass matching
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:56:09 +00:00
Virgil
c6034031a3 feat(bridge): enforce additional schema constraints
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:50:29 +00:00
Virgil
bfa80e3a27 feat(api): support repeated query parameters in openapi client
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:42:09 +00:00
Virgil
b9f91811d8 feat(api): support HEAD request bodies in OpenAPI client
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:37:13 +00:00
Virgil
19838779ef feat(api): normalize CLI list arguments
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:31:22 +00:00
Virgil
1cc0f2fd48 feat(api): standardise panic responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:25:45 +00:00
Virgil
ac59d284b1 feat(api): document rate limit headers on all responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:18:10 +00:00
Virgil
3b92eda93a feat(api): add shared response envelope schema
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:12:50 +00:00
Virgil
90600aa434 feat(api): expose swagger server metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:07:16 +00:00
Virgil
e713fb9f56 feat(api): emit rate limit headers on success and reject
Adds X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset to successful responses and 429 rejections, and documents the headers in OpenAPI.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 16:01:09 +00:00
Virgil
28f9540fa8 fix(bridge): enforce tool schema enum validation
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:54:32 +00:00
Virgil
ac21992623 feat(api): enforce tool schema enums
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:46:35 +00:00
Virgil
4420651fcf feat(api): document request ID response headers
Add X-Request-ID to the generated OpenAPI response headers so the spec matches the runtime contract for request ID propagation.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:39:38 +00:00
Virgil
1bb2f68b3f feat(api): document rate limit response headers
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:32:01 +00:00
Virgil
fd09309ce9 feat(api): document rate limit and timeout responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:26:17 +00:00
Virgil
726938f04a feat(api): add auth responses to openapi
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:18:25 +00:00
Virgil
321ced1a36 feat(api): add OpenAPI server metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 15:13:39 +00:00
Virgil
4bc132f101 feat(api): fall back to group tags in openapi
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 14:46:15 +00:00
Virgil
b58de8f8f0 feat(provider): expose registry spec files
Add stable registry helpers for enumerating provider OpenAPI spec files, plus iterator coverage. This gives discovery consumers a direct way to aggregate provider docs without changing routing behaviour.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 14:38:45 +00:00
Virgil
90e237ae31 feat(api): include HEAD request bodies in OpenAPI
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 14:29:30 +00:00
Virgil
684a37cd84 fix(api): return listen errors immediately
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 14:20:30 +00:00
Virgil
926a723d9c feat(api): add runtime OpenAPI client
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 14:16:10 +00:00
Virgil
fb6812df09 feat(api): emit request bodies for non-GET operations
Keep OpenAPI requestBody generation aligned with the RouteDescription contract by allowing non-GET operations, including DELETE, to declare JSON bodies.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 14:04:04 +00:00
Virgil
c4cbd018ac feat(api): auto-attach request metadata
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 14:00:04 +00:00
Virgil
37b7fd21ae feat(cache): refresh request meta on cache hits
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:53:37 +00:00
Virgil
00a59947b4 feat(api): attach request metadata to responses
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:48:53 +00:00
Virgil
4efa435a47 feat(api): add MCP resource listing endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:42:29 +00:00
Virgil
2d8bb12311 fix(api): preserve request id on cache hits
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:37:38 +00:00
Virgil
825b61c113 feat(api): add request ID accessor helper
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:31:19 +00:00
Virgil
c9cf407530 feat(api): add Stoplight docs renderer
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:26:55 +00:00
Virgil
5d5ca8aa51 feat(api): validate ToolBridge output schemas
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:18:10 +00:00
Virgil
9aa7c644ef fix(api): disable non-positive timeouts
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 13:12:51 +00:00
Virgil
65ae0fca6d feat(api): drain SSE clients on shutdown
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:45:30 +00:00
Virgil
837a910148 feat(cache): add LRU eviction limit
Add an optional maxEntries cap to WithCache so the in-memory cache can evict old entries instead of growing without bound.\n\nCo-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:27:22 +00:00
Virgil
9afaed4d7c feat(api): document bearer auth in openapi
Add a bearerAuth security scheme to the generated OpenAPI document and mark non-public operations as secured, while keeping /health explicitly public.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 09:08:34 +00:00
Virgil
f030665566 feat(api): preserve path params in operationId 2026-04-01 08:11:33 +00:00
Virgil
16abc45efa feat(api): add stable openapi operation ids
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 07:36:35 +00:00
Virgil
491c9a1c69 fix(i18n): preserve matched locale tags 2026-04-01 07:22:25 +00:00
Virgil
5eaaa8a01e feat(api): add seo analysis endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 07:18:01 +00:00
Virgil
71eebc53aa feat(provider): expose proxy metadata in registry info
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 07:08:19 +00:00
Virgil
1c9e4891e7 feat(api): add spec description flag
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 07:02:28 +00:00
Virgil
6fdd769212 feat(api): add per-IP rate limiting middleware
Adds a token-bucket WithRateLimit option that rejects excess requests with 429 Too Many Requests and a standard error envelope.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:54:58 +00:00
Virgil
797c5f9571 feat(api): add entitlements endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:47:28 +00:00
Virgil
db1efd502c feat(api): add unified pixel tracking endpoint
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:41:54 +00:00
Virgil
3ead3fed2b feat(api): implement MCP resource reads
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 06:33:11 +00:00
Virgil
6ef194754e feat(bridge): validate tool request bodies 2026-04-01 06:23:58 +00:00
Virgil
10fc9559fa fix(api): make SSE drain wait for clients
Drain() now signals SSE clients to disconnect, closes the event stream under the broker lock, and waits for handler goroutines to exit before returning. Added a regression test to verify that active clients disconnect and the broker reaches zero client count.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-01 05:13:44 +00:00
Virgil
6fc1767d31 fix(api): normalize remaining MCP and rate-limit error envelopes 2026-03-30 06:14:22 +00:00
Virgil
ee3fba1e7a feat(api): standardize agent-facing response envelopes 2026-03-30 05:52:06 +00:00
128 changed files with 22582 additions and 823 deletions

View file

@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Core API is the REST framework for the Lethean ecosystem, providing both a **Go HTTP engine** (Gin-based, with OpenAPI generation, WebSocket/SSE, ToolBridge) and a **PHP Laravel package** (rate limiting, webhooks, API key management, OpenAPI documentation). Both halves serve the same purpose in their respective stacks.
Module: `forge.lthn.ai/core/api` | Package: `lthn/api` | Licence: EUPL-1.2
Module: `dappco.re/go/core/api` | Package: `dappco.re/php/service` | Licence: EUPL-1.2
## Build and Test Commands
@ -93,7 +93,7 @@ Key services: `WebhookService`, `RateLimitService`, `IpRestrictionService`, `Ope
| Go module | Role |
|-----------|------|
| `forge.lthn.ai/core/cli` | CLI command registration |
| `dappco.re/go/core/cli` | CLI command registration |
| `github.com/gin-gonic/gin` | HTTP router |
| `github.com/casbin/casbin/v2` | Authorisation policies |
| `github.com/coreos/go-oidc/v3` | OIDC / Authentik |

163
api.go
View file

@ -6,12 +6,14 @@ package api
import (
"context"
"errors"
"iter"
"net/http"
"reflect"
"slices"
"time"
core "dappco.re/go/core"
"github.com/gin-contrib/expvar"
"github.com/gin-contrib/pprof"
"github.com/gin-gonic/gin"
@ -24,23 +26,62 @@ const defaultAddr = ":8080"
const shutdownTimeout = 10 * time.Second
// Engine is the central API server managing route groups and middleware.
//
// Example:
//
// engine, err := api.New(api.WithAddr(":8081"))
// if err != nil {
// panic(err)
// }
// _ = engine.Handler()
type Engine struct {
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
chatCompletionsResolver *ModelResolver
chatCompletionsPath string
cacheTTL time.Duration
cacheMaxEntries int
cacheMaxBytes int
wsHandler http.Handler
wsGinHandler gin.HandlerFunc
wsPath string
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerSummary string
swaggerDesc string
swaggerVersion string
swaggerPath string
swaggerTermsOfService string
swaggerServers []string
swaggerContactName string
swaggerContactURL string
swaggerContactEmail string
swaggerLicenseName string
swaggerLicenseURL string
swaggerSecuritySchemes map[string]any
swaggerExternalDocsDescription string
swaggerExternalDocsURL string
authentikConfig AuthentikConfig
pprofEnabled bool
expvarEnabled bool
ssePath string
graphql *graphqlConfig
i18nConfig I18nConfig
openAPISpecEnabled bool
openAPISpecPath string
}
// New creates an Engine with the given options.
// The default listen address is ":8080".
//
// Example:
//
// engine, err := api.New(api.WithAddr(":8081"), api.WithResponseMeta())
// if err != nil {
// panic(err)
// }
func New(opts ...Option) (*Engine, error) {
e := &Engine{
addr: defaultAddr,
@ -48,31 +89,62 @@ func New(opts ...Option) (*Engine, error) {
for _, opt := range opts {
opt(e)
}
// Apply calibrated defaults for optional subsystems.
if e.chatCompletionsResolver != nil && core.Trim(e.chatCompletionsPath) == "" {
e.chatCompletionsPath = defaultChatCompletionsPath
}
return e, nil
}
// Addr returns the configured listen address.
//
// Example:
//
// engine, _ := api.New(api.WithAddr(":9090"))
// addr := engine.Addr()
func (e *Engine) Addr() string {
return e.addr
}
// Groups returns all registered route groups.
// Groups returns a copy of all registered route groups.
//
// Example:
//
// groups := engine.Groups()
func (e *Engine) Groups() []RouteGroup {
return e.groups
return slices.Clone(e.groups)
}
// GroupsIter returns an iterator over all registered route groups.
//
// Example:
//
// for group := range engine.GroupsIter() {
// _ = group
// }
func (e *Engine) GroupsIter() iter.Seq[RouteGroup] {
return slices.Values(e.groups)
groups := slices.Clone(e.groups)
return slices.Values(groups)
}
// Register adds a route group to the engine.
//
// Example:
//
// engine.Register(myGroup)
func (e *Engine) Register(group RouteGroup) {
if isNilRouteGroup(group) {
return
}
e.groups = append(e.groups, group)
}
// Channels returns all WebSocket channel names from registered StreamGroups.
// Groups that do not implement StreamGroup are silently skipped.
//
// Example:
//
// channels := engine.Channels()
func (e *Engine) Channels() []string {
var channels []string
for _, g := range e.groups {
@ -84,9 +156,16 @@ func (e *Engine) Channels() []string {
}
// ChannelsIter returns an iterator over WebSocket channel names from registered StreamGroups.
//
// Example:
//
// for channel := range engine.ChannelsIter() {
// _ = channel
// }
func (e *Engine) ChannelsIter() iter.Seq[string] {
groups := slices.Clone(e.groups)
return func(yield func(string) bool) {
for _, g := range e.groups {
for _, g := range groups {
if sg, ok := g.(StreamGroup); ok {
for _, c := range sg.Channels() {
if !yield(c) {
@ -100,12 +179,22 @@ func (e *Engine) ChannelsIter() iter.Seq[string] {
// Handler builds the Gin engine and returns it as an http.Handler.
// Each call produces a fresh handler reflecting the current set of groups.
//
// Example:
//
// handler := engine.Handler()
func (e *Engine) Handler() http.Handler {
return e.build()
}
// Serve starts the HTTP server and blocks until the context is cancelled,
// then performs a graceful shutdown allowing in-flight requests to complete.
//
// Example:
//
// ctx, cancel := context.WithCancel(context.Background())
// defer cancel()
// _ = engine.Serve(ctx)
func (e *Engine) Serve(ctx context.Context) error {
srv := &http.Server{
Addr: e.addr,
@ -114,14 +203,24 @@ func (e *Engine) Serve(ctx context.Context) error {
errCh := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
if err := srv.ListenAndServe(); err != nil && !core.Is(err, http.ErrServerClosed) {
errCh <- err
}
close(errCh)
}()
// Block until context is cancelled.
<-ctx.Done()
// Return immediately if the listener fails before shutdown is requested.
select {
case err := <-errCh:
return err
case <-ctx.Done():
}
// Signal SSE clients first so their handlers can exit cleanly before the
// HTTP server begins its own shutdown sequence.
if e.sseBroker != nil {
e.sseBroker.Drain()
}
// Graceful shutdown with timeout.
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
@ -139,7 +238,7 @@ func (e *Engine) Serve(ctx context.Context) error {
// user-supplied middleware, the health endpoint, and all registered route groups.
func (e *Engine) build() *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.Use(recoveryMiddleware())
// Apply user-supplied middleware after recovery but before routes.
for _, mw := range e.middlewares {
@ -151,20 +250,34 @@ 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 each registered group at its base path.
for _, g := range e.groups {
if isNilRouteGroup(g) {
continue
}
rg := r.Group(g.BasePath())
g.RegisterRoutes(rg)
}
// Mount WebSocket handler if configured.
if e.wsHandler != nil {
r.GET("/ws", wrapWSHandler(e.wsHandler))
// Mount WebSocket handler if configured. WithWebSocket (gin-native) takes
// precedence over WithWSHandler (http.Handler) when both are supplied so
// the more specific gin form wins.
switch {
case e.wsGinHandler != nil:
r.GET(resolveWSPath(e.wsPath), e.wsGinHandler)
case e.wsHandler != nil:
r.GET(resolveWSPath(e.wsPath), wrapWSHandler(e.wsHandler))
}
// Mount SSE endpoint if configured.
if e.sseBroker != nil {
r.GET("/events", e.sseBroker.Handler())
r.GET(resolveSSEPath(e.ssePath), e.sseBroker.Handler())
}
// Mount GraphQL endpoint if configured.
@ -174,7 +287,15 @@ func (e *Engine) build() *gin.Engine {
// Mount Swagger UI if enabled.
if e.swaggerEnabled {
registerSwagger(r, e.swaggerTitle, e.swaggerDesc, e.swaggerVersion, e.groups)
registerSwagger(r, e, e.groups)
}
// Mount the standalone OpenAPI JSON endpoint (RFC.endpoints.md — "GET
// /v1/openapi.json") when explicitly enabled. Unlike Swagger UI the spec
// document is served directly so ToolBridge consumers and SDK generators
// can fetch the latest description without loading the UI bundle.
if e.openAPISpecEnabled {
registerOpenAPISpec(r, e)
}
// Mount pprof profiling endpoints if enabled.
@ -189,3 +310,17 @@ func (e *Engine) build() *gin.Engine {
return r
}
func isNilRouteGroup(group RouteGroup) bool {
if group == nil {
return true
}
value := reflect.ValueOf(group)
switch value.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return value.IsNil()
default:
return false
}
}

View file

@ -29,6 +29,16 @@ func (h *healthGroup) RegisterRoutes(rg *gin.RouterGroup) {
})
}
type panicGroup struct{}
func (p *panicGroup) Name() string { return "panic" }
func (p *panicGroup) BasePath() string { return "/panic" }
func (p *panicGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/boom", func(c *gin.Context) {
panic("boom")
})
}
// ── New ─────────────────────────────────────────────────────────────────
func TestNew_Good(t *testing.T) {
@ -85,6 +95,28 @@ func TestRegister_Good_MultipleGroups(t *testing.T) {
}
}
func TestRegister_Good_GroupsReturnsCopy(t *testing.T) {
e, _ := api.New()
first := &healthGroup{}
second := &stubGroup{}
e.Register(first)
e.Register(second)
groups := e.Groups()
groups[0] = nil
fresh := e.Groups()
if fresh[0] == nil {
t.Fatal("expected Groups to return a copy, but engine state was mutated")
}
if fresh[0].Name() != first.Name() {
t.Fatalf("expected first group name %q, got %q", first.Name(), fresh[0].Name())
}
if fresh[1].Name() != "stub" {
t.Fatalf("expected second group name %q, got %q", "stub", fresh[1].Name())
}
}
// ── Handler ─────────────────────────────────────────────────────────────
func TestHandler_Good_HealthEndpoint(t *testing.T) {
@ -149,6 +181,41 @@ func TestHandler_Bad_NotFound(t *testing.T) {
}
}
func TestHandler_Bad_PanicReturnsEnvelope(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRequestID())
e.Register(&panicGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/panic/boom", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil {
t.Fatal("expected Error to be non-nil")
}
if resp.Error.Code != "internal_server_error" {
t.Fatalf("expected error code=%q, got %q", "internal_server_error", resp.Error.Code)
}
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 == "" {
t.Fatal("expected X-Request-ID header to survive panic recovery")
}
}
// ── Serve + graceful shutdown ───────────────────────────────────────────
func TestServe_Good_GracefulShutdown(t *testing.T) {
@ -202,3 +269,32 @@ func TestServe_Good_GracefulShutdown(t *testing.T) {
t.Fatal("Serve did not return within 5 seconds after context cancellation")
}
}
func TestServe_Bad_ReturnsListenErrorBeforeCancel(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("failed to reserve port: %v", err)
}
addr := ln.Addr().String()
defer ln.Close()
e, _ := api.New(api.WithAddr(addr))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- e.Serve(ctx)
}()
select {
case serveErr := <-errCh:
if serveErr == nil {
t.Fatal("expected Serve to return a listen error, got nil")
}
case <-time.After(2 * time.Second):
cancel()
t.Fatal("Serve did not return promptly after listener failure")
}
}

View file

@ -9,11 +9,17 @@ import (
"strings"
"sync"
core "dappco.re/go/core"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/gin-gonic/gin"
)
// AuthentikConfig holds settings for the Authentik forward-auth integration.
//
// Example:
//
// cfg := api.AuthentikConfig{Issuer: "https://auth.example.com/", ClientID: "core-api"}
type AuthentikConfig struct {
// Issuer is the OIDC issuer URL (e.g. https://auth.example.com/application/o/my-app/).
Issuer string
@ -26,12 +32,32 @@ type AuthentikConfig struct {
TrustedProxy bool
// PublicPaths lists additional paths that do not require authentication.
// /health and /swagger are always public.
// /health and the configured Swagger UI path are always public.
PublicPaths []string
}
// AuthentikConfig returns the configured Authentik settings for the engine.
//
// The result snapshots the Engine state at call time and clones slices so
// callers can safely reuse or modify the returned value.
//
// Example:
//
// cfg := engine.AuthentikConfig()
func (e *Engine) AuthentikConfig() AuthentikConfig {
if e == nil {
return AuthentikConfig{}
}
return cloneAuthentikConfig(e.authentikConfig)
}
// AuthentikUser represents an authenticated user extracted from Authentik
// forward-auth headers or a validated JWT.
//
// Example:
//
// user := &api.AuthentikUser{Username: "alice", Groups: []string{"admins"}}
type AuthentikUser struct {
Username string `json:"username"`
Email string `json:"email"`
@ -43,6 +69,10 @@ type AuthentikUser struct {
}
// HasGroup reports whether the user belongs to the named group.
//
// Example:
//
// user.HasGroup("admins")
func (u *AuthentikUser) HasGroup(group string) bool {
return slices.Contains(u.Groups, group)
}
@ -53,6 +83,10 @@ const authentikUserKey = "authentik_user"
// GetUser retrieves the AuthentikUser from the Gin context.
// Returns nil when no user has been set (unauthenticated request or
// middleware not active).
//
// Example:
//
// user := api.GetUser(c)
func GetUser(c *gin.Context) *AuthentikUser {
val, exists := c.Get(authentikUserKey)
if !exists {
@ -134,7 +168,7 @@ func validateJWT(ctx context.Context, cfg AuthentikConfig, rawToken string) (*Au
// The middleware is PERMISSIVE: it populates the context when credentials are
// present but never rejects unauthenticated requests. Downstream handlers
// use GetUser to check authentication.
func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
func authentikMiddleware(cfg AuthentikConfig, publicPaths func() []string) gin.HandlerFunc {
// Build the set of public paths that skip header extraction entirely.
public := map[string]bool{
"/health": true,
@ -148,11 +182,19 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
// Skip public paths.
path := c.Request.URL.Path
for p := range public {
if strings.HasPrefix(path, p) {
if isPublicPath(path, p) {
c.Next()
return
}
}
if publicPaths != nil {
for _, p := range publicPaths() {
if isPublicPath(path, p) {
c.Next()
return
}
}
}
// Block 1: Extract user from X-authentik-* forward-auth headers.
if cfg.TrustedProxy {
@ -167,10 +209,10 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
}
if groups := c.GetHeader("X-authentik-groups"); groups != "" {
user.Groups = strings.Split(groups, "|")
user.Groups = core.Split(groups, "|")
}
if ent := c.GetHeader("X-authentik-entitlements"); ent != "" {
user.Entitlements = strings.Split(ent, "|")
user.Entitlements = core.Split(ent, "|")
}
c.Set(authentikUserKey, user)
@ -180,8 +222,8 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
// Block 2: Attempt JWT validation for direct API clients.
// Only when OIDC is configured and no user was extracted from headers.
if cfg.Issuer != "" && cfg.ClientID != "" && GetUser(c) == nil {
if auth := c.GetHeader("Authorization"); strings.HasPrefix(auth, "Bearer ") {
rawToken := strings.TrimPrefix(auth, "Bearer ")
if auth := c.GetHeader("Authorization"); core.HasPrefix(auth, "Bearer ") {
rawToken := core.TrimPrefix(auth, "Bearer ")
if user, err := validateJWT(c.Request.Context(), cfg, rawToken); err == nil {
c.Set(authentikUserKey, user)
}
@ -193,9 +235,57 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
}
}
func cloneAuthentikConfig(cfg AuthentikConfig) AuthentikConfig {
out := cfg
out.Issuer = core.Trim(out.Issuer)
out.ClientID = core.Trim(out.ClientID)
out.PublicPaths = normalisePublicPaths(cfg.PublicPaths)
return out
}
// normalisePublicPaths trims whitespace, ensures a leading slash, and removes
// duplicate entries while preserving the first occurrence of each path.
func normalisePublicPaths(paths []string) []string {
if len(paths) == 0 {
return nil
}
out := make([]string, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
path = core.Trim(path)
if path == "" {
continue
}
if !core.HasPrefix(path, "/") {
path = "/" + path
}
path = strings.TrimRight(path, "/")
if path == "" {
path = "/"
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
out = append(out, path)
}
if len(out) == 0 {
return nil
}
return out
}
// RequireAuth is Gin middleware that rejects unauthenticated requests.
// It checks for a user set by the Authentik middleware and returns 401
// when none is present.
//
// Example:
//
// r.GET("/private", api.RequireAuth(), handler)
func RequireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
if GetUser(c) == nil {
@ -210,6 +300,10 @@ func RequireAuth() gin.HandlerFunc {
// RequireGroup is Gin middleware that rejects requests from users who do
// not belong to the specified group. Returns 401 when no user is present
// and 403 when the user lacks the required group membership.
//
// Example:
//
// r.GET("/admin", api.RequireGroup("admins"), handler)
func RequireGroup(group string) gin.HandlerFunc {
return func(c *gin.Context) {
user := GetUser(c)

View file

@ -221,6 +221,27 @@ func TestHealthBypassesAuthentik_Good(t *testing.T) {
}
}
func TestPublicPaths_Good_SimilarPrefixDoesNotBypassAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{
TrustedProxy: true,
PublicPaths: []string{"/public"},
}
e, _ := api.New(api.WithAuthentik(cfg))
e.Register(&publicPrefixGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/publicity/secure", nil)
req.Header.Set("X-authentik-username", "alice")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 for /publicity/secure with auth header, got %d: %s", w.Code, w.Body.String())
}
}
func TestGetUser_Good_NilContext(t *testing.T) {
gin.SetMode(gin.TestMode)
@ -322,6 +343,33 @@ func TestBearerAndAuthentikCoexist_Good(t *testing.T) {
}
}
func TestAuthentik_Good_CustomSwaggerPathBypassesAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := api.AuthentikConfig{TrustedProxy: true}
e, err := api.New(
api.WithAuthentik(cfg),
api.WithSwagger("Test API", "A test API service", "1.0.0"),
api.WithSwaggerPath("/docs"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
resp, err := http.Get(srv.URL + "/docs/doc.json")
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for custom swagger path without auth, got %d", resp.StatusCode)
}
}
// ── RequireAuth / RequireGroup ────────────────────────────────────────
func TestRequireAuth_Good(t *testing.T) {
@ -458,3 +506,15 @@ func (g *groupRequireGroup) RegisterRoutes(rg *gin.RouterGroup) {
c.JSON(200, api.OK("admin panel"))
})
}
// publicPrefixGroup provides a route that should still be processed by auth
// middleware even though its path shares a prefix with a public path.
type publicPrefixGroup struct{}
func (g *publicPrefixGroup) Name() string { return "public-prefix" }
func (g *publicPrefixGroup) BasePath() string { return "/publicity" }
func (g *publicPrefixGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/secure", api.RequireAuth(), func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("protected"))
})
}

926
bridge.go
View file

@ -3,12 +3,29 @@
package api
import (
"bufio"
"bytes"
"encoding/json"
"io"
"iter"
"net"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"unicode/utf8"
core "dappco.re/go/core"
"github.com/gin-gonic/gin"
)
// ToolDescriptor describes a tool that can be exposed as a REST endpoint.
//
// Example:
//
// desc := api.ToolDescriptor{Name: "ping", Description: "Ping the service"}
type ToolDescriptor struct {
Name string // Tool name, e.g. "file_read" (becomes POST path segment)
Description string // Human-readable description
@ -19,6 +36,10 @@ type ToolDescriptor struct {
// ToolBridge converts tool descriptors into REST endpoints and OpenAPI paths.
// It implements both RouteGroup and DescribableGroup.
//
// Example:
//
// bridge := api.NewToolBridge("/mcp")
type ToolBridge struct {
basePath string
name string
@ -30,7 +51,14 @@ type boundTool struct {
handler gin.HandlerFunc
}
var _ RouteGroup = (*ToolBridge)(nil)
var _ DescribableGroup = (*ToolBridge)(nil)
// NewToolBridge creates a bridge that mounts tool endpoints at basePath.
//
// Example:
//
// bridge := api.NewToolBridge("/mcp")
func NewToolBridge(basePath string) *ToolBridge {
return &ToolBridge{
basePath: basePath,
@ -39,62 +67,97 @@ func NewToolBridge(basePath string) *ToolBridge {
}
// Add registers a tool with its HTTP handler.
//
// Example:
//
// bridge.Add(api.ToolDescriptor{Name: "ping", Description: "Ping the service"}, handler)
func (b *ToolBridge) Add(desc ToolDescriptor, handler gin.HandlerFunc) {
if validator := newToolInputValidator(desc.OutputSchema); validator != nil {
handler = wrapToolResponseHandler(handler, validator)
}
if validator := newToolInputValidator(desc.InputSchema); validator != nil {
handler = wrapToolHandler(handler, validator)
}
b.tools = append(b.tools, boundTool{descriptor: desc, handler: handler})
}
// Name returns the bridge identifier.
//
// Example:
//
// name := bridge.Name()
func (b *ToolBridge) Name() string { return b.name }
// BasePath returns the URL prefix for all tool endpoints.
//
// Example:
//
// path := bridge.BasePath()
func (b *ToolBridge) BasePath() string { return b.basePath }
// RegisterRoutes mounts POST /{tool_name} for each registered tool.
// RegisterRoutes mounts GET / (tool listing) and POST /{tool_name} for each
// registered tool. The listing endpoint returns a JSON envelope containing the
// registered tool descriptors and mirrors RFC.endpoints.md §"ToolBridge" so
// clients can discover every tool exposed on the bridge in a single call.
//
// Example:
//
// bridge.RegisterRoutes(rg)
// // GET /{basePath}/ -> tool catalogue
// // POST /{basePath}/{name} -> dispatch tool
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("", b.listHandler())
rg.GET("/", b.listHandler())
for _, t := range b.tools {
rg.POST("/"+t.descriptor.Name, t.handler)
}
}
// Describe returns OpenAPI route descriptions for all registered tools.
func (b *ToolBridge) Describe() []RouteDescription {
descs := make([]RouteDescription, 0, len(b.tools))
for _, t := range b.tools {
tags := []string{t.descriptor.Group}
if t.descriptor.Group == "" {
tags = []string{b.name}
// listHandler returns a Gin handler that serves the tool catalogue at the
// bridge's base path. The response envelope matches RFC.endpoints.md — an
// array of tool descriptors with their name, description, group, and the
// declared input/output JSON schemas.
//
// GET /v1/tools -> {"success": true, "data": [{"name": "ping", ...}]}
func (b *ToolBridge) listHandler() gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusOK, OK(b.Tools()))
}
descs = append(descs, RouteDescription{
Method: "POST",
Path: "/" + t.descriptor.Name,
Summary: t.descriptor.Description,
Description: t.descriptor.Description,
Tags: tags,
RequestBody: t.descriptor.InputSchema,
Response: t.descriptor.OutputSchema,
})
}
// Describe returns OpenAPI route descriptions for all registered tools plus a
// GET entry describing the tool listing endpoint at the bridge's base path.
//
// Example:
//
// descs := bridge.Describe()
func (b *ToolBridge) Describe() []RouteDescription {
tools := b.snapshotTools()
descs := make([]RouteDescription, 0, len(tools)+1)
descs = append(descs, describeToolList(b.name))
for _, tool := range tools {
descs = append(descs, describeTool(tool.descriptor, b.name))
}
return descs
}
// DescribeIter returns an iterator over OpenAPI route descriptions for all registered tools.
// DescribeIter returns an iterator over OpenAPI route descriptions for all
// registered tools plus a leading GET entry for the tool listing endpoint.
//
// Example:
//
// for rd := range bridge.DescribeIter() {
// _ = rd
// }
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
tools := b.snapshotTools()
defaultTag := b.name
return func(yield func(RouteDescription) bool) {
for _, t := range b.tools {
tags := []string{t.descriptor.Group}
if t.descriptor.Group == "" {
tags = []string{b.name}
if !yield(describeToolList(defaultTag)) {
return
}
rd := RouteDescription{
Method: "POST",
Path: "/" + t.descriptor.Name,
Summary: t.descriptor.Description,
Description: t.descriptor.Description,
Tags: tags,
RequestBody: t.descriptor.InputSchema,
Response: t.descriptor.OutputSchema,
}
if !yield(rd) {
for _, tool := range tools {
if !yield(describeTool(tool.descriptor, defaultTag)) {
return
}
}
@ -102,21 +165,812 @@ func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
}
// Tools returns all registered tool descriptors.
//
// Example:
//
// descs := bridge.Tools()
func (b *ToolBridge) Tools() []ToolDescriptor {
descs := make([]ToolDescriptor, len(b.tools))
for i, t := range b.tools {
tools := b.snapshotTools()
descs := make([]ToolDescriptor, len(tools))
for i, t := range tools {
descs[i] = t.descriptor
}
return descs
}
// ToolsIter returns an iterator over all registered tool descriptors.
//
// Example:
//
// for desc := range bridge.ToolsIter() {
// _ = desc
// }
func (b *ToolBridge) ToolsIter() iter.Seq[ToolDescriptor] {
tools := b.snapshotTools()
return func(yield func(ToolDescriptor) bool) {
for _, t := range b.tools {
if !yield(t.descriptor) {
for _, tool := range tools {
if !yield(tool.descriptor) {
return
}
}
}
}
func (b *ToolBridge) snapshotTools() []boundTool {
if len(b.tools) == 0 {
return nil
}
return slices.Clone(b.tools)
}
func describeTool(desc ToolDescriptor, defaultTag string) RouteDescription {
tags := cleanTags([]string{desc.Group})
if len(tags) == 0 {
tags = []string{defaultTag}
}
return RouteDescription{
Method: "POST",
Path: "/" + desc.Name,
Summary: desc.Description,
Description: desc.Description,
Tags: tags,
RequestBody: desc.InputSchema,
Response: desc.OutputSchema,
}
}
// describeToolList returns the RouteDescription for GET {basePath}/ —
// the tool catalogue listing documented in RFC.endpoints.md.
//
// rd := describeToolList("tools")
// // rd.Method == "GET" && rd.Path == "/"
func describeToolList(defaultTag string) RouteDescription {
tags := cleanTags([]string{defaultTag})
if len(tags) == 0 {
tags = []string{"tools"}
}
return RouteDescription{
Method: "GET",
Path: "/",
Summary: "List available tools",
Description: "Returns every tool registered on the bridge, including its declared input and output JSON schemas.",
Tags: tags,
StatusCode: http.StatusOK,
Response: map[string]any{
"type": "array",
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{"type": "string"},
"description": map[string]any{"type": "string"},
"group": map[string]any{"type": "string"},
"inputSchema": map[string]any{"type": "object", "additionalProperties": true},
"outputSchema": map[string]any{"type": "object", "additionalProperties": true},
},
"required": []string{"name"},
},
},
}
}
// maxToolRequestBodyBytes is the maximum request body size accepted by the
// tool bridge handler. Requests larger than this are rejected with 413.
const maxToolRequestBodyBytes = 10 << 20 // 10 MiB
func wrapToolHandler(handler gin.HandlerFunc, validator *toolInputValidator) gin.HandlerFunc {
return func(c *gin.Context) {
limited := http.MaxBytesReader(c.Writer, c.Request.Body, maxToolRequestBodyBytes)
body, err := io.ReadAll(limited)
if err != nil {
status := http.StatusBadRequest
msg := "Unable to read request body"
if err.Error() == "http: request body too large" {
status = http.StatusRequestEntityTooLarge
msg = "Request body exceeds the maximum allowed size"
}
c.AbortWithStatusJSON(status, FailWithDetails(
"invalid_request_body",
msg,
map[string]any{"error": err.Error()},
))
return
}
if err := validator.Validate(body); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, FailWithDetails(
"invalid_request_body",
"Request body does not match the declared tool schema",
map[string]any{"error": err.Error()},
))
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
handler(c)
}
}
func wrapToolResponseHandler(handler gin.HandlerFunc, validator *toolInputValidator) gin.HandlerFunc {
return func(c *gin.Context) {
recorder := newToolResponseRecorder(c.Writer)
c.Writer = recorder
handler(c)
if recorder.Status() >= 200 && recorder.Status() < 300 {
if err := validator.ValidateResponse(recorder.body.Bytes()); err != nil {
recorder.reset()
recorder.writeErrorResponse(http.StatusInternalServerError, FailWithDetails(
"invalid_tool_response",
"Tool response does not match the declared output schema",
map[string]any{"error": err.Error()},
))
return
}
}
recorder.commit()
}
}
type toolInputValidator struct {
schema map[string]any
}
func newToolInputValidator(schema map[string]any) *toolInputValidator {
if len(schema) == 0 {
return nil
}
return &toolInputValidator{schema: schema}
}
func (v *toolInputValidator) Validate(body []byte) error {
if len(bytes.TrimSpace(body)) == 0 {
return core.E("ToolBridge.Validate", "request body is required", nil)
}
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
var payload any
if err := dec.Decode(&payload); err != nil {
return core.E("ToolBridge.Validate", "invalid JSON", err)
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return core.E("ToolBridge.Validate", "request body must contain a single JSON value", nil)
}
return validateSchemaNode(payload, v.schema, "")
}
func (v *toolInputValidator) ValidateResponse(body []byte) error {
if len(v.schema) == 0 {
return nil
}
// Use a decoder with UseNumber so that large integers in the envelope
// (including within the data field) are preserved as json.Number rather
// than being silently coerced to float64. This matches the behaviour of
// the Validate path and avoids precision loss for 64-bit integer values.
var envelope map[string]any
envDec := json.NewDecoder(bytes.NewReader(body))
envDec.UseNumber()
if err := envDec.Decode(&envelope); err != nil {
return core.E("ToolBridge.ValidateResponse", "invalid JSON response", err)
}
success, _ := envelope["success"].(bool)
if !success {
return core.E("ToolBridge.ValidateResponse", "response is missing a successful envelope", nil)
}
// data is serialised with omitempty, so a nil/zero-value payload from
// constructors like OK(nil) or OK(false) will omit the key entirely.
// Treat a missing data key as a valid nil payload for successful responses.
data, ok := envelope["data"]
if !ok {
return nil
}
encoded, err := json.Marshal(data)
if err != nil {
return core.E("ToolBridge.ValidateResponse", "encode response data", err)
}
var payload any
dec := json.NewDecoder(bytes.NewReader(encoded))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return core.E("ToolBridge.ValidateResponse", "decode response data", err)
}
return validateSchemaNode(payload, v.schema, "")
}
func validateSchemaNode(value any, schema map[string]any, path string) error {
if len(schema) == 0 {
return nil
}
schemaType, _ := schema["type"].(string)
if schemaType != "" {
switch schemaType {
case "object":
obj, ok := value.(map[string]any)
if !ok {
return typeError(path, "object", value)
}
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)
}
}
for name, rawChild := range schemaMap(schema["properties"]) {
childSchema, ok := rawChild.(map[string]any)
if !ok {
continue
}
childValue, ok := obj[name]
if !ok {
continue
}
if err := validateSchemaNode(childValue, childSchema, joinPath(path, name)); err != nil {
return err
}
}
if additionalProperties, ok := schema["additionalProperties"].(bool); ok && !additionalProperties {
properties := schemaMap(schema["properties"])
for name := range obj {
if properties != nil {
if _, ok := properties[name]; ok {
continue
}
}
return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s contains unknown field %q", displayPath(path), name), nil)
}
}
if err := validateObjectConstraints(obj, schema, path); err != nil {
return err
}
case "array":
arr, ok := value.([]any)
if !ok {
return typeError(path, "array", value)
}
if items := schemaMap(schema["items"]); len(items) > 0 {
for i, item := range arr {
if err := validateSchemaNode(item, items, joinPath(path, strconv.Itoa(i))); err != nil {
return err
}
}
}
if err := validateArrayConstraints(arr, schema, path); err != nil {
return err
}
case "string":
str, ok := value.(string)
if !ok {
return typeError(path, "string", value)
}
if err := validateStringConstraints(str, schema, path); err != nil {
return err
}
case "boolean":
if _, ok := value.(bool); !ok {
return typeError(path, "boolean", value)
}
case "integer":
if !isIntegerValue(value) {
return typeError(path, "integer", value)
}
if err := validateNumericConstraints(value, schema, path); err != nil {
return err
}
case "number":
if !isNumberValue(value) {
return typeError(path, "number", value)
}
if err := validateNumericConstraints(value, schema, path); err != nil {
return err
}
}
}
if schemaType == "" && (len(schemaMap(schema["properties"])) > 0 || schema["required"] != nil || schema["additionalProperties"] != nil) {
props := schemaMap(schema["properties"])
return validateSchemaNode(value, map[string]any{
"type": "object",
"properties": props,
"required": schema["required"],
"additionalProperties": schema["additionalProperties"],
}, path)
}
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)
}
}
if err := validateSchemaCombinators(value, schema, path); err != nil {
return err
}
return nil
}
func validateSchemaCombinators(value any, schema map[string]any, path string) error {
if subschemas := schemaObjects(schema["allOf"]); len(subschemas) > 0 {
for _, subschema := range subschemas {
if err := validateSchemaNode(value, subschema, path); err != nil {
return err
}
}
}
if subschemas := schemaObjects(schema["anyOf"]); len(subschemas) > 0 {
for _, subschema := range subschemas {
if err := validateSchemaNode(value, subschema, path); err == nil {
goto anyOfMatched
}
}
return core.E("ToolBridge.ValidateSchema", core.Sprintf("%s must match at least one schema in anyOf", displayPath(path)), nil)
}
anyOfMatched:
if subschemas := schemaObjects(schema["oneOf"]); len(subschemas) > 0 {
matches := 0
for _, subschema := range subschemas {
if err := validateSchemaNode(value, subschema, path); err == nil {
matches++
}
}
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("ToolBridge.ValidateSchema", 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 nil
}
func validateStringConstraints(value string, schema map[string]any, path string) error {
length := utf8.RuneCountInString(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)
}
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)
}
if pattern, ok := schema["pattern"].(string); ok && pattern != "" {
re, err := regexp.Compile(pattern)
if err != nil {
return core.E("ToolBridge.ValidateSchema", 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 nil
}
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)
}
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 nil
}
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)
}
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 nil
}
func validateObjectConstraints(value map[string]any, schema map[string]any, path string) 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)
}
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 nil
}
func schemaInt(value any) (int, bool) {
switch v := value.(type) {
case int:
return v, true
case int8:
return int(v), true
case int16:
return int(v), true
case int32:
return int(v), true
case int64:
return int(v), true
case uint:
return int(v), true
case uint8:
return int(v), true
case uint16:
return int(v), true
case uint32:
return int(v), true
case uint64:
return int(v), true
case float64:
if v == float64(int(v)) {
return int(v), true
}
case json.Number:
if n, err := v.Int64(); err == nil {
return int(n), true
}
}
return 0, false
}
func schemaFloat(value any) (float64, bool) {
switch v := value.(type) {
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int8:
return float64(v), true
case int16:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint8:
return float64(v), true
case uint16:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
case json.Number:
if n, err := v.Float64(); err == nil {
return n, true
}
}
return 0, false
}
func numericLessThan(value any, limit float64) bool {
if n, ok := numericValue(value); ok {
return n < limit
}
return false
}
func numericGreaterThan(value any, limit float64) bool {
if n, ok := numericValue(value); ok {
return n > limit
}
return false
}
type toolResponseRecorder struct {
gin.ResponseWriter
headers http.Header
body bytes.Buffer
status int
wroteHeader bool
}
func newToolResponseRecorder(w gin.ResponseWriter) *toolResponseRecorder {
headers := make(http.Header)
for k, vals := range w.Header() {
headers[k] = append([]string(nil), vals...)
}
return &toolResponseRecorder{
ResponseWriter: w,
headers: headers,
status: http.StatusOK,
}
}
func (w *toolResponseRecorder) Header() http.Header {
return w.headers
}
func (w *toolResponseRecorder) WriteHeader(code int) {
w.status = code
w.wroteHeader = true
}
func (w *toolResponseRecorder) WriteHeaderNow() {
w.wroteHeader = true
}
func (w *toolResponseRecorder) Write(data []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.Write(data)
}
func (w *toolResponseRecorder) WriteString(s string) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.body.WriteString(s)
}
func (w *toolResponseRecorder) Flush() {
}
func (w *toolResponseRecorder) Status() int {
if w.wroteHeader {
return w.status
}
return http.StatusOK
}
func (w *toolResponseRecorder) Size() int {
return w.body.Len()
}
func (w *toolResponseRecorder) Written() bool {
return w.wroteHeader
}
func (w *toolResponseRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return nil, nil, core.E("ToolBridge.ResponseRecorder", "response hijacking is not supported by ToolBridge output validation", nil)
}
func (w *toolResponseRecorder) commit() {
for k := range w.ResponseWriter.Header() {
w.ResponseWriter.Header().Del(k)
}
for k, vals := range w.headers {
for _, v := range vals {
w.ResponseWriter.Header().Add(k, v)
}
}
w.ResponseWriter.WriteHeader(w.Status())
_, _ = w.ResponseWriter.Write(w.body.Bytes())
}
func (w *toolResponseRecorder) reset() {
w.headers = make(http.Header)
w.body.Reset()
w.status = http.StatusInternalServerError
w.wroteHeader = false
}
func (w *toolResponseRecorder) writeErrorResponse(status int, resp Response[any]) {
data, err := json.Marshal(resp)
if err != nil {
w.status = http.StatusInternalServerError
w.wroteHeader = true
http.Error(w.ResponseWriter, "internal server error", http.StatusInternalServerError)
return
}
// Keep recorder state aligned with the replacement response so that
// Status(), Written(), Header() and Size() all reflect the error
// response. Post-handler middleware and metrics must observe correct
// values, not stale state from the reset() call above.
w.status = status
w.wroteHeader = true
if w.headers == nil {
w.headers = make(http.Header)
}
w.headers.Set("Content-Type", "application/json")
w.body.Reset()
_, _ = w.body.Write(data)
w.commit()
}
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)
}
func displayPath(path string) string {
if path == "" {
return "request body"
}
return "request body." + path
}
func joinPath(parent, child string) string {
if parent == "" {
return child
}
return parent + "." + child
}
func schemaMap(value any) map[string]any {
if value == nil {
return nil
}
m, _ := value.(map[string]any)
return m
}
func schemaObjects(value any) []map[string]any {
switch raw := value.(type) {
case []any:
out := make([]map[string]any, 0, len(raw))
for _, item := range raw {
if schema := schemaMap(item); schema != nil {
out = append(out, schema)
}
}
return out
case []map[string]any:
return append([]map[string]any(nil), raw...)
default:
return nil
}
}
func stringList(value any) []string {
switch raw := value.(type) {
case []any:
out := make([]string, 0, len(raw))
for _, item := range raw {
name, ok := item.(string)
if !ok {
continue
}
out = append(out, name)
}
return out
case []string:
return append([]string(nil), raw...)
default:
return nil
}
}
func isIntegerValue(value any) bool {
switch v := value.(type) {
case json.Number:
_, err := v.Int64()
return err == nil
case float64:
return v == float64(int64(v))
default:
return false
}
}
func isNumberValue(value any) bool {
switch value.(type) {
case json.Number, float64:
return true
default:
return false
}
}
func enumContains(value any, rawEnum any) bool {
items := enumValues(rawEnum)
for _, candidate := range items {
if valuesEqual(value, candidate) {
return true
}
}
return false
}
func enumValues(rawEnum any) []any {
switch values := rawEnum.(type) {
case []any:
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
case []string:
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
default:
return nil
}
}
func valuesEqual(left, right any) bool {
if isNumericValue(left) && isNumericValue(right) {
lv, lok := numericValue(left)
rv, rok := numericValue(right)
return lok && rok && lv == rv
}
return reflect.DeepEqual(left, right)
}
func isNumericValue(value any) bool {
switch value.(type) {
case json.Number, float64, float32, int, int8, int16, int32, int64,
uint, uint8, uint16, uint32, uint64:
return true
default:
return false
}
}
func numericValue(value any) (float64, bool) {
switch v := value.(type) {
case json.Number:
n, err := v.Float64()
return n, err == nil
case float64:
return v, true
case float32:
return float64(v), true
case int:
return float64(v), true
case int8:
return float64(v), true
case int16:
return float64(v), true
case int32:
return float64(v), true
case int64:
return float64(v), true
case uint:
return float64(v), true
case uint8:
return float64(v), true
case uint16:
return float64(v), true
case uint32:
return float64(v), true
case uint64:
return float64(v), true
default:
return 0, false
}
}
func describeJSONValue(value any) string {
switch value.(type) {
case nil:
return "null"
case string:
return "string"
case bool:
return "boolean"
case json.Number, float64:
return "number"
case map[string]any:
return "object"
case []any:
return "array"
default:
return core.Sprintf("%T", value)
}
}

View file

@ -3,6 +3,7 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@ -117,39 +118,565 @@ func TestToolBridge_Good_Describe(t *testing.T) {
var dg api.DescribableGroup = bridge
descs := dg.Describe()
if len(descs) != 2 {
t.Fatalf("expected 2 descriptions, got %d", len(descs))
// Describe() returns the GET tool listing entry followed by every tool.
if len(descs) != 3 {
t.Fatalf("expected 3 descriptions (listing + 2 tools), got %d", len(descs))
}
// Listing entry mirrors RFC.endpoints.md — GET /v1/tools returns the catalogue.
if descs[0].Method != "GET" {
t.Fatalf("expected descs[0].Method=%q, got %q", "GET", descs[0].Method)
}
if descs[0].Path != "/" {
t.Fatalf("expected descs[0].Path=%q, got %q", "/", descs[0].Path)
}
// First tool.
if descs[0].Method != "POST" {
t.Fatalf("expected descs[0].Method=%q, got %q", "POST", descs[0].Method)
if descs[1].Method != "POST" {
t.Fatalf("expected descs[1].Method=%q, got %q", "POST", descs[1].Method)
}
if descs[0].Path != "/file_read" {
t.Fatalf("expected descs[0].Path=%q, got %q", "/file_read", descs[0].Path)
if descs[1].Path != "/file_read" {
t.Fatalf("expected descs[1].Path=%q, got %q", "/file_read", descs[1].Path)
}
if descs[0].Summary != "Read a file from disk" {
t.Fatalf("expected descs[0].Summary=%q, got %q", "Read a file from disk", descs[0].Summary)
if 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 len(descs[0].Tags) != 1 || descs[0].Tags[0] != "files" {
t.Fatalf("expected descs[0].Tags=[files], got %v", descs[0].Tags)
if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "files" {
t.Fatalf("expected descs[1].Tags=[files], got %v", descs[1].Tags)
}
if descs[0].RequestBody == nil {
t.Fatal("expected descs[0].RequestBody to be non-nil")
if descs[1].RequestBody == nil {
t.Fatal("expected descs[1].RequestBody to be non-nil")
}
if descs[0].Response == nil {
t.Fatal("expected descs[0].Response to be non-nil")
if descs[1].Response == nil {
t.Fatal("expected descs[1].Response to be non-nil")
}
// Second tool.
if descs[1].Path != "/metrics_query" {
t.Fatalf("expected descs[1].Path=%q, got %q", "/metrics_query", descs[1].Path)
if descs[2].Path != "/metrics_query" {
t.Fatalf("expected descs[2].Path=%q, got %q", "/metrics_query", descs[2].Path)
}
if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "metrics" {
t.Fatalf("expected descs[1].Tags=[metrics], got %v", descs[1].Tags)
if len(descs[2].Tags) != 1 || descs[2].Tags[0] != "metrics" {
t.Fatalf("expected descs[2].Tags=[metrics], got %v", descs[2].Tags)
}
if descs[1].Response != nil {
t.Fatalf("expected descs[1].Response to be nil, got %v", descs[1].Response)
if descs[2].Response != nil {
t.Fatalf("expected descs[2].Response to be nil, got %v", descs[2].Response)
}
}
func TestToolBridge_Good_DescribeTrimsBlankGroup(t *testing.T) {
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: " ",
}, func(c *gin.Context) {})
descs := bridge.Describe()
// Describe() returns the GET listing plus one tool description.
if len(descs) != 2 {
t.Fatalf("expected 2 descriptions (listing + tool), got %d", len(descs))
}
if len(descs[1].Tags) != 1 || descs[1].Tags[0] != "tools" {
t.Fatalf("expected blank group to fall back to bridge tag, got %v", descs[1].Tags)
}
}
func TestToolBridge_Good_ValidatesRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: "files",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []any{"path"},
},
}, func(c *gin.Context) {
var payload map[string]any
if err := json.NewDecoder(c.Request.Body).Decode(&payload); err != nil {
t.Fatalf("handler could not read validated body: %v", err)
}
c.JSON(http.StatusOK, api.OK(payload["path"]))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", bytes.NewBufferString(`{"path":"/tmp/file.txt"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data != "/tmp/file.txt" {
t.Fatalf("expected validated payload to reach handler, got %q", resp.Data)
}
}
func TestToolBridge_Good_ValidatesResponseBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: "files",
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []any{"path"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK(map[string]any{"path": "/tmp/file.txt"}))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[map[string]any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !resp.Success {
t.Fatal("expected Success=true")
}
if resp.Data["path"] != "/tmp/file.txt" {
t.Fatalf("expected validated response data to reach client, got %v", resp.Data["path"])
}
}
func TestToolBridge_Bad_InvalidResponseBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: "files",
OutputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []any{"path"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK(map[string]any{"path": 123}))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil || resp.Error.Code != "invalid_tool_response" {
t.Fatalf("expected invalid_tool_response error, got %#v", resp.Error)
}
}
func TestToolBridge_Bad_InvalidRequestBody(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: "files",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
"required": []any{"path"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("should not run"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/file_read", bytes.NewBufferString(`{"path":123}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
}
}
func TestToolBridge_Good_ValidatesEnumValues(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "publish_item",
Description: "Publish an item",
Group: "items",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"status": map[string]any{
"type": "string",
"enum": []any{"draft", "published"},
},
},
"required": []any{"status"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("published"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"published"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestToolBridge_Bad_RejectsInvalidEnumValues(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "publish_item",
Description: "Publish an item",
Group: "items",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"status": map[string]any{
"type": "string",
"enum": []any{"draft", "published"},
},
},
"required": []any{"status"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("published"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"archived"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
}
}
func TestToolBridge_Good_ValidatesSchemaCombinators(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "route_choice",
Description: "Choose a route",
Group: "items",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"choice": map[string]any{
"oneOf": []any{
map[string]any{
"type": "string",
"allOf": []any{
map[string]any{"minLength": 2},
map[string]any{"pattern": "^[A-Z]+$"},
},
},
map[string]any{
"type": "string",
"pattern": "^A",
},
},
},
},
"required": []any{"choice"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("accepted"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/route_choice", bytes.NewBufferString(`{"choice":"BC"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestToolBridge_Bad_RejectsAmbiguousOneOfMatches(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "route_choice",
Description: "Choose a route",
Group: "items",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"choice": map[string]any{
"oneOf": []any{
map[string]any{
"type": "string",
"allOf": []any{
map[string]any{"minLength": 1},
map[string]any{"pattern": "^[A-Z]+$"},
},
},
map[string]any{
"type": "string",
"pattern": "^A",
},
},
},
},
"required": []any{"choice"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("accepted"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/route_choice", bytes.NewBufferString(`{"choice":"A"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
}
}
func TestToolBridge_Bad_RejectsAdditionalProperties(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "publish_item",
Description: "Publish an item",
Group: "items",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"status": map[string]any{"type": "string"},
},
"required": []any{"status"},
"additionalProperties": false,
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("published"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_item", bytes.NewBufferString(`{"status":"published","unexpected":true}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
}
}
func TestToolBridge_Good_EnforcesStringConstraints(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "publish_code",
Description: "Publish a code",
Group: "items",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"code": map[string]any{
"type": "string",
"minLength": 3,
"maxLength": 5,
"pattern": "^[A-Z]+$",
},
},
"required": []any{"code"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("accepted"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/publish_code", bytes.NewBufferString(`{"code":"ABC"}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestToolBridge_Bad_RejectsNumericAndCollectionConstraints(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := gin.New()
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{
Name: "quota_check",
Description: "Check quotas",
Group: "items",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"count": map[string]any{
"type": "integer",
"minimum": 1,
"maximum": 3,
},
"labels": map[string]any{
"type": "array",
"minItems": 2,
"maxItems": 4,
"items": map[string]any{
"type": "string",
},
},
"payload": map[string]any{
"type": "object",
"minProperties": 1,
"maxProperties": 2,
"additionalProperties": true,
},
},
"required": []any{"count", "labels", "payload"},
},
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("accepted"))
})
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodPost, "/tools/quota_check", bytes.NewBufferString(`{"count":0,"labels":["one"],"payload":{}}`))
engine.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for numeric/collection constraint failure, got %d", w.Code)
}
var resp api.Response[any]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false")
}
if resp.Error == nil || resp.Error.Code != "invalid_request_body" {
t.Fatalf("expected invalid_request_body error, got %#v", resp.Error)
}
}
@ -181,10 +708,13 @@ func TestToolBridge_Bad_EmptyBridge(t *testing.T) {
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
// Describe should return empty slice.
// Describe should return only the GET listing entry when no tools are registered.
descs := bridge.Describe()
if len(descs) != 0 {
t.Fatalf("expected 0 descriptions, got %d", len(descs))
if len(descs) != 1 {
t.Fatalf("expected 1 description (tool listing), got %d", len(descs))
}
if descs[0].Method != "GET" || descs[0].Path != "/" {
t.Fatalf("expected solitary description to be the tool listing, got %+v", descs[0])
}
// Tools should return empty slice.
@ -194,6 +724,125 @@ func TestToolBridge_Bad_EmptyBridge(t *testing.T) {
}
}
// TestToolBridge_Good_ListsRegisteredTools verifies that GET on the bridge's
// base path returns the catalogue of registered tools per RFC.endpoints.md —
// "GET /v1/tools List available tools".
func TestToolBridge_Good_ListsRegisteredTools(t *testing.T) {
gin.SetMode(gin.TestMode)
bridge := api.NewToolBridge("/v1/tools")
bridge.Add(api.ToolDescriptor{
Name: "file_read",
Description: "Read a file from disk",
Group: "files",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{"type": "string"},
},
},
}, func(c *gin.Context) {})
bridge.Add(api.ToolDescriptor{
Name: "metrics_query",
Description: "Query metrics data",
Group: "metrics",
}, func(c *gin.Context) {})
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/tools", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
}
var resp api.Response[[]api.ToolDescriptor]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !resp.Success {
t.Fatal("expected Success=true for tool listing")
}
if len(resp.Data) != 2 {
t.Fatalf("expected 2 tool descriptors, got %d", len(resp.Data))
}
if resp.Data[0].Name != "file_read" {
t.Fatalf("expected Data[0].Name=%q, got %q", "file_read", resp.Data[0].Name)
}
if resp.Data[1].Name != "metrics_query" {
t.Fatalf("expected Data[1].Name=%q, got %q", "metrics_query", resp.Data[1].Name)
}
}
// TestToolBridge_Bad_ListingRoutesWhenEmpty verifies the listing endpoint
// still serves an empty array when no tools are registered on the bridge.
func TestToolBridge_Bad_ListingRoutesWhenEmpty(t *testing.T) {
gin.SetMode(gin.TestMode)
bridge := api.NewToolBridge("/tools")
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/tools", nil)
engine.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200 from empty listing, got %d", w.Code)
}
var resp api.Response[[]api.ToolDescriptor]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if !resp.Success {
t.Fatal("expected Success=true from empty listing")
}
if len(resp.Data) != 0 {
t.Fatalf("expected empty list, got %d entries", len(resp.Data))
}
}
// TestToolBridge_Ugly_ListingCoexistsWithToolEndpoint verifies that the GET
// listing and POST /{tool_name} endpoints register on the same base path
// without colliding.
func TestToolBridge_Ugly_ListingCoexistsWithToolEndpoint(t *testing.T) {
gin.SetMode(gin.TestMode)
bridge := api.NewToolBridge("/v1/tools")
bridge.Add(api.ToolDescriptor{
Name: "ping",
Description: "Ping tool",
}, func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("pong"))
})
engine := gin.New()
rg := engine.Group(bridge.BasePath())
bridge.RegisterRoutes(rg)
// Listing still answers at the base path.
listReq, _ := http.NewRequest(http.MethodGet, "/v1/tools", nil)
listW := httptest.NewRecorder()
engine.ServeHTTP(listW, listReq)
if listW.Code != http.StatusOK {
t.Fatalf("expected 200 from listing, got %d", listW.Code)
}
// Tool dispatch still answers at POST {basePath}/{name}.
toolReq, _ := http.NewRequest(http.MethodPost, "/v1/tools/ping", nil)
toolW := httptest.NewRecorder()
engine.ServeHTTP(toolW, toolReq)
if toolW.Code != http.StatusOK {
t.Fatalf("expected 200 from tool dispatch, got %d", toolW.Code)
}
}
func TestToolBridge_Good_IntegrationWithEngine(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New()

View file

@ -6,9 +6,10 @@ import (
"io"
"net/http"
"strconv"
"strings"
"sync"
core "dappco.re/go/core"
"github.com/andybalholm/brotli"
"github.com/gin-gonic/gin"
)
@ -47,7 +48,7 @@ func newBrotliHandler(level int) *brotliHandler {
// Handle is the Gin middleware function that compresses responses with Brotli.
func (h *brotliHandler) Handle(c *gin.Context) {
if !strings.Contains(c.Request.Header.Get("Accept-Encoding"), "br") {
if !core.Contains(c.Request.Header.Get("Accept-Encoding"), "br") {
c.Next()
return
}

188
cache.go
View file

@ -4,8 +4,10 @@ package api
import (
"bytes"
"container/list"
"maps"
"net/http"
"strconv"
"sync"
"time"
@ -17,6 +19,7 @@ type cacheEntry struct {
status int
headers http.Header
body []byte
size int
expires time.Time
}
@ -24,39 +27,147 @@ type cacheEntry struct {
type cacheStore struct {
mu sync.RWMutex
entries map[string]*cacheEntry
order *list.List
index map[string]*list.Element
maxEntries int
maxBytes int
currentBytes int
}
// newCacheStore creates an empty cache store.
func newCacheStore() *cacheStore {
// At least one of maxEntries or maxBytes must be positive; if both are
// non-positive the store would be unbounded and newCacheStore returns nil so
// callers can skip registering the middleware.
func newCacheStore(maxEntries, maxBytes int) *cacheStore {
if maxEntries <= 0 && maxBytes <= 0 {
return nil
}
return &cacheStore{
entries: make(map[string]*cacheEntry),
order: list.New(),
index: make(map[string]*list.Element),
maxEntries: maxEntries,
maxBytes: maxBytes,
}
}
// get retrieves a non-expired entry for the given key.
// Returns nil if the key is missing or expired.
func (s *cacheStore) get(key string) *cacheEntry {
s.mu.RLock()
s.mu.Lock()
entry, ok := s.entries[key]
s.mu.RUnlock()
if !ok {
s.mu.Unlock()
return nil
}
// Check expiry before promoting in the LRU order so we never move a stale
// entry to the front. All expiry checking and eviction happen inside the
// same critical section to avoid a TOCTOU race.
if time.Now().After(entry.expires) {
s.mu.Lock()
if elem, exists := s.index[key]; exists {
s.order.Remove(elem)
delete(s.index, key)
}
s.currentBytes -= entry.size
if s.currentBytes < 0 {
s.currentBytes = 0
}
delete(s.entries, key)
s.mu.Unlock()
return nil
}
// Only promote to LRU front after confirming the entry is still valid.
if elem, exists := s.index[key]; exists {
s.order.MoveToFront(elem)
}
s.mu.Unlock()
return entry
}
// set stores a cache entry with the given TTL.
func (s *cacheStore) set(key string, entry *cacheEntry) {
s.mu.Lock()
s.entries[key] = entry
if entry.size <= 0 {
entry.size = cacheEntrySize(entry.headers, entry.body)
}
if elem, ok := s.index[key]; ok {
// Reject an oversized replacement before touching LRU state so the
// existing entry remains intact when the new value cannot fit.
if s.maxBytes > 0 && entry.size > s.maxBytes {
s.mu.Unlock()
return
}
if existing, exists := s.entries[key]; exists {
s.currentBytes -= existing.size
if s.currentBytes < 0 {
s.currentBytes = 0
}
}
s.order.MoveToFront(elem)
s.entries[key] = entry
s.currentBytes += entry.size
s.evictBySizeLocked()
s.mu.Unlock()
return
}
if s.maxBytes > 0 && entry.size > s.maxBytes {
s.mu.Unlock()
return
}
for (s.maxEntries > 0 && len(s.entries) >= s.maxEntries) || s.wouldExceedBytesLocked(entry.size) {
if !s.evictOldestLocked() {
break
}
}
if s.maxBytes > 0 && s.wouldExceedBytesLocked(entry.size) {
s.mu.Unlock()
return
}
s.entries[key] = entry
elem := s.order.PushFront(key)
s.index[key] = elem
s.currentBytes += entry.size
s.mu.Unlock()
}
func (s *cacheStore) wouldExceedBytesLocked(nextSize int) bool {
if s.maxBytes <= 0 {
return false
}
return s.currentBytes+nextSize > s.maxBytes
}
func (s *cacheStore) evictBySizeLocked() {
for s.maxBytes > 0 && s.currentBytes > s.maxBytes {
if !s.evictOldestLocked() {
return
}
}
}
func (s *cacheStore) evictOldestLocked() bool {
back := s.order.Back()
if back == nil {
return false
}
oldKey := back.Value.(string)
if existing, ok := s.entries[oldKey]; ok {
s.currentBytes -= existing.size
if s.currentBytes < 0 {
s.currentBytes = 0
}
}
delete(s.entries, oldKey)
delete(s.index, oldKey)
s.order.Remove(back)
return true
}
// cacheWriter intercepts writes to capture the response body and status.
@ -89,14 +200,51 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
// Serve from cache if a valid entry exists.
if entry := store.get(key); entry != nil {
for k, vals := range entry.headers {
for _, v := range vals {
c.Writer.Header().Set(k, v)
body := entry.body
metaRewritten := false
if meta := GetRequestMeta(c); meta != nil {
body = refreshCachedResponseMeta(entry.body, meta)
metaRewritten = true
}
// staleValidatorHeader returns true for headers that describe the
// exact bytes of the cached body and must be dropped when the body
// has been rewritten by refreshCachedResponseMeta.
staleValidatorHeader := func(canonical string) bool {
if !metaRewritten {
return false
}
switch canonical {
case "Etag", "Content-Md5", "Digest":
return true
}
return false
}
for k, vals := range entry.headers {
canonical := http.CanonicalHeaderKey(k)
if canonical == "X-Request-Id" {
continue
}
if canonical == "Content-Length" {
continue
}
if staleValidatorHeader(canonical) {
continue
}
for _, v := range vals {
c.Writer.Header().Add(k, v)
}
}
if requestID := GetRequestID(c); requestID != "" {
c.Writer.Header().Set("X-Request-ID", requestID)
} else if requestID := c.GetHeader("X-Request-ID"); requestID != "" {
c.Writer.Header().Set("X-Request-ID", requestID)
}
c.Writer.Header().Set("X-Cache", "HIT")
c.Writer.Header().Set("Content-Length", strconv.Itoa(len(body)))
c.Writer.WriteHeader(entry.status)
_, _ = c.Writer.Write(entry.body)
_, _ = c.Writer.Write(body)
c.Abort()
return
}
@ -119,8 +267,28 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
status: status,
headers: headers,
body: cw.body.Bytes(),
size: cacheEntrySize(headers, cw.body.Bytes()),
expires: time.Now().Add(ttl),
})
}
}
}
// refreshCachedResponseMeta updates the meta envelope in a cached JSON body so
// request-scoped metadata reflects the current request instead of the cache fill.
// Non-JSON bodies, malformed JSON, and responses without a top-level object are
// returned unchanged.
func refreshCachedResponseMeta(body []byte, meta *Meta) []byte {
return refreshResponseMetaBody(body, meta)
}
func cacheEntrySize(headers http.Header, body []byte) int {
size := len(body)
for key, vals := range headers {
size += len(key)
for _, val := range vals {
size += len(val)
}
}
return size
}

43
cache_config.go Normal file
View file

@ -0,0 +1,43 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import "time"
// CacheConfig captures the configured response cache settings for an Engine.
//
// It is intentionally small and serialisable so callers can inspect the active
// cache policy without needing to rebuild middleware state.
//
// Example:
//
// cfg := api.CacheConfig{Enabled: true, TTL: 5 * time.Minute}
type CacheConfig struct {
Enabled bool
TTL time.Duration
MaxEntries int
MaxBytes int
}
// CacheConfig returns the currently configured response cache settings for the engine.
//
// The result snapshots the Engine state at call time.
//
// Example:
//
// cfg := engine.CacheConfig()
func (e *Engine) CacheConfig() CacheConfig {
if e == nil {
return CacheConfig{}
}
cfg := CacheConfig{
TTL: e.cacheTTL,
MaxEntries: e.cacheMaxEntries,
MaxBytes: e.cacheMaxBytes,
}
if e.cacheTTL > 0 {
cfg.Enabled = true
}
return cfg
}

View file

@ -40,6 +40,23 @@ func (g *cacheCounterGroup) RegisterRoutes(rg *gin.RouterGroup) {
})
}
type cacheSizedGroup struct {
counter atomic.Int64
}
func (g *cacheSizedGroup) Name() string { return "cache-sized" }
func (g *cacheSizedGroup) BasePath() string { return "/cache" }
func (g *cacheSizedGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/small", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("small-%d-%s", n, strings.Repeat("a", 96))))
})
rg.GET("/large", func(c *gin.Context) {
n := g.counter.Add(1)
c.JSON(http.StatusOK, api.OK(fmt.Sprintf("large-%d-%s", n, strings.Repeat("b", 96))))
})
}
// ── WithCache ───────────────────────────────────────────────────────────
func TestWithCache_Good_CachesGETResponse(t *testing.T) {
@ -89,6 +106,36 @@ func TestWithCache_Good_CachesGETResponse(t *testing.T) {
}
}
func TestWithCacheLimits_Good_CachesGETResponse(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCacheLimits(5*time.Second, 1, 0))
e.Register(grp)
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w1.Code)
}
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)
}
if got := w2.Header().Get("X-Cache"); got != "HIT" {
t.Fatalf("expected X-Cache=HIT, got %q", got)
}
if grp.counter.Load() != 1 {
t.Fatalf("expected counter=1 (cached), got %d", grp.counter.Load())
}
}
func TestWithCache_Good_POSTNotCached(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
@ -214,6 +261,189 @@ func TestWithCache_Good_CombinesWithOtherMiddleware(t *testing.T) {
}
}
func TestWithCache_Good_PreservesCurrentRequestIDOnHit(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(
api.WithRequestID(),
api.WithCache(5*time.Second),
)
e.Register(grp)
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
req1.Header.Set("X-Request-ID", "first-request-id")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w1.Code)
}
if got := w1.Header().Get("X-Request-ID"); 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")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w2.Code)
}
if got := w2.Header().Get("X-Request-ID"); 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" {
t.Fatalf("expected X-Cache=HIT, got %q", got)
}
var resp2 api.Response[string]
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp2.Data != "call-1" {
t.Fatalf("expected cached response data %q, got %q", "call-1", resp2.Data)
}
if resp2.Meta == nil {
t.Fatal("expected cached response meta to be attached")
}
if resp2.Meta.RequestID != "second-request-id" {
t.Fatalf("expected cached response request_id=%q, got %q", "second-request-id", resp2.Meta.RequestID)
}
if resp2.Meta.Duration == "" {
t.Fatal("expected cached response duration to be refreshed")
}
}
func TestWithCache_Good_PreservesCurrentRequestMetaOnHit(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithRequestID(),
api.WithCache(5*time.Second),
)
e.Register(requestMetaTestGroup{})
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
req1.Header.Set("X-Request-ID", "first-request-id")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w1.Code)
}
var resp1 api.Response[string]
if err := json.Unmarshal(w1.Body.Bytes(), &resp1); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp1.Meta == nil {
t.Fatal("expected meta on first response")
}
if resp1.Meta.RequestID != "first-request-id" {
t.Fatalf("expected first response request_id=%q, got %q", "first-request-id", resp1.Meta.RequestID)
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
req2.Header.Set("X-Request-ID", "second-request-id")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w2.Code)
}
var resp2 api.Response[string]
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp2.Meta == nil {
t.Fatal("expected meta on cached response")
}
if resp2.Meta.RequestID != "second-request-id" {
t.Fatalf("expected cached response request_id=%q, got %q", "second-request-id", resp2.Meta.RequestID)
}
if resp2.Meta.Duration == "" {
t.Fatal("expected cached response duration to be refreshed")
}
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" {
t.Fatalf("expected response header X-Request-ID=%q, got %q", "second-request-id", got)
}
}
type cacheHeaderGroup struct{}
func (cacheHeaderGroup) Name() string { return "cache-headers" }
func (cacheHeaderGroup) BasePath() string { return "/cache" }
func (cacheHeaderGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/multi", func(c *gin.Context) {
c.Writer.Header().Add("Link", "</next?page=2>; rel=\"next\"")
c.Writer.Header().Add("Link", "</prev?page=0>; rel=\"prev\"")
c.JSON(http.StatusOK, api.OK("cached"))
})
}
func TestWithCache_Good_PreservesMultiValueHeadersOnHit(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithCache(5 * time.Second))
e.Register(cacheHeaderGroup{})
h := e.Handler()
w1 := httptest.NewRecorder()
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)
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/multi", nil)
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected 200 on cache hit, got %d", w2.Code)
}
linkHeaders := w2.Header().Values("Link")
if len(linkHeaders) != 2 {
t.Fatalf("expected 2 Link headers on cache hit, got %v", linkHeaders)
}
if linkHeaders[0] != "</next?page=2>; rel=\"next\"" {
t.Fatalf("expected first Link header to be preserved, got %q", linkHeaders[0])
}
if linkHeaders[1] != "</prev?page=0>; rel=\"prev\"" {
t.Fatalf("expected second Link header to be preserved, got %q", linkHeaders[1])
}
}
func TestWithCache_Ugly_NonPositiveTTLDisablesMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(0))
e.Register(grp)
h := e.Handler()
for i := 0; i < 2; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w, req)
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 != "" {
t.Fatalf("expected no X-Cache header with disabled cache, got %q", got)
}
}
if grp.counter.Load() != 2 {
t.Fatalf("expected counter=2 with disabled cache, got %d", grp.counter.Load())
}
}
func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
@ -250,3 +480,75 @@ func TestWithCache_Good_ExpiredCacheMisses(t *testing.T) {
t.Fatalf("expected counter=2, got %d", grp.counter.Load())
}
}
func TestWithCache_Good_EvictsWhenCapacityReached(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheCounterGroup{}
e, _ := api.New(api.WithCache(5*time.Second, 1))
e.Register(grp)
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w1, req1)
if !strings.Contains(w1.Body.String(), "call-1") {
t.Fatalf("expected first response to contain %q, got %q", "call-1", w1.Body.String())
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/other", nil)
h.ServeHTTP(w2, req2)
if !strings.Contains(w2.Body.String(), "other-2") {
t.Fatalf("expected second response to contain %q, got %q", "other-2", w2.Body.String())
}
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest(http.MethodGet, "/cache/counter", nil)
h.ServeHTTP(w3, req3)
if !strings.Contains(w3.Body.String(), "call-3") {
t.Fatalf("expected evicted response to contain %q, got %q", "call-3", w3.Body.String())
}
if grp.counter.Load() != 3 {
t.Fatalf("expected counter=3 after eviction, got %d", grp.counter.Load())
}
}
func TestWithCache_Good_EvictsWhenSizeLimitReached(t *testing.T) {
gin.SetMode(gin.TestMode)
grp := &cacheSizedGroup{}
e, _ := api.New(api.WithCacheLimits(5*time.Second, 10, 250))
e.Register(grp)
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/cache/small", nil)
h.ServeHTTP(w1, req1)
if !strings.Contains(w1.Body.String(), "small-1") {
t.Fatalf("expected first response to contain %q, got %q", "small-1", w1.Body.String())
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/cache/large", nil)
h.ServeHTTP(w2, req2)
if !strings.Contains(w2.Body.String(), "large-2") {
t.Fatalf("expected second response to contain %q, got %q", "large-2", w2.Body.String())
}
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest(http.MethodGet, "/cache/small", nil)
h.ServeHTTP(w3, req3)
if !strings.Contains(w3.Body.String(), "small-3") {
t.Fatalf("expected size-limited cache to evict the oldest entry, got %q", w3.Body.String())
}
if got := w3.Header().Get("X-Cache"); got != "" {
t.Fatalf("expected re-executed response to miss the cache, got X-Cache=%q", got)
}
if grp.counter.Load() != 3 {
t.Fatalf("expected counter=3 after size-based eviction, got %d", grp.counter.Load())
}
}

908
chat_completions.go Normal file
View file

@ -0,0 +1,908 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"unicode"
"dappco.re/go/core"
inference "dappco.re/go/core/inference"
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
)
const defaultChatCompletionsPath = "/v1/chat/completions"
const (
chatDefaultTemperature = 1.0
chatDefaultTopP = 0.95
chatDefaultTopK = 64
chatDefaultMaxTokens = 2048
)
const channelMarker = "<|channel>"
// ChatCompletionRequest is the OpenAI-compatible request body.
//
// body := ChatCompletionRequest{
// Model: "lemer",
// Messages: []ChatMessage{{Role: "user", Content: "What is 2+2?"}},
// Stream: true,
// }
type ChatCompletionRequest struct {
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
Temperature *float32 `json:"temperature,omitempty"`
TopP *float32 `json:"top_p,omitempty"`
TopK *int `json:"top_k,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Stream bool `json:"stream,omitempty"`
Stop []string `json:"stop,omitempty"`
User string `json:"user,omitempty"`
}
// ChatMessage is a single turn in a conversation.
//
// msg := ChatMessage{Role: "user", Content: "Hello"}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatCompletionResponse is the OpenAI-compatible response body.
//
// resp.Choices[0].Message.Content // "4"
type ChatCompletionResponse struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChoice `json:"choices"`
Usage ChatUsage `json:"usage"`
Thought *string `json:"thought,omitempty"`
}
// ChatChoice is a single response option.
//
// choice.Message.Content // The generated text
// choice.FinishReason // "stop", "length", or "error"
type ChatChoice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}
// ChatUsage reports token consumption for the request.
//
// usage.TotalTokens // PromptTokens + CompletionTokens
type ChatUsage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ChatCompletionChunk is a single SSE chunk during streaming.
//
// chunk.Choices[0].Delta.Content // Partial token text
type ChatCompletionChunk struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChunkChoice `json:"choices"`
Thought *string `json:"thought,omitempty"`
}
// ChatChunkChoice is a streaming delta.
//
// delta.Content // New token(s) in this chunk
type ChatChunkChoice struct {
Index int `json:"index"`
Delta ChatMessageDelta `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// ChatMessageDelta is the incremental content within a streaming chunk.
//
// delta.Content // "" on first chunk (role-only), then token text
type ChatMessageDelta struct {
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
}
type chatCompletionError struct {
Message string `json:"message"`
Type string `json:"type"`
Param string `json:"param,omitempty"`
Code string `json:"code"`
}
type chatCompletionErrorResponse struct {
Error chatCompletionError `json:"error"`
}
type modelResolutionError struct {
code string
param string
msg string
}
func (e *modelResolutionError) Error() string {
if e == nil {
return ""
}
return e.msg
}
// ModelResolver resolves model names to loaded inference.TextModel instances.
//
// Resolution order:
//
// 1. Exact cache hit
// 2. ~/.core/models.yaml path mapping
// 3. discovery by architecture via inference.Discover()
type ModelResolver struct {
mu sync.RWMutex
loadedByName map[string]inference.TextModel
loadedByPath map[string]inference.TextModel
discovery map[string]string
}
// NewModelResolver constructs a ModelResolver with empty caches. The returned
// resolver is safe for concurrent use — ResolveModel serialises cache updates
// through an internal sync.RWMutex.
//
// resolver := api.NewModelResolver()
// engine, _ := api.New(api.WithChatCompletions(resolver))
func NewModelResolver() *ModelResolver {
return &ModelResolver{
loadedByName: make(map[string]inference.TextModel),
loadedByPath: make(map[string]inference.TextModel),
discovery: make(map[string]string),
}
}
// ResolveModel maps a model name to a loaded inference.TextModel.
// Cached models are reused. Unknown names return an error.
func (r *ModelResolver) ResolveModel(name string) (inference.TextModel, error) {
if r == nil {
return nil, &modelResolutionError{
code: "model_not_found",
param: "model",
msg: "model resolver is not configured",
}
}
requested := core.Lower(strings.TrimSpace(name))
if requested == "" {
return nil, &modelResolutionError{
code: "invalid_request_error",
param: "model",
msg: "model is required",
}
}
r.mu.RLock()
if cached, ok := r.loadedByName[requested]; ok {
r.mu.RUnlock()
return cached, nil
}
r.mu.RUnlock()
if path, ok := r.lookupModelPath(requested); ok {
return r.loadByPath(requested, path)
}
if path, ok := r.resolveDiscoveredPath(requested); ok {
return r.loadByPath(requested, path)
}
return nil, &modelResolutionError{
code: "model_not_found",
param: "model",
msg: fmt.Sprintf("model %q not found", requested),
}
}
func (r *ModelResolver) loadByPath(name, path string) (inference.TextModel, error) {
cleanPath := core.Path(path)
r.mu.Lock()
if cached, ok := r.loadedByPath[cleanPath]; ok {
r.loadedByName[name] = cached
r.mu.Unlock()
return cached, nil
}
r.mu.Unlock()
loaded, err := inference.LoadModel(cleanPath)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "loading") {
return nil, &modelResolutionError{
code: "model_loading",
param: "model",
msg: err.Error(),
}
}
return nil, &modelResolutionError{
code: "model_not_found",
param: "model",
msg: err.Error(),
}
}
r.mu.Lock()
r.loadedByName[name] = loaded
r.loadedByPath[cleanPath] = loaded
r.mu.Unlock()
return loaded, nil
}
func (r *ModelResolver) lookupModelPath(name string) (string, bool) {
mappings, ok := r.modelsYAMLMapping()
if !ok {
return "", false
}
if path, ok := mappings[name]; ok && strings.TrimSpace(path) != "" {
return path, true
}
return "", false
}
func (r *ModelResolver) modelsYAMLMapping() (map[string]string, bool) {
configPath := core.Path(core.Env("DIR_HOME"), ".core", "models.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
return nil, false
}
var content any
if err := yaml.Unmarshal(data, &content); err != nil {
return nil, false
}
root, ok := content.(map[string]any)
if !ok || root == nil {
return nil, false
}
normalized := make(map[string]string)
if models, ok := root["models"].(map[string]any); ok && models != nil {
for key, raw := range models {
if value, ok := raw.(string); ok {
normalized[core.Lower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
}
}
for key, raw := range root {
value, ok := raw.(string)
if !ok {
continue
}
normalized[core.Lower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
if len(normalized) == 0 {
return nil, false
}
return normalized, true
}
func (r *ModelResolver) resolveDiscoveredPath(name string) (string, bool) {
candidates := []string{name}
if n := strings.IndexRune(name, ':'); n > 0 {
candidates = append(candidates, name[:n])
}
r.mu.RLock()
for _, candidate := range candidates {
if path, ok := r.discovery[candidate]; ok {
r.mu.RUnlock()
return path, true
}
}
r.mu.RUnlock()
base := core.Path(core.Env("DIR_HOME"), ".core", "models")
var discovered string
for _, m := range discoveryModels(base) {
modelType := strings.ToLower(strings.TrimSpace(m.ModelType))
for _, candidate := range candidates {
if candidate != "" && candidate == modelType {
discovered = m.Path
break
}
}
if discovered != "" {
break
}
}
if discovered == "" {
return "", false
}
r.mu.Lock()
for _, candidate := range candidates {
if candidate != "" {
r.discovery[candidate] = discovered
}
}
r.mu.Unlock()
return discovered, true
}
type discoveredModel struct {
Path string
ModelType string
}
// discoveryModels enumerates locally discovered models under base and
// returns Path + ModelType pairs for name resolution.
//
// for _, m := range discoveryModels(base) {
// _ = m.Path
// }
func discoveryModels(base string) []discoveredModel {
var out []discoveredModel
for m := range inference.Discover(base) {
if m.Path == "" || m.ModelType == "" {
continue
}
out = append(out, discoveredModel{Path: m.Path, ModelType: m.ModelType})
}
return out
}
// ThinkingExtractor separates thinking channel content from response text.
// Applied as a post-processing step on the token stream.
//
// extractor := NewThinkingExtractor()
// for tok := range model.Chat(ctx, messages) {
// extractor.Process(tok)
// }
// response := extractor.Content() // User-facing text
// thinking := extractor.Thinking() // Internal reasoning (may be nil)
type ThinkingExtractor struct {
currentChannel string
content strings.Builder
thought strings.Builder
}
// NewThinkingExtractor constructs a ThinkingExtractor that starts on the
// "assistant" channel. Tokens are routed to Content() until a
// "<|channel>thought" marker switches the stream to the thinking channel (and
// similarly back).
//
// extractor := api.NewThinkingExtractor()
func NewThinkingExtractor() *ThinkingExtractor {
return &ThinkingExtractor{
currentChannel: "assistant",
}
}
// Process feeds a single generated token into the extractor. Tokens are
// appended to the current channel buffer (content or thought), switching on
// the "<|channel>NAME" marker. Non-streaming handlers call Process in a loop
// and then read Content and Thinking when generation completes.
//
// for tok := range model.Chat(ctx, messages) {
// extractor.Process(tok)
// }
func (te *ThinkingExtractor) Process(token inference.Token) {
te.writeDeltas(token.Text)
}
// Content returns all text accumulated on the user-facing "assistant" channel
// so far. Safe to call on a nil receiver (returns "").
//
// text := extractor.Content()
func (te *ThinkingExtractor) Content() string {
if te == nil {
return ""
}
return te.content.String()
}
// Thinking returns all text accumulated on the internal "thought" channel so
// far or nil when no thinking tokens were produced. Safe to call on a nil
// receiver.
//
// if thinking := extractor.Thinking(); thinking != nil {
// response.Thought = thinking
// }
func (te *ThinkingExtractor) Thinking() *string {
if te == nil {
return nil
}
if te.thought.Len() == 0 {
return nil
}
out := te.thought.String()
return &out
}
// writeDeltas tokenises text into the current channel, switching channels
// whenever it encounters the "<|channel>NAME" marker. It returns the content
// and thought fragments that were added to the builders during this call so
// streaming handlers can emit only the new bytes to the wire.
//
// contentDelta, thoughtDelta := extractor.writeDeltas(tok.Text)
func (te *ThinkingExtractor) writeDeltas(text string) (string, string) {
if te == nil {
return "", ""
}
beforeContentLen := te.content.Len()
beforeThoughtLen := te.thought.Len()
remaining := text
for {
next := strings.Index(remaining, channelMarker)
if next < 0 {
te.writeToCurrentChannel(remaining)
break
}
te.writeToCurrentChannel(remaining[:next])
remaining = remaining[next+len(channelMarker):]
remaining = strings.TrimLeftFunc(remaining, unicode.IsSpace)
if remaining == "" {
break
}
chanName, consumed := parseChannelName(remaining)
if consumed <= 0 {
te.writeToCurrentChannel(channelMarker)
if remaining != "" {
te.writeToCurrentChannel(remaining)
}
break
}
if chanName == "" {
te.writeToCurrentChannel(channelMarker)
} else {
te.currentChannel = chanName
}
remaining = remaining[consumed:]
}
return te.content.String()[beforeContentLen:], te.thought.String()[beforeThoughtLen:]
}
func (te *ThinkingExtractor) writeToCurrentChannel(text string) {
if text == "" {
return
}
if te.currentChannel == "thought" {
te.thought.WriteString(text)
return
}
te.content.WriteString(text)
}
func parseChannelName(s string) (string, int) {
if s == "" {
return "", 0
}
count := 0
for i := 0; i < len(s); i++ {
c := s[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' {
count++
continue
}
break
}
if count == 0 {
return "", 0
}
return strings.ToLower(s[:count]), count
}
type chatCompletionsHandler struct {
resolver *ModelResolver
}
func newChatCompletionsHandler(resolver *ModelResolver) *chatCompletionsHandler {
return &chatCompletionsHandler{
resolver: resolver,
}
}
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")
return
}
var req ChatCompletionRequest
if err := decodeJSONBody(c.Request.Body, &req); err != nil {
writeChatCompletionError(c, 400, "invalid_request_error", "body", "invalid request body", "")
return
}
if err := validateChatRequest(&req); err != nil {
chatErr, ok := err.(*chatCompletionRequestError)
if !ok {
writeChatCompletionError(c, http.StatusBadRequest, "invalid_request_error", "body", err.Error(), "")
return
}
writeChatCompletionError(c, chatErr.Status, chatErr.Type, chatErr.Param, chatErr.Message, chatErr.Code)
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
}
options, err := chatRequestOptions(&req)
if err != nil {
writeChatCompletionError(c, 400, "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, options...)
return
}
h.serveNonStreaming(c, model, req, messages, options...)
}
func (h *chatCompletionsHandler) serveNonStreaming(c *gin.Context, model inference.TextModel, req ChatCompletionRequest, messages []inference.Message, opts ...inference.GenerateOption) {
ctx := c.Request.Context()
created := time.Now().Unix()
completionID := newChatCompletionID()
extractor := NewThinkingExtractor()
for tok := range model.Chat(ctx, messages, opts...) {
extractor.Process(tok)
}
if err := model.Err(); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "loading") {
writeChatCompletionError(c, http.StatusServiceUnavailable, "model_loading", "model", err.Error(), "")
return
}
writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "")
return
}
metrics := model.Metrics()
content := extractor.Content()
finishReason := "stop"
if isTokenLengthCapReached(req.MaxTokens, metrics.GeneratedTokens) {
finishReason = "length"
}
response := ChatCompletionResponse{
ID: completionID,
Object: "chat.completion",
Created: created,
Model: req.Model,
Choices: []ChatChoice{
{
Index: 0,
Message: ChatMessage{
Role: "assistant",
Content: content,
},
FinishReason: finishReason,
},
},
Usage: ChatUsage{
PromptTokens: metrics.PromptTokens,
CompletionTokens: metrics.GeneratedTokens,
TotalTokens: metrics.PromptTokens + metrics.GeneratedTokens,
},
}
if thought := extractor.Thinking(); thought != nil {
response.Thought = thought
}
c.JSON(http.StatusOK, response)
}
func (h *chatCompletionsHandler) serveStreaming(c *gin.Context, model inference.TextModel, req ChatCompletionRequest, messages []inference.Message, opts ...inference.GenerateOption) {
ctx := c.Request.Context()
created := time.Now().Unix()
completionID := newChatCompletionID()
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Status(200)
c.Writer.Flush()
extractor := NewThinkingExtractor()
chunkFirst := true
sentAny := false
for tok := range model.Chat(ctx, messages, opts...) {
contentDelta, thoughtDelta := extractor.writeDeltas(tok.Text)
if !chunkFirst && contentDelta == "" && thoughtDelta == "" {
continue
}
delta := ChatMessageDelta{}
if chunkFirst {
delta.Role = "assistant"
}
delta.Content = contentDelta
chunk := ChatCompletionChunk{
ID: completionID,
Object: "chat.completion.chunk",
Created: created,
Model: req.Model,
Choices: []ChatChunkChoice{
{
Index: 0,
Delta: delta,
FinishReason: nil,
},
},
}
if thoughtDelta != "" {
t := thoughtDelta
chunk.Thought = &t
}
if encoded, encodeErr := json.Marshal(chunk); encodeErr == nil {
c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", encoded))
c.Writer.Flush()
sentAny = true
}
chunkFirst = false
}
if err := model.Err(); err != nil && !sentAny {
if strings.Contains(strings.ToLower(err.Error()), "loading") {
writeChatCompletionError(c, http.StatusServiceUnavailable, "model_loading", "model", err.Error(), "")
return
}
writeChatCompletionError(c, http.StatusInternalServerError, "inference_error", "model", err.Error(), "")
return
}
finishReason := "stop"
metrics := model.Metrics()
if err := model.Err(); err != nil {
finishReason = "error"
}
if finishReason != "error" && isTokenLengthCapReached(req.MaxTokens, metrics.GeneratedTokens) {
finishReason = "length"
}
finished := finishReason
finalChunk := ChatCompletionChunk{
ID: completionID,
Object: "chat.completion.chunk",
Created: created,
Model: req.Model,
Choices: []ChatChunkChoice{
{
Index: 0,
Delta: ChatMessageDelta{},
FinishReason: &finished,
},
},
}
if encoded, encodeErr := json.Marshal(finalChunk); encodeErr == nil {
c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", encoded))
}
c.Writer.WriteString("data: [DONE]\n\n")
c.Writer.Flush()
}
type chatCompletionRequestError struct {
Status int
Type string
Code string
Param string
Message string
}
func (e *chatCompletionRequestError) Error() string {
if e == nil {
return ""
}
return e.Message
}
func validateChatRequest(req *ChatCompletionRequest) error {
if strings.TrimSpace(req.Model) == "" {
return &chatCompletionRequestError{
Status: 400,
Type: "invalid_request_error",
Code: "invalid_request_error",
Param: "model",
Message: "model is required",
}
}
if len(req.Messages) == 0 {
return &chatCompletionRequestError{
Status: 400,
Type: "invalid_request_error",
Code: "invalid_request_error",
Param: "messages",
Message: "messages must be a non-empty array",
}
}
for i, msg := range req.Messages {
if strings.TrimSpace(msg.Role) == "" {
return &chatCompletionRequestError{
Status: 400,
Type: "invalid_request_error",
Code: "invalid_request_error",
Param: fmt.Sprintf("messages[%d].role", i),
Message: "message role is required",
}
}
}
return nil
}
func chatRequestOptions(req *ChatCompletionRequest) ([]inference.GenerateOption, error) {
opts := make([]inference.GenerateOption, 0, 5)
opts = append(opts, inference.WithTemperature(chatResolvedFloat(req.Temperature, chatDefaultTemperature)))
opts = append(opts, inference.WithTopP(chatResolvedFloat(req.TopP, chatDefaultTopP)))
opts = append(opts, inference.WithTopK(chatResolvedInt(req.TopK, chatDefaultTopK)))
opts = append(opts, inference.WithMaxTokens(chatResolvedInt(req.MaxTokens, chatDefaultMaxTokens)))
stops, err := parsedStopTokens(req.Stop)
if err != nil {
return nil, err
}
if len(stops) > 0 {
opts = append(opts, inference.WithStopTokens(stops...))
}
return opts, nil
}
// chatResolvedFloat honours an explicitly set float sampling parameter or
// falls back to the calibrated default when the pointer is nil.
//
// Spec §11.2: "When a parameter is omitted (nil), the server applies the
// calibrated default. When explicitly set (including 0.0), the server honours
// the caller's value."
//
// temperature := chatResolvedFloat(req.Temperature, chatDefaultTemperature)
func chatResolvedFloat(v *float32, def float32) float32 {
if v == nil {
return def
}
return *v
}
// chatResolvedInt honours an explicitly set integer sampling parameter or
// falls back to the calibrated default when the pointer is nil.
//
// topK := chatResolvedInt(req.TopK, chatDefaultTopK)
func chatResolvedInt(v *int, def int) int {
if v == nil {
return def
}
return *v
}
func parsedStopTokens(stops []string) ([]int32, error) {
if len(stops) == 0 {
return nil, nil
}
out := make([]int32, 0, len(stops))
for _, raw := range stops {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("stop entries cannot be empty")
}
parsed, err := strconv.ParseInt(raw, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid stop token %q", raw)
}
out = append(out, int32(parsed))
}
return out, nil
}
// isTokenLengthCapReached reports whether the generated token count meets or
// exceeds the caller's max_tokens budget. Nil or non-positive caps disable the
// check (streams terminate by backend signal only).
//
// if isTokenLengthCapReached(req.MaxTokens, metrics.GeneratedTokens) {
// finishReason = "length"
// }
func isTokenLengthCapReached(maxTokens *int, generated int) bool {
if maxTokens == nil || *maxTokens <= 0 {
return false
}
return generated >= *maxTokens
}
func mapResolverError(err error) (int, string, string, string) {
resErr, ok := err.(*modelResolutionError)
if !ok {
return 500, "inference_error", "inference_error", "model"
}
switch resErr.code {
case "model_loading":
return http.StatusServiceUnavailable, "model_loading", "model_loading", resErr.param
case "model_not_found":
return 404, "model_not_found", "model_not_found", resErr.param
default:
return 500, "inference_error", "inference_error", resErr.param
}
}
func writeChatCompletionError(c *gin.Context, status int, errType, param, message, code string) {
if status <= 0 {
status = http.StatusInternalServerError
}
resp := chatCompletionErrorResponse{
Error: chatCompletionError{
Message: message,
Type: errType,
Param: param,
Code: codeOrDefault(code, errType),
},
}
c.Header("Content-Type", "application/json")
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;
// we use seconds for simplicity and OpenAI parity.
c.Header("Retry-After", "10")
}
c.JSON(status, resp)
}
func codeOrDefault(code, fallback string) string {
if code != "" {
return code
}
if fallback != "" {
return fallback
}
return "inference_error"
}
func newChatCompletionID() string {
return fmt.Sprintf("chatcmpl-%d-%06d", time.Now().Unix(), rand.Intn(1_000_000))
}
func decodeJSONBody(reader io.Reader, dest any) error {
decoder := json.NewDecoder(reader)
decoder.DisallowUnknownFields()
return decoder.Decode(dest)
}

View file

@ -0,0 +1,218 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"testing"
inference "dappco.re/go/core/inference"
)
// TestChatCompletions_chatResolvedFloat_Good_ReturnsDefaultWhenNil verifies the
// calibrated default wins when the caller omits the parameter (pointer is nil).
//
// Spec §11.2 — "When a parameter is omitted (nil), the server applies the
// calibrated default."
func TestChatCompletions_chatResolvedFloat_Good_ReturnsDefaultWhenNil(t *testing.T) {
got := chatResolvedFloat(nil, chatDefaultTemperature)
if got != chatDefaultTemperature {
t.Fatalf("expected default %v, got %v", chatDefaultTemperature, got)
}
}
// TestChatCompletions_chatResolvedFloat_Good_HonoursExplicitZero verifies that
// an explicitly-set zero value overrides the default.
//
// Spec §11.2 — "When explicitly set (including 0.0), the server honours the
// caller's value."
func TestChatCompletions_chatResolvedFloat_Good_HonoursExplicitZero(t *testing.T) {
zero := float32(0.0)
got := chatResolvedFloat(&zero, chatDefaultTemperature)
if got != 0.0 {
t.Fatalf("expected explicit 0.0 to be honoured, got %v", got)
}
}
// TestChatCompletions_chatResolvedInt_Good_ReturnsDefaultWhenNil mirrors the
// float variant for integer sampling parameters (top_k, max_tokens).
func TestChatCompletions_chatResolvedInt_Good_ReturnsDefaultWhenNil(t *testing.T) {
got := chatResolvedInt(nil, chatDefaultTopK)
if got != chatDefaultTopK {
t.Fatalf("expected default %d, got %d", chatDefaultTopK, got)
}
}
// TestChatCompletions_chatResolvedInt_Good_HonoursExplicitZero verifies that
// an explicitly-set zero integer overrides the default.
func TestChatCompletions_chatResolvedInt_Good_HonoursExplicitZero(t *testing.T) {
zero := 0
got := chatResolvedInt(&zero, chatDefaultTopK)
if got != 0 {
t.Fatalf("expected explicit 0 to be honoured, got %d", got)
}
}
// TestChatCompletions_chatRequestOptions_Good_AppliesGemmaDefaults verifies
// that an otherwise-empty request produces the Gemma 4 calibrated sampling
// defaults documented in RFC §11.2.
//
// temperature 1.0, top_p 0.95, top_k 64, max_tokens 2048
func TestChatCompletions_chatRequestOptions_Good_AppliesGemmaDefaults(t *testing.T) {
req := &ChatCompletionRequest{Model: "lemer"}
opts, err := chatRequestOptions(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(opts) == 0 {
t.Fatal("expected at least one inference option for defaults")
}
cfg := inference.ApplyGenerateOpts(opts)
if cfg.Temperature != chatDefaultTemperature {
t.Fatalf("expected default temperature %v, got %v", chatDefaultTemperature, cfg.Temperature)
}
if cfg.TopP != chatDefaultTopP {
t.Fatalf("expected default top_p %v, got %v", chatDefaultTopP, cfg.TopP)
}
if cfg.TopK != chatDefaultTopK {
t.Fatalf("expected default top_k %d, got %d", chatDefaultTopK, cfg.TopK)
}
if cfg.MaxTokens != chatDefaultMaxTokens {
t.Fatalf("expected default max_tokens %d, got %d", chatDefaultMaxTokens, cfg.MaxTokens)
}
}
// TestChatCompletions_chatRequestOptions_Good_HonoursExplicitSampling verifies
// that caller-supplied sampling parameters (including zero for greedy decoding)
// override the Gemma 4 calibrated defaults.
func TestChatCompletions_chatRequestOptions_Good_HonoursExplicitSampling(t *testing.T) {
temp := float32(0.0)
topP := float32(0.5)
topK := 10
maxTokens := 512
req := &ChatCompletionRequest{
Model: "lemer",
Temperature: &temp,
TopP: &topP,
TopK: &topK,
MaxTokens: &maxTokens,
}
opts, err := chatRequestOptions(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := inference.ApplyGenerateOpts(opts)
if cfg.Temperature != 0.0 {
t.Fatalf("expected explicit temperature 0.0, got %v", cfg.Temperature)
}
if cfg.TopP != 0.5 {
t.Fatalf("expected explicit top_p 0.5, got %v", cfg.TopP)
}
if cfg.TopK != 10 {
t.Fatalf("expected explicit top_k 10, got %d", cfg.TopK)
}
if cfg.MaxTokens != 512 {
t.Fatalf("expected explicit max_tokens 512, got %d", cfg.MaxTokens)
}
}
// TestChatCompletions_chatRequestOptions_Bad_RejectsMalformedStop verifies the
// stop-token parser surfaces malformed values rather than silently ignoring
// them.
func TestChatCompletions_chatRequestOptions_Bad_RejectsMalformedStop(t *testing.T) {
req := &ChatCompletionRequest{
Model: "lemer",
Stop: []string{"oops"},
}
if _, err := chatRequestOptions(req); err == nil {
t.Fatal("expected malformed stop entry to produce an error")
}
}
// TestChatCompletions_chatRequestOptions_Ugly_EmptyStopEntryRejected ensures
// an all-whitespace stop entry is treated as invalid rather than as zero.
func TestChatCompletions_chatRequestOptions_Ugly_EmptyStopEntryRejected(t *testing.T) {
req := &ChatCompletionRequest{
Model: "lemer",
Stop: []string{" "},
}
if _, err := chatRequestOptions(req); err == nil {
t.Fatal("expected empty stop entry to produce an error")
}
}
// TestChatCompletions_isTokenLengthCapReached_Good_RespectsCap documents the
// finish_reason=length contract — generation that meets the caller's
// max_tokens budget is reported as length-capped.
func TestChatCompletions_isTokenLengthCapReached_Good_RespectsCap(t *testing.T) {
cap := 10
if !isTokenLengthCapReached(&cap, 10) {
t.Fatal("expected cap to be reached when generated == max_tokens")
}
if !isTokenLengthCapReached(&cap, 20) {
t.Fatal("expected cap to be reached when generated > max_tokens")
}
if isTokenLengthCapReached(&cap, 5) {
t.Fatal("expected cap not reached when generated < max_tokens")
}
}
// TestChatCompletions_isTokenLengthCapReached_Ugly_NilOrZeroDisablesCap
// documents that the cap is disabled when max_tokens is unset or non-positive.
func TestChatCompletions_isTokenLengthCapReached_Ugly_NilOrZeroDisablesCap(t *testing.T) {
if isTokenLengthCapReached(nil, 999_999) {
t.Fatal("expected nil max_tokens to disable the cap")
}
zero := 0
if isTokenLengthCapReached(&zero, 999_999) {
t.Fatal("expected zero max_tokens to disable the cap")
}
neg := -1
if isTokenLengthCapReached(&neg, 999_999) {
t.Fatal("expected negative max_tokens to disable the cap")
}
}
// TestChatCompletions_ThinkingExtractor_Good_SeparatesThoughtFromContent
// verifies the <|channel>thought marker routes tokens to Thinking() and
// subsequent <|channel>assistant tokens land back in Content(). Covers RFC
// §11.6.
//
// The extractor skips whitespace between the marker and the channel name
// ("<|channel>thought ...") but preserves whitespace inside channel bodies —
// so "Hello " + thought block + " World" arrives as "Hello World" with
// both separating spaces retained.
func TestChatCompletions_ThinkingExtractor_Good_SeparatesThoughtFromContent(t *testing.T) {
ex := NewThinkingExtractor()
ex.Process(inference.Token{Text: "Hello"})
ex.Process(inference.Token{Text: "<|channel>thought planning... "})
ex.Process(inference.Token{Text: "<|channel>assistant World"})
content := ex.Content()
if content != "Hello World" {
t.Fatalf("expected content %q, got %q", "Hello World", content)
}
thinking := ex.Thinking()
if thinking == nil {
t.Fatal("expected thinking content to be captured")
}
if *thinking != " planning... " {
t.Fatalf("expected thinking %q, got %q", " planning... ", *thinking)
}
}
// TestChatCompletions_ThinkingExtractor_Ugly_NilReceiverIsSafe documents the
// nil-safe accessors so middleware can call them defensively.
func TestChatCompletions_ThinkingExtractor_Ugly_NilReceiverIsSafe(t *testing.T) {
var ex *ThinkingExtractor
if got := ex.Content(); got != "" {
t.Fatalf("expected empty content on nil receiver, got %q", got)
}
if got := ex.Thinking(); got != nil {
t.Fatalf("expected nil thinking on nil receiver, got %v", got)
}
}

158
chat_completions_test.go Normal file
View file

@ -0,0 +1,158 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
// TestChatCompletions_WithChatCompletions_Good verifies that WithChatCompletions
// mounts the endpoint and unknown model names produce a 404 body conforming to
// RFC §11.7.
func TestChatCompletions_WithChatCompletions_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
resolver := api.NewModelResolver()
engine, err := api.New(api.WithChatCompletions(resolver))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{
"model": "missing-model",
"messages": [{"role":"user","content":"hi"}]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d (body=%s)", rec.Code, rec.Body.String())
}
var payload struct {
Error struct {
Message string `json:"message"`
Type string `json:"type"`
Param string `json:"param"`
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("invalid JSON body: %v", err)
}
if payload.Error.Code != "model_not_found" {
t.Fatalf("expected code=model_not_found, got %q", payload.Error.Code)
}
if payload.Error.Type != "model_not_found" {
t.Fatalf("expected type=model_not_found, got %q", payload.Error.Type)
}
if payload.Error.Param != "model" {
t.Fatalf("expected param=model, got %q", payload.Error.Param)
}
}
// TestChatCompletions_WithChatCompletionsPath_Good verifies the custom mount path override.
func TestChatCompletions_WithChatCompletionsPath_Good(t *testing.T) {
gin.SetMode(gin.TestMode)
resolver := api.NewModelResolver()
engine, err := api.New(
api.WithChatCompletions(resolver),
api.WithChatCompletionsPath("/chat"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/chat", strings.NewReader(`{
"model": "missing-model",
"messages": [{"role":"user","content":"hi"}]
}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d (body=%s)", rec.Code, rec.Body.String())
}
}
// TestChatCompletions_ValidateRequest_Bad verifies that missing messages produces a 400.
func TestChatCompletions_ValidateRequest_Bad(t *testing.T) {
gin.SetMode(gin.TestMode)
resolver := api.NewModelResolver()
engine, _ := api.New(api.WithChatCompletions(resolver))
cases := []struct {
name string
body string
code string
}{
{
name: "missing-messages",
body: `{"model":"lemer"}`,
code: "invalid_request_error",
},
{
name: "missing-model",
body: `{"messages":[{"role":"user","content":"hi"}]}`,
code: "invalid_request_error",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader([]byte(tc.body)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d (body=%s)", rec.Code, rec.Body.String())
}
var payload struct {
Error struct {
Type string `json:"type"`
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("invalid JSON body: %v", err)
}
if payload.Error.Type != tc.code {
t.Fatalf("expected type=%q, got %q", tc.code, payload.Error.Type)
}
})
}
}
// TestChatCompletions_NoResolver_Ugly verifies graceful handling when an engine
// is constructed WITHOUT a resolver — no route is mounted.
func TestChatCompletions_NoResolver_Ugly(t *testing.T) {
gin.SetMode(gin.TestMode)
engine, _ := api.New()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(`{}`))
rec := httptest.NewRecorder()
engine.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected 404 when no resolver is configured, got %d", rec.Code)
}
}

1046
client.go Normal file

File diff suppressed because it is too large Load diff

963
client_test.go Normal file
View file

@ -0,0 +1,963 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"slices"
api "dappco.re/go/core/api"
)
func TestOpenAPIClient_Good_CallOperationByID(t *testing.T) {
errCh := make(chan error, 2)
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.URL.Query().Get("name"); got != "Ada" {
errCh <- fmt.Errorf("expected query name=Ada, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`))
})
mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
errCh <- fmt.Errorf("expected POST, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.URL.Query().Get("verbose"); got != "true" {
errCh <- fmt.Errorf("expected query verbose=true, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"123","name":"Ada"}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/hello:
get:
operationId: get_hello
/users/{id}:
post:
operationId: update_user
requestBody:
required: true
content:
application/json:
schema:
type: object
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
result, err := client.Call("get_hello", map[string]any{
"name": "Ada",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
hello, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if hello["message"] != "hello" {
t.Fatalf("expected message=hello, got %#v", hello["message"])
}
result, err = client.Call("update_user", map[string]any{
"path": map[string]any{
"id": "123",
},
"query": map[string]any{
"verbose": true,
},
"body": map[string]any{
"name": "Ada",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
updated, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if updated["id"] != "123" {
t.Fatalf("expected id=123, got %#v", updated["id"])
}
if updated["name"] != "Ada" {
t.Fatalf("expected name=Ada, got %#v", updated["name"])
}
}
func TestOpenAPIClient_Good_LoadsSpecFromReader(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"pong"}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := api.NewOpenAPIClient(
api.WithSpecReader(strings.NewReader(`openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/ping:
get:
operationId: ping
`)),
api.WithBaseURL(srv.URL),
)
result, err := client.Call("ping", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
ping, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if ping["message"] != "pong" {
t.Fatalf("expected message=pong, got %#v", ping["message"])
}
}
func TestOpenAPIClient_Good_ExposesOperationSnapshots(t *testing.T) {
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/users/{id}:
post:
operationId: update_user
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
`)
client := api.NewOpenAPIClient(api.WithSpec(specPath))
operations, err := client.Operations()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(operations) != 1 {
t.Fatalf("expected 1 operation, got %d", len(operations))
}
op := operations[0]
if op.OperationID != "update_user" {
t.Fatalf("expected operationId update_user, got %q", op.OperationID)
}
if op.Method != http.MethodPost {
t.Fatalf("expected method POST, got %q", op.Method)
}
if op.PathTemplate != "/users/{id}" {
t.Fatalf("expected path template /users/{id}, got %q", op.PathTemplate)
}
if !op.HasRequestBody {
t.Fatal("expected operation to report a request body")
}
if len(op.Parameters) != 1 || op.Parameters[0].Name != "id" {
t.Fatalf("expected one path parameter snapshot, got %+v", op.Parameters)
}
op.Parameters[0].Schema["type"] = "integer"
operations[0].PathTemplate = "/mutated"
again, err := client.Operations()
if err != nil {
t.Fatalf("unexpected error on re-read: %v", err)
}
if again[0].PathTemplate != "/users/{id}" {
t.Fatalf("expected snapshot to remain immutable, got %q", again[0].PathTemplate)
}
if got := again[0].Parameters[0].Schema["type"]; got != "string" {
t.Fatalf("expected cloned parameter schema, got %#v", got)
}
}
func TestOpenAPIClient_Good_ExposesServerSnapshots(t *testing.T) {
client := api.NewOpenAPIClient(api.WithSpecReader(strings.NewReader(`openapi: 3.1.0
info:
title: Test API
version: 1.0.0
servers:
- url: https://api.example.com
- url: /relative
paths: {}
`)))
servers, err := client.Servers()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !slices.Equal(servers, []string{"https://api.example.com", "/relative"}) {
t.Fatalf("expected server snapshot to preserve order, got %v", servers)
}
servers[0] = "https://mutated.example.com"
again, err := client.Servers()
if err != nil {
t.Fatalf("unexpected error on re-read: %v", err)
}
if !slices.Equal(again, []string{"https://api.example.com", "/relative"}) {
t.Fatalf("expected server snapshot to be cloned, got %v", again)
}
}
func TestOpenAPIClient_Good_IteratorsExposeSnapshots(t *testing.T) {
client := api.NewOpenAPIClient(api.WithSpecReader(strings.NewReader(`openapi: 3.1.0
info:
title: Test API
version: 1.0.0
servers:
- url: https://api.example.com
paths:
/users/{id}:
post:
operationId: update_user
parameters:
- name: id
in: path
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
type: object
`)))
operations, err := client.OperationsIter()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var operationIDs []string
for op := range operations {
operationIDs = append(operationIDs, op.OperationID)
}
if !slices.Equal(operationIDs, []string{"update_user"}) {
t.Fatalf("expected iterator to preserve operation snapshots, got %v", operationIDs)
}
servers, err := client.ServersIter()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var serverURLs []string
for server := range servers {
serverURLs = append(serverURLs, server)
}
if !slices.Equal(serverURLs, []string{"https://api.example.com"}) {
t.Fatalf("expected iterator to preserve server snapshots, got %v", serverURLs)
}
}
func TestOpenAPIClient_Good_CallHeadOperationWithRequestBody(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/head", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodHead {
errCh <- fmt.Errorf("expected HEAD, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.URL.RawQuery; got != "" {
errCh <- fmt.Errorf("expected no query string, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
errCh <- fmt.Errorf("read body: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if string(body) != `{"name":"Ada"}` {
errCh <- fmt.Errorf("expected JSON body {\"name\":\"Ada\"}, got %q", string(body))
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/head:
head:
operationId: head_check
requestBody:
required: true
content:
application/json:
schema:
type: object
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
result, err := client.Call("head_check", map[string]any{
"name": "Ada",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
if result != nil {
t.Fatalf("expected nil result for empty HEAD response body, got %T", result)
}
}
func TestOpenAPIClient_Good_CallOperationWithRepeatedQueryValues(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.URL.Query()["tag"]; len(got) != 2 || got[0] != "alpha" || got[1] != "beta" {
errCh <- fmt.Errorf("expected repeated tag values [alpha beta], got %v", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.URL.Query().Get("page"); got != "2" {
errCh <- fmt.Errorf("expected page=2, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/search:
get:
operationId: search_items
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
result, err := client.Call("search_items", map[string]any{
"tag": []string{"alpha", "beta"},
"page": 2,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
decoded, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if okValue, ok := decoded["ok"].(bool); !ok || !okValue {
t.Fatalf("expected ok=true, got %#v", decoded["ok"])
}
}
func TestOpenAPIClient_Good_UsesTopLevelQueryParametersOnPost(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/submit", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
errCh <- fmt.Errorf("expected POST, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.URL.Query().Get("verbose"); got != "true" {
errCh <- fmt.Errorf("expected query verbose=true, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
body, err := io.ReadAll(r.Body)
if err != nil {
errCh <- fmt.Errorf("read body: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if string(body) != `{"name":"Ada"}` {
errCh <- fmt.Errorf("expected JSON body {\"name\":\"Ada\"}, got %q", string(body))
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/submit:
post:
operationId: submit_item
requestBody:
required: true
content:
application/json:
schema:
type: object
parameters:
- name: verbose
in: query
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
result, err := client.Call("submit_item", map[string]any{
"verbose": true,
"name": "Ada",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
decoded, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if okValue, ok := decoded["ok"].(bool); !ok || !okValue {
t.Fatalf("expected ok=true, got %#v", decoded["ok"])
}
}
func TestOpenAPIClient_Bad_MissingRequiredQueryParameter(t *testing.T) {
called := make(chan struct{}, 1)
mux := http.NewServeMux()
mux.HandleFunc("/submit", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/submit:
post:
operationId: submit_item
parameters:
- name: verbose
in: query
required: true
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
if _, err := client.Call("submit_item", map[string]any{
"name": "Ada",
}); err == nil {
t.Fatal("expected required query parameter validation error, got nil")
}
select {
case <-called:
t.Fatal("expected validation to fail before the HTTP call")
default:
}
}
func TestOpenAPIClient_Bad_ValidatesQueryParameterAgainstSchema(t *testing.T) {
called := make(chan struct{}, 1)
mux := http.NewServeMux()
mux.HandleFunc("/search", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/search:
get:
operationId: search_items
parameters:
- name: page
in: query
schema:
type: integer
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
if _, err := client.Call("search_items", map[string]any{
"page": "two",
}); err == nil {
t.Fatal("expected query parameter validation error, got nil")
}
select {
case <-called:
t.Fatal("expected validation to fail before the HTTP call")
default:
}
}
func TestOpenAPIClient_Bad_ValidatesPathParameterAgainstSchema(t *testing.T) {
called := make(chan struct{}, 1)
mux := http.NewServeMux()
mux.HandleFunc("/users/123", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/users/{id}:
get:
operationId: get_user
parameters:
- name: id
in: path
required: true
schema:
type: integer
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
if _, err := client.Call("get_user", map[string]any{
"path": map[string]any{
"id": "abc",
},
}); err == nil {
t.Fatal("expected path parameter validation error, got nil")
}
select {
case <-called:
t.Fatal("expected validation to fail before the HTTP call")
default:
}
}
func TestOpenAPIClient_Good_UsesHeaderAndCookieParameters(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/inspect", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.Header.Get("X-Trace-ID"); got != "trace-123" {
errCh <- fmt.Errorf("expected X-Trace-ID=trace-123, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
if got := r.Header.Get("X-Custom-Header"); got != "custom-value" {
errCh <- fmt.Errorf("expected X-Custom-Header=custom-value, got %q", got)
w.WriteHeader(http.StatusInternalServerError)
return
}
session, err := r.Cookie("session_id")
if err != nil {
errCh <- fmt.Errorf("expected session_id cookie: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if session.Value != "cookie-123" {
errCh <- fmt.Errorf("expected session_id=cookie-123, got %q", session.Value)
w.WriteHeader(http.StatusInternalServerError)
return
}
pref, err := r.Cookie("pref")
if err != nil {
errCh <- fmt.Errorf("expected pref cookie: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
if pref.Value != "dark" {
errCh <- fmt.Errorf("expected pref=dark, got %q", pref.Value)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"ok":true}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/inspect:
get:
operationId: inspect_request
parameters:
- name: X-Trace-ID
in: header
- name: session_id
in: cookie
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
result, err := client.Call("inspect_request", map[string]any{
"X-Trace-ID": "trace-123",
"session_id": "cookie-123",
"header": map[string]any{
"X-Custom-Header": "custom-value",
},
"cookie": map[string]any{
"pref": "dark",
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
decoded, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if okValue, ok := decoded["ok"].(bool); !ok || !okValue {
t.Fatalf("expected ok=true, got %#v", decoded["ok"])
}
}
func TestOpenAPIClient_Good_UsesFirstAbsoluteServer(t *testing.T) {
errCh := make(chan error, 1)
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
errCh <- fmt.Errorf("expected GET, got %s", r.Method)
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"message":"hello"}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
servers:
- url: " `+srv.URL+` "
- url: /
- url: " `+srv.URL+` "
paths:
/hello:
get:
operationId: get_hello
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
)
result, err := client.Call("get_hello", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
select {
case err := <-errCh:
t.Fatal(err)
default:
}
hello, ok := result.(map[string]any)
if !ok {
t.Fatalf("expected map result, got %T", result)
}
if hello["message"] != "hello" {
t.Fatalf("expected message=hello, got %#v", hello["message"])
}
}
func TestOpenAPIClient_Bad_ValidatesRequestBodyAgainstSchema(t *testing.T) {
called := make(chan struct{}, 1)
mux := http.NewServeMux()
mux.HandleFunc("/users", func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"id":"123"}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/users:
post:
operationId: create_user
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
data:
type: object
properties:
id:
type: string
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
if _, err := client.Call("create_user", map[string]any{
"body": map[string]any{},
}); err == nil {
t.Fatal("expected request body validation error, got nil")
}
select {
case <-called:
t.Fatal("expected request validation to fail before the HTTP call")
default:
}
}
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.Write([]byte(`{"success":true,"data":{"id":123}}`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths:
/users:
get:
operationId: list_users
responses:
"200":
description: OK
content:
application/json:
schema:
type: object
required: [success, data]
properties:
success:
type: boolean
data:
type: object
required: [id]
properties:
id:
type: string
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL(srv.URL),
)
if _, err := client.Call("list_users", nil); err == nil {
t.Fatal("expected response validation error, got nil")
}
}
func TestOpenAPIClient_Bad_MissingOperation(t *testing.T) {
specPath := writeTempSpec(t, `openapi: 3.1.0
info:
title: Test API
version: 1.0.0
paths: {}
`)
client := api.NewOpenAPIClient(
api.WithSpec(specPath),
api.WithBaseURL("http://example.invalid"),
)
if _, err := client.Call("missing", nil); err == nil {
t.Fatal("expected error for missing operation, got nil")
}
}
func writeTempSpec(t *testing.T, contents string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "openapi.yaml")
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatalf("write spec: %v", err)
}
return path
}

View file

@ -1,18 +1,32 @@
// SPDX-License-Identifier: EUPL-1.2
// Package api registers the `core api` command group on the root Core
// instance. It exposes two subcommands:
//
// - api/spec — generate the OpenAPI specification from registered route
// groups plus the built-in tool bridge and write it to stdout or a file.
// - api/sdk — run openapi-generator-cli over a generated spec to produce
// client SDKs in the configured target languages.
//
// The commands use the Core framework's declarative Command API. Flags are
// declared via the Flags Options map and read from the incoming Options
// during the Action.
package api
import "forge.lthn.ai/core/cli/pkg/cli"
import (
"dappco.re/go/core"
"dappco.re/go/core/cli/pkg/cli"
)
func init() {
cli.RegisterCommands(AddAPICommands)
}
// AddAPICommands registers the 'api' command group.
func AddAPICommands(root *cli.Command) {
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
root.AddCommand(apiCmd)
addSpecCommand(apiCmd)
addSDKCommand(apiCmd)
// AddAPICommands registers the `api/spec` and `api/sdk` commands on the given
// Core instance.
//
// core.RegisterCommands(api.AddAPICommands)
func AddAPICommands(c *core.Core) {
addSpecCommand(c)
addSDKCommand(c)
}

71
cmd/api/cmd_args.go Normal file
View file

@ -0,0 +1,71 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"strings"
core "dappco.re/go/core"
)
// splitUniqueCSV trims and deduplicates a comma-separated list while
// preserving the first occurrence of each value.
func splitUniqueCSV(raw string) []string {
if raw == "" {
return nil
}
parts := core.Split(raw, ",")
values := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
value := core.Trim(part)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
return values
}
// normalisePublicPaths trims whitespace, ensures a leading slash, and removes
// duplicate entries while preserving the first occurrence of each path.
func normalisePublicPaths(paths []string) []string {
if len(paths) == 0 {
return nil
}
out := make([]string, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, path := range paths {
path = core.Trim(path)
if path == "" {
continue
}
if !core.HasPrefix(path, "/") {
path = "/" + path
}
path = strings.TrimRight(path, "/")
if path == "" {
path = "/"
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
out = append(out, path)
}
if len(out) == 0 {
return nil
}
return out
}

View file

@ -3,90 +3,111 @@
package api
import (
"context"
"fmt"
"iter"
"os"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
core "dappco.re/go/core"
"dappco.re/go/core/cli/pkg/cli"
goapi "dappco.re/go/core/api"
coreio "dappco.re/go/core/io"
)
func addSDKCommand(parent *cli.Command) {
var (
lang string
output string
specFile string
packageName string
const (
defaultSDKTitle = "Lethean Core API"
defaultSDKDescription = "Lethean Core API"
defaultSDKVersion = "1.0.0"
)
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
if lang == "" {
return coreerr.E("sdk.Generate", "--lang is required. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil)
func addSDKCommand(c *core.Core) {
c.Command("api/sdk", core.Command{
Description: "Generate client SDKs from OpenAPI spec",
Action: sdkAction,
})
}
// If no spec file provided, generate one to a temp file.
if specFile == "" {
builder := &goapi.SpecBuilder{
Title: "Lethean Core API",
Description: "Lethean Core API",
Version: "1.0.0",
func sdkAction(opts core.Options) core.Result {
lang := opts.String("lang")
output := opts.String("output")
if output == "" {
output = "./sdk"
}
specFile := opts.String("spec")
packageName := opts.String("package")
if packageName == "" {
packageName = "lethean"
}
bridge := goapi.NewToolBridge("/tools")
groups := []goapi.RouteGroup{bridge}
tmpFile, err := os.CreateTemp("", "openapi-*.json")
if err != nil {
return coreerr.E("sdk.Generate", "create temp spec file", err)
}
defer coreio.Local.Delete(tmpFile.Name())
if err := goapi.ExportSpec(tmpFile, "json", builder, groups); err != nil {
tmpFile.Close()
return coreerr.E("sdk.Generate", "generate spec", err)
}
tmpFile.Close()
specFile = tmpFile.Name()
languages := splitUniqueCSV(lang)
if len(languages) == 0 {
return core.Result{Value: cli.Err("--lang is required and must include at least one non-empty language"), OK: false}
}
gen := &goapi.SDKGenerator{
SpecPath: specFile,
OutputDir: output,
PackageName: packageName,
}
if !gen.Available() {
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil)
cli.Error("openapi-generator-cli not found. Install with:")
cli.Print(" brew install openapi-generator (macOS)")
cli.Print(" npm install @openapitools/openapi-generator-cli -g")
return core.Result{Value: cli.Err("openapi-generator-cli not installed"), OK: false}
}
// Generate for each language.
for l := range strings.SplitSeq(lang, ",") {
l = strings.TrimSpace(l)
if l == "" {
continue
resolvedSpecFile := specFile
if resolvedSpecFile == "" {
cfg := sdkConfigFromOptions(opts)
builder, err := sdkSpecBuilder(cfg)
if err != nil {
return core.Result{Value: err, OK: false}
}
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
if err := gen.Generate(context.Background(), l); err != nil {
return coreerr.E("sdk.Generate", "generate "+l, err)
groups := sdkSpecGroupsIter()
tmpFile, err := os.CreateTemp("", "openapi-*.json")
if err != nil {
return core.Result{Value: cli.Wrap(err, "create temp spec file"), OK: false}
}
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
tmpPath := tmpFile.Name()
if err := tmpFile.Close(); err != nil {
_ = coreio.Local.Delete(tmpPath)
return core.Result{Value: cli.Wrap(err, "close temp spec file"), OK: false}
}
defer coreio.Local.Delete(tmpPath)
if err := goapi.ExportSpecToFileIter(tmpPath, "json", builder, groups); err != nil {
return core.Result{Value: cli.Wrap(err, "generate spec"), OK: false}
}
resolvedSpecFile = tmpPath
}
return nil
})
gen.SpecPath = resolvedSpecFile
cli.StringFlag(cmd, &lang, "lang", "l", "", "Target language(s), comma-separated (e.g. go,python,typescript-fetch)")
cli.StringFlag(cmd, &output, "output", "o", "./sdk", "Output directory for generated SDKs")
cli.StringFlag(cmd, &specFile, "spec", "s", "", "Path to existing OpenAPI spec (generates from MCP tools if not provided)")
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
parent.AddCommand(cmd)
for _, l := range languages {
cli.Dim("Generating " + l + " SDK...")
if err := gen.Generate(cli.Context(), l); err != nil {
return core.Result{Value: cli.Wrap(err, "generate "+l), OK: false}
}
cli.Dim(" Done: " + output + "/" + l + "/")
}
return core.Result{OK: true}
}
func sdkSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) {
return newSpecBuilder(cfg)
}
func sdkSpecGroupsIter() iter.Seq[goapi.RouteGroup] {
return specGroupsIter(goapi.NewToolBridge("/tools"))
}
// sdkConfigFromOptions mirrors specConfigFromOptions but falls back to
// SDK-specific title/description/version defaults.
func sdkConfigFromOptions(opts core.Options) specBuilderConfig {
cfg := specConfigFromOptions(opts)
cfg.title = stringOr(opts.String("title"), defaultSDKTitle)
cfg.description = stringOr(opts.String("description"), defaultSDKDescription)
cfg.version = stringOr(opts.String("version"), defaultSDKVersion)
return cfg
}

98
cmd/api/cmd_sdk_test.go Normal file
View file

@ -0,0 +1,98 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"testing"
core "dappco.re/go/core"
api "dappco.re/go/core/api"
)
// TestCmdSdk_AddSDKCommand_Good verifies the sdk command registers under
// the expected api/sdk path with an executable Action.
func TestCmdSdk_AddSDKCommand_Good(t *testing.T) {
c := core.New()
addSDKCommand(c)
r := c.Command("api/sdk")
if !r.OK {
t.Fatalf("expected api/sdk command to be registered")
}
cmd, ok := r.Value.(*core.Command)
if !ok {
t.Fatalf("expected *core.Command, got %T", r.Value)
}
if cmd.Action == nil {
t.Fatal("expected non-nil Action on api/sdk")
}
if cmd.Description == "" {
t.Fatal("expected Description on api/sdk")
}
}
// TestCmdSdk_SdkAction_Bad_RequiresLanguage rejects invocations that omit
// the --lang flag entirely.
func TestCmdSdk_SdkAction_Bad_RequiresLanguage(t *testing.T) {
opts := core.NewOptions()
r := sdkAction(opts)
if r.OK {
t.Fatal("expected sdk action to fail without --lang")
}
}
// TestCmdSdk_SdkAction_Bad_EmptyLanguageList rejects --lang values that
// resolve to no real language entries after splitting and trimming.
func TestCmdSdk_SdkAction_Bad_EmptyLanguageList(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "lang", Value: " , , "},
)
r := sdkAction(opts)
if r.OK {
t.Fatal("expected sdk action to fail when --lang only contains separators")
}
}
// TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge verifies the SDK
// builder always exposes the bundled tools route group.
func TestCmdSdk_SdkSpecGroupsIter_Good_IncludesToolBridge(t *testing.T) {
snapshot := api.RegisteredSpecGroups()
api.ResetSpecGroups()
t.Cleanup(func() {
api.ResetSpecGroups()
api.RegisterSpecGroups(snapshot...)
})
groups := collectRouteGroups(sdkSpecGroupsIter())
if len(groups) == 0 {
t.Fatal("expected at least the bundled tools bridge")
}
found := false
for _, g := range groups {
if g.BasePath() == "/tools" {
found = true
break
}
}
if !found {
t.Fatal("expected /tools route group in sdk spec iterator")
}
}
// TestCmdSdk_SdkConfigFromOptions_Ugly_FallsBackToSDKDefaults exercises the
// SDK-specific default fallbacks for empty title/description/version.
func TestCmdSdk_SdkConfigFromOptions_Ugly_FallsBackToSDKDefaults(t *testing.T) {
opts := core.NewOptions()
cfg := sdkConfigFromOptions(opts)
if cfg.title != defaultSDKTitle {
t.Fatalf("expected default SDK title, got %q", cfg.title)
}
if cfg.description != defaultSDKDescription {
t.Fatalf("expected default SDK description, got %q", cfg.description)
}
if cfg.version != defaultSDKVersion {
t.Fatalf("expected default SDK version, got %q", cfg.version)
}
}

View file

@ -3,52 +3,117 @@
package api
import (
"fmt"
"encoding/json"
"os"
"forge.lthn.ai/core/cli/pkg/cli"
core "dappco.re/go/core"
"dappco.re/go/core/cli/pkg/cli"
goapi "dappco.re/go/core/api"
)
func addSpecCommand(parent *cli.Command) {
var (
output string
format string
title string
version string
)
cmd := cli.NewCommand("spec", "Generate OpenAPI specification", "", func(cmd *cli.Command, args []string) error {
// Build spec from registered route groups.
// Additional groups can be added here as the platform grows.
builder := &goapi.SpecBuilder{
Title: title,
Description: "Lethean Core API",
Version: version,
func addSpecCommand(c *core.Core) {
c.Command("api/spec", core.Command{
Description: "Generate OpenAPI specification",
Action: specAction,
})
}
func specAction(opts core.Options) core.Result {
cfg := specConfigFromOptions(opts)
output := opts.String("output")
format := opts.String("format")
if format == "" {
format = "json"
}
builder, err := newSpecBuilder(cfg)
if err != nil {
return core.Result{Value: err, OK: false}
}
// Start with the default tool bridge — future versions will
// auto-populate from the MCP tool registry once the bridge
// integration lands in the local go-ai module.
bridge := goapi.NewToolBridge("/tools")
groups := []goapi.RouteGroup{bridge}
groups := specGroupsIter(bridge)
if output != "" {
if err := goapi.ExportSpecToFile(output, format, builder, groups); err != nil {
return err
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
return core.Result{Value: cli.Wrap(err, "write spec"), OK: false}
}
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
return nil
cli.Dim("Spec written to " + output)
return core.Result{OK: true}
}
return goapi.ExportSpec(os.Stdout, format, builder, groups)
})
cli.StringFlag(cmd, &output, "output", "o", "", "Write spec to file instead of stdout")
cli.StringFlag(cmd, &format, "format", "f", "json", "Output format: json or yaml")
cli.StringFlag(cmd, &title, "title", "t", "Lethean Core API", "API title in spec")
cli.StringFlag(cmd, &version, "version", "V", "1.0.0", "API version in spec")
parent.AddCommand(cmd)
if err := goapi.ExportSpecIter(os.Stdout, format, builder, groups); err != nil {
return core.Result{Value: cli.Wrap(err, "render spec"), OK: false}
}
return core.Result{OK: true}
}
func parseServers(raw string) []string {
return splitUniqueCSV(raw)
}
func parseSecuritySchemes(raw string) (map[string]any, error) {
raw = core.Trim(raw)
if raw == "" {
return nil, nil
}
var schemes map[string]any
if err := json.Unmarshal([]byte(raw), &schemes); err != nil {
return nil, cli.Wrap(err, "invalid security schemes JSON")
}
return schemes, nil
}
// specConfigFromOptions extracts a specBuilderConfig from the CLI options bag.
// Callers supply flags via `--key=value` on the command line; the CLI parser
// converts them to the option keys read here.
func specConfigFromOptions(opts core.Options) specBuilderConfig {
cfg := specBuilderConfig{
title: stringOr(opts.String("title"), "Lethean Core API"),
summary: opts.String("summary"),
description: stringOr(opts.String("description"), "Lethean Core API"),
version: stringOr(opts.String("version"), "1.0.0"),
swaggerPath: opts.String("swagger-path"),
graphqlPath: opts.String("graphql-path"),
graphqlPlayground: opts.Bool("graphql-playground"),
graphqlPlaygroundPath: opts.String("graphql-playground-path"),
ssePath: opts.String("sse-path"),
wsPath: opts.String("ws-path"),
pprofEnabled: opts.Bool("pprof"),
expvarEnabled: opts.Bool("expvar"),
openAPISpecEnabled: opts.Bool("openapi-spec"),
openAPISpecPath: opts.String("openapi-spec-path"),
chatCompletionsEnabled: opts.Bool("chat-completions"),
chatCompletionsPath: opts.String("chat-completions-path"),
cacheEnabled: opts.Bool("cache"),
cacheTTL: opts.String("cache-ttl"),
cacheMaxEntries: opts.Int("cache-max-entries"),
cacheMaxBytes: opts.Int("cache-max-bytes"),
i18nDefaultLocale: opts.String("i18n-default-locale"),
i18nSupportedLocales: opts.String("i18n-supported-locales"),
authentikIssuer: opts.String("authentik-issuer"),
authentikClientID: opts.String("authentik-client-id"),
authentikTrustedProxy: opts.Bool("authentik-trusted-proxy"),
authentikPublicPaths: opts.String("authentik-public-paths"),
termsURL: opts.String("terms-of-service"),
contactName: opts.String("contact-name"),
contactURL: opts.String("contact-url"),
contactEmail: opts.String("contact-email"),
licenseName: opts.String("license-name"),
licenseURL: opts.String("license-url"),
externalDocsDescription: opts.String("external-docs-description"),
externalDocsURL: opts.String("external-docs-url"),
servers: opts.String("server"),
securitySchemes: opts.String("security-schemes"),
}
return cfg
}
func stringOr(v, fallback string) string {
if core.Trim(v) == "" {
return fallback
}
return v
}

273
cmd/api/cmd_spec_test.go Normal file
View file

@ -0,0 +1,273 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"encoding/json"
"iter"
"os"
"testing"
core "dappco.re/go/core"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
type specCmdStubGroup struct{}
func (specCmdStubGroup) Name() string { return "registered" }
func (specCmdStubGroup) BasePath() string { return "/registered" }
func (specCmdStubGroup) RegisterRoutes(rg *gin.RouterGroup) {}
func (specCmdStubGroup) Describe() []api.RouteDescription {
return []api.RouteDescription{
{
Method: "GET",
Path: "/ping",
Summary: "Ping registered group",
Tags: []string{"registered"},
Response: map[string]any{
"type": "string",
},
},
}
}
func collectRouteGroups(groups iter.Seq[api.RouteGroup]) []api.RouteGroup {
out := make([]api.RouteGroup, 0)
for group := range groups {
out = append(out, group)
}
return out
}
// TestCmdSpec_AddSpecCommand_Good verifies the spec command registers under
// the expected api/spec path with an executable Action.
func TestCmdSpec_AddSpecCommand_Good(t *testing.T) {
c := core.New()
addSpecCommand(c)
r := c.Command("api/spec")
if !r.OK {
t.Fatalf("expected api/spec command to be registered")
}
cmd, ok := r.Value.(*core.Command)
if !ok {
t.Fatalf("expected *core.Command, got %T", r.Value)
}
if cmd.Action == nil {
t.Fatal("expected non-nil Action on api/spec")
}
if cmd.Description == "" {
t.Fatal("expected Description on api/spec")
}
}
// TestCmdSpec_SpecAction_Good_WritesJSONToFile exercises the spec action with
// an output file flag and verifies the resulting OpenAPI document parses.
func TestCmdSpec_SpecAction_Good_WritesJSONToFile(t *testing.T) {
outputFile := t.TempDir() + "/spec.json"
opts := core.NewOptions(
core.Option{Key: "output", Value: outputFile},
core.Option{Key: "format", Value: "json"},
)
r := specAction(opts)
if !r.OK {
t.Fatalf("expected OK result, got %v", r.Value)
}
data, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("expected spec file to be written: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("expected valid JSON spec, got error: %v", err)
}
if spec["openapi"] == nil {
t.Fatal("expected openapi field in generated spec")
}
}
// TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved ensures that flag
// keys from the CLI parser populate the spec builder configuration.
func TestCmdSpec_SpecConfigFromOptions_Good_FlagsArePreserved(t *testing.T) {
opts := core.NewOptions(
core.Option{Key: "title", Value: "Custom API"},
core.Option{Key: "summary", Value: "Brief summary"},
core.Option{Key: "description", Value: "Long description"},
core.Option{Key: "version", Value: "9.9.9"},
core.Option{Key: "swagger-path", Value: "/docs"},
core.Option{Key: "graphql-playground", Value: true},
core.Option{Key: "cache", Value: true},
core.Option{Key: "cache-max-entries", Value: 100},
core.Option{Key: "i18n-supported-locales", Value: "en-GB,fr"},
)
cfg := specConfigFromOptions(opts)
if cfg.title != "Custom API" {
t.Fatalf("expected title=Custom API, got %q", cfg.title)
}
if cfg.summary != "Brief summary" {
t.Fatalf("expected summary preserved, got %q", cfg.summary)
}
if cfg.version != "9.9.9" {
t.Fatalf("expected version=9.9.9, got %q", cfg.version)
}
if cfg.swaggerPath != "/docs" {
t.Fatalf("expected swagger path, got %q", cfg.swaggerPath)
}
if !cfg.graphqlPlayground {
t.Fatal("expected graphql playground enabled")
}
if !cfg.cacheEnabled {
t.Fatal("expected cache enabled")
}
if cfg.cacheMaxEntries != 100 {
t.Fatalf("expected cacheMaxEntries=100, got %d", cfg.cacheMaxEntries)
}
if cfg.i18nSupportedLocales != "en-GB,fr" {
t.Fatalf("expected i18n supported locales, got %q", cfg.i18nSupportedLocales)
}
}
// TestCmdSpec_SpecConfigFromOptions_Good_OpenAPIAndChatFlagsPreserved
// verifies the new spec-level flags for the standalone OpenAPI JSON and
// chat completions endpoints round-trip through the CLI parser.
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: "chat-completions", Value: true},
core.Option{Key: "chat-completions-path", Value: "/api/v1/chat/completions"},
)
cfg := specConfigFromOptions(opts)
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.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)
}
}
// TestCmdSpec_NewSpecBuilder_Good_PropagatesNewFlags verifies that the
// spec builder respects the new OpenAPI and ChatCompletions flags.
func TestCmdSpec_NewSpecBuilder_Good_PropagatesNewFlags(t *testing.T) {
cfg := specBuilderConfig{
title: "Test",
version: "1.0.0",
openAPISpecEnabled: true,
openAPISpecPath: "/api/v1/openapi.json",
chatCompletionsEnabled: true,
chatCompletionsPath: "/api/v1/chat/completions",
}
builder, err := newSpecBuilder(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
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.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)
}
}
// TestCmdSpec_NewSpecBuilder_Ugly_PathImpliesEnabled verifies that supplying
// only a path override turns the endpoint on automatically so callers need
// not pass both flags in CI scripts.
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",
}
builder, err := newSpecBuilder(cfg)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !builder.OpenAPISpecEnabled {
t.Fatal("expected OpenAPISpecEnabled to be inferred from path override")
}
if !builder.ChatCompletionsEnabled {
t.Fatal("expected ChatCompletionsEnabled to be inferred from path override")
}
}
// TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied ensures empty values
// do not blank out required defaults like title, description, version.
func TestCmdSpec_SpecConfigFromOptions_Bad_DefaultsApplied(t *testing.T) {
opts := core.NewOptions()
cfg := specConfigFromOptions(opts)
if cfg.title != "Lethean Core API" {
t.Fatalf("expected default title, got %q", cfg.title)
}
if cfg.description != "Lethean Core API" {
t.Fatalf("expected default description, got %q", cfg.description)
}
if cfg.version != "1.0.0" {
t.Fatalf("expected default version, got %q", cfg.version)
}
}
// TestCmdSpec_StringOr_Ugly_TrimsWhitespaceFallback covers the awkward
// whitespace path where an option is set to whitespace but should still
// fall back to the supplied default.
func TestCmdSpec_StringOr_Ugly_TrimsWhitespaceFallback(t *testing.T) {
if got := stringOr(" ", "fallback"); got != "fallback" {
t.Fatalf("expected whitespace to fall back to default, got %q", got)
}
if got := stringOr("value", "fallback"); got != "value" {
t.Fatalf("expected explicit value to win, got %q", got)
}
if got := stringOr("", ""); got != "" {
t.Fatalf("expected empty/empty to remain empty, got %q", got)
}
}
// TestSpecGroupsIter_Good_DeduplicatesExtraBridge verifies the iterator does
// not emit a duplicate when the registered groups already contain a tool
// bridge with the same base path.
func TestSpecGroupsIter_Good_DeduplicatesExtraBridge(t *testing.T) {
snapshot := api.RegisteredSpecGroups()
api.ResetSpecGroups()
t.Cleanup(func() {
api.ResetSpecGroups()
api.RegisterSpecGroups(snapshot...)
})
group := specCmdStubGroup{}
api.RegisterSpecGroups(group)
groups := collectRouteGroups(specGroupsIter(group))
if len(groups) != 1 {
t.Fatalf("expected duplicate extra group to be skipped, got %d groups", len(groups))
}
if groups[0].Name() != group.Name() || groups[0].BasePath() != group.BasePath() {
t.Fatalf("expected original group to be preserved, got %s at %s", groups[0].Name(), groups[0].BasePath())
}
}

View file

@ -1,101 +0,0 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bytes"
"testing"
"forge.lthn.ai/core/cli/pkg/cli"
)
func TestAPISpecCmd_Good_CommandStructure(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
apiCmd, _, err := root.Find([]string{"api"})
if err != nil {
t.Fatalf("api command not found: %v", err)
}
specCmd, _, err := apiCmd.Find([]string{"spec"})
if err != nil {
t.Fatalf("spec subcommand not found: %v", err)
}
if specCmd.Use != "spec" {
t.Fatalf("expected Use=spec, got %s", specCmd.Use)
}
}
func TestAPISpecCmd_Good_JSON(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
apiCmd, _, err := root.Find([]string{"api"})
if err != nil {
t.Fatalf("api command not found: %v", err)
}
specCmd, _, err := apiCmd.Find([]string{"spec"})
if err != nil {
t.Fatalf("spec subcommand not found: %v", err)
}
// Verify flags exist
if specCmd.Flag("format") == nil {
t.Fatal("expected --format flag on spec command")
}
if specCmd.Flag("output") == nil {
t.Fatal("expected --output flag on spec command")
}
if specCmd.Flag("title") == nil {
t.Fatal("expected --title flag on spec command")
}
if specCmd.Flag("version") == nil {
t.Fatal("expected --version flag on spec command")
}
}
func TestAPISDKCmd_Bad_NoLang(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
root.SetArgs([]string{"api", "sdk"})
buf := new(bytes.Buffer)
root.SetOut(buf)
root.SetErr(buf)
err := root.Execute()
if err == nil {
t.Fatal("expected error when --lang not provided")
}
}
func TestAPISDKCmd_Good_ValidatesLanguage(t *testing.T) {
root := &cli.Command{Use: "root"}
AddAPICommands(root)
apiCmd, _, err := root.Find([]string{"api"})
if err != nil {
t.Fatalf("api command not found: %v", err)
}
sdkCmd, _, err := apiCmd.Find([]string{"sdk"})
if err != nil {
t.Fatalf("sdk subcommand not found: %v", err)
}
// Verify flags exist
if sdkCmd.Flag("lang") == nil {
t.Fatal("expected --lang flag on sdk command")
}
if sdkCmd.Flag("output") == nil {
t.Fatal("expected --output flag on sdk command")
}
if sdkCmd.Flag("spec") == nil {
t.Fatal("expected --spec flag on sdk command")
}
if sdkCmd.Flag("package") == nil {
t.Fatal("expected --package flag on sdk command")
}
}

135
cmd/api/spec_builder.go Normal file
View file

@ -0,0 +1,135 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"time"
core "dappco.re/go/core"
goapi "dappco.re/go/core/api"
)
type specBuilderConfig struct {
title string
summary string
description string
version string
swaggerPath string
graphqlPath string
graphqlPlayground bool
graphqlPlaygroundPath string
ssePath string
wsPath string
pprofEnabled bool
expvarEnabled bool
openAPISpecEnabled bool
openAPISpecPath string
chatCompletionsEnabled bool
chatCompletionsPath string
cacheEnabled bool
cacheTTL string
cacheMaxEntries int
cacheMaxBytes int
i18nDefaultLocale string
i18nSupportedLocales string
authentikIssuer string
authentikClientID string
authentikTrustedProxy bool
authentikPublicPaths string
termsURL string
contactName string
contactURL string
contactEmail string
licenseName string
licenseURL string
externalDocsDescription string
externalDocsURL string
servers string
securitySchemes string
}
func newSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) {
swaggerPath := core.Trim(cfg.swaggerPath)
graphqlPath := core.Trim(cfg.graphqlPath)
ssePath := core.Trim(cfg.ssePath)
wsPath := core.Trim(cfg.wsPath)
cacheTTL := core.Trim(cfg.cacheTTL)
cacheTTLValid := parsePositiveDuration(cacheTTL)
openAPISpecPath := core.Trim(cfg.openAPISpecPath)
chatCompletionsPath := core.Trim(cfg.chatCompletionsPath)
builder := &goapi.SpecBuilder{
Title: core.Trim(cfg.title),
Summary: core.Trim(cfg.summary),
Description: core.Trim(cfg.description),
Version: core.Trim(cfg.version),
SwaggerEnabled: swaggerPath != "",
SwaggerPath: swaggerPath,
GraphQLEnabled: graphqlPath != "" || cfg.graphqlPlayground,
GraphQLPath: graphqlPath,
GraphQLPlayground: cfg.graphqlPlayground,
GraphQLPlaygroundPath: core.Trim(cfg.graphqlPlaygroundPath),
SSEEnabled: ssePath != "",
SSEPath: ssePath,
WSEnabled: wsPath != "",
WSPath: wsPath,
PprofEnabled: cfg.pprofEnabled,
ExpvarEnabled: cfg.expvarEnabled,
ChatCompletionsEnabled: cfg.chatCompletionsEnabled || chatCompletionsPath != "",
ChatCompletionsPath: chatCompletionsPath,
OpenAPISpecEnabled: cfg.openAPISpecEnabled || openAPISpecPath != "",
OpenAPISpecPath: openAPISpecPath,
CacheEnabled: cfg.cacheEnabled || cacheTTLValid,
CacheTTL: cacheTTL,
CacheMaxEntries: cfg.cacheMaxEntries,
CacheMaxBytes: cfg.cacheMaxBytes,
I18nDefaultLocale: core.Trim(cfg.i18nDefaultLocale),
TermsOfService: core.Trim(cfg.termsURL),
ContactName: core.Trim(cfg.contactName),
ContactURL: core.Trim(cfg.contactURL),
ContactEmail: core.Trim(cfg.contactEmail),
Servers: parseServers(cfg.servers),
LicenseName: core.Trim(cfg.licenseName),
LicenseURL: core.Trim(cfg.licenseURL),
ExternalDocsDescription: core.Trim(cfg.externalDocsDescription),
ExternalDocsURL: core.Trim(cfg.externalDocsURL),
AuthentikIssuer: core.Trim(cfg.authentikIssuer),
AuthentikClientID: core.Trim(cfg.authentikClientID),
AuthentikTrustedProxy: cfg.authentikTrustedProxy,
AuthentikPublicPaths: normalisePublicPaths(splitUniqueCSV(cfg.authentikPublicPaths)),
}
builder.I18nSupportedLocales = parseLocales(cfg.i18nSupportedLocales)
if builder.I18nDefaultLocale == "" && len(builder.I18nSupportedLocales) > 0 {
builder.I18nDefaultLocale = "en"
}
if cfg.securitySchemes != "" {
schemes, err := parseSecuritySchemes(cfg.securitySchemes)
if err != nil {
return nil, err
}
builder.SecuritySchemes = schemes
}
return builder, nil
}
func parseLocales(raw string) []string {
return splitUniqueCSV(raw)
}
func parsePositiveDuration(raw string) bool {
raw = core.Trim(raw)
if raw == "" {
return false
}
d, err := time.ParseDuration(raw)
if err != nil || d <= 0 {
return false
}
return true
}

View file

@ -0,0 +1,16 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"iter"
goapi "dappco.re/go/core/api"
)
// specGroupsIter snapshots the registered spec groups and appends one optional
// extra group. It keeps the command paths iterator-backed while preserving the
// existing ordering guarantees.
func specGroupsIter(extra goapi.RouteGroup) iter.Seq[goapi.RouteGroup] {
return goapi.SpecGroupsIter(extra)
}

View file

@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"slices"
"strings"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
@ -32,6 +33,10 @@ var supportedLanguages = map[string]string{
}
// SDKGenerator wraps openapi-generator-cli for SDK generation.
//
// Example:
//
// gen := &api.SDKGenerator{SpecPath: "./openapi.yaml", OutputDir: "./sdk", PackageName: "service"}
type SDKGenerator struct {
// SpecPath is the path to the OpenAPI spec file (JSON or YAML).
SpecPath string
@ -45,22 +50,50 @@ type SDKGenerator struct {
// Generate creates an SDK for the given language using openapi-generator-cli.
// The language must be one of the supported languages returned by SupportedLanguages().
//
// Example:
//
// err := gen.Generate(context.Background(), "go")
func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
if g == nil {
return coreerr.E("SDKGenerator.Generate", "generator is nil", nil)
}
if ctx == nil {
return coreerr.E("SDKGenerator.Generate", "context is nil", nil)
}
language = strings.TrimSpace(language)
generator, ok := supportedLanguages[language]
if !ok {
return coreerr.E("SDKGenerator.Generate", fmt.Sprintf("unsupported language %q: supported languages are %v", language, SupportedLanguages()), nil)
}
if _, err := os.Stat(g.SpecPath); os.IsNotExist(err) {
return coreerr.E("SDKGenerator.Generate", "spec file not found: "+g.SpecPath, nil)
specPath := strings.TrimSpace(g.SpecPath)
if specPath == "" {
return coreerr.E("SDKGenerator.Generate", "spec path is required", nil)
}
if _, err := os.Stat(specPath); err != nil {
if os.IsNotExist(err) {
return coreerr.E("SDKGenerator.Generate", "spec file not found: "+specPath, nil)
}
return coreerr.E("SDKGenerator.Generate", "stat spec file", err)
}
outputDir := filepath.Join(g.OutputDir, language)
outputBase := strings.TrimSpace(g.OutputDir)
if outputBase == "" {
return coreerr.E("SDKGenerator.Generate", "output directory is required", nil)
}
if !g.Available() {
return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli not installed", nil)
}
outputDir := filepath.Join(outputBase, language)
if err := coreio.Local.EnsureDir(outputDir); err != nil {
return coreerr.E("SDKGenerator.Generate", "create output directory", err)
}
args := g.buildArgs(generator, outputDir)
args := g.buildArgs(specPath, generator, outputDir)
cmd := exec.CommandContext(ctx, "openapi-generator-cli", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@ -73,10 +106,10 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
}
// buildArgs constructs the openapi-generator-cli command arguments.
func (g *SDKGenerator) buildArgs(generator, outputDir string) []string {
func (g *SDKGenerator) buildArgs(specPath, generator, outputDir string) []string {
args := []string{
"generate",
"-i", g.SpecPath,
"-i", specPath,
"-g", generator,
"-o", outputDir,
}
@ -87,6 +120,12 @@ func (g *SDKGenerator) buildArgs(generator, outputDir string) []string {
}
// Available checks if openapi-generator-cli is installed and accessible.
//
// Example:
//
// if !gen.Available() {
// t.Fatal("openapi-generator-cli is required")
// }
func (g *SDKGenerator) Available() bool {
_, err := exec.LookPath("openapi-generator-cli")
return err == nil
@ -94,11 +133,21 @@ func (g *SDKGenerator) Available() bool {
// SupportedLanguages returns the list of supported SDK target languages
// in sorted order for deterministic output.
//
// Example:
//
// langs := api.SupportedLanguages()
func SupportedLanguages() []string {
return slices.Sorted(maps.Keys(supportedLanguages))
}
// SupportedLanguagesIter returns an iterator over supported SDK target languages in sorted order.
//
// Example:
//
// for lang := range api.SupportedLanguagesIter() {
// fmt.Println(lang)
// }
func SupportedLanguagesIter() iter.Seq[string] {
return slices.Values(SupportedLanguages())
}

View file

@ -59,7 +59,108 @@ func TestSDKGenerator_Bad_MissingSpec(t *testing.T) {
}
}
func TestSDKGenerator_Bad_EmptySpecPath(t *testing.T) {
gen := &api.SDKGenerator{
OutputDir: t.TempDir(),
}
err := gen.Generate(context.Background(), "go")
if err == nil {
t.Fatal("expected error for empty spec path, got nil")
}
if !strings.Contains(err.Error(), "spec path is required") {
t.Fatalf("expected error to contain 'spec path is required', got: %v", err)
}
}
func TestSDKGenerator_Bad_EmptyOutputDir(t *testing.T) {
specDir := t.TempDir()
specPath := filepath.Join(specDir, "spec.json")
if err := os.WriteFile(specPath, []byte(`{"openapi":"3.1.0"}`), 0o644); err != nil {
t.Fatalf("failed to write spec file: %v", err)
}
gen := &api.SDKGenerator{
SpecPath: specPath,
}
err := gen.Generate(context.Background(), "go")
if err == nil {
t.Fatal("expected error for empty output directory, got nil")
}
if !strings.Contains(err.Error(), "output directory is required") {
t.Fatalf("expected error to contain 'output directory is required', got: %v", err)
}
}
func TestSDKGenerator_Bad_NilContext(t *testing.T) {
gen := &api.SDKGenerator{
SpecPath: filepath.Join(t.TempDir(), "nonexistent.json"),
OutputDir: t.TempDir(),
}
err := gen.Generate(nil, "go")
if err == nil {
t.Fatal("expected error for nil context, got nil")
}
if !strings.Contains(err.Error(), "context is nil") {
t.Fatalf("expected error to contain 'context is nil', got: %v", err)
}
}
func TestSDKGenerator_Bad_NilReceiver(t *testing.T) {
var gen *api.SDKGenerator
err := gen.Generate(context.Background(), "go")
if err == nil {
t.Fatal("expected error for nil generator, got nil")
}
if !strings.Contains(err.Error(), "generator is nil") {
t.Fatalf("expected error to contain 'generator is nil', got: %v", err)
}
}
func TestSDKGenerator_Bad_MissingGenerator(t *testing.T) {
t.Setenv("PATH", t.TempDir())
specDir := t.TempDir()
specPath := filepath.Join(specDir, "spec.json")
if err := os.WriteFile(specPath, []byte(`{"openapi":"3.1.0"}`), 0o644); err != nil {
t.Fatalf("failed to write spec file: %v", err)
}
outputDir := filepath.Join(t.TempDir(), "nested", "sdk")
gen := &api.SDKGenerator{
SpecPath: specPath,
OutputDir: outputDir,
}
err := gen.Generate(context.Background(), "go")
if err == nil {
t.Fatal("expected error when openapi-generator-cli is missing, got nil")
}
if !strings.Contains(err.Error(), "openapi-generator-cli not installed") {
t.Fatalf("expected missing-generator error, got: %v", err)
}
if _, statErr := os.Stat(filepath.Join(outputDir, "go")); !os.IsNotExist(statErr) {
t.Fatalf("expected output directory not to be created when generator is missing, got err=%v", statErr)
}
}
func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
oldPath := os.Getenv("PATH")
// Provide a fake openapi-generator-cli so Generate reaches the exec step
// without depending on the host environment.
binDir := t.TempDir()
binPath := filepath.Join(binDir, "openapi-generator-cli")
script := []byte("#!/bin/sh\nexit 1\n")
if err := os.WriteFile(binPath, script, 0o755); err != nil {
t.Fatalf("failed to write fake generator: %v", err)
}
t.Setenv("PATH", binDir+string(os.PathListSeparator)+oldPath)
// Write a minimal spec file so we pass the file-exists check.
specDir := t.TempDir()
specPath := filepath.Join(specDir, "spec.json")
@ -73,8 +174,8 @@ func TestSDKGenerator_Good_OutputDirCreated(t *testing.T) {
OutputDir: outputDir,
}
// Generate will fail at the exec step (openapi-generator-cli likely not installed),
// but the output directory should have been created before that.
// Generate will fail at the exec step, but the output directory should have
// been created before the CLI returned its non-zero status.
_ = gen.Generate(context.Background(), "go")
expected := filepath.Join(outputDir, "go")

View file

@ -30,6 +30,8 @@ type Engine struct {
swaggerTitle string
swaggerDesc string
swaggerVersion string
swaggerExternalDocsDescription string
swaggerExternalDocsURL string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
@ -128,6 +130,9 @@ type RouteDescription struct {
Summary string
Description string
Tags []string
Deprecated bool
StatusCode int
Parameters []ParameterDescription
RequestBody map[string]any
Response map[string]any
}
@ -151,12 +156,19 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
| `WithAddr(addr)` | Listen address | Default `:8080` |
| `WithBearerAuth(token)` | Static bearer token authentication | Skips `/health` and `/swagger` |
| `WithRequestID()` | `X-Request-ID` propagation | Preserves client-supplied IDs; generates 16-byte hex otherwise |
| `WithResponseMeta()` | Request metadata in JSON envelopes | Merges `request_id` and `duration` into standard responses |
| `WithCORS(origins...)` | CORS policy | `"*"` enables `AllowAllOrigins`; 12-hour `MaxAge` |
| `WithRateLimit(limit)` | Per-IP token-bucket rate limiting | `429 Too Many Requests`; `X-RateLimit-*` on success; `Retry-After` on rejection; zero or negative disables |
| `WithMiddleware(mw...)` | Arbitrary Gin middleware | Escape hatch for custom middleware |
| `WithStatic(prefix, root)` | Static file serving | Directory listing disabled |
| `WithWSHandler(h)` | WebSocket at `/ws` | Wraps any `http.Handler` |
| `WithAuthentik(cfg)` | Authentik forward-auth + OIDC JWT | Permissive; populates context, never rejects |
| `WithSwagger(title, desc, ver)` | Swagger UI at `/swagger/` | Runtime spec via `SpecBuilder` |
| `WithSwaggerTermsOfService(url)` | OpenAPI terms of service metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
| `WithSwaggerContact(name, url, email)` | OpenAPI contact metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
| `WithSwaggerServers(servers...)` | OpenAPI server metadata | Feeds the runtime Swagger spec and exported docs |
| `WithSwaggerLicense(name, url)` | OpenAPI licence metadata | Populates the Swagger spec info block without manual `SpecBuilder` wiring |
| `WithSwaggerExternalDocs(description, url)` | OpenAPI external documentation metadata | Populates the top-level `externalDocs` block without manual `SpecBuilder` wiring |
| `WithPprof()` | Go profiling at `/debug/pprof/` | WARNING: do not expose in production without authentication |
| `WithExpvar()` | Runtime metrics at `/debug/vars` | WARNING: do not expose in production without authentication |
| `WithSecure()` | Security headers | HSTS 1 year, X-Frame-Options DENY, nosniff, strict referrer |
@ -164,7 +176,8 @@ They execute after `gin.Recovery()` but before any route handler. The `Option` t
| `WithBrotli(level...)` | Brotli response compression | Writer pool for efficiency; default compression if level omitted |
| `WithSlog(logger)` | Structured request logging | Falls back to `slog.Default()` if nil |
| `WithTimeout(d)` | Per-request deadline | 504 with standard error envelope on timeout |
| `WithCache(ttl)` | In-memory GET response caching | `X-Cache: HIT` header on cache hits; 2xx only |
| `WithCache(ttl)` | In-memory GET response caching | Defaults to 1 000-entry LRU cap when no explicit bounds given; `X-Cache: HIT` header on cache hits; 2xx only |
| `WithCacheLimits(ttl, maxEntries, maxBytes)` | In-memory GET response caching with explicit bounds | Clearer cache configuration when eviction policy should be self-documenting |
| `WithSessions(name, secret)` | Cookie-backed server sessions | gin-contrib/sessions with cookie store |
| `WithAuthz(enforcer)` | Casbin policy-based authorisation | Subject from HTTP Basic Auth; 403 on deny |
| `WithHTTPSign(secrets, opts...)` | HTTP Signatures verification | draft-cavage-http-signatures; 401/400 on failure |
@ -371,14 +384,20 @@ redirects and introspection). The GraphQL handler is created via gqlgen's
## 8. Response Caching
`WithCache(ttl)` installs a URL-keyed in-memory response cache scoped to GET requests:
`WithCacheLimits(ttl, maxEntries, maxBytes)` installs a URL-keyed in-memory response cache scoped to GET requests:
```go
engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
```
- Only successful 2xx responses are cached.
- Non-GET methods pass through uncached.
- Cached responses are served with an `X-Cache: HIT` header.
- Expired entries are evicted lazily on the next access for the same key.
- The cache is not shared across `Engine` instances.
- There is no size limit on the cache.
- `WithCache(ttl)` defaults to a 1 000-entry LRU cap when called with only a TTL; pass explicit limits via `WithCacheLimits` for production tuning.
- Both `maxEntries` and `maxBytes` being non-positive causes `WithCacheLimits` to skip registration; at least one must be positive.
- Setting only one limit to a non-positive value leaves that dimension unbounded while the other limit controls eviction.
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
capture the response body and status code for storage.
@ -573,7 +592,9 @@ Generates an OpenAPI 3.1 specification from registered route groups.
| `--output` | `-o` | (stdout) | Write spec to file |
| `--format` | `-f` | `json` | Output format: `json` or `yaml` |
| `--title` | `-t` | `Lethean Core API` | API title |
| `--description` | `-d` | `Lethean Core API` | API description |
| `--version` | `-V` | `1.0.0` | API version |
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
### `core api sdk`
@ -585,6 +606,10 @@ Generates client SDKs from an OpenAPI spec using `openapi-generator-cli`.
| `--output` | `-o` | `./sdk` | Output directory |
| `--spec` | `-s` | (auto-generated) | Path to existing OpenAPI spec |
| `--package` | `-p` | `lethean` | Package name for generated SDK |
| `--title` | `-t` | `Lethean Core API` | API title in generated spec |
| `--description` | `-d` | `Lethean Core API` | API description in generated spec |
| `--version` | `-V` | `1.0.0` | API version in generated spec |
| `--server` | `-S` | (none) | Comma-separated OpenAPI server URL(s) |
---

View file

@ -169,11 +169,12 @@ At the end of Phase 3, the module has 176 tests.
## Known Limitations
### 1. Cache has no size limit
### 1. Cache remains in-memory
`WithCache(ttl)` stores all successful GET responses in memory with no maximum entry count or
total size bound. For a server receiving requests to many distinct URLs, the cache will grow
without bound. A LRU eviction policy or a configurable maximum is the natural next step.
`WithCache(ttl, maxEntries, maxBytes)` can now bound the cache by entry count and approximate
payload size, but it still stores responses in memory. Workloads with very large cached bodies
or a long-lived process will still consume RAM, so a disk-backed cache would be the next step if
that becomes a concern.
### 2. SDK codegen requires an external binary

View file

@ -44,6 +44,7 @@ func main() {
api.WithSecure(),
api.WithSlog(nil),
api.WithSwagger("My API", "A service description", "1.0.0"),
api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/"),
)
engine.Register(myRoutes) // any RouteGroup implementation
@ -94,7 +95,7 @@ engine.Register(&Routes{service: svc})
| File | Purpose |
|------|---------|
| `api.go` | `Engine` struct, `New()`, `build()`, `Serve()`, `Handler()`, `Channels()` |
| `options.go` | All `With*()` option functions (25 options) |
| `options.go` | All `With*()` option functions (28 options) |
| `group.go` | `RouteGroup`, `StreamGroup`, `DescribableGroup` interfaces; `RouteDescription` |
| `response.go` | `Response[T]`, `Error`, `Meta`, `OK()`, `Fail()`, `FailWithDetails()`, `Paginated()` |
| `middleware.go` | `bearerAuthMiddleware()`, `requestIDMiddleware()` |
@ -146,7 +147,7 @@ engine.Register(&Routes{service: svc})
| `go.opentelemetry.io/contrib/.../otelgin` | OpenTelemetry Gin instrumentation |
| `golang.org/x/text` | BCP 47 language tag matching |
| `gopkg.in/yaml.v3` | YAML export of OpenAPI specs |
| `forge.lthn.ai/core/cli` | CLI command registration (for `cmd/api/` subcommands) |
| `dappco.re/go/core/cli` | CLI command registration (for `cmd/api/` subcommands) |
### Ecosystem position

View file

@ -4,9 +4,12 @@ package api
import (
"encoding/json"
"fmt"
"io"
"iter"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
@ -16,43 +19,112 @@ import (
// ExportSpec generates the OpenAPI spec and writes it to w.
// Format must be "json" or "yaml".
//
// Example:
//
// _ = api.ExportSpec(os.Stdout, "yaml", builder, engine.Groups())
func ExportSpec(w io.Writer, format string, builder *SpecBuilder, groups []RouteGroup) error {
data, err := builder.Build(groups)
if err != nil {
return coreerr.E("ExportSpec", "build spec", err)
}
switch format {
return writeSpec(w, format, data, "ExportSpec")
}
// ExportSpecIter generates the OpenAPI spec from an iterator and writes it to w.
// Format must be "json" or "yaml".
//
// Example:
//
// _ = api.ExportSpecIter(os.Stdout, "json", builder, api.RegisteredSpecGroupsIter())
func ExportSpecIter(w io.Writer, format string, builder *SpecBuilder, groups iter.Seq[RouteGroup]) error {
data, err := builder.BuildIter(groups)
if err != nil {
return coreerr.E("ExportSpecIter", "build spec", err)
}
return writeSpec(w, format, data, "ExportSpecIter")
}
func writeSpec(w io.Writer, format string, data []byte, op string) error {
switch strings.ToLower(strings.TrimSpace(format)) {
case "json":
_, err = w.Write(data)
_, err := w.Write(data)
return err
case "yaml":
// Unmarshal JSON then re-marshal as YAML.
var obj any
if err := json.Unmarshal(data, &obj); err != nil {
return coreerr.E("ExportSpec", "unmarshal spec", err)
return coreerr.E(op, "unmarshal spec", err)
}
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
if err := enc.Encode(obj); err != nil {
return coreerr.E("ExportSpec", "encode yaml", err)
return coreerr.E(op, "encode yaml", err)
}
return enc.Close()
default:
return coreerr.E("ExportSpec", "unsupported format "+format+": use \"json\" or \"yaml\"", nil)
return coreerr.E(op, fmt.Sprintf("unsupported format %s: use %q or %q", format, "json", "yaml"), nil)
}
}
// ExportSpecToFile writes the spec to the given path.
// The parent directory is created if it does not exist.
//
// Example:
//
// _ = api.ExportSpecToFile("./api/openapi.yaml", "yaml", builder, engine.Groups())
func ExportSpecToFile(path, format string, builder *SpecBuilder, groups []RouteGroup) error {
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
return coreerr.E("ExportSpecToFile", "create directory", err)
return exportSpecToFile(path, "ExportSpecToFile", func(w io.Writer) error {
return ExportSpec(w, format, builder, groups)
})
}
f, err := os.Create(path)
// ExportSpecToFileIter writes the OpenAPI spec from an iterator to the given path.
// The parent directory is created if it does not exist.
//
// Example:
//
// _ = api.ExportSpecToFileIter("./api/openapi.json", "json", builder, api.RegisteredSpecGroupsIter())
func ExportSpecToFileIter(path, format string, builder *SpecBuilder, groups iter.Seq[RouteGroup]) error {
return exportSpecToFile(path, "ExportSpecToFileIter", func(w io.Writer) error {
return ExportSpecIter(w, format, builder, groups)
})
}
func exportSpecToFile(path, op string, write func(io.Writer) error) (err error) {
dir := filepath.Dir(path)
if err := coreio.Local.EnsureDir(dir); err != nil {
return coreerr.E(op, "create directory", err)
}
// Write to a temp file in the same directory so the rename is atomic on
// most filesystems. The destination is never truncated unless the full
// export succeeds.
f, err := os.CreateTemp(dir, ".export-*.tmp")
if err != nil {
return coreerr.E("ExportSpecToFile", "create file", err)
return coreerr.E(op, "create temp file", err)
}
defer f.Close()
return ExportSpec(f, format, builder, groups)
tmpPath := f.Name()
defer func() {
if err != nil {
_ = os.Remove(tmpPath)
}
}()
if writeErr := write(f); writeErr != nil {
_ = f.Close()
return writeErr
}
if closeErr := f.Close(); closeErr != nil {
return coreerr.E(op, "close temp file", closeErr)
}
if renameErr := os.Rename(tmpPath, path); renameErr != nil {
return coreerr.E(op, "rename temp file", renameErr)
}
return nil
}

View file

@ -5,6 +5,7 @@ package api_test
import (
"bytes"
"encoding/json"
"iter"
"net/http"
"os"
"path/filepath"
@ -65,6 +66,24 @@ func TestExportSpec_Good_YAML(t *testing.T) {
}
}
func TestExportSpec_Good_NormalisesFormatInput(t *testing.T) {
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
var buf bytes.Buffer
if err := api.ExportSpec(&buf, " YAML ", builder, nil); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := yaml.Unmarshal(buf.Bytes(), &spec); err != nil {
t.Fatalf("output is not valid YAML: %v", err)
}
if spec["openapi"] != "3.1.0" {
t.Fatalf("expected openapi=3.1.0, got %v", spec["openapi"])
}
}
func TestExportSpec_Bad_InvalidFormat(t *testing.T) {
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
@ -164,3 +183,41 @@ func TestExportSpec_Good_WithToolBridge(t *testing.T) {
t.Fatal("expected /tools/metrics_query path in spec")
}
}
func TestExportSpecIter_Good_WithGroupIterator(t *testing.T) {
builder := &api.SpecBuilder{Title: "Test", Description: "Test API", Version: "1.0.0"}
group := &specStubGroup{
name: "iter",
basePath: "/iter",
descs: []api.RouteDescription{
{
Method: "GET",
Path: "/ping",
Summary: "Ping iter group",
Response: map[string]any{
"type": "string",
},
},
},
}
groups := iter.Seq[api.RouteGroup](func(yield func(api.RouteGroup) bool) {
_ = yield(group)
})
var buf bytes.Buffer
if err := api.ExportSpecIter(&buf, "json", builder, groups); err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(buf.Bytes(), &spec); err != nil {
t.Fatalf("output is not valid JSON: %v", err)
}
paths := spec["paths"].(map[string]any)
if _, ok := paths["/iter/ping"]; !ok {
t.Fatal("expected /iter/ping path in spec")
}
}

12
go.mod
View file

@ -3,9 +3,11 @@ module dappco.re/go/core/api
go 1.26.0
require (
dappco.re/go/core v0.8.0-alpha.1
dappco.re/go/core/cli v0.5.2
dappco.re/go/core/inference v0.2.1
dappco.re/go/core/io v0.1.7
dappco.re/go/core/log v0.0.4
forge.lthn.ai/core/cli v0.3.7
dappco.re/go/core/log v0.1.2
github.com/99designs/gqlgen v0.17.88
github.com/andybalholm/brotli v1.2.0
github.com/casbin/casbin/v2 v2.135.0
@ -38,10 +40,7 @@ require (
)
require (
forge.lthn.ai/core/go v0.3.2 // indirect
forge.lthn.ai/core/go-i18n v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
dappco.re/go/core/i18n v0.2.3 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
@ -132,6 +131,7 @@ require (
replace (
dappco.re/go/core => ../go
dappco.re/go/core/i18n => ../go-i18n
dappco.re/go/core/inference => ../go-inference
dappco.re/go/core/io => ../go-io
dappco.re/go/core/log => ../go-log
)

12
go.sum
View file

@ -1,13 +1,5 @@
forge.lthn.ai/core/cli v0.3.7 h1:1GrbaGg0wDGHr6+klSbbGyN/9sSbHvFbdySJznymhwg=
forge.lthn.ai/core/cli v0.3.7/go.mod h1:DBUppJkA9P45ZFGgI2B8VXw1rAZxamHoI/KG7fRvTNs=
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
dappco.re/go/core/cli v0.5.2 h1:mo+PERo3lUytE+r3ArHr8o2nTftXjgPPsU/rn3ETXDM=
dappco.re/go/core/cli v0.5.2/go.mod h1:D4zfn3ec/hb72AWX/JWDvkW+h2WDKQcxGUrzoss7q2s=
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=

View file

@ -4,6 +4,9 @@ package api
import (
"net/http"
"strings"
core "dappco.re/go/core"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
@ -21,10 +24,61 @@ type graphqlConfig struct {
playground bool
}
// GraphQLConfig captures the configured GraphQL endpoint settings for an Engine.
//
// It is intentionally small and serialisable so callers can inspect the active
// GraphQL surface without reaching into the internal handler configuration.
//
// Example:
//
// cfg := api.GraphQLConfig{Enabled: true, Path: "/graphql", Playground: true}
type GraphQLConfig struct {
Enabled bool
Path string
Playground bool
PlaygroundPath string
}
// GraphQLConfig returns the currently configured GraphQL settings for the engine.
//
// The result snapshots the Engine state at call time and normalises any configured
// URL path using the same rules as the runtime handlers.
//
// Example:
//
// cfg := engine.GraphQLConfig()
func (e *Engine) GraphQLConfig() GraphQLConfig {
if e == nil {
return GraphQLConfig{}
}
cfg := GraphQLConfig{
Enabled: e.graphql != nil,
Playground: e.graphql != nil && e.graphql.playground,
}
if e.graphql != nil {
cfg.Path = normaliseGraphQLPath(e.graphql.path)
if e.graphql.playground {
cfg.PlaygroundPath = cfg.Path + "/playground"
}
}
return cfg
}
// GraphQLOption configures a GraphQL endpoint.
//
// Example:
//
// opts := []api.GraphQLOption{api.WithPlayground(), api.WithGraphQLPath("/gql")}
type GraphQLOption func(*graphqlConfig)
// WithPlayground enables the GraphQL Playground UI at {path}/playground.
//
// Example:
//
// api.WithGraphQL(schema, api.WithPlayground())
func WithPlayground() GraphQLOption {
return func(cfg *graphqlConfig) {
cfg.playground = true
@ -33,9 +87,13 @@ func WithPlayground() GraphQLOption {
// WithGraphQLPath sets a custom URL path for the GraphQL endpoint.
// The default path is "/graphql".
//
// Example:
//
// api.WithGraphQL(schema, api.WithGraphQLPath("/gql"))
func WithGraphQLPath(path string) GraphQLOption {
return func(cfg *graphqlConfig) {
cfg.path = path
cfg.path = normaliseGraphQLPath(path)
}
}
@ -55,6 +113,22 @@ func mountGraphQL(r *gin.Engine, cfg *graphqlConfig) {
}
}
// normaliseGraphQLPath coerces custom GraphQL paths into a stable form.
// The path always begins with a single slash and never ends with one.
func normaliseGraphQLPath(path string) string {
path = core.Trim(path)
if path == "" {
return defaultGraphQLPath
}
path = "/" + strings.Trim(path, "/")
if path == "/" {
return defaultGraphQLPath
}
return path
}
// wrapHTTPHandler adapts a standard http.Handler to a Gin handler function.
func wrapHTTPHandler(h http.Handler) gin.HandlerFunc {
return func(c *gin.Context) {

45
graphql_config_test.go Normal file
View file

@ -0,0 +1,45 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"testing"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
func TestEngine_GraphQLConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath(" /gql/ ")),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.GraphQLConfig()
if !cfg.Enabled {
t.Fatal("expected GraphQL to be enabled")
}
if cfg.Path != "/gql" {
t.Fatalf("expected GraphQL path /gql, got %q", cfg.Path)
}
if !cfg.Playground {
t.Fatal("expected GraphQL playground to be enabled")
}
if cfg.PlaygroundPath != "/gql/playground" {
t.Fatalf("expected GraphQL playground path /gql/playground, got %q", cfg.PlaygroundPath)
}
}
func TestEngine_GraphQLConfig_Good_EmptyOnNilEngine(t *testing.T) {
var e *api.Engine
cfg := e.GraphQLConfig()
if cfg.Enabled || cfg.Path != "" || cfg.Playground || cfg.PlaygroundPath != "" {
t.Fatalf("expected zero-value GraphQL config, got %+v", cfg)
}
}

View file

@ -192,6 +192,72 @@ func TestWithGraphQL_Good_CustomPath(t *testing.T) {
}
}
func TestWithGraphQL_Good_NormalisesCustomPath(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath(" /gql/ "), api.WithPlayground()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
resp, err := http.Post(srv.URL+"/gql", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at normalised /gql, got %d", resp.StatusCode)
}
pgResp, err := http.Get(srv.URL + "/gql/playground")
if err != nil {
t.Fatalf("playground request failed: %v", err)
}
defer pgResp.Body.Close()
if pgResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at normalised /gql/playground, got %d", pgResp.StatusCode)
}
}
func TestWithGraphQL_Good_DefaultPathWhenEmptyCustomPath(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithGraphQL(newTestSchema(), api.WithGraphQLPath(""), api.WithPlayground()))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
srv := httptest.NewServer(e.Handler())
defer srv.Close()
body := `{"query":"{ name }"}`
resp, err := http.Post(srv.URL+"/graphql", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at default /graphql, got %d", resp.StatusCode)
}
pgResp, err := http.Get(srv.URL + "/graphql/playground")
if err != nil {
t.Fatalf("playground request failed: %v", err)
}
defer pgResp.Body.Close()
if pgResp.StatusCode != http.StatusOK {
t.Fatalf("expected 200 at default /graphql/playground, got %d", pgResp.StatusCode)
}
}
func TestWithGraphQL_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)

View file

@ -2,10 +2,18 @@
package api
import "github.com/gin-gonic/gin"
import (
"iter"
"github.com/gin-gonic/gin"
)
// RouteGroup registers API routes onto a Gin router group.
// Subsystems implement this interface to declare their endpoints.
//
// Example:
//
// var g api.RouteGroup = &myGroup{}
type RouteGroup interface {
// Name returns a human-readable identifier for the group.
Name() string
@ -18,6 +26,10 @@ type RouteGroup interface {
}
// StreamGroup optionally declares WebSocket channels a subsystem publishes to.
//
// Example:
//
// var sg api.StreamGroup = &myStreamGroup{}
type StreamGroup interface {
// Channels returns the list of channel names this group streams on.
Channels() []string
@ -26,19 +38,92 @@ type StreamGroup interface {
// DescribableGroup extends RouteGroup with OpenAPI metadata.
// RouteGroups that implement this will have their endpoints
// included in the generated OpenAPI specification.
//
// Example:
//
// var dg api.DescribableGroup = &myDescribableGroup{}
type DescribableGroup interface {
RouteGroup
// Describe returns endpoint descriptions for OpenAPI generation.
Describe() []RouteDescription
}
// DescribableGroupIter extends DescribableGroup with an iterator-based
// description source for callers that want to avoid slice allocation.
//
// Example:
//
// var dg api.DescribableGroupIter = &myDescribableGroup{}
type DescribableGroupIter interface {
DescribableGroup
// DescribeIter returns endpoint descriptions for OpenAPI generation.
DescribeIter() iter.Seq[RouteDescription]
}
// RouteDescription describes a single endpoint for OpenAPI generation.
//
// Example:
//
// rd := api.RouteDescription{
// Method: "POST",
// Path: "/users",
// Summary: "Create a user",
// Description: "Creates a new user account.",
// Tags: []string{"users"},
// StatusCode: 201,
// RequestBody: map[string]any{"type": "object"},
// Response: map[string]any{"type": "object"},
// }
type RouteDescription struct {
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
Path string // Path relative to BasePath, e.g. "/generate"
Summary string // Short summary
Description string // Long description
Tags []string // OpenAPI tags for grouping
// Hidden omits the route from generated documentation.
Hidden bool
// Deprecated marks the operation as deprecated in OpenAPI.
Deprecated bool
// SunsetDate marks when a deprecated operation will be removed.
// Use YYYY-MM-DD or an RFC 7231 HTTP date string.
SunsetDate string
// Replacement points to the successor endpoint URL, when known.
Replacement string
// NoticeURL points to a detailed deprecation notice or migration guide,
// surfaced as the API-Deprecation-Notice-URL response header per spec §8.
NoticeURL string
// StatusCode is the documented 2xx success status code.
// Zero defaults to 200.
StatusCode int
// Security overrides the default bearerAuth requirement when non-nil.
// Use an empty, non-nil slice to mark the route as public.
Security []map[string][]string
Parameters []ParameterDescription
RequestBody map[string]any // JSON Schema for request body (nil for GET)
RequestExample any // Optional example payload for the request body.
Response map[string]any // JSON Schema for success response data
ResponseExample any // Optional example payload for the success response.
ResponseHeaders map[string]string
}
// ParameterDescription describes an OpenAPI parameter for a route.
//
// Example:
//
// param := api.ParameterDescription{
// Name: "id",
// In: "path",
// Description: "User identifier",
// Required: true,
// Schema: map[string]any{"type": "string"},
// Example: "usr_123",
// }
type ParameterDescription struct {
Name string // Parameter name.
In string // Parameter location: path, query, header, or cookie.
Description string // Human-readable parameter description.
Required bool // Whether the parameter is required.
Deprecated bool // Whether the parameter is deprecated.
Schema map[string]any // JSON Schema for the parameter value.
Example any // Optional example value.
}

140
i18n.go
View file

@ -3,6 +3,10 @@
package api
import (
"slices"
core "dappco.re/go/core"
"github.com/gin-gonic/gin"
"golang.org/x/text/language"
)
@ -13,7 +17,21 @@ const i18nContextKey = "i18n.locale"
// i18nMessagesKey is the Gin context key for the message lookup map.
const i18nMessagesKey = "i18n.messages"
// i18nCatalogKey is the Gin context key for the full locale->message catalog.
const i18nCatalogKey = "i18n.catalog"
// i18nDefaultLocaleKey stores the configured default locale for fallback lookups.
const i18nDefaultLocaleKey = "i18n.default_locale"
// I18nConfig configures the internationalisation middleware.
//
// Example:
//
// cfg := api.I18nConfig{
// DefaultLocale: "en",
// Supported: []string{"en", "fr"},
// Messages: map[string]map[string]string{"fr": {"greeting": "Bonjour"}},
// }
type I18nConfig struct {
// DefaultLocale is the fallback locale when the Accept-Language header
// is absent or does not match any supported locale. Defaults to "en".
@ -30,11 +48,32 @@ type I18nConfig struct {
Messages map[string]map[string]string
}
// I18nConfig returns the configured locale and message catalogue settings for
// the engine.
//
// The result snapshots the Engine state at call time and clones slices/maps so
// callers can safely reuse or modify the returned value.
//
// Example:
//
// cfg := engine.I18nConfig()
func (e *Engine) I18nConfig() I18nConfig {
if e == nil {
return I18nConfig{}
}
return cloneI18nConfig(e.i18nConfig)
}
// WithI18n adds Accept-Language header parsing and locale detection middleware.
// The middleware uses golang.org/x/text/language for RFC 5646 language matching
// with quality weighting support. The detected locale is stored in the Gin
// context and can be retrieved by handlers via GetLocale().
//
// Example:
//
// api.New(api.WithI18n(api.I18nConfig{Supported: []string{"en", "fr"}}))
//
// If messages are configured, handlers can look up localised strings via
// GetMessage(). This is a lightweight bridge — the go-i18n grammar engine
// can replace the message map later.
@ -57,14 +96,16 @@ func WithI18n(cfg ...I18nConfig) Option {
tags = append(tags, tag)
}
}
snapshot := cloneI18nConfig(config)
e.i18nConfig = snapshot
matcher := language.NewMatcher(tags)
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, config))
e.middlewares = append(e.middlewares, i18nMiddleware(matcher, snapshot))
}
}
// i18nMiddleware returns Gin middleware that parses Accept-Language, matches
// it against supported locales, and stores the result in the context.
// it against supported locales, and stores the resolved BCP 47 tag in the context.
func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
return func(c *gin.Context) {
accept := c.GetHeader("Accept-Language")
@ -75,19 +116,17 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
} else {
tags, _, _ := language.ParseAcceptLanguage(accept)
tag, _, _ := matcher.Match(tags...)
base, _ := tag.Base()
locale = base.String()
locale = tag.String()
}
c.Set(i18nContextKey, locale)
c.Set(i18nDefaultLocaleKey, cfg.DefaultLocale)
// Attach the message map for this locale if messages are configured.
if cfg.Messages != nil {
c.Set(i18nCatalogKey, cfg.Messages)
if msgs, ok := cfg.Messages[locale]; ok {
c.Set(i18nMessagesKey, msgs)
} else if msgs, ok := cfg.Messages[cfg.DefaultLocale]; ok {
// Fall back to default locale messages.
c.Set(i18nMessagesKey, msgs)
}
}
@ -97,6 +136,10 @@ func i18nMiddleware(matcher language.Matcher, cfg I18nConfig) gin.HandlerFunc {
// GetLocale returns the detected locale for the current request.
// Returns "en" if the i18n middleware was not applied.
//
// Example:
//
// locale := api.GetLocale(c)
func GetLocale(c *gin.Context) string {
if v, ok := c.Get(i18nContextKey); ok {
if s, ok := v.(string); ok {
@ -109,6 +152,10 @@ func GetLocale(c *gin.Context) string {
// GetMessage looks up a localised message by key for the current request.
// Returns the message string and true if found, or empty string and false
// if the key does not exist or the i18n middleware was not applied.
//
// Example:
//
// msg, ok := api.GetMessage(c, "greeting")
func GetMessage(c *gin.Context, key string) (string, bool) {
if v, ok := c.Get(i18nMessagesKey); ok {
if msgs, ok := v.(map[string]string); ok {
@ -117,5 +164,84 @@ func GetMessage(c *gin.Context, key string) (string, bool) {
}
}
}
catalog, _ := c.Get(i18nCatalogKey)
msgsByLocale, _ := catalog.(map[string]map[string]string)
if len(msgsByLocale) == 0 {
return "", false
}
locales := localeFallbacks(GetLocale(c))
if defaultLocale, ok := c.Get(i18nDefaultLocaleKey); ok {
if fallback, ok := defaultLocale.(string); ok && fallback != "" {
locales = append(locales, localeFallbacks(fallback)...)
}
}
seen := make(map[string]struct{}, len(locales))
for _, locale := range locales {
if locale == "" {
continue
}
if _, ok := seen[locale]; ok {
continue
}
seen[locale] = struct{}{}
if msgs, ok := msgsByLocale[locale]; ok {
if msg, ok := msgs[key]; ok {
return msg, true
}
}
}
return "", false
}
// localeFallbacks returns the locale and its parent tags in order from
// most specific to least specific. For example, "fr-CA" yields
// ["fr-CA", "fr"] and "zh-Hant-TW" yields ["zh-Hant-TW", "zh-Hant", "zh"].
func localeFallbacks(locale string) []string {
locale = core.Trim(core.Replace(locale, "_", "-"))
if locale == "" {
return nil
}
parts := core.Split(locale, "-")
if len(parts) == 0 {
return []string{locale}
}
fallbacks := make([]string, 0, len(parts))
for i := len(parts); i >= 1; i-- {
fallbacks = append(fallbacks, core.Join("-", parts[:i]...))
}
return fallbacks
}
func cloneI18nConfig(cfg I18nConfig) I18nConfig {
out := cfg
out.Supported = slices.Clone(cfg.Supported)
out.Messages = cloneI18nMessages(cfg.Messages)
return out
}
func cloneI18nMessages(messages map[string]map[string]string) map[string]map[string]string {
if len(messages) == 0 {
return nil
}
out := make(map[string]map[string]string, len(messages))
for locale, msgs := range messages {
if len(msgs) == 0 {
out[locale] = nil
continue
}
cloned := make(map[string]string, len(msgs))
for key, value := range msgs {
cloned[key] = value
}
out[locale] = cloned
}
return out
}

View file

@ -6,6 +6,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/gin-gonic/gin"
@ -133,6 +134,33 @@ func TestWithI18n_Good_QualityWeighting(t *testing.T) {
}
}
func TestWithI18n_Good_PreservesMatchedLocaleTag(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: []string{"en", "fr", "fr-CA"},
}))
e.Register(&i18nTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/locale", nil)
req.Header.Set("Accept-Language", "fr-CA, fr;q=0.8")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp i18nLocaleResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data["locale"] != "fr-CA" {
t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data["locale"])
}
}
func TestWithI18n_Good_CombinesWithOtherMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
@ -224,3 +252,122 @@ func TestWithI18n_Good_LooksUpMessage(t *testing.T) {
t.Fatalf("expected message=%q, got %q", "Hello", respEn.Data.Message)
}
}
func TestWithI18n_Good_FallsBackToParentLocaleMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: []string{"en", "fr", "fr-CA"},
Messages: map[string]map[string]string{
"en": {"greeting": "Hello"},
"fr": {"greeting": "Bonjour"},
},
}))
e.Register(&i18nTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil)
req.Header.Set("Accept-Language", "fr-CA")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp i18nMessageResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data.Locale != "fr-CA" {
t.Fatalf("expected locale=%q, got %q", "fr-CA", resp.Data.Locale)
}
if resp.Data.Message != "Bonjour" {
t.Fatalf("expected fallback message=%q, got %q", "Bonjour", resp.Data.Message)
}
if !resp.Data.Found {
t.Fatal("expected found=true")
}
}
func TestEngine_I18nConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
e, _ := api.New(api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: []string{"en", "fr"},
Messages: map[string]map[string]string{
"en": {"greeting": "Hello"},
"fr": {"greeting": "Bonjour"},
},
}))
snap := e.I18nConfig()
if snap.DefaultLocale != "en" {
t.Fatalf("expected default locale en, got %q", snap.DefaultLocale)
}
if !slices.Equal(snap.Supported, []string{"en", "fr"}) {
t.Fatalf("expected supported locales [en fr], got %v", snap.Supported)
}
if snap.Messages["fr"]["greeting"] != "Bonjour" {
t.Fatalf("expected cloned French greeting, got %q", snap.Messages["fr"]["greeting"])
}
}
func TestEngine_I18nConfig_Good_ClonesMutableInputs(t *testing.T) {
supported := []string{"en", "fr"}
messages := map[string]map[string]string{
"en": {"greeting": "Hello"},
"fr": {"greeting": "Bonjour"},
}
e, _ := api.New(api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: supported,
Messages: messages,
}))
supported[0] = "de"
messages["fr"]["greeting"] = "Salut"
snap := e.I18nConfig()
if !slices.Equal(snap.Supported, []string{"en", "fr"}) {
t.Fatalf("expected engine supported locales to be cloned, got %v", snap.Supported)
}
if snap.Messages["fr"]["greeting"] != "Bonjour" {
t.Fatalf("expected engine message catalogue to be cloned, got %q", snap.Messages["fr"]["greeting"])
}
}
func TestWithI18n_Good_SnapshotsMutableInputs(t *testing.T) {
gin.SetMode(gin.TestMode)
messages := map[string]map[string]string{
"en": {"greeting": "Hello"},
"fr": {"greeting": "Bonjour"},
}
e, _ := api.New(api.WithI18n(api.I18nConfig{
DefaultLocale: "en",
Supported: []string{"en", "fr"},
Messages: messages,
}))
e.Register(&i18nTestGroup{})
messages["fr"]["greeting"] = "Salut"
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/i18n/greeting", nil)
req.Header.Set("Accept-Language", "fr")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp i18nMessageResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Data.Message != "Bonjour" {
t.Fatalf("expected cloned greeting %q, got %q", "Bonjour", resp.Data.Message)
}
}

View file

@ -6,19 +6,44 @@ import (
"crypto/rand"
"encoding/hex"
"net/http"
"runtime/debug"
"strings"
"time"
core "dappco.re/go/core"
"github.com/gin-gonic/gin"
)
// requestIDContextKey is the Gin context key used by requestIDMiddleware.
const requestIDContextKey = "request_id"
// requestStartContextKey stores when the request began so handlers can
// calculate elapsed duration for response metadata.
const requestStartContextKey = "request_start"
// recoveryMiddleware converts panics into a standard JSON error envelope.
// This keeps internal failures consistent with the rest of the framework
// and avoids Gin's default plain-text 500 response.
func recoveryMiddleware() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
_, _ = gin.DefaultErrorWriter.Write([]byte(core.Sprintf("[Recovery] panic recovered: %v\n", recovered)))
debug.PrintStack()
c.AbortWithStatusJSON(http.StatusInternalServerError, Fail(
"internal_server_error",
"Internal server error",
))
})
}
// bearerAuthMiddleware validates the Authorization: Bearer <token> header.
// Requests to paths in the skip list are allowed through without authentication.
// Returns 401 with Fail("unauthorised", ...) on missing or invalid tokens.
func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc {
func bearerAuthMiddleware(token string, skip func() []string) gin.HandlerFunc {
return func(c *gin.Context) {
// Check whether the request path should bypass authentication.
for _, path := range skip {
if strings.HasPrefix(c.Request.URL.Path, path) {
for _, path := range skip() {
if isPublicPath(c.Request.URL.Path, path) {
c.Next()
return
}
@ -30,8 +55,8 @@ func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc {
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") || parts[1] != token {
parts := core.SplitN(header, " ", 2)
if len(parts) != 2 || core.Lower(parts[0]) != "bearer" || parts[1] != token {
c.AbortWithStatusJSON(http.StatusUnauthorized, Fail("unauthorised", "invalid bearer token"))
return
}
@ -40,11 +65,37 @@ func bearerAuthMiddleware(token string, skip []string) gin.HandlerFunc {
}
}
// isPublicPath reports whether requestPath should bypass auth for publicPath.
// It matches the exact path and any nested subpath, but not sibling prefixes
// such as /swaggerx when the public path is /swagger.
func isPublicPath(requestPath, publicPath string) bool {
if publicPath == "" {
return false
}
normalized := strings.TrimRight(publicPath, "/")
if normalized == "" {
normalized = "/"
}
if requestPath == normalized {
return true
}
if normalized == "/" {
return true
}
return core.HasPrefix(requestPath, normalized+"/")
}
// requestIDMiddleware ensures every response carries an X-Request-ID header.
// If the client sends one, it is preserved; otherwise a random 16-byte hex
// string is generated. The ID is also stored in the Gin context as "request_id".
func requestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(requestStartContextKey, time.Now())
id := c.GetHeader("X-Request-ID")
if id == "" {
b := make([]byte, 16)
@ -52,8 +103,63 @@ func requestIDMiddleware() gin.HandlerFunc {
id = hex.EncodeToString(b)
}
c.Set("request_id", id)
c.Set(requestIDContextKey, id)
c.Header("X-Request-ID", id)
c.Next()
}
}
// GetRequestID returns the request ID assigned by requestIDMiddleware.
// Returns an empty string when the middleware was not applied.
//
// Example:
//
// id := api.GetRequestID(c)
func GetRequestID(c *gin.Context) string {
if v, ok := c.Get(requestIDContextKey); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
// GetRequestDuration returns the elapsed time since requestIDMiddleware started
// handling the request. Returns 0 when the middleware was not applied.
//
// Example:
//
// d := api.GetRequestDuration(c)
func GetRequestDuration(c *gin.Context) time.Duration {
if v, ok := c.Get(requestStartContextKey); ok {
if started, ok := v.(time.Time); ok && !started.IsZero() {
return time.Since(started)
}
}
return 0
}
// GetRequestMeta returns request metadata collected by requestIDMiddleware.
// The returned meta includes the request ID and elapsed duration when
// available. It returns nil when neither value is available.
//
// Example:
//
// meta := api.GetRequestMeta(c)
func GetRequestMeta(c *gin.Context) *Meta {
meta := &Meta{}
if id := GetRequestID(c); id != "" {
meta.RequestID = id
}
if duration := GetRequestDuration(c); duration > 0 {
meta.Duration = duration.String()
}
if meta.RequestID == "" && meta.Duration == "" {
return nil
}
return meta
}

View file

@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
@ -26,6 +27,75 @@ func (m *mwTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
})
}
type swaggerLikeGroup struct{}
func (g *swaggerLikeGroup) Name() string { return "swagger-like" }
func (g *swaggerLikeGroup) BasePath() string { return "/swaggerx" }
func (g *swaggerLikeGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/secret", func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("classified"))
})
}
type requestIDTestGroup struct {
gotID *string
}
func (g requestIDTestGroup) Name() string { return "request-id" }
func (g requestIDTestGroup) BasePath() string { return "/v1" }
func (g requestIDTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/secret", func(c *gin.Context) {
*g.gotID = api.GetRequestID(c)
c.JSON(http.StatusOK, api.OK("classified"))
})
}
type requestMetaTestGroup struct{}
func (g requestMetaTestGroup) Name() string { return "request-meta" }
func (g requestMetaTestGroup) BasePath() string { return "/v1" }
func (g requestMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/meta", func(c *gin.Context) {
time.Sleep(2 * time.Millisecond)
resp := api.AttachRequestMeta(c, api.Paginated("classified", 1, 25, 100))
c.JSON(http.StatusOK, resp)
})
}
type autoResponseMetaTestGroup struct{}
func (g autoResponseMetaTestGroup) Name() string { return "auto-response-meta" }
func (g autoResponseMetaTestGroup) BasePath() string { return "/v1" }
func (g autoResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/meta", func(c *gin.Context) {
time.Sleep(2 * time.Millisecond)
c.JSON(http.StatusOK, api.Paginated("classified", 1, 25, 100))
})
}
type autoErrorResponseMetaTestGroup struct{}
func (g autoErrorResponseMetaTestGroup) Name() string { return "auto-error-response-meta" }
func (g autoErrorResponseMetaTestGroup) BasePath() string { return "/v1" }
func (g autoErrorResponseMetaTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/error", func(c *gin.Context) {
time.Sleep(2 * time.Millisecond)
c.JSON(http.StatusBadRequest, api.Fail("bad_request", "request failed"))
})
}
type plusJSONResponseMetaTestGroup struct{}
func (g plusJSONResponseMetaTestGroup) Name() string { return "plus-json-response-meta" }
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.Status(http.StatusOK)
_, _ = c.Writer.Write([]byte(`{"success":true,"data":"ok"}`))
})
}
// ── Bearer auth ─────────────────────────────────────────────────────────
func TestBearerAuth_Bad_MissingToken(t *testing.T) {
@ -114,6 +184,21 @@ func TestBearerAuth_Good_HealthBypassesAuth(t *testing.T) {
}
}
func TestBearerAuth_Bad_SimilarPrefixDoesNotBypassAuth(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithBearerAuth("s3cret"))
e.Register(&swaggerLikeGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/swaggerx/secret", nil)
h.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 for /swaggerx/secret, got %d", w.Code)
}
}
// ── Request ID ──────────────────────────────────────────────────────────
func TestRequestID_Good_GeneratedWhenMissing(t *testing.T) {
@ -151,6 +236,176 @@ func TestRequestID_Good_PreservesClientID(t *testing.T) {
}
}
func TestRequestID_Good_ContextAccessor(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRequestID())
var gotID string
e.Register(requestIDTestGroup{gotID: &gotID})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/secret", nil)
req.Header.Set("X-Request-ID", "client-id-xyz")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if gotID == "" {
t.Fatal("expected GetRequestID to return the request ID inside the handler")
}
if gotID != "client-id-xyz" {
t.Fatalf("expected GetRequestID=%q, got %q", "client-id-xyz", gotID)
}
}
func TestRequestID_Good_RequestMetaHelper(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRequestID())
e.Register(requestMetaTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
req.Header.Set("X-Request-ID", "client-id-meta")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
}
if resp.Meta.RequestID != "client-id-meta" {
t.Fatalf("expected request_id=%q, got %q", "client-id-meta", resp.Meta.RequestID)
}
if resp.Meta.Duration == "" {
t.Fatal("expected duration to be populated")
}
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)
}
}
func TestResponseMeta_Good_AttachesMetaAutomatically(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithRequestID(),
api.WithResponseMeta(),
)
e.Register(autoResponseMetaTestGroup{})
h := e.Handler()
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/meta", nil)
req.Header.Set("X-Request-ID", "client-id-auto-meta")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
}
if resp.Meta.RequestID != "client-id-auto-meta" {
t.Fatalf("expected request_id=%q, got %q", "client-id-auto-meta", resp.Meta.RequestID)
}
if resp.Meta.Duration == "" {
t.Fatal("expected duration to be populated")
}
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" {
t.Fatalf("expected response header X-Request-ID=%q, got %q", "client-id-auto-meta", got)
}
}
func TestResponseMeta_Good_AttachesMetaToErrorResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithRequestID(),
api.WithResponseMeta(),
)
e.Register(autoErrorResponseMetaTestGroup{})
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")
h.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
}
if resp.Meta.RequestID != "client-id-auto-error-meta" {
t.Fatalf("expected request_id=%q, got %q", "client-id-auto-error-meta", resp.Meta.RequestID)
}
if resp.Meta.Duration == "" {
t.Fatal("expected duration to be populated")
}
if resp.Error == nil || resp.Error.Code != "bad_request" {
t.Fatalf("expected bad_request error, got %+v", resp.Error)
}
}
func TestResponseMeta_Good_AttachesMetaToPlusJSONContentType(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(
api.WithRequestID(),
api.WithResponseMeta(),
)
e.Register(plusJSONResponseMetaTestGroup{})
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")
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if got := w.Header().Get("Content-Type"); got != "application/problem+json" {
t.Fatalf("expected Content-Type to be preserved, got %q", got)
}
var resp api.Response[string]
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Meta == nil {
t.Fatal("expected Meta to be present")
}
if resp.Meta.RequestID != "client-id-plus-json-meta" {
t.Fatalf("expected request_id=%q, got %q", "client-id-plus-json-meta", resp.Meta.RequestID)
}
if resp.Meta.Duration == "" {
t.Fatal("expected duration to be populated")
}
}
// ── CORS ────────────────────────────────────────────────────────────────
func TestCORS_Good_PreflightAllOrigins(t *testing.T) {

View file

@ -5,6 +5,7 @@ package api_test
import (
"slices"
"testing"
"time"
api "dappco.re/go/core/api"
)
@ -27,6 +28,28 @@ func TestEngine_GroupsIter(t *testing.T) {
}
}
func TestEngine_GroupsIter_Good_SnapshotsCurrentGroups(t *testing.T) {
e, _ := api.New()
g1 := &healthGroup{}
g2 := &stubGroup{}
e.Register(g1)
iter := e.GroupsIter()
e.Register(g2)
var groups []api.RouteGroup
for g := range iter {
groups = append(groups, g)
}
if len(groups) != 1 {
t.Fatalf("expected iterator snapshot to contain 1 group, got %d", len(groups))
}
if groups[0].Name() != "health-extra" {
t.Fatalf("expected snapshot to preserve original group, got %q", groups[0].Name())
}
}
type streamGroupStub struct {
healthGroup
channels []string
@ -52,6 +75,207 @@ func TestEngine_ChannelsIter(t *testing.T) {
}
}
func TestEngine_ChannelsIter_Good_SnapshotsCurrentChannels(t *testing.T) {
e, _ := api.New()
g1 := &streamGroupStub{channels: []string{"ch1", "ch2"}}
g2 := &streamGroupStub{channels: []string{"ch3"}}
e.Register(g1)
iter := e.ChannelsIter()
e.Register(g2)
var channels []string
for ch := range iter {
channels = append(channels, ch)
}
expected := []string{"ch1", "ch2"}
if !slices.Equal(channels, expected) {
t.Fatalf("expected snapshot channels %v, got %v", expected, channels)
}
}
func TestEngine_CacheConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
e, _ := api.New(api.WithCacheLimits(5*time.Minute, 10, 1024))
cfg := e.CacheConfig()
if !cfg.Enabled {
t.Fatal("expected cache config to be enabled")
}
if cfg.TTL != 5*time.Minute {
t.Fatalf("expected TTL %v, got %v", 5*time.Minute, cfg.TTL)
}
if cfg.MaxEntries != 10 {
t.Fatalf("expected MaxEntries 10, got %d", cfg.MaxEntries)
}
if cfg.MaxBytes != 1024 {
t.Fatalf("expected MaxBytes 1024, got %d", cfg.MaxBytes)
}
}
func TestEngine_RuntimeConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
broker := api.NewSSEBroker()
e, err := api.New(
api.WithSwagger("Runtime API", "Runtime snapshot", "1.2.3"),
api.WithSwaggerPath("/docs"),
api.WithCacheLimits(5*time.Minute, 10, 1024),
api.WithGraphQL(newTestSchema(), api.WithPlayground()),
api.WithI18n(api.I18nConfig{
DefaultLocale: "en-GB",
Supported: []string{"en-GB", "fr"},
}),
api.WithWSPath("/socket"),
api.WithSSE(broker),
api.WithSSEPath("/events"),
api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.example.com",
ClientID: "runtime-client",
TrustedProxy: true,
PublicPaths: []string{"/public", "/docs"},
}),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.RuntimeConfig()
if !cfg.Swagger.Enabled {
t.Fatal("expected swagger snapshot to be enabled")
}
if cfg.Swagger.Path != "/docs" {
t.Fatalf("expected swagger path /docs, got %q", cfg.Swagger.Path)
}
if cfg.Transport.SwaggerPath != "/docs" {
t.Fatalf("expected transport swagger path /docs, got %q", cfg.Transport.SwaggerPath)
}
if cfg.Transport.GraphQLPlaygroundPath != "/graphql/playground" {
t.Fatalf("expected transport graphql playground path /graphql/playground, got %q", cfg.Transport.GraphQLPlaygroundPath)
}
if !cfg.Cache.Enabled || cfg.Cache.TTL != 5*time.Minute {
t.Fatalf("expected cache snapshot to be populated, got %+v", cfg.Cache)
}
if !cfg.GraphQL.Enabled {
t.Fatal("expected GraphQL snapshot to be enabled")
}
if cfg.GraphQL.Path != "/graphql" {
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" {
t.Fatalf("expected GraphQL playground path /graphql/playground, got %q", cfg.GraphQL.PlaygroundPath)
}
if cfg.I18n.DefaultLocale != "en-GB" {
t.Fatalf("expected default locale en-GB, got %q", cfg.I18n.DefaultLocale)
}
if !slices.Equal(cfg.I18n.Supported, []string{"en-GB", "fr"}) {
t.Fatalf("expected supported locales [en-GB fr], got %v", cfg.I18n.Supported)
}
if cfg.Authentik.Issuer != "https://auth.example.com" {
t.Fatalf("expected Authentik issuer https://auth.example.com, got %q", cfg.Authentik.Issuer)
}
if cfg.Authentik.ClientID != "runtime-client" {
t.Fatalf("expected Authentik client ID runtime-client, got %q", cfg.Authentik.ClientID)
}
if !cfg.Authentik.TrustedProxy {
t.Fatal("expected Authentik trusted proxy to be enabled")
}
if !slices.Equal(cfg.Authentik.PublicPaths, []string{"/public", "/docs"}) {
t.Fatalf("expected Authentik public paths [/public /docs], got %v", cfg.Authentik.PublicPaths)
}
}
func TestEngine_RuntimeConfig_Good_EmptyOnNilEngine(t *testing.T) {
var e *api.Engine
cfg := e.RuntimeConfig()
if cfg.Swagger.Enabled || cfg.Transport.SwaggerEnabled || cfg.GraphQL.Enabled || cfg.Cache.Enabled || cfg.I18n.DefaultLocale != "" || cfg.Authentik.Issuer != "" {
t.Fatalf("expected zero-value runtime config, got %+v", cfg)
}
}
func TestEngine_AuthentikConfig_Good_SnapshotsCurrentSettings(t *testing.T) {
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.example.com",
ClientID: "client",
TrustedProxy: true,
PublicPaths: []string{"/public", "/docs"},
}))
cfg := e.AuthentikConfig()
if cfg.Issuer != "https://auth.example.com" {
t.Fatalf("expected issuer https://auth.example.com, got %q", cfg.Issuer)
}
if cfg.ClientID != "client" {
t.Fatalf("expected client ID client, got %q", cfg.ClientID)
}
if !cfg.TrustedProxy {
t.Fatal("expected trusted proxy to be enabled")
}
if !slices.Equal(cfg.PublicPaths, []string{"/public", "/docs"}) {
t.Fatalf("expected public paths [/public /docs], got %v", cfg.PublicPaths)
}
}
func TestEngine_AuthentikConfig_Good_ClonesPublicPaths(t *testing.T) {
publicPaths := []string{"/public", "/docs"}
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.example.com",
PublicPaths: publicPaths,
}))
cfg := e.AuthentikConfig()
publicPaths[0] = "/mutated"
if cfg.PublicPaths[0] != "/public" {
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"},
}))
cfg := e.AuthentikConfig()
expected := []string{"/public", "/docs"}
if !slices.Equal(cfg.PublicPaths, expected) {
t.Fatalf("expected normalised public paths %v, got %v", expected, cfg.PublicPaths)
}
}
func TestEngine_AuthentikConfig_Good_BlankPublicPathsRemainNil(t *testing.T) {
e, _ := api.New(api.WithAuthentik(api.AuthentikConfig{
PublicPaths: []string{" ", "\t", ""},
}))
cfg := e.AuthentikConfig()
if cfg.PublicPaths != nil {
t.Fatalf("expected blank public paths to collapse to nil, got %v", cfg.PublicPaths)
}
}
func TestEngine_Register_Good_IgnoresNilGroups(t *testing.T) {
e, _ := api.New()
var nilGroup *healthGroup
e.Register(nilGroup)
g1 := &healthGroup{}
e.Register(g1)
groups := e.Groups()
if len(groups) != 1 {
t.Fatalf("expected 1 registered group, got %d", len(groups))
}
if groups[0].Name() != "health-extra" {
t.Fatalf("expected the original group to be preserved, got %q", groups[0].Name())
}
}
func TestToolBridge_Iterators(t *testing.T) {
b := api.NewToolBridge("/tools")
desc := api.ToolDescriptor{Name: "test", Group: "g1"}
@ -66,13 +290,53 @@ func TestToolBridge_Iterators(t *testing.T) {
t.Errorf("ToolsIter failed, got %v", tools)
}
// Test DescribeIter
// Test DescribeIter — emits the GET listing entry followed by each tool.
var descs []api.RouteDescription
for d := range b.DescribeIter() {
descs = append(descs, d)
}
if len(descs) != 1 || descs[0].Path != "/test" {
t.Errorf("DescribeIter failed, got %v", descs)
if len(descs) != 2 {
t.Errorf("DescribeIter failed: expected listing + 1 tool, got %v", descs)
}
if descs[0].Method != "GET" || descs[0].Path != "/" {
t.Errorf("DescribeIter failed: expected first entry to be GET / listing, got %+v", descs[0])
}
if descs[1].Path != "/test" {
t.Errorf("DescribeIter failed: expected tool entry at /test, got %+v", descs[1])
}
}
func TestToolBridge_Iterators_Good_SnapshotCurrentTools(t *testing.T) {
b := api.NewToolBridge("/tools")
b.Add(api.ToolDescriptor{Name: "first", Group: "g1"}, nil)
toolsIter := b.ToolsIter()
descsIter := b.DescribeIter()
b.Add(api.ToolDescriptor{Name: "second", Group: "g2"}, nil)
var tools []api.ToolDescriptor
for tool := range toolsIter {
tools = append(tools, tool)
}
var descs []api.RouteDescription
for desc := range descsIter {
descs = append(descs, desc)
}
if len(tools) != 1 || tools[0].Name != "first" {
t.Fatalf("expected ToolsIter snapshot to contain the original tool, got %v", tools)
}
// DescribeIter snapshots the listing entry plus the tool registered at call time.
if len(descs) != 2 {
t.Fatalf("expected DescribeIter snapshot to contain listing + original tool, got %v", descs)
}
if descs[0].Method != "GET" || descs[0].Path != "/" {
t.Fatalf("expected DescribeIter snapshot to start with the GET listing entry, got %+v", descs[0])
}
if descs[1].Path != "/first" {
t.Fatalf("expected DescribeIter snapshot to contain the original tool, got %v", descs)
}
}

2492
openapi.go

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -9,6 +9,8 @@ import (
"slices"
"time"
core "dappco.re/go/core"
"github.com/99designs/gqlgen/graphql"
"github.com/casbin/casbin/v2"
"github.com/gin-contrib/authz"
@ -26,9 +28,17 @@ import (
)
// Option configures an Engine during construction.
//
// Example:
//
// engine, _ := api.New(api.WithAddr(":8080"))
type Option func(*Engine)
// WithAddr sets the listen address for the server.
//
// Example:
//
// api.New(api.WithAddr(":8443"))
func WithAddr(addr string) Option {
return func(e *Engine) {
e.addr = addr
@ -36,26 +46,57 @@ func WithAddr(addr string) Option {
}
// WithBearerAuth adds bearer token authentication middleware.
// Requests to /health and paths starting with /swagger are exempt.
// Requests to /health and the Swagger UI path are exempt.
//
// Example:
//
// api.New(api.WithBearerAuth("secret"))
func WithBearerAuth(token string) Option {
return func(e *Engine) {
skip := []string{"/health", "/swagger"}
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, skip))
e.middlewares = append(e.middlewares, bearerAuthMiddleware(token, func() []string {
skip := []string{"/health"}
if swaggerPath := resolveSwaggerPath(e.swaggerPath); swaggerPath != "" {
skip = append(skip, swaggerPath)
}
return skip
}))
}
}
// WithRequestID adds middleware that assigns an X-Request-ID to every response.
// Client-provided IDs are preserved; otherwise a random hex ID is generated.
//
// Example:
//
// api.New(api.WithRequestID())
func WithRequestID() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, requestIDMiddleware())
}
}
// WithResponseMeta attaches request metadata to JSON envelope responses.
// It preserves any existing pagination metadata and merges in request_id
// and duration when available from the request context. Combine it with
// WithRequestID() to populate both fields automatically.
//
// Example:
//
// api.New(api.WithRequestID(), api.WithResponseMeta())
func WithResponseMeta() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, responseMetaMiddleware())
}
}
// WithCORS configures Cross-Origin Resource Sharing via gin-contrib/cors.
// Pass "*" to allow all origins, or supply specific origin URLs.
// Standard methods (GET, POST, PUT, PATCH, DELETE, OPTIONS) and common
// headers (Authorization, Content-Type, X-Request-ID) are permitted.
//
// Example:
//
// api.New(api.WithCORS("*"))
func WithCORS(allowOrigins ...string) Option {
return func(e *Engine) {
cfg := cors.Config{
@ -76,6 +117,10 @@ func WithCORS(allowOrigins ...string) Option {
}
// WithMiddleware appends arbitrary Gin middleware to the engine.
//
// Example:
//
// api.New(api.WithMiddleware(loggingMiddleware))
func WithMiddleware(mw ...gin.HandlerFunc) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, mw...)
@ -85,6 +130,10 @@ func WithMiddleware(mw ...gin.HandlerFunc) Option {
// WithStatic serves static files from the given root directory at urlPrefix.
// Directory listing is disabled; only individual files are served.
// Internally this uses gin-contrib/static as Gin middleware.
//
// Example:
//
// api.New(api.WithStatic("/assets", "./public"))
func WithStatic(urlPrefix, root string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, static.Serve(urlPrefix, static.LocalFile(root, false)))
@ -92,33 +141,236 @@ func WithStatic(urlPrefix, root string) Option {
}
// WithWSHandler registers a WebSocket handler at GET /ws.
// Use WithWSPath to customise the route before mounting the handler.
// Typically this wraps a go-ws Hub.Handler().
//
// Example:
//
// api.New(api.WithWSHandler(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})))
func WithWSHandler(h http.Handler) Option {
return func(e *Engine) {
e.wsHandler = h
}
}
// WithAuthentik adds Authentik forward-auth middleware that extracts user
// identity from X-authentik-* headers set by a trusted reverse proxy.
// The middleware is permissive: unauthenticated requests are allowed through.
func WithAuthentik(cfg AuthentikConfig) Option {
// WithWebSocket registers a Gin-native WebSocket handler at GET /ws.
//
// This is the gin-handler form of WithWSHandler. The handler receives the
// request via *gin.Context and is responsible for performing the upgrade
// (typically with gorilla/websocket) and managing the message loop.
// Use WithWSPath to customise the route before mounting the handler.
//
// Example:
//
// api.New(api.WithWebSocket(func(c *gin.Context) {
// // upgrade and handle messages
// }))
func WithWebSocket(h gin.HandlerFunc) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, authentikMiddleware(cfg))
if h == nil {
return
}
e.wsGinHandler = h
}
}
// WithSwagger enables the Swagger UI at /swagger/.
// WithWSPath sets a custom URL path for the WebSocket endpoint.
// The default path is "/ws".
//
// Example:
//
// api.New(api.WithWSPath("/socket"))
func WithWSPath(path string) Option {
return func(e *Engine) {
e.wsPath = normaliseWSPath(path)
}
}
// WithAuthentik adds Authentik forward-auth middleware that extracts user
// identity from X-authentik-* headers set by a trusted reverse proxy.
// The middleware is permissive: unauthenticated requests are allowed through.
//
// Example:
//
// api.New(api.WithAuthentik(api.AuthentikConfig{TrustedProxy: true}))
func WithAuthentik(cfg AuthentikConfig) Option {
return func(e *Engine) {
snapshot := cloneAuthentikConfig(cfg)
e.authentikConfig = snapshot
e.middlewares = append(e.middlewares, authentikMiddleware(snapshot, func() []string {
return []string{resolveSwaggerPath(e.swaggerPath)}
}))
}
}
// WithSunset adds deprecation headers to every response.
// The middleware appends Deprecation, optional Sunset, optional Link, and
// X-API-Warn headers without clobbering any existing header values. Use it to
// deprecate an entire route group or API version.
//
// Example:
//
// api.New(api.WithSunset("2026-12-31", "https://api.example.com/v2"))
func WithSunset(sunsetDate, replacement string) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, ApiSunset(sunsetDate, replacement))
}
}
// WithSwagger enables the Swagger UI at /swagger/ by default.
// The title, description, and version populate the OpenAPI info block.
// Use WithSwaggerSummary() to set the optional info.summary field.
//
// Example:
//
// api.New(api.WithSwagger("Service", "Public API", "1.0.0"))
func WithSwagger(title, description, version string) Option {
return func(e *Engine) {
e.swaggerTitle = title
e.swaggerDesc = description
e.swaggerVersion = version
e.swaggerTitle = core.Trim(title)
e.swaggerDesc = core.Trim(description)
e.swaggerVersion = core.Trim(version)
e.swaggerEnabled = true
}
}
// WithSwaggerSummary adds the OpenAPI info.summary field to generated specs.
//
// Example:
//
// api.WithSwaggerSummary("Service overview")
func WithSwaggerSummary(summary string) Option {
return func(e *Engine) {
if summary = core.Trim(summary); summary != "" {
e.swaggerSummary = summary
}
}
}
// WithSwaggerPath sets a custom URL path for the Swagger UI.
// The default path is "/swagger".
//
// Example:
//
// api.New(api.WithSwaggerPath("/docs"))
func WithSwaggerPath(path string) Option {
return func(e *Engine) {
e.swaggerPath = normaliseSwaggerPath(path)
}
}
// WithSwaggerTermsOfService adds the terms of service URL to the generated Swagger spec.
// Empty strings are ignored.
//
// Example:
//
// api.WithSwaggerTermsOfService("https://example.com/terms")
func WithSwaggerTermsOfService(url string) Option {
return func(e *Engine) {
if url = core.Trim(url); url != "" {
e.swaggerTermsOfService = url
}
}
}
// WithSwaggerContact adds contact metadata to the generated Swagger spec.
// Empty fields are ignored. Multiple calls replace the previous contact data.
//
// Example:
//
// api.WithSwaggerContact("API Support", "https://example.com/support", "support@example.com")
func WithSwaggerContact(name, url, email string) Option {
return func(e *Engine) {
if name = core.Trim(name); name != "" {
e.swaggerContactName = name
}
if url = core.Trim(url); url != "" {
e.swaggerContactURL = url
}
if email = core.Trim(email); email != "" {
e.swaggerContactEmail = email
}
}
}
// WithSwaggerServers adds OpenAPI server metadata to the generated Swagger spec.
// Empty strings are ignored. Multiple calls append and normalise the combined
// server list so callers can compose metadata across options.
//
// Example:
//
// api.WithSwaggerServers("https://api.example.com", "https://docs.example.com")
func WithSwaggerServers(servers ...string) Option {
return func(e *Engine) {
e.swaggerServers = normaliseServers(append(e.swaggerServers, servers...))
}
}
// WithSwaggerLicense adds licence metadata to the generated Swagger spec.
// Pass both a name and URL to populate the OpenAPI info block consistently.
//
// Example:
//
// api.WithSwaggerLicense("EUPL-1.2", "https://eupl.eu/1.2/en/")
func WithSwaggerLicense(name, url string) Option {
return func(e *Engine) {
if name = core.Trim(name); name != "" {
e.swaggerLicenseName = name
}
if url = core.Trim(url); url != "" {
e.swaggerLicenseURL = url
}
}
}
// WithSwaggerSecuritySchemes merges custom OpenAPI security schemes into the
// generated Swagger spec. Existing schemes are preserved unless the new map
// defines the same key, in which case the later definition wins.
//
// Example:
//
// api.WithSwaggerSecuritySchemes(map[string]any{
// "apiKeyAuth": map[string]any{
// "type": "apiKey",
// "in": "header",
// "name": "X-API-Key",
// },
// })
func WithSwaggerSecuritySchemes(schemes map[string]any) Option {
return func(e *Engine) {
if len(schemes) == 0 {
return
}
if e.swaggerSecuritySchemes == nil {
e.swaggerSecuritySchemes = make(map[string]any, len(schemes))
}
for name, scheme := range schemes {
name = core.Trim(name)
if name == "" || scheme == nil {
continue
}
e.swaggerSecuritySchemes[name] = cloneOpenAPIValue(scheme)
}
}
}
// WithSwaggerExternalDocs adds top-level external documentation metadata to
// the generated Swagger spec.
// Empty URLs are ignored; the description is optional.
//
// Example:
//
// api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs")
func WithSwaggerExternalDocs(description, url string) Option {
return func(e *Engine) {
if description = core.Trim(description); description != "" {
e.swaggerExternalDocsDescription = description
}
if url = core.Trim(url); url != "" {
e.swaggerExternalDocsURL = url
}
}
}
// WithPprof enables Go runtime profiling endpoints at /debug/pprof/.
// The standard pprof handlers (index, cmdline, profile, symbol, trace,
// allocs, block, goroutine, heap, mutex, threadcreate) are registered
@ -126,6 +378,10 @@ func WithSwagger(title, description, version string) Option {
//
// WARNING: pprof exposes sensitive runtime data and should only be
// enabled in development or behind authentication in production.
//
// Example:
//
// api.New(api.WithPprof())
func WithPprof() Option {
return func(e *Engine) {
e.pprofEnabled = true
@ -140,6 +396,10 @@ func WithPprof() Option {
// WARNING: expvar exposes runtime internals (memory allocation,
// goroutine counts, command-line arguments) and should only be
// enabled in development or behind authentication in production.
//
// Example:
//
// api.New(api.WithExpvar())
func WithExpvar() Option {
return func(e *Engine) {
e.expvarEnabled = true
@ -151,6 +411,10 @@ func WithExpvar() Option {
// X-Content-Type-Options nosniff, and Referrer-Policy strict-origin-when-cross-origin.
// SSL redirect is not enabled so the middleware works behind a reverse proxy
// that terminates TLS.
//
// Example:
//
// api.New(api.WithSecure())
func WithSecure() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, secure.New(secure.Config{
@ -167,6 +431,10 @@ func WithSecure() Option {
// WithGzip adds gzip response compression middleware via gin-contrib/gzip.
// An optional compression level may be supplied (e.g. gzip.BestSpeed,
// gzip.BestCompression). If omitted, gzip.DefaultCompression is used.
//
// Example:
//
// api.New(api.WithGzip())
func WithGzip(level ...int) Option {
return func(e *Engine) {
l := gzip.DefaultCompression
@ -180,6 +448,10 @@ func WithGzip(level ...int) Option {
// WithBrotli adds Brotli response compression middleware using andybalholm/brotli.
// An optional compression level may be supplied (e.g. BrotliBestSpeed,
// BrotliBestCompression). If omitted, BrotliDefaultCompression is used.
//
// Example:
//
// api.New(api.WithBrotli())
func WithBrotli(level ...int) Option {
return func(e *Engine) {
l := BrotliDefaultCompression
@ -193,6 +465,10 @@ func WithBrotli(level ...int) Option {
// WithSlog adds structured request logging middleware via gin-contrib/slog.
// Each request is logged with method, path, status code, latency, and client IP.
// If logger is nil, slog.Default() is used.
//
// Example:
//
// api.New(api.WithSlog(nil))
func WithSlog(logger *slog.Logger) Option {
return func(e *Engine) {
if logger == nil {
@ -214,8 +490,15 @@ func WithSlog(logger *slog.Logger) Option {
//
// A zero or negative duration effectively disables the timeout (the handler
// runs without a deadline) — this is safe and will not panic.
//
// Example:
//
// api.New(api.WithTimeout(5 * time.Second))
func WithTimeout(d time.Duration) Option {
return func(e *Engine) {
if d <= 0 {
return
}
e.middlewares = append(e.middlewares, timeout.New(
timeout.WithTimeout(d),
timeout.WithResponse(timeoutResponse),
@ -228,21 +511,91 @@ func timeoutResponse(c *gin.Context) {
c.JSON(http.StatusGatewayTimeout, Fail("timeout", "Request timed out"))
}
// cacheDefaultMaxEntries is the entry cap applied by WithCache when the caller
// does not supply explicit limits. Prevents unbounded growth when WithCache is
// called with only a TTL argument.
const cacheDefaultMaxEntries = 1_000
// WithCache adds in-memory response caching middleware for GET requests.
// Successful (2xx) GET responses are cached for the given TTL and served
// with an X-Cache: HIT header on subsequent requests. Non-GET methods
// and error responses pass through uncached.
func WithCache(ttl time.Duration) Option {
//
// Optional integer limits enable LRU eviction:
// - maxEntries limits the number of cached responses
// - maxBytes limits the approximate total cached payload size
//
// At least one limit must be positive; when called with only a TTL the entry
// cap defaults to cacheDefaultMaxEntries (1 000) to prevent unbounded growth.
// A non-positive TTL disables the middleware entirely.
//
// Example:
//
// engine, _ := api.New(api.WithCache(5*time.Minute, 100, 10<<20))
func WithCache(ttl time.Duration, maxEntries ...int) Option {
entryLimit := cacheDefaultMaxEntries
byteLimit := 0
if len(maxEntries) > 0 {
entryLimit = maxEntries[0]
}
if len(maxEntries) > 1 {
byteLimit = maxEntries[1]
}
return WithCacheLimits(ttl, entryLimit, byteLimit)
}
// WithCacheLimits adds in-memory response caching middleware for GET requests
// with explicit entry and payload-size bounds.
//
// This is the clearer form of WithCache when call sites want to make the
// eviction policy self-documenting.
//
// Example:
//
// engine, _ := api.New(api.WithCacheLimits(5*time.Minute, 100, 10<<20))
func WithCacheLimits(ttl time.Duration, maxEntries, maxBytes int) Option {
return func(e *Engine) {
store := newCacheStore()
if ttl <= 0 {
return
}
// newCacheStore returns nil when both limits are non-positive (unbounded),
// which is a footgun; skip middleware registration in that case.
store := newCacheStore(maxEntries, maxBytes)
if store == nil {
return
}
e.cacheTTL = ttl
e.cacheMaxEntries = maxEntries
e.cacheMaxBytes = maxBytes
e.middlewares = append(e.middlewares, cacheMiddleware(store, ttl))
}
}
// WithRateLimit adds token-bucket rate limiting middleware.
// Requests are bucketed by API key or bearer token when present, and
// otherwise by client IP. Passing requests are annotated with
// X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
// Requests exceeding the configured limit are rejected with 429 Too Many
// Requests, Retry-After, and the standard Fail() error envelope.
// A zero or negative limit disables rate limiting.
//
// Example:
//
// engine, _ := api.New(api.WithRateLimit(100))
func WithRateLimit(limit int) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, rateLimitMiddleware(limit))
}
}
// WithSessions adds server-side session management middleware via
// gin-contrib/sessions using a cookie-based store. The name parameter
// sets the session cookie name (e.g. "session") and secret is the key
// used for cookie signing and encryption.
//
// Example:
//
// api.New(api.WithSessions("session", []byte("secret")))
func WithSessions(name string, secret []byte) Option {
return func(e *Engine) {
store := cookie.NewStore(secret)
@ -255,6 +608,10 @@ func WithSessions(name string, secret []byte) Option {
// holding the desired model and policy rules. The middleware extracts the
// subject from HTTP Basic Authentication, evaluates it against the request
// method and path, and returns 403 Forbidden when the policy denies access.
//
// Example:
//
// api.New(api.WithAuthz(enforcer))
func WithAuthz(enforcer *casbin.Enforcer) Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, authz.NewAuthorizer(enforcer))
@ -274,6 +631,10 @@ func WithAuthz(enforcer *casbin.Enforcer) Option {
//
// Requests with a missing, malformed, or invalid signature are rejected with
// 401 Unauthorised or 400 Bad Request.
//
// Example:
//
// api.New(api.WithHTTPSign(secrets))
func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
return func(e *Engine) {
auth := httpsign.NewAuthenticator(secrets, opts...)
@ -281,16 +642,34 @@ func WithHTTPSign(secrets httpsign.Secrets, opts ...httpsign.Option) Option {
}
}
// WithSSE registers a Server-Sent Events broker at GET /events.
// Clients connect to the endpoint and receive a streaming text/event-stream
// response. The broker manages client connections and broadcasts events
// WithSSE registers a Server-Sent Events broker at the configured path.
// By default the endpoint is mounted at GET /events; use WithSSEPath to
// customise the route. Clients receive a streaming text/event-stream
// response and the broker manages client connections and broadcasts events
// published via its Publish method.
//
// Example:
//
// broker := api.NewSSEBroker()
// engine, _ := api.New(api.WithSSE(broker))
func WithSSE(broker *SSEBroker) Option {
return func(e *Engine) {
e.sseBroker = broker
}
}
// WithSSEPath sets a custom URL path for the SSE endpoint.
// The default path is "/events".
//
// Example:
//
// api.New(api.WithSSEPath("/stream"))
func WithSSEPath(path string) Option {
return func(e *Engine) {
e.ssePath = normaliseSSEPath(path)
}
}
// WithLocation adds reverse proxy header detection middleware via
// gin-contrib/location. It inspects X-Forwarded-Proto and X-Forwarded-Host
// headers to determine the original scheme and host when the server runs
@ -298,6 +677,10 @@ func WithSSE(broker *SSEBroker) Option {
//
// After this middleware runs, handlers can call location.Get(c) to retrieve
// a *url.URL with the detected scheme, host, and base path.
//
// Example:
//
// engine, _ := api.New(api.WithLocation())
func WithLocation() Option {
return func(e *Engine) {
e.middlewares = append(e.middlewares, location.Default())
@ -311,6 +694,10 @@ func WithLocation() Option {
// api.New(
// api.WithGraphQL(schema, api.WithPlayground(), api.WithGraphQLPath("/gql")),
// )
//
// Example:
//
// engine, _ := api.New(api.WithGraphQL(schema))
func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option {
return func(e *Engine) {
cfg := &graphqlConfig{
@ -323,3 +710,81 @@ func WithGraphQL(schema graphql.ExecutableSchema, opts ...GraphQLOption) Option
e.graphql = cfg
}
}
// WithChatCompletions mounts an OpenAI-compatible POST /v1/chat/completions
// endpoint backed by the given ModelResolver. The resolver maps model names to
// loaded inference.TextModel instances (see chat_completions.go).
//
// Use WithChatCompletionsPath to override the default "/v1/chat/completions"
// mount point. The endpoint streams Server-Sent Events when the request body
// sets "stream": true, and otherwise returns a single JSON response that
// mirrors OpenAI's chat completion payload.
//
// Example:
//
// resolver := api.NewModelResolver()
// engine, _ := api.New(api.WithChatCompletions(resolver))
func WithChatCompletions(resolver *ModelResolver) Option {
return func(e *Engine) {
e.chatCompletionsResolver = resolver
}
}
// WithChatCompletionsPath sets a custom URL path for the chat completions
// endpoint. The default path is "/v1/chat/completions".
//
// Example:
//
// api.New(api.WithChatCompletionsPath("/api/v1/chat/completions"))
func WithChatCompletionsPath(path string) Option {
return func(e *Engine) {
path = core.Trim(path)
if path == "" {
e.chatCompletionsPath = defaultChatCompletionsPath
return
}
if !core.HasPrefix(path, "/") {
path = "/" + path
}
e.chatCompletionsPath = path
}
}
// 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
// application/json directly so SDK generators and ToolBridge consumers can
// fetch it without loading the UI bundle.
//
// Example:
//
// engine, _ := api.New(api.WithOpenAPISpec())
func WithOpenAPISpec() Option {
return func(e *Engine) {
e.openAPISpecEnabled = true
}
}
// WithOpenAPISpecPath sets a custom URL path for the standalone OpenAPI JSON
// endpoint. An empty string falls back to the RFC default "/v1/openapi.json".
// The override also enables the endpoint so callers can configure the URL
// without an additional WithOpenAPISpec() call.
//
// Example:
//
// api.New(api.WithOpenAPISpecPath("/api/v1/openapi.json"))
func WithOpenAPISpecPath(path string) Option {
return func(e *Engine) {
path = core.Trim(path)
if path == "" {
e.openAPISpecPath = defaultOpenAPISpecPath
e.openAPISpecEnabled = true
return
}
if !core.HasPrefix(path, "/") {
path = "/" + path
}
e.openAPISpecPath = path
e.openAPISpecEnabled = true
}
}

View file

@ -1,4 +1,4 @@
// SPDX-Licence-Identifier: EUPL-1.2
// SPDX-License-Identifier: EUPL-1.2
// Package provider defines the Service Provider Framework interfaces.
//

View file

@ -1,4 +1,4 @@
// SPDX-Licence-Identifier: EUPL-1.2
// SPDX-License-Identifier: EUPL-1.2
package provider
@ -6,8 +6,10 @@ import (
"net/http"
"net/http/httputil"
"net/url"
"strings"
core "dappco.re/go/core"
coreapi "dappco.re/go/core/api"
"github.com/gin-gonic/gin"
)
@ -39,14 +41,30 @@ type ProxyConfig struct {
type ProxyProvider struct {
config ProxyConfig
proxy *httputil.ReverseProxy
err error
}
// NewProxy creates a ProxyProvider from the given configuration.
// The upstream URL must be valid or NewProxy will panic.
// Invalid upstream URLs do not panic; the provider retains the
// configuration error and responds with a standard 500 envelope when
// mounted. This keeps provider construction safe for callers.
func NewProxy(cfg ProxyConfig) *ProxyProvider {
target, err := url.Parse(cfg.Upstream)
if err != nil {
panic("provider.NewProxy: invalid upstream URL: " + err.Error())
return &ProxyProvider{
config: cfg,
err: err,
}
}
// url.Parse accepts inputs like "127.0.0.1:9901" without error — they
// parse without a scheme or host, which causes httputil.ReverseProxy to
// fail silently at runtime. Require both to be present.
if target.Scheme == "" || target.Host == "" {
return &ProxyProvider{
config: cfg,
err: core.E("ProxyProvider.New", core.Sprintf("upstream %q must include a scheme and host (e.g. http://127.0.0.1:9901)", cfg.Upstream), nil),
}
}
proxy := httputil.NewSingleHostReverseProxy(target)
@ -54,16 +72,15 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider {
// Preserve the original Director but strip the base path so the
// upstream receives clean paths (e.g. /items instead of /api/v1/cool-widget/items).
defaultDirector := proxy.Director
basePath := strings.TrimSuffix(cfg.BasePath, "/")
basePath := core.TrimSuffix(cfg.BasePath, "/")
proxy.Director = func(req *http.Request) {
defaultDirector(req)
// Strip the base path prefix from the request path.
req.URL.Path = strings.TrimPrefix(req.URL.Path, basePath)
if req.URL.Path == "" {
req.URL.Path = "/"
req.URL.Path = stripBasePath(req.URL.Path, basePath)
if req.URL.RawPath != "" {
req.URL.RawPath = stripBasePath(req.URL.RawPath, basePath)
}
req.URL.RawPath = strings.TrimPrefix(req.URL.RawPath, basePath)
}
return &ProxyProvider{
@ -72,6 +89,43 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider {
}
}
// Err reports any configuration error detected while constructing the proxy.
// A nil error means the proxy is ready to mount and serve requests.
func (p *ProxyProvider) Err() error {
if p == nil {
return nil
}
return p.err
}
// stripBasePath removes an exact base path prefix from a request path.
// It only strips when the path matches the base path itself or lives under
// the base path boundary, so "/api" will not accidentally trim "/api-v2".
func stripBasePath(path, basePath string) string {
basePath = core.TrimSuffix(core.Trim(basePath), "/")
if basePath == "" || basePath == "/" {
if path == "" {
return "/"
}
return path
}
if path == basePath {
return "/"
}
prefix := basePath + "/"
if core.HasPrefix(path, prefix) {
trimmed := core.TrimPrefix(path, basePath)
if trimmed == "" {
return "/"
}
return trimmed
}
return path
}
// Name returns the provider identity.
func (p *ProxyProvider) Name() string {
return p.config.Name
@ -85,6 +139,19 @@ func (p *ProxyProvider) BasePath() string {
// RegisterRoutes mounts a catch-all reverse proxy handler on the router group.
func (p *ProxyProvider) RegisterRoutes(rg *gin.RouterGroup) {
rg.Any("/*path", func(c *gin.Context) {
if p == nil || p.err != nil || p.proxy == nil {
details := map[string]any{}
if p != nil && p.err != nil {
details["error"] = p.err.Error()
}
c.JSON(http.StatusInternalServerError, coreapi.FailWithDetails(
"invalid_provider_configuration",
"Provider is misconfigured",
details,
))
return
}
// Use the underlying http.ResponseWriter directly. Gin's
// responseWriter wrapper does not implement http.CloseNotifier,
// which httputil.ReverseProxy requires for cancellation signalling.

View file

@ -0,0 +1,26 @@
// SPDX-License-Identifier: EUPL-1.2
package provider
import "testing"
func TestStripBasePath_Good_ExactBoundary(t *testing.T) {
got := stripBasePath("/api/v1/cool-widget/items", "/api/v1/cool-widget")
if got != "/items" {
t.Fatalf("expected stripped path %q, got %q", "/items", got)
}
}
func TestStripBasePath_Good_RootPath(t *testing.T) {
got := stripBasePath("/api/v1/cool-widget", "/api/v1/cool-widget")
if got != "/" {
t.Fatalf("expected stripped root path %q, got %q", "/", got)
}
}
func TestStripBasePath_Good_DoesNotTrimPartialPrefix(t *testing.T) {
got := stripBasePath("/api/v1/cool-widget-2/items", "/api/v1/cool-widget")
if got != "/api/v1/cool-widget-2/items" {
t.Fatalf("expected partial prefix to remain unchanged, got %q", got)
}
}

View file

@ -183,11 +183,32 @@ func TestProxyProvider_Renderable_Good(t *testing.T) {
}
func TestProxyProvider_Ugly_InvalidUpstream(t *testing.T) {
assert.Panics(t, func() {
provider.NewProxy(provider.ProxyConfig{
p := provider.NewProxy(provider.ProxyConfig{
Name: "bad",
BasePath: "/api/v1/bad",
Upstream: "://not-a-url",
})
})
require.NotNil(t, p)
assert.Error(t, p.Err())
engine, err := api.New()
require.NoError(t, err)
engine.Register(p)
handler := engine.Handler()
req := httptest.NewRequest(http.MethodGet, "/api/v1/bad/items", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assert.Equal(t, http.StatusInternalServerError, w.Code)
var body map[string]any
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
assert.Equal(t, false, body["success"])
errObj, ok := body["error"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "invalid_provider_configuration", errObj["code"])
}

View file

@ -1,4 +1,4 @@
// SPDX-Licence-Identifier: EUPL-1.2
// SPDX-License-Identifier: EUPL-1.2
package provider
@ -88,6 +88,24 @@ func (r *Registry) Streamable() []Streamable {
return result
}
// StreamableIter returns an iterator over all registered providers that
// implement the Streamable interface.
func (r *Registry) StreamableIter() iter.Seq[Streamable] {
r.mu.RLock()
providers := slices.Clone(r.providers)
r.mu.RUnlock()
return func(yield func(Streamable) bool) {
for _, p := range providers {
if s, ok := p.(Streamable); ok {
if !yield(s) {
return
}
}
}
}
}
// Describable returns all providers that implement the Describable interface.
func (r *Registry) Describable() []Describable {
r.mu.RLock()
@ -101,6 +119,24 @@ func (r *Registry) Describable() []Describable {
return result
}
// DescribableIter returns an iterator over all registered providers that
// implement the Describable interface.
func (r *Registry) DescribableIter() iter.Seq[Describable] {
r.mu.RLock()
providers := slices.Clone(r.providers)
r.mu.RUnlock()
return func(yield func(Describable) bool) {
for _, p := range providers {
if d, ok := p.(Describable); ok {
if !yield(d) {
return
}
}
}
}
}
// Renderable returns all providers that implement the Renderable interface.
func (r *Registry) Renderable() []Renderable {
r.mu.RLock()
@ -114,12 +150,32 @@ func (r *Registry) Renderable() []Renderable {
return result
}
// RenderableIter returns an iterator over all registered providers that
// implement the Renderable interface.
func (r *Registry) RenderableIter() iter.Seq[Renderable] {
r.mu.RLock()
providers := slices.Clone(r.providers)
r.mu.RUnlock()
return func(yield func(Renderable) bool) {
for _, p := range providers {
if rv, ok := p.(Renderable); ok {
if !yield(rv) {
return
}
}
}
}
}
// ProviderInfo is a serialisable summary of a registered provider.
type ProviderInfo struct {
Name string `json:"name"`
BasePath string `json:"basePath"`
Channels []string `json:"channels,omitempty"`
Element *ElementSpec `json:"element,omitempty"`
SpecFile string `json:"specFile,omitempty"`
Upstream string `json:"upstream,omitempty"`
}
// Info returns a summary of all registered providers.
@ -140,7 +196,76 @@ func (r *Registry) Info() []ProviderInfo {
elem := rv.Element()
info.Element = &elem
}
if sf, ok := p.(interface{ SpecFile() string }); ok {
info.SpecFile = sf.SpecFile()
}
if up, ok := p.(interface{ Upstream() string }); ok {
info.Upstream = up.Upstream()
}
infos = append(infos, info)
}
return infos
}
// InfoIter returns an iterator over all registered provider summaries.
// The iterator snapshots the current registry contents so callers can range
// over it without holding the registry lock.
func (r *Registry) InfoIter() iter.Seq[ProviderInfo] {
r.mu.RLock()
providers := slices.Clone(r.providers)
r.mu.RUnlock()
return func(yield func(ProviderInfo) bool) {
for _, p := range providers {
info := ProviderInfo{
Name: p.Name(),
BasePath: p.BasePath(),
}
if s, ok := p.(Streamable); ok {
info.Channels = s.Channels()
}
if rv, ok := p.(Renderable); ok {
elem := rv.Element()
info.Element = &elem
}
if sf, ok := p.(interface{ SpecFile() string }); ok {
info.SpecFile = sf.SpecFile()
}
if up, ok := p.(interface{ Upstream() string }); ok {
info.Upstream = up.Upstream()
}
if !yield(info) {
return
}
}
}
}
// SpecFiles returns all non-empty provider OpenAPI spec file paths.
// The result is deduplicated and sorted for stable discovery output.
func (r *Registry) SpecFiles() []string {
r.mu.RLock()
defer r.mu.RUnlock()
files := make(map[string]struct{}, len(r.providers))
for _, p := range r.providers {
if sf, ok := p.(interface{ SpecFile() string }); ok {
if path := sf.SpecFile(); path != "" {
files[path] = struct{}{}
}
}
}
out := make([]string, 0, len(files))
for path := range files {
out = append(out, path)
}
slices.Sort(out)
return out
}
// SpecFilesIter returns an iterator over all non-empty provider OpenAPI spec files.
func (r *Registry) SpecFilesIter() iter.Seq[string] {
return slices.Values(r.SpecFiles())
}

View file

@ -38,6 +38,13 @@ func (r *renderableProvider) Element() provider.ElementSpec {
return provider.ElementSpec{Tag: "core-stub-panel", Source: "/assets/stub.js"}
}
type specFileProvider struct {
stubProvider
specFile string
}
func (s *specFileProvider) SpecFile() string { return s.specFile }
type fullProvider struct {
streamableProvider
}
@ -112,6 +119,36 @@ func TestRegistry_Streamable_Good(t *testing.T) {
assert.Equal(t, []string{"stub.event"}, s[0].Channels())
}
func TestRegistry_StreamableIter_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
reg.Add(&streamableProvider{})
var streamables []provider.Streamable
for s := range reg.StreamableIter() {
streamables = append(streamables, s)
}
assert.Len(t, streamables, 1)
assert.Equal(t, []string{"stub.event"}, streamables[0].Channels())
}
func TestRegistry_StreamableIter_Good_SnapshotCurrentProviders(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&streamableProvider{})
iter := reg.StreamableIter()
reg.Add(&streamableProvider{})
var streamables []provider.Streamable
for s := range iter {
streamables = append(streamables, s)
}
assert.Len(t, streamables, 1)
assert.Equal(t, []string{"stub.event"}, streamables[0].Channels())
}
func TestRegistry_Describable_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{}) // not describable
@ -122,6 +159,36 @@ func TestRegistry_Describable_Good(t *testing.T) {
assert.Len(t, d[0].Describe(), 1)
}
func TestRegistry_DescribableIter_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
reg.Add(&describableProvider{})
var describables []provider.Describable
for d := range reg.DescribableIter() {
describables = append(describables, d)
}
assert.Len(t, describables, 1)
assert.Len(t, describables[0].Describe(), 1)
}
func TestRegistry_DescribableIter_Good_SnapshotCurrentProviders(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&describableProvider{})
iter := reg.DescribableIter()
reg.Add(&describableProvider{})
var describables []provider.Describable
for d := range iter {
describables = append(describables, d)
}
assert.Len(t, describables, 1)
assert.Len(t, describables[0].Describe(), 1)
}
func TestRegistry_Renderable_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{}) // not renderable
@ -132,6 +199,36 @@ func TestRegistry_Renderable_Good(t *testing.T) {
assert.Equal(t, "core-stub-panel", r[0].Element().Tag)
}
func TestRegistry_RenderableIter_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
reg.Add(&renderableProvider{})
var renderables []provider.Renderable
for r := range reg.RenderableIter() {
renderables = append(renderables, r)
}
assert.Len(t, renderables, 1)
assert.Equal(t, "core-stub-panel", renderables[0].Element().Tag)
}
func TestRegistry_RenderableIter_Good_SnapshotCurrentProviders(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&renderableProvider{})
iter := reg.RenderableIter()
reg.Add(&renderableProvider{})
var renderables []provider.Renderable
for r := range iter {
renderables = append(renderables, r)
}
assert.Len(t, renderables, 1)
assert.Equal(t, "core-stub-panel", renderables[0].Element().Tag)
}
func TestRegistry_Info_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&fullProvider{})
@ -147,6 +244,59 @@ func TestRegistry_Info_Good(t *testing.T) {
assert.Equal(t, "core-full-panel", info.Element.Tag)
}
func TestRegistry_Info_Good_ProxyMetadata(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(provider.NewProxy(provider.ProxyConfig{
Name: "proxy",
BasePath: "/api/proxy",
Upstream: "http://127.0.0.1:9999",
SpecFile: "/tmp/proxy-openapi.json",
}))
infos := reg.Info()
require.Len(t, infos, 1)
info := infos[0]
assert.Equal(t, "proxy", info.Name)
assert.Equal(t, "/api/proxy", info.BasePath)
assert.Equal(t, "/tmp/proxy-openapi.json", info.SpecFile)
assert.Equal(t, "http://127.0.0.1:9999", info.Upstream)
}
func TestRegistry_InfoIter_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&fullProvider{})
var infos []provider.ProviderInfo
for info := range reg.InfoIter() {
infos = append(infos, info)
}
require.Len(t, infos, 1)
info := infos[0]
assert.Equal(t, "full", info.Name)
assert.Equal(t, "/api/full", info.BasePath)
assert.Equal(t, []string{"stub.event"}, info.Channels)
require.NotNil(t, info.Element)
assert.Equal(t, "core-full-panel", info.Element.Tag)
}
func TestRegistry_InfoIter_Good_SnapshotCurrentProviders(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&fullProvider{})
iter := reg.InfoIter()
reg.Add(&specFileProvider{specFile: "/tmp/later.json"})
var infos []provider.ProviderInfo
for info := range iter {
infos = append(infos, info)
}
require.Len(t, infos, 1)
assert.Equal(t, "full", infos[0].Name)
}
func TestRegistry_Iter_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
@ -158,3 +308,27 @@ func TestRegistry_Iter_Good(t *testing.T) {
}
assert.Equal(t, 2, count)
}
func TestRegistry_SpecFiles_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&stubProvider{})
reg.Add(&specFileProvider{specFile: "/tmp/b.json"})
reg.Add(&specFileProvider{specFile: "/tmp/a.yaml"})
reg.Add(&specFileProvider{specFile: "/tmp/a.yaml"})
reg.Add(&specFileProvider{specFile: ""})
assert.Equal(t, []string{"/tmp/a.yaml", "/tmp/b.json"}, reg.SpecFiles())
}
func TestRegistry_SpecFilesIter_Good(t *testing.T) {
reg := provider.NewRegistry()
reg.Add(&specFileProvider{specFile: "/tmp/z.json"})
reg.Add(&specFileProvider{specFile: "/tmp/x.json"})
var files []string
for file := range reg.SpecFilesIter() {
files = append(files, file)
}
assert.Equal(t, []string{"/tmp/x.json", "/tmp/z.json"}, files)
}

277
ratelimit.go Normal file
View file

@ -0,0 +1,277 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"crypto/sha256"
"encoding/hex"
"math"
"net/http"
"strconv"
"sync"
"time"
core "dappco.re/go/core"
"github.com/gin-gonic/gin"
)
const (
rateLimitCleanupInterval = time.Minute
rateLimitStaleAfter = 10 * time.Minute
// rateLimitMaxBuckets caps the total number of tracked keys to prevent
// unbounded memory growth under high-cardinality traffic (e.g. scanning
// bots cycling random IPs). When the cap is reached, new keys that cannot
// evict a stale bucket are routed to a shared overflow bucket so requests
// are still rate-limited rather than bypassing the limiter entirely.
rateLimitMaxBuckets = 100_000
rateLimitOverflowKey = "__overflow__"
)
type rateLimitStore struct {
mu sync.Mutex
buckets map[string]*rateLimitBucket
limit int
lastSweep time.Time
}
type rateLimitBucket struct {
mu sync.Mutex
tokens float64
last time.Time
lastSeen time.Time
}
type rateLimitDecision struct {
allowed bool
retryAfter time.Duration
limit int
remaining int
resetAt time.Time
}
func newRateLimitStore(limit int) *rateLimitStore {
now := time.Now()
return &rateLimitStore{
buckets: make(map[string]*rateLimitBucket),
limit: limit,
lastSweep: now,
}
}
func (s *rateLimitStore) allow(key string) rateLimitDecision {
now := time.Now()
s.mu.Lock()
bucket, ok := s.buckets[key]
if !ok || now.Sub(bucket.lastSeen) > rateLimitStaleAfter {
// Enforce the bucket cap before inserting a new entry. First try to
// evict a single stale entry; if none exists and the map is full,
// route the request to the shared overflow bucket so it is still
// rate-limited rather than bypassing the limiter.
if !ok && len(s.buckets) >= rateLimitMaxBuckets {
evicted := false
for k, candidate := range s.buckets {
if now.Sub(candidate.lastSeen) > rateLimitStaleAfter {
delete(s.buckets, k)
evicted = true
break
}
}
if !evicted {
// Cap reached and no stale entry to evict: use overflow bucket.
key = rateLimitOverflowKey
if ob, exists := s.buckets[key]; exists {
bucket = ob
ok = true
}
}
}
if !ok {
bucket = &rateLimitBucket{
tokens: float64(s.limit),
last: now,
lastSeen: now,
}
s.buckets[key] = bucket
} else {
bucket.lastSeen = now
}
} else {
bucket.lastSeen = now
}
if now.Sub(s.lastSweep) >= rateLimitCleanupInterval {
for k, candidate := range s.buckets {
if now.Sub(candidate.lastSeen) > rateLimitStaleAfter {
delete(s.buckets, k)
}
}
s.lastSweep = now
}
s.mu.Unlock()
bucket.mu.Lock()
defer bucket.mu.Unlock()
elapsed := now.Sub(bucket.last)
if elapsed > 0 {
refill := elapsed.Seconds() * float64(s.limit)
if bucket.tokens+refill > float64(s.limit) {
bucket.tokens = float64(s.limit)
} else {
bucket.tokens += refill
}
bucket.last = now
}
if bucket.tokens >= 1 {
bucket.tokens--
return rateLimitDecision{
allowed: true,
limit: s.limit,
remaining: int(math.Floor(bucket.tokens)),
resetAt: now.Add(timeUntilFull(bucket.tokens, s.limit)),
}
}
deficit := 1 - bucket.tokens
wait := time.Duration(deficit / float64(s.limit) * float64(time.Second))
if wait <= 0 {
wait = time.Second / time.Duration(s.limit)
if wait <= 0 {
wait = time.Second
}
}
return rateLimitDecision{
allowed: false,
retryAfter: wait,
limit: s.limit,
remaining: 0,
resetAt: now.Add(wait),
}
}
func rateLimitMiddleware(limit int) gin.HandlerFunc {
if limit <= 0 {
return func(c *gin.Context) {
c.Next()
}
}
store := newRateLimitStore(limit)
return func(c *gin.Context) {
key := clientRateLimitKey(c)
decision := store.allow(key)
if !decision.allowed {
secs := int(decision.retryAfter / time.Second)
if decision.retryAfter%time.Second != 0 {
secs++
}
if secs < 1 {
secs = 1
}
setRateLimitHeaders(c, decision.limit, decision.remaining, decision.resetAt)
c.Header("Retry-After", strconv.Itoa(secs))
c.AbortWithStatusJSON(http.StatusTooManyRequests, Fail(
"rate_limit_exceeded",
"Too many requests",
))
return
}
setRateLimitHeaders(c, decision.limit, decision.remaining, decision.resetAt)
c.Next()
}
}
func setRateLimitHeaders(c *gin.Context, limit, remaining int, resetAt time.Time) {
if limit > 0 {
c.Header("X-RateLimit-Limit", strconv.Itoa(limit))
}
if remaining < 0 {
remaining = 0
}
c.Header("X-RateLimit-Remaining", strconv.Itoa(remaining))
if !resetAt.IsZero() {
reset := resetAt.Unix()
if reset <= time.Now().Unix() {
reset = time.Now().Add(time.Second).Unix()
}
c.Header("X-RateLimit-Reset", strconv.FormatInt(reset, 10))
}
}
func timeUntilFull(tokens float64, limit int) time.Duration {
if limit <= 0 {
return 0
}
missing := float64(limit) - tokens
if missing <= 0 {
return 0
}
seconds := missing / float64(limit)
if seconds <= 0 {
return 0
}
return time.Duration(math.Ceil(seconds * float64(time.Second)))
}
// clientRateLimitKey derives a bucket key for the request. It prefers a
// validated principal placed in context by auth middleware, then falls back to
// raw credential headers (X-API-Key or Bearer token, hashed with SHA-256 so
// secrets are never stored in the bucket map), and finally falls back to the
// client IP when no credentials are present.
func clientRateLimitKey(c *gin.Context) string {
// Prefer a validated principal placed in context by auth middleware.
if principal, ok := c.Get("principal"); ok && principal != nil {
if s, ok := principal.(string); ok && s != "" {
return "principal:" + s
}
}
if userID, ok := c.Get("userID"); ok && userID != nil {
if s, ok := userID.(string); ok && s != "" {
return "user:" + s
}
}
// Fall back to credential headers before the IP so that different API
// keys coming from the same NAT address are bucketed independently. The
// raw secret is never stored — it is hashed with SHA-256 first.
if apiKey := core.Trim(c.GetHeader("X-API-Key")); apiKey != "" {
h := sha256.Sum256([]byte(apiKey))
return "cred:sha256:" + hex.EncodeToString(h[:])
}
if bearer := bearerTokenFromHeader(c.GetHeader("Authorization")); bearer != "" {
h := sha256.Sum256([]byte(bearer))
return "cred:sha256:" + hex.EncodeToString(h[:])
}
// Last resort: fall back to IP address.
if ip := c.ClientIP(); ip != "" {
return "ip:" + ip
}
if c.Request != nil && c.Request.RemoteAddr != "" {
return "ip:" + c.Request.RemoteAddr
}
return "ip:unknown"
}
func bearerTokenFromHeader(header string) string {
header = core.Trim(header)
if header == "" {
return ""
}
parts := core.SplitN(header, " ", 2)
if len(parts) != 2 || core.Lower(parts[0]) != "bearer" {
return ""
}
return core.Trim(parts[1])
}

240
ratelimit_test.go Normal file
View file

@ -0,0 +1,240 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
type rateLimitTestGroup struct{}
func (r *rateLimitTestGroup) Name() string { return "rate-limit" }
func (r *rateLimitTestGroup) BasePath() string { return "/rate" }
func (r *rateLimitTestGroup) RegisterRoutes(rg *gin.RouterGroup) {
rg.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, api.OK("pong"))
})
}
func TestWithRateLimit_Good_AllowsBurstThenRejects(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRateLimit(2))
e.Register(&rateLimitTestGroup{})
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req1.RemoteAddr = "203.0.113.10:1234"
h.ServeHTTP(w1, req1)
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" {
t.Fatalf("expected X-RateLimit-Limit=2, got %q", got)
}
if got := w1.Header().Get("X-RateLimit-Remaining"); got != "1" {
t.Fatalf("expected X-RateLimit-Remaining=1, got %q", got)
}
if got := w1.Header().Get("X-RateLimit-Reset"); got == "" {
t.Fatal("expected X-RateLimit-Reset on successful response")
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req2.RemoteAddr = "203.0.113.10:1234"
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected second request to succeed, got %d", w2.Code)
}
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req3.RemoteAddr = "203.0.113.10:1234"
h.ServeHTTP(w3, req3)
if w3.Code != http.StatusTooManyRequests {
t.Fatalf("expected third request to be rate limited, got %d", w3.Code)
}
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" {
t.Fatalf("expected X-RateLimit-Limit=2 on 429, got %q", got)
}
if got := w3.Header().Get("X-RateLimit-Remaining"); got != "0" {
t.Fatalf("expected X-RateLimit-Remaining=0 on 429, got %q", got)
}
if got := w3.Header().Get("X-RateLimit-Reset"); got == "" {
t.Fatal("expected X-RateLimit-Reset on 429 response")
}
var resp api.Response[any]
if err := json.Unmarshal(w3.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Success {
t.Fatal("expected Success=false for rate limited response")
}
if resp.Error == nil || resp.Error.Code != "rate_limit_exceeded" {
t.Fatalf("expected rate_limit_exceeded error, got %+v", resp.Error)
}
}
func TestWithRateLimit_Good_IsolatesPerIP(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRateLimit(1))
e.Register(&rateLimitTestGroup{})
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req1.RemoteAddr = "203.0.113.10:1234"
h.ServeHTTP(w1, req1)
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" {
t.Fatalf("expected X-RateLimit-Limit=1, got %q", got)
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req2.RemoteAddr = "203.0.113.11:1234"
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected second IP to have its own bucket, got %d", w2.Code)
}
}
func TestWithRateLimit_Good_IsolatesPerAPIKey(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRateLimit(1))
e.Register(&rateLimitTestGroup{})
h := e.Handler()
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")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected first API key request to succeed, got %d", w1.Code)
}
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")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected second API key to have its own bucket, got %d", w2.Code)
}
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")
h.ServeHTTP(w3, req3)
if w3.Code != http.StatusTooManyRequests {
t.Fatalf("expected repeated API key to be rate limited, got %d", w3.Code)
}
}
func TestWithRateLimit_Good_UsesBearerTokenWhenPresent(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRateLimit(1))
e.Register(&rateLimitTestGroup{})
h := e.Handler()
w1 := httptest.NewRecorder()
req1, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req1.RemoteAddr = "203.0.113.30:1234"
req1.Header.Set("Authorization", "Bearer token-a")
h.ServeHTTP(w1, req1)
if w1.Code != http.StatusOK {
t.Fatalf("expected first bearer token request to succeed, got %d", w1.Code)
}
w2 := httptest.NewRecorder()
req2, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req2.RemoteAddr = "203.0.113.30:1234"
req2.Header.Set("Authorization", "Bearer token-b")
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("expected second bearer token to have its own bucket, got %d", w2.Code)
}
w3 := httptest.NewRecorder()
req3, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req3.RemoteAddr = "203.0.113.30:1234"
req3.Header.Set("Authorization", "Bearer token-a")
h.ServeHTTP(w3, req3)
if w3.Code != http.StatusTooManyRequests {
t.Fatalf("expected repeated bearer token to be rate limited, got %d", w3.Code)
}
}
func TestWithRateLimit_Good_RefillsOverTime(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRateLimit(1))
e.Register(&rateLimitTestGroup{})
h := e.Handler()
req, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req.RemoteAddr = "203.0.113.12:1234"
w1 := httptest.NewRecorder()
h.ServeHTTP(w1, req.Clone(req.Context()))
if w1.Code != http.StatusOK {
t.Fatalf("expected first request to succeed, got %d", w1.Code)
}
w2 := httptest.NewRecorder()
req2 := req.Clone(req.Context())
req2.RemoteAddr = req.RemoteAddr
h.ServeHTTP(w2, req2)
if w2.Code != http.StatusTooManyRequests {
t.Fatalf("expected second request to be rate limited, got %d", w2.Code)
}
time.Sleep(1100 * time.Millisecond)
w3 := httptest.NewRecorder()
req3 := req.Clone(req.Context())
req3.RemoteAddr = req.RemoteAddr
h.ServeHTTP(w3, req3)
if w3.Code != http.StatusOK {
t.Fatalf("expected bucket to refill after waiting, got %d", w3.Code)
}
}
func TestWithRateLimit_Ugly_NonPositiveLimitDisablesMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
e, _ := api.New(api.WithRateLimit(0))
e.Register(&rateLimitTestGroup{})
h := e.Handler()
for i := 0; i < 3; i++ {
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/rate/ping", nil)
req.RemoteAddr = "203.0.113.13:1234"
h.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected request %d to succeed with disabled limiter, got %d", i+1, w.Code)
}
}
}

View file

@ -2,7 +2,14 @@
package api
import "github.com/gin-gonic/gin"
// Response is the standard envelope for all API responses.
//
// Example:
//
// resp := api.OK(map[string]any{"id": 42})
// resp.Success // true
type Response[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
@ -11,6 +18,10 @@ type Response[T any] struct {
}
// Error describes a failed API request.
//
// Example:
//
// err := api.Error{Code: "invalid_input", Message: "Name is required"}
type Error struct {
Code string `json:"code"`
Message string `json:"message"`
@ -18,6 +29,10 @@ type Error struct {
}
// Meta carries pagination and request metadata.
//
// Example:
//
// meta := api.Meta{RequestID: "req_123", Duration: "12ms"}
type Meta struct {
RequestID string `json:"request_id,omitempty"`
Duration string `json:"duration,omitempty"`
@ -27,6 +42,10 @@ type Meta struct {
}
// OK wraps data in a successful response envelope.
//
// Example:
//
// c.JSON(http.StatusOK, api.OK(map[string]any{"name": "status"}))
func OK[T any](data T) Response[T] {
return Response[T]{
Success: true,
@ -35,6 +54,10 @@ func OK[T any](data T) Response[T] {
}
// Fail creates an error response with the given code and message.
//
// Example:
//
// c.JSON(http.StatusBadRequest, api.Fail("invalid_input", "Name is required"))
func Fail(code, message string) Response[any] {
return Response[any]{
Success: false,
@ -46,6 +69,10 @@ func Fail(code, message string) Response[any] {
}
// FailWithDetails creates an error response with additional detail payload.
//
// Example:
//
// c.JSON(http.StatusBadRequest, api.FailWithDetails("invalid_input", "Name is required", map[string]any{"field": "name"}))
func FailWithDetails(code, message string, details any) Response[any] {
return Response[any]{
Success: false,
@ -58,6 +85,10 @@ func FailWithDetails(code, message string, details any) Response[any] {
}
// Paginated wraps data in a successful response with pagination metadata.
//
// Example:
//
// c.JSON(http.StatusOK, api.Paginated(items, 2, 50, 200))
func Paginated[T any](data T, page, perPage, total int) Response[T] {
return Response[T]{
Success: true,
@ -69,3 +100,31 @@ func Paginated[T any](data T, page, perPage, total int) Response[T] {
},
}
}
// AttachRequestMeta merges request metadata into an existing response envelope.
// Existing pagination metadata is preserved; request_id and duration are added
// when available from the Gin context.
//
// Example:
//
// resp = api.AttachRequestMeta(c, resp)
func AttachRequestMeta[T any](c *gin.Context, resp Response[T]) Response[T] {
meta := GetRequestMeta(c)
if meta == nil {
return resp
}
if resp.Meta == nil {
resp.Meta = meta
return resp
}
if resp.Meta.RequestID == "" {
resp.Meta.RequestID = meta.RequestID
}
if resp.Meta.Duration == "" {
resp.Meta.Duration = meta.Duration
}
return resp
}

287
response_meta.go Normal file
View file

@ -0,0 +1,287 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bufio"
"bytes"
"encoding/json"
"io"
"mime"
"net"
"net/http"
"strconv"
"time"
core "dappco.re/go/core"
"github.com/gin-gonic/gin"
)
// responseMetaRecorder buffers JSON responses so request metadata can be
// injected into the standard envelope before the body is written to the client.
type responseMetaRecorder struct {
gin.ResponseWriter
headers http.Header
body bytes.Buffer
size int
status int
wroteHeader bool
committed bool
passthrough bool
}
func newResponseMetaRecorder(w gin.ResponseWriter) *responseMetaRecorder {
headers := make(http.Header)
for k, vals := range w.Header() {
headers[k] = append([]string(nil), vals...)
}
return &responseMetaRecorder{
ResponseWriter: w,
headers: headers,
status: http.StatusOK,
}
}
func (w *responseMetaRecorder) Header() http.Header {
return w.headers
}
func (w *responseMetaRecorder) WriteHeader(code int) {
if w.passthrough {
w.status = code
w.wroteHeader = true
w.ResponseWriter.WriteHeader(code)
return
}
w.status = code
w.wroteHeader = true
}
func (w *responseMetaRecorder) WriteHeaderNow() {
if w.passthrough {
w.wroteHeader = true
w.ResponseWriter.WriteHeaderNow()
return
}
w.wroteHeader = true
}
func (w *responseMetaRecorder) Write(data []byte) (int, error) {
if w.passthrough {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(data)
}
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
n, err := w.body.Write(data)
w.size += n
return n, err
}
func (w *responseMetaRecorder) WriteString(s string) (int, error) {
if w.passthrough {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.WriteString(s)
}
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
n, err := w.body.WriteString(s)
w.size += n
return n, err
}
func (w *responseMetaRecorder) Flush() {
if w.passthrough {
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
return
}
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
w.commit(true)
w.passthrough = true
if f, ok := w.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func (w *responseMetaRecorder) Status() int {
if w.wroteHeader {
return w.status
}
return http.StatusOK
}
func (w *responseMetaRecorder) Size() int {
if w.passthrough {
return w.ResponseWriter.Size()
}
return w.size
}
func (w *responseMetaRecorder) Written() bool {
return w.wroteHeader
}
func (w *responseMetaRecorder) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if w.passthrough {
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, io.ErrClosedPipe
}
w.wroteHeader = true
w.passthrough = true
if h, ok := w.ResponseWriter.(http.Hijacker); ok {
return h.Hijack()
}
return nil, nil, io.ErrClosedPipe
}
func (w *responseMetaRecorder) commit(writeBody bool) {
if w.committed {
return
}
for k := range w.ResponseWriter.Header() {
w.ResponseWriter.Header().Del(k)
}
for k, vals := range w.headers {
for _, v := range vals {
w.ResponseWriter.Header().Add(k, v)
}
}
w.ResponseWriter.WriteHeader(w.Status())
if writeBody {
_, _ = w.ResponseWriter.Write(w.body.Bytes())
w.body.Reset()
}
w.committed = true
}
// responseMetaMiddleware injects request metadata into JSON envelope
// responses before they are written to the client.
func responseMetaMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if _, ok := c.Get(requestStartContextKey); !ok {
c.Set(requestStartContextKey, time.Now())
}
recorder := newResponseMetaRecorder(c.Writer)
c.Writer = recorder
c.Next()
if recorder.passthrough {
return
}
body := recorder.body.Bytes()
if meta := GetRequestMeta(c); meta != nil && shouldAttachResponseMeta(recorder.Header().Get("Content-Type"), body) {
if refreshed := refreshResponseMetaBody(body, meta); refreshed != nil {
body = refreshed
}
}
recorder.body.Reset()
_, _ = recorder.body.Write(body)
recorder.size = len(body)
recorder.Header().Set("Content-Length", strconv.Itoa(len(body)))
recorder.commit(true)
}
}
// refreshResponseMetaBody injects request metadata into a cached or buffered
// JSON envelope without disturbing existing pagination metadata.
func refreshResponseMetaBody(body []byte, meta *Meta) []byte {
if meta == nil {
return body
}
var payload any
dec := json.NewDecoder(bytes.NewReader(body))
dec.UseNumber()
if err := dec.Decode(&payload); err != nil {
return body
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return body
}
obj, ok := payload.(map[string]any)
if !ok {
return body
}
if _, ok := obj["success"]; !ok {
if _, ok := obj["error"]; !ok {
return body
}
}
current := map[string]any{}
if existing, ok := obj["meta"].(map[string]any); ok {
current = existing
}
if meta.RequestID != "" {
current["request_id"] = meta.RequestID
}
if meta.Duration != "" {
current["duration"] = meta.Duration
}
obj["meta"] = current
updated, err := json.Marshal(obj)
if err != nil {
return body
}
return updated
}
func shouldAttachResponseMeta(contentType string, body []byte) bool {
if !isJSONContentType(contentType) {
return false
}
trimmed := bytes.TrimSpace(body)
return len(trimmed) > 0 && trimmed[0] == '{'
}
func isJSONContentType(contentType string) bool {
if core.Trim(contentType) == "" {
return false
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = core.Trim(contentType)
}
mediaType = core.Lower(mediaType)
return mediaType == "application/json" ||
core.HasSuffix(mediaType, "+json") ||
core.HasSuffix(mediaType, "/json")
}

46
runtime_config.go Normal file
View file

@ -0,0 +1,46 @@
// SPDX-License-Identifier: EUPL-1.2
package api
// RuntimeConfig captures the engine's current runtime-facing configuration in
// a single snapshot.
//
// It groups the existing Swagger, transport, GraphQL, cache, and i18n snapshots
// so callers can inspect the active engine surface without joining multiple
// method results themselves.
//
// Example:
//
// cfg := engine.RuntimeConfig()
type RuntimeConfig struct {
Swagger SwaggerConfig
Transport TransportConfig
GraphQL GraphQLConfig
Cache CacheConfig
I18n I18nConfig
Authentik AuthentikConfig
}
// RuntimeConfig returns a stable snapshot of the engine's current runtime
// configuration.
//
// The result clones the underlying snapshots so callers can safely retain or
// modify the returned value.
//
// Example:
//
// cfg := engine.RuntimeConfig()
func (e *Engine) RuntimeConfig() RuntimeConfig {
if e == nil {
return RuntimeConfig{}
}
return RuntimeConfig{
Swagger: e.SwaggerConfig(),
Transport: e.TransportConfig(),
GraphQL: e.GraphQLConfig(),
Cache: e.CacheConfig(),
I18n: e.I18nConfig(),
Authentik: e.AuthentikConfig(),
}
}

57
servers.go Normal file
View file

@ -0,0 +1,57 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"strings"
core "dappco.re/go/core"
)
// normaliseServers trims whitespace, removes empty entries, and preserves
// the first occurrence of each server URL.
func normaliseServers(servers []string) []string {
if len(servers) == 0 {
return nil
}
cleaned := make([]string, 0, len(servers))
seen := make(map[string]struct{}, len(servers))
for _, server := range servers {
server = normaliseServer(server)
if server == "" {
continue
}
if _, ok := seen[server]; ok {
continue
}
seen[server] = struct{}{}
cleaned = append(cleaned, server)
}
if len(cleaned) == 0 {
return nil
}
return cleaned
}
// normaliseServer trims surrounding whitespace and removes a trailing slash
// from non-root server URLs so equivalent metadata collapses to one entry.
func normaliseServer(server string) string {
server = core.Trim(server)
if server == "" {
return ""
}
if server == "/" {
return server
}
server = strings.TrimRight(server, "/")
if server == "" {
return "/"
}
return server
}

288
spec_builder_helper.go Normal file
View file

@ -0,0 +1,288 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"reflect"
"slices"
core "dappco.re/go/core"
)
// SwaggerConfig captures the configured Swagger/OpenAPI metadata for an Engine.
//
// It is intentionally small and serialisable so callers can inspect the active
// documentation surface without rebuilding an OpenAPI document.
//
// Example:
//
// cfg := api.SwaggerConfig{Title: "Service", Summary: "Public API"}
type SwaggerConfig struct {
Enabled bool
Path string
Title string
Summary string
Description string
Version string
TermsOfService string
ContactName string
ContactURL string
ContactEmail string
Servers []string
LicenseName string
LicenseURL string
SecuritySchemes map[string]any
ExternalDocsDescription string
ExternalDocsURL string
}
// OpenAPISpecBuilder returns a SpecBuilder populated from the engine's current
// Swagger, transport, cache, i18n, and Authentik metadata.
//
// Example:
//
// builder := engine.OpenAPISpecBuilder()
func (e *Engine) OpenAPISpecBuilder() *SpecBuilder {
if e == nil {
return &SpecBuilder{}
}
runtime := e.RuntimeConfig()
builder := &SpecBuilder{
Title: runtime.Swagger.Title,
Summary: runtime.Swagger.Summary,
Description: runtime.Swagger.Description,
Version: runtime.Swagger.Version,
SwaggerEnabled: runtime.Swagger.Enabled,
TermsOfService: runtime.Swagger.TermsOfService,
ContactName: runtime.Swagger.ContactName,
ContactURL: runtime.Swagger.ContactURL,
ContactEmail: runtime.Swagger.ContactEmail,
Servers: slices.Clone(runtime.Swagger.Servers),
LicenseName: runtime.Swagger.LicenseName,
LicenseURL: runtime.Swagger.LicenseURL,
SecuritySchemes: cloneSecuritySchemes(runtime.Swagger.SecuritySchemes),
ExternalDocsDescription: runtime.Swagger.ExternalDocsDescription,
ExternalDocsURL: runtime.Swagger.ExternalDocsURL,
}
builder.SwaggerPath = runtime.Transport.SwaggerPath
builder.GraphQLEnabled = runtime.GraphQL.Enabled
builder.GraphQLPath = runtime.GraphQL.Path
builder.GraphQLPlayground = runtime.GraphQL.Playground
builder.GraphQLPlaygroundPath = runtime.GraphQL.PlaygroundPath
builder.WSPath = runtime.Transport.WSPath
builder.WSEnabled = runtime.Transport.WSEnabled
builder.SSEPath = runtime.Transport.SSEPath
builder.SSEEnabled = runtime.Transport.SSEEnabled
builder.PprofEnabled = runtime.Transport.PprofEnabled
builder.ExpvarEnabled = runtime.Transport.ExpvarEnabled
builder.ChatCompletionsEnabled = runtime.Transport.ChatCompletionsEnabled
builder.ChatCompletionsPath = runtime.Transport.ChatCompletionsPath
builder.OpenAPISpecEnabled = runtime.Transport.OpenAPISpecEnabled
builder.OpenAPISpecPath = runtime.Transport.OpenAPISpecPath
builder.CacheEnabled = runtime.Cache.Enabled
if runtime.Cache.TTL > 0 {
builder.CacheTTL = runtime.Cache.TTL.String()
}
builder.CacheMaxEntries = runtime.Cache.MaxEntries
builder.CacheMaxBytes = runtime.Cache.MaxBytes
builder.I18nDefaultLocale = runtime.I18n.DefaultLocale
builder.I18nSupportedLocales = slices.Clone(runtime.I18n.Supported)
builder.AuthentikIssuer = runtime.Authentik.Issuer
builder.AuthentikClientID = runtime.Authentik.ClientID
builder.AuthentikTrustedProxy = runtime.Authentik.TrustedProxy
builder.AuthentikPublicPaths = slices.Clone(runtime.Authentik.PublicPaths)
return builder
}
// SwaggerConfig returns the currently configured Swagger metadata for the engine.
//
// The result snapshots the Engine state at call time and clones slices/maps so
// callers can safely reuse or modify the returned value.
//
// Example:
//
// cfg := engine.SwaggerConfig()
func (e *Engine) SwaggerConfig() SwaggerConfig {
if e == nil {
return SwaggerConfig{}
}
cfg := SwaggerConfig{
Enabled: e.swaggerEnabled,
Title: e.swaggerTitle,
Summary: e.swaggerSummary,
Description: e.swaggerDesc,
Version: e.swaggerVersion,
TermsOfService: e.swaggerTermsOfService,
ContactName: e.swaggerContactName,
ContactURL: e.swaggerContactURL,
ContactEmail: e.swaggerContactEmail,
Servers: slices.Clone(e.swaggerServers),
LicenseName: e.swaggerLicenseName,
LicenseURL: e.swaggerLicenseURL,
SecuritySchemes: cloneSecuritySchemes(e.swaggerSecuritySchemes),
ExternalDocsDescription: e.swaggerExternalDocsDescription,
ExternalDocsURL: e.swaggerExternalDocsURL,
}
if core.Trim(e.swaggerPath) != "" {
cfg.Path = normaliseSwaggerPath(e.swaggerPath)
}
return cfg
}
func cloneSecuritySchemes(schemes map[string]any) map[string]any {
if len(schemes) == 0 {
return nil
}
out := make(map[string]any, len(schemes))
for name, scheme := range schemes {
out[name] = cloneOpenAPIValue(scheme)
}
return out
}
func cloneRouteDescription(rd RouteDescription) RouteDescription {
out := rd
out.Tags = slices.Clone(rd.Tags)
out.Security = cloneSecurityRequirements(rd.Security)
out.Parameters = cloneParameterDescriptions(rd.Parameters)
out.RequestBody = cloneOpenAPIObject(rd.RequestBody)
out.RequestExample = cloneOpenAPIValue(rd.RequestExample)
out.Response = cloneOpenAPIObject(rd.Response)
out.ResponseExample = cloneOpenAPIValue(rd.ResponseExample)
out.ResponseHeaders = cloneStringMap(rd.ResponseHeaders)
return out
}
func cloneParameterDescriptions(params []ParameterDescription) []ParameterDescription {
if params == nil {
return nil
}
if len(params) == 0 {
return []ParameterDescription{}
}
out := make([]ParameterDescription, len(params))
for i, param := range params {
out[i] = param
out[i].Schema = cloneOpenAPIObject(param.Schema)
out[i].Example = cloneOpenAPIValue(param.Example)
}
return out
}
func cloneSecurityRequirements(security []map[string][]string) []map[string][]string {
if security == nil {
return nil
}
if len(security) == 0 {
return []map[string][]string{}
}
out := make([]map[string][]string, len(security))
for i, requirement := range security {
if len(requirement) == 0 {
continue
}
cloned := make(map[string][]string, len(requirement))
for name, scopes := range requirement {
cloned[name] = slices.Clone(scopes)
}
out[i] = cloned
}
return out
}
func cloneOpenAPIObject(v map[string]any) map[string]any {
if v == nil {
return nil
}
if len(v) == 0 {
return map[string]any{}
}
cloned, _ := cloneOpenAPIValue(v).(map[string]any)
return cloned
}
func cloneStringMap(v map[string]string) map[string]string {
if v == nil {
return nil
}
if len(v) == 0 {
return map[string]string{}
}
out := make(map[string]string, len(v))
for key, value := range v {
out[key] = value
}
return out
}
// cloneOpenAPIValue recursively copies JSON-like OpenAPI values so callers can
// safely retain and reuse their original maps after configuring an engine.
func cloneOpenAPIValue(v any) any {
switch value := v.(type) {
case map[string]any:
out := make(map[string]any, len(value))
for k, nested := range value {
out[k] = cloneOpenAPIValue(nested)
}
return out
case []any:
out := make([]any, len(value))
for i, nested := range value {
out[i] = cloneOpenAPIValue(nested)
}
return out
default:
rv := reflect.ValueOf(v)
if !rv.IsValid() {
return nil
}
switch rv.Kind() {
case reflect.Map:
out := reflect.MakeMapWithSize(rv.Type(), rv.Len())
for _, key := range rv.MapKeys() {
cloned := cloneOpenAPIValue(rv.MapIndex(key).Interface())
if cloned == nil {
out.SetMapIndex(key, reflect.Zero(rv.Type().Elem()))
continue
}
out.SetMapIndex(key, reflect.ValueOf(cloned))
}
return out.Interface()
case reflect.Slice:
if rv.IsNil() {
return v
}
out := reflect.MakeSlice(rv.Type(), rv.Len(), rv.Len())
for i := 0; i < rv.Len(); i++ {
cloned := cloneOpenAPIValue(rv.Index(i).Interface())
if cloned == nil {
out.Index(i).Set(reflect.Zero(rv.Type().Elem()))
continue
}
out.Index(i).Set(reflect.ValueOf(cloned))
}
return out.Interface()
default:
return value
}
}
}

View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import "testing"
func TestEngine_SwaggerConfig_Good_NormalisesPathAtSnapshot(t *testing.T) {
e := &Engine{
swaggerPath: " /docs/ ",
}
cfg := e.SwaggerConfig()
if cfg.Path != "/docs" {
t.Fatalf("expected normalised Swagger path /docs, got %q", cfg.Path)
}
}
func TestEngine_TransportConfig_Good_NormalisesGraphQLPathAtSnapshot(t *testing.T) {
e := &Engine{
graphql: &graphqlConfig{
path: " /gql/ ",
},
}
cfg := e.TransportConfig()
if cfg.GraphQLPath != "/gql" {
t.Fatalf("expected normalised GraphQL path /gql, got %q", cfg.GraphQLPath)
}
}

748
spec_builder_helper_test.go Normal file
View file

@ -0,0 +1,748 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"encoding/json"
"net/http"
"testing"
"time"
"github.com/gin-gonic/gin"
"slices"
api "dappco.re/go/core/api"
)
func TestEngine_Good_OpenAPISpecBuilderCarriesEngineMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
broker := api.NewSSEBroker()
e, err := api.New(
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
api.WithSwaggerSummary("Engine overview"),
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.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",
},
}),
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
api.WithCacheLimits(5*time.Minute, 42, 8192),
api.WithI18n(api.I18nConfig{
DefaultLocale: "en-GB",
Supported: []string{"en-GB", "fr"},
}),
api.WithAuthentik(api.AuthentikConfig{
Issuer: "https://auth.example.com",
ClientID: "core-client",
TrustedProxy: true,
PublicPaths: []string{" /public/ ", "docs", "/public"},
}),
api.WithWSPath("/socket"),
api.WithWSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})),
api.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")),
api.WithSSE(broker),
api.WithSSEPath("/events"),
api.WithPprof(),
api.WithExpvar(),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
info, ok := spec["info"].(map[string]any)
if !ok {
t.Fatal("expected info object in generated spec")
}
if info["title"] != "Engine API" {
t.Fatalf("expected title Engine API, got %v", info["title"])
}
if info["description"] != "Engine metadata" {
t.Fatalf("expected description Engine metadata, got %v", info["description"])
}
if info["version"] != "2.0.0" {
t.Fatalf("expected version 2.0.0, got %v", info["version"])
}
if info["summary"] != "Engine overview" {
t.Fatalf("expected summary Engine overview, got %v", info["summary"])
}
if got := spec["x-swagger-ui-path"]; got != "/docs" {
t.Fatalf("expected x-swagger-ui-path=/docs, got %v", got)
}
if got := spec["x-swagger-enabled"]; got != true {
t.Fatalf("expected x-swagger-enabled=true, got %v", got)
}
if got := spec["x-graphql-enabled"]; got != true {
t.Fatalf("expected x-graphql-enabled=true, got %v", got)
}
if got := spec["x-graphql-path"]; got != "/gql" {
t.Fatalf("expected x-graphql-path=/gql, got %v", got)
}
if got := spec["x-graphql-playground"]; got != true {
t.Fatalf("expected x-graphql-playground=true, got %v", got)
}
if got := spec["x-graphql-playground-path"]; got != "/gql/playground" {
t.Fatalf("expected x-graphql-playground-path=/gql/playground, got %v", got)
}
if got := spec["x-ws-path"]; got != "/socket" {
t.Fatalf("expected x-ws-path=/socket, got %v", got)
}
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" {
t.Fatalf("expected x-sse-path=/events, got %v", got)
}
if got := spec["x-sse-enabled"]; got != true {
t.Fatalf("expected x-sse-enabled=true, got %v", got)
}
if got := spec["x-pprof-enabled"]; got != true {
t.Fatalf("expected x-pprof-enabled=true, got %v", got)
}
if got := spec["x-expvar-enabled"]; got != true {
t.Fatalf("expected x-expvar-enabled=true, got %v", got)
}
if got := spec["x-cache-enabled"]; got != true {
t.Fatalf("expected x-cache-enabled=true, got %v", got)
}
if got := spec["x-cache-ttl"]; got != "5m0s" {
t.Fatalf("expected x-cache-ttl=5m0s, got %v", got)
}
if got := spec["x-cache-max-entries"]; got != float64(42) {
t.Fatalf("expected x-cache-max-entries=42, got %v", got)
}
if got := spec["x-cache-max-bytes"]; got != float64(8192) {
t.Fatalf("expected x-cache-max-bytes=8192, got %v", got)
}
if got := spec["x-i18n-default-locale"]; got != "en-GB" {
t.Fatalf("expected x-i18n-default-locale=en-GB, got %v", got)
}
locales, ok := spec["x-i18n-supported-locales"].([]any)
if !ok {
t.Fatalf("expected x-i18n-supported-locales array, got %T", spec["x-i18n-supported-locales"])
}
if len(locales) != 2 || locales[0] != "en-GB" || locales[1] != "fr" {
t.Fatalf("expected supported locales [en-GB fr], got %v", locales)
}
if got := spec["x-authentik-issuer"]; got != "https://auth.example.com" {
t.Fatalf("expected x-authentik-issuer=https://auth.example.com, got %v", got)
}
if got := spec["x-authentik-client-id"]; got != "core-client" {
t.Fatalf("expected x-authentik-client-id=core-client, got %v", got)
}
if got := spec["x-authentik-trusted-proxy"]; got != true {
t.Fatalf("expected x-authentik-trusted-proxy=true, got %v", got)
}
publicPaths, ok := spec["x-authentik-public-paths"].([]any)
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" {
t.Fatalf("expected public paths [/health /swagger /docs /public], got %v", publicPaths)
}
contact, ok := info["contact"].(map[string]any)
if !ok {
t.Fatal("expected contact metadata in generated spec")
}
if contact["name"] != "API Support" {
t.Fatalf("expected contact name API Support, got %v", contact["name"])
}
license, ok := info["license"].(map[string]any)
if !ok {
t.Fatal("expected licence metadata in generated spec")
}
if license["name"] != "EUPL-1.2" {
t.Fatalf("expected licence name EUPL-1.2, got %v", license["name"])
}
if info["termsOfService"] != "https://example.com/terms" {
t.Fatalf("expected termsOfService to be preserved, got %v", info["termsOfService"])
}
securitySchemes, ok := spec["components"].(map[string]any)["securitySchemes"].(map[string]any)
if !ok {
t.Fatal("expected securitySchemes metadata in generated spec")
}
apiKeyAuth, ok := securitySchemes["apiKeyAuth"].(map[string]any)
if !ok {
t.Fatal("expected apiKeyAuth security scheme in generated spec")
}
if apiKeyAuth["type"] != "apiKey" {
t.Fatalf("expected apiKeyAuth.type=apiKey, got %v", apiKeyAuth["type"])
}
if apiKeyAuth["in"] != "header" {
t.Fatalf("expected apiKeyAuth.in=header, got %v", apiKeyAuth["in"])
}
if apiKeyAuth["name"] != "X-API-Key" {
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
}
externalDocs, ok := spec["externalDocs"].(map[string]any)
if !ok {
t.Fatal("expected externalDocs metadata in generated spec")
}
if externalDocs["url"] != "https://example.com/docs" {
t.Fatalf("expected externalDocs url to be preserved, got %v", externalDocs["url"])
}
servers, ok := spec["servers"].([]any)
if !ok {
t.Fatalf("expected servers array in generated spec, got %T", spec["servers"])
}
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" {
t.Fatalf("expected first server to be https://api.example.com, got %v", servers[0])
}
if servers[1].(map[string]any)["url"] != "/" {
t.Fatalf("expected second server to be /, got %v", servers[1])
}
paths, ok := spec["paths"].(map[string]any)
if !ok {
t.Fatalf("expected paths object in generated spec, got %T", spec["paths"])
}
if _, ok := paths["/gql"]; !ok {
t.Fatal("expected GraphQL path from engine metadata in generated spec")
}
if _, ok := paths["/gql/playground"]; !ok {
t.Fatal("expected GraphQL playground path from engine metadata in generated spec")
}
if _, ok := paths["/socket"]; !ok {
t.Fatal("expected custom WebSocket path from engine metadata in generated spec")
}
if _, ok := paths["/events"]; !ok {
t.Fatal("expected SSE path from engine metadata in generated spec")
}
if _, ok := paths["/debug/pprof"]; !ok {
t.Fatal("expected pprof path from engine metadata in generated spec")
}
if _, ok := paths["/debug/vars"]; !ok {
t.Fatal("expected expvar path from engine metadata in generated spec")
}
}
func TestEngine_Good_SwaggerConfigCarriesEngineMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
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.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",
},
}),
api.WithSwaggerExternalDocs("Developer guide", "https://example.com/docs"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.SwaggerConfig()
if !cfg.Enabled {
t.Fatal("expected Swagger to be enabled")
}
if cfg.Path != "" {
t.Fatalf("expected empty Swagger path when none is configured, got %q", cfg.Path)
}
if cfg.Title != "Engine API" {
t.Fatalf("expected title Engine API, got %q", cfg.Title)
}
if cfg.Description != "Engine metadata" {
t.Fatalf("expected description Engine metadata, got %q", cfg.Description)
}
if cfg.Version != "2.0.0" {
t.Fatalf("expected version 2.0.0, got %q", cfg.Version)
}
if cfg.Summary != "Engine overview" {
t.Fatalf("expected summary Engine overview, got %q", cfg.Summary)
}
if cfg.TermsOfService != "https://example.com/terms" {
t.Fatalf("expected termsOfService to be preserved, got %q", cfg.TermsOfService)
}
if cfg.ContactName != "API Support" {
t.Fatalf("expected contact name API Support, got %q", cfg.ContactName)
}
if cfg.LicenseName != "EUPL-1.2" {
t.Fatalf("expected licence name EUPL-1.2, got %q", cfg.LicenseName)
}
if cfg.ExternalDocsURL != "https://example.com/docs" {
t.Fatalf("expected external docs URL https://example.com/docs, got %q", cfg.ExternalDocsURL)
}
if len(cfg.Servers) != 2 {
t.Fatalf("expected 2 normalised servers, got %d", len(cfg.Servers))
}
if cfg.Servers[0] != "https://api.example.com" {
t.Fatalf("expected first server to be https://api.example.com, got %q", cfg.Servers[0])
}
if cfg.Servers[1] != "/" {
t.Fatalf("expected second server to be /, got %q", cfg.Servers[1])
}
cfgWithPath, err := api.New(
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
api.WithSwaggerPath("/docs"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
snap := cfgWithPath.SwaggerConfig()
if snap.Path != "/docs" {
t.Fatalf("expected Swagger path /docs, got %q", snap.Path)
}
apiKeyAuth, ok := cfg.SecuritySchemes["apiKeyAuth"].(map[string]any)
if !ok {
t.Fatal("expected apiKeyAuth security scheme in Swagger config")
}
if apiKeyAuth["name"] != "X-API-Key" {
t.Fatalf("expected apiKeyAuth.name=X-API-Key, got %v", apiKeyAuth["name"])
}
cfg.Servers[0] = "https://mutated.example.com"
apiKeyAuth["name"] = "Changed"
reshot := e.SwaggerConfig()
if reshot.Servers[0] != "https://api.example.com" {
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" {
t.Fatalf("expected cloned security scheme name X-API-Key, got %v", reshotScheme["name"])
}
}
func TestEngine_Good_SwaggerConfigTrimsRuntimeMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(
api.WithSwagger(" Engine API ", " Engine metadata ", " 2.0.0 "),
api.WithSwaggerSummary(" Engine overview "),
api.WithSwaggerTermsOfService(" https://example.com/terms "),
api.WithSwaggerContact(" API Support ", " https://example.com/support ", " support@example.com "),
api.WithSwaggerLicense(" EUPL-1.2 ", " https://eupl.eu/1.2/en/ "),
api.WithSwaggerExternalDocs(" Developer guide ", " https://example.com/docs "),
api.WithAuthentik(api.AuthentikConfig{
Issuer: " https://auth.example.com ",
ClientID: " core-client ",
TrustedProxy: true,
PublicPaths: []string{" /public/ ", " docs ", "/public"},
}),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
swagger := e.SwaggerConfig()
if swagger.Title != "Engine API" {
t.Fatalf("expected trimmed title Engine API, got %q", swagger.Title)
}
if swagger.Description != "Engine metadata" {
t.Fatalf("expected trimmed description Engine metadata, got %q", swagger.Description)
}
if swagger.Version != "2.0.0" {
t.Fatalf("expected trimmed version 2.0.0, got %q", swagger.Version)
}
if swagger.Summary != "Engine overview" {
t.Fatalf("expected trimmed summary Engine overview, got %q", swagger.Summary)
}
if swagger.TermsOfService != "https://example.com/terms" {
t.Fatalf("expected trimmed termsOfService, got %q", swagger.TermsOfService)
}
if swagger.ContactName != "API Support" || swagger.ContactURL != "https://example.com/support" || swagger.ContactEmail != "support@example.com" {
t.Fatalf("expected trimmed contact metadata, got %+v", swagger)
}
if swagger.LicenseName != "EUPL-1.2" || swagger.LicenseURL != "https://eupl.eu/1.2/en/" {
t.Fatalf("expected trimmed licence metadata, got %+v", swagger)
}
if swagger.ExternalDocsDescription != "Developer guide" || swagger.ExternalDocsURL != "https://example.com/docs" {
t.Fatalf("expected trimmed external docs metadata, got %+v", swagger)
}
auth := e.AuthentikConfig()
if auth.Issuer != "https://auth.example.com" {
t.Fatalf("expected trimmed issuer, got %q", auth.Issuer)
}
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) {
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)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
info, ok := spec["info"].(map[string]any)
if !ok {
t.Fatal("expected info object in generated spec")
}
if info["title"] != "Engine API" || info["description"] != "Engine metadata" || info["version"] != "2.0.0" || info["summary"] != "Engine overview" {
t.Fatalf("expected trimmed OpenAPI info block, got %+v", info)
}
if info["termsOfService"] != "https://example.com/terms" {
t.Fatalf("expected trimmed termsOfService in spec, got %v", info["termsOfService"])
}
}
func TestEngine_Good_TransportConfigCarriesEngineMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
broker := api.NewSSEBroker()
e, err := api.New(
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.WithGraphQL(newTestSchema(), api.WithPlayground(), api.WithGraphQLPath("/gql")),
api.WithSSE(broker),
api.WithSSEPath("/events"),
api.WithPprof(),
api.WithExpvar(),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.TransportConfig()
if !cfg.SwaggerEnabled {
t.Fatal("expected Swagger to be enabled")
}
if cfg.SwaggerPath != "/docs" {
t.Fatalf("expected swagger path /docs, got %q", cfg.SwaggerPath)
}
if cfg.GraphQLPath != "/gql" {
t.Fatalf("expected graphql path /gql, got %q", cfg.GraphQLPath)
}
if !cfg.GraphQLEnabled {
t.Fatal("expected GraphQL to be enabled")
}
if !cfg.GraphQLPlayground {
t.Fatal("expected GraphQL playground to be enabled")
}
if !cfg.WSEnabled {
t.Fatal("expected WebSocket to be enabled")
}
if cfg.WSPath != "/socket" {
t.Fatalf("expected ws path /socket, got %q", cfg.WSPath)
}
if !cfg.SSEEnabled {
t.Fatal("expected SSE to be enabled")
}
if cfg.SSEPath != "/events" {
t.Fatalf("expected sse path /events, got %q", cfg.SSEPath)
}
if !cfg.PprofEnabled {
t.Fatal("expected pprof to be enabled")
}
if !cfg.ExpvarEnabled {
t.Fatal("expected expvar to be enabled")
}
}
func TestEngine_Good_TransportConfigReportsDisabledSwaggerWithoutUI(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithSwaggerPath("/docs"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.TransportConfig()
if cfg.SwaggerEnabled {
t.Fatal("expected Swagger to remain disabled when only the path is configured")
}
if cfg.SwaggerPath != "/docs" {
t.Fatalf("expected swagger path /docs, got %q", cfg.SwaggerPath)
}
}
// TestEngine_Good_TransportConfigReportsChatCompletions verifies that the
// chat completions resolver surfaces through TransportConfig so callers can
// discover the RFC §11.1 endpoint without rebuilding the engine.
func TestEngine_Good_TransportConfigReportsChatCompletions(t *testing.T) {
gin.SetMode(gin.TestMode)
resolver := api.NewModelResolver()
e, err := api.New(api.WithChatCompletions(resolver))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.TransportConfig()
if !cfg.ChatCompletionsEnabled {
t.Fatal("expected chat completions to be enabled")
}
if cfg.ChatCompletionsPath != "/v1/chat/completions" {
t.Fatalf("expected chat completions path /v1/chat/completions, got %q", cfg.ChatCompletionsPath)
}
}
// TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride verifies
// that WithChatCompletionsPath surfaces through TransportConfig.
func TestEngine_Good_TransportConfigHonoursChatCompletionsPathOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
resolver := api.NewModelResolver()
e, err := api.New(
api.WithChatCompletions(resolver),
api.WithChatCompletionsPath("/chat"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.TransportConfig()
if cfg.ChatCompletionsPath != "/chat" {
t.Fatalf("expected chat completions path /chat, got %q", cfg.ChatCompletionsPath)
}
}
// TestEngine_Good_TransportConfigReportsOpenAPISpec verifies that the
// WithOpenAPISpec option surfaces the standalone JSON endpoint (RFC
// /v1/openapi.json) through TransportConfig so callers can discover it
// alongside the other framework routes.
func TestEngine_Good_TransportConfigReportsOpenAPISpec(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithOpenAPISpec())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.TransportConfig()
if !cfg.OpenAPISpecEnabled {
t.Fatal("expected OpenAPISpecEnabled=true")
}
if cfg.OpenAPISpecPath != "/v1/openapi.json" {
t.Fatalf("expected OpenAPISpecPath=/v1/openapi.json, got %q", cfg.OpenAPISpecPath)
}
}
// TestEngine_Good_TransportConfigHonoursOpenAPISpecPathOverride verifies
// that WithOpenAPISpecPath surfaces through TransportConfig.
func TestEngine_Good_TransportConfigHonoursOpenAPISpecPathOverride(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithOpenAPISpecPath("/api/v1/openapi.json"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.TransportConfig()
if !cfg.OpenAPISpecEnabled {
t.Fatal("expected OpenAPISpecEnabled inferred from path override")
}
if cfg.OpenAPISpecPath != "/api/v1/openapi.json" {
t.Fatalf("expected custom path, got %q", cfg.OpenAPISpecPath)
}
}
// TestEngine_Bad_TransportConfigOmitsOpenAPISpecWhenDisabled confirms the
// standalone OpenAPI endpoint reports as disabled when neither WithOpenAPISpec
// nor WithOpenAPISpecPath has been invoked.
func TestEngine_Bad_TransportConfigOmitsOpenAPISpecWhenDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg := e.TransportConfig()
if cfg.OpenAPISpecEnabled {
t.Fatal("expected OpenAPISpecEnabled=false when not configured")
}
if cfg.OpenAPISpecPath != "" {
t.Fatalf("expected empty OpenAPISpecPath, got %q", cfg.OpenAPISpecPath)
}
}
func TestEngine_Good_OpenAPISpecBuilderExportsDefaultSwaggerPath(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithSwagger("Engine API", "Engine metadata", "2.0.0"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got := spec["x-swagger-ui-path"]; got != "/swagger" {
t.Fatalf("expected default x-swagger-ui-path=/swagger, got %v", got)
}
}
func TestEngine_Good_OpenAPISpecBuilderCarriesExplicitSwaggerPathWithoutUI(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithSwaggerPath("/docs"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got := spec["x-swagger-ui-path"]; got != "/docs" {
t.Fatalf("expected explicit x-swagger-ui-path=/docs, got %v", got)
}
}
func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredWSPathWithoutHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithWSPath("/socket"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got := spec["x-ws-path"]; got != "/socket" {
t.Fatalf("expected x-ws-path=/socket, got %v", got)
}
}
func TestEngine_Good_OpenAPISpecBuilderCarriesConfiguredSSEPathWithoutBroker(t *testing.T) {
gin.SetMode(gin.TestMode)
e, err := api.New(api.WithSSE(nil), api.WithSSEPath("/events"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
builder := e.OpenAPISpecBuilder()
data, err := builder.Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got := spec["x-sse-path"]; got != "/events" {
t.Fatalf("expected x-sse-path=/events, got %v", got)
}
}
func TestEngine_Good_OpenAPISpecBuilderClonesSecuritySchemes(t *testing.T) {
gin.SetMode(gin.TestMode)
securityScheme := map[string]any{
"type": "oauth2",
"flows": map[string]any{
"clientCredentials": map[string]any{
"tokenUrl": "https://auth.example.com/token",
},
},
}
schemes := map[string]any{
"oauth2": securityScheme,
}
e, err := api.New(
api.WithSwagger("Engine API", "Engine metadata", "2.0.0"),
api.WithSwaggerSecuritySchemes(schemes),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Mutate the original input after configuration. The builder snapshot should
// remain stable and keep the original token URL.
securityScheme["type"] = "mutated"
securityScheme["flows"].(map[string]any)["clientCredentials"].(map[string]any)["tokenUrl"] = "https://mutated.example.com/token"
data, err := e.OpenAPISpecBuilder().Build(nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var spec map[string]any
if err := json.Unmarshal(data, &spec); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
securitySchemes := spec["components"].(map[string]any)["securitySchemes"].(map[string]any)
oauth2, ok := securitySchemes["oauth2"].(map[string]any)
if !ok {
t.Fatal("expected oauth2 security scheme in generated spec")
}
if oauth2["type"] != "oauth2" {
t.Fatalf("expected cloned oauth2.type=oauth2, got %v", oauth2["type"])
}
flows := oauth2["flows"].(map[string]any)
clientCredentials := flows["clientCredentials"].(map[string]any)
if clientCredentials["tokenUrl"] != "https://auth.example.com/token" {
t.Fatalf("expected original tokenUrl to be preserved, got %v", clientCredentials["tokenUrl"])
}
}

154
spec_registry.go Normal file
View file

@ -0,0 +1,154 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"iter"
"sync"
"slices"
)
// specRegistry stores RouteGroups that should be included in CLI-generated
// OpenAPI documents. Packages can register their groups during init and the
// API CLI will pick them up when building specs or SDKs.
var specRegistry struct {
mu sync.RWMutex
groups []RouteGroup
}
// RegisterSpecGroups adds route groups to the package-level spec registry.
// Nil groups are ignored. Registered groups are returned by RegisteredSpecGroups
// in the order they were added.
//
// Example:
//
// api.RegisterSpecGroups(api.NewToolBridge("/mcp"))
func RegisterSpecGroups(groups ...RouteGroup) {
RegisterSpecGroupsIter(slices.Values(groups))
}
// RegisterSpecGroupsIter adds route groups from an iterator to the package-level
// spec registry.
//
// Nil groups are ignored. Registered groups are returned by RegisteredSpecGroups
// in the order they were added.
//
// Example:
//
// api.RegisterSpecGroupsIter(api.RegisteredSpecGroupsIter())
func RegisterSpecGroupsIter(groups iter.Seq[RouteGroup]) {
if groups == nil {
return
}
specRegistry.mu.Lock()
defer specRegistry.mu.Unlock()
for group := range groups {
if group == nil {
continue
}
if specRegistryContains(group) {
continue
}
specRegistry.groups = append(specRegistry.groups, group)
}
}
// RegisteredSpecGroups returns a copy of the route groups registered for
// CLI-generated OpenAPI documents.
//
// Example:
//
// groups := api.RegisteredSpecGroups()
func RegisteredSpecGroups() []RouteGroup {
specRegistry.mu.RLock()
defer specRegistry.mu.RUnlock()
out := make([]RouteGroup, len(specRegistry.groups))
copy(out, specRegistry.groups)
return out
}
// RegisteredSpecGroupsIter returns an iterator over the route groups registered
// for CLI-generated OpenAPI documents.
//
// The iterator snapshots the current registry contents so callers can range
// over it without holding the registry lock.
//
// Example:
//
// for g := range api.RegisteredSpecGroupsIter() {
// _ = g
// }
func RegisteredSpecGroupsIter() iter.Seq[RouteGroup] {
specRegistry.mu.RLock()
groups := slices.Clone(specRegistry.groups)
specRegistry.mu.RUnlock()
return slices.Values(groups)
}
// SpecGroupsIter returns the registered spec groups plus one optional extra
// group, deduplicated by group identity.
//
// The iterator snapshots the registry before yielding so callers can range
// over it without holding the registry lock.
//
// Example:
//
// for g := range api.SpecGroupsIter(api.NewToolBridge("/tools")) {
// _ = g
// }
func SpecGroupsIter(extra RouteGroup) iter.Seq[RouteGroup] {
return func(yield func(RouteGroup) bool) {
seen := map[string]struct{}{}
for group := range RegisteredSpecGroupsIter() {
key := specGroupKey(group)
seen[key] = struct{}{}
if !yield(group) {
return
}
}
if extra != nil {
if _, ok := seen[specGroupKey(extra)]; ok {
return
}
if !yield(extra) {
return
}
}
}
}
// ResetSpecGroups clears the package-level spec registry.
// It is primarily intended for tests that need to isolate global state.
//
// Example:
//
// api.ResetSpecGroups()
func ResetSpecGroups() {
specRegistry.mu.Lock()
defer specRegistry.mu.Unlock()
specRegistry.groups = nil
}
func specRegistryContains(group RouteGroup) bool {
key := specGroupKey(group)
for _, existing := range specRegistry.groups {
if specGroupKey(existing) == key {
return true
}
}
return false
}
func specGroupKey(group RouteGroup) string {
if group == nil {
return ""
}
return group.Name() + "\x00" + group.BasePath()
}

138
spec_registry_test.go Normal file
View file

@ -0,0 +1,138 @@
// SPDX-License-Identifier: EUPL-1.2
package api_test
import (
"iter"
"testing"
"github.com/gin-gonic/gin"
api "dappco.re/go/core/api"
)
type specRegistryStubGroup struct {
name string
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 TestRegisterSpecGroups_Good_DeduplicatesByIdentity(t *testing.T) {
snapshot := api.RegisteredSpecGroups()
api.ResetSpecGroups()
t.Cleanup(func() {
api.ResetSpecGroups()
api.RegisterSpecGroups(snapshot...)
})
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
second := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
third := &specRegistryStubGroup{name: "beta", basePath: "/beta"}
api.RegisterSpecGroups(nil, first, second, third, first)
groups := api.RegisteredSpecGroups()
if len(groups) != 2 {
t.Fatalf("expected 2 unique groups, got %d", len(groups))
}
if groups[0].Name() != "alpha" || groups[0].BasePath() != "/alpha" {
t.Fatalf("expected first group to be alpha at /alpha, got %s at %s", groups[0].Name(), groups[0].BasePath())
}
if groups[1].Name() != "beta" || groups[1].BasePath() != "/beta" {
t.Fatalf("expected second group to be beta at /beta, got %s at %s", groups[1].Name(), groups[1].BasePath())
}
}
func TestRegisterSpecGroups_Good_IteratorReturnsSnapshot(t *testing.T) {
snapshot := api.RegisteredSpecGroups()
api.ResetSpecGroups()
t.Cleanup(func() {
api.ResetSpecGroups()
api.RegisterSpecGroups(snapshot...)
})
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
second := &specRegistryStubGroup{name: "beta", basePath: "/beta"}
api.RegisterSpecGroups(first)
iter := api.RegisteredSpecGroupsIter()
api.RegisterSpecGroups(second)
var groups []api.RouteGroup
for group := range iter {
groups = append(groups, group)
}
if len(groups) != 1 {
t.Fatalf("expected iterator snapshot to contain 1 group, got %d", len(groups))
}
if groups[0].Name() != "alpha" || groups[0].BasePath() != "/alpha" {
t.Fatalf("expected iterator snapshot to preserve alpha at /alpha, got %s at %s", groups[0].Name(), groups[0].BasePath())
}
}
func TestRegisterSpecGroupsIter_Good_DeduplicatesAndRegisters(t *testing.T) {
snapshot := api.RegisteredSpecGroups()
api.ResetSpecGroups()
t.Cleanup(func() {
api.ResetSpecGroups()
api.RegisterSpecGroups(snapshot...)
})
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
second := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
third := &specRegistryStubGroup{name: "gamma", basePath: "/gamma"}
groups := iter.Seq[api.RouteGroup](func(yield func(api.RouteGroup) bool) {
for _, group := range []api.RouteGroup{first, second, nil, third, first} {
if !yield(group) {
return
}
}
})
api.RegisterSpecGroupsIter(groups)
registered := api.RegisteredSpecGroups()
if len(registered) != 2 {
t.Fatalf("expected 2 unique groups, got %d", len(registered))
}
if registered[0].Name() != "alpha" || registered[0].BasePath() != "/alpha" {
t.Fatalf("expected first group to be alpha at /alpha, got %s at %s", registered[0].Name(), registered[0].BasePath())
}
if registered[1].Name() != "gamma" || registered[1].BasePath() != "/gamma" {
t.Fatalf("expected second group to be gamma at /gamma, got %s at %s", registered[1].Name(), registered[1].BasePath())
}
}
func TestSpecGroupsIter_Good_DeduplicatesExtraBridge(t *testing.T) {
snapshot := api.RegisteredSpecGroups()
api.ResetSpecGroups()
t.Cleanup(func() {
api.ResetSpecGroups()
api.RegisterSpecGroups(snapshot...)
})
first := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
extra := &specRegistryStubGroup{name: "alpha", basePath: "/alpha"}
api.RegisterSpecGroups(first)
var groups []api.RouteGroup
for group := range api.SpecGroupsIter(extra) {
groups = append(groups, group)
}
if len(groups) != 1 {
t.Fatalf("expected deduplicated iterator to return 1 group, got %d", len(groups))
}
if groups[0].Name() != "alpha" || groups[0].BasePath() != "/alpha" {
t.Fatalf("expected alpha at /alpha, got %s at %s", groups[0].Name(), groups[0].BasePath())
}
}

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
namespace Core\Api\Concerns;
use Illuminate\Http\JsonResponse;
@ -11,15 +13,33 @@ use Illuminate\Http\JsonResponse;
*/
trait HasApiResponses
{
/**
* Return a standard error response.
*/
protected function errorResponse(
string $errorCode,
string $message,
array $meta = [],
int $status = 400,
): JsonResponse {
return response()->json(array_merge([
'success' => false,
'error' => $errorCode,
'message' => $message,
'error_code' => $errorCode,
], $meta), $status);
}
/**
* Return a no workspace response.
*/
protected function noWorkspaceResponse(): JsonResponse
{
return response()->json([
'error' => 'no_workspace',
'message' => 'No workspace found. Please select a workspace first.',
], 404);
return $this->errorResponse(
errorCode: 'no_workspace',
message: 'No workspace found. Please select a workspace first.',
status: 404,
);
}
/**
@ -27,10 +47,14 @@ trait HasApiResponses
*/
protected function notFoundResponse(string $resource = 'Resource'): JsonResponse
{
return response()->json([
'error' => 'not_found',
'message' => "{$resource} not found.",
], 404);
return $this->errorResponse(
errorCode: 'not_found',
message: "{$resource} not found.",
meta: [
'resource' => $resource,
],
status: 404,
);
}
/**
@ -38,12 +62,15 @@ trait HasApiResponses
*/
protected function limitReachedResponse(string $feature, ?string $message = null): JsonResponse
{
return response()->json([
'error' => 'feature_limit_reached',
'message' => $message ?? 'You have reached your limit for this feature.',
return $this->errorResponse(
errorCode: 'feature_limit_reached',
message: $message ?? 'You have reached your limit for this feature.',
meta: [
'feature' => $feature,
'upgrade_url' => route('hub.usage'),
], 403);
],
status: 403,
);
}
/**
@ -51,10 +78,20 @@ trait HasApiResponses
*/
protected function accessDeniedResponse(string $message = 'Access denied.'): JsonResponse
{
return response()->json([
'error' => 'access_denied',
'message' => $message,
], 403);
return $this->forbiddenResponse($message, status: 403);
}
/**
* Return a forbidden response.
*/
protected function forbiddenResponse(string $message, array $meta = [], int $status = 403): JsonResponse
{
return $this->errorResponse(
errorCode: 'forbidden',
message: $message,
meta: $meta,
status: $status,
);
}
/**
@ -63,6 +100,7 @@ trait HasApiResponses
protected function successResponse(string $message, array $data = []): JsonResponse
{
return response()->json(array_merge([
'success' => true,
'message' => $message,
], $data));
}
@ -73,6 +111,7 @@ trait HasApiResponses
protected function createdResponse(mixed $resource, string $message = 'Created successfully.'): JsonResponse
{
return response()->json([
'success' => true,
'message' => $message,
'data' => $resource,
], 201);
@ -81,13 +120,16 @@ trait HasApiResponses
/**
* Return a validation error response.
*/
protected function validationErrorResponse(array $errors): JsonResponse
protected function validationErrorResponse(array $errors, int $status = 422): JsonResponse
{
return response()->json([
'error' => 'validation_failed',
'message' => 'The given data was invalid.',
return $this->errorResponse(
errorCode: 'validation_failed',
message: 'The given data was invalid.',
meta: [
'errors' => $errors,
], 422);
],
status: $status,
);
}
/**
@ -97,10 +139,11 @@ trait HasApiResponses
*/
protected function invalidStatusResponse(string $message): JsonResponse
{
return response()->json([
'error' => 'invalid_status',
'message' => $message,
], 422);
return $this->errorResponse(
errorCode: 'invalid_status',
message: $message,
status: 422,
);
}
/**
@ -110,15 +153,13 @@ trait HasApiResponses
*/
protected function providerErrorResponse(string $message, ?string $provider = null): JsonResponse
{
$response = [
'error' => 'provider_error',
'message' => $message,
];
if ($provider !== null) {
$response['provider'] = $provider;
}
return response()->json($response, 400);
return $this->errorResponse(
errorCode: 'provider_error',
message: $message,
meta: array_filter([
'provider' => $provider,
]),
status: 400,
);
}
}

View file

@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Concerns\ResolvesWorkspace;
use Core\Api\Models\ApiKey;
use Core\Api\Services\ApiUsageService;
use Core\Front\Controller;
use Core\Tenant\Models\Workspace;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Entitlements API controller.
*
* Returns the current workspace's plan limits and usage snapshot.
*/
class EntitlementApiController extends Controller
{
use HasApiResponses;
use ResolvesWorkspace;
public function __construct(
protected ApiUsageService $usageService
) {
}
/**
* Show the current workspace entitlements.
*
* GET /api/entitlements
*/
public function show(Request $request): JsonResponse
{
$workspace = $this->resolveWorkspace($request);
if (! $workspace instanceof Workspace) {
return $this->noWorkspaceResponse();
}
$apiKey = $request->attributes->get('api_key');
$authType = $request->attributes->get('auth_type', 'session');
$rateLimitProfile = $this->resolveRateLimitProfile($authType);
$activeApiKeys = ApiKey::query()
->forWorkspace($workspace->id)
->active()
->count();
$usage = $this->usageService->getWorkspaceSummary($workspace->id);
return response()->json([
'workspace_id' => $workspace->id,
'workspace' => [
'id' => $workspace->id,
'name' => $workspace->name ?? null,
],
'authentication' => [
'type' => $authType,
'scopes' => $apiKey instanceof ApiKey ? $apiKey->scopes : null,
],
'limits' => [
'rate_limit' => $rateLimitProfile,
'api_keys' => [
'active' => $activeApiKeys,
'maximum' => (int) config('api.keys.max_per_workspace', 10),
'remaining' => max(0, (int) config('api.keys.max_per_workspace', 10) - $activeApiKeys),
],
'webhooks' => [
'maximum' => (int) config('api.webhooks.max_per_workspace', 5),
],
],
'usage' => $usage,
'features' => [
'pixel' => true,
'mcp' => true,
'webhooks' => true,
'usage_alerts' => (bool) config('api.alerts.enabled', true),
],
]);
}
/**
* Resolve the rate limit profile for the current auth context.
*/
protected function resolveRateLimitProfile(string $authType): array
{
$rateLimits = (array) config('api.rate_limits', []);
$key = $authType === 'session' ? 'default' : 'authenticated';
$profile = (array) ($rateLimits[$key] ?? []);
return [
'name' => $key,
'limit' => (int) ($profile['limit'] ?? 0),
'window' => (int) ($profile['window'] ?? 60),
'burst' => (float) ($profile['burst'] ?? 1.0),
];
}
}

View file

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Documentation\Attributes\ApiParameter;
use Core\Api\Documentation\Attributes\ApiTag;
use Core\Api\Services\SeoReportService;
use Core\Front\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
/**
* SEO report and analysis controller.
*/
#[ApiTag('SEO', 'SEO report and analysis endpoints')]
class SeoReportController extends Controller
{
use HasApiResponses;
public function __construct(
protected SeoReportService $seoReportService
) {
}
/**
* Analyse a URL and return a technical SEO report.
*
* GET /api/seo/report?url=https://example.com
*/
#[ApiParameter(
name: 'url',
in: 'query',
type: 'string',
description: 'URL to analyse',
required: true,
format: 'uri'
)]
public function show(Request $request): JsonResponse
{
$validated = $request->validate([
'url' => ['required', 'url'],
]);
try {
$report = $this->seoReportService->analyse($validated['url']);
} catch (RuntimeException) {
return $this->errorResponse(
errorCode: 'seo_unavailable',
message: 'Unable to fetch the requested URL.',
meta: [
'provider' => 'seo',
],
status: 502,
);
}
return response()->json([
'data' => $report,
]);
}
}

View file

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Core\Api\Documentation\Attributes\ApiResponse;
use Core\Api\Documentation\Attributes\ApiTag;
use Core\Api\RateLimit\RateLimit;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
/**
* Unified tracking pixel controller.
*
* GET /api/pixel/{pixelKey} returns a transparent 1x1 GIF for image embeds.
* POST /api/pixel/{pixelKey} returns 204 No Content for fetch-based tracking.
*/
#[ApiTag('Pixel', 'Unified tracking pixel endpoint')]
class UnifiedPixelController extends Controller
{
/**
* Transparent 1x1 GIF used by browser pixel embeds.
*/
private const TRANSPARENT_GIF = 'R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw==';
/**
* Track a pixel hit.
*
* GET /api/pixel/abc12345 -> transparent GIF
* POST /api/pixel/abc12345 -> 204 No Content
*/
#[ApiResponse(
200,
null,
'Transparent 1x1 GIF pixel response',
contentType: 'image/gif',
schema: [
'type' => 'string',
'format' => 'binary',
],
)]
#[ApiResponse(204, null, 'Accepted without a response body')]
#[RateLimit(limit: 10000, window: 60)]
public function track(Request $request, string $pixelKey): Response
{
if ($request->isMethod('post')) {
return response()->noContent()
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->header('Pragma', 'no-cache')
->header('Expires', '0');
}
$pixel = base64_decode(self::TRANSPARENT_GIF);
return response($pixel, 200)
->header('Content-Type', 'image/gif')
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
->header('Pragma', 'no-cache')
->header('Expires', '0')
->header('Content-Length', (string) strlen($pixel));
}
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Core\Api\Concerns\HasApiResponses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@ -16,6 +17,8 @@ use Core\Social\Models\Webhook;
*/
class WebhookSecretController extends Controller
{
use HasApiResponses;
public function __construct(
protected WebhookSecretRotationService $rotationService
) {}
@ -28,7 +31,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$webhook = Webhook::where('workspace_id', $workspace->id)
@ -36,7 +39,7 @@ class WebhookSecretController extends Controller
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
return $this->notFoundResponse('Webhook');
}
$validated = $request->validate([
@ -66,7 +69,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
@ -74,7 +77,7 @@ class WebhookSecretController extends Controller
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
return $this->notFoundResponse('Webhook endpoint');
}
$validated = $request->validate([
@ -104,7 +107,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$webhook = Webhook::where('workspace_id', $workspace->id)
@ -112,7 +115,7 @@ class WebhookSecretController extends Controller
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
return $this->notFoundResponse('Webhook');
}
return response()->json([
@ -128,7 +131,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
@ -136,7 +139,7 @@ class WebhookSecretController extends Controller
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
return $this->notFoundResponse('Webhook endpoint');
}
return response()->json([
@ -152,7 +155,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$webhook = Webhook::where('workspace_id', $workspace->id)
@ -160,7 +163,7 @@ class WebhookSecretController extends Controller
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
return $this->notFoundResponse('Webhook');
}
$this->rotationService->invalidatePreviousSecret($webhook);
@ -179,7 +182,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
@ -187,7 +190,7 @@ class WebhookSecretController extends Controller
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
return $this->notFoundResponse('Webhook endpoint');
}
$this->rotationService->invalidatePreviousSecret($endpoint);
@ -206,7 +209,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$webhook = Webhook::where('workspace_id', $workspace->id)
@ -214,7 +217,7 @@ class WebhookSecretController extends Controller
->first();
if (! $webhook) {
return response()->json(['error' => 'Webhook not found'], 404);
return $this->notFoundResponse('Webhook');
}
$validated = $request->validate([
@ -240,7 +243,7 @@ class WebhookSecretController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$endpoint = ContentWebhookEndpoint::where('workspace_id', $workspace->id)
@ -248,7 +251,7 @@ class WebhookSecretController extends Controller
->first();
if (! $endpoint) {
return response()->json(['error' => 'Webhook endpoint not found'], 404);
return $this->notFoundResponse('Webhook endpoint');
}
$validated = $request->validate([

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Core\Api\Controllers\Api;
use Core\Api\Concerns\HasApiResponses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
@ -17,6 +18,8 @@ use Core\Api\Services\WebhookTemplateService;
*/
class WebhookTemplateController extends Controller
{
use HasApiResponses;
public function __construct(
protected WebhookTemplateService $templateService
) {}
@ -29,7 +32,7 @@ class WebhookTemplateController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$query = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
@ -61,7 +64,7 @@ class WebhookTemplateController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
@ -69,7 +72,7 @@ class WebhookTemplateController extends Controller
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
return $this->notFoundResponse('Template');
}
return response()->json([
@ -85,7 +88,7 @@ class WebhookTemplateController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$validated = $request->validate([
@ -102,10 +105,9 @@ class WebhookTemplateController extends Controller
$validation = $this->templateService->validateTemplate($validated['template'], $format);
if (! $validation['valid']) {
return response()->json([
'error' => 'Invalid template',
'errors' => $validation['errors'],
], 422);
return $this->validationErrorResponse([
'template' => $validation['errors'],
]);
}
$template = WebhookPayloadTemplate::create([
@ -133,7 +135,7 @@ class WebhookTemplateController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
@ -141,7 +143,7 @@ class WebhookTemplateController extends Controller
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
return $this->notFoundResponse('Template');
}
$validated = $request->validate([
@ -159,10 +161,9 @@ class WebhookTemplateController extends Controller
$validation = $this->templateService->validateTemplate($validated['template'], $format);
if (! $validation['valid']) {
return response()->json([
'error' => 'Invalid template',
'errors' => $validation['errors'],
], 422);
return $this->validationErrorResponse([
'template' => $validation['errors'],
]);
}
}
@ -186,7 +187,7 @@ class WebhookTemplateController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
@ -194,12 +195,12 @@ class WebhookTemplateController extends Controller
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
return $this->notFoundResponse('Template');
}
// Don't allow deleting builtin templates
if ($template->isBuiltin()) {
return response()->json(['error' => 'Built-in templates cannot be deleted'], 403);
return $this->forbiddenResponse('Built-in templates cannot be deleted');
}
$template->delete();
@ -255,7 +256,7 @@ class WebhookTemplateController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
@ -263,7 +264,7 @@ class WebhookTemplateController extends Controller
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
return $this->notFoundResponse('Template');
}
$newName = $request->input('name', $template->name.' (copy)');
@ -282,7 +283,7 @@ class WebhookTemplateController extends Controller
$workspace = $request->user()?->defaultHostWorkspace();
if (! $workspace) {
return response()->json(['error' => 'Workspace not found'], 404);
return $this->noWorkspaceResponse();
}
$template = WebhookPayloadTemplate::where('workspace_id', $workspace->id)
@ -290,7 +291,7 @@ class WebhookTemplateController extends Controller
->first();
if (! $template) {
return response()->json(['error' => 'Template not found'], 404);
return $this->notFoundResponse('Template');
}
$template->setAsDefault();

View file

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Core\Api\Controllers;
use Core\Front\Controller;
use Core\Api\Concerns\HasApiResponses;
use Core\Api\Documentation\Attributes\ApiParameter;
use Core\Api\Models\ApiKey;
use Core\Mod\Mcp\Models\McpApiRequest;
use Core\Mod\Mcp\Models\McpToolCall;
@ -23,6 +25,8 @@ use Symfony\Component\Yaml\Yaml;
*/
class McpApiController extends Controller
{
use HasApiResponses;
/**
* List all available MCP servers.
*
@ -47,15 +51,48 @@ class McpApiController extends Controller
* Get server details with tools and resources.
*
* GET /api/v1/mcp/servers/{id}
*
* Query params:
* - include_versions: bool - include version info for each tool
* - include_content: bool - include resource content when the definition already contains it
*/
#[ApiParameter(
name: 'include_versions',
in: 'query',
type: 'boolean',
description: 'Include version information for each tool',
required: false,
example: false,
default: false
)]
#[ApiParameter(
name: 'include_content',
in: 'query',
type: 'boolean',
description: 'Include resource content when the definition already contains it',
required: false,
example: false,
default: false
)]
public function server(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
return $this->notFoundResponse('Server');
}
if ($request->boolean('include_versions', false)) {
$server['tools'] = $this->enrichToolsWithVersioning($id, $server['tools'] ?? []);
}
if ($request->boolean('include_content', false)) {
$server['resources'] = $this->enrichResourcesWithContent($server['resources'] ?? []);
}
$server['tool_count'] = count($server['tools'] ?? []);
$server['resource_count'] = count($server['resources'] ?? []);
return response()->json($server);
}
@ -67,12 +104,21 @@ class McpApiController extends Controller
* Query params:
* - include_versions: bool - include version info for each tool
*/
#[ApiParameter(
name: 'include_versions',
in: 'query',
type: 'boolean',
description: 'Include version information for each tool',
required: false,
example: false,
default: false
)]
public function tools(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
return $this->notFoundResponse('Server');
}
$tools = $server['tools'] ?? [];
@ -107,6 +153,116 @@ class McpApiController extends Controller
]);
}
/**
* List resources for a specific server.
*
* GET /api/v1/mcp/servers/{id}/resources
*
* Query params:
* - include_content: bool - include resource content when the definition already contains it
*/
#[ApiParameter(
name: 'include_content',
in: 'query',
type: 'boolean',
description: 'Include resource content when the definition already contains it',
required: false,
example: false,
default: false
)]
public function resources(Request $request, string $id): JsonResponse
{
$server = $this->loadServerFull($id);
if (! $server) {
return $this->notFoundResponse('Server');
}
$includeContent = $request->boolean('include_content', false);
$resources = collect($server['resources'] ?? [])
->filter(fn ($resource) => is_array($resource))
->map(function (array $resource) use ($includeContent) {
$payload = array_filter([
'uri' => $resource['uri'] ?? null,
'path' => $resource['path'] ?? null,
'name' => $resource['name'] ?? null,
'description' => $resource['description'] ?? null,
'mime_type' => $resource['mime_type'] ?? ($resource['mimeType'] ?? null),
], static fn ($value) => $value !== null);
if ($includeContent && $this->resourceDefinitionHasContent($resource)) {
$payload['content'] = $this->normaliseResourceContent($resource);
}
return $payload;
})
->values();
return response()->json([
'server' => $id,
'resources' => $resources,
'count' => $resources->count(),
]);
}
/**
* Enrich a tool collection with version metadata.
*
* @param array<int, array<string, mixed>> $tools
* @return array<int, array<string, mixed>>
*/
protected function enrichToolsWithVersioning(string $serverId, array $tools): array
{
$versionService = app(ToolVersionService::class);
return collect($tools)->map(function (array $tool) use ($serverId, $versionService) {
$toolName = $tool['name'] ?? '';
$latestVersion = $versionService->getLatestVersion($serverId, $toolName);
$tool['versioning'] = [
'latest_version' => $latestVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
'is_versioned' => $latestVersion !== null,
'deprecated' => $latestVersion?->is_deprecated ?? false,
];
if ($latestVersion?->input_schema) {
$tool['inputSchema'] = $latestVersion->input_schema;
}
return $tool;
})->all();
}
/**
* Enrich a resource collection with inline content when available.
*
* @param array<int, array<string, mixed>> $resources
* @return array<int, array<string, mixed>>
*/
protected function enrichResourcesWithContent(array $resources): array
{
return collect($resources)
->filter(fn ($resource) => is_array($resource))
->map(function (array $resource) {
$payload = array_filter([
'uri' => $resource['uri'] ?? null,
'path' => $resource['path'] ?? null,
'name' => $resource['name'] ?? null,
'description' => $resource['description'] ?? null,
'mime_type' => $resource['mime_type'] ?? ($resource['mimeType'] ?? null),
], static fn ($value) => $value !== null);
if ($this->resourceDefinitionHasContent($resource)) {
$payload['content'] = $this->normaliseResourceContent($resource);
}
return $payload;
})
->values()
->all();
}
/**
* Execute a tool on an MCP server.
*
@ -129,13 +285,13 @@ class McpApiController extends Controller
$server = $this->loadServerFull($validated['server']);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
return $this->notFoundResponse('Server');
}
// Verify tool exists in server definition
$toolDef = collect($server['tools'] ?? [])->firstWhere('name', $validated['tool']);
if (! $toolDef) {
return response()->json(['error' => 'Tool not found'], 404);
return $this->notFoundResponse('Tool');
}
// Version resolution
@ -153,16 +309,18 @@ class McpApiController extends Controller
// Sunset versions return 410 Gone
$status = ($error['code'] ?? '') === 'TOOL_VERSION_SUNSET' ? 410 : 400;
return response()->json([
'success' => false,
'error' => $error['message'] ?? 'Version error',
'error_code' => $error['code'] ?? 'VERSION_ERROR',
return $this->errorResponse(
errorCode: $error['code'] ?? 'VERSION_ERROR',
message: $error['message'] ?? 'Version error',
meta: [
'server' => $validated['server'],
'tool' => $validated['tool'],
'requested_version' => $validated['version'] ?? null,
'latest_version' => $error['latest_version'] ?? null,
'migration_notes' => $error['migration_notes'] ?? null,
], $status);
],
status: $status,
);
}
/** @var McpToolVersion|null $toolVersion */
@ -178,15 +336,17 @@ class McpApiController extends Controller
);
if (! empty($validationErrors)) {
return response()->json([
'success' => false,
'error' => 'Validation failed',
'error_code' => 'VALIDATION_ERROR',
return $this->errorResponse(
errorCode: 'VALIDATION_ERROR',
message: 'Validation failed',
meta: [
'validation_errors' => $validationErrors,
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? 'unversioned',
], 422);
],
status: 422,
);
}
}
@ -201,7 +361,8 @@ class McpApiController extends Controller
$result = $this->executeToolViaArtisan(
$validated['server'],
$validated['tool'],
$validated['arguments'] ?? []
$validated['arguments'] ?? [],
$toolVersion?->version
);
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
@ -262,7 +423,16 @@ class McpApiController extends Controller
// Log full request for debugging/replay
$this->logApiRequest($request, $validated, 500, $response, $durationMs, $apiKey, $e->getMessage());
return response()->json($response, 500);
return $this->errorResponse(
errorCode: 'tool_execution_error',
message: $e->getMessage(),
meta: array_filter([
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? ToolVersionService::DEFAULT_VERSION,
]),
status: 500,
);
}
}
@ -343,13 +513,13 @@ class McpApiController extends Controller
{
$serverConfig = $this->loadServerFull($server);
if (! $serverConfig) {
return response()->json(['error' => 'Server not found'], 404);
return $this->notFoundResponse('Server');
}
// Verify tool exists in server definition
$toolDef = collect($serverConfig['tools'] ?? [])->firstWhere('name', $tool);
if (! $toolDef) {
return response()->json(['error' => 'Tool not found'], 404);
return $this->notFoundResponse('Tool');
}
$versionService = app(ToolVersionService::class);
@ -374,7 +544,7 @@ class McpApiController extends Controller
$toolVersion = $versionService->getToolAtVersion($server, $tool, $version);
if (! $toolVersion) {
return response()->json(['error' => 'Version not found'], 404);
return $this->notFoundResponse('Version');
}
$response = response()->json($toolVersion->toApiArray());
@ -397,9 +567,13 @@ class McpApiController extends Controller
*/
public function resource(Request $request, string $uri): JsonResponse
{
$uri = rawurldecode($uri);
// Parse URI format: server://resource/path
if (! preg_match('/^([a-z0-9-]+):\/\/(.+)$/', $uri, $matches)) {
return response()->json(['error' => 'Invalid resource URI format'], 400);
return $this->validationErrorResponse([
'uri' => ['Invalid resource URI format. Expected pattern server://resource/path'],
], 400);
}
$serverId = $matches[1];
@ -407,53 +581,62 @@ class McpApiController extends Controller
$server = $this->loadServerFull($serverId);
if (! $server) {
return response()->json(['error' => 'Server not found'], 404);
return $this->notFoundResponse('Server');
}
$resourceDef = $this->findResourceDefinition($server, $uri, $resourcePath);
if ($resourceDef !== null && $this->resourceDefinitionHasContent($resourceDef)) {
return response()->json([
'uri' => $uri,
'server' => $serverId,
'resource' => $resourcePath,
'content' => $this->normaliseResourceContent($resourceDef),
]);
}
try {
$result = $this->readResourceViaArtisan($serverId, $resourcePath);
if ($result === null) {
return $this->notFoundResponse('Resource');
}
if (is_array($result) && array_key_exists('content', $result)) {
$content = $result['content'];
} elseif (is_array($result) && array_key_exists('contents', $result)) {
$content = $result['contents'];
} else {
$content = $result;
}
return response()->json([
'uri' => $uri,
'content' => $result,
'server' => $serverId,
'resource' => $resourcePath,
'content' => $content,
]);
} catch (\Throwable $e) {
return response()->json([
'error' => $e->getMessage(),
return $this->errorResponse(
errorCode: 'resource_read_error',
message: $e->getMessage(),
meta: [
'uri' => $uri,
], 500);
],
status: 500,
);
}
}
/**
* Execute tool via artisan MCP server command.
*/
protected function executeToolViaArtisan(string $server, string $tool, array $arguments): mixed
protected function executeToolViaArtisan(string $server, string $tool, array $arguments, ?string $version = null): mixed
{
$commandMap = [
'hosthub-agent' => 'mcp:agent-server',
'socialhost' => 'mcp:socialhost-server',
'biohost' => 'mcp:biohost-server',
'commerce' => 'mcp:commerce-server',
'supporthost' => 'mcp:support-server',
'upstream' => 'mcp:upstream-server',
];
$command = $commandMap[$server] ?? null;
$command = $this->resolveMcpServerCommand($server);
if (! $command) {
throw new \RuntimeException("Unknown server: {$server}");
}
// Build MCP request
$mcpRequest = [
'jsonrpc' => '2.0',
'id' => uniqid(),
'method' => 'tools/call',
'params' => [
'name' => $tool,
'arguments' => $arguments,
],
];
$mcpRequest = $this->buildToolCallRequest($tool, $arguments, $version);
// Execute via process
$process = proc_open(
@ -489,14 +672,157 @@ class McpApiController extends Controller
return $response['result'] ?? null;
}
/**
* Build the JSON-RPC payload for an MCP tool call.
*/
protected function buildToolCallRequest(string $tool, array $arguments, ?string $version = null): array
{
$params = [
'name' => $tool,
'arguments' => $arguments,
];
if ($version !== null && $version !== '') {
$params['version'] = $version;
}
return [
'jsonrpc' => '2.0',
'id' => uniqid(),
'method' => 'tools/call',
'params' => $params,
];
}
/**
* Read resource via artisan MCP server command.
*/
protected function readResourceViaArtisan(string $server, string $path): mixed
{
// Similar to executeToolViaArtisan but with resources/read method
// Simplified for now - can expand later
return ['path' => $path, 'content' => 'Resource reading not yet implemented'];
$command = $this->resolveMcpServerCommand($server);
if (! $command) {
throw new \RuntimeException("Unknown server: {$server}");
}
$mcpRequest = [
'jsonrpc' => '2.0',
'id' => uniqid(),
'method' => 'resources/read',
'params' => [
'uri' => "{$server}://{$path}",
'path' => $path,
],
];
$process = proc_open(
['php', 'artisan', $command],
[
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
base_path()
);
if (! is_resource($process)) {
throw new \RuntimeException('Failed to start MCP server process');
}
fwrite($pipes[0], json_encode($mcpRequest)."\n");
fclose($pipes[0]);
$output = stream_get_contents($pipes[1]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);
$response = json_decode($output, true);
if (! is_array($response)) {
throw new \RuntimeException('Invalid MCP resource response');
}
if (isset($response['error'])) {
throw new \RuntimeException($response['error']['message'] ?? 'Resource read failed');
}
return $response['result'] ?? null;
}
/**
* Resolve the artisan command used for a given MCP server.
*/
protected function resolveMcpServerCommand(string $server): ?string
{
$commandMap = [
'hosthub-agent' => 'mcp:agent-server',
'socialhost' => 'mcp:socialhost-server',
'biohost' => 'mcp:biohost-server',
'commerce' => 'mcp:commerce-server',
'supporthost' => 'mcp:support-server',
'upstream' => 'mcp:upstream-server',
];
return $commandMap[$server] ?? null;
}
/**
* Find a resource definition within the loaded server config.
*/
protected function findResourceDefinition(array $server, string $uri, string $path): mixed
{
foreach ($server['resources'] ?? [] as $resource) {
if (! is_array($resource)) {
continue;
}
$resourceUri = $resource['uri'] ?? null;
$resourcePath = $resource['path'] ?? null;
$resourceName = $resource['name'] ?? null;
if ($resourceUri === $uri || $resourcePath === $path || $resourceName === basename($path)) {
return $resource;
}
}
return null;
}
/**
* Normalise a resource definition into a response payload.
*/
protected function normaliseResourceContent(mixed $resource): mixed
{
if (! is_array($resource)) {
return $resource;
}
foreach (['content', 'contents', 'body', 'text', 'value'] as $field) {
if (array_key_exists($field, $resource)) {
return $resource[$field];
}
}
return $resource;
}
/**
* Determine whether a resource definition already carries readable content.
*/
protected function resourceDefinitionHasContent(mixed $resource): bool
{
if (! is_array($resource)) {
return true;
}
foreach (['content', 'contents', 'body', 'text', 'value'] as $field) {
if (array_key_exists($field, $resource)) {
return true;
}
}
return false;
}
/**

View file

@ -27,6 +27,19 @@ use Attribute;
* {
* return UserResource::collection(User::paginate());
* }
*
* // For non-JSON or binary responses
* #[ApiResponse(
* 200,
* null,
* 'Transparent tracking pixel',
* contentType: 'image/gif',
* schema: ['type' => 'string', 'format' => 'binary']
* )]
* public function pixel()
* {
* return response($gif, 200)->header('Content-Type', 'image/gif');
* }
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
readonly class ApiResponse
@ -37,6 +50,8 @@ readonly class ApiResponse
* @param string|null $description Description of the response
* @param bool $paginated Whether this is a paginated collection response
* @param array<string> $headers Additional response headers to document
* @param string|null $contentType Explicit response media type for non-JSON responses
* @param array<string, mixed>|null $schema Explicit response schema when the body is not inferred from a resource
*/
public function __construct(
public int $status,
@ -44,6 +59,8 @@ readonly class ApiResponse
public ?string $description = null,
public bool $paginated = false,
public array $headers = [],
public ?string $contentType = null,
public ?array $schema = null,
) {}
/**
@ -64,10 +81,11 @@ readonly class ApiResponse
302 => 'Found (redirect)',
304 => 'Not modified',
400 => 'Bad request',
401 => 'Unauthorized',
401 => 'Unauthorised',
403 => 'Forbidden',
404 => 'Not found',
405 => 'Method not allowed',
410 => 'Gone',
409 => 'Conflict',
422 => 'Validation error',
429 => 'Too many requests',

View file

@ -34,6 +34,7 @@ class DocumentationController
return match ($defaultUi) {
'swagger' => $this->swagger($request),
'redoc' => $this->redoc($request),
'stoplight' => $this->stoplight($request),
default => $this->scalar($request),
};
}
@ -74,6 +75,19 @@ class DocumentationController
]);
}
/**
* Show Stoplight Elements.
*/
public function stoplight(Request $request): View
{
$config = config('api-docs.ui.stoplight', []);
return view('api-docs::stoplight', [
'specUrl' => route('api.docs.openapi.json'),
'config' => $config,
]);
}
/**
* Get OpenAPI specification as JSON.
*/

View file

@ -53,7 +53,7 @@ class ApiKeyAuthExtension implements Extension
'properties' => [
'message' => [
'type' => 'string',
'example' => 'This action is unauthorized.',
'example' => 'This action is unauthorised.',
],
],
];

View file

@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Extensions;
use Core\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* Sunset Extension.
*
* Documents endpoint deprecation and sunset metadata for routes using
* the `api.sunset` middleware.
*/
class SunsetExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
$spec['components']['headers']['deprecation'] = [
'description' => 'Indicates that the endpoint is deprecated.',
'schema' => [
'type' => 'string',
'enum' => ['true'],
],
];
$spec['components']['headers']['sunset'] = [
'description' => 'The date and time after which the endpoint will no longer be supported.',
'schema' => [
'type' => 'string',
'format' => 'date-time',
],
];
$spec['components']['headers']['link'] = [
'description' => 'Reference to the successor endpoint, when one is provided.',
'schema' => [
'type' => 'string',
],
];
$spec['components']['headers']['xapiwarn'] = [
'description' => 'Human-readable deprecation warning for clients.',
'schema' => [
'type' => 'string',
],
];
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
$sunset = $this->sunsetMiddlewareArguments($route);
if ($sunset === null) {
return $operation;
}
$operation['deprecated'] = true;
foreach ($operation['responses'] as $status => &$response) {
if (! is_numeric($status) || (int) $status < 200 || (int) $status >= 300) {
continue;
}
$response['headers'] = $response['headers'] ?? [];
$response['headers']['Deprecation'] = [
'$ref' => '#/components/headers/deprecation',
];
if ($sunset['sunsetDate'] !== null && $sunset['sunsetDate'] !== '') {
$response['headers']['Sunset'] = [
'$ref' => '#/components/headers/sunset',
];
}
$response['headers']['X-API-Warn'] = [
'$ref' => '#/components/headers/xapiwarn',
];
if (
$sunset['replacement'] !== null
&& $sunset['replacement'] !== ''
&& ! isset($response['headers']['Link'])
) {
$response['headers']['Link'] = [
'$ref' => '#/components/headers/link',
];
}
}
unset($response);
return $operation;
}
/**
* Extract the configured sunset middleware arguments from a route.
*
* Returns null when the route does not use the sunset middleware.
*
* @return array{sunsetDate:?string,replacement:?string}|null
*/
protected function sunsetMiddlewareArguments(Route $route): ?array
{
foreach ($route->middleware() as $middleware) {
if (! str_starts_with($middleware, 'api.sunset') && ! str_contains($middleware, 'ApiSunset')) {
continue;
}
$arguments = null;
if (str_contains($middleware, ':')) {
[, $arguments] = explode(':', $middleware, 2);
}
if ($arguments === null || $arguments === '') {
return [
'sunsetDate' => null,
'replacement' => null,
];
}
$parts = explode(',', $arguments, 2);
$sunsetDate = trim($parts[0] ?? '');
$replacement = isset($parts[1]) ? trim($parts[1]) : null;
if ($replacement === '') {
$replacement = null;
}
return [
'sunsetDate' => $sunsetDate !== '' ? $sunsetDate : null,
'replacement' => $replacement,
];
}
return null;
}
}

View file

@ -0,0 +1,126 @@
<?php
declare(strict_types=1);
namespace Core\Api\Documentation\Extensions;
use Core\Api\Documentation\Extension;
use Illuminate\Routing\Route;
/**
* API Version Extension.
*
* Documents the X-API-Version response header and version-driven deprecation
* metadata for routes using the api.version middleware.
*/
class VersionExtension implements Extension
{
/**
* Extend the complete OpenAPI specification.
*/
public function extend(array $spec, array $config): array
{
if (! (bool) config('api.headers.include_version', true)) {
return $spec;
}
$spec['components']['headers'] = $spec['components']['headers'] ?? [];
$spec['components']['headers']['xapiversion'] = [
'description' => 'API version used to process the request.',
'schema' => [
'type' => 'string',
],
];
return $spec;
}
/**
* Extend an individual operation.
*/
public function extendOperation(array $operation, Route $route, string $method, array $config): array
{
$version = $this->versionMiddlewareVersion($route);
if ($version === null) {
return $operation;
}
$includeVersion = (bool) config('api.headers.include_version', true);
$includeDeprecation = (bool) config('api.headers.include_deprecation', true);
$deprecatedVersions = array_map('intval', config('api.versioning.deprecated', []));
$sunsetDates = config('api.versioning.sunset', []);
$isDeprecatedVersion = in_array($version, $deprecatedVersions, true);
$sunsetDate = $sunsetDates[$version] ?? null;
if ($isDeprecatedVersion) {
$operation['deprecated'] = true;
}
foreach ($operation['responses'] as $status => &$response) {
if (! is_numeric($status) || (int) $status < 200 || (int) $status >= 600) {
continue;
}
$response['headers'] = $response['headers'] ?? [];
if ($includeVersion && ! isset($response['headers']['X-API-Version'])) {
$response['headers']['X-API-Version'] = [
'$ref' => '#/components/headers/xapiversion',
];
}
if (! $includeDeprecation || ! $isDeprecatedVersion) {
continue;
}
$response['headers']['Deprecation'] = [
'$ref' => '#/components/headers/deprecation',
];
$response['headers']['X-API-Warn'] = [
'$ref' => '#/components/headers/xapiwarn',
];
if ($sunsetDate !== null && $sunsetDate !== '') {
$response['headers']['Sunset'] = [
'$ref' => '#/components/headers/sunset',
];
}
}
unset($response);
return $operation;
}
/**
* Extract the version number from api.version middleware.
*/
protected function versionMiddlewareVersion(Route $route): ?int
{
foreach ($route->middleware() as $middleware) {
if (! str_starts_with($middleware, 'api.version') && ! str_contains($middleware, 'ApiVersion')) {
continue;
}
if (! str_contains($middleware, ':')) {
return null;
}
[, $arguments] = explode(':', $middleware, 2);
$arguments = trim($arguments);
if ($arguments === '') {
return null;
}
$parts = explode(',', $arguments, 2);
$version = ltrim(trim($parts[0] ?? ''), 'vV');
if ($version === '' || ! is_numeric($version)) {
return null;
}
return (int) $version;
}
return null;
}
}

View file

@ -11,6 +11,8 @@ use Core\Api\Documentation\Attributes\ApiSecurity;
use Core\Api\Documentation\Attributes\ApiTag;
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
use Core\Api\Documentation\Extensions\RateLimitExtension;
use Core\Api\Documentation\Extensions\SunsetExtension;
use Core\Api\Documentation\Extensions\VersionExtension;
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Routing\Route;
@ -57,7 +59,9 @@ class OpenApiBuilder
{
$this->extensions = [
new WorkspaceHeaderExtension,
new VersionExtension,
new RateLimitExtension,
new SunsetExtension,
new ApiKeyAuthExtension,
];
}
@ -229,6 +233,7 @@ class OpenApiBuilder
protected function buildPaths(array $config): array
{
$paths = [];
$operationIds = [];
$includePatterns = $config['routes']['include'] ?? ['api/*'];
$excludePatterns = $config['routes']['exclude'] ?? [];
@ -243,7 +248,7 @@ class OpenApiBuilder
foreach ($methods as $method) {
$method = strtolower($method);
$operation = $this->buildOperation($route, $method, $config);
$operation = $this->buildOperation($route, $method, $config, $operationIds);
if ($operation !== null) {
$paths[$path][$method] = $operation;
@ -297,7 +302,7 @@ class OpenApiBuilder
/**
* Build operation for a specific route and method.
*/
protected function buildOperation(Route $route, string $method, array $config): ?array
protected function buildOperation(Route $route, string $method, array $config, array &$operationIds): ?array
{
$controller = $route->getController();
$action = $route->getActionMethod();
@ -309,7 +314,7 @@ class OpenApiBuilder
$operation = [
'summary' => $this->buildSummary($route, $method),
'operationId' => $this->buildOperationId($route, $method),
'operationId' => $this->buildOperationId($route, $method, $operationIds),
'tags' => $this->buildOperationTags($route, $controller, $action),
'responses' => $this->buildResponses($controller, $action),
];
@ -328,7 +333,7 @@ class OpenApiBuilder
// Add request body for POST/PUT/PATCH
if (in_array($method, ['post', 'put', 'patch'])) {
$operation['requestBody'] = $this->buildRequestBody($controller, $action);
$operation['requestBody'] = $this->buildRequestBody($route, $controller, $action);
}
// Add security requirements
@ -398,15 +403,24 @@ class OpenApiBuilder
/**
* Build operation ID from route name.
*/
protected function buildOperationId(Route $route, string $method): string
protected function buildOperationId(Route $route, string $method, array &$operationIds): string
{
$name = $route->getName();
if ($name) {
return Str::camel(str_replace(['.', '-'], '_', $name));
$base = Str::camel(str_replace(['.', '-'], '_', $name));
} else {
$base = Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
}
return Str::camel($method.'_'.str_replace(['/', '-', '.'], '_', $route->uri()));
$count = $operationIds[$base] ?? 0;
$operationIds[$base] = $count + 1;
if ($count === 0) {
return $base;
}
return $base.'_'.($count + 1);
}
/**
@ -511,16 +525,36 @@ class OpenApiBuilder
protected function buildParameters(Route $route, ?object $controller, string $action, array $config): array
{
$parameters = [];
$parameterIndex = [];
$addParameter = function (array $parameter) use (&$parameters, &$parameterIndex): void {
$name = $parameter['name'] ?? null;
$in = $parameter['in'] ?? null;
if (! is_string($name) || $name === '' || ! is_string($in) || $in === '') {
return;
}
$key = $in.':'.$name;
if (isset($parameterIndex[$key])) {
$parameters[$parameterIndex[$key]] = $parameter;
return;
}
$parameterIndex[$key] = count($parameters);
$parameters[] = $parameter;
};
// Add path parameters
preg_match_all('/\{([^}?]+)\??}/', $route->uri(), $matches);
foreach ($matches[1] as $param) {
$parameters[] = [
$addParameter([
'name' => $param,
'in' => 'path',
'required' => true,
'schema' => ['type' => 'string'],
];
]);
}
// Add parameters from ApiParameter attributes
@ -532,12 +566,12 @@ class OpenApiBuilder
foreach ($paramAttrs as $attr) {
$param = $attr->newInstance();
$parameters[] = $param->toOpenApi();
$addParameter($param->toOpenApi());
}
}
}
return $parameters;
return array_values($parameters);
}
/**
@ -578,15 +612,23 @@ class OpenApiBuilder
'description' => $response->getDescription(),
];
if ($response->resource !== null && class_exists($response->resource)) {
$schema = null;
if (is_array($response->schema) && ! empty($response->schema)) {
$schema = $response->schema;
} elseif ($response->resource !== null && class_exists($response->resource)) {
$schema = $this->extractResourceSchema($response->resource);
if ($response->paginated) {
$schema = $this->wrapPaginatedSchema($schema);
}
}
if ($schema !== null) {
$contentType = $response->contentType ?: 'application/json';
$result['content'] = [
'application/json' => [
$contentType => [
'schema' => $schema,
],
];
@ -614,14 +656,181 @@ class OpenApiBuilder
return ['type' => 'object'];
}
// For now, return a generic object schema
// A more sophisticated implementation would analyze the resource's toArray method
try {
$resource = new $resourceClass(new \stdClass);
$data = $resource->toArray(request());
if (is_array($data)) {
return $this->inferArraySchema($data);
}
} catch (\Throwable) {
// Fall back to a generic object schema when the resource cannot
// be instantiated safely in the current context.
}
return [
'type' => 'object',
'additionalProperties' => true,
];
}
/**
* Infer an OpenAPI schema from a PHP array.
*/
protected function inferArraySchema(array $value): array
{
if (array_is_list($value)) {
$itemSchema = ['type' => 'object'];
foreach ($value as $item) {
if ($item === null) {
continue;
}
$itemSchema = $this->inferValueSchema($item);
break;
}
return [
'type' => 'array',
'items' => $itemSchema,
];
}
$properties = [];
foreach ($value as $key => $item) {
$properties[(string) $key] = $this->inferValueSchema($item, (string) $key);
}
return [
'type' => 'object',
'properties' => $properties,
'additionalProperties' => true,
];
}
/**
* Infer an OpenAPI schema node from a PHP value.
*/
protected function inferValueSchema(mixed $value, ?string $key = null): array
{
if ($value === null) {
return $this->inferNullableSchema($key);
}
if (is_bool($value)) {
return ['type' => 'boolean'];
}
if (is_int($value)) {
return ['type' => 'integer'];
}
if (is_float($value)) {
return ['type' => 'number'];
}
if (is_string($value)) {
return $this->inferStringSchema($value, $key);
}
if (is_array($value)) {
return $this->inferArraySchema($value);
}
if (is_object($value)) {
return $this->inferObjectSchema($value);
}
return [];
}
/**
* Infer a schema for a null value using the field name as a hint.
*/
protected function inferNullableSchema(?string $key): array
{
if ($key === null) {
return ['nullable' => true];
}
$normalized = strtolower($key);
return match (true) {
$normalized === 'id',
str_ends_with($normalized, '_id'),
str_ends_with($normalized, 'count'),
str_ends_with($normalized, 'total'),
str_ends_with($normalized, 'page'),
str_ends_with($normalized, 'limit'),
str_ends_with($normalized, 'offset'),
str_ends_with($normalized, 'size'),
str_ends_with($normalized, 'quantity'),
str_ends_with($normalized, 'rank'),
str_ends_with($normalized, 'score') => ['type' => 'integer', 'nullable' => true],
str_starts_with($normalized, 'is_'),
str_starts_with($normalized, 'has_'),
str_starts_with($normalized, 'can_'),
str_starts_with($normalized, 'should_'),
str_starts_with($normalized, 'enabled'),
str_starts_with($normalized, 'active') => ['type' => 'boolean', 'nullable' => true],
str_ends_with($normalized, '_at'),
str_ends_with($normalized, '_on'),
str_contains($normalized, 'date'),
str_contains($normalized, 'time'),
str_contains($normalized, 'timestamp') => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
str_contains($normalized, 'email') => ['type' => 'string', 'format' => 'email', 'nullable' => true],
str_contains($normalized, 'url'),
str_contains($normalized, 'uri') => ['type' => 'string', 'format' => 'uri', 'nullable' => true],
str_contains($normalized, 'uuid') => ['type' => 'string', 'format' => 'uuid', 'nullable' => true],
str_contains($normalized, 'name'),
str_contains($normalized, 'title'),
str_contains($normalized, 'description'),
str_contains($normalized, 'status'),
str_contains($normalized, 'type'),
str_contains($normalized, 'code'),
str_contains($normalized, 'token'),
str_contains($normalized, 'slug'),
str_contains($normalized, 'key') => ['type' => 'string', 'nullable' => true],
default => ['nullable' => true],
};
}
/**
* Infer a schema for a string value using the field name as a hint.
*/
protected function inferStringSchema(string $value, ?string $key): array
{
if ($key !== null) {
$nullable = $this->inferNullableSchema($key);
if (($nullable['type'] ?? null) === 'string') {
$nullable['nullable'] = false;
return $nullable;
}
}
return ['type' => 'string'];
}
/**
* Infer a schema for an object value.
*/
protected function inferObjectSchema(object $value): array
{
$properties = [];
foreach (get_object_vars($value) as $key => $item) {
$properties[$key] = $this->inferValueSchema($item, (string) $key);
}
return [
'type' => 'object',
'properties' => $properties,
'additionalProperties' => true,
];
}
/**
* Wrap schema in pagination structure.
*/
@ -661,8 +870,45 @@ class OpenApiBuilder
/**
* Build request body schema.
*/
protected function buildRequestBody(?object $controller, string $action): array
protected function buildRequestBody(Route $route, ?object $controller, string $action): array
{
if ($controller instanceof \Core\Api\Controllers\McpApiController && $action === 'callTool') {
return [
'required' => true,
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'properties' => [
'server' => [
'type' => 'string',
'maxLength' => 64,
'description' => 'MCP server identifier.',
],
'tool' => [
'type' => 'string',
'maxLength' => 128,
'description' => 'Tool name to invoke on the selected server.',
],
'arguments' => [
'type' => 'object',
'description' => 'Tool arguments passed through to MCP.',
'additionalProperties' => true,
],
'version' => [
'type' => 'string',
'maxLength' => 32,
'description' => 'Optional tool version to execute. Defaults to the latest supported version.',
],
],
'required' => ['server', 'tool'],
'additionalProperties' => true,
],
],
],
];
}
return [
'required' => true,
'content' => [

View file

@ -20,6 +20,7 @@ Route::get('/', [DocumentationController::class, 'index'])->name('api.docs');
Route::get('/swagger', [DocumentationController::class, 'swagger'])->name('api.docs.swagger');
Route::get('/scalar', [DocumentationController::class, 'scalar'])->name('api.docs.scalar');
Route::get('/redoc', [DocumentationController::class, 'redoc'])->name('api.docs.redoc');
Route::get('/stoplight', [DocumentationController::class, 'stoplight'])->name('api.docs.stoplight');
// OpenAPI specification routes
Route::get('/openapi.json', [DocumentationController::class, 'openApiJson'])

View file

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="API Documentation - Stoplight Elements">
<title>{{ config('api-docs.info.title', 'API Documentation') }} - Stoplight</title>
<style>
html, body {
margin: 0;
min-height: 100%;
background: #0f172a;
}
elements-api {
height: 100vh;
width: 100%;
display: block;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/@stoplight/elements/styles.min.css">
</head>
<body>
<elements-api
apiDescriptionUrl="{{ $specUrl }}"
router="hash"
layout="{{ $config['layout'] ?? 'sidebar' }}"
theme="{{ $config['theme'] ?? 'dark' }}"
@if($config['hide_try_it'] ?? false) hideTryIt="true" @endif
></elements-api>
<script src="https://unpkg.com/@stoplight/elements/web-components.min.js"></script>
</body>
</html>

View file

@ -268,6 +268,13 @@ return [
'hide_download_button' => false,
'hide_models' => false,
],
// Stoplight Elements specific options
'stoplight' => [
'theme' => 'dark', // 'dark' or 'light'
'layout' => 'sidebar', // 'sidebar' or 'stacked'
'hide_try_it' => false,
],
],
/*

View file

@ -5,7 +5,9 @@ declare(strict_types=1);
namespace Core\Api\Exceptions;
use Core\Api\RateLimit\RateLimitResult;
use Core\Api\Concerns\HasApiResponses;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
@ -15,6 +17,8 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
*/
class RateLimitExceededException extends HttpException
{
use HasApiResponses;
public function __construct(
protected RateLimitResult $rateLimitResult,
string $message = 'Too many requests. Please slow down.',
@ -33,15 +37,22 @@ class RateLimitExceededException extends HttpException
/**
* Render the exception as a JSON response.
*/
public function render(): JsonResponse
public function render(?Request $request = null): JsonResponse
{
return response()->json([
'error' => 'rate_limit_exceeded',
'message' => $this->getMessage(),
// Return the rate-limit error response with rate-limit headers attached.
// CORS headers are intentionally omitted here; they are applied by the
// framework's CORS middleware (or PublicApiCors) which handles patterns,
// credentials, and Vary correctly for all responses — including errors.
return $this->errorResponse(
errorCode: 'rate_limit_exceeded',
message: $this->getMessage(),
meta: [
'retry_after' => $this->rateLimitResult->retryAfter,
'limit' => $this->rateLimitResult->limit,
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
], 429, $this->rateLimitResult->headers());
],
status: 429,
)->withHeaders($this->rateLimitResult->headers());
}
/**

View file

@ -6,6 +6,7 @@ namespace Core\Api\Middleware;
use Core\Api\Models\ApiKey;
use Core\Api\Services\IpRestrictionService;
use Core\Api\Concerns\HasApiResponses;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -24,6 +25,8 @@ use Symfony\Component\HttpFoundation\Response;
*/
class AuthenticateApiKey
{
use HasApiResponses;
public function handle(Request $request, Closure $next, ?string $scope = null): Response
{
$token = $request->bearerToken();
@ -113,14 +116,15 @@ class AuthenticateApiKey
}
/**
* Return 401 Unauthorized response.
* Return 401 Unauthorised response.
*/
protected function unauthorized(string $message): Response
{
return response()->json([
'error' => 'unauthorized',
'message' => $message,
], 401);
return $this->errorResponse(
errorCode: 'unauthorized',
message: $message,
status: 401,
);
}
/**
@ -128,9 +132,6 @@ class AuthenticateApiKey
*/
protected function forbidden(string $message): Response
{
return response()->json([
'error' => 'forbidden',
'message' => $message,
], 403);
return $this->forbiddenResponse($message, status: 403);
}
}

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Core\Api\Middleware;
use Core\Api\Models\ApiKey;
use Core\Api\Concerns\HasApiResponses;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
*/
class CheckApiScope
{
use HasApiResponses;
public function handle(Request $request, Closure $next, string ...$scopes): Response
{
$apiKey = $request->attributes->get('api_key');
@ -38,12 +41,13 @@ class CheckApiScope
// Check all required scopes
foreach ($scopes as $scope) {
if (! $apiKey->hasScope($scope)) {
return response()->json([
'error' => 'forbidden',
'message' => "API key missing required scope: {$scope}",
return $this->forbiddenResponse(
message: "API key missing required scope: {$scope}",
meta: [
'required_scopes' => $scopes,
'key_scopes' => $apiKey->scopes,
], 403);
],
);
}
}

View file

@ -6,6 +6,7 @@ namespace Core\Api\Middleware;
use Closure;
use Core\Api\Models\ApiKey;
use Core\Api\Concerns\HasApiResponses;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
@ -25,6 +26,8 @@ use Symfony\Component\HttpFoundation\Response;
*/
class EnforceApiScope
{
use HasApiResponses;
/**
* HTTP method to required scope mapping.
*/
@ -52,12 +55,13 @@ class EnforceApiScope
$requiredScope = self::METHOD_SCOPES[$method] ?? ApiKey::SCOPE_READ;
if (! $apiKey->hasScope($requiredScope)) {
return response()->json([
'error' => 'forbidden',
'message' => "API key missing required scope: {$requiredScope}",
return $this->forbiddenResponse(
message: "API key missing required scope: {$requiredScope}",
meta: [
'detail' => "{$method} requests require '{$requiredScope}' scope",
'key_scopes' => $apiKey->scopes,
], 403);
],
);
}
return $next($request);

View file

@ -40,7 +40,7 @@ class ErrorResource extends JsonResource
/**
* Common error factory methods.
*/
public static function unauthorized(string $message = 'Unauthorized'): static
public static function unauthorized(string $message = 'Unauthorised'): static
{
return new static('unauthorized', $message);
}

View file

@ -2,7 +2,12 @@
declare(strict_types=1);
use Core\Api\Controllers\Api\UnifiedPixelController;
use Core\Api\Controllers\Api\EntitlementApiController;
use Core\Api\Controllers\Api\SeoReportController;
use Core\Api\Controllers\Api\WebhookSecretController;
use Core\Api\Controllers\McpApiController;
use Core\Api\Middleware\PublicApiCors;
use Core\Mcp\Middleware\McpApiKeyAuth;
use Illuminate\Support\Facades\Route;
@ -13,11 +18,81 @@ use Illuminate\Support\Facades\Route;
|
| Core API routes for cross-cutting concerns.
|
| TODO: SeoReportController, UnifiedPixelController, EntitlementApiController
| are planned but not yet implemented. Re-add routes when controllers exist.
| SEO, pixel tracking, entitlements, and MCP bridge endpoints.
|
*/
// ─────────────────────────────────────────────────────────────────────────────
// Unified Pixel (public tracking)
// ─────────────────────────────────────────────────────────────────────────────
Route::middleware([PublicApiCors::class, 'api.rate'])
->prefix('pixel')
->name('api.pixel.')
->group(function () {
Route::match(['GET', 'POST', 'OPTIONS'], '/{pixelKey}', [UnifiedPixelController::class, 'track'])
->name('track');
});
// ─────────────────────────────────────────────────────────────────────────────
// SEO analysis (authenticated)
// ─────────────────────────────────────────────────────────────────────────────
Route::middleware(['auth.api', 'api.scope.enforce'])
->prefix('seo')
->name('api.seo.')
->group(function () {
Route::get('/report', [SeoReportController::class, 'show'])
->name('report');
});
// ─────────────────────────────────────────────────────────────────────────────
// Entitlements (authenticated)
// ─────────────────────────────────────────────────────────────────────────────
Route::middleware(['auth.api', 'api.scope.enforce'])
->prefix('entitlements')
->name('api.entitlements.')
->group(function () {
Route::get('/', [EntitlementApiController::class, 'show'])
->name('show');
});
// ─────────────────────────────────────────────────────────────────────────────
// Webhook secret rotation (authenticated)
// ─────────────────────────────────────────────────────────────────────────────
Route::middleware(['auth.api', 'api.scope.enforce'])
->prefix('webhooks')
->name('api.webhooks.')
->group(function () {
Route::prefix('social/{uuid}/secret')
->name('social.')
->group(function () {
Route::post('/rotate', [WebhookSecretController::class, 'rotateSocialSecret'])
->name('rotate-secret');
Route::get('/', [WebhookSecretController::class, 'socialSecretStatus'])
->name('status');
Route::delete('/previous', [WebhookSecretController::class, 'invalidateSocialPreviousSecret'])
->name('invalidate-previous');
Route::patch('/grace-period', [WebhookSecretController::class, 'updateSocialGracePeriod'])
->name('grace-period');
});
Route::prefix('content/{uuid}/secret')
->name('content.')
->group(function () {
Route::post('/rotate', [WebhookSecretController::class, 'rotateContentSecret'])
->name('rotate-secret');
Route::get('/', [WebhookSecretController::class, 'contentSecretStatus'])
->name('status');
Route::delete('/previous', [WebhookSecretController::class, 'invalidateContentPreviousSecret'])
->name('invalidate-previous');
Route::patch('/grace-period', [WebhookSecretController::class, 'updateContentGracePeriod'])
->name('grace-period');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// MCP HTTP Bridge (API key auth)
// ─────────────────────────────────────────────────────────────────────────────
@ -34,6 +109,8 @@ Route::middleware(['throttle:120,1', McpApiKeyAuth::class, 'api.scope.enforce'])
->name('servers.show');
Route::get('/servers/{id}/tools', [McpApiController::class, 'tools'])
->name('servers.tools');
Route::get('/servers/{id}/resources', [McpApiController::class, 'resources'])
->name('servers.resources');
// Tool version history (read)
Route::get('/servers/{server}/tools/{tool}/versions', [McpApiController::class, 'toolVersions'])

View file

@ -2,11 +2,12 @@
declare(strict_types=1);
namespace Mod\Api\Services;
namespace Core\Api\Services;
use Carbon\Carbon;
use Mod\Api\Models\ApiUsage;
use Mod\Api\Models\ApiUsageDaily;
use Core\Api\Models\ApiKey;
use Core\Api\Models\ApiUsage;
use Core\Api\Models\ApiUsageDaily;
/**
* API Usage Service - tracks and reports API usage metrics.
@ -282,7 +283,7 @@ class ApiUsageService
// Fetch API keys separately to avoid broken eager loading with aggregation
$apiKeyIds = $aggregated->pluck('api_key_id')->filter()->unique()->all();
$apiKeys = \Mod\Api\Models\ApiKey::whereIn('id', $apiKeyIds)
$apiKeys = ApiKey::whereIn('id', $apiKeyIds)
->select('id', 'name', 'prefix')
->get()
->keyBy('id');

View file

@ -0,0 +1,534 @@
<?php
declare(strict_types=1);
namespace Core\Api\Services;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;
/**
* SEO report service.
*
* Fetches a page and extracts the most useful technical SEO signals from it.
*/
class SeoReportService
{
/**
* Analyse a URL and return a technical SEO report.
*
* @throws RuntimeException when the URL is blocked for SSRF reasons or the fetch fails.
*/
public function analyse(string $url): array
{
$this->validateUrlForSsrf($url);
try {
$response = Http::withHeaders([
'User-Agent' => config('app.name', 'Core API').' SEO Reporter/1.0',
'Accept' => 'text/html,application/xhtml+xml',
])
->timeout((int) config('api.seo.timeout', 10))
->withoutRedirecting()
->get($url)
->throw();
} catch (RuntimeException $exception) {
throw $exception;
} catch (Throwable $exception) {
throw new RuntimeException('Unable to fetch the requested URL.', 0, $exception);
}
$html = (string) $response->body();
$xpath = $this->loadXPath($html);
$title = $this->extractSingleText($xpath, '//title');
$description = $this->extractMetaContent($xpath, 'description');
$canonical = $this->extractLinkHref($xpath, 'canonical');
$robots = $this->extractMetaContent($xpath, 'robots');
$language = $this->extractHtmlAttribute($xpath, 'lang');
$charset = $this->extractCharset($xpath);
$openGraph = [
'title' => $this->extractMetaContent($xpath, 'og:title', 'property'),
'description' => $this->extractMetaContent($xpath, 'og:description', 'property'),
'image' => $this->extractMetaContent($xpath, 'og:image', 'property'),
'type' => $this->extractMetaContent($xpath, 'og:type', 'property'),
'site_name' => $this->extractMetaContent($xpath, 'og:site_name', 'property'),
];
$twitterCard = [
'card' => $this->extractMetaContent($xpath, 'twitter:card', 'name'),
'title' => $this->extractMetaContent($xpath, 'twitter:title', 'name'),
'description' => $this->extractMetaContent($xpath, 'twitter:description', 'name'),
'image' => $this->extractMetaContent($xpath, 'twitter:image', 'name'),
];
$headings = $this->countHeadings($xpath);
$issues = $this->buildIssues($title, $description, $canonical, $robots, $openGraph, $headings);
return [
'url' => $url,
'status_code' => $response->status(),
'content_type' => $response->header('Content-Type'),
'score' => $this->calculateScore($issues),
'summary' => [
'title' => $title,
'description' => $description,
'canonical' => $canonical,
'robots' => $robots,
'language' => $language,
'charset' => $charset,
],
'open_graph' => $openGraph,
'twitter' => $twitterCard,
'headings' => $headings,
'issues' => $issues,
'recommendations' => $this->buildRecommendations($issues),
];
}
/**
* Load an HTML document into an XPath query object.
*/
protected function loadXPath(string $html): DOMXPath
{
$previous = libxml_use_internal_errors(true);
$document = new DOMDocument();
$document->loadHTML($html, LIBXML_NOERROR | LIBXML_NOWARNING);
libxml_clear_errors();
libxml_use_internal_errors($previous);
return new DOMXPath($document);
}
/**
* Extract the first text node matched by an XPath query.
*/
protected function extractSingleText(DOMXPath $xpath, string $query): ?string
{
$nodes = $xpath->query($query);
if (! $nodes || $nodes->length === 0) {
return null;
}
$node = $nodes->item(0);
if (! $node) {
return null;
}
$value = trim($node->textContent ?? '');
return $value !== '' ? $value : null;
}
/**
* Extract a meta tag content value.
*/
protected function extractMetaContent(DOMXPath $xpath, string $name, string $attribute = 'name'): ?string
{
$query = sprintf('//meta[@%s=%s]/@content', $attribute, $this->quoteForXPath($name));
$nodes = $xpath->query($query);
if (! $nodes || $nodes->length === 0) {
return null;
}
$node = $nodes->item(0);
if (! $node) {
return null;
}
$value = trim($node->textContent ?? '');
return $value !== '' ? $value : null;
}
/**
* Extract a link href value.
*/
protected function extractLinkHref(DOMXPath $xpath, string $rel): ?string
{
$query = sprintf('//link[@rel=%s]/@href', $this->quoteForXPath($rel));
$nodes = $xpath->query($query);
if (! $nodes || $nodes->length === 0) {
return null;
}
$node = $nodes->item(0);
if (! $node) {
return null;
}
$value = trim($node->textContent ?? '');
return $value !== '' ? $value : null;
}
/**
* Extract the HTML lang attribute.
*/
protected function extractHtmlAttribute(DOMXPath $xpath, string $attribute): ?string
{
$nodes = $xpath->query(sprintf('//html/@%s', $attribute));
if (! $nodes || $nodes->length === 0) {
return null;
}
$node = $nodes->item(0);
if (! $node) {
return null;
}
$value = trim($node->textContent ?? '');
return $value !== '' ? $value : null;
}
/**
* Extract a charset declaration.
*/
protected function extractCharset(DOMXPath $xpath): ?string
{
$nodes = $xpath->query('//meta[@charset]/@charset');
if ($nodes && $nodes->length > 0) {
$node = $nodes->item(0);
if ($node) {
$value = trim($node->textContent ?? '');
if ($value !== '') {
return $value;
}
}
}
// The http-equiv Content-Type meta returns a full value such as
// "text/html; charset=utf-8". Extract only the charset token so that
// 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];
}
}
return null;
}
/**
* Count headings by level.
*
* @return array<string, int>
*/
protected function countHeadings(DOMXPath $xpath): array
{
$counts = [];
for ($level = 1; $level <= 6; $level++) {
$nodes = $xpath->query('//h'.$level);
$counts['h'.$level] = $nodes ? $nodes->length : 0;
}
return $counts;
}
/**
* Build issue list from the extracted SEO data.
*
* @return array<int, array<string, string>>
*/
protected function buildIssues(
?string $title,
?string $description,
?string $canonical,
?string $robots,
array $openGraph,
array $headings
): array {
$issues = [];
if ($title === null) {
$issues[] = $this->issue('missing_title', 'No <title> tag was found.', 'high');
} elseif (Str::length($title) < 10) {
$issues[] = $this->issue('title_too_short', 'The page title is shorter than 10 characters.', 'medium');
} elseif (Str::length($title) > 60) {
$issues[] = $this->issue('title_too_long', 'The page title is longer than 60 characters.', 'medium');
}
if ($description === null) {
$issues[] = $this->issue('missing_description', 'No meta description was found.', 'high');
}
if ($canonical === null) {
$issues[] = $this->issue('missing_canonical', 'No canonical URL was found.', 'medium');
}
if (($headings['h1'] ?? 0) === 0) {
$issues[] = $this->issue('missing_h1', 'The page does not contain an H1 heading.', 'high');
} elseif (($headings['h1'] ?? 0) > 1) {
$issues[] = $this->issue('multiple_h1', 'The page contains multiple H1 headings.', 'medium');
}
if (($openGraph['title'] ?? null) === null) {
$issues[] = $this->issue('missing_og_title', 'No Open Graph title was found.', 'low');
}
if (($openGraph['description'] ?? null) === null) {
$issues[] = $this->issue('missing_og_description', 'No Open Graph description was found.', 'low');
}
if ($robots !== null && Str::contains(Str::lower($robots), ['noindex', 'nofollow'])) {
$issues[] = $this->issue('robots_restricted', 'Robots directives block indexing or following links.', 'high');
}
return $issues;
}
/**
* Convert a list of issues to a report score.
*/
protected function calculateScore(array $issues): int
{
$penalties = [
'missing_title' => 20,
'title_too_short' => 5,
'title_too_long' => 5,
'missing_description' => 15,
'missing_canonical' => 10,
'missing_h1' => 15,
'multiple_h1' => 5,
'missing_og_title' => 5,
'missing_og_description' => 5,
'robots_restricted' => 20,
];
$score = 100;
foreach ($issues as $issue) {
$score -= $penalties[$issue['code']] ?? 0;
}
return max(0, $score);
}
/**
* Build recommendations from issues.
*
* @return array<int, string>
*/
protected function buildRecommendations(array $issues): array
{
$recommendations = [];
foreach ($issues as $issue) {
$recommendations[] = match ($issue['code']) {
'missing_title' => 'Add a concise page title that describes the page content.',
'title_too_short' => 'Expand the page title so it is more descriptive.',
'title_too_long' => 'Shorten the page title to keep it under 60 characters.',
'missing_description' => 'Add a meta description to improve search snippets.',
'missing_canonical' => 'Add a canonical URL to prevent duplicate content issues.',
'missing_h1' => 'Add a single, descriptive H1 heading.',
'multiple_h1' => 'Reduce the page to a single primary H1 heading.',
'missing_og_title' => 'Add an Open Graph title for better social sharing.',
'missing_og_description' => 'Add an Open Graph description for better social sharing.',
'robots_restricted' => 'Remove noindex or nofollow directives if the page should be indexed.',
default => $issue['message'],
};
}
return array_values(array_unique($recommendations));
}
/**
* Build an issue record.
*
* @return array{code: string, message: string, severity: string}
*/
protected function issue(string $code, string $message, string $severity): array
{
return [
'code' => $code,
'message' => $message,
'severity' => $severity,
];
}
/**
* Validate that a URL is safe to fetch and does not target internal/private
* network resources (SSRF protection).
*
* Blocks:
* - Non-HTTP/HTTPS schemes
* - Loopback addresses (127.0.0.0/8, ::1)
* - RFC-1918 private ranges (10/8, 172.16/12, 192.168/16)
* - Link-local ranges (169.254.0.0/16, fe80::/10)
* - IPv6 ULA (fc00::/7)
*
* @throws RuntimeException when the URL fails SSRF validation.
*/
protected function validateUrlForSsrf(string $url): void
{
$parsed = parse_url($url);
if ($parsed === false || empty($parsed['scheme']) || empty($parsed['host'])) {
throw new RuntimeException('The supplied URL is not valid.');
}
if (! in_array(strtolower($parsed['scheme']), ['http', 'https'], true)) {
throw new RuntimeException('Only HTTP and HTTPS URLs are permitted.');
}
$host = $parsed['host'];
// If the host is an IP literal (IPv4 or bracketed IPv6), validate it
// directly. dns_get_record returns nothing for IP literals and
// gethostbyname returns the same value, so both would silently skip
// the private-range check without this explicit guard.
$normalised = ltrim(rtrim($host, ']'), '['); // strip IPv6 brackets
if (filter_var($normalised, FILTER_VALIDATE_IP) !== false) {
if ($this->isPrivateIp($normalised)) {
throw new RuntimeException('The supplied URL resolves to a private or reserved address.');
}
// Valid public IP literal — no DNS lookup required.
return;
}
$records = dns_get_record($host, DNS_A | DNS_AAAA) ?: [];
// Fall back to gethostbyname for hosts not returned by dns_get_record.
if (empty($records)) {
$resolved = gethostbyname($host);
if ($resolved !== $host) {
$records[] = ['ip' => $resolved];
}
}
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if ($ip === null) {
continue;
}
if ($this->isPrivateIp($ip)) {
throw new RuntimeException('The supplied URL resolves to a private or reserved address.');
}
}
}
/**
* Return true when an IP address falls within a private, loopback, or
* link-local range.
*/
protected function isPrivateIp(string $ip): bool
{
// inet_pton returns false for invalid addresses.
$packed = inet_pton($ip);
if ($packed === false) {
return true; // Treat unresolvable as unsafe.
}
if (strlen($packed) === 4) {
return $this->isPrivateIpv4($ip);
}
// IPv6 checks.
// ::ffff:0:0/96 — IPv4-mapped addresses (e.g. ::ffff:127.0.0.1).
// The first 10 bytes are 0x00, bytes 10-11 are 0xff 0xff, then 4
// bytes of IPv4. Evaluate the embedded IPv4 address against the
// standard private ranges.
if (str_repeat("\x00", 10) . "\xff\xff" === substr($packed, 0, 12)) {
$ipv4 = inet_ntop(substr($packed, 12, 4));
if ($ipv4 !== false && $this->isPrivateIpv4($ipv4)) {
return true;
}
}
// Loopback (::1).
if ($ip === '::1') {
return true;
}
$prefix2 = strtolower(substr(bin2hex($packed), 0, 2));
// fe80::/10 — first byte 0xfe, second byte 0x800xbf
if ($prefix2 === 'fe') {
$secondNibble = hexdec(substr(bin2hex($packed), 2, 1));
if ($secondNibble >= 8 && $secondNibble <= 11) {
return true;
}
}
// fc00::/7 — first byte 0xfc or 0xfd
if (in_array($prefix2, ['fc', 'fd'], true)) {
return true;
}
return false;
}
/**
* Return true when an IPv4 address string falls within a private,
* loopback, link-local, or reserved range.
*
* Handles 0.0.0.0/8 (RFC 1122 "this network"), 127/8 (loopback),
* 10/8, 172.16/12, 192.168/16 (RFC 1918), and 169.254/16 (link-local).
*/
protected function isPrivateIpv4(string $ip): bool
{
$long = ip2long($ip);
if ($long === false) {
return true; // Treat unparsable as unsafe.
}
$privateRanges = [
['start' => ip2long('0.0.0.0'), 'end' => ip2long('0.255.255.255')], // 0.0.0.0/8 (RFC 1122)
['start' => ip2long('127.0.0.0'), 'end' => ip2long('127.255.255.255')], // loopback
['start' => ip2long('10.0.0.0'), 'end' => ip2long('10.255.255.255')], // RFC-1918
['start' => ip2long('172.16.0.0'), 'end' => ip2long('172.31.255.255')], // RFC-1918
['start' => ip2long('192.168.0.0'), 'end' => ip2long('192.168.255.255')], // RFC-1918
['start' => ip2long('169.254.0.0'), 'end' => ip2long('169.254.255.255')], // link-local
];
foreach ($privateRanges as $range) {
if ($long >= $range['start'] && $long <= $range['end']) {
return true;
}
}
return false;
}
/**
* Quote a literal for XPath queries.
*/
protected function quoteForXPath(string $value): string
{
if (! str_contains($value, "'")) {
return "'{$value}'";
}
if (! str_contains($value, '"')) {
return '"'.$value.'"';
}
$parts = array_map(
fn (string $part) => "'{$part}'",
explode("'", $value)
);
return 'concat('.implode(", \"'\", ", $parts).')';
}
}

View file

@ -2,12 +2,12 @@
declare(strict_types=1);
use Mod\Api\Models\ApiKey;
use Mod\Api\Models\ApiUsage;
use Mod\Api\Models\ApiUsageDaily;
use Mod\Api\Services\ApiUsageService;
use Mod\Tenant\Models\User;
use Mod\Tenant\Models\Workspace;
use Core\Api\Models\ApiKey;
use Core\Api\Models\ApiUsage;
use Core\Api\Models\ApiUsageDaily;
use Core\Api\Services\ApiUsageService;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);

View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
it('renders Stoplight Elements when selected as the default documentation ui', function () {
config(['api-docs.ui.default' => 'stoplight']);
$response = $this->get('/api/docs');
$response->assertOk();
$response->assertSee('elements-api', false);
$response->assertSee('@stoplight/elements', false);
});
it('renders the dedicated Stoplight documentation route', function () {
$response = $this->get('/api/docs/stoplight');
$response->assertOk();
$response->assertSee('elements-api', false);
$response->assertSee('@stoplight/elements', false);
});

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Core\Api\Models\ApiKey;
use Core\Api\Services\ApiUsageService;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'Entitlements Key',
[ApiKey::SCOPE_READ]
);
$this->plainKey = $result['plain_key'];
$this->apiKey = $result['api_key'];
});
it('returns entitlement limits and usage for the current workspace', function () {
app(ApiUsageService::class)->record(
apiKeyId: $this->apiKey->id,
workspaceId: $this->workspace->id,
endpoint: '/api/entitlements',
method: 'GET',
statusCode: 200,
responseTimeMs: 42,
ipAddress: '127.0.0.1',
userAgent: 'Pest'
);
$response = $this->getJson('/api/entitlements', [
'Authorization' => "Bearer {$this->plainKey}",
]);
$response->assertOk();
$response->assertJsonPath('workspace_id', $this->workspace->id);
$response->assertJsonPath('authentication.type', 'api_key');
$response->assertJsonPath('limits.api_keys.maximum', config('api.keys.max_per_workspace'));
$response->assertJsonPath('limits.api_keys.active', 1);
$response->assertJsonPath('usage.totals.requests', 1);
$response->assertJsonPath('features.mcp', true);
});

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Core\Api\Controllers\McpApiController;
it('includes the requested tool version in the MCP JSON-RPC payload', function () {
$controller = new class extends McpApiController
{
public function payload(string $tool, array $arguments, ?string $version = null): array
{
return $this->buildToolCallRequest($tool, $arguments, $version);
}
};
$payload = $controller->payload('search', ['query' => 'status'], '1.2.3');
expect($payload['jsonrpc'])->toBe('2.0');
expect($payload['method'])->toBe('tools/call');
expect($payload['params'])->toMatchArray([
'name' => 'search',
'arguments' => ['query' => 'status'],
'version' => '1.2.3',
]);
});
it('omits the version field when one is not requested', function () {
$controller = new class extends McpApiController
{
public function payload(string $tool, array $arguments, ?string $version = null): array
{
return $this->buildToolCallRequest($tool, $arguments, $version);
}
};
$payload = $controller->payload('search', ['query' => 'status']);
expect($payload['params'])->toMatchArray([
'name' => 'search',
'arguments' => ['query' => 'status'],
]);
expect($payload['params'])->not->toHaveKey('version');
});

View file

@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Cache;
use Mod\Api\Models\ApiKey;
use Mod\Tenant\Models\User;
use Mod\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'MCP Resource Key',
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
$this->plainKey = $result['plain_key'];
$this->serverId = 'test-resource-server';
$this->serverDir = resource_path('mcp/servers');
$this->serverFile = $this->serverDir.'/'.$this->serverId.'.yaml';
if (! is_dir($this->serverDir)) {
mkdir($this->serverDir, 0777, true);
}
file_put_contents($this->serverFile, <<<YAML
id: test-resource-server
name: Test Resource Server
status: available
resources:
- uri: test-resource-server://documents/welcome
path: documents/welcome
name: welcome
content:
message: Hello from the MCP resource bridge
version: 1
YAML);
});
afterEach(function () {
Cache::flush();
if (isset($this->serverFile) && is_file($this->serverFile)) {
unlink($this->serverFile);
}
if (isset($this->serverDir) && is_dir($this->serverDir)) {
@rmdir($this->serverDir);
}
$mcpDir = dirname($this->serverDir ?? '');
if (is_dir($mcpDir)) {
@rmdir($mcpDir);
}
});
it('reads a resource from the server definition', function () {
$encodedUri = rawurlencode('test-resource-server://documents/welcome');
$response = $this->getJson("/api/mcp/resources/{$encodedUri}", [
'Authorization' => "Bearer {$this->plainKey}",
]);
$response->assertOk();
$response->assertJson([
'uri' => 'test-resource-server://documents/welcome',
'server' => 'test-resource-server',
'resource' => 'documents/welcome',
]);
expect($response->json('content'))->toBe([
'message' => 'Hello from the MCP resource bridge',
'version' => 1,
]);
});
it('lists resources for a server', function () {
$response = $this->getJson('/api/mcp/servers/test-resource-server/resources', [
'Authorization' => "Bearer {$this->plainKey}",
]);
$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.path', 'documents/welcome');
$response->assertJsonPath('resources.0.name', 'welcome');
$response->assertJsonMissingPath('resources.0.content');
});

View file

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
use Core\Mod\Mcp\Services\ToolVersionService;
use Illuminate\Support\Facades\Cache;
use Mod\Api\Models\ApiKey;
use Mod\Tenant\Models\User;
use Mod\Tenant\Models\Workspace;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
Cache::flush();
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'MCP Server Detail Key',
[ApiKey::SCOPE_READ, ApiKey::SCOPE_WRITE]
);
$this->plainKey = $result['plain_key'];
$this->serverId = 'test-detail-server';
$this->serverDir = resource_path('mcp/servers');
$this->serverFile = $this->serverDir.'/'.$this->serverId.'.yaml';
if (! is_dir($this->serverDir)) {
mkdir($this->serverDir, 0777, true);
}
file_put_contents($this->serverFile, <<<YAML
id: test-detail-server
name: Test Detail Server
status: available
tools:
- name: search
description: Search records
inputSchema:
type: object
properties:
query:
type: string
required:
- query
resources:
- uri: test-detail-server://documents/welcome
path: documents/welcome
name: welcome
content:
message: Hello from the server detail endpoint
version: 2
YAML);
app()->instance(ToolVersionService::class, new class
{
public function getLatestVersion(string $serverId, string $toolName): object
{
return (object) [
'version' => '2.1.0',
'is_deprecated' => false,
'input_schema' => [
'type' => 'object',
'properties' => [
'query' => [
'type' => 'string',
],
],
'required' => ['query'],
],
];
}
});
});
afterEach(function () {
Cache::flush();
if (isset($this->serverFile) && is_file($this->serverFile)) {
unlink($this->serverFile);
}
if (isset($this->serverDir) && is_dir($this->serverDir)) {
@rmdir($this->serverDir);
}
$mcpDir = dirname($this->serverDir ?? '');
if (is_dir($mcpDir)) {
@rmdir($mcpDir);
}
});
it('includes tool versions and resource content on server detail requests when requested', function () {
$response = $this->getJson('/api/mcp/servers/test-detail-server?include_versions=1&include_content=1', [
'Authorization' => "Bearer {$this->plainKey}",
]);
$response->assertOk();
$response->assertJsonPath('id', 'test-detail-server');
$response->assertJsonPath('tools.0.name', 'search');
$response->assertJsonPath('tools.0.versioning.latest_version', '2.1.0');
$response->assertJsonPath('tools.0.inputSchema.required.0', 'query');
$response->assertJsonPath('resources.0.uri', 'test-detail-server://documents/welcome');
$response->assertJsonPath('resources.0.content.message', 'Hello from the server detail endpoint');
$response->assertJsonPath('resources.0.content.version', 2);
$response->assertJsonPath('tool_count', 1);
$response->assertJsonPath('resource_count', 1);
});

View file

@ -10,6 +10,7 @@ use Core\Api\Documentation\Attributes\ApiTag;
use Core\Api\Documentation\Extension;
use Core\Api\Documentation\Extensions\ApiKeyAuthExtension;
use Core\Api\Documentation\Extensions\RateLimitExtension;
use Core\Api\Documentation\Extensions\SunsetExtension;
use Core\Api\Documentation\Extensions\WorkspaceHeaderExtension;
use Core\Api\Documentation\OpenApiBuilder;
use Core\Api\RateLimit\RateLimit;
@ -152,6 +153,26 @@ describe('OpenApiBuilder Controller Scanning', function () {
expect($operation['operationId'])->toBe('testScanItemsIndex');
});
it('makes duplicate operation IDs unique', function () {
RouteFacade::prefix('api')
->middleware('api')
->group(function () {
RouteFacade::get('/duplicate-id/dup-one', fn () => response()->json([]));
RouteFacade::get('/duplicate-id/dup_one', fn () => response()->json([]));
});
config(['api-docs.routes.include' => ['api/*']]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
$first = $spec['paths']['/api/duplicate-id/dup-one']['get']['operationId'];
$second = $spec['paths']['/api/duplicate-id/dup_one']['get']['operationId'];
expect($first)->not->toBe($second);
expect($second)->toEndWith('_2');
});
it('generates summary from route name', function () {
$builder = new OpenApiBuilder;
$spec = $builder->build();
@ -175,6 +196,116 @@ describe('OpenApiBuilder Controller Scanning', function () {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Application Endpoint Parameter Docs
// ─────────────────────────────────────────────────────────────────────────────
describe('Application Endpoint Parameter Docs', function () {
it('documents the SEO report url query parameter', function () {
$builder = new OpenApiBuilder;
$spec = $builder->build();
$operation = $spec['paths']['/api/seo/report']['get'];
$urlParam = collect($operation['parameters'] ?? [])->firstWhere('name', 'url');
expect($urlParam)->not->toBeNull();
expect($urlParam['in'])->toBe('query');
expect($urlParam['required'])->toBeTrue();
expect($urlParam['schema']['format'])->toBe('uri');
});
it('documents the pixel endpoint as binary for GET and no-content for POST', function () {
$builder = new OpenApiBuilder;
$spec = $builder->build();
$getOperation = $spec['paths']['/api/pixel/{pixelKey}']['get'];
$getResponse = $getOperation['responses']['200'] ?? [];
$getContent = $getResponse['content']['image/gif']['schema'] ?? null;
expect($getContent)->toBe([
'type' => 'string',
'format' => 'binary',
]);
$postOperation = $spec['paths']['/api/pixel/{pixelKey}']['post'];
$postResponse = $postOperation['responses']['204'] ?? [];
expect($postResponse['description'] ?? null)->toBe('Accepted without a response body');
expect($postResponse)->not->toHaveKey('content');
});
it('documents MCP list query parameters', function () {
$builder = new OpenApiBuilder;
$spec = $builder->build();
$toolsOperation = $spec['paths']['/api/mcp/servers/{id}/tools']['get'];
$includeVersions = collect($toolsOperation['parameters'] ?? [])->firstWhere('name', 'include_versions');
expect($includeVersions)->not->toBeNull();
expect($includeVersions['in'])->toBe('query');
expect($includeVersions['schema']['type'])->toBe('boolean');
$resourcesOperation = $spec['paths']['/api/mcp/servers/{id}/resources']['get'];
$includeContent = collect($resourcesOperation['parameters'] ?? [])->firstWhere('name', 'include_content');
expect($includeContent)->not->toBeNull();
expect($includeContent['in'])->toBe('query');
expect($includeContent['schema']['type'])->toBe('boolean');
$serverOperation = $spec['paths']['/api/mcp/servers/{id}']['get'];
$serverIncludeVersions = collect($serverOperation['parameters'] ?? [])->firstWhere('name', 'include_versions');
$serverIncludeContent = collect($serverOperation['parameters'] ?? [])->firstWhere('name', 'include_content');
expect($serverIncludeVersions)->not->toBeNull();
expect($serverIncludeVersions['in'])->toBe('query');
expect($serverIncludeVersions['schema']['type'])->toBe('boolean');
expect($serverIncludeContent)->not->toBeNull();
expect($serverIncludeContent['in'])->toBe('query');
expect($serverIncludeContent['schema']['type'])->toBe('boolean');
});
it('lets explicit path parameter metadata override the generated entry', function () {
RouteFacade::prefix('api')
->middleware('api')
->group(function () {
RouteFacade::get('/test-scan/items/{id}/explicit', [TestExplicitPathParameterController::class, 'show']);
});
$builder = new OpenApiBuilder;
$spec = $builder->build();
$operation = $spec['paths']['/api/test-scan/items/{id}/explicit']['get'];
$parameters = $operation['parameters'] ?? [];
expect($parameters)->toHaveCount(1);
$idParam = collect($parameters)->firstWhere('name', 'id');
expect($idParam)->not->toBeNull();
expect($idParam['in'])->toBe('path');
expect($idParam['required'])->toBeTrue();
expect($idParam['description'])->toBe('Explicit item identifier');
});
it('documents the MCP tool call request body shape', function () {
$builder = new OpenApiBuilder;
$spec = $builder->build();
$operation = $spec['paths']['/api/mcp/tools/call']['post'];
$schema = $operation['requestBody']['content']['application/json']['schema'] ?? null;
expect($schema)->not->toBeNull();
expect($schema['type'])->toBe('object');
expect($schema['properties'])->toHaveKey('server')
->toHaveKey('tool')
->toHaveKey('arguments')
->toHaveKey('version');
expect($schema['required'])->toBe(['server', 'tool']);
expect($schema['additionalProperties'])->toBeTrue();
});
});
// ─────────────────────────────────────────────────────────────────────────────
// ApiParameter Attribute Parsing
// ─────────────────────────────────────────────────────────────────────────────
@ -323,9 +454,10 @@ describe('ApiResponse Attribute Rendering', function () {
201 => 'Resource created',
204 => 'No content',
400 => 'Bad request',
401 => 'Unauthorized',
401 => 'Unauthorised',
403 => 'Forbidden',
404 => 'Not found',
410 => 'Gone',
422 => 'Validation error',
429 => 'Too many requests',
500 => 'Internal server error',
@ -347,6 +479,29 @@ describe('ApiResponse Attribute Rendering', function () {
expect($response->resource)->toBe(TestJsonResource::class);
});
it('infers resource schema fields from JsonResource payloads', function () {
config(['api-docs.routes.include' => ['api/*']]);
config(['api-docs.routes.exclude' => []]);
RouteFacade::prefix('api')
->middleware('api')
->group(function () {
RouteFacade::get('/test-scan/items/{id}', [TestOpenApiController::class, 'show']);
});
$builder = new OpenApiBuilder;
$spec = $builder->build();
$schema = $spec['paths']['/api/test-scan/items/{id}']['get']['responses']['200']['content']['application/json']['schema'] ?? null;
expect($schema)->not->toBeNull();
expect($schema['type'])->toBe('object');
expect($schema['properties'])->toHaveKey('id')
->toHaveKey('name');
expect($schema['properties']['id']['type'])->toBe('integer');
expect($schema['properties']['name']['type'])->toBe('string');
});
it('supports paginated flag', function () {
$response = new ApiResponse(
status: 200,
@ -649,7 +804,7 @@ describe('Extension System', function () {
// ─────────────────────────────────────────────────────────────────────────────
describe('Error Response Documentation', function () {
it('documents 401 Unauthorized response', function () {
it('documents 401 Unauthorised response', function () {
$extension = new ApiKeyAuthExtension;
$spec = [
'info' => [],
@ -711,6 +866,69 @@ describe('Error Response Documentation', function () {
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Sunset Documentation
// ─────────────────────────────────────────────────────────────────────────────
describe('Sunset Documentation', function () {
it('registers deprecation headers in components', function () {
$extension = new SunsetExtension;
$spec = ['components' => []];
$result = $extension->extend($spec, []);
expect($result['components']['headers'])->toHaveKey('deprecation')
->toHaveKey('sunset')
->toHaveKey('link')
->toHaveKey('xapiwarn');
});
it('marks sunset routes as deprecated and documents their response headers', function () {
RouteFacade::prefix('api')
->middleware(['api', 'api.sunset:2025-06-01,/api/v2/legacy'])
->group(function () {
RouteFacade::get('/sunset-test/legacy', fn () => response()->json(['ok' => true]))
->name('sunset-test.legacy');
});
config(['api-docs.routes.include' => ['api/*']]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
$operation = $spec['paths']['/api/sunset-test/legacy']['get'];
expect($operation['deprecated'])->toBeTrue();
expect($operation['responses']['200']['headers'])->toHaveKey('Deprecation')
->toHaveKey('Sunset')
->toHaveKey('X-API-Warn')
->toHaveKey('Link');
});
it('only documents the sunset headers that the middleware will emit', function () {
RouteFacade::prefix('api')
->middleware(['api', 'api.sunset'])
->group(function () {
RouteFacade::get('/sunset-test/plain', fn () => response()->json(['ok' => true]))
->name('sunset-test.plain');
});
config(['api-docs.routes.include' => ['api/*']]);
$builder = new OpenApiBuilder;
$spec = $builder->build();
$operation = $spec['paths']['/api/sunset-test/plain']['get'];
$headers = $operation['responses']['200']['headers'];
expect($operation['deprecated'])->toBeTrue();
expect($headers)->toHaveKey('Deprecation')
->toHaveKey('X-API-Warn');
expect($headers)->not->toHaveKey('Sunset');
expect($headers)->not->toHaveKey('Link');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Authentication Documentation
// ─────────────────────────────────────────────────────────────────────────────
@ -1027,6 +1245,16 @@ class TestPartialHiddenController
public function hiddenMethod(): void {}
}
/**
* Test controller with an explicit path parameter override.
*/
class TestExplicitPathParameterController
{
#[ApiParameter('id', 'path', 'string', 'Explicit item identifier')]
#[ApiResponse(200, TestJsonResource::class, 'Item details')]
public function show(string $id): void {}
}
/**
* Test tagged controller.
*/

View file

@ -57,6 +57,10 @@ class OpenApiDocumentationTest extends TestCase
$response = new ApiResponse(404);
$this->assertEquals('Not found', $response->getDescription());
$goneResponse = new ApiResponse(410);
$this->assertEquals('Gone', $goneResponse->getDescription());
}
public function test_api_security_attribute(): void

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
use Core\Api\Documentation\OpenApiBuilder;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route as RouteFacade;
beforeEach(function () {
Config::set('api.headers.include_version', true);
Config::set('api.headers.include_deprecation', true);
Config::set('api.versioning.deprecated', [1]);
Config::set('api.versioning.sunset', [
1 => '2025-06-01',
]);
Config::set('api-docs.routes.include', ['api/*']);
Config::set('api-docs.routes.exclude', []);
});
it('documents version headers and version-driven deprecation on versioned routes', function () {
RouteFacade::prefix('api/v1')
->middleware(['api', 'api.version:1'])
->group(function () {
RouteFacade::get('/legacy-status', fn () => response()->json(['ok' => true]));
});
$spec = (new OpenApiBuilder)->build();
expect($spec['components']['headers']['xapiversion'] ?? null)->not->toBeNull();
$operation = $spec['paths']['/api/v1/legacy-status']['get'];
expect($operation['deprecated'] ?? null)->toBeTrue();
foreach (['200', '400', '500'] as $status) {
$headers = $operation['responses'][$status]['headers'] ?? [];
expect($headers)->toHaveKey('X-API-Version');
expect($headers)->toHaveKey('Deprecation');
expect($headers)->toHaveKey('Sunset');
expect($headers)->toHaveKey('X-API-Warn');
}
});

View file

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Cache;
beforeEach(function () {
Cache::flush();
});
afterEach(function () {
Cache::flush();
});
it('returns a transparent gif for get requests', function () {
$response = $this->get('/api/pixel/abc12345', [
'Origin' => 'https://example.com',
]);
$response->assertOk();
$response->assertHeader('Content-Type', 'image/gif');
$response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
$response->assertHeader('X-RateLimit-Limit', '10000');
$response->assertHeader('X-RateLimit-Remaining', '9999');
expect($response->getContent())->toBe(base64_decode('R0lGODlhAQABAPAAAP///wAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=='));
});
it('accepts post tracking requests without a body', function () {
$response = $this->post('/api/pixel/abc12345', [], [
'Origin' => 'https://example.com',
]);
$response->assertNoContent();
$response->assertHeader('Access-Control-Allow-Origin', 'https://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->assertNoContent();
$response->assertHeader('Access-Control-Allow-Origin', 'https://example.com');
$response->assertHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
});

View file

@ -0,0 +1,70 @@
<?php
declare(strict_types=1);
use Core\Api\Models\ApiKey;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Illuminate\Support\Facades\Http;
uses(\Illuminate\Foundation\Testing\RefreshDatabase::class);
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create();
$this->workspace->users()->attach($this->user->id, [
'role' => 'owner',
'is_default' => true,
]);
$result = ApiKey::generate(
$this->workspace->id,
$this->user->id,
'SEO Key',
[ApiKey::SCOPE_READ]
);
$this->plainKey = $result['plain_key'];
});
it('returns a technical SEO report for a URL', function () {
Http::fake([
'https://example.com*' => Http::response(<<<'HTML'
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Example Product Landing Page</title>
<meta name="description" content="A concise example description for the landing page.">
<link rel="canonical" href="https://example.com/landing-page">
<meta property="og:title" content="Example Product Landing Page">
<meta property="og:description" content="A concise example description for the landing page.">
<meta property="og:image" content="https://example.com/og-image.jpg">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Example">
<meta name="twitter:card" content="summary_large_image">
</head>
<body>
<h1>Example Product Landing Page</h1>
<h2>Key Features</h2>
</body>
</html>
HTML, 200, [
'Content-Type' => 'text/html; charset=utf-8',
]),
]);
$response = $this->getJson('/api/seo/report?url=https://example.com', [
'Authorization' => "Bearer {$this->plainKey}",
]);
$response->assertOk();
$response->assertJsonPath('data.url', 'https://example.com');
$response->assertJsonPath('data.status_code', 200);
$response->assertJsonPath('data.summary.title', 'Example Product Landing Page');
$response->assertJsonPath('data.summary.description', 'A concise example description for the landing page.');
$response->assertJsonPath('data.headings.h1', 1);
$response->assertJsonPath('data.open_graph.site_name', 'Example');
$response->assertJsonPath('data.score', 100);
$response->assertJsonPath('data.issues', []);
});

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\Route;
it('registers webhook secret management routes', function () {
$socialRotate = Route::getRoutes()->getByName('api.webhooks.social.rotate-secret');
$socialStatus = Route::getRoutes()->getByName('api.webhooks.social.status');
$contentRotate = Route::getRoutes()->getByName('api.webhooks.content.rotate-secret');
$contentStatus = Route::getRoutes()->getByName('api.webhooks.content.status');
expect($socialRotate)->not->toBeNull();
expect($socialRotate->uri())->toBe('api/webhooks/social/{uuid}/secret/rotate');
expect($socialRotate->methods())->toContain('POST');
expect($socialStatus)->not->toBeNull();
expect($socialStatus->uri())->toBe('api/webhooks/social/{uuid}/secret');
expect($socialStatus->methods())->toContain('GET');
expect($contentRotate)->not->toBeNull();
expect($contentRotate->uri())->toBe('api/webhooks/content/{uuid}/secret/rotate');
expect($contentRotate->methods())->toContain('POST');
expect($contentStatus)->not->toBeNull();
expect($contentStatus->uri())->toBe('api/webhooks/content/{uuid}/secret');
expect($contentStatus->methods())->toContain('GET');
});

View file

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
/**
* API Configuration
*
@ -220,6 +222,20 @@ return [
],
],
/*
|--------------------------------------------------------------------------
| SEO Analysis
|--------------------------------------------------------------------------
|
| Settings for the SEO report and analysis endpoint.
|
*/
'seo' => [
// HTTP timeout when fetching a page for analysis
'timeout' => env('API_SEO_TIMEOUT', 10),
],
/*
|--------------------------------------------------------------------------
| Pagination

Some files were not shown because too many files have changed in this diff Show more