@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.
Files changed (176) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index-DXVBFp5V.js +37 -0
  6. package/dist/index-DXVBFp5V.js.map +1 -0
  7. package/dist/index.es.js +49931 -55
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +28475 -34569
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
  12. package/dist/server-core/src/api/types.d.ts +1 -1
  13. package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
  14. package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
  15. package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
  16. package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
  17. package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
  18. package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
  19. package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
  20. package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
  21. package/dist/server-core/src/auth/index.d.ts +13 -2
  22. package/dist/server-core/src/auth/interfaces.d.ts +45 -6
  23. package/dist/server-core/src/auth/jwt.d.ts +1 -0
  24. package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
  25. package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
  26. package/dist/server-core/src/auth/middleware.d.ts +36 -11
  27. package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
  28. package/dist/server-core/src/auth/routes.d.ts +13 -3
  29. package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
  30. package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
  31. package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
  32. package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
  33. package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
  34. package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
  35. package/dist/server-core/src/cron/cron-store.d.ts +32 -0
  36. package/dist/server-core/src/cron/index.d.ts +6 -0
  37. package/dist/server-core/src/email/index.d.ts +2 -2
  38. package/dist/server-core/src/email/templates.d.ts +9 -0
  39. package/dist/server-core/src/email/types.d.ts +20 -23
  40. package/dist/server-core/src/history/history-routes.d.ts +2 -2
  41. package/dist/server-core/src/index.d.ts +5 -0
  42. package/dist/server-core/src/init.d.ts +112 -2
  43. package/dist/server-core/src/singleton.d.ts +35 -0
  44. package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
  45. package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
  46. package/dist/server-core/src/storage/index.d.ts +18 -11
  47. package/dist/server-core/src/storage/routes.d.ts +3 -3
  48. package/dist/server-core/src/storage/types.d.ts +28 -16
  49. package/dist/server-core/src/utils/dev-port.d.ts +35 -0
  50. package/dist/server-core/src/utils/logger.d.ts +31 -0
  51. package/dist/server-core/src/utils/request-logger.d.ts +19 -0
  52. package/dist/types/src/controllers/auth.d.ts +2 -0
  53. package/dist/types/src/controllers/client.d.ts +119 -7
  54. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  55. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  56. package/dist/types/src/controllers/data.d.ts +34 -7
  57. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  58. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  59. package/dist/types/src/controllers/email.d.ts +34 -0
  60. package/dist/types/src/controllers/index.d.ts +1 -0
  61. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  62. package/dist/types/src/controllers/navigation.d.ts +5 -5
  63. package/dist/types/src/controllers/registry.d.ts +6 -3
  64. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  65. package/dist/types/src/controllers/storage.d.ts +24 -26
  66. package/dist/types/src/rebase_context.d.ts +8 -4
  67. package/dist/types/src/types/backend.d.ts +4 -1
  68. package/dist/types/src/types/builders.d.ts +5 -4
  69. package/dist/types/src/types/chips.d.ts +1 -1
  70. package/dist/types/src/types/collections.d.ts +169 -125
  71. package/dist/types/src/types/cron.d.ts +102 -0
  72. package/dist/types/src/types/data_source.d.ts +1 -1
  73. package/dist/types/src/types/entity_actions.d.ts +8 -8
  74. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  75. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  76. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  77. package/dist/types/src/types/entity_views.d.ts +8 -8
  78. package/dist/types/src/types/export_import.d.ts +3 -3
  79. package/dist/types/src/types/index.d.ts +1 -0
  80. package/dist/types/src/types/plugins.d.ts +72 -18
  81. package/dist/types/src/types/properties.d.ts +118 -33
  82. package/dist/types/src/types/relations.d.ts +1 -1
  83. package/dist/types/src/types/slots.d.ts +30 -6
  84. package/dist/types/src/types/translations.d.ts +44 -0
  85. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  86. package/package.json +84 -84
  87. package/scratch.ts +6 -5
  88. package/src/api/ast-schema-editor.ts +11 -11
  89. package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
  90. package/src/api/errors.ts +38 -14
  91. package/src/api/graphql/graphql-schema-generator.ts +5 -3
  92. package/src/api/openapi-generator.ts +613 -58
  93. package/src/api/rest/api-generator.ts +17 -11
  94. package/src/api/rest/query-parser.ts +1 -1
  95. package/src/api/schema-editor-routes.ts +2 -0
  96. package/src/api/server.ts +8 -5
  97. package/src/api/types.ts +1 -1
  98. package/src/auth/admin-routes.ts +58 -17
  99. package/src/auth/apple-oauth.ts +130 -0
  100. package/src/auth/bitbucket-oauth.ts +82 -0
  101. package/src/auth/discord-oauth.ts +83 -0
  102. package/src/auth/facebook-oauth.ts +72 -0
  103. package/src/auth/github-oauth.ts +110 -0
  104. package/src/auth/gitlab-oauth.ts +70 -0
  105. package/src/auth/google-oauth.ts +34 -46
  106. package/src/auth/index.ts +15 -2
  107. package/src/auth/interfaces.ts +54 -7
  108. package/src/auth/jwt.ts +22 -5
  109. package/src/auth/linkedin-oauth.ts +81 -0
  110. package/src/auth/microsoft-oauth.ts +88 -0
  111. package/src/auth/middleware.ts +194 -45
  112. package/src/auth/password.ts +3 -1
  113. package/src/auth/rate-limiter.ts +4 -4
  114. package/src/auth/routes.ts +197 -139
  115. package/src/auth/slack-oauth.ts +71 -0
  116. package/src/auth/spotify-oauth.ts +67 -0
  117. package/src/auth/twitter-oauth.ts +120 -0
  118. package/src/collections/loader.ts +6 -6
  119. package/src/cron/cron-loader.ts +89 -0
  120. package/src/cron/cron-routes.test.ts +265 -0
  121. package/src/cron/cron-routes.ts +85 -0
  122. package/src/cron/cron-scheduler.test.ts +421 -0
  123. package/src/cron/cron-scheduler.ts +413 -0
  124. package/src/cron/cron-store.ts +163 -0
  125. package/src/cron/index.ts +6 -0
  126. package/src/db/interfaces.ts +2 -2
  127. package/src/email/index.ts +3 -2
  128. package/src/email/smtp-email-service.ts +5 -2
  129. package/src/email/templates.ts +93 -6
  130. package/src/email/types.ts +17 -24
  131. package/src/functions/function-loader.ts +34 -6
  132. package/src/functions/function-routes.ts +2 -2
  133. package/src/history/history-routes.ts +4 -3
  134. package/src/index.ts +10 -0
  135. package/src/init.ts +475 -57
  136. package/src/services/driver-registry.ts +1 -1
  137. package/src/singleton.test.ts +28 -0
  138. package/src/singleton.ts +70 -0
  139. package/src/storage/LocalStorageController.ts +50 -53
  140. package/src/storage/S3StorageController.ts +49 -46
  141. package/src/storage/index.ts +27 -16
  142. package/src/storage/routes.ts +76 -59
  143. package/src/storage/storage-registry.ts +5 -5
  144. package/src/storage/types.ts +44 -32
  145. package/src/types/index.ts +4 -4
  146. package/src/utils/dev-port.ts +176 -0
  147. package/src/utils/logger.ts +143 -0
  148. package/src/utils/logging.ts +5 -2
  149. package/src/utils/request-logger.ts +66 -0
  150. package/test/admin-routes.test.ts +114 -65
  151. package/test/api-generator.test.ts +73 -30
  152. package/test/ast-schema-editor.test.ts +5 -3
  153. package/test/auth-middleware-hono.test.ts +268 -33
  154. package/test/auth-routes.test.ts +325 -146
  155. package/test/driver-registry.test.ts +4 -2
  156. package/test/error-propagation.test.ts +226 -0
  157. package/test/errors.test.ts +12 -7
  158. package/test/jwt-security.test.ts +18 -9
  159. package/test/jwt.test.ts +26 -13
  160. package/test/middleware.test.ts +13 -8
  161. package/test/query-parser.test.ts +13 -8
  162. package/test/rate-limiter.test.ts +7 -7
  163. package/test/safe-compare.test.ts +66 -0
  164. package/test/singleton.test.ts +59 -0
  165. package/test/storage-local.test.ts +36 -43
  166. package/test/storage-registry.test.ts +38 -36
  167. package/test/storage-routes.test.ts +9 -5
  168. package/test/storage-s3.test.ts +51 -48
  169. package/test.ts +6 -0
  170. package/tsconfig.json +1 -1
  171. package/vite.config.ts +7 -5
  172. package/dist/index-BeMqpmfQ.js +0 -239
  173. package/dist/index-BeMqpmfQ.js.map +0 -1
  174. package/dist/index-bl4J3lNb.js +0 -55823
  175. package/dist/index-bl4J3lNb.js.map +0 -1
  176. 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
