cloesce 0.0.4-unstable.8 → 0.0.5-unstable.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,187 +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";
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";
5
7
  /**
6
8
  * Singleton instance containing the cidl, constructor registry, and wasm binary.
7
9
  * These values are guaranteed to never change throughout a workers lifetime.
8
10
  */
9
11
  export class RuntimeContainer {
10
- ast;
11
- constructorRegistry;
12
- wasm;
13
- static instance;
14
- constructor(ast, constructorRegistry, wasm) {
15
- this.ast = ast;
16
- this.constructorRegistry = constructorRegistry;
17
- this.wasm = wasm;
18
- }
19
- static async init(ast, constructorRegistry, wasm) {
20
- if (this.instance)
21
- return;
22
- const wasmAbi = await loadOrmWasm(ast, wasm);
23
- this.instance = new RuntimeContainer(ast, constructorRegistry, wasmAbi);
24
- }
25
- static get() {
26
- return this.instance;
27
- }
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
+ }
28
35
  }
29
36
  /**
30
- * Runtime entry point. Given a request, undergoes: routing, validating,
31
- * hydrating, and method dispatch.
32
- *
33
- * @returns A Response with an `HttpResult` JSON body.
37
+ * Expected states in which the router may exit.
34
38
  */
35
- export async function cloesce(request, env, ast, app, constructorRegistry, envMeta, apiRoute) {
36
- //#region Initialization
37
- const ir = new Map();
38
- ir.set(envMeta.envName, env);
39
- ir.set("Request", request);
40
- await RuntimeContainer.init(ast, constructorRegistry);
41
- const d1 = env[envMeta.dbName]; // TODO: multiple dbs
42
- //#endregion
43
- //#region Global Middleware
44
- for (const m of app.global) {
45
- const res = await m(request, env, ir);
46
- if (res) {
47
- return toResponse(res);
48
- }
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]);
49
105
  }
50
- //#endregion
51
- //#region Match the route to a model method
52
- const route = matchRoute(request, ast, apiRoute);
53
- if (route.isLeft()) {
54
- return toResponse(route.value);
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());
55
123
  }
56
- const { method, model, id } = route.unwrap();
57
- //#endregion
58
- //#region Model Middleware
59
- for (const m of app.model.get(model.name) ?? []) {
60
- const res = await m(request, env, ir);
61
- if (res) {
62
- return toResponse(res);
63
- }
124
+ const methods = this.methodMiddleware.get(ctor.name);
125
+ if (!methods.has(method)) {
126
+ methods.set(method, []);
64
127
  }
65
- //#endregion
66
- //#region Validate request body to the model method
67
- const validation = await validateRequest(request, ast, model, method, id);
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);
68
153
  if (validation.isLeft()) {
69
- return toResponse(validation.value);
154
+ return validation.value;
70
155
  }
71
156
  const { params, dataSource } = validation.unwrap();
72
- //#endregion
73
- //#region Method Middleware
74
- for (const m of app.method.get(model.name)?.get(method.name) ?? []) {
75
- const res = await m(request, env, ir);
76
- if (res) {
77
- return toResponse(res);
78
- }
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
+ }
79
165
  }
80
- //#endregion
81
- //#region Instantatiate the model
82
- const crudCtx = await (async () => {
83
- if (method.is_static) {
84
- return Either.right(CrudContext.fromCtor(d1, constructorRegistry[model.name]));
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
+ );
85
177
  }
86
- const hydratedModel = await hydrateModel(constructorRegistry, d1, model, id, // id must exist after matchRoute
87
- dataSource);
88
- return hydratedModel.map((_) => CrudContext.fromInstance(d1, hydratedModel.value, constructorRegistry[model.name]));
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));
89
191
  })();
90
- if (crudCtx.isLeft()) {
91
- return toResponse(crudCtx.value);
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));
92
226
  }
93
- //#endregion
94
- return toResponse(await methodDispatch(crudCtx.unwrap(), ir, method, params));
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
+ }
95
261
  }
