@stone-js/resources 0.8.9 → 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/defineResource.d.ts +30 -8
- package/dist/errors/ResourceContractError.d.ts +31 -0
- package/dist/helpers.d.ts +15 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +450 -106
- package/dist/middleware/ResourceRouteMiddleware.d.ts +33 -12
- package/dist/options/ResourcesBlueprint.d.ts +19 -0
- package/package.json +5 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,147 @@
|
|
|
1
|
-
import { hasMetadata, getMetadata,
|
|
1
|
+
import { RuntimeError, setClassMetadata, hasMetadata, getMetadata, classDecoratorLegacyWrapper, addBlueprint, methodDecoratorLegacyWrapper, addMetadata } from '@stone-js/core';
|
|
2
2
|
import { cloneValue } from '@stone-js/config';
|
|
3
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
|
+
|
|
4
145
|
/**
|
|
5
146
|
* Returns a copy of `object` without any `undefined` values (so conditional fields simply vanish).
|
|
6
147
|
*
|
|
@@ -63,88 +204,230 @@ function applyFields(output, fields) {
|
|
|
63
204
|
return fields !== undefined && fields.length > 0 ? only(clean, fields) : clean;
|
|
64
205
|
}
|
|
65
206
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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)`.
|
|
68
218
|
*
|
|
69
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.
|
|
70
221
|
* @param extra - Extra context to merge in.
|
|
71
222
|
* @returns The resource context.
|
|
72
223
|
*/
|
|
73
|
-
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', '');
|
|
74
227
|
return {
|
|
75
228
|
...extra,
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
78
237
|
};
|
|
79
238
|
}
|
|
80
239
|
/**
|
|
81
|
-
*
|
|
240
|
+
* Split a comma-separated string into a trimmed, non-empty list (or `undefined` when empty).
|
|
82
241
|
*
|
|
83
242
|
* @param value - The CSV string.
|
|
84
243
|
* @returns The list, or `undefined`.
|
|
85
244
|
*/
|
|
86
245
|
function splitCsv(value) {
|
|
87
|
-
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);
|
|
88
247
|
return parts.length > 0 ? parts : undefined;
|
|
89
248
|
}
|
|
90
249
|
|
|
91
250
|
/**
|
|
92
|
-
*
|
|
251
|
+
* Raised when what a handler produced does not match the schema its resource published.
|
|
252
|
+
*
|
|
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.
|
|
93
278
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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.
|
|
98
285
|
*
|
|
99
286
|
* @example
|
|
100
287
|
* ```ts
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
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) }
|
|
109
305
|
* }
|
|
110
306
|
* }
|
|
111
307
|
* ```
|
|
112
308
|
*/
|
|
113
309
|
class Resource {
|
|
310
|
+
checker;
|
|
311
|
+
onViolation;
|
|
114
312
|
/**
|
|
115
|
-
*
|
|
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
|
+
}
|
|
321
|
+
/**
|
|
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.
|
|
116
327
|
*
|
|
117
328
|
* @param model - The domain model.
|
|
118
329
|
* @param context - The resource context.
|
|
119
|
-
* @returns The
|
|
330
|
+
* @returns The projected output.
|
|
331
|
+
* @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
|
|
120
332
|
*/
|
|
121
|
-
item(model, context = {}) {
|
|
122
|
-
|
|
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);
|
|
123
338
|
}
|
|
124
339
|
/**
|
|
125
|
-
*
|
|
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.
|
|
126
345
|
*
|
|
127
346
|
* @param models - The domain models.
|
|
128
347
|
* @param context - The resource context.
|
|
129
|
-
* @returns The
|
|
348
|
+
* @returns The projected collection.
|
|
130
349
|
*/
|
|
131
|
-
collection(models, context = {}) {
|
|
132
|
-
|
|
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;
|
|
133
356
|
}
|
|
134
357
|
/**
|
|
135
|
-
*
|
|
358
|
+
* Project into a `{ data, meta }` envelope.
|
|
136
359
|
*
|
|
137
360
|
* @param models - A model or a collection.
|
|
138
361
|
* @param context - The resource context.
|
|
139
362
|
* @param meta - Optional metadata (pagination, counts, …).
|
|
140
363
|
* @returns The envelope.
|
|
141
364
|
*/
|
|
142
|
-
response(models, context = {}, meta) {
|
|
143
|
-
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);
|
|
144
369
|
return meta === undefined ? { data } : { data, meta };
|
|
145
370
|
}
|
|
146
371
|
/**
|
|
147
|
-
*
|
|
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).
|
|
148
431
|
*
|
|
149
432
|
* @param condition - Whether to include the value.
|
|
150
433
|
* @param value - The value, or a lazy factory (only evaluated when included).
|
|
@@ -157,7 +440,7 @@ class Resource {
|
|
|
157
440
|
return typeof value === 'function' ? value() : value;
|
|
158
441
|
}
|
|
159
442
|
/**
|
|
160
|
-
* Include a value only when the relation was requested
|
|
443
|
+
* Include a value only when the relation was requested through `context.include`.
|
|
161
444
|
*
|
|
162
445
|
* @param context - The resource context.
|
|
163
446
|
* @param name - The relation name.
|
|
@@ -170,25 +453,47 @@ class Resource {
|
|
|
170
453
|
}
|
|
171
454
|
|
|
172
455
|
/**
|
|
173
|
-
* The imperative
|
|
174
|
-
*
|
|
175
|
-
*
|
|
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.
|
|
176
460
|
*
|
|
177
|
-
* @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.
|
|
178
463
|
* @returns A resource.
|
|
179
464
|
*
|
|
180
465
|
* @example
|
|
181
466
|
* ```ts
|
|
182
|
-
* const userResource = defineResource<User>(
|
|
183
|
-
*
|
|
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
|
+
* })
|
|
184
472
|
* ```
|
|
185
473
|
*/
|
|
186
|
-
function defineResource(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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);
|
|
190
482
|
}
|
|
191
|
-
}();
|
|
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;
|
|
192
497
|
}
|
|
193
498
|
|
|
194
499
|
/**
|
|
@@ -204,39 +509,27 @@ const RETURNS_KEY = '@stone-js/resources/returns';
|
|
|
204
509
|
const API_RESOURCE_KEY = '@stone-js/resources/resource';
|
|
205
510
|
|
|
206
511
|
/**
|
|
207
|
-
*
|
|
512
|
+
* Class decorator: register a resource class under a name.
|
|
208
513
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
514
|
+
* ```ts
|
|
515
|
+
* @ApiResource('user')
|
|
516
|
+
* export class UserResource extends Resource<User> {
|
|
517
|
+
* toArray (user: User) { return { id: user.id, name: user.name } }
|
|
518
|
+
* }
|
|
519
|
+
* ```
|
|
213
520
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const { alias } = getMetadata(module, API_RESOURCE_KEY, {});
|
|
224
|
-
return { ...registry, [alias ?? module.name]: module };
|
|
225
|
-
}, {});
|
|
226
|
-
if (Object.keys(registered).length > 0) {
|
|
227
|
-
context.blueprint.set('stone.resources.registry', {
|
|
228
|
-
...context.blueprint.get('stone.resources.registry', {}),
|
|
229
|
-
...registered
|
|
230
|
-
});
|
|
231
|
-
}
|
|
232
|
-
return await next(context);
|
|
233
|
-
}
|
|
234
|
-
/**
|
|
235
|
-
* Meta blueprint middleware for resource discovery.
|
|
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.
|
|
236
530
|
*/
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
priority: 5
|
|
531
|
+
const ApiResource = (alias) => {
|
|
532
|
+
return setClassMetadata(API_RESOURCE_KEY, { alias });
|
|
240
533
|
};
|
|
241
534
|
|
|
242
535
|
/**
|
|
@@ -245,17 +538,12 @@ const MetaApiResourceMiddleware = {
|
|
|
245
538
|
* A route says what it exposes, once, where the route is defined:
|
|
246
539
|
*
|
|
247
540
|
* ```ts
|
|
248
|
-
* @Get('/users/:id', { resource:
|
|
541
|
+
* @Get('/users/:id', { resource: UserResource })
|
|
249
542
|
* ```
|
|
250
543
|
*
|
|
251
|
-
* The handler
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
* hash, an internal flag, is not exposed by accident, because the resource decides what leaves.
|
|
255
|
-
*
|
|
256
|
-
* It runs on the raw value the handler returned, before any response wrapping, so it knows nothing
|
|
257
|
-
* of HTTP and works in every context. Sparse fieldsets are read from the event, so `?fields=id,name`
|
|
258
|
-
* narrows the output without the route changing.
|
|
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.
|
|
259
547
|
*/
|
|
260
548
|
class ResourceRouteMiddleware {
|
|
261
549
|
blueprint;
|
|
@@ -270,6 +558,13 @@ class ResourceRouteMiddleware {
|
|
|
270
558
|
/**
|
|
271
559
|
* Run the handler, then shape what it returned.
|
|
272
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
|
+
*
|
|
273
568
|
* @param event - The incoming event.
|
|
274
569
|
* @param next - The next middleware.
|
|
275
570
|
* @returns The shaped output, or the untouched result when the route declares no resource.
|
|
@@ -280,8 +575,44 @@ class ResourceRouteMiddleware {
|
|
|
280
575
|
if (resource === undefined || result === undefined || result === null) {
|
|
281
576
|
return result;
|
|
282
577
|
}
|
|
283
|
-
const context = contextFromEvent(event
|
|
284
|
-
|
|
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;
|
|
285
616
|
}
|
|
286
617
|
/**
|
|
287
618
|
* The resource the matched route declared, with a registered name resolved to its resource.
|
|
@@ -348,7 +679,8 @@ class ResourceRouteMiddleware {
|
|
|
348
679
|
}
|
|
349
680
|
/**
|
|
350
681
|
* Resolve a registered entry: a resource class goes through the container, so its constructor gets
|
|
351
|
-
* the services it asked for
|
|
682
|
+
* the services it asked for — the validator it holds its own contract against, and whatever its
|
|
683
|
+
* `data()` needs to complete a model.
|
|
352
684
|
*
|
|
353
685
|
* @param entry - A resource, or a class to resolve into one.
|
|
354
686
|
* @returns The resource.
|
|
@@ -374,27 +706,39 @@ const MetaResourceRouteMiddleware = {
|
|
|
374
706
|
};
|
|
375
707
|
|
|
376
708
|
/**
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
* ```ts
|
|
380
|
-
* @ApiResource('user')
|
|
381
|
-
* export class UserResource extends Resource<User> {
|
|
382
|
-
* toArray (user: User) { return { id: user.id, name: user.name } }
|
|
383
|
-
* }
|
|
384
|
-
* ```
|
|
709
|
+
* Build-phase middleware: collect every class registered with `@ApiResource` into the registry.
|
|
385
710
|
*
|
|
386
|
-
*
|
|
387
|
-
* resources
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* this is how it gets it.
|
|
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.
|
|
391
715
|
*
|
|
392
|
-
* @param
|
|
393
|
-
*
|
|
394
|
-
* @returns
|
|
716
|
+
* @param context - The blueprint context.
|
|
717
|
+
* @param next - The next blueprint middleware.
|
|
718
|
+
* @returns The blueprint.
|
|
395
719
|
*/
|
|
396
|
-
|
|
397
|
-
|
|
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
|
|
398
742
|
};
|
|
399
743
|
|
|
400
744
|
/**
|
|
@@ -483,4 +827,4 @@ const Returns = (resource) => {
|
|
|
483
827
|
});
|
|
484
828
|
};
|
|
485
829
|
|
|
486
|
-
export { API_RESOURCE_KEY, ApiResource, ApiResourceMiddleware, MetaApiResourceMiddleware, MetaResourceRouteMiddleware, RETURNS_KEY, Resource, ResourceRouteMiddleware, Resources, Returns, applyFields, contextFromEvent, defineResource, except, only, resourcesBlueprint, 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 };
|