@stone-js/resources 0.8.8 → 0.8.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ContractChecker.d.ts +96 -0
- package/dist/Resource.d.ts +96 -28
- package/dist/declarations.d.ts +54 -16
- package/dist/decorators/ApiResource.d.ts +21 -0
- package/dist/decorators/Resources.d.ts +27 -0
- package/dist/decorators/Returns.d.ts +37 -0
- package/dist/decorators/constants.d.ts +11 -0
- package/dist/defineResource.d.ts +31 -9
- package/dist/errors/ResourceContractError.d.ts +31 -0
- package/dist/helpers.d.ts +16 -3
- package/dist/index.d.ts +13 -4
- package/dist/index.js +684 -45
- package/dist/middleware/BlueprintMiddleware.d.ts +18 -0
- package/dist/middleware/ResourceRouteMiddleware.d.ts +106 -0
- package/dist/options/ResourcesBlueprint.d.ts +65 -0
- package/package.json +9 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,147 @@
|
|
|
1
|
+
import { RuntimeError, setClassMetadata, hasMetadata, getMetadata, classDecoratorLegacyWrapper, addBlueprint, methodDecoratorLegacyWrapper, addMetadata } from '@stone-js/core';
|
|
2
|
+
import { cloneValue } from '@stone-js/config';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs a schema and reports what it said.
|
|
6
|
+
*
|
|
7
|
+
* This module reads schemas; it does not validate requests, own engines, or keep a registry — so it
|
|
8
|
+
* carries its own reader rather than depending on a validation module to project data. The dialects
|
|
9
|
+
* it accepts are public specifications, not one library's API: Standard Schema first, then the
|
|
10
|
+
* `safeParse`/`parse` shape, then a plain `validate`. An application writes its schemas once and both
|
|
11
|
+
* sides of the boundary read them.
|
|
12
|
+
*
|
|
13
|
+
* Substitutable: pass your own {@link IContractChecker} and this one steps aside.
|
|
14
|
+
*/
|
|
15
|
+
class ContractChecker {
|
|
16
|
+
/**
|
|
17
|
+
* Factory.
|
|
18
|
+
*
|
|
19
|
+
* @returns A checker.
|
|
20
|
+
*/
|
|
21
|
+
static create() {
|
|
22
|
+
return new this();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Run a schema against a value.
|
|
26
|
+
*
|
|
27
|
+
* @param schema - The contract.
|
|
28
|
+
* @param data - What the resource produced.
|
|
29
|
+
* @returns Whether it holds, the parsed value, and what failed.
|
|
30
|
+
* @throws {TypeError} When the value is not a schema this can run, because guessing would mean
|
|
31
|
+
* projecting unchecked data while reporting success.
|
|
32
|
+
*/
|
|
33
|
+
check(schema, data) {
|
|
34
|
+
if (this.isStandard(schema)) {
|
|
35
|
+
return this.fromStandard(schema, data);
|
|
36
|
+
}
|
|
37
|
+
if (this.hasSafeParse(schema)) {
|
|
38
|
+
return this.fromSafeParse(schema, data);
|
|
39
|
+
}
|
|
40
|
+
if (this.hasValidate(schema)) {
|
|
41
|
+
return this.fromValidate(schema, data);
|
|
42
|
+
}
|
|
43
|
+
if (this.hasParse(schema)) {
|
|
44
|
+
return this.fromParse(schema, data);
|
|
45
|
+
}
|
|
46
|
+
throw new TypeError('A resource schema must be runnable: a Standard Schema (Zod, Valibot, ArkType and others), or ' +
|
|
47
|
+
'something exposing `safeParse`, `parse` or `validate`. Reporting success on a value this cannot ' +
|
|
48
|
+
'run would mean projecting unchecked data, which is the one thing a contract must never do.');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Read a Standard Schema result.
|
|
52
|
+
*
|
|
53
|
+
* The synchronous path only: a schema whose validation is asynchronous returns a promise, and a
|
|
54
|
+
* promise is not a result. Saying so beats treating it as one, which is how `[object Promise]`
|
|
55
|
+
* reaches a response body.
|
|
56
|
+
*
|
|
57
|
+
* @param schema - The schema.
|
|
58
|
+
* @param data - The value.
|
|
59
|
+
* @returns The outcome.
|
|
60
|
+
*/
|
|
61
|
+
fromStandard(schema, data) {
|
|
62
|
+
const result = schema['~standard'].validate(data);
|
|
63
|
+
if (result instanceof Promise) {
|
|
64
|
+
throw new TypeError('This resource schema validates asynchronously, which a projection cannot consume. Use the ' +
|
|
65
|
+
'synchronous form of your schema, or supply a checker that awaits it.');
|
|
66
|
+
}
|
|
67
|
+
const issues = result.issues;
|
|
68
|
+
return issues === undefined || issues.length === 0
|
|
69
|
+
? { success: true, value: result.value }
|
|
70
|
+
: { success: false, issues: issues.map((issue) => this.toIssue(issue)) };
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Read a `safeParse` result (the Zod-like shape).
|
|
74
|
+
*
|
|
75
|
+
* @param schema - The schema.
|
|
76
|
+
* @param data - The value.
|
|
77
|
+
* @returns The outcome.
|
|
78
|
+
*/
|
|
79
|
+
fromSafeParse(schema, data) {
|
|
80
|
+
const result = schema.safeParse(data);
|
|
81
|
+
return result.success === true
|
|
82
|
+
? { success: true, value: result.data }
|
|
83
|
+
: { success: false, issues: (result.error?.issues ?? []).map((issue) => this.toIssue(issue)) };
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Read a native `validate` result: already the shape this module reports.
|
|
87
|
+
*
|
|
88
|
+
* @param schema - The schema.
|
|
89
|
+
* @param data - The value.
|
|
90
|
+
* @returns The outcome.
|
|
91
|
+
*/
|
|
92
|
+
fromValidate(schema, data) {
|
|
93
|
+
const result = schema.validate(data);
|
|
94
|
+
return result?.success === true
|
|
95
|
+
? { success: true, value: result.value }
|
|
96
|
+
: { success: false, issues: (result?.issues ?? []).map((issue) => this.toIssue(issue)) };
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Read a throwing `parse`.
|
|
100
|
+
*
|
|
101
|
+
* @param schema - The schema.
|
|
102
|
+
* @param data - The value.
|
|
103
|
+
* @returns The outcome.
|
|
104
|
+
*/
|
|
105
|
+
fromParse(schema, data) {
|
|
106
|
+
try {
|
|
107
|
+
return { success: true, value: schema.parse(data) };
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const issues = error?.issues ?? [{ message: String(error?.message ?? error), path: [] }];
|
|
111
|
+
return { success: false, issues: issues.map((issue) => this.toIssue(issue)) };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Normalise one issue, whatever dialect reported it.
|
|
116
|
+
*
|
|
117
|
+
* @param issue - The raw issue.
|
|
118
|
+
* @returns The issue.
|
|
119
|
+
*/
|
|
120
|
+
toIssue(issue) {
|
|
121
|
+
const path = Array.isArray(issue.path)
|
|
122
|
+
// Standard Schema paths may carry segment objects rather than plain keys.
|
|
123
|
+
? issue.path.map((segment) => (typeof segment === 'object' && segment !== null ? segment.key : segment))
|
|
124
|
+
: [];
|
|
125
|
+
return { message: issue.message ?? 'Invalid value', path };
|
|
126
|
+
}
|
|
127
|
+
/** @param schema - The candidate. @returns Whether it speaks Standard Schema. */
|
|
128
|
+
isStandard(schema) {
|
|
129
|
+
return typeof schema?.['~standard']?.validate === 'function';
|
|
130
|
+
}
|
|
131
|
+
/** @param schema - The candidate. @returns Whether it exposes `safeParse`. */
|
|
132
|
+
hasSafeParse(schema) {
|
|
133
|
+
return typeof schema?.safeParse === 'function';
|
|
134
|
+
}
|
|
135
|
+
/** @param schema - The candidate. @returns Whether it exposes `validate`. */
|
|
136
|
+
hasValidate(schema) {
|
|
137
|
+
return typeof schema?.validate === 'function';
|
|
138
|
+
}
|
|
139
|
+
/** @param schema - The candidate. @returns Whether it exposes `parse`. */
|
|
140
|
+
hasParse(schema) {
|
|
141
|
+
return typeof schema?.parse === 'function';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
1
145
|
/**
|
|
2
146
|
* Returns a copy of `object` without any `undefined` values (so conditional fields simply vanish).
|
|
3
147
|
*
|
|
@@ -60,88 +204,230 @@ function applyFields(output, fields) {
|
|
|
60
204
|
return fields !== undefined && fields.length > 0 ? only(clean, fields) : clean;
|
|
61
205
|
}
|
|
62
206
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
207
|
+
* Build a {@link ResourceContext} from an incoming event.
|
|
208
|
+
*
|
|
209
|
+
* The parameter names are configuration, not convention: an API that already answers `?view=` or
|
|
210
|
+
* `?only=` keeps its own vocabulary instead of gaining a second one. Defaults are `fields`, `include`
|
|
211
|
+
* and `view`.
|
|
212
|
+
*
|
|
213
|
+
* The authenticated principal is read too, because deciding what a caller may see is the most common
|
|
214
|
+
* reason two callers get different shapes — and a resource that cannot see who is asking has to be
|
|
215
|
+
* told by the handler, which is exactly the plumbing this module exists to remove.
|
|
216
|
+
*
|
|
217
|
+
* Agnostic: the event only needs `get(key)`.
|
|
65
218
|
*
|
|
66
219
|
* @param event - Anything with `get(key)` (an `IncomingHttpEvent`, a URL search wrapper, …).
|
|
220
|
+
* @param blueprint - The blueprint carrying the parameter names, when there is one.
|
|
67
221
|
* @param extra - Extra context to merge in.
|
|
68
222
|
* @returns The resource context.
|
|
69
223
|
*/
|
|
70
|
-
function contextFromEvent(event, extra = {}) {
|
|
224
|
+
function contextFromEvent(event, blueprint, extra = {}) {
|
|
225
|
+
const names = blueprint?.get('stone.resources.params', {}) ?? {};
|
|
226
|
+
const fragment = event.get(names.fragment ?? 'view', '');
|
|
71
227
|
return {
|
|
72
228
|
...extra,
|
|
73
|
-
|
|
74
|
-
|
|
229
|
+
event,
|
|
230
|
+
// `getUser()` and not `get('user')`: the principal is set through a resolver, not as metadata, so
|
|
231
|
+
// the generic accessor never reaches it. Duck-typed, because the kernel is agnostic and an event
|
|
232
|
+
// without a user simply has no such method.
|
|
233
|
+
principal: event.getUser?.(),
|
|
234
|
+
fields: splitCsv(event.get(names.fields ?? 'fields', '')),
|
|
235
|
+
include: splitCsv(event.get(names.include ?? 'include', '')),
|
|
236
|
+
fragment: fragment.length > 0 ? fragment : undefined
|
|
75
237
|
};
|
|
76
238
|
}
|
|
77
239
|
/**
|
|
78
|
-
*
|
|
240
|
+
* Split a comma-separated string into a trimmed, non-empty list (or `undefined` when empty).
|
|
79
241
|
*
|
|
80
242
|
* @param value - The CSV string.
|
|
81
243
|
* @returns The list, or `undefined`.
|
|
82
244
|
*/
|
|
83
245
|
function splitCsv(value) {
|
|
84
|
-
const parts = value.split(',').map((part) => part.trim()).filter((part) => part.length > 0);
|
|
246
|
+
const parts = String(value).split(',').map((part) => part.trim()).filter((part) => part.length > 0);
|
|
85
247
|
return parts.length > 0 ? parts : undefined;
|
|
86
248
|
}
|
|
87
249
|
|
|
88
250
|
/**
|
|
89
|
-
*
|
|
251
|
+
* Raised when what a handler produced does not match the schema its resource published.
|
|
90
252
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
* the
|
|
253
|
+
* This is a server-side fault, deliberately. The resource's schema is a promise made to every caller
|
|
254
|
+
* and to the published contract; data that breaks it means the application is about to answer
|
|
255
|
+
* something it documented it would not. Returning it anyway is the failure — a client cannot detect
|
|
256
|
+
* it, and a consumer generated from the contract will break on a field that was supposed to be there.
|
|
257
|
+
*
|
|
258
|
+
* It fires on a genuine breach, not on a difference: a schema strips what it does not describe, so
|
|
259
|
+
* extra fields are simply not exposed. Reaching this means something the contract requires is missing
|
|
260
|
+
* or has the wrong type.
|
|
261
|
+
*/
|
|
262
|
+
class ResourceContractError extends RuntimeError {
|
|
263
|
+
/** What failed, so a log says which field rather than "validation failed". */
|
|
264
|
+
issues;
|
|
265
|
+
/**
|
|
266
|
+
* @param message - What went wrong.
|
|
267
|
+
* @param options - Additional error options, including the issues.
|
|
268
|
+
*/
|
|
269
|
+
constructor(message, options = {}) {
|
|
270
|
+
super(message, options);
|
|
271
|
+
this.name = 'ResourceContractError';
|
|
272
|
+
this.issues = options.issues ?? [];
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Base API resource — the layer responsible for exposing data.
|
|
278
|
+
*
|
|
279
|
+
* A resource answers two questions, and the second is what makes it worth having. *What leaves?* —
|
|
280
|
+
* and *what did you promise leaves?* The promise is a schema, so the same declaration validates the
|
|
281
|
+
* response, documents it in the published contract, and lets a caller ask for a named subset of it.
|
|
282
|
+
*
|
|
283
|
+
* Projection is the schema's own work: what the schema does not describe is not exposed, so a field
|
|
284
|
+
* added to a model later — a password hash, an internal flag — cannot leak by being forgotten.
|
|
95
285
|
*
|
|
96
286
|
* @example
|
|
97
287
|
* ```ts
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
288
|
+
* @ApiResource('user')
|
|
289
|
+
* export class UserResource extends Resource<User> {
|
|
290
|
+
* constructor ({ posts }: { posts: PostService }) {
|
|
291
|
+
* super()
|
|
292
|
+
* this.posts = posts
|
|
293
|
+
* }
|
|
294
|
+
*
|
|
295
|
+
* schema () {
|
|
296
|
+
* return z.object({ id: z.number(), name: z.string(), posts: z.array(z.string()).optional() })
|
|
297
|
+
* }
|
|
298
|
+
*
|
|
299
|
+
* fragments () {
|
|
300
|
+
* return { summary: z.object({ id: z.number(), name: z.string() }) }
|
|
301
|
+
* }
|
|
302
|
+
*
|
|
303
|
+
* async data (user: User) {
|
|
304
|
+
* return { ...user, posts: await this.posts.titlesOf(user.id) }
|
|
106
305
|
* }
|
|
107
306
|
* }
|
|
108
307
|
* ```
|
|
109
308
|
*/
|
|
110
309
|
class Resource {
|
|
310
|
+
checker;
|
|
311
|
+
onViolation;
|
|
312
|
+
/**
|
|
313
|
+
* @param dependencies - Auto-wired services. Nothing is required: this module reads schemas with its
|
|
314
|
+
* own checker, so exposing data never depends on a validation module being
|
|
315
|
+
* enabled. Pass `checker` to substitute a dialect of your own.
|
|
316
|
+
*/
|
|
317
|
+
constructor(dependencies = {}) {
|
|
318
|
+
this.checker = dependencies.checker ?? ContractChecker.create();
|
|
319
|
+
this.onViolation = dependencies.onViolation ?? 'throw';
|
|
320
|
+
}
|
|
111
321
|
/**
|
|
112
|
-
*
|
|
322
|
+
* Project one model.
|
|
323
|
+
*
|
|
324
|
+
* The order is the design: complete the data, choose the contract the caller asked for, hold the
|
|
325
|
+
* result against it, then narrow. Validation happens *before* narrowing, so the promise is checked
|
|
326
|
+
* against everything the resource produced rather than against whatever survived a query parameter.
|
|
113
327
|
*
|
|
114
328
|
* @param model - The domain model.
|
|
115
329
|
* @param context - The resource context.
|
|
116
|
-
* @returns The
|
|
330
|
+
* @returns The projected output.
|
|
331
|
+
* @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
|
|
117
332
|
*/
|
|
118
|
-
item(model, context = {}) {
|
|
119
|
-
|
|
333
|
+
async item(model, context = {}) {
|
|
334
|
+
const data = this.data !== undefined ? await this.data(model, context) : model;
|
|
335
|
+
const schema = await this.schemaFor(context);
|
|
336
|
+
const projected = await this.project(data, schema, context);
|
|
337
|
+
return applyFields(projected, context.fields);
|
|
120
338
|
}
|
|
121
339
|
/**
|
|
122
|
-
*
|
|
340
|
+
* Project a collection.
|
|
341
|
+
*
|
|
342
|
+
* Sequential rather than concurrent: `data()` may reach a database or an API, and a hundred models
|
|
343
|
+
* turning into a hundred simultaneous calls is a denial of service an application performs on
|
|
344
|
+
* itself. A resource that wants concurrency batches inside its own `data()`, where it knows the cost.
|
|
123
345
|
*
|
|
124
346
|
* @param models - The domain models.
|
|
125
347
|
* @param context - The resource context.
|
|
126
|
-
* @returns The
|
|
348
|
+
* @returns The projected collection.
|
|
127
349
|
*/
|
|
128
|
-
collection(models, context = {}) {
|
|
129
|
-
|
|
350
|
+
async collection(models, context = {}) {
|
|
351
|
+
const out = [];
|
|
352
|
+
for (const model of models) {
|
|
353
|
+
out.push(await this.item(model, context));
|
|
354
|
+
}
|
|
355
|
+
return out;
|
|
130
356
|
}
|
|
131
357
|
/**
|
|
132
|
-
*
|
|
358
|
+
* Project into a `{ data, meta }` envelope.
|
|
133
359
|
*
|
|
134
360
|
* @param models - A model or a collection.
|
|
135
361
|
* @param context - The resource context.
|
|
136
362
|
* @param meta - Optional metadata (pagination, counts, …).
|
|
137
363
|
* @returns The envelope.
|
|
138
364
|
*/
|
|
139
|
-
response(models, context = {}, meta) {
|
|
140
|
-
const data = Array.isArray(models)
|
|
365
|
+
async response(models, context = {}, meta) {
|
|
366
|
+
const data = Array.isArray(models)
|
|
367
|
+
? await this.collection(models, context)
|
|
368
|
+
: await this.item(models, context);
|
|
141
369
|
return meta === undefined ? { data } : { data, meta };
|
|
142
370
|
}
|
|
143
371
|
/**
|
|
144
|
-
*
|
|
372
|
+
* The schema to hold this projection against: the requested fragment when the resource exposes one,
|
|
373
|
+
* the full contract otherwise.
|
|
374
|
+
*
|
|
375
|
+
* An unknown fragment falls back to the full contract rather than failing. A caller guessing
|
|
376
|
+
* `?view=nonsense` is asking a question, not attacking: answering the documented shape is more
|
|
377
|
+
* useful than a 500, and the fragments a resource exposes are published in the contract anyway.
|
|
378
|
+
*
|
|
379
|
+
* @param context - The resource context.
|
|
380
|
+
* @returns The schema.
|
|
381
|
+
*/
|
|
382
|
+
async schemaFor(context) {
|
|
383
|
+
const name = context.fragment;
|
|
384
|
+
if (name !== undefined && this.fragments !== undefined) {
|
|
385
|
+
const available = await this.fragments(context);
|
|
386
|
+
if (available[name] !== undefined) {
|
|
387
|
+
return available[name];
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
return this.schema(context);
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Hold the data against the contract, and return what the contract describes.
|
|
394
|
+
*
|
|
395
|
+
* The schema is the projection: its parsed value is the output, so a field the contract does not
|
|
396
|
+
* mention is not exposed, whatever the model gains later.
|
|
397
|
+
*
|
|
398
|
+
* @param data - The completed data.
|
|
399
|
+
* @param schema - The contract.
|
|
400
|
+
* @param context - The resource context.
|
|
401
|
+
* @returns The projected value.
|
|
402
|
+
* @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
|
|
403
|
+
*/
|
|
404
|
+
async project(data, schema, context) {
|
|
405
|
+
// From the context first, so an application may hand a projection its own dialect for one call;
|
|
406
|
+
// otherwise this module's own reader, which is why exposing data needs no validation module.
|
|
407
|
+
const checker = context.checker ?? this.checker;
|
|
408
|
+
const result = checker.check(schema, data);
|
|
409
|
+
if (result.success) {
|
|
410
|
+
return result.value;
|
|
411
|
+
}
|
|
412
|
+
const issues = result.issues ?? [];
|
|
413
|
+
const detail = issues
|
|
414
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
|
|
415
|
+
.join('; ');
|
|
416
|
+
const message = `${this.constructor.name} produced data that does not match the contract it publishes: ${detail}. ` +
|
|
417
|
+
'The response was not sent, because a caller cannot detect a broken contract and a consumer ' +
|
|
418
|
+
'generated from it would break on the field that is missing.';
|
|
419
|
+
// Configured per application, and per projection when a caller of `item()` wants to override it.
|
|
420
|
+
const policy = context.onViolation ?? this.onViolation;
|
|
421
|
+
if (policy === 'warn') {
|
|
422
|
+
// Availability over integrity, chosen explicitly by configuration: the caller still gets what
|
|
423
|
+
// the schema could parse, and the breach is on the record.
|
|
424
|
+
console.warn(`[@stone-js/resources] ${message}`);
|
|
425
|
+
return data;
|
|
426
|
+
}
|
|
427
|
+
throw new ResourceContractError(message, { issues, metadata: { context } });
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Include a value only when `condition` holds (otherwise the field is dropped).
|
|
145
431
|
*
|
|
146
432
|
* @param condition - Whether to include the value.
|
|
147
433
|
* @param value - The value, or a lazy factory (only evaluated when included).
|
|
@@ -154,7 +440,7 @@ class Resource {
|
|
|
154
440
|
return typeof value === 'function' ? value() : value;
|
|
155
441
|
}
|
|
156
442
|
/**
|
|
157
|
-
* Include a value only when the relation was requested
|
|
443
|
+
* Include a value only when the relation was requested through `context.include`.
|
|
158
444
|
*
|
|
159
445
|
* @param context - The resource context.
|
|
160
446
|
* @param name - The relation name.
|
|
@@ -167,25 +453,378 @@ class Resource {
|
|
|
167
453
|
}
|
|
168
454
|
|
|
169
455
|
/**
|
|
170
|
-
* The imperative
|
|
171
|
-
*
|
|
172
|
-
*
|
|
456
|
+
* The imperative way to define a resource: an object instead of a class.
|
|
457
|
+
*
|
|
458
|
+
* Parity is the rule, so this declares exactly what a class declares and gets exactly what a class
|
|
459
|
+
* gets. It needs nothing injected: this module reads schemas with its own checker.
|
|
173
460
|
*
|
|
174
|
-
* @param
|
|
461
|
+
* @param definition - The schema, and optionally fragments and a `data()` hook.
|
|
462
|
+
* @param dependencies - Optional explicit services, for a resource used outside a request.
|
|
175
463
|
* @returns A resource.
|
|
176
464
|
*
|
|
177
465
|
* @example
|
|
178
466
|
* ```ts
|
|
179
|
-
* const userResource = defineResource<User>(
|
|
180
|
-
*
|
|
467
|
+
* export const userResource = defineResource<User>({
|
|
468
|
+
* schema: z.object({ id: z.number(), name: z.string() }),
|
|
469
|
+
* fragments: { summary: z.object({ id: z.number() }) },
|
|
470
|
+
* data: async (user) => ({ ...user, posts: await posts.titlesOf(user.id) })
|
|
471
|
+
* })
|
|
472
|
+
* ```
|
|
473
|
+
*/
|
|
474
|
+
function defineResource(definition, dependencies = {}) {
|
|
475
|
+
const resource = new class extends Resource {
|
|
476
|
+
async schema(context) {
|
|
477
|
+
if (typeof definition.schema !== 'function') {
|
|
478
|
+
return definition.schema;
|
|
479
|
+
}
|
|
480
|
+
const build = definition.schema;
|
|
481
|
+
return await build(context);
|
|
482
|
+
}
|
|
483
|
+
}(dependencies);
|
|
484
|
+
if (definition.fragments !== undefined) {
|
|
485
|
+
const declared = definition.fragments;
|
|
486
|
+
resource.fragments = async (context) => {
|
|
487
|
+
if (typeof declared !== 'function') {
|
|
488
|
+
return declared;
|
|
489
|
+
}
|
|
490
|
+
return await declared(context);
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (definition.data !== undefined) {
|
|
494
|
+
resource.data = definition.data;
|
|
495
|
+
}
|
|
496
|
+
return resource;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Metadata key carrying what a handler method declared with `@Returns`.
|
|
501
|
+
*
|
|
502
|
+
* The module owns its key, which is what makes it independent: a resource shapes the output whether
|
|
503
|
+
* or not a router is in play, because the declaration lives on the handler, not on a route.
|
|
504
|
+
*/
|
|
505
|
+
const RETURNS_KEY = '@stone-js/resources/returns';
|
|
506
|
+
/**
|
|
507
|
+
* Metadata key carrying the alias a resource class registered itself under.
|
|
508
|
+
*/
|
|
509
|
+
const API_RESOURCE_KEY = '@stone-js/resources/resource';
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Class decorator: register a resource class under a name.
|
|
513
|
+
*
|
|
514
|
+
* ```ts
|
|
515
|
+
* @ApiResource('user')
|
|
516
|
+
* export class UserResource extends Resource<User> {
|
|
517
|
+
* toArray (user: User) { return { id: user.id, name: user.name } }
|
|
518
|
+
* }
|
|
181
519
|
* ```
|
|
520
|
+
*
|
|
521
|
+
* Routes and handlers then refer to it by name (`@Returns('user')`, or `{ resource: 'user' }`), so
|
|
522
|
+
* resources live in their own files, organised however the application likes, and nothing has to be
|
|
523
|
+
* imported at the route. The class is resolved by the container, so its constructor receives services
|
|
524
|
+
* and `toArray` can use them: a resource that formats dates for the caller's locale needs i18n, and
|
|
525
|
+
* this is how it gets it.
|
|
526
|
+
*
|
|
527
|
+
* @param alias - The name the resource is registered under. Defaults to the class name, which the
|
|
528
|
+
* discovery middleware fills in, since it is the one holding the class.
|
|
529
|
+
* @returns A class decorator.
|
|
182
530
|
*/
|
|
183
|
-
|
|
184
|
-
return
|
|
185
|
-
|
|
186
|
-
|
|
531
|
+
const ApiResource = (alias) => {
|
|
532
|
+
return setClassMetadata(API_RESOURCE_KEY, { alias });
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Route middleware: shapes what a route returns, after its handler ran.
|
|
537
|
+
*
|
|
538
|
+
* A route says what it exposes, once, where the route is defined:
|
|
539
|
+
*
|
|
540
|
+
* ```ts
|
|
541
|
+
* @Get('/users/:id', { resource: UserResource })
|
|
542
|
+
* ```
|
|
543
|
+
*
|
|
544
|
+
* The handler returns its domain model, whole, and this applies the resource on the way out. That is
|
|
545
|
+
* the point: a service should not have to know which fields are public, and a handler should not have
|
|
546
|
+
* to remember to strip them.
|
|
547
|
+
*/
|
|
548
|
+
class ResourceRouteMiddleware {
|
|
549
|
+
blueprint;
|
|
550
|
+
container;
|
|
551
|
+
/**
|
|
552
|
+
* @param dependencies - Auto-wired container services.
|
|
553
|
+
*/
|
|
554
|
+
constructor({ blueprint, container }) {
|
|
555
|
+
this.blueprint = blueprint;
|
|
556
|
+
this.container = container;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Run the handler, then shape what it returned.
|
|
560
|
+
*
|
|
561
|
+
* It handles both of the things a handler may hand back, which is the part that used to be wrong. A
|
|
562
|
+
* handler carrying a response decorator (`@JsonHttpResponse(201)`) has already been turned into a
|
|
563
|
+
* response by the time any route middleware runs, because that decorator wraps the method itself.
|
|
564
|
+
* Projecting the response object produced an empty payload and dropped the status with it. So a
|
|
565
|
+
* response is now projected **through its content**, in place: the payload is shaped and the status,
|
|
566
|
+
* the headers and everything else the handler chose are left exactly as they were.
|
|
567
|
+
*
|
|
568
|
+
* @param event - The incoming event.
|
|
569
|
+
* @param next - The next middleware.
|
|
570
|
+
* @returns The shaped output, or the untouched result when the route declares no resource.
|
|
571
|
+
*/
|
|
572
|
+
async handle(event, next) {
|
|
573
|
+
const resource = this.resourceFor(event);
|
|
574
|
+
const result = await next(event);
|
|
575
|
+
if (resource === undefined || result === undefined || result === null) {
|
|
576
|
+
return result;
|
|
577
|
+
}
|
|
578
|
+
const context = contextFromEvent(event, this.blueprint, {
|
|
579
|
+
onViolation: this.blueprint.get('stone.resources', {}).onViolation
|
|
580
|
+
});
|
|
581
|
+
if (this.isContentBearing(result)) {
|
|
582
|
+
const shaped = await this.shape(resource, result.content, context);
|
|
583
|
+
result.setContent(shaped);
|
|
584
|
+
return result;
|
|
585
|
+
}
|
|
586
|
+
return await this.shape(resource, result, context);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Project a value, whether it is one model or many.
|
|
590
|
+
*
|
|
591
|
+
* @param resource - The resource to apply.
|
|
592
|
+
* @param value - The value the handler produced.
|
|
593
|
+
* @param context - The resource context.
|
|
594
|
+
* @returns The projected value.
|
|
595
|
+
*/
|
|
596
|
+
async shape(resource, value, context) {
|
|
597
|
+
if (value === undefined || value === null) {
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
return Array.isArray(value)
|
|
601
|
+
? await resource.collection(value, context)
|
|
602
|
+
: await resource.item(value, context);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Whether a value is a response carrying a payload this can replace.
|
|
606
|
+
*
|
|
607
|
+
* Duck-typed: the kernel is agnostic, and each platform has its own response type.
|
|
608
|
+
*
|
|
609
|
+
* @param value - The value to test.
|
|
610
|
+
* @returns Whether it carries content.
|
|
611
|
+
*/
|
|
612
|
+
isContentBearing(value) {
|
|
613
|
+
return typeof value === 'object' && value !== null &&
|
|
614
|
+
typeof value.setContent === 'function' &&
|
|
615
|
+
'content' in value;
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* The resource the matched route declared, with a registered name resolved to its resource.
|
|
619
|
+
*
|
|
620
|
+
* @param event - The incoming event.
|
|
621
|
+
* @returns The resource, or `undefined` when the route declares none.
|
|
622
|
+
*/
|
|
623
|
+
resourceFor(event) {
|
|
624
|
+
const declared = this.declarationFor(event);
|
|
625
|
+
if (declared === undefined) {
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
if (typeof declared !== 'string') {
|
|
629
|
+
return this.resolve(declared);
|
|
630
|
+
}
|
|
631
|
+
const registry = this.blueprint.get('stone.resources', {}).registry ?? {};
|
|
632
|
+
const resource = registry[declared];
|
|
633
|
+
if (resource === undefined) {
|
|
634
|
+
throw new TypeError(`The route declares \`resource: '${declared}'\`, but no resource is registered under that ` +
|
|
635
|
+
'name. Register it with `blueprint.set(\'stone.resources.registry\', { ' + declared + ': … })`, ' +
|
|
636
|
+
'or declare the resource inline on the route.');
|
|
637
|
+
}
|
|
638
|
+
return this.resolve(resource);
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* What the handler about to run declared, from either of the two places it may live.
|
|
642
|
+
*
|
|
643
|
+
* The route's own option comes first, because when a router is in play a route is the single
|
|
644
|
+
* description of itself. Failing that, the handler's own `@Returns` metadata is read: that form owns
|
|
645
|
+
* its key and needs no router, so the same module shapes the output of a routed request, a
|
|
646
|
+
* single-handler service, a CLI command or a browser event.
|
|
647
|
+
*
|
|
648
|
+
* @param event - The incoming event.
|
|
649
|
+
* @returns What was declared, or `undefined`.
|
|
650
|
+
*/
|
|
651
|
+
declarationFor(event) {
|
|
652
|
+
// Duck-typed throughout: the kernel is agnostic, and an event without a router carries no route.
|
|
653
|
+
const route = event.getRoute?.();
|
|
654
|
+
const onRoute = route?.getOption?.('resource');
|
|
655
|
+
if (onRoute !== undefined) {
|
|
656
|
+
return onRoute;
|
|
657
|
+
}
|
|
658
|
+
const handler = route?.getOption?.('handler') ??
|
|
659
|
+
this.blueprint.get('stone.kernel.eventHandler', {});
|
|
660
|
+
return this.declaredOnHandler(handler);
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* What a handler declared with `@Returns`, if anything.
|
|
664
|
+
*
|
|
665
|
+
* @param handler - The handler about to run.
|
|
666
|
+
* @returns What the matching method declared, or `undefined`.
|
|
667
|
+
*/
|
|
668
|
+
declaredOnHandler(handler) {
|
|
669
|
+
const module = handler?.module;
|
|
670
|
+
if (module === undefined || !hasMetadata(module, RETURNS_KEY)) {
|
|
671
|
+
return undefined;
|
|
187
672
|
}
|
|
188
|
-
|
|
673
|
+
const declarations = getMetadata(module, RETURNS_KEY, []);
|
|
674
|
+
const action = handler?.action;
|
|
675
|
+
// A single-handler module declares one; a controller declares one per method.
|
|
676
|
+
return (action === undefined
|
|
677
|
+
? declarations[0]
|
|
678
|
+
: declarations.find((declaration) => declaration.action === action))?.resource;
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Resolve a registered entry: a resource class goes through the container, so its constructor gets
|
|
682
|
+
* the services it asked for — the validator it holds its own contract against, and whatever its
|
|
683
|
+
* `data()` needs to complete a model.
|
|
684
|
+
*
|
|
685
|
+
* @param entry - A resource, or a class to resolve into one.
|
|
686
|
+
* @returns The resource.
|
|
687
|
+
*/
|
|
688
|
+
resolve(entry) {
|
|
689
|
+
if (typeof entry !== 'function') {
|
|
690
|
+
return entry;
|
|
691
|
+
}
|
|
692
|
+
const ResourceClass = entry;
|
|
693
|
+
return this.container?.resolve?.(ResourceClass, true) ?? new ResourceClass({});
|
|
694
|
+
}
|
|
189
695
|
}
|
|
696
|
+
/**
|
|
697
|
+
* Meta middleware for route-declared resources.
|
|
698
|
+
*
|
|
699
|
+
* Registered on `stone.router.middleware` by `resourcesBlueprint`. Its priority puts it outside
|
|
700
|
+
* validation, so a request is shaped on the way out after having been validated on the way in.
|
|
701
|
+
*/
|
|
702
|
+
const MetaResourceRouteMiddleware = {
|
|
703
|
+
module: ResourceRouteMiddleware,
|
|
704
|
+
isClass: true,
|
|
705
|
+
priority: 4
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Build-phase middleware: collect every class registered with `@ApiResource` into the registry.
|
|
710
|
+
*
|
|
711
|
+
* The same scan the router does for its route definitions, applied to this module's own key. After it
|
|
712
|
+
* runs, `stone.resources.registry` maps each alias to its class, so a route or a handler can name a
|
|
713
|
+
* resource instead of importing it, and `@stone-js/openapi` can walk the registry to publish response
|
|
714
|
+
* shapes without loading anything itself.
|
|
715
|
+
*
|
|
716
|
+
* @param context - The blueprint context.
|
|
717
|
+
* @param next - The next blueprint middleware.
|
|
718
|
+
* @returns The blueprint.
|
|
719
|
+
*/
|
|
720
|
+
async function ApiResourceMiddleware(context, next) {
|
|
721
|
+
const registered = context
|
|
722
|
+
.modules
|
|
723
|
+
.filter((module) => hasMetadata(module, API_RESOURCE_KEY))
|
|
724
|
+
.reduce((registry, module) => {
|
|
725
|
+
const { alias } = getMetadata(module, API_RESOURCE_KEY, {});
|
|
726
|
+
return { ...registry, [alias ?? module.name]: module };
|
|
727
|
+
}, {});
|
|
728
|
+
if (Object.keys(registered).length > 0) {
|
|
729
|
+
context.blueprint.set('stone.resources.registry', {
|
|
730
|
+
...context.blueprint.get('stone.resources.registry', {}),
|
|
731
|
+
...registered
|
|
732
|
+
});
|
|
733
|
+
}
|
|
734
|
+
return await next(context);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Meta blueprint middleware for resource discovery.
|
|
738
|
+
*/
|
|
739
|
+
const MetaApiResourceMiddleware = {
|
|
740
|
+
module: ApiResourceMiddleware,
|
|
741
|
+
priority: 5
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Opt-in blueprint: register it to shape what routes return.
|
|
746
|
+
*
|
|
747
|
+
* It contributes the route middleware that applies whatever a route declared under `resource`.
|
|
748
|
+
* `stone.router.middleware` is an array, so this merges with the rest of the app. The middleware is
|
|
749
|
+
* a no-op on routes that declare nothing.
|
|
750
|
+
*
|
|
751
|
+
* @example
|
|
752
|
+
* ```typescript
|
|
753
|
+
* import { resourcesBlueprint } from '@stone-js/resources'
|
|
754
|
+
*
|
|
755
|
+
* export const Application = defineStoneApp({ name: 'my-app' }, [resourcesBlueprint])
|
|
756
|
+
* ```
|
|
757
|
+
*/
|
|
758
|
+
const resourcesBlueprint = {
|
|
759
|
+
stone: {
|
|
760
|
+
resources: {},
|
|
761
|
+
blueprint: {
|
|
762
|
+
middleware: [
|
|
763
|
+
MetaApiResourceMiddleware
|
|
764
|
+
]
|
|
765
|
+
},
|
|
766
|
+
router: {
|
|
767
|
+
middleware: [
|
|
768
|
+
MetaResourceRouteMiddleware
|
|
769
|
+
]
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Class decorator: shape what routes return, declaratively.
|
|
776
|
+
*
|
|
777
|
+
* `@Resources()` installs the route middleware that applies whatever a route declared under
|
|
778
|
+
* `resource`, so a handler returns its domain model and only what the resource allows leaves the
|
|
779
|
+
* application.
|
|
780
|
+
*
|
|
781
|
+
* @param options - The resources configuration. Everything is optional.
|
|
782
|
+
* @returns A class decorator.
|
|
783
|
+
*
|
|
784
|
+
* @example
|
|
785
|
+
* ```typescript
|
|
786
|
+
* import { Resources } from '@stone-js/resources'
|
|
787
|
+
*
|
|
788
|
+
* @Resources({ registry: { user: userResource } })
|
|
789
|
+
* @StoneApp({ name: 'my-app' })
|
|
790
|
+
* export class Application {}
|
|
791
|
+
* ```
|
|
792
|
+
*/
|
|
793
|
+
const Resources = (options = {}) => {
|
|
794
|
+
return classDecoratorLegacyWrapper((target, context) => {
|
|
795
|
+
// The blueprint is the single source of truth for what the module declares; the decorator only
|
|
796
|
+
// overrides what it can, its options bucket.
|
|
797
|
+
const blueprint = cloneValue(resourcesBlueprint);
|
|
798
|
+
blueprint.stone.resources = { ...blueprint.stone.resources, ...options };
|
|
799
|
+
addBlueprint(target, context, blueprint);
|
|
800
|
+
});
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Method decorator: declare what a handler exposes.
|
|
805
|
+
*
|
|
806
|
+
* ```ts
|
|
807
|
+
* @Returns(userResource) // the resource itself
|
|
808
|
+
* @Returns('user') // a registered resource class
|
|
809
|
+
* ```
|
|
810
|
+
*
|
|
811
|
+
* The counterpart of `@Validate`: one says what comes in, the other what goes out, and between them
|
|
812
|
+
* the handler is free to return its domain model whole. Whatever the model gains later, a password
|
|
813
|
+
* hash, an internal flag, does not leak, because the resource decides what leaves.
|
|
814
|
+
*
|
|
815
|
+
* Like `@Validate`, this knows nothing about the router. The declaration is recorded on the handler
|
|
816
|
+
* under this module's own key, so it works in a routed application, a single-handler service, a CLI
|
|
817
|
+
* command or the browser. When a router is in play you may put it on the route instead
|
|
818
|
+
* (`@Get('/users/:id', { resource: userResource })`), which keeps everything a route does in one
|
|
819
|
+
* place; both forms end up in the same middleware.
|
|
820
|
+
*
|
|
821
|
+
* @param resource - What the handler exposes.
|
|
822
|
+
* @returns A method decorator.
|
|
823
|
+
*/
|
|
824
|
+
const Returns = (resource) => {
|
|
825
|
+
return methodDecoratorLegacyWrapper((_target, context) => {
|
|
826
|
+
addMetadata(context, RETURNS_KEY, { action: context.name, resource });
|
|
827
|
+
});
|
|
828
|
+
};
|
|
190
829
|
|
|
191
|
-
export { Resource, applyFields, contextFromEvent, defineResource, except, only, stripUndefined };
|
|
830
|
+
export { API_RESOURCE_KEY, ApiResource, ApiResourceMiddleware, ContractChecker, MetaApiResourceMiddleware, MetaResourceRouteMiddleware, RETURNS_KEY, Resource, ResourceContractError, ResourceRouteMiddleware, Resources, Returns, applyFields, contextFromEvent, defineResource, except, only, resourcesBlueprint, stripUndefined };
|