Compare commits

...

260 commits
v0.1.1 ... dev

Author SHA1 Message Date
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
Claude
ca9b495884
chore: migrate to dappco.re vanity import path
Change module path from forge.lthn.ai/core/api to dappco.re/go/core/api.
Update all Go imports accordingly:
- forge.lthn.ai/core/api -> dappco.re/go/core/api
- forge.lthn.ai/core/go-io -> dappco.re/go/core/io
- forge.lthn.ai/core/go-log -> dappco.re/go/core/log

forge.lthn.ai/core/cli left as-is (not yet migrated).
Local replace directives added for dappco.re paths until vanity
URL server is configured.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:50:37 +00:00
Snider
39588489e3 chore: sync dependencies for v0.1.6
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:54:45 +00:00
Snider
1a4ef9fc1f chore: sync dependencies for v0.1.5
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:47:24 +00:00
Snider
8f8199cf3c chore: sync dependencies for v0.1.4
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 22:20:35 +00:00
Snider
d510af404d refactor(api): replace fmt.Errorf and os.* with coreerr.E and coreio.Local
Replace all fmt.Errorf/errors.New in production code with coreerr.E() from
go-log. Replace os.MkdirAll with coreio.Local.EnsureDir and os.Remove with
coreio.Local.Delete. Promote go-io and go-log to direct dependencies in go.mod.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 21:49:02 +00:00
Snider
5c1f438a48 chore: sync go.mod dependencies
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-15 15:36:47 +00:00
Snider
ac452b6924 chore: add .core/ and .idea/ to .gitignore 2026-03-15 10:17:49 +00:00
Snider
b2a8a9b389 fix: update stale import paths and dependency versions from extraction
Resolve stale forge.lthn.ai/core/cli v0.1.0 references (tag never existed,
earliest is v0.0.1) and regenerate go.sum via workspace-aware go mod tidy.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-14 13:38:58 +00:00
139 changed files with 19887 additions and 743 deletions

1
.gitignore vendored
View file

@ -12,3 +12,4 @@ vendor/
# PHP
/vendor/
node_modules/
.core/

150
api.go
View file

