@classytic/arc 2.11.4 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -12
- package/dist/{BaseController-swXruJ2_.mjs → BaseController-DX_T-bDB.mjs} +388 -423
- package/dist/EventTransport-CT_52aWU.d.mts +34 -0
- package/dist/EventTransport-DLWoUMHy.mjs +103 -0
- package/dist/{ResourceRegistry-DkAeAuTX.mjs → ResourceRegistry-CTERg_2x.mjs} +139 -66
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +1 -1
- package/dist/auth/audit.d.mts +199 -0
- package/dist/auth/audit.mjs +288 -0
- package/dist/auth/index.d.mts +3 -3
- package/dist/auth/index.mjs +117 -191
- package/dist/{betterAuthOpenApi-DwxtK3uG.mjs → betterAuthOpenApi--M_i87dQ.mjs} +1 -1
- package/dist/buildHandler-olo-gt94.mjs +610 -0
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/describe.d.mts +89 -13
- package/dist/cli/commands/describe.mjs +56 -2
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +147 -48
- package/dist/cli/commands/init.d.mts +13 -0
- package/dist/cli/commands/init.mjs +130 -87
- package/dist/cli/commands/introspect.mjs +8 -1
- package/dist/context/index.mjs +1 -1
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +5 -5
- package/dist/core-DECn6zaU.mjs +1399 -0
- package/dist/{createActionRouter-CIKOcNA7.mjs → createActionRouter-CBxLLbn3.mjs} +7 -20
- package/dist/createAggregationRouter-CRIBv4sC.mjs +114 -0
- package/dist/{createApp-C9bRrqlX.mjs → createApp-XX2-N0Yd.mjs} +28 -22
- package/dist/{defineEvent-D1Ky9M1D.mjs → defineEvent-D5h7EvAx.mjs} +1 -1
- package/dist/docs/index.d.mts +24 -11
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-DOFoxoDs.mjs → elevation-DgoeTyfX.mjs} +1 -1
- package/dist/errorHandler-Bk-AGhkU.mjs +174 -0
- package/dist/errorHandler-DFr45ZG4.d.mts +45 -0
- package/dist/errors-j4aJm1Wg.mjs +184 -0
- package/dist/{eventPlugin-Cts2-Tfj.mjs → eventPlugin-CaKTYkYM.mjs} +28 -4
- package/dist/{eventPlugin-DDJoNEPL.d.mts → eventPlugin-qXpqTebY.d.mts} +24 -1
- package/dist/events/index.d.mts +6 -6
- package/dist/events/index.mjs +11 -35
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-BRjxOAFp.d.mts → fields-COhcH3fk.d.mts} +23 -2
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +1 -1
- package/dist/idempotency/index.mjs +1 -20
- package/dist/idempotency/redis.mjs +1 -1
- package/dist/{index-rHjXmJar.d.mts → index-BTqLEvhu.d.mts} +163 -3
- package/dist/{index-CXXRbnf8.d.mts → index-BtW7qYwa.d.mts} +660 -326
- package/dist/{index-m8mOOlFW.d.mts → index-Ds61mrJE.d.mts} +50 -4
- package/dist/{index-D9t1KNaB.d.mts → index-Dz5IKsrE.d.mts} +360 -219
- package/dist/index.d.mts +6 -7
- package/dist/index.mjs +9 -10
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +1 -1
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +60 -11
- package/dist/integrations/streamline.mjs +75 -85
- package/dist/integrations/websocket.mjs +2 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +2 -2
- package/dist/migrations/index.d.mts +23 -3
- package/dist/migrations/index.mjs +0 -7
- package/dist/{multipartBody-CvTR1Un6.mjs → multipartBody-BOvVSVCD.mjs} +11 -8
- package/dist/openapi-noXno2CV.mjs +968 -0
- package/dist/org/index.d.mts +2 -2
- package/dist/org/index.mjs +1 -1
- package/dist/permissions/index.d.mts +3 -3
- package/dist/permissions/index.mjs +3 -3
- package/dist/{permissions-gd_aUWrR.mjs → permissions-ohQyv50e.mjs} +404 -176
- package/dist/{pipe-DVoIheVC.mjs → pipe-Zr0KXjQe.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +16 -31
- package/dist/plugins/index.mjs +33 -13
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.mjs +1 -1
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +6 -9
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +2 -2
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +6 -8
- package/dist/{presets-Z7P5w4gF.mjs → presets-BbkjdPeH.mjs} +6 -28
- package/dist/{queryCachePlugin-Bq6bO6vc.mjs → queryCachePlugin-m1XsgAIJ.mjs} +3 -3
- package/dist/{redis-stream-xTGxB2bm.d.mts → redis-stream-D6HzR1Z_.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{replyHelpers-ByllIXXV.mjs → replyHelpers-CK-FNO8E.mjs} +3 -21
- package/dist/{resourceToTools-CxNmI6xF.mjs → resourceToTools-DLL32us3.mjs} +224 -71
- package/dist/{routerShared-BqLRb5l7.mjs → routerShared-DrOa-26E.mjs} +41 -36
- package/dist/{schemaIR-Dy2p4MxS.mjs → schemaIR-lYhC2gE5.mjs} +1 -1
- package/dist/schemas/index.d.mts +100 -30
- package/dist/schemas/index.mjs +86 -29
- package/dist/scim/index.d.mts +264 -0
- package/dist/scim/index.mjs +963 -0
- package/dist/scope/index.d.mts +3 -3
- package/dist/scope/index.mjs +4 -4
- package/dist/{sse-V7aXc3bW.mjs → sse-Bz-5ZeTt.mjs} +1 -1
- package/dist/{store-helpers-Cp4uKC1U.mjs → store-helpers-BkIN9-vu.mjs} +1 -1
- package/dist/testing/index.d.mts +2 -8
- package/dist/testing/index.mjs +16 -24
- package/dist/types/index.d.mts +4 -4
- package/dist/{types-D7KpfiL1.d.mts → types-BvqwCCSx.d.mts} +73 -25
- package/dist/{types-DDyTPc6y.d.mts → types-CTYvcwHe.d.mts} +195 -1
- package/dist/{types-AOD8fxIw.mjs → types-C_s5moIu.mjs} +117 -1
- package/dist/{types-BQ9TJQNy.d.mts → types-DQHFc8PM.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -2
- package/dist/utils/index.mjs +5 -5
- package/dist/{utils-CcYTj09l.mjs → utils-_h9B3c57.mjs} +1269 -1334
- package/dist/{versioning-DsglKfM_.d.mts → versioning-DTTvc80y.d.mts} +1 -1
- package/package.json +24 -34
- package/skills/arc/SKILL.md +147 -51
- package/skills/arc/references/agent-auth.md +238 -0
- package/skills/arc/references/api-reference.md +187 -0
- package/skills/arc/references/auth.md +354 -7
- package/skills/arc/references/enterprise-auth.md +94 -0
- package/skills/arc/references/events.md +8 -6
- package/skills/arc/references/mcp.md +2 -2
- package/skills/arc/references/multi-tenancy.md +11 -2
- package/skills/arc/references/production.md +10 -9
- package/skills/arc/references/scim.md +247 -0
- package/skills/arc/references/testing.md +1 -1
- package/skills/arc-code-review/SKILL.md +141 -0
- package/skills/arc-code-review/references/anti-patterns.md +911 -0
- package/skills/arc-code-review/references/arc-cheatsheet.md +380 -0
- package/skills/arc-code-review/references/migration-recipes.md +700 -0
- package/skills/arc-code-review/references/mongokit-migration.md +386 -0
- package/skills/arc-code-review/references/scaffolding.md +230 -0
- package/skills/arc-code-review/references/severity.md +127 -0
- package/dist/EventTransport-BFQjw9pB.mjs +0 -133
- package/dist/EventTransport-CYNUXdCJ.d.mts +0 -293
- package/dist/adapters/index.d.mts +0 -3
- package/dist/adapters/index.mjs +0 -2
- package/dist/adapters-DUUiiimH.mjs +0 -964
- package/dist/auth/mongoose.d.mts +0 -191
- package/dist/auth/mongoose.mjs +0 -73
- package/dist/core-CbcQRIch.mjs +0 -1054
- package/dist/errorHandler-BQm8ZxTK.mjs +0 -173
- package/dist/errorHandler-DEWmGWPz.d.mts +0 -114
- package/dist/errors-D5c-5BJL.mjs +0 -232
- package/dist/index-Rg8axYPz.d.mts +0 -370
- package/dist/openapi-D7G1V7ex.mjs +0 -557
- /package/dist/{HookSystem-CGsMd6oK.mjs → HookSystem-Iiebom92.mjs} +0 -0
- /package/dist/{actionPermissions-sUUKDhtP.mjs → actionPermissions-CyUkQu6O.mjs} +0 -0
- /package/dist/{caching-CheW3m-S.mjs → caching-SM8gghN6.mjs} +0 -0
- /package/dist/{constants-BhY1OHoH.mjs → constants-Cxde4rpC.mjs} +0 -0
- /package/dist/{elevation-BQQXZ_VR.d.mts → elevation-BXOWoGCF.d.mts} +0 -0
- /package/dist/{keys-CARyUjiR.mjs → keys-CGcCbNyu.mjs} +0 -0
- /package/dist/{loadResources-CPpkyKfM.mjs → loadResources-DBMQg_Aj.mjs} +0 -0
- /package/dist/{memory-DikHSvWa.mjs → memory-UBydS5ku.mjs} +0 -0
- /package/dist/{metrics-Csh4nsvv.mjs → metrics-Qnvwc-LQ.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-DQgqgifU.mjs} +0 -0
- /package/dist/{registry-D63ee7fl.mjs → registry-I-ogLgL9.mjs} +0 -0
- /package/dist/{requestContext-C5XeK3VA.mjs → requestContext-SSaaTgW8.mjs} +0 -0
- /package/dist/{schemaConverter-B0oKLuqI.mjs → schemaConverter-De34B1ZG.mjs} +0 -0
- /package/dist/{typeGuards-CcFZXgU7.mjs → typeGuards-BzkXkvVv.mjs} +0 -0
- /package/dist/{types-DV9WDfeg.mjs → types-D57iXYb8.mjs} +0 -0
- /package/dist/{versioning-CGPjkqAg.mjs → versioning-BUrT5aP4.mjs} +0 -0
|
@@ -0,0 +1,968 @@
|
|
|
1
|
+
import { t as getUserRoles } from "./types-D57iXYb8.mjs";
|
|
2
|
+
import { n as convertRouteSchema } from "./schemaConverter-De34B1ZG.mjs";
|
|
3
|
+
import { t as resolveActionPermission } from "./actionPermissions-CyUkQu6O.mjs";
|
|
4
|
+
import { t as buildActionBodySchema } from "./createActionRouter-CBxLLbn3.mjs";
|
|
5
|
+
import fp from "fastify-plugin";
|
|
6
|
+
//#region src/docs/openapi/canonical-schemas.ts
|
|
7
|
+
/**
|
|
8
|
+
* Static canonical schemas — referenced once, from
|
|
9
|
+
* `components.schemas`. Per-resource schemas (paginated `oneOf` with
|
|
10
|
+
* the resource's `items.$ref`) are built via
|
|
11
|
+
* `buildPaginatedListSchema(itemRef)` below.
|
|
12
|
+
*/
|
|
13
|
+
const CANONICAL_SCHEMAS = {
|
|
14
|
+
ErrorDetail: {
|
|
15
|
+
type: "object",
|
|
16
|
+
required: ["code", "message"],
|
|
17
|
+
description: "Single field-scoped error detail. Mirrors `@classytic/repo-core/errors` ErrorDetail.",
|
|
18
|
+
properties: {
|
|
19
|
+
path: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "Dot-path pointer to the offending field (e.g. `lines.0.quantity`)."
|
|
22
|
+
},
|
|
23
|
+
code: { type: "string" },
|
|
24
|
+
message: { type: "string" },
|
|
25
|
+
meta: {
|
|
26
|
+
type: "object",
|
|
27
|
+
additionalProperties: true
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
ErrorContract: {
|
|
32
|
+
type: "object",
|
|
33
|
+
required: ["code", "message"],
|
|
34
|
+
description: "Canonical error wire shape emitted by arc's error handler. Mirrors `@classytic/repo-core/errors` ErrorContract — flat top-level `code` / `message`, NOT nested under `{ error: { ... } }`.",
|
|
35
|
+
properties: {
|
|
36
|
+
code: {
|
|
37
|
+
type: "string",
|
|
38
|
+
description: "Hierarchical machine code (e.g. `not_found`, `validation_error`, `order.validation.missing_line`). Arc's legacy UPPER_SNAKE codes (`'NOT_FOUND'`, `'VALIDATION_ERROR'`) also flow through this field for back-compat."
|
|
39
|
+
},
|
|
40
|
+
message: {
|
|
41
|
+
type: "string",
|
|
42
|
+
description: "Human-readable, safe-for-client message."
|
|
43
|
+
},
|
|
44
|
+
status: {
|
|
45
|
+
type: "integer",
|
|
46
|
+
description: "Suggested HTTP status code."
|
|
47
|
+
},
|
|
48
|
+
details: {
|
|
49
|
+
type: "array",
|
|
50
|
+
items: { $ref: "#/components/schemas/ErrorDetail" }
|
|
51
|
+
},
|
|
52
|
+
correlationId: {
|
|
53
|
+
type: "string",
|
|
54
|
+
description: "Trace identifier for support lookups."
|
|
55
|
+
},
|
|
56
|
+
meta: {
|
|
57
|
+
type: "object",
|
|
58
|
+
additionalProperties: true,
|
|
59
|
+
description: "Non-PII metadata. Safe to log, safe to return."
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
DeleteResult: {
|
|
64
|
+
type: "object",
|
|
65
|
+
required: ["message"],
|
|
66
|
+
description: "Arc's HTTP DELETE wire shape. The handler returns `{ message, id?, soft? }` (see `BaseCrudController.delete`). // arc-specific extension to repo-core's DeleteResult TYPE — repo-core's type carries internal `count?` for batch adapters that surface counts inline; arc's HTTP handler does not project that to the wire. // arc-specific extension: `meta` (e.g. `{ message: 'Deleted successfully' }`) is merged at the top level by `fastifyAdapter` — `message` already covers that, no second nesting.",
|
|
67
|
+
properties: {
|
|
68
|
+
message: {
|
|
69
|
+
type: "string",
|
|
70
|
+
description: "Human-readable success message."
|
|
71
|
+
},
|
|
72
|
+
id: {
|
|
73
|
+
type: "string",
|
|
74
|
+
description: "Primary key of the removed document (string form)."
|
|
75
|
+
},
|
|
76
|
+
soft: {
|
|
77
|
+
type: "boolean",
|
|
78
|
+
description: "True when a soft-delete plugin intercepted the operation."
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Build the per-resource paginated list response schema as a plain
|
|
85
|
+
* `oneOf` of the four canonical list envelopes:
|
|
86
|
+
*
|
|
87
|
+
* - offset (`{ method: 'offset', data, page, limit, total, pages, hasNext, hasPrev }`)
|
|
88
|
+
* - keyset (`{ method: 'keyset', data, limit, hasMore, next }`)
|
|
89
|
+
* - aggregate (`{ method: 'aggregate', data, page, limit, total, pages, hasNext, hasPrev }`)
|
|
90
|
+
* - bare (`{ data }`)
|
|
91
|
+
*
|
|
92
|
+
* The first three carry the `method` literal as a discriminant string;
|
|
93
|
+
* codegen tools narrow on `method`. Bare lacks `method` — consumers
|
|
94
|
+
* narrow on its absence.
|
|
95
|
+
*
|
|
96
|
+
* NOTE on shape parity with repo-core/pagination/types.ts:
|
|
97
|
+
* - Keyset uses `hasMore` (boolean) + `next: string | null` — see
|
|
98
|
+
* `KeysetPaginationResultCore`. We model `next` as nullable string.
|
|
99
|
+
* - Offset/aggregate are structurally identical save for the
|
|
100
|
+
* `method` literal (the discriminant exists so consumers can route
|
|
101
|
+
* "this came from an aggregate, not a plain find").
|
|
102
|
+
*/
|
|
103
|
+
function buildPaginatedListSchema(itemRef) {
|
|
104
|
+
return {
|
|
105
|
+
description: "List response — discriminated union of arc's four canonical list shapes. Branch on `method` (`'offset' | 'keyset' | 'aggregate'`) for paginated results; absence of `method` indicates a bare (unpaginated) list.",
|
|
106
|
+
oneOf: [
|
|
107
|
+
{
|
|
108
|
+
type: "object",
|
|
109
|
+
required: [
|
|
110
|
+
"method",
|
|
111
|
+
"data",
|
|
112
|
+
"page",
|
|
113
|
+
"limit",
|
|
114
|
+
"total",
|
|
115
|
+
"pages",
|
|
116
|
+
"hasNext",
|
|
117
|
+
"hasPrev"
|
|
118
|
+
],
|
|
119
|
+
description: "Offset-paginated result.",
|
|
120
|
+
properties: {
|
|
121
|
+
method: {
|
|
122
|
+
type: "string",
|
|
123
|
+
enum: ["offset"]
|
|
124
|
+
},
|
|
125
|
+
data: {
|
|
126
|
+
type: "array",
|
|
127
|
+
items: { $ref: itemRef }
|
|
128
|
+
},
|
|
129
|
+
page: { type: "integer" },
|
|
130
|
+
limit: { type: "integer" },
|
|
131
|
+
total: { type: "integer" },
|
|
132
|
+
pages: { type: "integer" },
|
|
133
|
+
hasNext: { type: "boolean" },
|
|
134
|
+
hasPrev: { type: "boolean" }
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
type: "object",
|
|
139
|
+
required: [
|
|
140
|
+
"method",
|
|
141
|
+
"data",
|
|
142
|
+
"limit",
|
|
143
|
+
"hasMore",
|
|
144
|
+
"next"
|
|
145
|
+
],
|
|
146
|
+
description: "Keyset-paginated result.",
|
|
147
|
+
properties: {
|
|
148
|
+
method: {
|
|
149
|
+
type: "string",
|
|
150
|
+
enum: ["keyset"]
|
|
151
|
+
},
|
|
152
|
+
data: {
|
|
153
|
+
type: "array",
|
|
154
|
+
items: { $ref: itemRef }
|
|
155
|
+
},
|
|
156
|
+
limit: { type: "integer" },
|
|
157
|
+
hasMore: { type: "boolean" },
|
|
158
|
+
next: {
|
|
159
|
+
type: "string",
|
|
160
|
+
nullable: true,
|
|
161
|
+
description: "Opaque cursor for the next page, or null when `hasMore` is false. Round-trip verbatim as the `after` query param."
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
type: "object",
|
|
167
|
+
required: [
|
|
168
|
+
"method",
|
|
169
|
+
"data",
|
|
170
|
+
"page",
|
|
171
|
+
"limit",
|
|
172
|
+
"total",
|
|
173
|
+
"pages",
|
|
174
|
+
"hasNext",
|
|
175
|
+
"hasPrev"
|
|
176
|
+
],
|
|
177
|
+
description: "Aggregate-paginated result.",
|
|
178
|
+
properties: {
|
|
179
|
+
method: {
|
|
180
|
+
type: "string",
|
|
181
|
+
enum: ["aggregate"]
|
|
182
|
+
},
|
|
183
|
+
data: {
|
|
184
|
+
type: "array",
|
|
185
|
+
items: { $ref: itemRef }
|
|
186
|
+
},
|
|
187
|
+
page: { type: "integer" },
|
|
188
|
+
limit: { type: "integer" },
|
|
189
|
+
total: { type: "integer" },
|
|
190
|
+
pages: { type: "integer" },
|
|
191
|
+
hasNext: { type: "boolean" },
|
|
192
|
+
hasPrev: { type: "boolean" }
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
type: "object",
|
|
197
|
+
required: ["data"],
|
|
198
|
+
description: "Bare (unpaginated) list — `{ data: T[] }` only.",
|
|
199
|
+
properties: { data: {
|
|
200
|
+
type: "array",
|
|
201
|
+
items: { $ref: itemRef }
|
|
202
|
+
} }
|
|
203
|
+
}
|
|
204
|
+
]
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/docs/openapi/field-permissions.ts
|
|
209
|
+
/**
|
|
210
|
+
* Field permission descriptions — appended to schema-property
|
|
211
|
+
* `description` strings during component generation so codegen surfaces
|
|
212
|
+
* the perm rule next to the field type.
|
|
213
|
+
*/
|
|
214
|
+
/**
|
|
215
|
+
* Format a field permission rule for an OpenAPI field description.
|
|
216
|
+
*
|
|
217
|
+
* Mirrors the runtime field-permission types — the four supported rule
|
|
218
|
+
* kinds map to a sentence each.
|
|
219
|
+
*/
|
|
220
|
+
function formatFieldPermDescription(perm) {
|
|
221
|
+
switch (perm.type) {
|
|
222
|
+
case "hidden": return "Hidden — never returned in responses";
|
|
223
|
+
case "visibleTo": return `Visible to: ${(perm.roles ?? []).join(", ")}`;
|
|
224
|
+
case "writableBy": return `Writable by: ${(perm.roles ?? []).join(", ")}`;
|
|
225
|
+
case "redactFor": return `Redacted for: ${(perm.roles ?? []).join(", ")}`;
|
|
226
|
+
default: return perm.type;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/docs/openapi/components.ts
|
|
231
|
+
/**
|
|
232
|
+
* Generate component schema definitions from pre-stored registry
|
|
233
|
+
* schemas.
|
|
234
|
+
*
|
|
235
|
+
* Schemas are generated at resource definition time and stored in the
|
|
236
|
+
* registry. Response schema priority:
|
|
237
|
+
* 1. If resource provides explicit `openApiSchemas.response`, use it
|
|
238
|
+
* as-is.
|
|
239
|
+
* 2. Otherwise, auto-generate from `createBody` + `_id` + timestamps.
|
|
240
|
+
* 3. Fallback to a placeholder doc with just `_id` + timestamps.
|
|
241
|
+
*
|
|
242
|
+
* Note: this emits OpenAPI documentation only — does NOT affect Fastify
|
|
243
|
+
* serialization.
|
|
244
|
+
*/
|
|
245
|
+
function generateSchemas(resources) {
|
|
246
|
+
const schemas = { ...CANONICAL_SCHEMAS };
|
|
247
|
+
for (const resource of resources) {
|
|
248
|
+
const storedSchemas = resource.openApiSchemas;
|
|
249
|
+
const fieldPerms = resource.fieldPermissions;
|
|
250
|
+
if (storedSchemas?.response) schemas[resource.name] = {
|
|
251
|
+
type: "object",
|
|
252
|
+
description: resource.displayName,
|
|
253
|
+
...storedSchemas.response
|
|
254
|
+
};
|
|
255
|
+
else if (storedSchemas?.createBody) schemas[resource.name] = {
|
|
256
|
+
type: "object",
|
|
257
|
+
description: resource.displayName,
|
|
258
|
+
properties: {
|
|
259
|
+
_id: {
|
|
260
|
+
type: "string",
|
|
261
|
+
description: "Unique identifier"
|
|
262
|
+
},
|
|
263
|
+
...storedSchemas.createBody.properties ?? {},
|
|
264
|
+
createdAt: {
|
|
265
|
+
type: "string",
|
|
266
|
+
format: "date-time",
|
|
267
|
+
description: "Creation timestamp"
|
|
268
|
+
},
|
|
269
|
+
updatedAt: {
|
|
270
|
+
type: "string",
|
|
271
|
+
format: "date-time",
|
|
272
|
+
description: "Last update timestamp"
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
else schemas[resource.name] = {
|
|
277
|
+
type: "object",
|
|
278
|
+
description: resource.displayName,
|
|
279
|
+
properties: {
|
|
280
|
+
_id: {
|
|
281
|
+
type: "string",
|
|
282
|
+
description: "Unique identifier"
|
|
283
|
+
},
|
|
284
|
+
createdAt: {
|
|
285
|
+
type: "string",
|
|
286
|
+
format: "date-time",
|
|
287
|
+
description: "Creation timestamp"
|
|
288
|
+
},
|
|
289
|
+
updatedAt: {
|
|
290
|
+
type: "string",
|
|
291
|
+
format: "date-time",
|
|
292
|
+
description: "Last update timestamp"
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
const resourceSchema = schemas[resource.name];
|
|
297
|
+
if (fieldPerms && resourceSchema?.properties) {
|
|
298
|
+
const props = resourceSchema.properties;
|
|
299
|
+
for (const [field, perm] of Object.entries(fieldPerms)) {
|
|
300
|
+
const propSchema = props[field];
|
|
301
|
+
if (propSchema) {
|
|
302
|
+
const desc = propSchema.description ?? "";
|
|
303
|
+
const permDesc = formatFieldPermDescription(perm);
|
|
304
|
+
propSchema.description = desc ? `${desc} (${permDesc})` : permDesc;
|
|
305
|
+
} else if (perm.type === "hidden") {}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (storedSchemas?.createBody) {
|
|
309
|
+
schemas[`${resource.name}Input`] = {
|
|
310
|
+
type: "object",
|
|
311
|
+
description: `${resource.displayName} create input`,
|
|
312
|
+
...storedSchemas.createBody
|
|
313
|
+
};
|
|
314
|
+
if (storedSchemas.updateBody) schemas[`${resource.name}Update`] = {
|
|
315
|
+
type: "object",
|
|
316
|
+
description: `${resource.displayName} update input`,
|
|
317
|
+
...storedSchemas.updateBody
|
|
318
|
+
};
|
|
319
|
+
} else schemas[`${resource.name}Input`] = {
|
|
320
|
+
type: "object",
|
|
321
|
+
description: `${resource.displayName} input`
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
return schemas;
|
|
325
|
+
}
|
|
326
|
+
//#endregion
|
|
327
|
+
//#region src/docs/openapi/operations.ts
|
|
328
|
+
/**
|
|
329
|
+
* Standard error responses that every CRUD route ships. Per-route
|
|
330
|
+
* additions (e.g. 404 on get/update/delete, 409 on create/update) are
|
|
331
|
+
* merged on top via `extras.responses`.
|
|
332
|
+
*
|
|
333
|
+
* @internal
|
|
334
|
+
*/
|
|
335
|
+
function buildErrorResponses(opts) {
|
|
336
|
+
const responses = {};
|
|
337
|
+
if (opts.requiresAuth) {
|
|
338
|
+
responses["401"] = {
|
|
339
|
+
description: "Authentication required — no valid Bearer token provided",
|
|
340
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
|
|
341
|
+
};
|
|
342
|
+
responses["403"] = {
|
|
343
|
+
description: opts.permRoles?.length ? `Forbidden — requires one of: ${opts.permRoles.join(", ")}` : "Forbidden — insufficient permissions",
|
|
344
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
responses["500"] = {
|
|
348
|
+
description: "Internal server error",
|
|
349
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
|
|
350
|
+
};
|
|
351
|
+
return responses;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Validation / not-found / conflict — appended to specific CRUD
|
|
355
|
+
* operations. Every shape references `ErrorContract`.
|
|
356
|
+
*/
|
|
357
|
+
function errorResponse(description) {
|
|
358
|
+
return {
|
|
359
|
+
description,
|
|
360
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorContract" } } }
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Create an operation object.
|
|
365
|
+
*
|
|
366
|
+
* @param requiresAuthOverride Override for whether auth is required (used by
|
|
367
|
+
* custom routes that pass `permissions: allowPublic()` etc., which the
|
|
368
|
+
* generic `permissions.{operation}` lookup wouldn't find).
|
|
369
|
+
* @param additionalSecurity Extra security alternatives from external
|
|
370
|
+
* integrations (OR'd with bearerAuth) — e.g. plugin-injected
|
|
371
|
+
* `apiKeyAuth + orgHeader` combos.
|
|
372
|
+
*/
|
|
373
|
+
function createOperation(resource, operation, summary, extras, requiresAuthOverride, additionalSecurity = []) {
|
|
374
|
+
const operationPermission = (resource.permissions || {})[operation];
|
|
375
|
+
const isPublic = operationPermission?._isPublic === true;
|
|
376
|
+
operationPermission?._roles;
|
|
377
|
+
const requiresAuth = requiresAuthOverride !== void 0 ? requiresAuthOverride : typeof operationPermission === "function" && !isPublic;
|
|
378
|
+
const permAnnotation = describePermissionForOpenApi(operationPermission);
|
|
379
|
+
const descParts = [];
|
|
380
|
+
if (permAnnotation) descParts.push(`**Permission**: ${permAnnotation.type === "public" ? "Public" : permAnnotation.type === "requireRoles" ? `Requires roles: ${(permAnnotation.roles ?? []).join(", ")}` : "Requires authentication"}`);
|
|
381
|
+
if (resource.presets && resource.presets.length > 0) descParts.push(`**Presets**: ${resource.presets.join(", ")}`);
|
|
382
|
+
const applicableSteps = (resource.pipelineSteps ?? []).filter((s) => {
|
|
383
|
+
if (!s.operations) return true;
|
|
384
|
+
return s.operations.includes(operation);
|
|
385
|
+
});
|
|
386
|
+
const op = {
|
|
387
|
+
tags: [resource.tag || "Resource"],
|
|
388
|
+
summary: `${summary} ${(resource.displayName || resource.name).toLowerCase()}`,
|
|
389
|
+
operationId: `${resource.name}_${operation}`,
|
|
390
|
+
...descParts.length > 0 && { description: descParts.join("\n\n") },
|
|
391
|
+
...requiresAuth && { security: [{ bearerAuth: [] }, ...additionalSecurity] },
|
|
392
|
+
...permAnnotation && { "x-arc-permission": permAnnotation },
|
|
393
|
+
...applicableSteps.length > 0 && { "x-arc-pipeline": applicableSteps.map((s) => ({
|
|
394
|
+
type: s.type,
|
|
395
|
+
name: s.name
|
|
396
|
+
})) },
|
|
397
|
+
responses: buildErrorResponses({
|
|
398
|
+
requiresAuth,
|
|
399
|
+
permRoles: permAnnotation?.roles
|
|
400
|
+
}),
|
|
401
|
+
...extras
|
|
402
|
+
};
|
|
403
|
+
if (extras.responses) op.responses = {
|
|
404
|
+
...buildErrorResponses({
|
|
405
|
+
requiresAuth,
|
|
406
|
+
permRoles: permAnnotation?.roles
|
|
407
|
+
}),
|
|
408
|
+
...extras.responses
|
|
409
|
+
};
|
|
410
|
+
return op;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Describe a permission check function for OpenAPI.
|
|
414
|
+
* Extracts role, org role, and team permission metadata from permission
|
|
415
|
+
* functions.
|
|
416
|
+
*/
|
|
417
|
+
function describePermissionForOpenApi(check) {
|
|
418
|
+
if (!check || typeof check !== "function") return void 0;
|
|
419
|
+
const fn = check;
|
|
420
|
+
if (fn._isPublic === true) return { type: "public" };
|
|
421
|
+
const result = { type: "requireAuth" };
|
|
422
|
+
if (Array.isArray(fn._roles) && fn._roles.length > 0) {
|
|
423
|
+
result.type = "requireRoles";
|
|
424
|
+
result.roles = fn._roles;
|
|
425
|
+
}
|
|
426
|
+
if (Array.isArray(fn._orgRoles) && fn._orgRoles.length > 0) result.orgRoles = fn._orgRoles;
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
429
|
+
//#endregion
|
|
430
|
+
//#region src/docs/openapi/parameters.ts
|
|
431
|
+
/**
|
|
432
|
+
* Default query parameters for list endpoints when the resource hasn't
|
|
433
|
+
* provided an explicit `openApiSchemas.listQuery` schema. These match
|
|
434
|
+
* the defaults arc's `QueryParser` honours, so codegen consumers get
|
|
435
|
+
* working scaffolds even for kits that don't surface a typed list query.
|
|
436
|
+
*/
|
|
437
|
+
const DEFAULT_LIST_PARAMS = [
|
|
438
|
+
{
|
|
439
|
+
name: "page",
|
|
440
|
+
in: "query",
|
|
441
|
+
schema: { type: "integer" },
|
|
442
|
+
description: "Page number"
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
name: "limit",
|
|
446
|
+
in: "query",
|
|
447
|
+
schema: { type: "integer" },
|
|
448
|
+
description: "Items per page"
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
name: "sort",
|
|
452
|
+
in: "query",
|
|
453
|
+
schema: { type: "string" },
|
|
454
|
+
description: "Sort field (prefix with - for descending)"
|
|
455
|
+
}
|
|
456
|
+
];
|
|
457
|
+
/**
|
|
458
|
+
* Convert Fastify-style params (`/:id`) to OpenAPI-style params
|
|
459
|
+
* (`/{id}`).
|
|
460
|
+
*/
|
|
461
|
+
function toOpenApiPath(path) {
|
|
462
|
+
return path.replace(/:([^/]+)/g, "{$1}");
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Convert a JSON-Schema `{ type: 'object', properties: {...} }` into an
|
|
466
|
+
* OpenAPI parameter array — each property becomes one query parameter.
|
|
467
|
+
*
|
|
468
|
+
* `description` is lifted from the property to the Parameter level
|
|
469
|
+
* (OpenAPI's preferred location); the rest of the schema body stays in
|
|
470
|
+
* `param.schema`.
|
|
471
|
+
*/
|
|
472
|
+
function convertSchemaToParameters(schema) {
|
|
473
|
+
const params = [];
|
|
474
|
+
const properties = schema.properties || {};
|
|
475
|
+
const required = schema.required || [];
|
|
476
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
477
|
+
const description = prop.description;
|
|
478
|
+
const { description: _, ...schemaProps } = prop;
|
|
479
|
+
const param = {
|
|
480
|
+
name,
|
|
481
|
+
in: "query",
|
|
482
|
+
required: required.includes(name),
|
|
483
|
+
schema: schemaProps
|
|
484
|
+
};
|
|
485
|
+
if (description) param.description = description;
|
|
486
|
+
params.push(param);
|
|
487
|
+
}
|
|
488
|
+
return params;
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Extract path parameters from a route path (e.g. `/foo/:id/bar/:slug`
|
|
492
|
+
* → `[{ name: 'id', ...}, { name: 'slug', ...}]`). All extracted params
|
|
493
|
+
* are typed as `string` — Fastify path captures are always strings.
|
|
494
|
+
*/
|
|
495
|
+
function extractPathParams(path) {
|
|
496
|
+
const params = [];
|
|
497
|
+
const matches = path.matchAll(/:([^/]+)/g);
|
|
498
|
+
for (const match of matches) {
|
|
499
|
+
const paramName = match[1];
|
|
500
|
+
if (paramName) params.push({
|
|
501
|
+
name: paramName,
|
|
502
|
+
in: "path",
|
|
503
|
+
required: true,
|
|
504
|
+
schema: { type: "string" }
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
return params;
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/docs/openapi/action-paths.ts
|
|
511
|
+
/**
|
|
512
|
+
* Action endpoint emitter — `POST /:resource/:id/action`.
|
|
513
|
+
*
|
|
514
|
+
* Generates a single dispatch endpoint per resource that lists every
|
|
515
|
+
* declared action via the `action` discriminant. Body schema is built
|
|
516
|
+
* via the SAME `buildActionBodySchema` runtime uses, so docs and
|
|
517
|
+
* validation stay in sync (one source of truth for the action envelope
|
|
518
|
+
* shape).
|
|
519
|
+
*
|
|
520
|
+
* NOTE: action **response** shape varies per action — the dispatcher
|
|
521
|
+
* returns whatever the handler returned. We can't statically type the
|
|
522
|
+
* response without the handler exposing its return type, and most
|
|
523
|
+
* handlers return either the mutated resource document or a kit-defined
|
|
524
|
+
* envelope. We declare the `200` body schema as an empty object (`{}`)
|
|
525
|
+
* which `@hey-api/openapi-ts` and friends compile to `unknown` — that's
|
|
526
|
+
* the most accurate thing we can say without lying to consumers about
|
|
527
|
+
* shape. Per-action shape is documented in the `description` field;
|
|
528
|
+
* future work could let resource authors declare a per-action
|
|
529
|
+
* `responseSchema`.
|
|
530
|
+
*/
|
|
531
|
+
/**
|
|
532
|
+
* Append the action-dispatch path (`POST /:basePath/:id/action`) when
|
|
533
|
+
* the resource declares any `actions`.
|
|
534
|
+
*/
|
|
535
|
+
function appendActionPaths(paths, resource, basePath, additionalSecurity) {
|
|
536
|
+
if (!resource.actions || resource.actions.length === 0) return;
|
|
537
|
+
const actionPath = toOpenApiPath(`${basePath}/:id/action`);
|
|
538
|
+
const actionEnum = resource.actions.map((a) => a.name);
|
|
539
|
+
const actionSchemas = {};
|
|
540
|
+
for (const a of resource.actions) if (a.schema) actionSchemas[a.name] = a.schema;
|
|
541
|
+
const bodySchema = buildActionBodySchema(actionEnum, actionSchemas);
|
|
542
|
+
const descLines = [
|
|
543
|
+
"Unified action endpoint for state transitions.",
|
|
544
|
+
"",
|
|
545
|
+
"**Available actions:**"
|
|
546
|
+
];
|
|
547
|
+
for (const a of resource.actions) {
|
|
548
|
+
const roles = a.permissions?._roles;
|
|
549
|
+
const roleStr = roles?.length ? ` — requires: ${roles.join(" or ")}` : "";
|
|
550
|
+
const descStr = a.description ? ` — ${a.description}` : "";
|
|
551
|
+
descLines.push(`- \`${a.name}\`${roleStr}${descStr}`);
|
|
552
|
+
}
|
|
553
|
+
descLines.push("", "Response shape depends on the action handler — typically the mutated resource document or a kit-defined result envelope. See the per-action description above.");
|
|
554
|
+
const anyAuthRequired = resource.actions.some((a) => {
|
|
555
|
+
const effective = resolveActionPermission({
|
|
556
|
+
action: { permissions: a.permissions },
|
|
557
|
+
resourcePermissions: resource.permissions,
|
|
558
|
+
resourceActionPermissions: resource.actionPermissions
|
|
559
|
+
});
|
|
560
|
+
return typeof effective === "function" && !effective._isPublic;
|
|
561
|
+
});
|
|
562
|
+
if (!paths[actionPath]) paths[actionPath] = {};
|
|
563
|
+
paths[actionPath].post = createOperation(resource, "action", `Perform action (${actionEnum.join(" / ")})`, {
|
|
564
|
+
parameters: [{
|
|
565
|
+
name: "id",
|
|
566
|
+
in: "path",
|
|
567
|
+
required: true,
|
|
568
|
+
schema: { type: "string" },
|
|
569
|
+
description: "Resource ID"
|
|
570
|
+
}],
|
|
571
|
+
description: descLines.join("\n"),
|
|
572
|
+
requestBody: {
|
|
573
|
+
required: true,
|
|
574
|
+
content: { "application/json": { schema: bodySchema } }
|
|
575
|
+
},
|
|
576
|
+
responses: {
|
|
577
|
+
"200": {
|
|
578
|
+
description: "Action executed successfully",
|
|
579
|
+
content: { "application/json": { schema: {} } }
|
|
580
|
+
},
|
|
581
|
+
"400": errorResponse("Invalid action or missing required fields"),
|
|
582
|
+
"404": errorResponse("Resource not found")
|
|
583
|
+
}
|
|
584
|
+
}, anyAuthRequired, additionalSecurity);
|
|
585
|
+
}
|
|
586
|
+
//#endregion
|
|
587
|
+
//#region src/docs/openapi/aggregation-paths.ts
|
|
588
|
+
/**
|
|
589
|
+
* Emit one OpenAPI path entry per declared aggregation.
|
|
590
|
+
*
|
|
591
|
+
* Path: `GET /:resource/aggregations/<name>`
|
|
592
|
+
* Response: `{ rows: AggregationRow[] }` where each row is keyed by
|
|
593
|
+
* the groupBy fields (nested object for joined-alias paths)
|
|
594
|
+
* plus the measure aliases.
|
|
595
|
+
*/
|
|
596
|
+
function appendAggregationPaths(paths, resource, basePath, additionalSecurity) {
|
|
597
|
+
if (!resource.aggregations) return;
|
|
598
|
+
for (const agg of resource.aggregations) {
|
|
599
|
+
const path = toOpenApiPath(`${basePath}/aggregations/${agg.name}`);
|
|
600
|
+
const requiresAuth = !agg.permissions?._isPublic;
|
|
601
|
+
const rowSchema = buildAggregationRowSchema(normalizeGroupByForOpenApi(agg.groupBy), agg.measures, agg.lookupAliases);
|
|
602
|
+
const querystring = {
|
|
603
|
+
type: "object",
|
|
604
|
+
properties: {},
|
|
605
|
+
additionalProperties: true
|
|
606
|
+
};
|
|
607
|
+
if (agg.requireDateRange) {
|
|
608
|
+
const props = querystring.properties;
|
|
609
|
+
const f = agg.requireDateRange.field;
|
|
610
|
+
props[`${f}[gte]`] = {
|
|
611
|
+
type: "string",
|
|
612
|
+
description: `Lower bound (inclusive) of required date range on \`${f}\`.`
|
|
613
|
+
};
|
|
614
|
+
props[`${f}[lte]`] = {
|
|
615
|
+
type: "string",
|
|
616
|
+
description: `Upper bound (inclusive) of required date range on \`${f}\`.`
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (agg.requireFilters?.length) {
|
|
620
|
+
const props = querystring.properties;
|
|
621
|
+
for (const f of agg.requireFilters) props[f] = {
|
|
622
|
+
type: "string",
|
|
623
|
+
description: `Required filter on \`${f}\` — request rejected (400) if missing.`
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
const descLines = [];
|
|
627
|
+
if (agg.description) descLines.push(agg.description);
|
|
628
|
+
descLines.push("Portable aggregation. Caller filters via query string narrow the base + tenant scope; response shape is `{ rows: [...] }` matching repo-core's `AggResult` contract.");
|
|
629
|
+
if (Object.keys(agg.measures).length > 0) {
|
|
630
|
+
const measureLines = Object.entries(agg.measures).map(([alias, op]) => `- \`${alias}\` — \`${op}\``).join("\n");
|
|
631
|
+
descLines.push("", "**Measures:**", measureLines);
|
|
632
|
+
}
|
|
633
|
+
if (agg.requireDateRange) descLines.push("", `**Required date range** on \`${agg.requireDateRange.field}\` — supply \`?${agg.requireDateRange.field}[gte]=...&${agg.requireDateRange.field}[lte]=...\`.` + (agg.requireDateRange.maxRangeDays ? ` Range cap: ${agg.requireDateRange.maxRangeDays} days.` : ""));
|
|
634
|
+
if (!paths[path]) paths[path] = {};
|
|
635
|
+
paths[path].get = createOperation(resource, `aggregation.${agg.name}`, agg.summary ?? `Aggregation: ${agg.name}`, {
|
|
636
|
+
description: descLines.join("\n"),
|
|
637
|
+
parameters: [{
|
|
638
|
+
name: "querystring",
|
|
639
|
+
in: "query",
|
|
640
|
+
required: false,
|
|
641
|
+
schema: querystring,
|
|
642
|
+
description: "Filter narrowing — composes with base filter + tenant scope."
|
|
643
|
+
}],
|
|
644
|
+
responses: {
|
|
645
|
+
"200": {
|
|
646
|
+
description: "Aggregation result",
|
|
647
|
+
content: { "application/json": { schema: {
|
|
648
|
+
type: "object",
|
|
649
|
+
required: ["rows"],
|
|
650
|
+
properties: { rows: {
|
|
651
|
+
type: "array",
|
|
652
|
+
items: rowSchema
|
|
653
|
+
} }
|
|
654
|
+
} } }
|
|
655
|
+
},
|
|
656
|
+
"400": errorResponse("Missing required filter or invalid date range"),
|
|
657
|
+
"422": errorResponse("Result row count exceeded `maxGroups` cap"),
|
|
658
|
+
"501": errorResponse("Adapter does not implement `aggregate()`"),
|
|
659
|
+
"504": errorResponse("Aggregation execution timed out")
|
|
660
|
+
}
|
|
661
|
+
}, requiresAuth, additionalSecurity);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Build the JSON Schema for a single aggregation row. Combines the
|
|
666
|
+
* groupBy field shape (nested for joined-alias paths) with the
|
|
667
|
+
* measure-alias scalars.
|
|
668
|
+
*
|
|
669
|
+
* Group keys with dotted paths (e.g. `'category.code'`) emit a nested
|
|
670
|
+
* `category: { code: string }` object, matching the cross-kit
|
|
671
|
+
* `nestDottedKeys` output. Plain group keys are flat.
|
|
672
|
+
*
|
|
673
|
+
* Measure scalars are always `number` — every measure op
|
|
674
|
+
* (`count` / `sum` / `avg` / `min` / `max` / `countDistinct`)
|
|
675
|
+
* produces a numeric result.
|
|
676
|
+
*/
|
|
677
|
+
function buildAggregationRowSchema(groupByFields, measures, _lookupAliases) {
|
|
678
|
+
const properties = {};
|
|
679
|
+
for (const field of groupByFields) setNestedSchemaProp(properties, field.split("."), { type: "string" });
|
|
680
|
+
for (const alias of Object.keys(measures)) properties[alias] = { type: "number" };
|
|
681
|
+
return {
|
|
682
|
+
type: "object",
|
|
683
|
+
properties,
|
|
684
|
+
additionalProperties: false
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function normalizeGroupByForOpenApi(groupBy) {
|
|
688
|
+
if (!groupBy) return [];
|
|
689
|
+
return typeof groupBy === "string" ? [groupBy] : groupBy;
|
|
690
|
+
}
|
|
691
|
+
function setNestedSchemaProp(target, path, leaf) {
|
|
692
|
+
if (path.length === 1) {
|
|
693
|
+
target[path[0]] = leaf;
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const head = path[0];
|
|
697
|
+
const rest = path.slice(1);
|
|
698
|
+
let nested = target[head];
|
|
699
|
+
if (!nested || typeof nested !== "object") {
|
|
700
|
+
nested = {
|
|
701
|
+
type: "object",
|
|
702
|
+
properties: {}
|
|
703
|
+
};
|
|
704
|
+
target[head] = nested;
|
|
705
|
+
}
|
|
706
|
+
if (!nested.properties) nested.properties = {};
|
|
707
|
+
setNestedSchemaProp(nested.properties, rest, leaf);
|
|
708
|
+
}
|
|
709
|
+
//#endregion
|
|
710
|
+
//#region src/docs/openapi/crud-paths.ts
|
|
711
|
+
/**
|
|
712
|
+
* Append the default-CRUD paths (`GET /` list + `POST /` create on the
|
|
713
|
+
* collection path; `GET/PATCH|PUT/DELETE /:id` on the item path).
|
|
714
|
+
* Honours `disableDefaultRoutes`, `disabledRoutes`, and `updateMethod`.
|
|
715
|
+
*/
|
|
716
|
+
function appendCrudPaths(paths, resource, basePath, additionalSecurity) {
|
|
717
|
+
if (resource.disableDefaultRoutes) return;
|
|
718
|
+
const disabledSet = new Set(resource.disabledRoutes ?? []);
|
|
719
|
+
const updateMethod = resource.updateMethod ?? "PATCH";
|
|
720
|
+
const collectionPath = {};
|
|
721
|
+
if (!disabledSet.has("list")) collectionPath.get = createOperation(resource, "list", "List all", {
|
|
722
|
+
parameters: resource.openApiSchemas?.listQuery ? convertSchemaToParameters(resource.openApiSchemas.listQuery) : DEFAULT_LIST_PARAMS,
|
|
723
|
+
responses: {
|
|
724
|
+
"200": {
|
|
725
|
+
description: "List of items",
|
|
726
|
+
content: { "application/json": { schema: buildPaginatedListSchema(`#/components/schemas/${resource.name}`) } }
|
|
727
|
+
},
|
|
728
|
+
"400": errorResponse("Validation error — bad filter / sort / pagination params")
|
|
729
|
+
}
|
|
730
|
+
}, void 0, additionalSecurity);
|
|
731
|
+
if (!disabledSet.has("create")) collectionPath.post = createOperation(resource, "create", "Create new", {
|
|
732
|
+
requestBody: {
|
|
733
|
+
required: true,
|
|
734
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}Input` } } }
|
|
735
|
+
},
|
|
736
|
+
responses: {
|
|
737
|
+
"201": {
|
|
738
|
+
description: "Created successfully",
|
|
739
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}` } } }
|
|
740
|
+
},
|
|
741
|
+
"400": errorResponse("Validation error — request body failed schema validation"),
|
|
742
|
+
"409": errorResponse("Conflict — duplicate key on a unique-indexed field")
|
|
743
|
+
}
|
|
744
|
+
}, void 0, additionalSecurity);
|
|
745
|
+
if (Object.keys(collectionPath).length > 0) paths[basePath] = collectionPath;
|
|
746
|
+
const itemPath = {};
|
|
747
|
+
if (!disabledSet.has("get")) itemPath.get = createOperation(resource, "get", "Get by ID", {
|
|
748
|
+
parameters: [{
|
|
749
|
+
name: "id",
|
|
750
|
+
in: "path",
|
|
751
|
+
required: true,
|
|
752
|
+
schema: { type: "string" }
|
|
753
|
+
}],
|
|
754
|
+
responses: {
|
|
755
|
+
"200": {
|
|
756
|
+
description: "Item found",
|
|
757
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}` } } }
|
|
758
|
+
},
|
|
759
|
+
"404": errorResponse("Not found")
|
|
760
|
+
}
|
|
761
|
+
}, void 0, additionalSecurity);
|
|
762
|
+
if (!disabledSet.has("update")) {
|
|
763
|
+
const updateOp = createOperation(resource, "update", "Update", {
|
|
764
|
+
parameters: [{
|
|
765
|
+
name: "id",
|
|
766
|
+
in: "path",
|
|
767
|
+
required: true,
|
|
768
|
+
schema: { type: "string" }
|
|
769
|
+
}],
|
|
770
|
+
requestBody: {
|
|
771
|
+
required: true,
|
|
772
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}Input` } } }
|
|
773
|
+
},
|
|
774
|
+
responses: {
|
|
775
|
+
"200": {
|
|
776
|
+
description: "Updated successfully",
|
|
777
|
+
content: { "application/json": { schema: { $ref: `#/components/schemas/${resource.name}` } } }
|
|
778
|
+
},
|
|
779
|
+
"400": errorResponse("Validation error — request body failed schema validation"),
|
|
780
|
+
"404": errorResponse("Not found"),
|
|
781
|
+
"409": errorResponse("Conflict — duplicate key on a unique-indexed field")
|
|
782
|
+
}
|
|
783
|
+
}, void 0, additionalSecurity);
|
|
784
|
+
if (updateMethod === "both") {
|
|
785
|
+
itemPath.put = updateOp;
|
|
786
|
+
itemPath.patch = updateOp;
|
|
787
|
+
} else if (updateMethod === "PUT") itemPath.put = updateOp;
|
|
788
|
+
else itemPath.patch = updateOp;
|
|
789
|
+
}
|
|
790
|
+
if (!disabledSet.has("delete")) itemPath.delete = createOperation(resource, "delete", "Delete", {
|
|
791
|
+
parameters: [{
|
|
792
|
+
name: "id",
|
|
793
|
+
in: "path",
|
|
794
|
+
required: true,
|
|
795
|
+
schema: { type: "string" }
|
|
796
|
+
}],
|
|
797
|
+
responses: {
|
|
798
|
+
"200": {
|
|
799
|
+
description: "Deleted successfully",
|
|
800
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/DeleteResult" } } }
|
|
801
|
+
},
|
|
802
|
+
"404": errorResponse("Not found")
|
|
803
|
+
}
|
|
804
|
+
}, void 0, additionalSecurity);
|
|
805
|
+
if (Object.keys(itemPath).length > 0) paths[toOpenApiPath(`${basePath}/:id`)] = itemPath;
|
|
806
|
+
}
|
|
807
|
+
//#endregion
|
|
808
|
+
//#region src/docs/openapi/custom-paths.ts
|
|
809
|
+
/**
|
|
810
|
+
* Append every entry in `resource.customRoutes` to the `paths` map.
|
|
811
|
+
*/
|
|
812
|
+
function appendCustomRoutePaths(paths, resource, basePath, additionalSecurity) {
|
|
813
|
+
for (const route of resource.customRoutes || []) {
|
|
814
|
+
const fullPath = toOpenApiPath(`${basePath}${route.path}`);
|
|
815
|
+
const method = route.method.toLowerCase();
|
|
816
|
+
if (!paths[fullPath]) paths[fullPath] = {};
|
|
817
|
+
const handlerName = route.operation ?? (typeof route.handler === "string" ? route.handler : "handler");
|
|
818
|
+
const isPublicRoute = route.permissions?._isPublic === true;
|
|
819
|
+
const requiresAuthForRoute = !!route.permissions && !isPublicRoute;
|
|
820
|
+
const extras = {
|
|
821
|
+
parameters: extractPathParams(route.path),
|
|
822
|
+
responses: { "200": { description: route.description || "Success" } }
|
|
823
|
+
};
|
|
824
|
+
const rawSchema = route.schema;
|
|
825
|
+
const routeSchema = rawSchema ? convertRouteSchema(rawSchema, "openapi-3.0") : void 0;
|
|
826
|
+
if (routeSchema?.body && [
|
|
827
|
+
"post",
|
|
828
|
+
"put",
|
|
829
|
+
"patch"
|
|
830
|
+
].includes(method)) extras.requestBody = {
|
|
831
|
+
required: true,
|
|
832
|
+
content: { "application/json": { schema: routeSchema.body } }
|
|
833
|
+
};
|
|
834
|
+
if (routeSchema?.querystring) {
|
|
835
|
+
const queryParams = convertSchemaToParameters(routeSchema.querystring);
|
|
836
|
+
extras.parameters = [...extras.parameters || [], ...queryParams];
|
|
837
|
+
}
|
|
838
|
+
if (routeSchema?.response) {
|
|
839
|
+
const responseSchemas = routeSchema.response;
|
|
840
|
+
for (const [statusCode, schema] of Object.entries(responseSchemas)) extras.responses[statusCode] = {
|
|
841
|
+
description: schema.description || `Response ${statusCode}`,
|
|
842
|
+
content: { "application/json": { schema } }
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
paths[fullPath][method] = createOperation(resource, handlerName, route.summary ?? handlerName, extras, requiresAuthForRoute, additionalSecurity);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
//#endregion
|
|
849
|
+
//#region src/docs/openapi/paths.ts
|
|
850
|
+
/**
|
|
851
|
+
* Generate the OpenAPI `paths` entries for a single resource.
|
|
852
|
+
*/
|
|
853
|
+
function generateResourcePaths(resource, apiPrefix = "", additionalSecurity = []) {
|
|
854
|
+
const paths = {};
|
|
855
|
+
const basePath = `${apiPrefix}${resource.prefix}`;
|
|
856
|
+
appendCrudPaths(paths, resource, basePath, additionalSecurity);
|
|
857
|
+
appendCustomRoutePaths(paths, resource, basePath, additionalSecurity);
|
|
858
|
+
appendActionPaths(paths, resource, basePath, additionalSecurity);
|
|
859
|
+
appendAggregationPaths(paths, resource, basePath, additionalSecurity);
|
|
860
|
+
return paths;
|
|
861
|
+
}
|
|
862
|
+
//#endregion
|
|
863
|
+
//#region src/docs/openapi/index.ts
|
|
864
|
+
const openApiPlugin = async (fastify, opts = {}) => {
|
|
865
|
+
const { title = "Arc API", version = "1.0.0", description, serverUrl, prefix = "/_docs", apiPrefix = "", authRoles = [] } = opts;
|
|
866
|
+
const buildSpec = () => {
|
|
867
|
+
const arc = fastify.arc;
|
|
868
|
+
const resources = arc?.registry?.getAll() ?? [];
|
|
869
|
+
const externalPaths = arc?.externalOpenApiPaths ?? [];
|
|
870
|
+
return buildOpenApiSpec(resources, {
|
|
871
|
+
title,
|
|
872
|
+
version,
|
|
873
|
+
description,
|
|
874
|
+
serverUrl,
|
|
875
|
+
apiPrefix
|
|
876
|
+
}, externalPaths.length > 0 ? externalPaths : void 0);
|
|
877
|
+
};
|
|
878
|
+
fastify.get(`${prefix}/openapi.json`, async (request, reply) => {
|
|
879
|
+
if (authRoles.length > 0) {
|
|
880
|
+
const user = request.user;
|
|
881
|
+
const roles = getUserRoles(user);
|
|
882
|
+
if (!authRoles.some((r) => roles.includes(r)) && !roles.includes("superadmin")) {
|
|
883
|
+
reply.code(403).send({ error: "Access denied" });
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
return buildSpec();
|
|
888
|
+
});
|
|
889
|
+
fastify.log?.debug?.(`OpenAPI spec available at ${prefix}/openapi.json`);
|
|
890
|
+
};
|
|
891
|
+
/**
|
|
892
|
+
* Build OpenAPI spec from registry resources.
|
|
893
|
+
* Shared by HTTP docs endpoint and CLI export command.
|
|
894
|
+
*/
|
|
895
|
+
function buildOpenApiSpec(resources, options = {}, externalPaths) {
|
|
896
|
+
const { title = "Arc API", version = "1.0.0", description, serverUrl, apiPrefix = "" } = options;
|
|
897
|
+
const paths = {};
|
|
898
|
+
const tags = [];
|
|
899
|
+
const additionalSecurity = externalPaths?.flatMap((ext) => ext.resourceSecurity ?? []) ?? [];
|
|
900
|
+
for (const resource of resources) {
|
|
901
|
+
const tagDescParts = [`${resource.displayName || resource.name} operations`];
|
|
902
|
+
if (resource.presets && resource.presets.length > 0) tagDescParts.push(`Presets: ${resource.presets.join(", ")}`);
|
|
903
|
+
if (resource.pipelineSteps && resource.pipelineSteps.length > 0) {
|
|
904
|
+
const stepNames = resource.pipelineSteps.map((s) => `${s.type}(${s.name})`);
|
|
905
|
+
tagDescParts.push(`Pipeline: ${stepNames.join(" → ")}`);
|
|
906
|
+
}
|
|
907
|
+
if (resource.events && resource.events.length > 0) tagDescParts.push(`Events: ${resource.events.join(", ")}`);
|
|
908
|
+
tags.push({
|
|
909
|
+
name: resource.tag || resource.name,
|
|
910
|
+
description: tagDescParts.join(". ")
|
|
911
|
+
});
|
|
912
|
+
const resourcePaths = generateResourcePaths(resource, apiPrefix, additionalSecurity);
|
|
913
|
+
Object.assign(paths, resourcePaths);
|
|
914
|
+
}
|
|
915
|
+
if (externalPaths) for (const ext of externalPaths) {
|
|
916
|
+
for (const [path, methods] of Object.entries(ext.paths)) paths[path] = paths[path] ? {
|
|
917
|
+
...paths[path],
|
|
918
|
+
...methods
|
|
919
|
+
} : methods;
|
|
920
|
+
if (ext.tags) {
|
|
921
|
+
for (const tag of ext.tags) if (!tags.find((t) => t.name === tag.name)) tags.push(tag);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
const externalSecuritySchemes = externalPaths?.reduce((acc, ext) => ({
|
|
925
|
+
...acc,
|
|
926
|
+
...ext.securitySchemes
|
|
927
|
+
}), {}) ?? {};
|
|
928
|
+
const externalSchemas = externalPaths?.reduce((acc, ext) => ({
|
|
929
|
+
...acc,
|
|
930
|
+
...ext.schemas
|
|
931
|
+
}), {}) ?? {};
|
|
932
|
+
return {
|
|
933
|
+
openapi: "3.0.3",
|
|
934
|
+
info: {
|
|
935
|
+
title,
|
|
936
|
+
version,
|
|
937
|
+
...description && { description }
|
|
938
|
+
},
|
|
939
|
+
...serverUrl && { servers: [{ url: serverUrl }] },
|
|
940
|
+
paths,
|
|
941
|
+
components: {
|
|
942
|
+
schemas: {
|
|
943
|
+
...generateSchemas(resources),
|
|
944
|
+
...externalSchemas
|
|
945
|
+
},
|
|
946
|
+
securitySchemes: {
|
|
947
|
+
bearerAuth: {
|
|
948
|
+
type: "http",
|
|
949
|
+
scheme: "bearer",
|
|
950
|
+
bearerFormat: "JWT"
|
|
951
|
+
},
|
|
952
|
+
orgHeader: {
|
|
953
|
+
type: "apiKey",
|
|
954
|
+
in: "header",
|
|
955
|
+
name: "x-organization-id"
|
|
956
|
+
},
|
|
957
|
+
...externalSecuritySchemes
|
|
958
|
+
}
|
|
959
|
+
},
|
|
960
|
+
tags
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
var openapi_default = fp(openApiPlugin, {
|
|
964
|
+
name: "arc-openapi",
|
|
965
|
+
fastify: "5.x"
|
|
966
|
+
});
|
|
967
|
+
//#endregion
|
|
968
|
+
export { openApiPlugin as n, openapi_default as r, buildOpenApiSpec as t };
|