96
262
  /**
97
- * Matches a request to a method on a model.
263
+ * Matches a request to an ApiInvocation
98
264
  * @param apiRoute The route from the domain to the actual API, ie https://foo.com/route/to/api => route/to/api/
99
- * @returns 404 or a `MatchedRoute`
265
+ * @returns 404 or a matched route.
100
266
  */
101
- function matchRoute(request, ast, apiRoute) {
102
- const url = new URL(request.url);
103
- // Error state: We expect an exact request format, and expect that the model
104
- // and are apart of the CIDL
105
- const notFound = (e) => Either.left(errorState(404, `Path not found: ${e} ${url.pathname}`));
106
- const routeParts = url.pathname
107
- .slice(apiRoute.length)
108
- .split("/")
109
- .filter(Boolean);
110
- if (routeParts.length < 2) {
111
- return notFound("Expected /model/method or /model/:id/method");
112
- }
113
- // Attempt to extract from routeParts
114
- const modelName = routeParts[0];
115
- const methodName = routeParts[routeParts.length - 1];
116
- const id = routeParts.length === 3 ? routeParts[1] : null;
117
- const model = ast.models[modelName];
118
- if (!model) {
119
- return notFound(`Unknown model ${modelName}`);
120
- }
267
+ function matchRoute(request, ast, routePrefix) {
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) {
121
286
  const method = model.methods[methodName];
122
- if (!method) {
123
- 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);
124
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);
125
304
  if (request.method !== method.http_verb) {
126
- return notFound("Unmatched HTTP method");
305
+ return notFound(RouterError.UnmatchedHttpVerb);
127
306
  }
128
307
  return Either.right({
129
- model,
130
- method,
131
- id,
308
+ kind: "service",
309
+ namespace,
310
+ method,
311
+ service,
312
+ id: null,
132
313
  });
314
+ }
315
+ return notFound(RouterError.UnknownRoute);
133
316
  }
134
317
  /**
135
318
  * Validates the request's body/search params against a ModelMethod
136
319
  * @returns 400 or a `RequestParamMap` consisting of each parameters name mapped to its value, and
137
320
  * a data source
138
321
  */
139
- async function validateRequest(request, ast, model, method, id) {
140
- // Error state: any missing parameter, body, or malformed input will exit with 400.
141
- const invalidRequest = (e) => Either.left(errorState(400, `Invalid Request Body: ${e}`));
142
- if (!method.is_static && id == null) {
143
- return invalidRequest("Id's are required for instantiated methods.");
144
- }
145
- // Filter out any injected parameters that will not be passed
146
- // by the query.
147
- const requiredParams = method.parameters.filter((p) => !(typeof p.cidl_type === "object" && "Inject" in p.cidl_type));
148
- // Extract url or body parameters
149
- const url = new URL(request.url);
150
- let params = {};
151
- if (method.http_verb === "GET") {
152
- params = Object.fromEntries(url.searchParams.entries());
153
- }
154
- else {
155
- try {
156
- 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;
157
343
  }
158
- catch {
159
- 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;
160
352
  }
161
- }
162
- // Ensure all required params exist
163
- if (!requiredParams.every((p) => p.name in params)) {
164
- return invalidRequest(`Missing parameters.`);
165
- }
166
- // Validate all parameters type
167
- for (const p of requiredParams) {
168
- const value = params[p.name];
169
- const isPartial = typeof p.cidl_type !== "string" && "Partial" in p.cidl_type;
170
- if (!validateCidlType(ast, value, p.cidl_type, isPartial)) {
171
- return invalidRequest("Invalid parameters.");
353
+ default: {
354
+ throw new Error("not implemented");
172
355
  }
356
+ }
357
+ } catch {
358
+ return invalidRequest(RouterError.RequestMissingBody);
173
359
  }
174
- // Validate data source if exists
175
- const dataSourceParam = requiredParams.find((p) => typeof p.cidl_type === "object" && "DataSource" in p.cidl_type);
176
- const dataSource = dataSourceParam
177
- ? params[dataSourceParam.name]
178
- : null;
179
- if (dataSource &&
180
- dataSource !== NO_DATA_SOURCE &&
181
- !(dataSource in model.data_sources)) {
182
- return invalidRequest(`Unknown data source ${dataSource}`);
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();
183
378
  }
184
- return Either.right({ params, dataSource });
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 });
185
393
  }
