@stone-js/resources 0.8.8 → 0.8.10

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