@stone-js/resources 0.8.11 → 0.8.13

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.
@@ -45,7 +45,7 @@ export interface ResourceDependencies {
45
45
  /** The reader every projection is held against, bound as `contractChecker`. */
46
46
  contractChecker?: IContractChecker;
47
47
  }
48
- export declare abstract class Resource<Model = unknown, Output extends ResourceOutput = ResourceOutput> implements IResource<Model, Output> {
48
+ export declare abstract class Resource<Model = unknown, Output extends ResourceOutput = ResourceOutput, EventType = unknown, PrincipalType = unknown> implements IResource<Model, Output, EventType, PrincipalType> {
49
49
  protected checker: IContractChecker;
50
50
  protected onViolation: ContractViolationPolicy;
51
51
  /**
@@ -68,7 +68,7 @@ export declare abstract class Resource<Model = unknown, Output extends ResourceO
68
68
  * @param context - The resource context.
69
69
  * @returns The schema.
70
70
  */
71
- abstract schema(context: ResourceContext): ResourceSchema | Promise<ResourceSchema>;
71
+ abstract schema(context: ResourceContext<EventType, PrincipalType>): ResourceSchema | Promise<ResourceSchema>;
72
72
  /**
73
73
  * Project one model.
74
74
  *
@@ -81,7 +81,7 @@ export declare abstract class Resource<Model = unknown, Output extends ResourceO
81
81
  * @returns The projected output.
82
82
  * @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
83
83
  */
84
- item(model: Model, context?: ResourceContext): Promise<Output>;
84
+ item(model: Model, context?: ResourceContext<EventType, PrincipalType>): Promise<Output>;
85
85
  /**
86
86
  * Project a collection.
87
87
  *
@@ -93,7 +93,7 @@ export declare abstract class Resource<Model = unknown, Output extends ResourceO
93
93
  * @param context - The resource context.
94
94
  * @returns The projected collection.
95
95
  */
96
- collection(models: Model[], context?: ResourceContext): Promise<Output[]>;
96
+ collection(models: Model[], context?: ResourceContext<EventType, PrincipalType>): Promise<Output[]>;
97
97
  /**
98
98
  * Project into a `{ data, meta }` envelope.
99
99
  *
@@ -102,19 +102,7 @@ export declare abstract class Resource<Model = unknown, Output extends ResourceO
102
102
  * @param meta - Optional metadata (pagination, counts, …).
103
103
  * @returns The envelope.
104
104
  */
105
- response(models: Model | Model[], context?: ResourceContext, meta?: Record<string, unknown>): Promise<ResourceEnvelope<Output | Output[]>>;
106
- /**
107
- * Optional: shape or complete the model before it meets the schema.
108
- *
109
- * `declare`, not a field: an uninitialised class field is *defined* as `undefined` on the instance,
110
- * which would shadow the very method a subclass wrote — the override would exist on the prototype
111
- * and never be reached. This states the type and emits nothing.
112
- */
113
- data?: (model: Model, context: ResourceContext) => Promiseable<unknown>;
114
- /**
115
- * Named subsets a caller may ask for. Override to expose fragments.
116
- */
117
- fragments?: (context: ResourceContext) => Record<string, ResourceSchema> | Promise<Record<string, ResourceSchema>>;
105
+ response(models: Model | Model[], context?: ResourceContext<EventType, PrincipalType>, meta?: Record<string, unknown>): Promise<ResourceEnvelope<Output | Output[]>>;
118
106
  /**
119
107
  * The schema to hold this projection against: the requested fragment when the resource exposes one,
120
108
  * the full contract otherwise.
@@ -126,7 +114,7 @@ export declare abstract class Resource<Model = unknown, Output extends ResourceO
126
114
  * @param context - The resource context.
127
115
  * @returns The schema.
128
116
  */
129
- protected schemaFor(context: ResourceContext): Promise<ResourceSchema>;
117
+ protected schemaFor(context: ResourceContext<EventType, PrincipalType>): Promise<ResourceSchema>;
130
118
  /**
131
119
  * Hold the data against the contract, and return what the contract describes.
132
120
  *
@@ -158,3 +146,28 @@ export declare abstract class Resource<Model = unknown, Output extends ResourceO
158
146
  */
159
147
  protected whenIncluded<T>(context: ResourceContext, name: string, value: T | (() => T)): T | undefined;
160
148
  }
149
+ /**
150
+ * The two optional hooks, declared as methods rather than as properties.
151
+ *
152
+ * A property-typed function is contravariant on its parameters, and TypeScript refuses a method where
153
+ * the base declared a property. Between them, those two rules meant a subclass could neither narrow the
154
+ * context nor write `async data (model, context) {}`, which is the form every example uses. Declared
155
+ * here, on an interface merged with the class, both forms are accepted and the type parameters actually
156
+ * reach the signature a subclass writes.
157
+ */
158
+ export interface Resource<Model, Output extends ResourceOutput, EventType, PrincipalType> {
159
+ /**
160
+ * Optional: shape or complete the model before it meets the schema.
161
+ *
162
+ * A method signature, deliberately, against the repository's own lint rule: a property-typed
163
+ * function is contravariant on its parameters, so a subclass narrowing the context was rejected, and
164
+ * TypeScript separately refuses a method where the base declared a property, so
165
+ * `async data (model, context) {}` was rejected too. The rule's soundness argument is theoretical for
166
+ * an extension point; the cost was measured, in an application that could not type its resources.
167
+ */
168
+ data?(model: Model, context: ResourceContext<EventType, PrincipalType>): Promiseable<unknown>;
169
+ /**
170
+ * Named subsets a caller may ask for. Override to expose fragments.
171
+ */
172
+ fragments?(context: ResourceContext<EventType, PrincipalType>): Record<string, ResourceSchema> | Promise<Record<string, ResourceSchema>>;
173
+ }
@@ -15,17 +15,28 @@ export type ResourceSchema = unknown;
15
15
  * carried. The authenticated principal is part of it, because deciding what a caller may see is the
16
16
  * most common reason a projection differs between two callers.
17
17
  */
18
- export interface ResourceContext {
19
- /** Requested sparse fieldset narrows the output to these top-level keys. */
18
+ export interface ResourceContext<EventType = unknown, PrincipalType = unknown> {
19
+ /** Requested sparse fieldset: narrows the output to these top-level keys. */
20
20
  fields?: string[];
21
21
  /** Requested relations to embed. */
22
22
  include?: string[];
23
23
  /** The requested fragment, when the caller asked for one by name. */
24
24
  fragment?: string;
25
- /** The authenticated principal, when the application has one. */
26
- principal?: unknown;
27
- /** The event being answered, for a resource that needs more than the parameters above. */
28
- event?: unknown;
25
+ /**
26
+ * The authenticated principal, when the application has one.
27
+ *
28
+ * Typed by the resource, because deciding what a caller may see is the most common reason two
29
+ * callers get different shapes, and `unknown` makes every such decision a cast. The default stays
30
+ * `unknown`: this module never assumes an application has users, let alone what a user is.
31
+ */
32
+ principal?: PrincipalType;
33
+ /**
34
+ * The event being answered, for a resource that needs more than the parameters above.
35
+ *
36
+ * Typed by the resource for the same reason, and `unknown` by default because the module is
37
+ * agnostic of the platform the event came from.
38
+ */
39
+ event?: EventType;
29
40
  /** Anything else a resource needs. */
30
41
  [key: string]: unknown;
31
42
  }
@@ -47,29 +58,29 @@ export interface ResourceEnvelope<T> {
47
58
  * and to the resource itself, which validates against it before anything leaves. One declaration, three
48
59
  * consumers, and no way for the documentation to drift from the response.
49
60
  */
50
- export interface IResource<Model = unknown, Output = ResourceOutput> {
61
+ export interface IResource<Model = unknown, Output = ResourceOutput, EventType = unknown, PrincipalType = unknown> {
51
62
  /** The contract: the schema every projection is validated against and documented from. */
52
- schema: (context: ResourceContext) => ResourceSchema | Promise<ResourceSchema>;
63
+ schema(context: ResourceContext<EventType, PrincipalType>): ResourceSchema | Promise<ResourceSchema>;
53
64
  /**
54
65
  * Named subsets a caller may ask for, each with its own schema.
55
66
  *
56
67
  * A fragment is not a filter: it is a contract of its own, documented and validated like the full
57
68
  * one. That is what makes `?view=summary` safe to expose.
58
69
  */
59
- fragments?: (context: ResourceContext) => Record<string, ResourceSchema> | Promise<Record<string, ResourceSchema>>;
70
+ fragments?(context: ResourceContext<EventType, PrincipalType>): Record<string, ResourceSchema> | Promise<Record<string, ResourceSchema>>;
60
71
  /**
61
72
  * Optional hook to shape or complete the model before it meets the schema.
62
73
  *
63
74
  * Asynchronous, and resolved from the container, so it may reach any service: fetch a relation,
64
75
  * translate a label, compute a total. Whatever it returns is what the schema then validates.
65
76
  */
66
- data?: (model: Model, context: ResourceContext) => Promiseable<unknown>;
77
+ data?(model: Model, context: ResourceContext<EventType, PrincipalType>): Promiseable<unknown>;
67
78
  /** Project one model. */
68
- item: (model: Model, context?: ResourceContext) => Promise<Output>;
79
+ item(model: Model, context?: ResourceContext<EventType, PrincipalType>): Promise<Output>;
69
80
  /** Project a collection. */
70
- collection: (models: Model[], context?: ResourceContext) => Promise<Output[]>;
81
+ collection(models: Model[], context?: ResourceContext<EventType, PrincipalType>): Promise<Output[]>;
71
82
  /** Project into a `{ data, meta }` envelope. */
72
- response: (models: Model | Model[], context?: ResourceContext, meta?: Record<string, unknown>) => Promise<ResourceEnvelope<Output | Output[]>>;
83
+ response(models: Model | Model[], context?: ResourceContext<EventType, PrincipalType>, meta?: Record<string, unknown>): Promise<ResourceEnvelope<Output | Output[]>>;
73
84
  }
74
85
  /** What to do when the data does not match the contract the resource published. */
75
86
  export type ContractViolationPolicy = 'throw' | 'warn';
package/dist/index.d.ts CHANGED
@@ -7,7 +7,6 @@ export * from './decorators/Returns.js';
7
7
  export * from './defineResource.js';
8
8
  export * from './errors/ResourceContractError.js';
9
9
  export * from './helpers.js';
10
- export * from './middleware/BlueprintMiddleware.js';
11
10
  export * from './middleware/ResourceRouteMiddleware.js';
12
11
  export * from './options/ResourcesBlueprint.js';
13
12
  export * from './Resource.js';
package/dist/index.js CHANGED
@@ -541,11 +541,48 @@ class ResourceRouteMiddleware {
541
541
  onViolation: this.blueprint.get('stone.resources', {}).onViolation
542
542
  });
543
543
  if (this.isContentBearing(result)) {
544
- const shaped = await this.shape(resource, result.content, context);
544
+ const shaped = await this.shapePayload(resource, result.content, context);
545
545
  result.setContent(shaped);
546
546
  return result;
547
547
  }
548
- return await this.shape(resource, result, context);
548
+ return await this.shapePayload(resource, result, context);
549
+ }
550
+ /**
551
+ * Shape a value, or shape what is inside the envelope the application declared.
552
+ *
553
+ * A page is `{ items: [...], meta: { total } }`, and `items` and `meta` are not fields of a model:
554
+ * projecting that object would publish the envelope as if it were the thing. An application names its
555
+ * own wrapper once, in `stone.resources.envelope`, and everything around the payload is left as it
556
+ * was, counts and cursors included.
557
+ *
558
+ * Nothing is assumed when nothing is declared, because guessing which key holds the payload would
559
+ * quietly mangle a model that happens to have one by that name.
560
+ *
561
+ * @param resource - The resource to apply.
562
+ * @param value - The value the handler produced.
563
+ * @param context - The resource context.
564
+ * @returns The projected value, wrapper intact.
565
+ */
566
+ async shapePayload(resource, value, context) {
567
+ const key = this.envelopeKeyOf(value);
568
+ if (key === undefined) {
569
+ return await this.shape(resource, value, context);
570
+ }
571
+ const envelope = value;
572
+ return { ...envelope, [key]: await this.shape(resource, envelope[key], context) };
573
+ }
574
+ /**
575
+ * Which declared envelope key this value carries, if any.
576
+ *
577
+ * @param value - The value the handler produced.
578
+ * @returns The key holding the payload, or nothing when this is not an envelope.
579
+ */
580
+ envelopeKeyOf(value) {
581
+ const declared = this.blueprint.get('stone.resources', {}).envelope;
582
+ if (declared === undefined || typeof value !== 'object' || value === null || Array.isArray(value)) {
583
+ return undefined;
584
+ }
585
+ return [declared.payload].flat().find((candidate) => candidate in value);
549
586
  }
550
587
  /**
551
588
  * Project a value, whether it is one model or many.
@@ -669,42 +706,6 @@ const MetaResourceRouteMiddleware = {
669
706
  priority: 4
670
707
  };
671
708
 
672
- /**
673
- * Build-phase middleware: collect every class registered with `@ApiResource` into the registry.
674
- *
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.
679
- *
680
- * @param context - The blueprint context.
681
- * @param next - The next blueprint middleware.
682
- * @returns The blueprint.
683
- */
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
706
- };
707
-
708
709
  /**
709
710
  * Opt-in blueprint: register it to shape what routes return.
710
711
  *
@@ -737,11 +738,6 @@ const resourcesBlueprint = {
737
738
  stone: {
738
739
  resources: {},
739
740
  services: [MetaContractChecker],
740
- blueprint: {
741
- middleware: [
742
- MetaApiResourceMiddleware
743
- ]
744
- },
745
741
  router: {
746
742
  middleware: [
747
743
  MetaResourceRouteMiddleware
@@ -849,4 +845,4 @@ const Returns = (resource) => {
849
845
  });
850
846
  };
851
847
 
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 };
848
+ export { API_RESOURCE_KEY, ApiResource, ContractChecker, MetaContractChecker, MetaResourceRouteMiddleware, RETURNS_KEY, Resource, ResourceContractError, ResourceRouteMiddleware, Resources, Returns, applyFields, contextFromEvent, defineResource, except, only, resourcesBlueprint, stripUndefined };
@@ -43,6 +43,30 @@ export declare class ResourceRouteMiddleware {
43
43
  * @returns The shaped output, or the untouched result when the route declares no resource.
44
44
  */
45
45
  handle(event: IncomingEvent, next: NextMiddleware<IncomingEvent, OutgoingResponse>): Promise<OutgoingResponse>;
46
+ /**
47
+ * Shape a value, or shape what is inside the envelope the application declared.
48
+ *
49
+ * A page is `{ items: [...], meta: { total } }`, and `items` and `meta` are not fields of a model:
50
+ * projecting that object would publish the envelope as if it were the thing. An application names its
51
+ * own wrapper once, in `stone.resources.envelope`, and everything around the payload is left as it
52
+ * was, counts and cursors included.
53
+ *
54
+ * Nothing is assumed when nothing is declared, because guessing which key holds the payload would
55
+ * quietly mangle a model that happens to have one by that name.
56
+ *
57
+ * @param resource - The resource to apply.
58
+ * @param value - The value the handler produced.
59
+ * @param context - The resource context.
60
+ * @returns The projected value, wrapper intact.
61
+ */
62
+ private shapePayload;
63
+ /**
64
+ * Which declared envelope key this value carries, if any.
65
+ *
66
+ * @param value - The value the handler produced.
67
+ * @returns The key holding the payload, or nothing when this is not an envelope.
68
+ */
69
+ private envelopeKeyOf;
46
70
  /**
47
71
  * Project a value, whether it is one model or many.
48
72
  *
@@ -3,6 +3,13 @@ import { AppConfig, MetaService, StoneBlueprint } from '@stone-js/core';
3
3
  /**
4
4
  * Resources configuration bucket (`stone.resources`).
5
5
  */
6
+ /**
7
+ * The envelope an application wraps its payloads in.
8
+ */
9
+ export interface ResourceEnvelopeConfig {
10
+ /** The key, or keys, that hold the payload to shape. */
11
+ payload: string | string[];
12
+ }
6
13
  export interface ResourcesConfig {
7
14
  /**
8
15
  * The query parameters a caller uses to ask for a shape.
@@ -35,6 +42,22 @@ export interface ResourcesConfig {
35
42
  * model unshaped, because an unshaped model is exactly what a resource exists to prevent.
36
43
  */
37
44
  registry?: Record<string, IResource<any, any>>;
45
+ /**
46
+ * The envelope this application wraps its payloads in, if it wraps them at all.
47
+ *
48
+ * A handler answering a page returns something like `{ items: [...], meta: { total } }`, and `items`
49
+ * and `meta` are not fields of a model: shaping that object would publish the wrapper as if it were
50
+ * the thing. Naming the word once is enough, and everything around the payload is left as it was,
51
+ * counts and cursors included.
52
+ *
53
+ * ```ts
54
+ * blueprint.set('stone.resources.envelope', { payload: 'items' })
55
+ * ```
56
+ *
57
+ * Undeclared by default, because guessing which key holds the payload would quietly mangle a model
58
+ * that happens to have one by that name.
59
+ */
60
+ envelope?: ResourceEnvelopeConfig;
38
61
  }
39
62
  /**
40
63
  * Application config augmented with the resources bucket.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stone-js/resources",
3
- "version": "0.8.11",
3
+ "version": "0.8.13",
4
4
  "description": "Framework-agnostic API resources for Stone.js. Shape what your domain exposes — sparse fieldsets, conditional fields, includes and envelopes — decoupled from controllers, the same on backend and frontend.",
5
5
  "author": "Mr. Stone <evensstone@gmail.com>",
6
6
  "license": "MIT",
@@ -58,7 +58,7 @@
58
58
  "typescript": "^5.6.3",
59
59
  "vitest": "^3.2.4",
60
60
  "zod": "^3.25.76",
61
- "@stone-js/service-container": "0.8.11"
61
+ "@stone-js/service-container": "0.8.13"
62
62
  },
63
63
  "ts-standard": {
64
64
  "globals": [
@@ -71,10 +71,10 @@
71
71
  ]
72
72
  },
73
73
  "dependencies": {
74
- "@stone-js/config": "0.8.11"
74
+ "@stone-js/config": "0.8.13"
75
75
  },
76
76
  "peerDependencies": {
77
- "@stone-js/core": "0.8.11"
77
+ "@stone-js/core": "0.8.13"
78
78
  },
79
79
  "scripts": {
80
80
  "lint": "ts-standard src",
@@ -83,10 +83,11 @@
83
83
  "doc": "typedoc",
84
84
  "clean": "rimraf dist",
85
85
  "build": "rollup -c",
86
- "test": "vitest run",
86
+ "test": "vitest run && npm run test:types",
87
87
  "test:cvg": "npm run test -- --coverage",
88
88
  "test:text": "npm run test:cvg -- --coverage.reporter=text",
89
89
  "test:html": "npm run test:cvg -- --coverage.reporter=html",
90
- "test:clover": "npm run test:cvg -- --coverage.reporter=clover"
90
+ "test:clover": "npm run test:cvg -- --coverage.reporter=clover",
91
+ "test:types": "tsc -p tsconfig.types.json"
91
92
  }
92
93
  }
@@ -1,18 +0,0 @@
1
- import { BlueprintContext, ClassType, IBlueprint, NextMiddleware, type MetaMiddleware } from '@stone-js/core';
2
- /**
3
- * Build-phase middleware: collect every class registered with `@ApiResource` into the registry.
4
- *
5
- * The same scan the router does for its route definitions, applied to this module's own key. After it
6
- * runs, `stone.resources.registry` maps each alias to its class, so a route or a handler can name a
7
- * resource instead of importing it, and `@stone-js/openapi` can walk the registry to publish response
8
- * shapes without loading anything itself.
9
- *
10
- * @param context - The blueprint context.
11
- * @param next - The next blueprint middleware.
12
- * @returns The blueprint.
13
- */
14
- export declare function ApiResourceMiddleware(context: BlueprintContext<IBlueprint, ClassType>, next: NextMiddleware<BlueprintContext<IBlueprint, ClassType>, IBlueprint>): Promise<IBlueprint>;
15
- /**
16
- * Meta blueprint middleware for resource discovery.
17
- */
18
- export declare const MetaApiResourceMiddleware: MetaMiddleware<any, any>;