186
394
  /**
187
395
  * Queries D1 for a particular model's ID, then transforms the SQL column output into
@@ -190,173 +398,102 @@ async function validateRequest(request, ast, model, method, id) {
190
398
  * @returns 500 if the D1 database is not synced with Cloesce and yields an error
191
399
  * @returns The instantiated model on success
192
400
  */
193
- async function hydrateModel(constructorRegistry, d1, model, id, dataSource) {
194
- // Error state: If the D1 database has been tweaked outside of Cloesce
195
- // resulting in a malformed query, exit with a 500.
196
- 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();
197
421
  // Error state: If no record is found for the id, return a 404
198
- const missingRecord = Either.left(errorState(404, "Record not found"));
199
- // Query DB
200
- let records;
201
- try {
202
- let includeTree = dataSource === NO_DATA_SOURCE
203
- ? null
204
- : constructorRegistry[model.name][dataSource];
205
- records = await d1
206
- .prepare(Orm.getQuery(constructorRegistry[model.name], includeTree).unwrap())
207
- .bind(id)
208
- .run();
209
- if (!records?.results) {
210
- return missingRecord;
211
- }
212
- if (records.error) {
213
- return malformedQuery(records.error);
214
- }
422
+ if (!records?.results) {
423
+ return exit(404, RouterError.ModelNotFound, "Record not found");
215
424
  }
216
- catch (e) {
217
- return malformedQuery(e);
425
+ if (records.error) {
426
+ return malformedQuery(records.error);
218
427
  }
219
- // Hydrate
220
- const models = mapSql(constructorRegistry[model.name], records.results, model.data_sources[dataSource]?.tree ?? {}).value;
221
- return Either.right(models[0]);
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]);
222
441
  }
223
442
  /**
224
443
  * Calls a method on a model given a list of parameters.
225
444
  * @returns 500 on an uncaught client error, 200 with a result body on success
226
445
  */
227
- async function methodDispatch(crudCtx, instanceRegistry, method, params) {
228
- // Error state: Client code ran into an uncaught exception.
229
- const uncaughtException = (e) => errorState(500, `Uncaught exception in method dispatch: ${e instanceof Error ? e.message : String(e)}`);
230
- const paramArray = [];
231
- for (const param of method.parameters) {
232
- if (params[param.name]) {
233
- paramArray.push(params[param.name]);
234
- continue;
235
- }
236
- // Injected type
237
- const injected = instanceRegistry.get(param.cidl_type["Inject"]);
238
- if (!injected) {
239
- // Error state: Injected parameters cannot be found at compile time, only at runtime.
240
- // If a injected reference does not exist, throw a 500.
241
- return errorState(500, `An injected parameter was missing from the instance registry: ${JSON.stringify(param.cidl_type)}`);
242
- }
243
- paramArray.push(injected);
244
- }
245
- // Ensure the result is always some HttpResult
246
- const resultWrapper = (res) => {
247
- const rt = method.return_type;
248
- if (rt === null) {
249
- return { ok: true, status: 200 };
250
- }
251
- if (typeof rt === "object" && rt !== null && "HttpResult" in rt) {
252
- return res;
253
- }
254
- return { ok: true, status: 200, data: res };
255
- };
256
- try {
257
- const res = await crudCtx.interceptCrud(method.name)(...paramArray);
258
- return resultWrapper(res);
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;
259
452
  }
260
- catch (e) {
261
- return uncaughtException(e);
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();
262
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
+ }
263
485
  }
