@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/index.js CHANGED
@@ -1,6 +1,147 @@
1
- import { hasMetadata, getMetadata, setClassMetadata, classDecoratorLegacyWrapper, addBlueprint, methodDecoratorLegacyWrapper, addMetadata } from '@stone-js/core';
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
- * Builds a {@link ResourceContext} from an incoming event's `fields` and `include` query
67
- * parameters (comma-separated). Agnostic: the event only needs a `get(key)` method.
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
- fields: splitCsv(event.get('fields', '')),
77
- include: splitCsv(event.get('include', ''))
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
- * Splits a comma-separated string into a trimmed, non-empty list (or `undefined` when empty).
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
- * Base API resource the declarative way to shape what your domain exposes.
251
+ * Raised when what a handler produced does not match the schema its resource published.
93
252
  *
94
- * Extend it and implement {@link Resource.toArray} to map a model to its public shape. Everything
95
- * else (sparse fieldsets, dropping conditional fields, collections, envelopes) is handled for you.
96
- * A resource is decoupled from controllers and platform-agnostic: the same resource shapes data on
97
- * the backend and on the frontend.
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
- * @example
100
- * ```ts
101
- * class UserResource extends Resource<User> {
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
- * Transform one model, applying the requested sparse fieldset and dropping undefined fields.
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 filtered public shape.
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
- return applyFields(this.toArray(model, context), context.fields);
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
- * Transform a collection of models.
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 transformed collection.
323
+ * @returns The projected collection.
130
324
  */
131
- collection(models, context = {}) {
132
- return models.map((model) => this.item(model, context));
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
- * Wrap a model or a collection in a `{ data, meta }` envelope.
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) ? this.collection(models, context) : this.item(models, context);
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
- * Include a value only when `condition` is truthy (otherwise the field is dropped).
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 via `context.include`.
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/functional way to define a resource a plain transform function instead of a
174
- * class. Returns a full {@link Resource} (so you still get `item`/`collection`/`response` and
175
- * sparse fieldsets for free).
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 transform - Maps a model to its public shape.
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>((user) => ({ id: user.id, name: user.name }))
183
- * userResource.collection(users, { fields: ['id'] })
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(transform) {
187
- return new class extends Resource {
188
- toArray(model, context) {
189
- return transform(model, context);
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: userResource })
503
+ * @Get('/users/:id', { resource: UserResource })
249
504
  * ```
250
505
  *
251
- * The handler then returns its domain model, whole, and this middleware applies the resource on the
252
- * way out. That is the point: a service should not have to know which fields are public, and a
253
- * handler should not have to remember to strip them. Whatever the model gains later, a password
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
- return (Array.isArray(result) ? resource.collection(result, context) : resource.item(result, context));
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 and `toArray` can use them, i18n included.
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
- return this.container?.resolve?.(ResourceClass, true) ?? new ResourceClass({});
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
- * Class decorator: register a resource class under a name.
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
- * Routes and handlers then refer to it by name (`@Returns('user')`, or `{ resource: 'user' }`), so
387
- * resources live in their own files, organised however the application likes, and nothing has to be
388
- * imported at the route. The class is resolved by the container, so its constructor receives services
389
- * and `toArray` can use them: a resource that formats dates for the caller's locale needs i18n, and
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 alias - The name the resource is registered under. Defaults to the class name, which the
393
- * discovery middleware fills in, since it is the one holding the class.
394
- * @returns A class decorator.
680
+ * @param context - The blueprint context.
681
+ * @param next - The next blueprint middleware.
682
+ * @returns The blueprint.
395
683
  */
396
- const ApiResource = (alias) => {
397
- return setClassMetadata(API_RESOURCE_KEY, { alias });
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 };