@ -9,6 +9,7 @@ import (
"errors"
"iter"
"net/http"
"reflect"
"slices"
"time"
@ -24,23 +25,57 @@ 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
wsHandler http.Handler
sseBroker *SSEBroker
swaggerEnabled bool
swaggerTitle string
swaggerDesc string
swaggerVersion string
pprofEnabled bool
expvarEnabled bool
graphql *graphqlConfig
addr string
groups []RouteGroup
middlewares []gin.HandlerFunc
cacheTTL time.Duration
cacheMaxEntries int
cacheMaxBytes int
wsHandler http.Handler
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
}
// 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,
@ -52,27 +87,54 @@ func New(opts ...Option) (*Engine, error) {
}
// 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 +146,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 +169,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,
@ -120,8 +199,18 @@ func (e *Engine) Serve(ctx context.Context) error {
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 +228,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 {
@ -153,18 +242,21 @@ func (e *Engine) build() *gin.Engine {
// 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))
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 +266,7 @@ 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 pprof profiling endpoints if enabled.
@ -189,3 +281,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

@ -13,7 +13,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Test helpers ────────────────────────────────────────────────────────
@ -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

@ -14,6 +14,10 @@ import (
)
// 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 +30,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 +67,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 +81,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 +166,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 +180,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 {
@ -193,9 +233,57 @@ func authentikMiddleware(cfg AuthentikConfig) gin.HandlerFunc {
}
}
func cloneAuthentikConfig(cfg AuthentikConfig) AuthentikConfig {
out := cfg
out.Issuer = strings.TrimSpace(out.Issuer)
out.ClientID = strings.TrimSpace(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 = strings.TrimSpace(path)
if path == "" {
continue
}
if !strings.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 +298,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

@ -13,7 +13,7 @@ import (
"strings"
"testing"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
"github.com/gin-gonic/gin"
)

View file

@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── AuthentikUser ──────────────────────────────────────────────────────
@ -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"))
})
}

View file

@ -11,7 +11,7 @@ import (
"github.com/casbin/casbin/v2/model"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// casbinModel is a minimal RESTful ACL model for testing authorisation.

831
bridge.go
View file

@ -3,12 +3,30 @@
package api
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"iter"
"net"
"net/http"
"reflect"
"regexp"
"slices"
"strconv"
"unicode/utf8"
"github.com/gin-gonic/gin"
coreerr "dappco.re/go/core/log"
)
// 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 +37,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 +52,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,17 +68,39 @@ 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.
//
// Example:
//
// bridge.RegisterRoutes(rg)
func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
for _, t := range b.tools {
rg.POST("/"+t.descriptor.Name, t.handler)
@ -57,44 +108,31 @@ func (b *ToolBridge) RegisterRoutes(rg *gin.RouterGroup) {
}
// Describe returns OpenAPI route descriptions for all registered tools.
//
// Example:
//
// descs := bridge.Describe()
func (b *ToolBridge) Describe() []RouteDescription {
descs := make([]RouteDescription, 0, len(b.tools))
for _, t := range b.tools {
tags := []string{t.descriptor.Group}
if t.descriptor.Group == "" {
tags = []string{b.name}
}
descs = append(descs, RouteDescription{
Method: "POST",
Path: "/" + t.descriptor.Name,
Summary: t.descriptor.Description,
Description: t.descriptor.Description,
Tags: tags,
RequestBody: t.descriptor.InputSchema,
Response: t.descriptor.OutputSchema,
})
tools := b.snapshotTools()
descs := make([]RouteDescription, 0, len(tools))
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.
//
// Example:
//
// for rd := range bridge.DescribeIter() {
// _ = rd
// }
func (b *ToolBridge) DescribeIter() iter.Seq[RouteDescription] {
tools := b.snapshotTools()
return func(yield func(RouteDescription) bool) {
for _, t := range b.tools {
tags := []string{t.descriptor.Group}
if t.descriptor.Group == "" {
tags = []string{b.name}
}
rd := RouteDescription{
Method: "POST",
Path: "/" + t.descriptor.Name,
Summary: t.descriptor.Description,
Description: t.descriptor.Description,
Tags: tags,
RequestBody: t.descriptor.InputSchema,
Response: t.descriptor.OutputSchema,
}
if !yield(rd) {
for _, tool := range tools {
if !yield(describeTool(tool.descriptor, b.name)) {
return
}
}
@ -102,21 +140,746 @@ 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,
}
}
func wrapToolHandler(handler gin.HandlerFunc, validator *toolInputValidator) gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(c.Request.Body)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, FailWithDetails(
"invalid_request_body",
"Unable to read request body",
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 coreerr.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 coreerr.E("ToolBridge.Validate", "invalid JSON", err)
}
var extra any
if err := dec.Decode(&extra); err != io.EOF {
return coreerr.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
}
var envelope map[string]any
if err := json.Unmarshal(body, &envelope); err != nil {
return coreerr.E("ToolBridge.ValidateResponse", "invalid JSON response", err)
}
success, _ := envelope["success"].(bool)
if !success {
return coreerr.E("ToolBridge.ValidateResponse", "response is missing a successful envelope", nil)
}
data, ok := envelope["data"]
if !ok {
return coreerr.E("ToolBridge.ValidateResponse", "response is missing data", nil)
}
encoded, err := json.Marshal(data)
if err != nil {
return coreerr.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 coreerr.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 coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must match exactly one schema in oneOf", displayPath(path)), nil)
}
return coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must be at least %d characters long", displayPath(path), minLength), nil)
}
if maxLength, ok := schemaInt(schema["maxLength"]); ok && length > maxLength {
return coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s has an invalid pattern %q", displayPath(path), pattern), err)
}
if !re.MatchString(value) {
return coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d items", displayPath(path), minItems), nil)
}
if maxItems, ok := schemaInt(schema["maxItems"]); ok && len(value) > maxItems {
return coreerr.E("ToolBridge.ValidateSchema", fmt.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 coreerr.E("ToolBridge.ValidateSchema", fmt.Sprintf("%s must contain at least %d properties", displayPath(path), minProps), nil)
}
if maxProps, ok := schemaInt(schema["maxProperties"]); ok && len(value) > maxProps {
return coreerr.E("ToolBridge.ValidateSchema", fmt.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, coreerr.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 {
http.Error(w.ResponseWriter, "internal server error", http.StatusInternalServerError)
return
}
w.ResponseWriter.Header().Set("Content-Type", "application/json")
w.ResponseWriter.WriteHeader(status)
_, _ = w.ResponseWriter.Write(data)
}
func typeError(path, want string, value any) error {
return coreerr.E("ToolBridge.ValidateSchema", fmt.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 fmt.Sprintf("%T", value)
}
}

View file

@ -3,6 +3,7 @@
package api_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
@ -10,7 +11,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── ToolBridge ─────────────────────────────────────────────────────────
@ -153,6 +154,522 @@ func TestToolBridge_Good_Describe(t *testing.T) {
}
}
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()
if len(descs) != 1 {
t.Fatalf("expected 1 description, got %d", len(descs))
}
if len(descs[0].Tags) != 1 || descs[0].Tags[0] != "tools" {
t.Fatalf("expected blank group to fall back to bridge tag, got %v", descs[0].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)
}
}
func TestToolBridge_Good_ToolsAccessor(t *testing.T) {
bridge := api.NewToolBridge("/tools")
bridge.Add(api.ToolDescriptor{Name: "alpha", Description: "Tool A", Group: "a"}, func(c *gin.Context) {})

View file

@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── WithBrotli ────────────────────────────────────────────────────────

149
cache.go
View file

@ -4,8 +4,10 @@ package api
import (
"bytes"
"container/list"
"maps"
"net/http"
"strconv"
"sync"
"time"
@ -17,34 +19,57 @@ type cacheEntry struct {
status int
headers http.Header
body []byte
size int
expires time.Time
}
// cacheStore is a simple thread-safe in-memory cache keyed by request URL.
type cacheStore struct {
mu sync.RWMutex
entries map[string]*cacheEntry
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 {
func newCacheStore(maxEntries, maxBytes int) *cacheStore {
return &cacheStore{
entries: make(map[string]*cacheEntry),
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 {
if elem, exists := s.index[key]; exists {
s.order.MoveToFront(elem)
}
}
s.mu.Unlock()
if !ok {
return nil
}
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
@ -55,10 +80,81 @@ func (s *cacheStore) get(key string) *cacheEntry {
// set stores a cache entry with the given TTL.
func (s *cacheStore) set(key string, entry *cacheEntry) {
s.mu.Lock()
if entry.size <= 0 {
entry.size = cacheEntrySize(entry.headers, entry.body)
}
if elem, ok := s.index[key]; ok {
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.
type cacheWriter struct {
gin.ResponseWriter
@ -89,14 +185,31 @@ func cacheMiddleware(store *cacheStore, ttl time.Duration) gin.HandlerFunc {
// Serve from cache if a valid entry exists.
if entry := store.get(key); entry != nil {
body := entry.body
if meta := GetRequestMeta(c); meta != nil {
body = refreshCachedResponseMeta(entry.body, meta)
}
for k, vals := range entry.headers {
if http.CanonicalHeaderKey(k) == "X-Request-ID" {
continue
}
if http.CanonicalHeaderKey(k) == "Content-Length" {
continue
}
for _, v := range vals {
c.Writer.Header().Set(k, v)
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 +232,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

@ -14,7 +14,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// cacheCounterGroup registers routes that increment a counter on each call,
@ -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())
}
}

1038
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

@ -8,7 +8,12 @@ func init() {
cli.RegisterCommands(AddAPICommands)
}
// AddAPICommands registers the 'api' command group.
// AddAPICommands registers the `api` command group.
//
// Example:
//
// root := &cli.Command{Use: "root"}
// api.AddAPICommands(root)
func AddAPICommands(root *cli.Command) {
apiCmd := cli.NewGroup("api", "API specification and SDK generation", "")
root.AddCommand(apiCmd)

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

@ -0,0 +1,67 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import "strings"
// 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 := strings.Split(raw, ",")
values := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
value := strings.TrimSpace(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 = strings.TrimSpace(path)
if path == "" {
continue
}
if !strings.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,14 +3,23 @@
package api
import (
"context"
"fmt"
"iter"
"os"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
goapi "forge.lthn.ai/core/api"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
goapi "dappco.re/go/core/api"
)
const (
defaultSDKTitle = "Lethean Core API"
defaultSDKDescription = "Lethean Core API"
defaultSDKVersion = "1.0.0"
)
func addSDKCommand(parent *cli.Command) {
@ -19,40 +28,20 @@ func addSDKCommand(parent *cli.Command) {
output string
specFile string
packageName string
cfg specBuilderConfig
)
cfg.title = defaultSDKTitle
cfg.description = defaultSDKDescription
cfg.version = defaultSDKVersion
cmd := cli.NewCommand("sdk", "Generate client SDKs from OpenAPI spec", "", func(cmd *cli.Command, args []string) error {
if lang == "" {
return fmt.Errorf("--lang is required. Supported: %s", strings.Join(goapi.SupportedLanguages(), ", "))
}
// If no spec file provided, generate one to a temp file.
if specFile == "" {
builder := &goapi.SpecBuilder{
Title: "Lethean Core API",
Description: "Lethean Core API",
Version: "1.0.0",
}
bridge := goapi.NewToolBridge("/tools")
groups := []goapi.RouteGroup{bridge}
tmpFile, err := os.CreateTemp("", "openapi-*.json")
if err != nil {
return fmt.Errorf("create temp spec file: %w", err)
}
defer os.Remove(tmpFile.Name())
if err := goapi.ExportSpec(tmpFile, "json", builder, groups); err != nil {
tmpFile.Close()
return fmt.Errorf("generate spec: %w", err)
}
tmpFile.Close()
specFile = tmpFile.Name()
languages := splitUniqueCSV(lang)
if len(languages) == 0 {
return coreerr.E("sdk.Generate", "--lang is required and must include at least one non-empty language. Supported: "+strings.Join(goapi.SupportedLanguages(), ", "), nil)
}
gen := &goapi.SDKGenerator{
SpecPath: specFile,
OutputDir: output,
PackageName: packageName,
}
@ -61,18 +50,42 @@ func addSDKCommand(parent *cli.Command) {
fmt.Fprintln(os.Stderr, "openapi-generator-cli not found. Install with:")
fmt.Fprintln(os.Stderr, " brew install openapi-generator (macOS)")
fmt.Fprintln(os.Stderr, " npm install @openapitools/openapi-generator-cli -g")
return fmt.Errorf("openapi-generator-cli not installed")
return coreerr.E("sdk.Generate", "openapi-generator-cli not installed", nil)
}
// Generate for each language.
for l := range strings.SplitSeq(lang, ",") {
l = strings.TrimSpace(l)
if l == "" {
continue
// If no spec file was provided, generate one only after confirming the
// generator is available.
if specFile == "" {
builder, err := sdkSpecBuilder(cfg)
if err != nil {
return err
}
groups := sdkSpecGroupsIter()
tmpFile, err := os.CreateTemp("", "openapi-*.json")
if err != nil {
return coreerr.E("sdk.Generate", "create temp spec file", err)
}
tmpPath := tmpFile.Name()
if err := tmpFile.Close(); err != nil {
_ = coreio.Local.Delete(tmpPath)
return coreerr.E("sdk.Generate", "close temp spec file", err)
}
defer coreio.Local.Delete(tmpPath)
if err := goapi.ExportSpecToFileIter(tmpPath, "json", builder, groups); err != nil {
return coreerr.E("sdk.Generate", "generate spec", err)
}
specFile = tmpPath
}
gen.SpecPath = specFile
// Generate for each language.
for _, l := range languages {
fmt.Fprintf(os.Stderr, "Generating %s SDK...\n", l)
if err := gen.Generate(context.Background(), l); err != nil {
return fmt.Errorf("generate %s: %w", l, err)
if err := gen.Generate(cli.Context(), l); err != nil {
return coreerr.E("sdk.Generate", "generate "+l, err)
}
fmt.Fprintf(os.Stderr, " Done: %s/%s/\n", output, l)
}
@ -82,8 +95,50 @@ func addSDKCommand(parent *cli.Command) {
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, &specFile, "spec", "s", "", "Path to an existing OpenAPI spec (generates a temporary spec from registered route groups and the built-in tool bridge if not provided)")
cli.StringFlag(cmd, &packageName, "package", "p", "lethean", "Package name for generated SDK")
registerSpecBuilderFlags(cmd, &cfg)
parent.AddCommand(cmd)
}
func sdkSpecBuilder(cfg specBuilderConfig) (*goapi.SpecBuilder, error) {
return newSpecBuilder(specBuilderConfig{
title: cfg.title,
summary: cfg.summary,
description: cfg.description,
version: cfg.version,
swaggerPath: cfg.swaggerPath,
graphqlPath: cfg.graphqlPath,
graphqlPlayground: cfg.graphqlPlayground,
graphqlPlaygroundPath: cfg.graphqlPlaygroundPath,
ssePath: cfg.ssePath,
wsPath: cfg.wsPath,
pprofEnabled: cfg.pprofEnabled,
expvarEnabled: cfg.expvarEnabled,
cacheEnabled: cfg.cacheEnabled,
cacheTTL: cfg.cacheTTL,
cacheMaxEntries: cfg.cacheMaxEntries,
cacheMaxBytes: cfg.cacheMaxBytes,
i18nDefaultLocale: cfg.i18nDefaultLocale,
i18nSupportedLocales: cfg.i18nSupportedLocales,
authentikIssuer: cfg.authentikIssuer,
authentikClientID: cfg.authentikClientID,
authentikTrustedProxy: cfg.authentikTrustedProxy,
authentikPublicPaths: cfg.authentikPublicPaths,
termsURL: cfg.termsURL,
contactName: cfg.contactName,
contactURL: cfg.contactURL,
contactEmail: cfg.contactEmail,
licenseName: cfg.licenseName,
licenseURL: cfg.licenseURL,
externalDocsDescription: cfg.externalDocsDescription,
externalDocsURL: cfg.externalDocsURL,
servers: cfg.servers,
securitySchemes: cfg.securitySchemes,
})
}
func sdkSpecGroupsIter() iter.Seq[goapi.RouteGroup] {
return specGroupsIter(goapi.NewToolBridge("/tools"))
}

View file

@ -3,52 +3,103 @@
package api
import (
"encoding/json"
"fmt"
"os"
"strings"
"forge.lthn.ai/core/cli/pkg/cli"
goapi "forge.lthn.ai/core/api"
goapi "dappco.re/go/core/api"
)
func addSpecCommand(parent *cli.Command) {
var (
output string
format string
title string
version string
output string
format string
cfg specBuilderConfig
)
cfg.title = "Lethean Core API"
cfg.description = "Lethean Core API"
cfg.version = "1.0.0"
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,
// Build spec from all route groups registered for CLI generation.
builder, err := newSpecBuilder(cfg)
if err != nil {
return err
}
// 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 {
if err := goapi.ExportSpecToFileIter(output, format, builder, groups); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "Spec written to %s\n", output)
return nil
}
return goapi.ExportSpec(os.Stdout, format, builder, groups)
return goapi.ExportSpecIter(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")
registerSpecBuilderFlags(cmd, &cfg)
parent.AddCommand(cmd)
}
func parseServers(raw string) []string {
return splitUniqueCSV(raw)
}
func parseSecuritySchemes(raw string) (map[string]any, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var schemes map[string]any
if err := json.Unmarshal([]byte(raw), &schemes); err != nil {
return nil, cli.Err("invalid security schemes JSON: %w", err)
}
return schemes, nil
}
func registerSpecBuilderFlags(cmd *cli.Command, cfg *specBuilderConfig) {
cli.StringFlag(cmd, &cfg.title, "title", "t", cfg.title, "API title in spec")
cli.StringFlag(cmd, &cfg.summary, "summary", "", cfg.summary, "OpenAPI info summary in spec")
cli.StringFlag(cmd, &cfg.description, "description", "d", cfg.description, "API description in spec")
cli.StringFlag(cmd, &cfg.version, "version", "V", cfg.version, "API version in spec")
cli.StringFlag(cmd, &cfg.swaggerPath, "swagger-path", "", "", "Swagger UI path in generated spec")
cli.StringFlag(cmd, &cfg.graphqlPath, "graphql-path", "", "", "GraphQL endpoint path in generated spec")
cli.BoolFlag(cmd, &cfg.graphqlPlayground, "graphql-playground", "", false, "Include the GraphQL playground endpoint in generated spec")
cli.StringFlag(cmd, &cfg.graphqlPlaygroundPath, "graphql-playground-path", "", "", "GraphQL playground path in generated spec")
cli.StringFlag(cmd, &cfg.ssePath, "sse-path", "", "", "SSE endpoint path in generated spec")
cli.StringFlag(cmd, &cfg.wsPath, "ws-path", "", "", "WebSocket endpoint path in generated spec")
cli.BoolFlag(cmd, &cfg.pprofEnabled, "pprof", "", false, "Include pprof endpoints in generated spec")
cli.BoolFlag(cmd, &cfg.expvarEnabled, "expvar", "", false, "Include expvar endpoint in generated spec")
cli.BoolFlag(cmd, &cfg.cacheEnabled, "cache", "", false, "Include cache metadata in generated spec")
cli.StringFlag(cmd, &cfg.cacheTTL, "cache-ttl", "", "", "Cache TTL in generated spec")
cli.IntFlag(cmd, &cfg.cacheMaxEntries, "cache-max-entries", "", 0, "Cache max entries in generated spec")
cli.IntFlag(cmd, &cfg.cacheMaxBytes, "cache-max-bytes", "", 0, "Cache max bytes in generated spec")
cli.StringFlag(cmd, &cfg.i18nDefaultLocale, "i18n-default-locale", "", "", "Default locale in generated spec")
cli.StringFlag(cmd, &cfg.i18nSupportedLocales, "i18n-supported-locales", "", "", "Comma-separated supported locales in generated spec")
cli.StringFlag(cmd, &cfg.authentikIssuer, "authentik-issuer", "", "", "Authentik issuer URL in generated spec")
cli.StringFlag(cmd, &cfg.authentikClientID, "authentik-client-id", "", "", "Authentik client ID in generated spec")
cli.BoolFlag(cmd, &cfg.authentikTrustedProxy, "authentik-trusted-proxy", "", false, "Mark Authentik proxy headers as trusted in generated spec")
cli.StringFlag(cmd, &cfg.authentikPublicPaths, "authentik-public-paths", "", "", "Comma-separated public paths in generated spec")
cli.StringFlag(cmd, &cfg.termsURL, "terms-of-service", "", "", "OpenAPI terms of service URL in spec")
cli.StringFlag(cmd, &cfg.contactName, "contact-name", "", "", "OpenAPI contact name in spec")
cli.StringFlag(cmd, &cfg.contactURL, "contact-url", "", "", "OpenAPI contact URL in spec")
cli.StringFlag(cmd, &cfg.contactEmail, "contact-email", "", "", "OpenAPI contact email in spec")
cli.StringFlag(cmd, &cfg.licenseName, "license-name", "", "", "OpenAPI licence name in spec")
cli.StringFlag(cmd, &cfg.licenseURL, "license-url", "", "", "OpenAPI licence URL in spec")
cli.StringFlag(cmd, &cfg.externalDocsDescription, "external-docs-description", "", "", "OpenAPI external documentation description in spec")
cli.StringFlag(cmd, &cfg.externalDocsURL, "external-docs-url", "", "", "OpenAPI external documentation URL in spec")
cli.StringFlag(cmd, &cfg.servers, "server", "S", "", "Comma-separated OpenAPI server URL(s)")
cli.StringFlag(cmd, &cfg.securitySchemes, "security-schemes", "", "", "JSON object of custom OpenAPI security schemes")
}

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,124 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"strings"
"time"
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
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 := strings.TrimSpace(cfg.swaggerPath)
graphqlPath := strings.TrimSpace(cfg.graphqlPath)
ssePath := strings.TrimSpace(cfg.ssePath)
wsPath := strings.TrimSpace(cfg.wsPath)
cacheTTL := strings.TrimSpace(cfg.cacheTTL)
cacheTTLValid := parsePositiveDuration(cacheTTL)
builder := &goapi.SpecBuilder{
Title: strings.TrimSpace(cfg.title),
Summary: strings.TrimSpace(cfg.summary),
Description: strings.TrimSpace(cfg.description),
Version: strings.TrimSpace(cfg.version),
SwaggerEnabled: swaggerPath != "",
SwaggerPath: swaggerPath,
GraphQLEnabled: graphqlPath != "" || cfg.graphqlPlayground,
GraphQLPath: graphqlPath,
GraphQLPlayground: cfg.graphqlPlayground,
GraphQLPlaygroundPath: strings.TrimSpace(cfg.graphqlPlaygroundPath),
SSEEnabled: ssePath != "",
SSEPath: ssePath,
WSEnabled: wsPath != "",
WSPath: wsPath,
PprofEnabled: cfg.pprofEnabled,
ExpvarEnabled: cfg.expvarEnabled,
CacheEnabled: cfg.cacheEnabled || cacheTTLValid,
CacheTTL: cacheTTL,
CacheMaxEntries: cfg.cacheMaxEntries,
CacheMaxBytes: cfg.cacheMaxBytes,
I18nDefaultLocale: strings.TrimSpace(cfg.i18nDefaultLocale),
TermsOfService: strings.TrimSpace(cfg.termsURL),
ContactName: strings.TrimSpace(cfg.contactName),
ContactURL: strings.TrimSpace(cfg.contactURL),
ContactEmail: strings.TrimSpace(cfg.contactEmail),
Servers: parseServers(cfg.servers),
LicenseName: strings.TrimSpace(cfg.licenseName),
LicenseURL: strings.TrimSpace(cfg.licenseURL),
ExternalDocsDescription: strings.TrimSpace(cfg.externalDocsDescription),
ExternalDocsURL: strings.TrimSpace(cfg.externalDocsURL),
AuthentikIssuer: strings.TrimSpace(cfg.authentikIssuer),
AuthentikClientID: strings.TrimSpace(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 = strings.TrimSpace(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,10 @@ import (
"os/exec"
"path/filepath"
"slices"
"strings"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
)
// Supported SDK target languages.
@ -29,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
@ -42,19 +50,47 @@ 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 fmt.Errorf("unsupported language %q: supported languages are %v", language, SupportedLanguages())
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 fmt.Errorf("spec file not found: %s", g.SpecPath)
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)
if err := os.MkdirAll(outputDir, 0o755); err != nil {
return fmt.Errorf("create output directory: %w", err)
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)
@ -63,7 +99,7 @@ func (g *SDKGenerator) Generate(ctx context.Context, language string) error {
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("openapi-generator-cli failed for %s: %w", language, err)
return coreerr.E("SDKGenerator.Generate", "openapi-generator-cli failed for "+language, err)
}
return nil
@ -84,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
@ -91,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

@ -10,7 +10,7 @@ import (
"strings"
"testing"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── SDKGenerator tests ─────────────────────────────────────────────────────
@ -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 | Compatibility wrapper for `WithCacheLimits(ttl, 0, 0)`; `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,19 @@ 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)` remains available as a compatibility wrapper for callers that do not need to spell out the bounds.
- Passing non-positive values to `WithCacheLimits` leaves that limit unbounded.
The implementation uses a `cacheWriter` that wraps `gin.ResponseWriter` to intercept and
capture the response body and status code for storage.
@ -573,7 +591,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 +605,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()` |

View file

@ -6,51 +6,109 @@ import (
"encoding/json"
"fmt"
"io"
"iter"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
coreio "dappco.re/go/core/io"
coreerr "dappco.re/go/core/log"
)
// 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 fmt.Errorf("build spec: %w", err)
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 fmt.Errorf("unmarshal spec: %w", err)
return coreerr.E(op, "unmarshal spec", err)
}
enc := yaml.NewEncoder(w)
enc.SetIndent(2)
if err := enc.Encode(obj); err != nil {
return fmt.Errorf("encode yaml: %w", err)
return coreerr.E(op, "encode yaml", err)
}
return enc.Close()
default:
return fmt.Errorf("unsupported format %q: use \"json\" or \"yaml\"", format)
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 := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory: %w", err)
return exportSpecToFile(path, "ExportSpecToFile", func(w io.Writer) error {
return ExportSpec(w, format, builder, groups)
})
}
// 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) {
if err := coreio.Local.EnsureDir(filepath.Dir(path)); err != nil {
return coreerr.E(op, "create directory", err)
}
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("create file: %w", err)
return coreerr.E(op, "create file", err)
}
defer f.Close()
return ExportSpec(f, format, builder, groups)
defer func() {
if closeErr := f.Close(); closeErr != nil && err == nil {
err = coreerr.E(op, "close file", closeErr)
}
}()
if err = write(f); err != nil {
return err
}
return nil
}

View file

@ -5,6 +5,7 @@ package api_test
import (
"bytes"
"encoding/json"
"iter"
"net/http"
"os"
"path/filepath"
@ -14,7 +15,7 @@ import (
"github.com/gin-gonic/gin"
"gopkg.in/yaml.v3"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── ExportSpec tests ─────────────────────────────────────────────────────
@ -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")
}
}

View file

@ -11,7 +11,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Expvar runtime metrics endpoint ─────────────────────────────────

3
go-io/go.mod Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core/io
go 1.26.0

29
go-io/local.go Normal file
View file

@ -0,0 +1,29 @@
// SPDX-License-Identifier: EUPL-1.2
package io
import "os"
// LocalFS provides simple local filesystem helpers used by the API module.
var Local localFS
type localFS struct{}
// EnsureDir creates the directory path if it does not already exist.
func (localFS) EnsureDir(path string) error {
if path == "" || path == "." {
return nil
}
return os.MkdirAll(path, 0o755)
}
// Delete removes the named file, ignoring missing files.
func (localFS) Delete(path string) error {
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

14
go-log/error.go Normal file
View file

@ -0,0 +1,14 @@
// SPDX-License-Identifier: EUPL-1.2
package log
import "fmt"
// E wraps an operation label and message in a conventional error.
// If err is non-nil, it is wrapped with %w.
func E(op, message string, err error) error {
if err != nil {
return fmt.Errorf("%s: %s: %w", op, message, err)
}
return fmt.Errorf("%s: %s", op, message)
}

3
go-log/go.mod Normal file
View file

@ -0,0 +1,3 @@
module dappco.re/go/core/log
go 1.26.0

91
go.mod
View file

@ -1,10 +1,12 @@
module forge.lthn.ai/core/api
module dappco.re/go/core/api
go 1.26.0
require (
forge.lthn.ai/core/cli v0.1.0
github.com/99designs/gqlgen v0.17.87
dappco.re/go/core/io v0.1.7
dappco.re/go/core/log v0.0.4
dappco.re/go/core/cli v0.3.7
github.com/99designs/gqlgen v0.17.88
github.com/andybalholm/brotli v1.2.0
github.com/casbin/casbin/v2 v2.135.0
github.com/coreos/go-oidc/v3 v3.17.0
@ -20,64 +22,66 @@ require (
github.com/gin-contrib/slog v1.2.0
github.com/gin-contrib/static v1.1.5
github.com/gin-contrib/timeout v1.1.0
github.com/gin-gonic/gin v1.11.0
github.com/gin-gonic/gin v1.12.0
github.com/gorilla/websocket v1.5.3
github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
github.com/swaggo/swag v1.16.6
github.com/vektah/gqlparser/v2 v2.5.32
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
golang.org/x/text v0.34.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
golang.org/x/text v0.35.0
gopkg.in/yaml.v3 v3.0.1
)
require (
forge.lthn.ai/core/go v0.1.0 // indirect
forge.lthn.ai/core/go-crypt v0.1.0 // indirect
dappco.re/go/core v0.3.2 // indirect
dappco.re/go/core/i18n v0.1.7 // indirect
dappco.re/go/core/inference v0.1.7 // indirect
dappco.re/go/core/log v0.0.4 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/ProtonMail/go-crypto v1.3.0 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/casbin/govaluate v1.10.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.2 // indirect
github.com/go-openapi/spec v0.22.0 // indirect
github.com/go-openapi/swag/conv v0.25.1 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.1 // indirect
github.com/go-openapi/swag/loading v0.25.1 // indirect
github.com/go-openapi/swag/stringutils v0.25.1 // indirect
github.com/go-openapi/swag/typeutils v0.25.1 // indirect
github.com/go-openapi/swag/yamlutils v0.25.1 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/jsonreference v0.21.5 // indirect
github.com/go-openapi/spec v0.22.4 // indirect
github.com/go-openapi/swag/conv v0.25.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
github.com/go-openapi/swag/loading v0.25.5 // indirect
github.com/go-openapi/swag/stringutils v0.25.5 // indirect
github.com/go-openapi/swag/typeutils v0.25.5 // indirect
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/context v1.1.2 // indirect
@ -91,34 +95,43 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/tools v0.42.0 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
replace (
dappco.re/go/core => ../go
dappco.re/go/core/i18n => ../go-i18n
dappco.re/go/core/io => ./go-io
dappco.re/go/core/log => ./go-log
)

180
go.sum
View file

@ -1,15 +1,17 @@
forge.lthn.ai/core/cli v0.1.0 h1:2XRiEMVzUElnQlZnHYDyfKIKQVPcCzGuYHlnz55GjsM=
forge.lthn.ai/core/cli v0.1.0/go.mod h1:mZ7dzccfzo0BP2dE7Mwuw9dXuIowiEd1G5ZGMoLuxVc=
forge.lthn.ai/core/go v0.1.0 h1:Ow/1NTajrrNPO0zgkskEyEGdx4SKpiNqTaqM0txNOYI=
forge.lthn.ai/core/go v0.1.0/go.mod h1:lwi0tccAlg5j3k6CfoNJEueBc5l9mUeSBX/x6uY8ZbQ=
forge.lthn.ai/core/go-crypt v0.1.0 h1:92gwdQi7iAwktpvZhL/8Cu+QS6xKCtGP4FJfyInPGnw=
forge.lthn.ai/core/go-crypt v0.1.0/go.mod h1:zVAgx6ZiGtC+dbX4R/VKvEPqsEqjyuLl4gQZH9SXBUw=
github.com/99designs/gqlgen v0.17.87 h1:pSnCIMhBQezAE8bc1GNmfdLXFmnWtWl1GRDFEE/nHP8=
github.com/99designs/gqlgen v0.17.87/go.mod h1:fK05f1RqSNfQpd4CfW5qk/810Tqi4/56Wf6Nem0khAg=
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=
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=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
@ -25,10 +27,10 @@ github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdK
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
@ -42,8 +44,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
@ -56,8 +58,6 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
@ -99,8 +99,8 @@ github.com/gin-contrib/static v1.1.5 h1:bAPqT4KTZN+4uDY1b90eSrD1t8iNzod7Jj8njwmn
github.com/gin-contrib/static v1.1.5/go.mod h1:8JSEXwZHcQ0uCrLPcsvnAJ4g+ODxeupP8Zetl9fd8wM=
github.com/gin-contrib/timeout v1.1.0 h1:WAmWseo5gfBUbMrMJu5hJxDclehfSJUmK2wGwCC/EFw=
github.com/gin-contrib/timeout v1.1.0/go.mod h1:NpRo4gd1Ad8ZQ4T6bQLVFDqiplCmPRs2nvfckxS2Fw4=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@ -108,31 +108,33 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
github.com/go-openapi/spec v0.22.0 h1:xT/EsX4frL3U09QviRIZXvkh80yibxQmtoEvyqug0Tw=
github.com/go-openapi/spec v0.22.0/go.mod h1:K0FhKxkez8YNS94XzF8YKEMULbFrRw4m15i2YUht4L0=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ=
github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ=
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
github.com/go-openapi/swag/conv v0.25.1 h1:+9o8YUg6QuqqBM5X6rYL/p1dpWeZRhoIt9x7CCP+he0=
github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.1 h1:AihLHaD0brrkJoMqEZOBNzTLnk81Kg9cWr+SPtxtgl8=
github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1 h1:DSQGcdB6G0N9c/KhtpYc71PzzGEIc/fZ1no35x4/XBY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.1/go.mod h1:kjmweouyPwRUEYMSrbAidoLMGeJ5p6zdHi9BgZiqmsg=
github.com/go-openapi/swag/loading v0.25.1 h1:6OruqzjWoJyanZOim58iG2vj934TysYVptyaoXS24kw=
github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc=
github.com/go-openapi/swag/stringutils v0.25.1 h1:Xasqgjvk30eUe8VKdmyzKtjkVjeiXx1Iz0zDfMNpPbw=
github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg=
github.com/go-openapi/swag/typeutils v0.25.1 h1:rD/9HsEQieewNt6/k+JBwkxuAHktFtH3I3ysiFZqukA=
github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8=
github.com/go-openapi/swag/yamlutils v0.25.1 h1:mry5ez8joJwzvMbaTGLhw8pXUnhDK91oSJLDPF1bmGk=
github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@ -143,8 +145,8 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc=
@ -187,8 +189,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@ -216,8 +218,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@ -252,54 +254,56 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0 h1:E7DmskpIO7ZR6QI6zKSEKIDNUYoKw9oHXP23gzbCdU0=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.67.0/go.mod h1:WB2cS9y+AwqqKhoo9gw6/ZxlSjFBUQGZ8BQOaD3FVXM=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0 h1:B2Pew5ufEtgkjLF+tSkXjgYZXQr9m7aCm1wLKB0URbU=
go.opentelemetry.io/contrib/propagators/b3 v1.42.0/go.mod h1:iPgUcSEF5DORW6+yNbdw/YevUy+QqJ508ncjhrRSCjc=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.25.0 h1:qnk6Ksugpi5Bz32947rkUgDt9/s5qvqDPl/gBKdMJLE=
golang.org/x/arch v0.25.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@ -308,25 +312,25 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=

View file

@ -4,6 +4,7 @@ package api
import (
"net/http"
"strings"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
@ -21,10 +22,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 +85,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 +111,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 = strings.TrimSpace(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

@ -15,7 +15,7 @@ import (
"github.com/vektah/gqlparser/v2"
"github.com/vektah/gqlparser/v2/ast"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// newTestSchema creates a minimal ExecutableSchema that responds to { name }
@ -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)

102
group.go
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,89 @@ 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
}
// RouteDescription describes a single endpoint for OpenAPI generation.
type RouteDescription struct {
Method string // HTTP method: GET, POST, PUT, DELETE, PATCH
Path string // Path relative to BasePath, e.g. "/generate"
Summary string // Short summary
Description string // Long description
Tags []string // OpenAPI tags for grouping
RequestBody map[string]any // JSON Schema for request body (nil for GET)
Response map[string]any // JSON Schema for success response data
// 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
// 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.
}

View file

@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Stub implementations ────────────────────────────────────────────────

View file

@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── WithGzip ──────────────────────────────────────────────────────────

View file

@ -17,7 +17,7 @@ import (
"github.com/gin-contrib/httpsign/crypto"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
const testSecretKey = "test-secret-key-for-hmac-sha256"

139
i18n.go
View file

@ -3,6 +3,9 @@
package api
import (
"slices"
"strings"
"github.com/gin-gonic/gin"
"golang.org/x/text/language"
)
@ -13,7 +16,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 +47,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 +95,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 +115,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 +135,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 +151,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 +163,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 = strings.TrimSpace(strings.ReplaceAll(locale, "_", "-"))
if locale == "" {
return nil
}
parts := strings.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, strings.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,11 +6,12 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"testing"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────
@ -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

@ -11,7 +11,7 @@ import (
"github.com/gin-contrib/location/v2"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────

View file

@ -5,20 +5,44 @@ package api
import (
"crypto/rand"
"encoding/hex"
"fmt"
"net/http"
"runtime/debug"
"strings"
"time"
"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) {
fmt.Fprintf(gin.DefaultErrorWriter, "[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
}
@ -40,11 +64,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 strings.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 +102,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,10 +7,11 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────
@ -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,8 +5,9 @@ package api_test
import (
"slices"
"testing"
"time"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
func TestEngine_GroupsIter(t *testing.T) {
@ -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"}
@ -76,6 +300,33 @@ func TestToolBridge_Iterators(t *testing.T) {
}
}
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)
}
if len(descs) != 1 || descs[0].Path != "/first" {
t.Fatalf("expected DescribeIter snapshot to contain the original tool, got %v", descs)
}
}
func TestCodegen_SupportedLanguagesIter(t *testing.T) {
var langs []string
for l := range api.SupportedLanguagesIter() {

2116
openapi.go

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"slices"
"strings"
"time"
"github.com/99designs/gqlgen/graphql"
@ -26,9 +27,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 +45,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 +116,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 +129,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 +140,215 @@ 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 {
// 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.middlewares = append(e.middlewares, authentikMiddleware(cfg))
e.wsPath = normaliseWSPath(path)
}
}
// WithSwagger enables the Swagger UI at /swagger/.
// 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 = strings.TrimSpace(title)
e.swaggerDesc = strings.TrimSpace(description)
e.swaggerVersion = strings.TrimSpace(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 = strings.TrimSpace(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 = strings.TrimSpace(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 = strings.TrimSpace(name); name != "" {
e.swaggerContactName = name
}
if url = strings.TrimSpace(url); url != "" {
e.swaggerContactURL = url
}
if email = strings.TrimSpace(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 = strings.TrimSpace(name); name != "" {
e.swaggerLicenseName = name
}
if url = strings.TrimSpace(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 = strings.TrimSpace(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 = strings.TrimSpace(description); description != "" {
e.swaggerExternalDocsDescription = description
}
if url = strings.TrimSpace(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 +356,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 +374,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 +389,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 +409,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 +426,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 +443,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 +468,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),
@ -232,17 +493,77 @@ func timeoutResponse(c *gin.Context) {
// 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
//
// Pass a non-positive value to either limit to leave that dimension
// unbounded for backward compatibility. 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 := 0
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
}
e.cacheTTL = ttl
e.cacheMaxEntries = maxEntries
e.cacheMaxBytes = maxBytes
store := newCacheStore(maxEntries, 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 +576,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 +599,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 +610,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 +645,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 +662,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{

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.
//
@ -12,7 +12,7 @@
package provider
import (
"forge.lthn.ai/core/api"
"dappco.re/go/core/api"
)
// Provider extends RouteGroup with a provider identity.

View file

@ -1,4 +1,4 @@
// SPDX-Licence-Identifier: EUPL-1.2
// SPDX-License-Identifier: EUPL-1.2
package provider
@ -8,6 +8,7 @@ import (
"net/url"
"strings"
coreapi "dappco.re/go/core/api"
"github.com/gin-gonic/gin"
)
@ -39,14 +40,20 @@ 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,
}
}
proxy := httputil.NewSingleHostReverseProxy(target)
@ -59,11 +66,10 @@ func NewProxy(cfg ProxyConfig) *ProxyProvider {
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 +78,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 = strings.TrimSuffix(strings.TrimSpace(basePath), "/")
if basePath == "" || basePath == "/" {
if path == "" {
return "/"
}
return path
}
if path == basePath {
return "/"
}
prefix := basePath + "/"
if strings.HasPrefix(path, prefix) {
trimmed := strings.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 +128,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

@ -8,8 +8,8 @@ import (
"net/http/httptest"
"testing"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"dappco.re/go/core/api"
"dappco.re/go/core/api/pkg/provider"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -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{
Name: "bad",
BasePath: "/api/v1/bad",
Upstream: "://not-a-url",
})
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
@ -7,7 +7,7 @@ import (
"slices"
"sync"
"forge.lthn.ai/core/api"
"dappco.re/go/core/api"
)
// Registry collects providers and mounts them on an api.Engine.
@ -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

@ -5,8 +5,8 @@ package provider_test
import (
"testing"
"forge.lthn.ai/core/api"
"forge.lthn.ai/core/api/pkg/provider"
"dappco.re/go/core/api"
"dappco.re/go/core/api/pkg/provider"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -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,9 +119,39 @@ 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
reg.Add(&stubProvider{}) // not describable
reg.Add(&describableProvider{}) // describable
d := reg.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)
}

View file

@ -9,7 +9,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Pprof profiling endpoints ─────────────────────────────────────────

216
ratelimit.go Normal file
View file

@ -0,0 +1,216 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
)
const (
rateLimitCleanupInterval = time.Minute
rateLimitStaleAfter = 10 * time.Minute
)
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 {
bucket = &rateLimitBucket{
tokens: float64(s.limit),
last: now,
lastSeen: now,
}
s.buckets[key] = bucket
} 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
}
c.Next()
setRateLimitHeaders(c, decision.limit, decision.remaining, decision.resetAt)
}
}
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 prefers caller-provided credentials for bucket
// isolation, then falls back to the network address.
func clientRateLimitKey(c *gin.Context) string {
if apiKey := strings.TrimSpace(c.GetHeader("X-API-Key")); apiKey != "" {
return "api_key:" + apiKey
}
if bearer := bearerTokenFromHeader(c.GetHeader("Authorization")); bearer != "" {
return "bearer:" + bearer
}
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 = strings.TrimSpace(header)
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return ""
}
return strings.TrimSpace(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
}

277
response_meta.go Normal file
View file

@ -0,0 +1,277 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"bufio"
"bytes"
"encoding/json"
"io"
"mime"
"net"
"net/http"
"strconv"
"strings"
"time"
"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
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)
}
return w.body.Write(data)
}
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)
}
return w.body.WriteString(s)
}
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 {
return w.body.Len()
}
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.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 strings.TrimSpace(contentType) == "" {
return false
}
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = strings.TrimSpace(contentType)
}
mediaType = strings.ToLower(mediaType)
return mediaType == "application/json" ||
strings.HasSuffix(mediaType, "+json") ||
strings.HasSuffix(mediaType, "/json")
}

View file

@ -6,7 +6,7 @@ import (
"encoding/json"
"testing"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── OK ──────────────────────────────────────────────────────────────────

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(),
}
}

View file

@ -10,7 +10,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── WithSecure ──────────────────────────────────────────────────────────

53
servers.go Normal file
View file

@ -0,0 +1,53 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import "strings"
// 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 = strings.TrimSpace(server)
if server == "" {
return ""
}
if server == "/" {
return server
}
server = strings.TrimRight(server, "/")
if server == "" {
return "/"
}
return server
}

View file

@ -11,7 +11,7 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── Helpers ─────────────────────────────────────────────────────────────

View file

@ -11,7 +11,7 @@ import (
"github.com/gin-gonic/gin"
api "forge.lthn.ai/core/api"
api "dappco.re/go/core/api"
)
// ── WithSlog ──────────────────────────────────────────────────────────

283
spec_builder_helper.go Normal file
View file

@ -0,0 +1,283 @@
// SPDX-License-Identifier: EUPL-1.2
package api
import (
"reflect"
"slices"
"strings"
)
// 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.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 strings.TrimSpace(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)
}
}

647
spec_builder_helper_test.go Normal file
View file

@ -0,0 +1,647 @@
// 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)
}
}
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

@ -11,15 +11,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 +45,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 +60,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.',
'feature' => $feature,
'upgrade_url' => route('hub.usage'),
], 403);
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'),
],
status: 403,
);
}
/**
@ -51,10 +76,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 +98,7 @@ trait HasApiResponses
protected function successResponse(string $message, array $data = []): JsonResponse
{
return response()->json(array_merge([
'success' => true,
'message' => $message,
], $data));
}
@ -73,6 +109,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 +118,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.',
'errors' => $errors,
], 422);
return $this->errorResponse(
errorCode: 'validation_failed',
message: 'The given data was invalid.',
meta: [
'errors' => $errors,
],
status: $status,
);
}
/**
@ -97,10 +137,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 +151,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',
'server' => $validated['server'],
'tool' => $validated['tool'],
'requested_version' => $validated['version'] ?? null,
'latest_version' => $error['latest_version'] ?? null,
'migration_notes' => $error['migration_notes'] ?? null,
], $status);
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,
);
}
/** @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',
'validation_errors' => $validationErrors,
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? 'unversioned',
], 422);
return $this->errorResponse(
errorCode: 'VALIDATION_ERROR',
message: 'Validation failed',
meta: [
'validation_errors' => $validationErrors,
'server' => $validated['server'],
'tool' => $validated['tool'],
'version' => $toolVersion?->version ?? 'unversioned',
],
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(),
'uri' => $uri,
], 500);
return $this->errorResponse(
errorCode: 'resource_read_error',
message: $e->getMessage(),
meta: [
'uri' => $uri,
],
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' }}"
hideTryIt="{{ ($config['hide_try_it'] ?? false) ? 'true' : 'false' }}"
></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,26 @@ 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(),
'retry_after' => $this->rateLimitResult->retryAfter,
'limit' => $this->rateLimitResult->limit,
'resets_at' => $this->rateLimitResult->resetsAt->toIso8601String(),
], 429, $this->rateLimitResult->headers());
$response = $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(),
],
status: 429,
)->withHeaders($this->rateLimitResult->headers());
if ($request !== null) {
$origin = $request->headers->get('Origin', '*');
$response->headers->set('Access-Control-Allow-Origin', $origin);
$response->headers->set('Vary', 'Origin');
}
return $response;
}
/**

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}",
'required_scopes' => $scopes,
'key_scopes' => $apiKey->scopes,
], 403);
return $this->forbiddenResponse(
message: "API key missing required scope: {$scope}",
meta: [
'required_scopes' => $scopes,
'key_scopes' => $apiKey->scopes,
],
);
}
}

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}",
'detail' => "{$method} requests require '{$requiredScope}' scope",
'key_scopes' => $apiKey->scopes,
], 403);
return $this->forbiddenResponse(
message: "API key missing required scope: {$requiredScope}",
meta: [
'detail' => "{$method} requests require '{$requiredScope}' scope",
'key_scopes' => $apiKey->scopes,
],
);
}
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,11 @@
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\ApiUsage;
use Core\Api\Models\ApiUsageDaily;
/**
* API Usage Service - tracks and reports API usage metrics.

View file

@ -0,0 +1,372 @@
<?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.
*/
public function analyse(string $url): array
{
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))
->get($url);
} 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;
}
}
}
return $this->extractMetaContent($xpath, 'content-type', 'http-equiv');
}
/**
* 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,
];
}
/**
* 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);
});

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