cloesce 0.0.4-unstable.9 → 0.0.5-unstable.1

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.
@@ -1,275 +1,395 @@
1
- import { Either, isNullableType, getNavigationPropertyCidlType, NO_DATA_SOURCE, } from "../common.js";
2
1
  import { mapSql, loadOrmWasm } from "./wasm.js";
3
- import { CrudContext } from "./crud.js";
4
- import { Orm } from "../ui/backend.js";
5
- export class CloesceApp {
6
- globalMiddleware = [];
7
- modelMiddleware = new Map();
8
- methodMiddleware = new Map();
9
- responseMiddleware = [];
10
- routePrefix = "api";
11
- /**
12
- * Registers global middleware which runs before any route matching.
13
- *
14
- * @param m - The middleware function to register.
15
- */
16
- onRequest(m) {
17
- this.globalMiddleware.push(m);
18
- }
19
- /**
20
- * Registers middleware which runs after the response is generated, but before
21
- * it is returned to the client.
22
- *
23
- * Optionally, return a new HttpResult to short-circuit the response.
24
- *
25
- * Errors thrown in response middleware are caught and returned as a 500 response.
26
- *
27
- * Errors thrown in earlier middleware or route processing are not caught here.
28
- *
29
- * @param m - The middleware function to register.
30
- */
31
- onResponse(m) {
32
- this.responseMiddleware.push(m);
33
- }
34
- /**
35
- * Registers middleware for a specific model type.
36
- *
37
- * Runs before request validation and method middleware.
38
- *
39
- * @typeParam T - The model type.
40
- * @param ctor - The model constructor (used to derive its name).
41
- * @param m - The middleware function to register.
42
- */
43
- onModel(ctor, m) {
44
- if (this.modelMiddleware.has(ctor.name)) {
45
- this.modelMiddleware.get(ctor.name).push(m);
46
- }
47
- else {
48
- this.modelMiddleware.set(ctor.name, [m]);
49
- }
50
- }
51
- /**
52
- * Registers middleware for a specific method on a model.
53
- *
54
- * Runs after model middleware and request validation.
55
- *
56
- * @typeParam T - The model type.
57
- * @param ctor - The model constructor (used to derive its name).
58
- * @param method - The method name on the model.
59
- * @param m - The middleware function to register.
60
- */
61
- onMethod(ctor, method, m) {
62
- if (!this.methodMiddleware.has(ctor.name)) {
63
- this.methodMiddleware.set(ctor.name, new Map());
64
- }
65
- const methods = this.methodMiddleware.get(ctor.name);
66
- if (!methods.has(method)) {
67
- methods.set(method, []);
68
- }
69
- methods.get(method).push(m);
70
- }
71
- /**
72
- * Router entry point. Undergoes route matching, request validation, hydration, and method dispatch.
73
- */
74
- async cloesce(request, env, ast, constructorRegistry, di, d1) {
75
- // Global middleware
76
- for (const m of this.globalMiddleware) {
77
- const res = await m(request, env, di);
78
- if (res) {
79
- return toResponse(res);
80
- }
81
- }
82
- // Route match
83
- const route = matchRoute(request, ast, this.routePrefix);
84
- if (route.isLeft()) {
85
- return toResponse(route.value);
86
- }
87
- const { method, model, id } = route.unwrap();
88
- // Model middleware
89
- for (const m of this.modelMiddleware.get(model.name) ?? []) {
90
- const res = await m(request, env, di);
91
- if (res) {
92
- return toResponse(res);
93
- }
94
- }
95
- // Request validation
96
- const validation = await validateRequest(request, ast, model, method, id);
97
- if (validation.isLeft()) {
98
- return toResponse(validation.value);
99
- }
100
- const { params, dataSource } = validation.unwrap();
101
- // Method middleware
102
- for (const m of this.methodMiddleware.get(model.name)?.get(method.name) ??
103
- []) {
104
- const res = await m(request, env, di);
105
- if (res) {
106
- return toResponse(res);
107
- }
108
- }
109
- // Hydration
110
- const crudCtx = await (async () => {
111
- if (method.is_static) {
112
- return Either.right(CrudContext.fromCtor(d1, constructorRegistry[model.name]));
113
- }
114
- const hydratedModel = await hydrateModel(constructorRegistry, d1, model, id, // id must exist after matchRoute
115
- dataSource);
116
- return hydratedModel.map((_) => CrudContext.fromInstance(d1, hydratedModel.value, constructorRegistry[model.name]));
117
- })();
118
- if (crudCtx.isLeft()) {
119
- return toResponse(crudCtx.value);
120
- }
121
- // Method dispatch
122
- return toResponse(await methodDispatch(crudCtx.unwrap(), di, method, params));
123
- }
124
- /**
125
- * Runs the Cloesce app. Intended to be called from the generated workers code.
126
- */
127
- async run(request, env, ast, constructorRegistry, envMeta) {
128
- const di = new Map();
129
- di.set(envMeta.envName, env);
130
- di.set("Request", request);
131
- await RuntimeContainer.init(ast, constructorRegistry);
132
- const d1 = env[envMeta.dbName]; // TODO: multiple dbs
133
- try {
134
- // Core cloesce processing
135
- const response = await this.cloesce(request, env, ast, constructorRegistry, di, d1);
136
- // Response middleware
137
- for (const m of this.responseMiddleware) {
138
- const res = await m(request, env, di, response);
139
- if (res) {
140
- return toResponse(res);
141
- }
142
- }
143
- return response;
144
- }
145
- catch (e) {
146
- console.error(JSON.stringify(e));
147
- return new Response(JSON.stringify({
148
- ok: false,
149
- status: 500,
150
- message: e.toString(),
151
- }), {
152
- status: 500,
153
- headers: { "Content-Type": "application/json" },
154
- });
155
- }
156
- }
157
- }
2
+ import { proxyCrud } from "./crud.js";
3
+ import { HttpResult, Orm } from "../ui/backend.js";
4
+ import { Either } from "../ui/common.js";
5
+ import { NO_DATA_SOURCE, MediaType } from "../ast.js";
6
+ import { RuntimeValidator } from "./validator.js";
158
7
  /**
159
8
  * Singleton instance containing the cidl, constructor registry, and wasm binary.
160
9
  * These values are guaranteed to never change throughout a workers lifetime.
161
10
  */
