@stone-js/resources 0.8.9 → 0.8.11
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 +113 -28
- package/dist/declarations.d.ts +55 -16
- package/dist/decorators/ApiResource.d.ts +22 -13
- package/dist/defineResource.d.ts +31 -8
- package/dist/errors/ResourceContractError.d.ts +31 -0
- package/dist/helpers.d.ts +15 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +483 -117
- package/dist/middleware/ResourceRouteMiddleware.d.ts +33 -12
- package/dist/options/ResourcesBlueprint.d.ts +29 -1
- package/package.json +6 -4
package/dist/index.js
CHANGED
|
@@ -1,6 +1,147 @@
|
|
|
1
|
-
import { hasMetadata, getMetadata,
|
|
1
|
+
import { RuntimeError, hasMetadata, getMetadata, classDecoratorLegacyWrapper, setMetadata, SERVICE_KEY, 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,205 @@ 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.
|
|
93
252
|
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* 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.
|
|
98
257
|
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* toArray (user: User, ctx: ResourceContext) {
|
|
103
|
-
* return {
|
|
104
|
-
* id: user.id,
|
|
105
|
-
* name: user.name,
|
|
106
|
-
* email: this.when(ctx.self === true, user.email),
|
|
107
|
-
* posts: this.whenIncluded(ctx, 'posts', () => postResource.collection(user.posts))
|
|
108
|
-
* }
|
|
109
|
-
* }
|
|
110
|
-
* }
|
|
111
|
-
* ```
|
|
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.
|
|
112
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
|
+
|
|
113
276
|
class Resource {
|
|
277
|
+
checker;
|
|
278
|
+
onViolation;
|
|
114
279
|
/**
|
|
115
|
-
*
|
|
280
|
+
* @param dependencies - Auto-wired services.
|
|
281
|
+
*
|
|
282
|
+
* One name, bound by this module's own blueprint, so the container resolves it like any other service
|
|
283
|
+
* and this constructor reads it plainly. That is the whole point: a dependency read off a container
|
|
284
|
+
* that never bound it is not optional, it throws, which is what made every container-resolved
|
|
285
|
+
* resource fail. The answer was to register the checker, not to test for its presence.
|
|
286
|
+
*
|
|
287
|
+
* One name and no more, because destructuring reads each one: a resource must not have to know
|
|
288
|
+
* which services happen to be bound. The violation policy is configuration, and it travels with the
|
|
289
|
+
* request in the context, from `stone.resources.onViolation`. Substituting the dialect is a matter
|
|
290
|
+
* of binding `contractChecker` yourself.
|
|
291
|
+
*/
|
|
292
|
+
constructor({ contractChecker } = {}) {
|
|
293
|
+
this.checker = contractChecker ?? ContractChecker.create();
|
|
294
|
+
this.onViolation = 'throw';
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Project one model.
|
|
298
|
+
*
|
|
299
|
+
* The order is the design: complete the data, choose the contract the caller asked for, hold the
|
|
300
|
+
* result against it, then narrow. Validation happens *before* narrowing, so the promise is checked
|
|
301
|
+
* against everything the resource produced rather than against whatever survived a query parameter.
|
|
116
302
|
*
|
|
117
303
|
* @param model - The domain model.
|
|
118
304
|
* @param context - The resource context.
|
|
119
|
-
* @returns The
|
|
305
|
+
* @returns The projected output.
|
|
306
|
+
* @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
|
|
120
307
|
*/
|
|
121
|
-
item(model, context = {}) {
|
|
122
|
-
|
|
308
|
+
async item(model, context = {}) {
|
|
309
|
+
const data = this.data !== undefined ? await this.data(model, context) : model;
|
|
310
|
+
const schema = await this.schemaFor(context);
|
|
311
|
+
const projected = await this.project(data, schema, context);
|
|
312
|
+
return applyFields(projected, context.fields);
|
|
123
313
|
}
|
|
124
314
|
/**
|
|
125
|
-
*
|
|
315
|
+
* Project a collection.
|
|
316
|
+
*
|
|
317
|
+
* Sequential rather than concurrent: `data()` may reach a database or an API, and a hundred models
|
|
318
|
+
* turning into a hundred simultaneous calls is a denial of service an application performs on
|
|
319
|
+
* itself. A resource that wants concurrency batches inside its own `data()`, where it knows the cost.
|
|
126
320
|
*
|
|
127
321
|
* @param models - The domain models.
|
|
128
322
|
* @param context - The resource context.
|
|
129
|
-
* @returns The
|
|
323
|
+
* @returns The projected collection.
|
|
130
324
|
*/
|
|
131
|
-
collection(models, context = {}) {
|
|
132
|
-
|
|
325
|
+
async collection(models, context = {}) {
|
|
326
|
+
const out = [];
|
|
327
|
+
for (const model of models) {
|
|
328
|
+
out.push(await this.item(model, context));
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
133
331
|
}
|
|
134
332
|
/**
|
|
135
|
-
*
|
|
333
|
+
* Project into a `{ data, meta }` envelope.
|
|
136
334
|
*
|
|
137
335
|
* @param models - A model or a collection.
|
|
138
336
|
* @param context - The resource context.
|
|
139
337
|
* @param meta - Optional metadata (pagination, counts, …).
|
|
140
338
|
* @returns The envelope.
|
|
141
339
|
*/
|
|
142
|
-
response(models, context = {}, meta) {
|
|
143
|
-
const data = Array.isArray(models)
|
|
340
|
+
async response(models, context = {}, meta) {
|
|
341
|
+
const data = Array.isArray(models)
|
|
342
|
+
? await this.collection(models, context)
|
|
343
|
+
: await this.item(models, context);
|
|
144
344
|
return meta === undefined ? { data } : { data, meta };
|
|
145
345
|
}
|
|
146
346
|
/**
|
|
147
|
-
*
|
|
347
|
+
* The schema to hold this projection against: the requested fragment when the resource exposes one,
|
|
348
|
+
* the full contract otherwise.
|
|
349
|
+
*
|
|
350
|
+
* An unknown fragment falls back to the full contract rather than failing. A caller guessing
|
|
351
|
+
* `?view=nonsense` is asking a question, not attacking: answering the documented shape is more
|
|
352
|
+
* useful than a 500, and the fragments a resource exposes are published in the contract anyway.
|
|
353
|
+
*
|
|
354
|
+
* @param context - The resource context.
|
|
355
|
+
* @returns The schema.
|
|
356
|
+
*/
|
|
357
|
+
async schemaFor(context) {
|
|
358
|
+
const name = context.fragment;
|
|
359
|
+
if (name !== undefined && this.fragments !== undefined) {
|
|
360
|
+
const available = await this.fragments(context);
|
|
361
|
+
if (available[name] !== undefined) {
|
|
362
|
+
return available[name];
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return this.schema(context);
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Hold the data against the contract, and return what the contract describes.
|
|
369
|
+
*
|
|
370
|
+
* The schema is the projection: its parsed value is the output, so a field the contract does not
|
|
371
|
+
* mention is not exposed, whatever the model gains later.
|
|
372
|
+
*
|
|
373
|
+
* @param data - The completed data.
|
|
374
|
+
* @param schema - The contract.
|
|
375
|
+
* @param context - The resource context.
|
|
376
|
+
* @returns The projected value.
|
|
377
|
+
* @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
|
|
378
|
+
*/
|
|
379
|
+
async project(data, schema, context) {
|
|
380
|
+
// From the context first, so an application may hand a projection its own dialect for one call;
|
|
381
|
+
// otherwise this module's own reader, which is why exposing data needs no validation module.
|
|
382
|
+
const checker = context.checker ?? this.checker;
|
|
383
|
+
const result = checker.check(schema, data);
|
|
384
|
+
if (result.success) {
|
|
385
|
+
return result.value;
|
|
386
|
+
}
|
|
387
|
+
const issues = result.issues ?? [];
|
|
388
|
+
const detail = issues
|
|
389
|
+
.map((issue) => `${issue.path.length > 0 ? issue.path.join('.') : '(root)'}: ${issue.message}`)
|
|
390
|
+
.join('; ');
|
|
391
|
+
const message = `${this.constructor.name} produced data that does not match the contract it publishes: ${detail}. ` +
|
|
392
|
+
'The response was not sent, because a caller cannot detect a broken contract and a consumer ' +
|
|
393
|
+
'generated from it would break on the field that is missing.';
|
|
394
|
+
// Configured per application, and per projection when a caller of `item()` wants to override it.
|
|
395
|
+
const policy = context.onViolation ?? this.onViolation;
|
|
396
|
+
if (policy === 'warn') {
|
|
397
|
+
// Availability over integrity, chosen explicitly by configuration: the caller still gets what
|
|
398
|
+
// the schema could parse, and the breach is on the record.
|
|
399
|
+
console.warn(`[@stone-js/resources] ${message}`);
|
|
400
|
+
return data;
|
|
401
|
+
}
|
|
402
|
+
throw new ResourceContractError(message, { issues, metadata: { context } });
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Include a value only when `condition` holds (otherwise the field is dropped).
|
|
148
406
|
*
|
|
149
407
|
* @param condition - Whether to include the value.
|
|
150
408
|
* @param value - The value, or a lazy factory (only evaluated when included).
|
|
@@ -157,7 +415,7 @@ class Resource {
|
|
|
157
415
|
return typeof value === 'function' ? value() : value;
|
|
158
416
|
}
|
|
159
417
|
/**
|
|
160
|
-
* Include a value only when the relation was requested
|
|
418
|
+
* Include a value only when the relation was requested through `context.include`.
|
|
161
419
|
*
|
|
162
420
|
* @param context - The resource context.
|
|
163
421
|
* @param name - The relation name.
|
|
@@ -170,25 +428,58 @@ class Resource {
|
|
|
170
428
|
}
|
|
171
429
|
|
|
172
430
|
/**
|
|
173
|
-
* The imperative
|
|
174
|
-
*
|
|
175
|
-
*
|
|
431
|
+
* The imperative way to define a resource: an object instead of a class.
|
|
432
|
+
*
|
|
433
|
+
* Parity is the rule, so this declares exactly what a class declares and gets exactly what a class
|
|
434
|
+
* gets. It needs nothing injected: this module reads schemas with its own checker.
|
|
176
435
|
*
|
|
177
|
-
* @param
|
|
436
|
+
* @param definition - The schema, and optionally fragments and a `data()` hook.
|
|
437
|
+
* @param dependencies - Optional explicit services, for a resource used outside a request.
|
|
178
438
|
* @returns A resource.
|
|
179
439
|
*
|
|
180
440
|
* @example
|
|
181
441
|
* ```ts
|
|
182
|
-
* const userResource = defineResource<User>(
|
|
183
|
-
*
|
|
442
|
+
* export const userResource = defineResource<User>({
|
|
443
|
+
* schema: z.object({ id: z.number(), name: z.string() }),
|
|
444
|
+
* fragments: { summary: z.object({ id: z.number() }) },
|
|
445
|
+
* data: async (user) => ({ ...user, posts: await posts.titlesOf(user.id) })
|
|
446
|
+
* })
|
|
184
447
|
* ```
|
|
185
448
|
*/
|
|
186
|
-
function defineResource(
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
449
|
+
function defineResource(definition, dependencies = {}) {
|
|
450
|
+
// Assigned after construction, not through it: the class constructor is the container's, and it
|
|
451
|
+
// only reads names this module binds. An explicit object is the imperative form's business.
|
|
452
|
+
const resource = new class extends Resource {
|
|
453
|
+
constructor() {
|
|
454
|
+
super();
|
|
455
|
+
if (dependencies.checker !== undefined) {
|
|
456
|
+
this.checker = dependencies.checker;
|
|
457
|
+
}
|
|
458
|
+
if (dependencies.onViolation !== undefined) {
|
|
459
|
+
this.onViolation = dependencies.onViolation;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
async schema(context) {
|
|
463
|
+
if (typeof definition.schema !== 'function') {
|
|
464
|
+
return definition.schema;
|
|
465
|
+
}
|
|
466
|
+
const build = definition.schema;
|
|
467
|
+
return await build(context);
|
|
190
468
|
}
|
|
191
469
|
}();
|
|
470
|
+
if (definition.fragments !== undefined) {
|
|
471
|
+
const declared = definition.fragments;
|
|
472
|
+
resource.fragments = async (context) => {
|
|
473
|
+
if (typeof declared !== 'function') {
|
|
474
|
+
return declared;
|
|
475
|
+
}
|
|
476
|
+
return await declared(context);
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
if (definition.data !== undefined) {
|
|
480
|
+
resource.data = definition.data;
|
|
481
|
+
}
|
|
482
|
+
return resource;
|
|
192
483
|
}
|
|
193
484
|
|
|
194
485
|
/**
|
|
@@ -203,59 +494,18 @@ const RETURNS_KEY = '@stone-js/resources/returns';
|
|
|
203
494
|
*/
|
|
204
495
|
const API_RESOURCE_KEY = '@stone-js/resources/resource';
|
|
205
496
|
|
|
206
|
-
/**
|
|
207
|
-
* Build-phase middleware: collect every class registered with `@ApiResource` into the registry.
|
|
208
|
-
*
|
|
209
|
-
* The same scan the router does for its route definitions, applied to this module's own key. After it
|
|
210
|
-
* runs, `stone.resources.registry` maps each alias to its class, so a route or a handler can name a
|
|
211
|
-
* resource instead of importing it, and `@stone-js/openapi` can walk the registry to publish response
|
|
212
|
-
* shapes without loading anything itself.
|
|
213
|
-
*
|
|
214
|
-
* @param context - The blueprint context.
|
|
215
|
-
* @param next - The next blueprint middleware.
|
|
216
|
-
* @returns The blueprint.
|
|
217
|
-
*/
|
|
218
|
-
async function ApiResourceMiddleware(context, next) {
|
|
219
|
-
const registered = context
|
|
220
|
-
.modules
|
|
221
|
-
.filter((module) => hasMetadata(module, API_RESOURCE_KEY))
|
|
222
|
-
.reduce((registry, module) => {
|
|
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.
|
|
236
|
-
*/
|
|
237
|
-
const MetaApiResourceMiddleware = {
|
|
238
|
-
module: ApiResourceMiddleware,
|
|
239
|
-
priority: 5
|
|
240
|
-
};
|
|
241
|
-
|
|
242
497
|
/**
|
|
243
498
|
* Route middleware: shapes what a route returns, after its handler ran.
|
|
244
499
|
*
|
|
245
500
|
* A route says what it exposes, once, where the route is defined:
|
|
246
501
|
*
|
|
247
502
|
* ```ts
|
|
248
|
-
* @Get('/users/:id', { resource:
|
|
503
|
+
* @Get('/users/:id', { resource: UserResource })
|
|
249
504
|
* ```
|
|
250
505
|
*
|
|
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.
|
|
506
|
+
* The handler returns its domain model, whole, and this applies the resource on the way out. That is
|
|
507
|
+
* the point: a service should not have to know which fields are public, and a handler should not have
|
|
508
|
+
* to remember to strip them.
|
|
259
509
|
*/
|
|
260
510
|
class ResourceRouteMiddleware {
|
|
261
511
|
blueprint;
|
|
@@ -270,6 +520,13 @@ class ResourceRouteMiddleware {
|
|
|
270
520
|
/**
|
|
271
521
|
* Run the handler, then shape what it returned.
|
|
272
522
|
*
|
|
523
|
+
* It handles both of the things a handler may hand back, which is the part that used to be wrong. A
|
|
524
|
+
* handler carrying a response decorator (`@JsonHttpResponse(201)`) has already been turned into a
|
|
525
|
+
* response by the time any route middleware runs, because that decorator wraps the method itself.
|
|
526
|
+
* Projecting the response object produced an empty payload and dropped the status with it. So a
|
|
527
|
+
* response is now projected **through its content**, in place: the payload is shaped and the status,
|
|
528
|
+
* the headers and everything else the handler chose are left exactly as they were.
|
|
529
|
+
*
|
|
273
530
|
* @param event - The incoming event.
|
|
274
531
|
* @param next - The next middleware.
|
|
275
532
|
* @returns The shaped output, or the untouched result when the route declares no resource.
|
|
@@ -280,8 +537,44 @@ class ResourceRouteMiddleware {
|
|
|
280
537
|
if (resource === undefined || result === undefined || result === null) {
|
|
281
538
|
return result;
|
|
282
539
|
}
|
|
283
|
-
const context = contextFromEvent(event
|
|
284
|
-
|
|
540
|
+
const context = contextFromEvent(event, this.blueprint, {
|
|
541
|
+
onViolation: this.blueprint.get('stone.resources', {}).onViolation
|
|
542
|
+
});
|
|
543
|
+
if (this.isContentBearing(result)) {
|
|
544
|
+
const shaped = await this.shape(resource, result.content, context);
|
|
545
|
+
result.setContent(shaped);
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
return await this.shape(resource, result, context);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Project a value, whether it is one model or many.
|
|
552
|
+
*
|
|
553
|
+
* @param resource - The resource to apply.
|
|
554
|
+
* @param value - The value the handler produced.
|
|
555
|
+
* @param context - The resource context.
|
|
556
|
+
* @returns The projected value.
|
|
557
|
+
*/
|
|
558
|
+
async shape(resource, value, context) {
|
|
559
|
+
if (value === undefined || value === null) {
|
|
560
|
+
return value;
|
|
561
|
+
}
|
|
562
|
+
return Array.isArray(value)
|
|
563
|
+
? await resource.collection(value, context)
|
|
564
|
+
: await resource.item(value, context);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Whether a value is a response carrying a payload this can replace.
|
|
568
|
+
*
|
|
569
|
+
* Duck-typed: the kernel is agnostic, and each platform has its own response type.
|
|
570
|
+
*
|
|
571
|
+
* @param value - The value to test.
|
|
572
|
+
* @returns Whether it carries content.
|
|
573
|
+
*/
|
|
574
|
+
isContentBearing(value) {
|
|
575
|
+
return typeof value === 'object' && value !== null &&
|
|
576
|
+
typeof value.setContent === 'function' &&
|
|
577
|
+
'content' in value;
|
|
285
578
|
}
|
|
286
579
|
/**
|
|
287
580
|
* The resource the matched route declared, with a registered name resolved to its resource.
|
|
@@ -348,7 +641,8 @@ class ResourceRouteMiddleware {
|
|
|
348
641
|
}
|
|
349
642
|
/**
|
|
350
643
|
* Resolve a registered entry: a resource class goes through the container, so its constructor gets
|
|
351
|
-
* the services it asked for
|
|
644
|
+
* the services it asked for — the validator it holds its own contract against, and whatever its
|
|
645
|
+
* `data()` needs to complete a model.
|
|
352
646
|
*
|
|
353
647
|
* @param entry - A resource, or a class to resolve into one.
|
|
354
648
|
* @returns The resource.
|
|
@@ -358,7 +652,9 @@ class ResourceRouteMiddleware {
|
|
|
358
652
|
return entry;
|
|
359
653
|
}
|
|
360
654
|
const ResourceClass = entry;
|
|
361
|
-
|
|
655
|
+
// `resolve(Class, true)` uses the binding `@ApiResource` declared, and binds it as a singleton
|
|
656
|
+
// when there is none, so a resource is built once with its dependencies wired either way.
|
|
657
|
+
return this.container?.resolve?.(ResourceClass, true) ?? new ResourceClass();
|
|
362
658
|
}
|
|
363
659
|
}
|
|
364
660
|
/**
|
|
@@ -374,27 +670,39 @@ const MetaResourceRouteMiddleware = {
|
|
|
374
670
|
};
|
|
375
671
|
|
|
376
672
|
/**
|
|
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
|
-
* ```
|
|
673
|
+
* Build-phase middleware: collect every class registered with `@ApiResource` into the registry.
|
|
385
674
|
*
|
|
386
|
-
*
|
|
387
|
-
* resources
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* this is how it gets it.
|
|
675
|
+
* The same scan the router does for its route definitions, applied to this module's own key. After it
|
|
676
|
+
* runs, `stone.resources.registry` maps each alias to its class, so a route or a handler can name a
|
|
677
|
+
* resource instead of importing it, and `@stone-js/openapi` can walk the registry to publish response
|
|
678
|
+
* shapes without loading anything itself.
|
|
391
679
|
*
|
|
392
|
-
* @param
|
|
393
|
-
*
|
|
394
|
-
* @returns
|
|
680
|
+
* @param context - The blueprint context.
|
|
681
|
+
* @param next - The next blueprint middleware.
|
|
682
|
+
* @returns The blueprint.
|
|
395
683
|
*/
|
|
396
|
-
|
|
397
|
-
|
|
684
|
+
async function ApiResourceMiddleware(context, next) {
|
|
685
|
+
const registered = context
|
|
686
|
+
.modules
|
|
687
|
+
.filter((module) => hasMetadata(module, API_RESOURCE_KEY))
|
|
688
|
+
.reduce((registry, module) => {
|
|
689
|
+
const { alias } = getMetadata(module, API_RESOURCE_KEY, {});
|
|
690
|
+
return { ...registry, [alias ?? module.name]: module };
|
|
691
|
+
}, {});
|
|
692
|
+
if (Object.keys(registered).length > 0) {
|
|
693
|
+
context.blueprint.set('stone.resources.registry', {
|
|
694
|
+
...context.blueprint.get('stone.resources.registry', {}),
|
|
695
|
+
...registered
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
return await next(context);
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Meta blueprint middleware for resource discovery.
|
|
702
|
+
*/
|
|
703
|
+
const MetaApiResourceMiddleware = {
|
|
704
|
+
module: ApiResourceMiddleware,
|
|
705
|
+
priority: 5
|
|
398
706
|
};
|
|
399
707
|
|
|
400
708
|
/**
|
|
@@ -411,9 +719,24 @@ const ApiResource = (alias) => {
|
|
|
411
719
|
* export const Application = defineStoneApp({ name: 'my-app' }, [resourcesBlueprint])
|
|
412
720
|
* ```
|
|
413
721
|
*/
|
|
722
|
+
/**
|
|
723
|
+
* The reader every resource holds its contract against, as a service.
|
|
724
|
+
*
|
|
725
|
+
* Bound so a resource's constructor can simply ask for it. A dependency read off the container that
|
|
726
|
+
* nothing ever bound is not optional, it is a crash, which is what made every container-resolved
|
|
727
|
+
* resource fail on a service nobody was told to register. The fix is the registration, not a
|
|
728
|
+
* conditional read.
|
|
729
|
+
*/
|
|
730
|
+
const MetaContractChecker = {
|
|
731
|
+
module: ContractChecker,
|
|
732
|
+
isClass: true,
|
|
733
|
+
singleton: true,
|
|
734
|
+
alias: 'contractChecker'
|
|
735
|
+
};
|
|
414
736
|
const resourcesBlueprint = {
|
|
415
737
|
stone: {
|
|
416
738
|
resources: {},
|
|
739
|
+
services: [MetaContractChecker],
|
|
417
740
|
blueprint: {
|
|
418
741
|
middleware: [
|
|
419
742
|
MetaApiResourceMiddleware
|
|
@@ -427,6 +750,49 @@ const resourcesBlueprint = {
|
|
|
427
750
|
}
|
|
428
751
|
};
|
|
429
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Declare a class as an API resource.
|
|
755
|
+
*
|
|
756
|
+
* Three statements in one, which is why nothing has to be wired by hand:
|
|
757
|
+
*
|
|
758
|
+
* 1. **It is a service.** The container builds it, as a singleton, which means its constructor is
|
|
759
|
+
* auto-wired like any other class: whatever it destructures is resolved for it, from the checker
|
|
760
|
+
* it holds its contract against to the repository its `data()` needs to complete a model. Nothing
|
|
761
|
+
* reads dependencies conditionally, because the container has them.
|
|
762
|
+
* 2. **It is reachable by name.** The alias is bound in the container as `resource:<name>`, prefixed
|
|
763
|
+
* on purpose: an application is free to bind its own `user` service, and a resource named `user`
|
|
764
|
+
* must not compete for that name.
|
|
765
|
+
* 3. **It activates the module.** The blueprint comes with the decorator, so a resource declared this
|
|
766
|
+
* way is registered and projected without a second gesture, and `resource: 'user'` on a route
|
|
767
|
+
* resolves to this class.
|
|
768
|
+
*
|
|
769
|
+
* @param alias - The name a route refers to it by. Defaults to the class name.
|
|
770
|
+
* @returns A class decorator.
|
|
771
|
+
*
|
|
772
|
+
* @example
|
|
773
|
+
* ```ts
|
|
774
|
+
* @ApiResource('user')
|
|
775
|
+
* export class UserResource extends Resource<User> {
|
|
776
|
+
* constructor (private readonly posts: PostRepository) { super() }
|
|
777
|
+
* schema (): unknown { return z.object({ id: z.number(), name: z.string() }) }
|
|
778
|
+
* }
|
|
779
|
+
* ```
|
|
780
|
+
*/
|
|
781
|
+
const ApiResource = (alias) => {
|
|
782
|
+
return classDecoratorLegacyWrapper((target, context) => {
|
|
783
|
+
const name = alias ?? target.name;
|
|
784
|
+
setMetadata(context, API_RESOURCE_KEY, { alias: name });
|
|
785
|
+
setMetadata(context, SERVICE_KEY, { singleton: true, isClass: true, alias: `resource:${name}` });
|
|
786
|
+
addBlueprint(target, context, resourcesBlueprint, {
|
|
787
|
+
stone: {
|
|
788
|
+
resources: {
|
|
789
|
+
registry: { [name]: target }
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
});
|
|
794
|
+
};
|
|
795
|
+
|
|
430
796
|
/**
|
|
431
797
|
* Class decorator: shape what routes return, declaratively.
|
|
432
798
|
*
|
|
@@ -483,4 +849,4 @@ const Returns = (resource) => {
|
|
|
483
849
|
});
|
|
484
850
|
};
|
|
485
851
|
|
|
486
|
-
export { API_RESOURCE_KEY, ApiResource, ApiResourceMiddleware, MetaApiResourceMiddleware, MetaResourceRouteMiddleware, RETURNS_KEY, Resource, ResourceRouteMiddleware, Resources, Returns, applyFields, contextFromEvent, defineResource, except, only, resourcesBlueprint, stripUndefined };
|
|
852
|
+
export { API_RESOURCE_KEY, ApiResource, ApiResourceMiddleware, ContractChecker, MetaApiResourceMiddleware, MetaContractChecker, MetaResourceRouteMiddleware, RETURNS_KEY, Resource, ResourceContractError, ResourceRouteMiddleware, Resources, Returns, applyFields, contextFromEvent, defineResource, except, only, resourcesBlueprint, stripUndefined };
|