@classytic/arc 1.0.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/LICENSE +21 -0
- package/README.md +900 -0
- package/bin/arc.js +344 -0
- package/dist/adapters/index.d.ts +237 -0
- package/dist/adapters/index.js +668 -0
- package/dist/arcCorePlugin-DTPWXcZN.d.ts +273 -0
- package/dist/audit/index.d.ts +195 -0
- package/dist/audit/index.js +319 -0
- package/dist/auth/index.d.ts +47 -0
- package/dist/auth/index.js +174 -0
- package/dist/cli/commands/docs.d.ts +11 -0
- package/dist/cli/commands/docs.js +474 -0
- package/dist/cli/commands/introspect.d.ts +8 -0
- package/dist/cli/commands/introspect.js +338 -0
- package/dist/cli/index.d.ts +43 -0
- package/dist/cli/index.js +520 -0
- package/dist/createApp-pzUAkzbz.d.ts +77 -0
- package/dist/docs/index.d.ts +166 -0
- package/dist/docs/index.js +650 -0
- package/dist/errors-8WIxGS_6.d.ts +122 -0
- package/dist/events/index.d.ts +117 -0
- package/dist/events/index.js +89 -0
- package/dist/factory/index.d.ts +38 -0
- package/dist/factory/index.js +1664 -0
- package/dist/hooks/index.d.ts +4 -0
- package/dist/hooks/index.js +199 -0
- package/dist/idempotency/index.d.ts +323 -0
- package/dist/idempotency/index.js +500 -0
- package/dist/index-DkAW8BXh.d.ts +1302 -0
- package/dist/index.d.ts +331 -0
- package/dist/index.js +4734 -0
- package/dist/migrations/index.d.ts +185 -0
- package/dist/migrations/index.js +274 -0
- package/dist/org/index.d.ts +129 -0
- package/dist/org/index.js +220 -0
- package/dist/permissions/index.d.ts +144 -0
- package/dist/permissions/index.js +100 -0
- package/dist/plugins/index.d.ts +46 -0
- package/dist/plugins/index.js +1069 -0
- package/dist/policies/index.d.ts +398 -0
- package/dist/policies/index.js +196 -0
- package/dist/presets/index.d.ts +336 -0
- package/dist/presets/index.js +382 -0
- package/dist/presets/multiTenant.d.ts +39 -0
- package/dist/presets/multiTenant.js +112 -0
- package/dist/registry/index.d.ts +16 -0
- package/dist/registry/index.js +253 -0
- package/dist/testing/index.d.ts +618 -0
- package/dist/testing/index.js +48032 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.js +8 -0
- package/dist/types-0IPhH_NR.d.ts +143 -0
- package/dist/types-B99TBmFV.d.ts +76 -0
- package/dist/utils/index.d.ts +655 -0
- package/dist/utils/index.js +905 -0
- package/package.json +227 -0
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
import fp from 'fastify-plugin';
|
|
2
|
+
|
|
3
|
+
// src/docs/openapi.ts
|
|
4
|
+
|
|
5
|
+
// src/registry/ResourceRegistry.ts
|
|
6
|
+
var ResourceRegistry = class {
|
|
7
|
+
_resources;
|
|
8
|
+
_frozen;
|
|
9
|
+
constructor() {
|
|
10
|
+
this._resources = /* @__PURE__ */ new Map();
|
|
11
|
+
this._frozen = false;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Register a resource
|
|
15
|
+
*/
|
|
16
|
+
register(resource, options = {}) {
|
|
17
|
+
if (this._frozen) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Registry frozen. Cannot register '${resource.name}' after startup.`
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
if (this._resources.has(resource.name)) {
|
|
23
|
+
throw new Error(`Resource '${resource.name}' already registered.`);
|
|
24
|
+
}
|
|
25
|
+
const entry = {
|
|
26
|
+
name: resource.name,
|
|
27
|
+
displayName: resource.displayName,
|
|
28
|
+
tag: resource.tag,
|
|
29
|
+
prefix: resource.prefix,
|
|
30
|
+
module: options.module ?? void 0,
|
|
31
|
+
adapter: resource.adapter ? {
|
|
32
|
+
type: resource.adapter.type,
|
|
33
|
+
name: resource.adapter.name
|
|
34
|
+
} : null,
|
|
35
|
+
permissions: resource.permissions,
|
|
36
|
+
presets: resource._appliedPresets ?? [],
|
|
37
|
+
routes: [],
|
|
38
|
+
// Populated later by getIntrospection()
|
|
39
|
+
additionalRoutes: resource.additionalRoutes.map((r) => ({
|
|
40
|
+
method: r.method,
|
|
41
|
+
path: r.path,
|
|
42
|
+
handler: typeof r.handler === "string" ? r.handler : r.handler.name || "anonymous",
|
|
43
|
+
summary: r.summary,
|
|
44
|
+
description: r.description,
|
|
45
|
+
permissions: r.permissions,
|
|
46
|
+
wrapHandler: r.wrapHandler,
|
|
47
|
+
schema: r.schema
|
|
48
|
+
// Include schema for OpenAPI docs
|
|
49
|
+
})),
|
|
50
|
+
events: Object.keys(resource.events ?? {}),
|
|
51
|
+
registeredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
52
|
+
disableDefaultRoutes: resource.disableDefaultRoutes,
|
|
53
|
+
openApiSchemas: options.openApiSchemas,
|
|
54
|
+
plugin: resource.toPlugin()
|
|
55
|
+
// Store plugin factory
|
|
56
|
+
};
|
|
57
|
+
this._resources.set(resource.name, entry);
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Get resource by name
|
|
62
|
+
*/
|
|
63
|
+
get(name) {
|
|
64
|
+
return this._resources.get(name);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Get all resources
|
|
68
|
+
*/
|
|
69
|
+
getAll() {
|
|
70
|
+
return Array.from(this._resources.values());
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Get resources by module
|
|
74
|
+
*/
|
|
75
|
+
getByModule(moduleName) {
|
|
76
|
+
return this.getAll().filter((r) => r.module === moduleName);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Get resources by preset
|
|
80
|
+
*/
|
|
81
|
+
getByPreset(presetName) {
|
|
82
|
+
return this.getAll().filter((r) => r.presets.includes(presetName));
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Check if resource exists
|
|
86
|
+
*/
|
|
87
|
+
has(name) {
|
|
88
|
+
return this._resources.has(name);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Get registry statistics
|
|
92
|
+
*/
|
|
93
|
+
getStats() {
|
|
94
|
+
const resources = this.getAll();
|
|
95
|
+
const presetCounts = {};
|
|
96
|
+
for (const r of resources) {
|
|
97
|
+
for (const preset of r.presets) {
|
|
98
|
+
presetCounts[preset] = (presetCounts[preset] ?? 0) + 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
totalResources: resources.length,
|
|
103
|
+
byModule: this._groupBy(resources, "module"),
|
|
104
|
+
presetUsage: presetCounts,
|
|
105
|
+
totalRoutes: resources.reduce((sum, r) => {
|
|
106
|
+
const defaultRouteCount = r.disableDefaultRoutes ? 0 : 5;
|
|
107
|
+
return sum + (r.additionalRoutes?.length ?? 0) + defaultRouteCount;
|
|
108
|
+
}, 0),
|
|
109
|
+
totalEvents: resources.reduce((sum, r) => sum + (r.events?.length ?? 0), 0)
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get full introspection data
|
|
114
|
+
*/
|
|
115
|
+
getIntrospection() {
|
|
116
|
+
return {
|
|
117
|
+
resources: this.getAll().map((r) => {
|
|
118
|
+
const defaultRoutes = r.disableDefaultRoutes ? [] : [
|
|
119
|
+
{ method: "GET", path: r.prefix, operation: "list" },
|
|
120
|
+
{ method: "GET", path: `${r.prefix}/:id`, operation: "get" },
|
|
121
|
+
{ method: "POST", path: r.prefix, operation: "create" },
|
|
122
|
+
{ method: "PATCH", path: `${r.prefix}/:id`, operation: "update" },
|
|
123
|
+
{ method: "DELETE", path: `${r.prefix}/:id`, operation: "delete" }
|
|
124
|
+
];
|
|
125
|
+
return {
|
|
126
|
+
name: r.name,
|
|
127
|
+
displayName: r.displayName,
|
|
128
|
+
prefix: r.prefix,
|
|
129
|
+
module: r.module,
|
|
130
|
+
presets: r.presets,
|
|
131
|
+
permissions: r.permissions,
|
|
132
|
+
routes: [
|
|
133
|
+
...defaultRoutes,
|
|
134
|
+
...r.additionalRoutes?.map((ar) => ({
|
|
135
|
+
method: ar.method,
|
|
136
|
+
path: `${r.prefix}${ar.path}`,
|
|
137
|
+
operation: typeof ar.handler === "string" ? ar.handler : "custom",
|
|
138
|
+
handler: typeof ar.handler === "string" ? ar.handler : void 0,
|
|
139
|
+
summary: ar.summary
|
|
140
|
+
})) ?? []
|
|
141
|
+
],
|
|
142
|
+
events: r.events
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
stats: this.getStats(),
|
|
146
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Freeze registry (prevent further registrations)
|
|
151
|
+
*/
|
|
152
|
+
freeze() {
|
|
153
|
+
this._frozen = true;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Check if frozen
|
|
157
|
+
*/
|
|
158
|
+
isFrozen() {
|
|
159
|
+
return this._frozen;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Unfreeze registry (for testing)
|
|
163
|
+
*/
|
|
164
|
+
_unfreeze() {
|
|
165
|
+
this._frozen = false;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Clear all resources (for testing)
|
|
169
|
+
*/
|
|
170
|
+
_clear() {
|
|
171
|
+
this._resources.clear();
|
|
172
|
+
this._frozen = false;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Group by key
|
|
176
|
+
*/
|
|
177
|
+
_groupBy(arr, key) {
|
|
178
|
+
const result = {};
|
|
179
|
+
for (const item of arr) {
|
|
180
|
+
const k = String(item[key] ?? "uncategorized");
|
|
181
|
+
result[k] = (result[k] ?? 0) + 1;
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
var registryKey = /* @__PURE__ */ Symbol.for("arc.resourceRegistry");
|
|
187
|
+
var globalScope = globalThis;
|
|
188
|
+
var resourceRegistry = globalScope[registryKey] ?? new ResourceRegistry();
|
|
189
|
+
if (!globalScope[registryKey]) {
|
|
190
|
+
globalScope[registryKey] = resourceRegistry;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/docs/openapi.ts
|
|
194
|
+
var openApiPlugin = async (fastify, opts = {}) => {
|
|
195
|
+
const {
|
|
196
|
+
title = "Arc API",
|
|
197
|
+
version = "1.0.0",
|
|
198
|
+
description,
|
|
199
|
+
serverUrl,
|
|
200
|
+
prefix = "/_docs",
|
|
201
|
+
apiPrefix = "",
|
|
202
|
+
authRoles = []
|
|
203
|
+
} = opts;
|
|
204
|
+
const buildSpec = () => {
|
|
205
|
+
const resources = resourceRegistry.getAll();
|
|
206
|
+
const paths = {};
|
|
207
|
+
const tags = [];
|
|
208
|
+
for (const resource of resources) {
|
|
209
|
+
tags.push({
|
|
210
|
+
name: resource.tag || resource.name,
|
|
211
|
+
description: `${resource.displayName || resource.name} operations`
|
|
212
|
+
});
|
|
213
|
+
const resourcePaths = generateResourcePaths(resource, apiPrefix);
|
|
214
|
+
Object.assign(paths, resourcePaths);
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
openapi: "3.0.3",
|
|
218
|
+
info: {
|
|
219
|
+
title,
|
|
220
|
+
version,
|
|
221
|
+
...description && { description }
|
|
222
|
+
},
|
|
223
|
+
...serverUrl && {
|
|
224
|
+
servers: [{ url: serverUrl }]
|
|
225
|
+
},
|
|
226
|
+
paths,
|
|
227
|
+
components: {
|
|
228
|
+
schemas: generateSchemas(resources),
|
|
229
|
+
securitySchemes: {
|
|
230
|
+
bearerAuth: {
|
|
231
|
+
type: "http",
|
|
232
|
+
scheme: "bearer",
|
|
233
|
+
bearerFormat: "JWT"
|
|
234
|
+
},
|
|
235
|
+
orgHeader: {
|
|
236
|
+
type: "apiKey",
|
|
237
|
+
in: "header",
|
|
238
|
+
name: "x-organization-id"
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
tags
|
|
243
|
+
// Note: Security is defined per-operation, not globally
|
|
244
|
+
// This allows public routes to have no security requirement
|
|
245
|
+
};
|
|
246
|
+
};
|
|
247
|
+
fastify.get(`${prefix}/openapi.json`, async (request, reply) => {
|
|
248
|
+
if (authRoles.length > 0) {
|
|
249
|
+
const user = request.user;
|
|
250
|
+
const hasRole = authRoles.some((role) => user?.roles?.includes(role));
|
|
251
|
+
if (!hasRole && !user?.roles?.includes("superadmin")) {
|
|
252
|
+
reply.code(403).send({ error: "Access denied" });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const spec = buildSpec();
|
|
257
|
+
return spec;
|
|
258
|
+
});
|
|
259
|
+
fastify.log?.info?.(`OpenAPI spec available at ${prefix}/openapi.json`);
|
|
260
|
+
};
|
|
261
|
+
function toOpenApiPath(path) {
|
|
262
|
+
return path.replace(/:([^/]+)/g, "{$1}");
|
|
263
|
+
}
|
|
264
|
+
function convertSchemaToParameters(schema) {
|
|
265
|
+
const params = [];
|
|
266
|
+
const properties = schema.properties || {};
|
|
267
|
+
const required = schema.required || [];
|
|
268
|
+
for (const [name, prop] of Object.entries(properties)) {
|
|
269
|
+
const description = prop.description;
|
|
270
|
+
const { description: _, ...schemaProps } = prop;
|
|
271
|
+
const param = {
|
|
272
|
+
name,
|
|
273
|
+
in: "query",
|
|
274
|
+
required: required.includes(name),
|
|
275
|
+
schema: schemaProps
|
|
276
|
+
};
|
|
277
|
+
if (description) {
|
|
278
|
+
param.description = description;
|
|
279
|
+
}
|
|
280
|
+
params.push(param);
|
|
281
|
+
}
|
|
282
|
+
return params;
|
|
283
|
+
}
|
|
284
|
+
var DEFAULT_LIST_PARAMS = [
|
|
285
|
+
{ name: "page", in: "query", schema: { type: "integer" }, description: "Page number" },
|
|
286
|
+
{ name: "limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
|
|
287
|
+
{ name: "sort", in: "query", schema: { type: "string" }, description: "Sort field (prefix with - for descending)" }
|
|
288
|
+
];
|
|
289
|
+
function generateResourcePaths(resource, apiPrefix = "") {
|
|
290
|
+
const paths = {};
|
|
291
|
+
const basePath = `${apiPrefix}${resource.prefix}`;
|
|
292
|
+
if (resource.disableDefaultRoutes && (!resource.additionalRoutes || resource.additionalRoutes.length === 0)) {
|
|
293
|
+
return paths;
|
|
294
|
+
}
|
|
295
|
+
if (!resource.disableDefaultRoutes) {
|
|
296
|
+
const listParams = resource.openApiSchemas?.listQuery ? convertSchemaToParameters(resource.openApiSchemas.listQuery) : DEFAULT_LIST_PARAMS;
|
|
297
|
+
paths[basePath] = {
|
|
298
|
+
get: createOperation(resource, "list", "List all", {
|
|
299
|
+
parameters: listParams,
|
|
300
|
+
responses: {
|
|
301
|
+
"200": {
|
|
302
|
+
description: "List of items",
|
|
303
|
+
content: {
|
|
304
|
+
"application/json": {
|
|
305
|
+
schema: {
|
|
306
|
+
type: "object",
|
|
307
|
+
properties: {
|
|
308
|
+
success: { type: "boolean" },
|
|
309
|
+
data: { type: "array", items: { $ref: `#/components/schemas/${resource.name}` } },
|
|
310
|
+
pagination: { $ref: "#/components/schemas/Pagination" }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}),
|
|
318
|
+
post: createOperation(resource, "create", "Create new", {
|
|
319
|
+
requestBody: {
|
|
320
|
+
required: true,
|
|
321
|
+
content: {
|
|
322
|
+
"application/json": {
|
|
323
|
+
schema: { $ref: `#/components/schemas/${resource.name}Input` }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
responses: {
|
|
328
|
+
"201": {
|
|
329
|
+
description: "Created successfully",
|
|
330
|
+
content: {
|
|
331
|
+
"application/json": {
|
|
332
|
+
schema: {
|
|
333
|
+
type: "object",
|
|
334
|
+
properties: {
|
|
335
|
+
success: { type: "boolean" },
|
|
336
|
+
data: { $ref: `#/components/schemas/${resource.name}` },
|
|
337
|
+
message: { type: "string" }
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
};
|
|
346
|
+
paths[toOpenApiPath(`${basePath}/:id`)] = {
|
|
347
|
+
get: createOperation(resource, "get", "Get by ID", {
|
|
348
|
+
parameters: [
|
|
349
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } }
|
|
350
|
+
],
|
|
351
|
+
responses: {
|
|
352
|
+
"200": {
|
|
353
|
+
description: "Item found",
|
|
354
|
+
content: {
|
|
355
|
+
"application/json": {
|
|
356
|
+
schema: {
|
|
357
|
+
type: "object",
|
|
358
|
+
properties: {
|
|
359
|
+
success: { type: "boolean" },
|
|
360
|
+
data: { $ref: `#/components/schemas/${resource.name}` }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
},
|
|
366
|
+
"404": { description: "Not found" }
|
|
367
|
+
}
|
|
368
|
+
}),
|
|
369
|
+
patch: createOperation(resource, "update", "Update", {
|
|
370
|
+
parameters: [
|
|
371
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } }
|
|
372
|
+
],
|
|
373
|
+
requestBody: {
|
|
374
|
+
required: true,
|
|
375
|
+
content: {
|
|
376
|
+
"application/json": {
|
|
377
|
+
schema: { $ref: `#/components/schemas/${resource.name}Input` }
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
responses: {
|
|
382
|
+
"200": {
|
|
383
|
+
description: "Updated successfully",
|
|
384
|
+
content: {
|
|
385
|
+
"application/json": {
|
|
386
|
+
schema: {
|
|
387
|
+
type: "object",
|
|
388
|
+
properties: {
|
|
389
|
+
success: { type: "boolean" },
|
|
390
|
+
data: { $ref: `#/components/schemas/${resource.name}` },
|
|
391
|
+
message: { type: "string" }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}),
|
|
399
|
+
delete: createOperation(resource, "delete", "Delete", {
|
|
400
|
+
parameters: [
|
|
401
|
+
{ name: "id", in: "path", required: true, schema: { type: "string" } }
|
|
402
|
+
],
|
|
403
|
+
responses: {
|
|
404
|
+
"200": {
|
|
405
|
+
description: "Deleted successfully",
|
|
406
|
+
content: {
|
|
407
|
+
"application/json": {
|
|
408
|
+
schema: {
|
|
409
|
+
type: "object",
|
|
410
|
+
properties: {
|
|
411
|
+
success: { type: "boolean" },
|
|
412
|
+
message: { type: "string" }
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
})
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
for (const route of resource.additionalRoutes || []) {
|
|
423
|
+
const fullPath = toOpenApiPath(`${basePath}${route.path}`);
|
|
424
|
+
const method = route.method.toLowerCase();
|
|
425
|
+
if (!paths[fullPath]) {
|
|
426
|
+
paths[fullPath] = {};
|
|
427
|
+
}
|
|
428
|
+
const handlerName = typeof route.handler === "string" ? route.handler : "handler";
|
|
429
|
+
const isPublicRoute = route.permissions?._isPublic === true;
|
|
430
|
+
const requiresAuthForRoute = !!route.permissions && !isPublicRoute;
|
|
431
|
+
const extras = {
|
|
432
|
+
parameters: extractPathParams(route.path),
|
|
433
|
+
responses: {
|
|
434
|
+
"200": { description: route.description || "Success" }
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
const routeSchema = route.schema;
|
|
438
|
+
if (routeSchema?.body && ["post", "put", "patch"].includes(method)) {
|
|
439
|
+
extras.requestBody = {
|
|
440
|
+
required: true,
|
|
441
|
+
content: {
|
|
442
|
+
"application/json": {
|
|
443
|
+
schema: routeSchema.body
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (routeSchema?.querystring) {
|
|
449
|
+
const queryParams = convertSchemaToParameters(routeSchema.querystring);
|
|
450
|
+
extras.parameters = [...extras.parameters || [], ...queryParams];
|
|
451
|
+
}
|
|
452
|
+
if (routeSchema?.response) {
|
|
453
|
+
const responseSchemas = routeSchema.response;
|
|
454
|
+
for (const [statusCode, schema] of Object.entries(responseSchemas)) {
|
|
455
|
+
extras.responses[statusCode] = {
|
|
456
|
+
description: schema.description || `Response ${statusCode}`,
|
|
457
|
+
content: {
|
|
458
|
+
"application/json": {
|
|
459
|
+
schema
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
paths[fullPath][method] = createOperation(
|
|
466
|
+
resource,
|
|
467
|
+
handlerName,
|
|
468
|
+
route.summary ?? handlerName,
|
|
469
|
+
extras,
|
|
470
|
+
requiresAuthForRoute
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
return paths;
|
|
474
|
+
}
|
|
475
|
+
function createOperation(resource, operation, summary, extras, requiresAuthOverride) {
|
|
476
|
+
const permissions = resource.permissions || {};
|
|
477
|
+
const operationPermission = permissions[operation];
|
|
478
|
+
const isPublic = operationPermission?._isPublic === true;
|
|
479
|
+
const requiresAuth = requiresAuthOverride !== void 0 ? requiresAuthOverride : typeof operationPermission === "function" && !isPublic;
|
|
480
|
+
return {
|
|
481
|
+
tags: [resource.tag || "Resource"],
|
|
482
|
+
summary: `${summary} ${(resource.displayName || resource.name).toLowerCase()}`,
|
|
483
|
+
operationId: `${resource.name}_${operation}`,
|
|
484
|
+
// Only add security requirement if route requires auth
|
|
485
|
+
...requiresAuth && {
|
|
486
|
+
security: [{ bearerAuth: [] }]
|
|
487
|
+
},
|
|
488
|
+
responses: {
|
|
489
|
+
...requiresAuth && {
|
|
490
|
+
"401": { description: "Unauthorized" },
|
|
491
|
+
"403": { description: "Forbidden" }
|
|
492
|
+
},
|
|
493
|
+
"500": { description: "Internal server error" }
|
|
494
|
+
},
|
|
495
|
+
...extras
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function extractPathParams(path) {
|
|
499
|
+
const params = [];
|
|
500
|
+
const matches = path.matchAll(/:([^/]+)/g);
|
|
501
|
+
for (const match of matches) {
|
|
502
|
+
const paramName = match[1];
|
|
503
|
+
if (paramName) {
|
|
504
|
+
params.push({
|
|
505
|
+
name: paramName,
|
|
506
|
+
in: "path",
|
|
507
|
+
required: true,
|
|
508
|
+
schema: { type: "string" }
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return params;
|
|
513
|
+
}
|
|
514
|
+
function generateSchemas(resources) {
|
|
515
|
+
const schemas = {
|
|
516
|
+
// Common schemas
|
|
517
|
+
Pagination: {
|
|
518
|
+
type: "object",
|
|
519
|
+
properties: {
|
|
520
|
+
page: { type: "integer" },
|
|
521
|
+
limit: { type: "integer" },
|
|
522
|
+
total: { type: "integer" },
|
|
523
|
+
totalPages: { type: "integer" },
|
|
524
|
+
hasNextPage: { type: "boolean" },
|
|
525
|
+
hasPrevPage: { type: "boolean" }
|
|
526
|
+
}
|
|
527
|
+
},
|
|
528
|
+
Error: {
|
|
529
|
+
type: "object",
|
|
530
|
+
properties: {
|
|
531
|
+
success: { type: "boolean", example: false },
|
|
532
|
+
error: { type: "string" },
|
|
533
|
+
code: { type: "string" },
|
|
534
|
+
requestId: { type: "string" },
|
|
535
|
+
timestamp: { type: "string" }
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
for (const resource of resources) {
|
|
540
|
+
const storedSchemas = resource.openApiSchemas;
|
|
541
|
+
if (storedSchemas?.createBody) {
|
|
542
|
+
schemas[resource.name] = {
|
|
543
|
+
type: "object",
|
|
544
|
+
description: resource.displayName,
|
|
545
|
+
...storedSchemas.createBody,
|
|
546
|
+
properties: {
|
|
547
|
+
_id: { type: "string", description: "Unique identifier" },
|
|
548
|
+
...storedSchemas.createBody.properties ?? {},
|
|
549
|
+
createdAt: { type: "string", format: "date-time", description: "Creation timestamp" },
|
|
550
|
+
updatedAt: { type: "string", format: "date-time", description: "Last update timestamp" }
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
schemas[`${resource.name}Input`] = {
|
|
554
|
+
type: "object",
|
|
555
|
+
description: `${resource.displayName} create input`,
|
|
556
|
+
...storedSchemas.createBody
|
|
557
|
+
};
|
|
558
|
+
if (storedSchemas.updateBody) {
|
|
559
|
+
schemas[`${resource.name}Update`] = {
|
|
560
|
+
type: "object",
|
|
561
|
+
description: `${resource.displayName} update input`,
|
|
562
|
+
...storedSchemas.updateBody
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
} else {
|
|
566
|
+
schemas[resource.name] = {
|
|
567
|
+
type: "object",
|
|
568
|
+
description: resource.displayName,
|
|
569
|
+
properties: {
|
|
570
|
+
_id: { type: "string", description: "Unique identifier" },
|
|
571
|
+
createdAt: { type: "string", format: "date-time", description: "Creation timestamp" },
|
|
572
|
+
updatedAt: { type: "string", format: "date-time", description: "Last update timestamp" }
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
schemas[`${resource.name}Input`] = {
|
|
576
|
+
type: "object",
|
|
577
|
+
description: `${resource.displayName} input`
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return schemas;
|
|
582
|
+
}
|
|
583
|
+
var openapi_default = fp(openApiPlugin, {
|
|
584
|
+
name: "arc-openapi",
|
|
585
|
+
fastify: "5.x"
|
|
586
|
+
});
|
|
587
|
+
var scalarPlugin = async (fastify, opts = {}) => {
|
|
588
|
+
const {
|
|
589
|
+
routePrefix = "/docs",
|
|
590
|
+
specUrl = "/_docs/openapi.json",
|
|
591
|
+
title = "API Documentation",
|
|
592
|
+
theme = "default",
|
|
593
|
+
showSidebar = true,
|
|
594
|
+
darkMode = false,
|
|
595
|
+
authRoles = [],
|
|
596
|
+
customCss = "",
|
|
597
|
+
favicon
|
|
598
|
+
} = opts;
|
|
599
|
+
const scalarConfig = JSON.stringify({
|
|
600
|
+
spec: { url: specUrl },
|
|
601
|
+
theme,
|
|
602
|
+
showSidebar,
|
|
603
|
+
darkMode,
|
|
604
|
+
...favicon && { favicon }
|
|
605
|
+
});
|
|
606
|
+
const html = `<!DOCTYPE html>
|
|
607
|
+
<html>
|
|
608
|
+
<head>
|
|
609
|
+
<meta charset="utf-8">
|
|
610
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
611
|
+
<title>${title}</title>
|
|
612
|
+
${favicon ? `<link rel="icon" href="${favicon}">` : ""}
|
|
613
|
+
<style>
|
|
614
|
+
body { margin: 0; padding: 0; }
|
|
615
|
+
${customCss}
|
|
616
|
+
</style>
|
|
617
|
+
</head>
|
|
618
|
+
<body>
|
|
619
|
+
<script id="api-reference" data-url="${specUrl}"></script>
|
|
620
|
+
<script>
|
|
621
|
+
var configuration = ${scalarConfig};
|
|
622
|
+
document.getElementById('api-reference').dataset.configuration = JSON.stringify(configuration);
|
|
623
|
+
</script>
|
|
624
|
+
<script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
|
|
625
|
+
</body>
|
|
626
|
+
</html>`;
|
|
627
|
+
fastify.get(routePrefix, async (request, reply) => {
|
|
628
|
+
if (authRoles.length > 0) {
|
|
629
|
+
const user = request.user;
|
|
630
|
+
const hasRole = authRoles.some((role) => user?.roles?.includes(role));
|
|
631
|
+
if (!hasRole && !user?.roles?.includes("superadmin")) {
|
|
632
|
+
reply.code(403).send({ error: "Access denied" });
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
reply.type("text/html").send(html);
|
|
637
|
+
});
|
|
638
|
+
if (!routePrefix.endsWith("/")) {
|
|
639
|
+
fastify.get(`${routePrefix}/`, async (_, reply) => {
|
|
640
|
+
reply.redirect(routePrefix);
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
fastify.log?.info?.(`Scalar API docs available at ${routePrefix}`);
|
|
644
|
+
};
|
|
645
|
+
var scalar_default = fp(scalarPlugin, {
|
|
646
|
+
name: "arc-scalar",
|
|
647
|
+
fastify: "5.x"
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
export { openapi_default as openApiPlugin, openApiPlugin as openApiPluginFn, scalar_default as scalarPlugin, scalarPlugin as scalarPluginFn };
|