264
- function validateCidlType(ast, value, cidlType, isPartial) {
265
- if (value === undefined)
266
- return isPartial;
267
- // TODO: consequences of null checking like this? 'null' is passed in
268
- // as a string for GET requests
269
- const nullable = isNullableType(cidlType);
270
- if (value == null || value === "null")
271
- return nullable;
272
- if (nullable) {
273
- cidlType = cidlType.Nullable; // Unwrap the nullable type
274
- }
275
- // Handle primitive string types with switch
276
- if (typeof cidlType === "string") {
277
- switch (cidlType) {
278
- case "Integer":
279
- return Number.isInteger(Number(value));
280
- case "Real":
281
- return !Number.isNaN(Number(value));
282
- case "Text":
283
- return typeof value === "string";
284
- case "Boolean":
285
- return typeof value === "boolean";
286
- case "DateIso":
287
- const date = new Date(value);
288
- return !isNaN(date.getTime());
289
- default:
290
- return false;
291
- }
292
- }
293
- // Handle Data Sources
294
- if ("DataSource" in cidlType) {
295
- return typeof value === "string";
296
- }
297
- // Handle Models
298
- let cidlTypeAccessor = "Partial" in cidlType
299
- ? cidlType.Partial
300
- : "Object" in cidlType
301
- ? cidlType.Object
302
- : undefined;
303
- if (cidlTypeAccessor && ast.models[cidlTypeAccessor]) {
304
- const model = ast.models[cidlTypeAccessor];
305
- if (!model || typeof value !== "object")
306
- return false;
307
- const valueObj = value;
308
- // Validate attributes
309
- if (!model.attributes.every((attr) => validateCidlType(ast, valueObj[attr.value.name], attr.value.cidl_type, isPartial))) {
310
- return false;
311
- }
312
- // Validate navigation properties
313
- return model.navigation_properties.every((nav) => {
314
- const navValue = valueObj[nav.var_name];
315
- return (navValue == null ||
316
- validateCidlType(ast, navValue, getNavigationPropertyCidlType(nav), isPartial));
317
- });
318
- }
319
- // Handle Plain Old Objects
320
- if (cidlTypeAccessor && ast.poos[cidlTypeAccessor]) {
321
- const poo = ast.poos[cidlTypeAccessor];
322
- if (!poo || typeof value !== "object")
323
- return false;
324
- const valueObj = value;
325
- // Validate attributes
326
- if (!poo.attributes.every((attr) => validateCidlType(ast, valueObj[attr.name], attr.cidl_type, isPartial))) {
327
- return false;
328
- }
329
- return true;
330
- }
331
- if ("Array" in cidlType) {
332
- const arr = cidlType.Array;
333
- return (Array.isArray(value) &&
334
- value.every((v) => validateCidlType(ast, v, arr, isPartial)));
335
- }
336
- if ("HttpResult" in cidlType) {
337
- if (value === null)
338
- return cidlType.HttpResult === null;
339
- if (cidlType.HttpResult === null)
340
- return false;
341
- return validateCidlType(ast, value, cidlType.HttpResult, isPartial);
342
- }
343
- return false;
344
- }
345
- function errorState(status, message) {
346
- return { ok: false, status, message };
347
- }
348
- function toResponse(r) {
349
- return new Response(JSON.stringify(r), {
350
- status: r.status,
351
- headers: { "Content-Type": "application/json" },
352
- });
486
+ function exit(status, state, message, debugMessage = "") {
487
+ return Either.left(
488
+ HttpResult.fail(status, `${message} (ErrorCode: ${state}${debugMessage})`),
489
+ );
353
490
  }
354
491
  /**
355
492
  * For testing purposes
356
493
  */
357
494
  export const _cloesceInternal = {
358
- matchRoute,
359
- validateRequest,
360
- methodDispatch,
361
- RuntimeContainer,
495
+ matchRoute,
496
+ validateRequest,
497
+ methodDispatch,
498
+ RuntimeContainer,
362
499
  };