- export function generateOpenApiSpec(collections: EntityCollection[], basePath: string = "/api"): Record<string, unknown> {
4
- const spec = {
5
- openapi: "3.0.0",
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 Auto-Generated API",
29
+ title: "Rebase API",
8
30
  version: "1.0.0",
9
- description: "Automatically generated REST API from Rebase collections"
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: {} as Record<string, unknown>
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
- (collections || []).forEach(collection => {
24
- const schemaName = collection.singularName || collection.name;
25
- spec.components.schemas[schemaName] = {
26
- type: "object",
27
- properties: {
28
- id: { type: "string" },
29
- ...Object.entries(collection.properties).reduce((props, [key, property]) => {
30
- if (property.type !== "relation") {
31
- props[key] = convertPropertyToOpenApiType(property);
32
- }
33
- return props;
34
- }, {} as Record<string, unknown>)
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
- const collectionPath = `/${collection.slug}`;
115
+ // Build component schema for this collection
116
+ schemas[schemaName] = buildCollectionSchema(collection);
39
117
 
40
- spec.paths[collectionPath] = {
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", in: "query", schema: { type: "integer", default: 20 } },
45
- { name: "offset", in: "query", schema: { type: "integer", default: 0 } },
46
- { name: "where", in: "query", schema: { type: "string" } },
47
- { name: "orderBy", in: "query", schema: { type: "string" } }
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: "Success",
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
- summary: `Create ${schemaName}`,
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
- spec.paths[`${collectionPath}/{id}`] = {
228
+ // ── GET/PUT/DELETE /data/{slug}/{id} ──────────────────────────
229
+ const entityPath = `/data/${slug}/{id}`;
230
+ paths[entityPath] = {
91
231
  get: {
92
- summary: `Get ${schemaName} by ID`,
232
+ tags: [collection.name],
233
+ summary: `Get ${collection.singularName || collection.name} by ID`,
234
+ operationId: `get${schemaName}ById`,
93
235
  parameters: [
94
- { name: "id", in: "path", required: true, schema: { type: "string" } }
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: "Success",
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
- summary: `Update ${schemaName}`,
264
+ tags: [collection.name],
265
+ summary: `Update ${collection.singularName || collection.name}`,
266
+ operationId: `update${schemaName}`,
109
267
  parameters: [
110
- { name: "id", in: "path", required: true, schema: { type: "string" } }
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
- summary: `Delete ${schemaName}`,
297
+ tags: [collection.name],
298
+ summary: `Delete ${collection.singularName || collection.name}`,
299
+ operationId: `delete${schemaName}`,
132
300
  parameters: [
133
- { name: "id", in: "path", required: true, schema: { type: "string" } }
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
- function convertPropertyToOpenApiType(property: Property): Record<string, unknown> {
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
- return { type: "string" };
149
- case "number":
150
- return { type: "number" };
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
- return { type: "boolean" };
153
- case "date":
154
- return { type: "string", format: "date-time" };
155
- case "array":
156
- return { type: "array", items: { type: "string" } };
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
- return { type: "string" };
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
  }