162
11
  export class RuntimeContainer {
163
- ast;
164
- constructorRegistry;
165
- wasm;
166
- static instance;
167
- constructor(ast, constructorRegistry, wasm) {
168
- this.ast = ast;
169
- this.constructorRegistry = constructorRegistry;
170
- this.wasm = wasm;
171
- }
172
- static async init(ast, constructorRegistry, wasm) {
173
- if (this.instance)
174
- return;
175
- const wasmAbi = await loadOrmWasm(ast, wasm);
176
- this.instance = new RuntimeContainer(ast, constructorRegistry, wasmAbi);
177
- }
178
- static get() {
179
- return this.instance;
12
+ ast;
13
+ constructorRegistry;
14
+ wasm;
15
+ static instance;
16
+ constructor(ast, constructorRegistry, wasm) {
17
+ this.ast = ast;
18
+ this.constructorRegistry = constructorRegistry;
19
+ this.wasm = wasm;
20
+ }
21
+ static async init(ast, constructorRegistry, wasm) {
22
+ if (this.instance) return;
23
+ const wasmAbi = await loadOrmWasm(ast, wasm);
24
+ this.instance = new RuntimeContainer(ast, constructorRegistry, wasmAbi);
25
+ }
26
+ static get() {
27
+ return this.instance;
28
+ }
29
+ /**
30
+ * Disposes the singleton instance. For testing purposes only.
31
+ */
32
+ static dispose() {
33
+ this.instance = undefined;
34
+ }
35
+ }
36
+ /**
37
+ * Expected states in which the router may exit.
38
+ */
39
+ export var RouterError;
40
+ (function (RouterError) {
41
+ RouterError[(RouterError["UnknownPrefix"] = 0)] = "UnknownPrefix";
42
+ RouterError[(RouterError["UnknownRoute"] = 1)] = "UnknownRoute";
43
+ RouterError[(RouterError["UnmatchedHttpVerb"] = 2)] = "UnmatchedHttpVerb";
44
+ RouterError[(RouterError["InstantiatedMethodMissingId"] = 3)] =
45
+ "InstantiatedMethodMissingId";
46
+ RouterError[(RouterError["RequestMissingBody"] = 4)] = "RequestMissingBody";
47
+ RouterError[(RouterError["RequestBodyMissingParameters"] = 5)] =
48
+ "RequestBodyMissingParameters";
49
+ RouterError[(RouterError["RequestBodyInvalidParameter"] = 6)] =
50
+ "RequestBodyInvalidParameter";
51
+ RouterError[(RouterError["InstantiatedMethodMissingDataSource"] = 7)] =
52
+ "InstantiatedMethodMissingDataSource";
53
+ RouterError[(RouterError["MissingDependency"] = 8)] = "MissingDependency";
54
+ RouterError[(RouterError["InvalidDatabaseQuery"] = 9)] =
55
+ "InvalidDatabaseQuery";
56
+ RouterError[(RouterError["ModelNotFound"] = 10)] = "ModelNotFound";
57
+ RouterError[(RouterError["UncaughtException"] = 11)] = "UncaughtException";
58
+ })(RouterError || (RouterError = {}));
59
+ export class CloesceApp {
60
+ routePrefix = "api";
61
+ globalMiddleware = [];
62
+ /**
63
+ * Registers global middleware which runs before any route matching.
64
+ *
65
+ * TODO: Middleware may violate the API contract and return unexpected types
66
+ *
67
+ * @param m - The middleware function to register.
68
+ */
69
+ onRequest(m) {
70
+ this.globalMiddleware.push(m);
71
+ }
72
+ resultMiddleware = [];
73
+ /**
74
+ * Registers middleware which runs after the response is generated, but before
75
+ * it is returned to the client.
76
+ *
77
+ * Optionally, return a new HttpResult to short-circuit the response.
78
+ *
79
+ * Errors thrown in response middleware are caught and returned as a 500 response.
80
+ *
81
+ * TODO: Middleware may violate the API contract and return unexpected types
82
+ *
83
+ * @param m - The middleware function to register.
84
+ */
85
+ onResult(m) {
86
+ this.resultMiddleware.push(m);
87
+ }
88
+ namespaceMiddleware = new Map();
89
+ /**
90
+ * Registers middleware for a specific namespace (being, a model or service)
91
+ *
92
+ * Runs before request validation and method middleware.
93
+ *
94
+ * TODO: Middleware may violate the API contract and return unexpected types
95
+ *
96
+ * @typeParam T - The namespace type
97
+ * @param ctor - The namespace's constructor (used to derive its name).
98
+ * @param m - The middleware function to register.
99
+ */
100
+ onNamespace(ctor, m) {
101
+ if (this.namespaceMiddleware.has(ctor.name)) {
102
+ this.namespaceMiddleware.get(ctor.name).push(m);
103
+ } else {
104
+ this.namespaceMiddleware.set(ctor.name, [m]);
105
+ }
106
+ }
107
+ methodMiddleware = new Map();
108
+ /**
109
+ * Registers middleware for a specific method on a namespace
110
+ *
111
+ * Runs after namespace middleware and request validation.
112
+ *
113
+ * TODO: Middleware may violate the API contract and return unexpected types
114
+ *
115
+ * @typeParam T - The namespace type
116
+ * @param ctor - The namespace constructor
117
+ * @param method - The method name on the namespace.
118
+ * @param m - The middleware function to register.
119
+ */
120
+ onMethod(ctor, method, m) {
121
+ if (!this.methodMiddleware.has(ctor.name)) {
122
+ this.methodMiddleware.set(ctor.name, new Map());
123
+ }
124
+ const methods = this.methodMiddleware.get(ctor.name);
125
+ if (!methods.has(method)) {
126
+ methods.set(method, []);
127
+ }
128
+ methods.get(method).push(m);
129
+ }
130
+ async router(request, env, ast, ctorReg, di) {
131
+ // Global middleware
132
+ for (const m of this.globalMiddleware) {
133
+ const res = await m(di);
134
+ if (res) {
135
+ return res;
136
+ }
137
+ }
138
+ // Route match
139
+ const routeRes = matchRoute(request, ast, this.routePrefix);
140
+ if (routeRes.isLeft()) {
141
+ return routeRes.value;
142
+ }
143
+ const route = routeRes.unwrap();
144
+ // Model middleware
145
+ for (const m of this.namespaceMiddleware.get(route.namespace) ?? []) {
146
+ const res = await m(di);
147
+ if (res) {
148
+ return res;
149
+ }
150
+ }
151
+ // Request validation
152
+ const validation = await validateRequest(request, ast, ctorReg, route);
153
+ if (validation.isLeft()) {
154
+ return validation.value;
155
+ }
156
+ const { params, dataSource } = validation.unwrap();
157
+ // Method middleware
158
+ for (const m of this.methodMiddleware
159
+ .get(route.namespace)
160
+ ?.get(route.method.name) ?? []) {
161
+ const res = await m(di);
162
+ if (res) {
163
+ return res;
164
+ }
165
+ }
166
+ // Hydration
167
+ const hydrated = await (async () => {
168
+ if (route.kind == "model") {
169
+ // TODO: Support multiple D1 bindings
170
+ // It's been verified by the compiler that wrangler_env exists if a D1 model is present
171
+ const d1 = env[ast.wrangler_env.db_binding];
172
+ // Proxy CRUD
173
+ if (route.method.is_static) {
174
+ return Either.right(
175
+ proxyCrud(ctorReg[route.namespace], ctorReg[route.namespace], d1),
176
+ );
177
+ }
178
+ return await hydrateModelD1(
179
+ ctorReg,
180
+ d1,
181
+ route.model,
182
+ route.id,
183
+ dataSource,
184
+ );
185
+ }
186
+ // Services
187
+ if (route.method.is_static) {
188
+ return Either.right(ctorReg[route.namespace]);
189
+ }
190
+ return Either.right(di.get(route.namespace));
191
+ })();
192
+ if (hydrated.isLeft()) {
193
+ return hydrated.value;
194
+ }
195
+ // Method dispatch
196
+ return await methodDispatch(hydrated.unwrap(), di, route, params);
197
+ }
198
+ /**
199
+ * Runs the Cloesce app. Intended to be called from the generated workers code.
200
+ */
201
+ async run(request, env, ast, ctorReg) {
202
+ await RuntimeContainer.init(ast, ctorReg);
203
+ const di = new Map();
204
+ if (ast.wrangler_env) {
205
+ di.set(ast.wrangler_env.name, env);
206
+ }
207
+ di.set("Request", request);
208
+ // Note: Services are in topological order
209
+ for (const name in ast.services) {
210
+ const service = ast.services[name];
211
+ for (const attr of service.attributes) {
212
+ const injected = di.get(attr.injected);
213
+ if (!injected) {
214
+ return exit(
215
+ 500,
216
+ RouterError.MissingDependency,
217
+ `An injected parameter was missing from the instance registry: ${JSON.stringify(attr.injected)}`,
218
+ )
219
+ .unwrapLeft()
220
+ .toResponse();
221
+ }
222
+ service[attr.var_name] = injected;
223
+ }
224
+ // Inject services
225
+ di.set(name, Object.assign(new ctorReg[name](), service));
180
226
  }
227
+ try {
228
+ let httpResult = await this.router(request, env, ast, ctorReg, di);
229
+ // Response middleware
230
+ for (const m of this.resultMiddleware) {
231
+ const res = await m(di, httpResult);
232
+ if (res) {
233
+ return res.toResponse();
234
+ }
235
+ }
236
+ return httpResult.toResponse();
237
+ } catch (e) {
238
+ let debug;
239
+ if (e instanceof Error) {
240
+ debug = {
241
+ name: e.name,
242
+ message: e.message,
243
+ stack: e.stack,
244
+ cause: e.cause,
245
+ };
246
+ } else {
247
+ debug = {
248
+ name: "NonErrorThrown",
249
+ message: typeof e === "string" ? e : JSON.stringify(e),
250
+ stack: undefined,
251
+ };
252
+ }
253
+ const res = HttpResult.fail(500, JSON.stringify(debug));
254
+ console.error(
255
+ "An uncaught error occurred in the Cloesce Router: ",
256
+ debug,
257
+ );
258
+ return res.toResponse();
259
+ }
260
+ }
181
261
  }
182
262
  /**
183
- * Matches a request to a method on a model.
263
+ * Matches a request to an ApiInvocation
184
264
  * @param apiRoute The route from the domain to the actual API, ie https://foo.com/route/to/api => route/to/api/
185
- * @returns 404 or a `MatchedRoute`
265
+ * @returns 404 or a matched route.
186
266
  */
187
267
  function matchRoute(request, ast, routePrefix) {
188
- const url = new URL(request.url);
189
- const parts = url.pathname.split("/").filter(Boolean);
190
- const prefix = routePrefix.split("/").filter(Boolean);
191
- // Error state: We expect an exact request format, and expect that the model
192
- // and are apart of the CIDL
193
- const notFound = (e) => Either.left(errorState(404, `Path not found: ${e} ${url.pathname}`));
194
- for (const p of prefix) {
195
- if (parts.shift() !== p)
196
- return notFound(`Missing prefix segment "${p}"`);
197
- }
198
- if (parts.length < 2) {
199
- return notFound("Expected /model/method or /model/:id/method");
200
- }
201
- // Attempt to extract from routeParts
202
- const modelName = parts[0];
203
- const methodName = parts[parts.length - 1];
204
- const id = parts.length === 3 ? parts[1] : null;
205
- const model = ast.models[modelName];
206
- if (!model) {
207
- return notFound(`Unknown model ${modelName}`);
208
- }
268
+ const url = new URL(request.url);
269
+ const parts = url.pathname.split("/").filter(Boolean);
270
+ const prefix = routePrefix.split("/").filter(Boolean);
271
+ // Error state: We expect an exact request format, and expect that the model
272
+ // and are apart of the CIDL
273
+ const notFound = (c) => exit(404, c, "Unknown route");
274
+ for (const p of prefix) {
275
+ if (parts.shift() !== p) return notFound(RouterError.UnknownPrefix);
276
+ }
277
+ if (parts.length < 2) {
278
+ return notFound(RouterError.UnknownPrefix);
279
+ }
280
+ // Extract pattern
281
+ const namespace = parts[0];
282
+ const methodName = parts[parts.length - 1];
283
+ const id = parts.length === 3 ? parts[1] : null;
284
+ const model = ast.models[namespace];
285
+ if (model) {
209
286
  const method = model.methods[methodName];
210
- if (!method) {
211
- return notFound(`Unknown method ${modelName}.${methodName}`);
287
+ if (!method) return notFound(RouterError.UnknownRoute);
288
+ if (request.method !== method.http_verb) {
289
+ return notFound(RouterError.UnmatchedHttpVerb);
212
290
  }
291
+ return Either.right({
292
+ kind: "model",
293
+ namespace,
294
+ method,
295
+ model,
296
+ id,
297
+ });
298
+ }
299
+ const service = ast.services[namespace];
300
+ if (service) {
301
+ const method = service.methods[methodName];
302
+ // Services do not have IDs.
303
+ if (!method || id) return notFound(RouterError.UnknownRoute);
213
304
  if (request.method !== method.http_verb) {
214
- return notFound("Unmatched HTTP method");
305
+ return notFound(RouterError.UnmatchedHttpVerb);
215
306
  }
216
307
  return Either.right({
217
- model,
218
- method,
219
- id,
308
+ kind: "service",
309
+ namespace,
310
+ method,
311
+ service,
312
+ id: null,
220
313
  });
314
+ }
315
+ return notFound(RouterError.UnknownRoute);
221
316
  }
222
317
  /**
223
318
  * Validates the request's body/search params against a ModelMethod
224
319
  * @returns 400 or a `RequestParamMap` consisting of each parameters name mapped to its value, and
225
320
  * a data source
226
321
  */
227
- async function validateRequest(request, ast, model, method, id) {
228
- // Error state: any missing parameter, body, or malformed input will exit with 400.
229
- const invalidRequest = (e) => Either.left(errorState(400, `Invalid Request Body: ${e}`));
230
- if (!method.is_static && id == null) {
231
- return invalidRequest("Id's are required for instantiated methods.");
232
- }
233
- // Filter out any injected parameters that will not be passed
234
- // by the query.
235
- const requiredParams = method.parameters.filter((p) => !(typeof p.cidl_type === "object" && "Inject" in p.cidl_type));
236
- // Extract url or body parameters
237
- const url = new URL(request.url);
238
- let params = {};
239
- if (method.http_verb === "GET") {
240
- params = Object.fromEntries(url.searchParams.entries());
241
- }
242
- else {
243
- try {
244
- params = await request.json();
322
+ async function validateRequest(request, ast, ctorReg, route) {
323
+ // Error state: any missing parameter, body, or malformed input will exit with 400.
324
+ const invalidRequest = (c) => exit(400, c, "Invalid Request Body");
325
+ // Models must have an ID on instantiated methods.
326
+ if (route.kind === "model" && !route.method.is_static && route.id == null) {
327
+ return invalidRequest(RouterError.InstantiatedMethodMissingId);
328
+ }
329
+ // Filter out injected parameters
330
+ const requiredParams = route.method.parameters.filter(
331
+ (p) => !(typeof p.cidl_type === "object" && "Inject" in p.cidl_type),
332
+ );
333
+ const url = new URL(request.url);
334
+ let params = Object.fromEntries(url.searchParams.entries());
335
+ // Extract all method parameters from the body
336
+ if (route.method.http_verb !== "GET") {
337
+ try {
338
+ switch (route.method.parameters_media) {
339
+ case MediaType.Json: {
340
+ const body = await request.json();
341
+ params = { ...params, ...body };
342
+ break;
245
343
  }
246
- catch {
247
- return invalidRequest("Could not retrieve JSON body.");
344
+ case MediaType.Octet: {
345
+ // Octet streams are verified by the Cloesce compiler to have
346
+ // one Stream type
347
+ const streamParam = requiredParams.find(
348
+ (p) => typeof p.cidl_type === "string" && p.cidl_type === "Stream",
349
+ );
350
+ params[streamParam.name] = request.body;
351
+ break;
248
352
  }
249
- }
250
- // Ensure all required params exist
251
- if (!requiredParams.every((p) => p.name in params)) {
252
- return invalidRequest(`Missing parameters.`);
253
- }
254
- // Validate all parameters type
255
- for (const p of requiredParams) {
256
- const value = params[p.name];
257
- const isPartial = typeof p.cidl_type !== "string" && "Partial" in p.cidl_type;
258
- if (!validateCidlType(ast, value, p.cidl_type, isPartial)) {
259
- return invalidRequest("Invalid parameters.");
353
+ default: {
354
+ throw new Error("not implemented");
260
355
  }
261
- }
262
- // Validate data source if exists
263
- const dataSourceParam = requiredParams.find((p) => typeof p.cidl_type === "object" && "DataSource" in p.cidl_type);
264
- const dataSource = dataSourceParam
265
- ? params[dataSourceParam.name]
266
- : null;
267
- if (dataSource &&
268
- dataSource !== NO_DATA_SOURCE &&
269
- !(dataSource in model.data_sources)) {
270
- return invalidRequest(`Unknown data source ${dataSource}`);
271
- }
272
- return Either.right({ params, dataSource });
356
+ }
357
+ } catch {
358
+ return invalidRequest(RouterError.RequestMissingBody);
359
+ }
360
+ }
361
+ if (!requiredParams.every((p) => p.name in params)) {
362
+ return invalidRequest(RouterError.RequestBodyMissingParameters);
363
+ }
364
+ // Validate all parameters type
365
+ // Octet streams can be passed as is
366
+ if (route.method.parameters_media !== MediaType.Octet) {
367
+ for (const p of requiredParams) {
368
+ const res = RuntimeValidator.validate(
369
+ params[p.name],
370
+ p.cidl_type,
371
+ ast,
372
+ ctorReg,
373
+ );
374
+ if (res.isLeft()) {
375
+ return invalidRequest(RouterError.RequestBodyInvalidParameter);
376
+ }
377
+ params[p.name] = res.unwrap();
378
+ }
379
+ }
380
+ // A data source is required for instantiated model methods
381
+ const dataSource = requiredParams
382
+ .filter(
383
+ (p) =>
384
+ typeof p.cidl_type === "object" &&
385
+ "DataSource" in p.cidl_type &&
386
+ p.cidl_type.DataSource === route.namespace,
387
+ )
388
+ .map((p) => params[p.name])[0];
389
+ if (route.kind === "model" && !route.method.is_static && !dataSource) {
390
+ return invalidRequest(RouterError.InstantiatedMethodMissingDataSource);
391
+ }
392
+ return Either.right({ params, dataSource: dataSource ?? null });
273
393
  }
274
394
  /**
275
395
  * Queries D1 for a particular model's ID, then transforms the SQL column output into
@@ -278,178 +398,102 @@ async function validateRequest(request, ast, model, method, id) {
278
398
  * @returns 500 if the D1 database is not synced with Cloesce and yields an error
279
399
  * @returns The instantiated model on success
280
400
  */
281
- async function hydrateModel(constructorRegistry, d1, model, id, dataSource) {
282
- // Error state: If the D1 database has been tweaked outside of Cloesce
283
- // resulting in a malformed query, exit with a 500.
284
- const malformedQuery = (e) => Either.left(errorState(500, `Error in hydration query, is the database out of sync with the backend?: ${e instanceof Error ? e.message : String(e)}`));
401
+ async function hydrateModelD1(constructorRegistry, d1, model, id, dataSource) {
402
+ // Error state: If some outside force tweaked the database schema, the query may fail.
403
+ // Otherwise, this indicates a bug in the compiler or runtime.
404
+ const malformedQuery = (e) =>
405
+ exit(
406
+ 500,
407
+ RouterError.InvalidDatabaseQuery,
408
+ `Error in hydration query, is the database out of sync with the backend?: ${e instanceof Error ? e.message : String(e)}`,
409
+ );
410
+ // Query DB
411
+ let records;
412
+ try {
413
+ let includeTree =
414
+ dataSource === NO_DATA_SOURCE
415
+ ? null
416
+ : constructorRegistry[model.name][dataSource];
417
+ records = await d1
418
+ .prepare(Orm.getQuery(constructorRegistry[model.name], includeTree))
419
+ .bind(id)
420
+ .run();
285
421
  // Error state: If no record is found for the id, return a 404
286
- const missingRecord = Either.left(errorState(404, "Record not found"));
287
- // Query DB
288
- let records;
289
- try {
290
- let includeTree = dataSource === NO_DATA_SOURCE
291
- ? null
292
- : constructorRegistry[model.name][dataSource];
293
- records = await d1
294
- .prepare(Orm.getQuery(constructorRegistry[model.name], includeTree))
295
- .bind(id)
296
- .run();
297
- if (!records?.results) {
298
- return missingRecord;
299
- }
300
- if (records.error) {
301
- return malformedQuery(records.error);
302
- }
303
- }
304
- catch (e) {
305
- return malformedQuery(e);
306
- }
307
- // Hydrate
308
- const models = mapSql(constructorRegistry[model.name], records.results, model.data_sources[dataSource]?.tree ?? {}).value;
309
- return Either.right(models[0]);
422
+ if (!records?.results) {
423
+ return exit(404, RouterError.ModelNotFound, "Record not found");
424
+ }
425
+ if (records.error) {
426
+ return malformedQuery(records.error);
427
+ }
428
+ } catch (e) {
429
+ return malformedQuery(e);
430
+ }
431
+ // Hydrate
432
+ const models = mapSql(
433
+ constructorRegistry[model.name],
434
+ records.results,
435
+ model.data_sources[dataSource]?.tree ?? {},
436
+ );
437
+ if (models.isLeft()) {
438
+ return malformedQuery(models.value);
439
+ }
440
+ return Either.right(models.unwrap()[0]);
310
441
  }
311
442
  /**
312
443
  * Calls a method on a model given a list of parameters.
313
444
  * @returns 500 on an uncaught client error, 200 with a result body on success
314
445
  */
315
- async function methodDispatch(crudCtx, instanceRegistry, method, params) {
316
- // Error state: Client code ran into an uncaught exception.
317
- const uncaughtException = (e) => errorState(500, `Uncaught exception in method dispatch: ${e instanceof Error ? e.message : String(e)}`);
318
- const paramArray = [];
319
- for (const param of method.parameters) {
320
- if (params[param.name]) {
321
- paramArray.push(params[param.name]);
322
- continue;
323
- }
324
- // Injected type
325
- const injected = instanceRegistry.get(param.cidl_type["Inject"]);
326
- if (!injected) {
327
- // Error state: Injected parameters cannot be found at compile time, only at runtime.
328
- // If a injected reference does not exist, throw a 500.
329
- return errorState(500, `An injected parameter was missing from the instance registry: ${JSON.stringify(param.cidl_type)}`);
330
- }
331
- paramArray.push(injected);
332
- }
333
- // Ensure the result is always some HttpResult
334
- const resultWrapper = (res) => {
335
- const rt = method.return_type;
336
- if (rt === null) {
337
- return { ok: true, status: 200 };
338
- }
339
- if (typeof rt === "object" && rt !== null && "HttpResult" in rt) {
340
- return res;
341
- }
342
- return { ok: true, status: 200, data: res };
343
- };
344
- try {
345
- const res = await crudCtx.interceptCrud(method.name)(...paramArray);
346
- return resultWrapper(res);
347
- }
348
- catch (e) {
349
- return uncaughtException(e);
350
- }
351
- }
352
- /**
353
- * Runtime type validation for CIDL types.
354
- *
355
- * Returns true if the value matches the CIDL type, false otherwise.
356
- */
357
- function validateCidlType(ast, value, cidlType, isPartial) {
358
- if (value === undefined)
359
- return isPartial;
360
- // TODO: consequences of null checking like this? 'null' is passed in
361
- // as a string for GET requests
362
- const nullable = isNullableType(cidlType);
363
- if (value == null || value === "null")
364
- return nullable;
365
- if (nullable) {
366
- cidlType = cidlType.Nullable; // Unwrap the nullable type
367
- }
368
- // Handle primitive string types with switch
369
- if (typeof cidlType === "string") {
370
- switch (cidlType) {
371
- case "Integer":
372
- return Number.isInteger(Number(value));
373
- case "Real":
374
- return !Number.isNaN(Number(value));
375
- case "Text":
376
- return typeof value === "string";
377
- case "Boolean":
378
- return typeof value === "boolean";
379
- case "DateIso":
380
- const date = new Date(value);
381
- return !isNaN(date.getTime());
382
- default:
383
- return false;
384
- }
385
- }
386
- // Handle Data Sources
387
- if ("DataSource" in cidlType) {
388
- return typeof value === "string";
389
- }
390
- // Handle Models
391
- let cidlTypeAccessor = "Partial" in cidlType
392
- ? cidlType.Partial
393
- : "Object" in cidlType
394
- ? cidlType.Object
395
- : undefined;
396
- if (cidlTypeAccessor && ast.models[cidlTypeAccessor]) {
397
- const model = ast.models[cidlTypeAccessor];
398
- if (!model || typeof value !== "object")
399
- return false;
400
- const valueObj = value;
401
- // Validate attributes
402
- if (!model.attributes.every((attr) => validateCidlType(ast, valueObj[attr.value.name], attr.value.cidl_type, isPartial))) {
403
- return false;
404
- }
405
- // Validate navigation properties
406
- return model.navigation_properties.every((nav) => {
407
- const navValue = valueObj[nav.var_name];
408
- return (navValue == null ||
409
- validateCidlType(ast, navValue, getNavigationPropertyCidlType(nav), isPartial));
410
- });
411
- }
412
- // Handle Plain Old Objects
413
- if (cidlTypeAccessor && ast.poos[cidlTypeAccessor]) {
414
- const poo = ast.poos[cidlTypeAccessor];
415
- if (!poo || typeof value !== "object")
416
- return false;
417
- const valueObj = value;
418
- // Validate attributes
419
- if (!poo.attributes.every((attr) => validateCidlType(ast, valueObj[attr.name], attr.cidl_type, isPartial))) {
420
- return false;
421
- }
422
- return true;
423
- }
424
- if ("Array" in cidlType) {
425
- const arr = cidlType.Array;
426
- return (Array.isArray(value) &&
427
- value.every((v) => validateCidlType(ast, v, arr, isPartial)));
428
- }
429
- if ("HttpResult" in cidlType) {
430
- if (value === null)
431
- return cidlType.HttpResult === null;
432
- if (cidlType.HttpResult === null)
433
- return false;
434
- return validateCidlType(ast, value, cidlType.HttpResult, isPartial);
435
- }
436
- return false;
446
+ async function methodDispatch(obj, di, route, params) {
447
+ const paramArray = [];
448
+ for (const param of route.method.parameters) {
449
+ if (param.name in params) {
450
+ paramArray.push(params[param.name]);
451
+ continue;
452
+ }
453
+ // Injected type
454
+ const injected = di.get(param.cidl_type["Inject"]);
455
+ if (!injected) {
456
+ // Error state: Injected parameters cannot be found at compile time, only at runtime.
457
+ // If a injected reference does not exist, throw a 500.
458
+ return exit(
459
+ 500,
460
+ RouterError.MissingDependency,
461
+ `An injected parameter was missing from the instance registry: ${JSON.stringify(param.cidl_type)}`,
462
+ ).unwrapLeft();
463
+ }
464
+ paramArray.push(injected);
465
+ }
466
+ const wrapResult = (res) => {
467
+ const rt = route.method.return_type;
468
+ const httpResult =
469
+ typeof rt === "object" && rt !== null && "HttpResult" in rt
470
+ ? res
471
+ : HttpResult.ok(200, res);
472
+ return httpResult.setMediaType(route.method.return_media);
473
+ };
474
+ try {
475
+ const res = await obj[route.method.name](...paramArray);
476
+ return wrapResult(res);
477
+ } catch (e) {
478
+ // Error state: Client code threw an uncaught exception.
479
+ return exit(
480
+ 500,
481
+ RouterError.UncaughtException,
482
+ `Uncaught exception in method dispatch: ${e instanceof Error ? e.message : String(e)}`,
483
+ ).unwrapLeft();
484
+ }
437
485
  }
438
- function errorState(status, message) {
439
- return { ok: false, status, message };
440
- }
441
- function toResponse(r) {
442
- return new Response(JSON.stringify(r), {
443
- status: r.status,
444
- headers: { "Content-Type": "application/json" },
445
- });
486
+ function exit(status, state, message, debugMessage = "") {
487
+ return Either.left(
488
+ HttpResult.fail(status, `${message} (ErrorCode: ${state}${debugMessage})`),
489
+ );
446
490
  }
447
491
  /**
448
492
  * For testing purposes
449
493
  */
450
494
  export const _cloesceInternal = {
451
- matchRoute,
452
- validateRequest,
453
- methodDispatch,
454
- RuntimeContainer,
495
+ matchRoute,
496
+ validateRequest,
497
+ methodDispatch,
498
+ RuntimeContainer,
455
499
  };