@rebasepro/server-core 0.0.1-canary.4d4fb3e → 0.0.1-canary.ca2cb6e
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/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
- package/dist/common/src/util/entities.d.ts +22 -0
- package/dist/common/src/util/relations.d.ts +14 -4
- package/dist/common/src/util/resolutions.d.ts +1 -1
- package/dist/index-DXVBFp5V.js +37 -0
- package/dist/index-DXVBFp5V.js.map +1 -0
- package/dist/index.es.js +49931 -55
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +28475 -34569
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
- package/dist/server-core/src/api/types.d.ts +1 -1
- package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
- package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
- package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
- package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
- package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
- package/dist/server-core/src/auth/index.d.ts +13 -2
- package/dist/server-core/src/auth/interfaces.d.ts +45 -6
- package/dist/server-core/src/auth/jwt.d.ts +1 -0
- package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
- package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
- package/dist/server-core/src/auth/middleware.d.ts +36 -11
- package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
- package/dist/server-core/src/auth/routes.d.ts +13 -3
- package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
- package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
- package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
- package/dist/server-core/src/cron/cron-store.d.ts +32 -0
- package/dist/server-core/src/cron/index.d.ts +6 -0
- package/dist/server-core/src/email/index.d.ts +2 -2
- package/dist/server-core/src/email/templates.d.ts +9 -0
- package/dist/server-core/src/email/types.d.ts +20 -23
- package/dist/server-core/src/history/history-routes.d.ts +2 -2
- package/dist/server-core/src/index.d.ts +5 -0
- package/dist/server-core/src/init.d.ts +112 -2
- package/dist/server-core/src/singleton.d.ts +35 -0
- package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
- package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
- package/dist/server-core/src/storage/index.d.ts +18 -11
- package/dist/server-core/src/storage/routes.d.ts +3 -3
- package/dist/server-core/src/storage/types.d.ts +28 -16
- package/dist/server-core/src/utils/dev-port.d.ts +35 -0
- package/dist/server-core/src/utils/logger.d.ts +31 -0
- package/dist/server-core/src/utils/request-logger.d.ts +19 -0
- package/dist/types/src/controllers/auth.d.ts +2 -0
- package/dist/types/src/controllers/client.d.ts +119 -7
- package/dist/types/src/controllers/collection_registry.d.ts +4 -3
- package/dist/types/src/controllers/customization_controller.d.ts +7 -1
- package/dist/types/src/controllers/data.d.ts +34 -7
- package/dist/types/src/controllers/data_driver.d.ts +20 -28
- package/dist/types/src/controllers/database_admin.d.ts +2 -2
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +1 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
- package/dist/types/src/controllers/navigation.d.ts +5 -5
- package/dist/types/src/controllers/registry.d.ts +6 -3
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
- package/dist/types/src/controllers/storage.d.ts +24 -26
- package/dist/types/src/rebase_context.d.ts +8 -4
- package/dist/types/src/types/backend.d.ts +4 -1
- package/dist/types/src/types/builders.d.ts +5 -4
- package/dist/types/src/types/chips.d.ts +1 -1
- package/dist/types/src/types/collections.d.ts +169 -125
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +1 -1
- package/dist/types/src/types/entity_actions.d.ts +8 -8
- package/dist/types/src/types/entity_callbacks.d.ts +15 -15
- package/dist/types/src/types/entity_link_builder.d.ts +1 -1
- package/dist/types/src/types/entity_overrides.d.ts +2 -1
- package/dist/types/src/types/entity_views.d.ts +8 -8
- package/dist/types/src/types/export_import.d.ts +3 -3
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/plugins.d.ts +72 -18
- package/dist/types/src/types/properties.d.ts +118 -33
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/slots.d.ts +30 -6
- package/dist/types/src/types/translations.d.ts +44 -0
- package/dist/types/src/types/user_management_delegate.d.ts +1 -0
- package/package.json +84 -84
- package/scratch.ts +6 -5
- package/src/api/ast-schema-editor.ts +11 -11
- package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
- package/src/api/errors.ts +38 -14
- package/src/api/graphql/graphql-schema-generator.ts +5 -3
- package/src/api/openapi-generator.ts +613 -58
- package/src/api/rest/api-generator.ts +17 -11
- package/src/api/rest/query-parser.ts +1 -1
- package/src/api/schema-editor-routes.ts +2 -0
- package/src/api/server.ts +8 -5
- package/src/api/types.ts +1 -1
- package/src/auth/admin-routes.ts +58 -17
- package/src/auth/apple-oauth.ts +130 -0
- package/src/auth/bitbucket-oauth.ts +82 -0
- package/src/auth/discord-oauth.ts +83 -0
- package/src/auth/facebook-oauth.ts +72 -0
- package/src/auth/github-oauth.ts +110 -0
- package/src/auth/gitlab-oauth.ts +70 -0
- package/src/auth/google-oauth.ts +34 -46
- package/src/auth/index.ts +15 -2
- package/src/auth/interfaces.ts +54 -7
- package/src/auth/jwt.ts +22 -5
- package/src/auth/linkedin-oauth.ts +81 -0
- package/src/auth/microsoft-oauth.ts +88 -0
- package/src/auth/middleware.ts +194 -45
- package/src/auth/password.ts +3 -1
- package/src/auth/rate-limiter.ts +4 -4
- package/src/auth/routes.ts +197 -139
- package/src/auth/slack-oauth.ts +71 -0
- package/src/auth/spotify-oauth.ts +67 -0
- package/src/auth/twitter-oauth.ts +120 -0
- package/src/collections/loader.ts +6 -6
- package/src/cron/cron-loader.ts +89 -0
- package/src/cron/cron-routes.test.ts +265 -0
- package/src/cron/cron-routes.ts +85 -0
- package/src/cron/cron-scheduler.test.ts +421 -0
- package/src/cron/cron-scheduler.ts +413 -0
- package/src/cron/cron-store.ts +163 -0
- package/src/cron/index.ts +6 -0
- package/src/db/interfaces.ts +2 -2
- package/src/email/index.ts +3 -2
- package/src/email/smtp-email-service.ts +5 -2
- package/src/email/templates.ts +93 -6
- package/src/email/types.ts +17 -24
- package/src/functions/function-loader.ts +34 -6
- package/src/functions/function-routes.ts +2 -2
- package/src/history/history-routes.ts +4 -3
- package/src/index.ts +10 -0
- package/src/init.ts +475 -57
- package/src/services/driver-registry.ts +1 -1
- package/src/singleton.test.ts +28 -0
- package/src/singleton.ts +70 -0
- package/src/storage/LocalStorageController.ts +50 -53
- package/src/storage/S3StorageController.ts +49 -46
- package/src/storage/index.ts +27 -16
- package/src/storage/routes.ts +76 -59
- package/src/storage/storage-registry.ts +5 -5
- package/src/storage/types.ts +44 -32
- package/src/types/index.ts +4 -4
- package/src/utils/dev-port.ts +176 -0
- package/src/utils/logger.ts +143 -0
- package/src/utils/logging.ts +5 -2
- package/src/utils/request-logger.ts +66 -0
- package/test/admin-routes.test.ts +114 -65
- package/test/api-generator.test.ts +73 -30
- package/test/ast-schema-editor.test.ts +5 -3
- package/test/auth-middleware-hono.test.ts +268 -33
- package/test/auth-routes.test.ts +325 -146
- package/test/driver-registry.test.ts +4 -2
- package/test/error-propagation.test.ts +226 -0
- package/test/errors.test.ts +12 -7
- package/test/jwt-security.test.ts +18 -9
- package/test/jwt.test.ts +26 -13
- package/test/middleware.test.ts +13 -8
- package/test/query-parser.test.ts +13 -8
- package/test/rate-limiter.test.ts +7 -7
- package/test/safe-compare.test.ts +66 -0
- package/test/singleton.test.ts +59 -0
- package/test/storage-local.test.ts +36 -43
- package/test/storage-registry.test.ts +38 -36
- package/test/storage-routes.test.ts +9 -5
- package/test/storage-s3.test.ts +51 -48
- package/test.ts +6 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +7 -5
- package/dist/index-BeMqpmfQ.js +0 -239
- package/dist/index-BeMqpmfQ.js.map +0 -1
- package/dist/index-bl4J3lNb.js +0 -55823
- package/dist/index-bl4J3lNb.js.map +0 -1
- package/vite.config.ts.timestamp-1775065397568-8a853255edf6e.mjs +0 -46
|
@@ -1,12 +1,36 @@
|
|
|
1
|
-
import { EntityCollection, Property } from "@rebasepro/types";
|
|
1
|
+
import { EntityCollection, Property, StringProperty, NumberProperty, ArrayProperty, MapProperty, Relation } from "@rebasepro/types";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
/**
|
|
4
|
+
* OpenAPI 3.0.3 specification generator.
|
|
5
|
+
*
|
|
6
|
+
* Produces a spec that exactly mirrors the REST API consumed by the
|
|
7
|
+
* Rebase SDK client (`@rebasepro/client`).
|
|
8
|
+
*
|
|
9
|
+
* Routes are mounted at `{basePath}/data/{slug}` by `initializeRebaseBackend`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface OpenApiGeneratorOptions {
|
|
13
|
+
/** Base path for the API (e.g. "/api"). Defaults to "/api". */
|
|
14
|
+
basePath?: string;
|
|
15
|
+
/** Whether auth is enabled on data routes. Defaults to true. */
|
|
16
|
+
requireAuth?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function generateOpenApiSpec(
|
|
20
|
+
collections: EntityCollection[],
|
|
21
|
+
options: OpenApiGeneratorOptions = {}
|
|
22
|
+
): Record<string, unknown> {
|
|
23
|
+
const basePath = options.basePath ?? "/api";
|
|
24
|
+
const requireAuth = options.requireAuth ?? true;
|
|
25
|
+
|
|
26
|
+
const spec: Record<string, unknown> = {
|
|
27
|
+
openapi: "3.0.3",
|
|
6
28
|
info: {
|
|
7
|
-
title: "Rebase
|
|
29
|
+
title: "Rebase API",
|
|
8
30
|
version: "1.0.0",
|
|
9
|
-
description:
|
|
31
|
+
description:
|
|
32
|
+
"Auto-generated REST API from Rebase collection definitions. " +
|
|
33
|
+
"This is the same API consumed by the `@rebasepro/client` SDK."
|
|
10
34
|
},
|
|
11
35
|
servers: [
|
|
12
36
|
{
|
|
@@ -16,39 +40,147 @@ export function generateOpenApiSpec(collections: EntityCollection[], basePath: s
|
|
|
16
40
|
],
|
|
17
41
|
paths: {} as Record<string, unknown>,
|
|
18
42
|
components: {
|
|
19
|
-
schemas: {
|
|
20
|
-
|
|
43
|
+
schemas: {
|
|
44
|
+
ErrorResponse: {
|
|
45
|
+
type: "object",
|
|
46
|
+
properties: {
|
|
47
|
+
error: {
|
|
48
|
+
type: "object",
|
|
49
|
+
required: ["message", "code"],
|
|
50
|
+
properties: {
|
|
51
|
+
message: { type: "string" },
|
|
52
|
+
code: { type: "string" },
|
|
53
|
+
details: {}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
PaginationMeta: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
total: { type: "integer",
|
|
62
|
+
description: "Total number of matching records" },
|
|
63
|
+
limit: { type: "integer",
|
|
64
|
+
description: "Page size used for this query" },
|
|
65
|
+
offset: { type: "integer",
|
|
66
|
+
description: "Number of records skipped" },
|
|
67
|
+
hasMore: { type: "boolean",
|
|
68
|
+
description: "Whether more records exist beyond this page" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} as Record<string, unknown>,
|
|
72
|
+
securitySchemes: {} as Record<string, unknown>
|
|
73
|
+
},
|
|
74
|
+
tags: [] as Array<{ name: string; description?: string }>
|
|
21
75
|
};
|
|
22
76
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
spec.components.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
77
|
+
// ── Security Schemes ─────────────────────────────────────────────────
|
|
78
|
+
if (requireAuth) {
|
|
79
|
+
(spec.components as Record<string, unknown>).securitySchemes = {
|
|
80
|
+
bearerAuth: {
|
|
81
|
+
type: "http",
|
|
82
|
+
scheme: "bearer",
|
|
83
|
+
bearerFormat: "JWT",
|
|
84
|
+
description:
|
|
85
|
+
"JWT access token obtained from `POST /auth/login` or `POST /auth/register`. " +
|
|
86
|
+
"Can also be a static service key for server-to-server authentication."
|
|
87
|
+
},
|
|
88
|
+
queryToken: {
|
|
89
|
+
type: "apiKey",
|
|
90
|
+
in: "query",
|
|
91
|
+
name: "token",
|
|
92
|
+
description: "Alternative: pass the JWT or service key as a `token` query parameter."
|
|
35
93
|
}
|
|
36
94
|
};
|
|
95
|
+
(spec as Record<string, unknown>).security = [
|
|
96
|
+
{ bearerAuth: [] },
|
|
97
|
+
{ queryToken: [] }
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const paths = spec.paths as Record<string, unknown>;
|
|
102
|
+
const schemas = (spec.components as Record<string, unknown>).schemas as Record<string, unknown>;
|
|
103
|
+
const tags = spec.tags as Array<{ name: string; description?: string }>;
|
|
104
|
+
|
|
105
|
+
// ── Collection routes ────────────────────────────────────────────────
|
|
106
|
+
for (const collection of (collections || [])) {
|
|
107
|
+
const schemaName = toPascalCase(collection.singularName || collection.name);
|
|
108
|
+
const slug = collection.slug;
|
|
109
|
+
|
|
110
|
+
tags.push({
|
|
111
|
+
name: collection.name,
|
|
112
|
+
description: collection.description || `CRUD operations for ${collection.name}`
|
|
113
|
+
});
|
|
37
114
|
|
|
38
|
-
|
|
115
|
+
// Build component schema for this collection
|
|
116
|
+
schemas[schemaName] = buildCollectionSchema(collection);
|
|
39
117
|
|
|
40
|
-
|
|
118
|
+
// Build an "input" schema (no read-only/auto fields like autoValue dates)
|
|
119
|
+
schemas[`${schemaName}Input`] = buildCollectionInputSchema(collection);
|
|
120
|
+
|
|
121
|
+
const dataPath = `/data/${slug}`;
|
|
122
|
+
|
|
123
|
+
// ── GET /data/{slug} — List entities ──────────────────────────
|
|
124
|
+
paths[dataPath] = {
|
|
41
125
|
get: {
|
|
126
|
+
tags: [collection.name],
|
|
42
127
|
summary: `List ${collection.name}`,
|
|
128
|
+
operationId: `list${schemaName}`,
|
|
43
129
|
parameters: [
|
|
44
|
-
{ name: "limit",
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
130
|
+
{ name: "limit",
|
|
131
|
+
in: "query",
|
|
132
|
+
schema: { type: "integer",
|
|
133
|
+
default: 20,
|
|
134
|
+
maximum: 100 },
|
|
135
|
+
description: "Maximum number of records to return" },
|
|
136
|
+
{ name: "offset",
|
|
137
|
+
in: "query",
|
|
138
|
+
schema: { type: "integer",
|
|
139
|
+
default: 0 },
|
|
140
|
+
description: "Number of records to skip" },
|
|
141
|
+
{ name: "page",
|
|
142
|
+
in: "query",
|
|
143
|
+
schema: { type: "integer",
|
|
144
|
+
minimum: 1 },
|
|
145
|
+
description: "Page number (alternative to offset). Calculates offset as (page-1)*limit" },
|
|
146
|
+
{
|
|
147
|
+
name: "orderBy",
|
|
148
|
+
in: "query",
|
|
149
|
+
schema: { type: "string" },
|
|
150
|
+
description: "Sort field and direction. Accepts `field:asc` or `field:desc`, or a JSON array `[{\"field\":\"name\",\"direction\":\"asc\"}]`",
|
|
151
|
+
example: "created_at:desc"
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: "where",
|
|
155
|
+
in: "query",
|
|
156
|
+
schema: { type: "string" },
|
|
157
|
+
description: "JSON object filter. Example: `{\"status\":[\"==\",\"active\"]}`"
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: "include",
|
|
161
|
+
in: "query",
|
|
162
|
+
schema: { type: "string" },
|
|
163
|
+
description: "Comma-separated list of relations to include (eager-load). Use `*` for all relations.",
|
|
164
|
+
example: "author,tags"
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: "fields",
|
|
168
|
+
in: "query",
|
|
169
|
+
schema: { type: "string" },
|
|
170
|
+
description: "Comma-separated list of fields to return (field selection)",
|
|
171
|
+
example: "id,name,created_at"
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "searchString",
|
|
175
|
+
in: "query",
|
|
176
|
+
schema: { type: "string" },
|
|
177
|
+
description: "Full-text search query"
|
|
178
|
+
},
|
|
179
|
+
...buildFilterParameters(collection)
|
|
48
180
|
],
|
|
49
181
|
responses: {
|
|
50
182
|
200: {
|
|
51
|
-
description: "
|
|
183
|
+
description: "Paginated list of entities",
|
|
52
184
|
content: {
|
|
53
185
|
"application/json": {
|
|
54
186
|
schema: {
|
|
@@ -57,104 +189,527 @@ export function generateOpenApiSpec(collections: EntityCollection[], basePath: s
|
|
|
57
189
|
data: {
|
|
58
190
|
type: "array",
|
|
59
191
|
items: { $ref: `#/components/schemas/${schemaName}` }
|
|
60
|
-
}
|
|
192
|
+
},
|
|
193
|
+
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
|
61
194
|
}
|
|
62
195
|
}
|
|
63
196
|
}
|
|
64
197
|
}
|
|
65
|
-
}
|
|
198
|
+
},
|
|
199
|
+
...errorResponses(requireAuth)
|
|
66
200
|
}
|
|
67
201
|
},
|
|
68
202
|
post: {
|
|
69
|
-
|
|
203
|
+
tags: [collection.name],
|
|
204
|
+
summary: `Create ${collection.singularName || collection.name}`,
|
|
205
|
+
operationId: `create${schemaName}`,
|
|
70
206
|
requestBody: {
|
|
207
|
+
required: true,
|
|
71
208
|
content: {
|
|
72
209
|
"application/json": {
|
|
73
|
-
schema: { $ref: `#/components/schemas/${schemaName}` }
|
|
210
|
+
schema: { $ref: `#/components/schemas/${schemaName}Input` }
|
|
74
211
|
}
|
|
75
212
|
}
|
|
76
213
|
},
|
|
77
214
|
responses: {
|
|
78
215
|
201: {
|
|
79
|
-
description: "Created",
|
|
216
|
+
description: "Created entity",
|
|
80
217
|
content: {
|
|
81
218
|
"application/json": {
|
|
82
219
|
schema: { $ref: `#/components/schemas/${schemaName}` }
|
|
83
220
|
}
|
|
84
221
|
}
|
|
85
|
-
}
|
|
222
|
+
},
|
|
223
|
+
...errorResponses(requireAuth)
|
|
86
224
|
}
|
|
87
225
|
}
|
|
88
226
|
};
|
|
89
227
|
|
|
90
|
-
|
|
228
|
+
// ── GET/PUT/DELETE /data/{slug}/{id} ──────────────────────────
|
|
229
|
+
const entityPath = `/data/${slug}/{id}`;
|
|
230
|
+
paths[entityPath] = {
|
|
91
231
|
get: {
|
|
92
|
-
|
|
232
|
+
tags: [collection.name],
|
|
233
|
+
summary: `Get ${collection.singularName || collection.name} by ID`,
|
|
234
|
+
operationId: `get${schemaName}ById`,
|
|
93
235
|
parameters: [
|
|
94
|
-
{ name: "id",
|
|
236
|
+
{ name: "id",
|
|
237
|
+
in: "path",
|
|
238
|
+
required: true,
|
|
239
|
+
schema: { type: "string" },
|
|
240
|
+
description: "Entity ID" },
|
|
241
|
+
{
|
|
242
|
+
name: "include",
|
|
243
|
+
in: "query",
|
|
244
|
+
schema: { type: "string" },
|
|
245
|
+
description: "Comma-separated list of relations to include",
|
|
246
|
+
example: "author,tags"
|
|
247
|
+
}
|
|
95
248
|
],
|
|
96
249
|
responses: {
|
|
97
250
|
200: {
|
|
98
|
-
description: "
|
|
251
|
+
description: "Entity found",
|
|
99
252
|
content: {
|
|
100
253
|
"application/json": {
|
|
101
254
|
schema: { $ref: `#/components/schemas/${schemaName}` }
|
|
102
255
|
}
|
|
103
256
|
}
|
|
104
|
-
}
|
|
257
|
+
},
|
|
258
|
+
404: { description: "Entity not found",
|
|
259
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
260
|
+
...errorResponses(requireAuth)
|
|
105
261
|
}
|
|
106
262
|
},
|
|
107
263
|
put: {
|
|
108
|
-
|
|
264
|
+
tags: [collection.name],
|
|
265
|
+
summary: `Update ${collection.singularName || collection.name}`,
|
|
266
|
+
operationId: `update${schemaName}`,
|
|
109
267
|
parameters: [
|
|
110
|
-
{ name: "id",
|
|
268
|
+
{ name: "id",
|
|
269
|
+
in: "path",
|
|
270
|
+
required: true,
|
|
271
|
+
schema: { type: "string" },
|
|
272
|
+
description: "Entity ID" }
|
|
111
273
|
],
|
|
112
274
|
requestBody: {
|
|
275
|
+
required: true,
|
|
113
276
|
content: {
|
|
114
277
|
"application/json": {
|
|
115
|
-
schema: { $ref: `#/components/schemas/${schemaName}` }
|
|
278
|
+
schema: { $ref: `#/components/schemas/${schemaName}Input` }
|
|
116
279
|
}
|
|
117
280
|
}
|
|
118
281
|
},
|
|
119
282
|
responses: {
|
|
120
283
|
200: {
|
|
121
|
-
description: "Updated",
|
|
284
|
+
description: "Updated entity",
|
|
122
285
|
content: {
|
|
123
286
|
"application/json": {
|
|
124
287
|
schema: { $ref: `#/components/schemas/${schemaName}` }
|
|
125
288
|
}
|
|
126
289
|
}
|
|
127
|
-
}
|
|
290
|
+
},
|
|
291
|
+
404: { description: "Entity not found",
|
|
292
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
293
|
+
...errorResponses(requireAuth)
|
|
128
294
|
}
|
|
129
295
|
},
|
|
130
296
|
delete: {
|
|
131
|
-
|
|
297
|
+
tags: [collection.name],
|
|
298
|
+
summary: `Delete ${collection.singularName || collection.name}`,
|
|
299
|
+
operationId: `delete${schemaName}`,
|
|
132
300
|
parameters: [
|
|
133
|
-
{ name: "id",
|
|
301
|
+
{ name: "id",
|
|
302
|
+
in: "path",
|
|
303
|
+
required: true,
|
|
304
|
+
schema: { type: "string" },
|
|
305
|
+
description: "Entity ID" }
|
|
134
306
|
],
|
|
135
307
|
responses: {
|
|
136
|
-
204: { description: "Deleted" }
|
|
308
|
+
204: { description: "Deleted successfully" },
|
|
309
|
+
404: { description: "Entity not found",
|
|
310
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } } },
|
|
311
|
+
...errorResponses(requireAuth)
|
|
137
312
|
}
|
|
138
313
|
}
|
|
139
314
|
};
|
|
140
|
-
|
|
315
|
+
|
|
316
|
+
// ── Subcollection routes ──────────────────────────────────────
|
|
317
|
+
const relations = (collection as EntityCollection & { relations?: Relation[] }).relations;
|
|
318
|
+
if (relations && relations.length > 0) {
|
|
319
|
+
for (const relation of relations) {
|
|
320
|
+
const relationName = relation.relationName;
|
|
321
|
+
if (!relationName) continue;
|
|
322
|
+
|
|
323
|
+
let targetName: string;
|
|
324
|
+
try {
|
|
325
|
+
const targetCollection = relation.target();
|
|
326
|
+
targetName = targetCollection.singularName || targetCollection.name;
|
|
327
|
+
} catch {
|
|
328
|
+
targetName = relationName;
|
|
329
|
+
}
|
|
330
|
+
const targetSchema = toPascalCase(targetName);
|
|
331
|
+
|
|
332
|
+
const subPath = `/data/${slug}/{parentId}/${relationName}`;
|
|
333
|
+
|
|
334
|
+
// Only add if the schema exists (target collection is also registered)
|
|
335
|
+
paths[subPath] = {
|
|
336
|
+
get: {
|
|
337
|
+
tags: [collection.name],
|
|
338
|
+
summary: `List ${relationName} for a ${collection.singularName || collection.name}`,
|
|
339
|
+
operationId: `list${schemaName}${toPascalCase(relationName)}`,
|
|
340
|
+
parameters: [
|
|
341
|
+
{ name: "parentId",
|
|
342
|
+
in: "path",
|
|
343
|
+
required: true,
|
|
344
|
+
schema: { type: "string" },
|
|
345
|
+
description: `${collection.singularName || collection.name} ID` },
|
|
346
|
+
{ name: "limit",
|
|
347
|
+
in: "query",
|
|
348
|
+
schema: { type: "integer",
|
|
349
|
+
default: 20 } },
|
|
350
|
+
{ name: "offset",
|
|
351
|
+
in: "query",
|
|
352
|
+
schema: { type: "integer",
|
|
353
|
+
default: 0 } },
|
|
354
|
+
{ name: "orderBy",
|
|
355
|
+
in: "query",
|
|
356
|
+
schema: { type: "string" } },
|
|
357
|
+
{ name: "searchString",
|
|
358
|
+
in: "query",
|
|
359
|
+
schema: { type: "string" } }
|
|
360
|
+
],
|
|
361
|
+
responses: {
|
|
362
|
+
200: {
|
|
363
|
+
description: `List of related ${relationName}`,
|
|
364
|
+
content: {
|
|
365
|
+
"application/json": {
|
|
366
|
+
schema: {
|
|
367
|
+
type: "object",
|
|
368
|
+
properties: {
|
|
369
|
+
data: {
|
|
370
|
+
type: "array",
|
|
371
|
+
items: schemas[targetSchema]
|
|
372
|
+
? { $ref: `#/components/schemas/${targetSchema}` }
|
|
373
|
+
: { type: "object" }
|
|
374
|
+
},
|
|
375
|
+
meta: { $ref: "#/components/schemas/PaginationMeta" }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
...errorResponses(requireAuth)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
141
388
|
|
|
142
389
|
return spec;
|
|
143
390
|
}
|
|
144
391
|
|
|
145
|
-
|
|
392
|
+
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Build the component schema for a collection (output / read shape).
|
|
396
|
+
* All fields are included (including relation foreign keys).
|
|
397
|
+
*/
|
|
398
|
+
function buildCollectionSchema(collection: EntityCollection): Record<string, unknown> {
|
|
399
|
+
const properties: Record<string, unknown> = {
|
|
400
|
+
id: { type: "string",
|
|
401
|
+
description: "Unique identifier" }
|
|
402
|
+
};
|
|
403
|
+
const required: string[] = ["id"];
|
|
404
|
+
|
|
405
|
+
for (const [key, property] of Object.entries(collection.properties)) {
|
|
406
|
+
// Skip relation properties — they are virtual and not part of the REST payload
|
|
407
|
+
if (property.type === "relation") continue;
|
|
408
|
+
|
|
409
|
+
properties[key] = convertPropertyToSchema(property);
|
|
410
|
+
|
|
411
|
+
if (property.validation?.required) {
|
|
412
|
+
required.push(key);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
type: "object",
|
|
418
|
+
required: required.length > 0 ? required : undefined,
|
|
419
|
+
properties
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Build an input schema (for POST/PUT) — excludes auto-generated fields.
|
|
425
|
+
*/
|
|
426
|
+
function buildCollectionInputSchema(collection: EntityCollection): Record<string, unknown> {
|
|
427
|
+
const properties: Record<string, unknown> = {};
|
|
428
|
+
const required: string[] = [];
|
|
429
|
+
|
|
430
|
+
for (const [key, property] of Object.entries(collection.properties)) {
|
|
431
|
+
if (property.type === "relation") continue;
|
|
432
|
+
|
|
433
|
+
// Skip auto-value date fields from the input schema
|
|
434
|
+
if (property.type === "date" && property.autoValue) continue;
|
|
435
|
+
|
|
436
|
+
// Skip auto-generated ID fields
|
|
437
|
+
if ("isId" in property && property.isId && property.isId !== "manual" && property.isId !== true) continue;
|
|
438
|
+
|
|
439
|
+
properties[key] = convertPropertyToSchema(property);
|
|
440
|
+
|
|
441
|
+
if (property.validation?.required) {
|
|
442
|
+
required.push(key);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// Allow explicit ID for create (optional)
|
|
447
|
+
properties["id"] = {
|
|
448
|
+
type: "string",
|
|
449
|
+
description: "Optional: client-assigned ID. If omitted, the server generates one."
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
type: "object",
|
|
454
|
+
required: required.length > 0 ? required : undefined,
|
|
455
|
+
properties
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Convert a Rebase Property to an OpenAPI 3.0 schema object.
|
|
461
|
+
*/
|
|
462
|
+
function convertPropertyToSchema(property: Property): Record<string, unknown> {
|
|
463
|
+
const base: Record<string, unknown> = {};
|
|
464
|
+
|
|
465
|
+
if (property.name) {
|
|
466
|
+
base.description = property.name;
|
|
467
|
+
}
|
|
468
|
+
|
|
146
469
|
switch (property.type) {
|
|
147
|
-
case "string":
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
470
|
+
case "string": {
|
|
471
|
+
const sp = property as StringProperty;
|
|
472
|
+
base.type = "string";
|
|
473
|
+
|
|
474
|
+
if (sp.enum) {
|
|
475
|
+
const enumValues = resolveEnumValues(sp.enum);
|
|
476
|
+
if (enumValues.length > 0) {
|
|
477
|
+
base.enum = enumValues;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
if (sp.validation) {
|
|
482
|
+
if (sp.validation.min !== undefined) base.minLength = sp.validation.min;
|
|
483
|
+
if (sp.validation.max !== undefined) base.maxLength = sp.validation.max;
|
|
484
|
+
if (sp.validation.length !== undefined) {
|
|
485
|
+
base.minLength = sp.validation.length;
|
|
486
|
+
base.maxLength = sp.validation.length;
|
|
487
|
+
}
|
|
488
|
+
if (sp.validation.matches !== undefined) {
|
|
489
|
+
base.pattern = String(sp.validation.matches);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (sp.email) base.format = "email";
|
|
494
|
+
if (sp.url) base.format = "uri";
|
|
495
|
+
if (sp.storage) base.format = "uri";
|
|
496
|
+
if (sp.markdown) base.description = (base.description || "") + " (Markdown)";
|
|
497
|
+
|
|
498
|
+
return base;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
case "number": {
|
|
502
|
+
const np = property as NumberProperty;
|
|
503
|
+
const isInteger = np.validation?.integer || np.columnType === "integer" || np.columnType === "serial" || np.columnType === "bigserial" || np.columnType === "bigint";
|
|
504
|
+
base.type = isInteger ? "integer" : "number";
|
|
505
|
+
|
|
506
|
+
if (np.enum) {
|
|
507
|
+
const enumValues = resolveEnumValues(np.enum);
|
|
508
|
+
if (enumValues.length > 0) {
|
|
509
|
+
base.enum = enumValues;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
if (np.validation) {
|
|
514
|
+
if (np.validation.min !== undefined) base.minimum = np.validation.min;
|
|
515
|
+
if (np.validation.max !== undefined) base.maximum = np.validation.max;
|
|
516
|
+
if (np.validation.moreThan !== undefined) {
|
|
517
|
+
base.minimum = np.validation.moreThan;
|
|
518
|
+
base.exclusiveMinimum = true;
|
|
519
|
+
}
|
|
520
|
+
if (np.validation.lessThan !== undefined) {
|
|
521
|
+
base.maximum = np.validation.lessThan;
|
|
522
|
+
base.exclusiveMaximum = true;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return base;
|
|
527
|
+
}
|
|
528
|
+
|
|
151
529
|
case "boolean":
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
case "
|
|
156
|
-
|
|
530
|
+
base.type = "boolean";
|
|
531
|
+
return base;
|
|
532
|
+
|
|
533
|
+
case "date": {
|
|
534
|
+
base.type = "string";
|
|
535
|
+
if (property.mode === "date") {
|
|
536
|
+
base.format = "date";
|
|
537
|
+
} else {
|
|
538
|
+
base.format = "date-time";
|
|
539
|
+
}
|
|
540
|
+
if (property.autoValue) {
|
|
541
|
+
base.readOnly = true;
|
|
542
|
+
base.description = (base.description || "") +
|
|
543
|
+
(property.autoValue === "on_create" ? " (Auto-set on creation)" : " (Auto-updated)");
|
|
544
|
+
}
|
|
545
|
+
return base;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
case "geopoint":
|
|
549
|
+
base.type = "object";
|
|
550
|
+
base.properties = {
|
|
551
|
+
latitude: { type: "number" },
|
|
552
|
+
longitude: { type: "number" }
|
|
553
|
+
};
|
|
554
|
+
base.required = ["latitude", "longitude"];
|
|
555
|
+
return base;
|
|
556
|
+
|
|
557
|
+
case "reference":
|
|
558
|
+
base.type = "string";
|
|
559
|
+
base.description = (base.description || "") + " (Reference ID)";
|
|
560
|
+
return base;
|
|
561
|
+
|
|
562
|
+
case "array": {
|
|
563
|
+
const ap = property as ArrayProperty;
|
|
564
|
+
base.type = "array";
|
|
565
|
+
|
|
566
|
+
if (ap.oneOf) {
|
|
567
|
+
// Discriminated union (e.g., content blocks)
|
|
568
|
+
const typeField = ap.oneOf.typeField || "type";
|
|
569
|
+
const valueField = ap.oneOf.valueField || "value";
|
|
570
|
+
const variants: Record<string, unknown>[] = [];
|
|
571
|
+
|
|
572
|
+
for (const [variantKey, variantProp] of Object.entries(ap.oneOf.properties)) {
|
|
573
|
+
variants.push({
|
|
574
|
+
type: "object",
|
|
575
|
+
properties: {
|
|
576
|
+
[typeField]: { type: "string",
|
|
577
|
+
enum: [variantKey] },
|
|
578
|
+
[valueField]: convertPropertyToSchema(variantProp)
|
|
579
|
+
},
|
|
580
|
+
required: [typeField, valueField]
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
base.items = { oneOf: variants };
|
|
585
|
+
} else if (ap.of) {
|
|
586
|
+
if (Array.isArray(ap.of)) {
|
|
587
|
+
base.items = { oneOf: ap.of.map(p => convertPropertyToSchema(p)) };
|
|
588
|
+
} else {
|
|
589
|
+
base.items = convertPropertyToSchema(ap.of);
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
base.items = {};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (ap.validation) {
|
|
596
|
+
if (ap.validation.min !== undefined) base.minItems = ap.validation.min;
|
|
597
|
+
if (ap.validation.max !== undefined) base.maxItems = ap.validation.max;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
return base;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
case "map": {
|
|
604
|
+
const mp = property as MapProperty;
|
|
605
|
+
base.type = "object";
|
|
606
|
+
|
|
607
|
+
if (mp.properties) {
|
|
608
|
+
const props: Record<string, unknown> = {};
|
|
609
|
+
const req: string[] = [];
|
|
610
|
+
|
|
611
|
+
for (const [key, subProp] of Object.entries(mp.properties)) {
|
|
612
|
+
props[key] = convertPropertyToSchema(subProp);
|
|
613
|
+
if (subProp.validation?.required) {
|
|
614
|
+
req.push(key);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
base.properties = props;
|
|
619
|
+
if (req.length > 0) base.required = req;
|
|
620
|
+
} else if (mp.keyValue) {
|
|
621
|
+
base.additionalProperties = true;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
return base;
|
|
625
|
+
}
|
|
626
|
+
|
|
157
627
|
default:
|
|
158
|
-
|
|
628
|
+
base.type = "string";
|
|
629
|
+
return base;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Resolve EnumValues (array or record) into a flat array of enum values.
|
|
635
|
+
*/
|
|
636
|
+
function resolveEnumValues(enumDef: Record<string | number, unknown> | Array<{ id: string | number }>): Array<string | number> {
|
|
637
|
+
if (Array.isArray(enumDef)) {
|
|
638
|
+
return enumDef.map(e => (typeof e === "object" && e !== null && "id" in e) ? e.id : e as string | number);
|
|
639
|
+
}
|
|
640
|
+
return Object.keys(enumDef).map(k => {
|
|
641
|
+
// Preserve numeric keys as numbers
|
|
642
|
+
const num = Number(k);
|
|
643
|
+
return isNaN(num) ? k : num;
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Build PostgREST-style filter parameters for a collection.
|
|
649
|
+
* These are additional query parameters like `?status=eq.active&price=gte.100`.
|
|
650
|
+
*/
|
|
651
|
+
function buildFilterParameters(collection: EntityCollection): Array<Record<string, unknown>> {
|
|
652
|
+
const params: Array<Record<string, unknown>> = [];
|
|
653
|
+
|
|
654
|
+
for (const [key, property] of Object.entries(collection.properties)) {
|
|
655
|
+
if (property.type === "relation" || property.type === "map" || property.type === "array" || property.type === "geopoint") {
|
|
656
|
+
continue;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
params.push({
|
|
660
|
+
name: key,
|
|
661
|
+
in: "query",
|
|
662
|
+
required: false,
|
|
663
|
+
schema: { type: "string" },
|
|
664
|
+
description:
|
|
665
|
+
`Filter by \`${key}\`. Supports PostgREST operators: ` +
|
|
666
|
+
"`eq.value`, `neq.value`, `gt.value`, `gte.value`, `lt.value`, `lte.value`, " +
|
|
667
|
+
"`in.(a,b,c)`, `nin.(a,b,c)`, `cs.value` (array-contains), `csa.(a,b)` (array-contains-any). " +
|
|
668
|
+
"Plain values imply equality.",
|
|
669
|
+
example: property.type === "string" ? "eq.active" : property.type === "number" ? "gte.100" : undefined
|
|
670
|
+
});
|
|
159
671
|
}
|
|
672
|
+
|
|
673
|
+
return params;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Standard error responses included on every endpoint.
|
|
678
|
+
*/
|
|
679
|
+
function errorResponses(requireAuth: boolean): Record<string, unknown> {
|
|
680
|
+
const responses: Record<string, unknown> = {
|
|
681
|
+
400: {
|
|
682
|
+
description: "Bad request",
|
|
683
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
684
|
+
},
|
|
685
|
+
500: {
|
|
686
|
+
description: "Internal server error",
|
|
687
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
|
|
691
|
+
if (requireAuth) {
|
|
692
|
+
responses[401] = {
|
|
693
|
+
description: "Authentication required or invalid token",
|
|
694
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
695
|
+
};
|
|
696
|
+
responses[403] = {
|
|
697
|
+
description: "Insufficient permissions",
|
|
698
|
+
content: { "application/json": { schema: { $ref: "#/components/schemas/ErrorResponse" } } }
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
return responses;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Convert a string to PascalCase for schema names.
|
|
707
|
+
*/
|
|
708
|
+
function toPascalCase(str: string): string {
|
|
709
|
+
return str
|
|
710
|
+
.replace(/[^a-zA-Z0-9]+/g, " ")
|
|
711
|
+
.split(" ")
|
|
712
|
+
.filter(Boolean)
|
|
713
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
714
|
+
.join("");
|
|
160
715
|
}
|