@stone-js/resources 0.8.9 → 0.8.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,96 @@
1
+ import { ContractIssue } from './errors/ResourceContractError.js';
2
+ /** What checking a projection against its contract produced. */
3
+ export interface ContractResult<T = unknown> {
4
+ success: boolean;
5
+ value?: T;
6
+ issues?: ContractIssue[];
7
+ }
8
+ /**
9
+ * What this module needs from a schema: the ability to run it and read the outcome.
10
+ *
11
+ * Declared so an application can substitute its own — a bespoke dialect, a shared engine it already
12
+ * configured — without this module knowing anything about it.
13
+ */
14
+ export interface IContractChecker {
15
+ check: <T>(schema: unknown, data: unknown) => ContractResult<T>;
16
+ }
17
+ /**
18
+ * Runs a schema and reports what it said.
19
+ *
20
+ * This module reads schemas; it does not validate requests, own engines, or keep a registry — so it
21
+ * carries its own reader rather than depending on a validation module to project data. The dialects
22
+ * it accepts are public specifications, not one library's API: Standard Schema first, then the
23
+ * `safeParse`/`parse` shape, then a plain `validate`. An application writes its schemas once and both
24
+ * sides of the boundary read them.
25
+ *
26
+ * Substitutable: pass your own {@link IContractChecker} and this one steps aside.
27
+ */
28
+ export declare class ContractChecker implements IContractChecker {
29
+ /**
30
+ * Factory.
31
+ *
32
+ * @returns A checker.
33
+ */
34
+ static create(): ContractChecker;
35
+ /**
36
+ * Run a schema against a value.
37
+ *
38
+ * @param schema - The contract.
39
+ * @param data - What the resource produced.
40
+ * @returns Whether it holds, the parsed value, and what failed.
41
+ * @throws {TypeError} When the value is not a schema this can run, because guessing would mean
42
+ * projecting unchecked data while reporting success.
43
+ */
44
+ check<T>(schema: unknown, data: unknown): ContractResult<T>;
45
+ /**
46
+ * Read a Standard Schema result.
47
+ *
48
+ * The synchronous path only: a schema whose validation is asynchronous returns a promise, and a
49
+ * promise is not a result. Saying so beats treating it as one, which is how `[object Promise]`
50
+ * reaches a response body.
51
+ *
52
+ * @param schema - The schema.
53
+ * @param data - The value.
54
+ * @returns The outcome.
55
+ */
56
+ private fromStandard;
57
+ /**
58
+ * Read a `safeParse` result (the Zod-like shape).
59
+ *
60
+ * @param schema - The schema.
61
+ * @param data - The value.
62
+ * @returns The outcome.
63
+ */
64
+ private fromSafeParse;
65
+ /**
66
+ * Read a native `validate` result: already the shape this module reports.
67
+ *
68
+ * @param schema - The schema.
69
+ * @param data - The value.
70
+ * @returns The outcome.
71
+ */
72
+ private fromValidate;
73
+ /**
74
+ * Read a throwing `parse`.
75
+ *
76
+ * @param schema - The schema.
77
+ * @param data - The value.
78
+ * @returns The outcome.
79
+ */
80
+ private fromParse;
81
+ /**
82
+ * Normalise one issue, whatever dialect reported it.
83
+ *
84
+ * @param issue - The raw issue.
85
+ * @returns The issue.
86
+ */
87
+ private toIssue;
88
+ /** @param schema - The candidate. @returns Whether it speaks Standard Schema. */
89
+ private isStandard;
90
+ /** @param schema - The candidate. @returns Whether it exposes `safeParse`. */
91
+ private hasSafeParse;
92
+ /** @param schema - The candidate. @returns Whether it exposes `validate`. */
93
+ private hasValidate;
94
+ /** @param schema - The candidate. @returns Whether it exposes `parse`. */
95
+ private hasParse;
96
+ }
@@ -1,62 +1,130 @@
1
- import { IResource, ResourceContext, ResourceEnvelope, ResourceOutput } from './declarations.js';
1
+ import { IContractChecker } from './ContractChecker.js';
2
+ import { ContractViolationPolicy, IResource, ResourceContext, ResourceEnvelope, ResourceOutput, ResourceSchema } from './declarations.js';
2
3
  /**
3
- * Base API resource — the declarative way to shape what your domain exposes.
4
+ * Base API resource — the layer responsible for exposing data.
4
5
  *
5
- * Extend it and implement {@link Resource.toArray} to map a model to its public shape. Everything
6
- * else (sparse fieldsets, dropping conditional fields, collections, envelopes) is handled for you.
7
- * A resource is decoupled from controllers and platform-agnostic: the same resource shapes data on
8
- * the backend and on the frontend.
6
+ * A resource answers two questions, and the second is what makes it worth having. *What leaves?* —
7
+ * and *what did you promise leaves?* The promise is a schema, so the same declaration validates the
8
+ * response, documents it in the published contract, and lets a caller ask for a named subset of it.
9
+ *
10
+ * Projection is the schema's own work: what the schema does not describe is not exposed, so a field
11
+ * added to a model later — a password hash, an internal flag — cannot leak by being forgotten.
9
12
  *
10
13
  * @example
11
14
  * ```ts
12
- * class UserResource extends Resource<User> {
13
- * toArray (user: User, ctx: ResourceContext) {
14
- * return {
15
- * id: user.id,
16
- * name: user.name,
17
- * email: this.when(ctx.self === true, user.email),
18
- * posts: this.whenIncluded(ctx, 'posts', () => postResource.collection(user.posts))
19
- * }
15
+ * @ApiResource('user')
16
+ * export class UserResource extends Resource<User> {
17
+ * constructor ({ posts }: { posts: PostService }) {
18
+ * super()
19
+ * this.posts = posts
20
+ * }
21
+ *
22
+ * schema () {
23
+ * return z.object({ id: z.number(), name: z.string(), posts: z.array(z.string()).optional() })
24
+ * }
25
+ *
26
+ * fragments () {
27
+ * return { summary: z.object({ id: z.number(), name: z.string() }) }
28
+ * }
29
+ *
30
+ * async data (user: User) {
31
+ * return { ...user, posts: await this.posts.titlesOf(user.id) }
20
32
  * }
21
33
  * }
22
34
  * ```
23
35
  */
24
36
  export declare abstract class Resource<Model = unknown, Output extends ResourceOutput = ResourceOutput> implements IResource<Model, Output> {
37
+ private readonly checker;
38
+ private readonly onViolation;
25
39
  /**
26
- * Map a model to its public shape (before field filtering).
40
+ * @param dependencies - Auto-wired services. Nothing is required: this module reads schemas with its
41
+ * own checker, so exposing data never depends on a validation module being
42
+ * enabled. Pass `checker` to substitute a dialect of your own.
43
+ */
44
+ constructor(dependencies?: {
45
+ checker?: IContractChecker;
46
+ onViolation?: ContractViolationPolicy;
47
+ });
48
+ /**
49
+ * The contract: what this resource exposes.
27
50
  *
28
- * @param model - The domain model.
29
51
  * @param context - The resource context.
30
- * @returns The public shape.
52
+ * @returns The schema.
31
53
  */
32
- abstract toArray(model: Model, context: ResourceContext): Output;
54
+ abstract schema(context: ResourceContext): ResourceSchema | Promise<ResourceSchema>;
33
55
  /**
34
- * Transform one model, applying the requested sparse fieldset and dropping undefined fields.
56
+ * Project one model.
57
+ *
58
+ * The order is the design: complete the data, choose the contract the caller asked for, hold the
59
+ * result against it, then narrow. Validation happens *before* narrowing, so the promise is checked
60
+ * against everything the resource produced rather than against whatever survived a query parameter.
35
61
  *
36
62
  * @param model - The domain model.
37
63
  * @param context - The resource context.
38
- * @returns The filtered public shape.
64
+ * @returns The projected output.
65
+ * @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
39
66
  */
40
- item(model: Model, context?: ResourceContext): Partial<Output>;
67
+ item(model: Model, context?: ResourceContext): Promise<Output>;
41
68
  /**
42
- * Transform a collection of models.
69
+ * Project a collection.
70
+ *
71
+ * Sequential rather than concurrent: `data()` may reach a database or an API, and a hundred models
72
+ * turning into a hundred simultaneous calls is a denial of service an application performs on
73
+ * itself. A resource that wants concurrency batches inside its own `data()`, where it knows the cost.
43
74
  *
44
75
  * @param models - The domain models.
45
76
  * @param context - The resource context.
46
- * @returns The transformed collection.
77
+ * @returns The projected collection.
47
78
  */
48
- collection(models: Model[], context?: ResourceContext): Array<Partial<Output>>;
79
+ collection(models: Model[], context?: ResourceContext): Promise<Output[]>;
49
80
  /**
50
- * Wrap a model or a collection in a `{ data, meta }` envelope.
81
+ * Project into a `{ data, meta }` envelope.
51
82
  *
52
83
  * @param models - A model or a collection.
53
84
  * @param context - The resource context.
54
85
  * @param meta - Optional metadata (pagination, counts, …).
55
86
  * @returns The envelope.
56
87
  */
57
- response(models: Model | Model[], context?: ResourceContext, meta?: Record<string, unknown>): ResourceEnvelope<Partial<Output> | Array<Partial<Output>>>;
88
+ response(models: Model | Model[], context?: ResourceContext, meta?: Record<string, unknown>): Promise<ResourceEnvelope<Output | Output[]>>;
89
+ /**
90
+ * Optional: shape or complete the model before it meets the schema.
91
+ *
92
+ * `declare`, not a field: an uninitialised class field is *defined* as `undefined` on the instance,
93
+ * which would shadow the very method a subclass wrote — the override would exist on the prototype
94
+ * and never be reached. This states the type and emits nothing.
95
+ */
96
+ data?: (model: Model, context: ResourceContext) => unknown | Promise<unknown>;
97
+ /**
98
+ * Named subsets a caller may ask for. Override to expose fragments.
99
+ */
100
+ fragments?: (context: ResourceContext) => Record<string, ResourceSchema> | Promise<Record<string, ResourceSchema>>;
101
+ /**
102
+ * The schema to hold this projection against: the requested fragment when the resource exposes one,
103
+ * the full contract otherwise.
104
+ *
105
+ * An unknown fragment falls back to the full contract rather than failing. A caller guessing
106
+ * `?view=nonsense` is asking a question, not attacking: answering the documented shape is more
107
+ * useful than a 500, and the fragments a resource exposes are published in the contract anyway.
108
+ *
109
+ * @param context - The resource context.
110
+ * @returns The schema.
111
+ */
112
+ protected schemaFor(context: ResourceContext): Promise<ResourceSchema>;
113
+ /**
114
+ * Hold the data against the contract, and return what the contract describes.
115
+ *
116
+ * The schema is the projection: its parsed value is the output, so a field the contract does not
117
+ * mention is not exposed, whatever the model gains later.
118
+ *
119
+ * @param data - The completed data.
120
+ * @param schema - The contract.
121
+ * @param context - The resource context.
122
+ * @returns The projected value.
123
+ * @throws {ResourceContractError} When the data breaks the contract and the policy is `throw`.
124
+ */
125
+ protected project(data: unknown, schema: ResourceSchema, context: ResourceContext): Promise<unknown>;
58
126
  /**
59
- * Include a value only when `condition` is truthy (otherwise the field is dropped).
127
+ * Include a value only when `condition` holds (otherwise the field is dropped).
60
128
  *
61
129
  * @param condition - Whether to include the value.
62
130
  * @param value - The value, or a lazy factory (only evaluated when included).
@@ -64,7 +132,7 @@ export declare abstract class Resource<Model = unknown, Output extends ResourceO
64
132
  */
65
133
  protected when<T>(condition: boolean, value: T | (() => T)): T | undefined;
66
134
  /**
67
- * Include a value only when the relation was requested via `context.include`.
135
+ * Include a value only when the relation was requested through `context.include`.
68
136
  *
69
137
  * @param context - The resource context.
70
138
  * @param name - The relation name.
@@ -1,36 +1,74 @@
1
1
  /**
2
- * The context that shapes a transformation: which fields the client asked for, which relations to
3
- * include, and any extra data (the current user, the event, …) a resource may consult.
2
+ * A schema, in whatever shape the application already writes them.
4
3
  *
5
- * It is intentionally open so resources can read whatever they need while staying agnostic.
4
+ * Anything `@stone-js/validation` accepts is accepted here: a Standard Schema (Zod, Valibot, ArkType
5
+ * and others), a Zod-like `safeParse`, or a native Stone.js schema. Resources do not define a schema
6
+ * language; they use the one the application already validates its input with, so a contract is
7
+ * written once in one dialect on both sides of the boundary.
8
+ */
9
+ export type ResourceSchema = unknown;
10
+ /**
11
+ * The context a projection is given.
12
+ *
13
+ * Open on purpose: a resource reads whatever it needs, and the middleware fills in what the request
14
+ * carried. The authenticated principal is part of it, because deciding what a caller may see is the
15
+ * most common reason a projection differs between two callers.
6
16
  */
7
17
  export interface ResourceContext {
8
- /** Requested sparse fieldset — when set, the output is limited to these top-level keys. */
18
+ /** Requested sparse fieldset — narrows the output to these top-level keys. */
9
19
  fields?: string[];
10
20
  /** Requested relations to embed. */
11
21
  include?: string[];
12
- /** Anything else a resource needs (e.g. the authenticated principal). */
22
+ /** The requested fragment, when the caller asked for one by name. */
23
+ fragment?: string;
24
+ /** The authenticated principal, when the application has one. */
25
+ principal?: unknown;
26
+ /** The event being answered, for a resource that needs more than the parameters above. */
27
+ event?: unknown;
28
+ /** Anything else a resource needs. */
13
29
  [key: string]: unknown;
14
30
  }
15
31
  /** A plain, serialisable output object. */
16
32
  export type ResourceOutput = Record<string, unknown>;
17
33
  /**
18
- * A `{ data, meta }` envelope around a transformed item or collection.
34
+ * A `{ data, meta }` envelope around a projected item or collection.
19
35
  */
20
36
  export interface ResourceEnvelope<T> {
21
37
  data: T;
22
38
  meta?: Record<string, unknown>;
23
39
  }
24
40
  /**
25
- * The resource contract: transform a model (or a collection) into its public representation.
41
+ * What a resource does: turn a domain model into the shape a caller is allowed to see, and say what
42
+ * that shape is.
43
+ *
44
+ * Saying it is the point. A projection written as code answers "what does this return?" only by being
45
+ * read and trusted; a projection written as a schema answers it to a person, to `@stone-js/openapi`,
46
+ * and to the resource itself, which validates against it before anything leaves. One declaration, three
47
+ * consumers, and no way for the documentation to drift from the response.
26
48
  */
27
- export interface IResource<Model = unknown, Output extends ResourceOutput = ResourceOutput> {
28
- /** Transform one model into its public shape (before field filtering). */
29
- toArray: (model: Model, context: ResourceContext) => Output;
30
- /** Transform one model, applying sparse fieldsets and dropping undefined fields. */
31
- item: (model: Model, context?: ResourceContext) => Partial<Output>;
32
- /** Transform a collection. */
33
- collection: (models: Model[], context?: ResourceContext) => Array<Partial<Output>>;
34
- /** Wrap a model or collection in a `{ data, meta }` envelope. */
35
- response: (models: Model | Model[], context?: ResourceContext, meta?: Record<string, unknown>) => ResourceEnvelope<Partial<Output> | Array<Partial<Output>>>;
49
+ export interface IResource<Model = unknown, Output = ResourceOutput> {
50
+ /** The contract: the schema every projection is validated against and documented from. */
51
+ schema: (context: ResourceContext) => ResourceSchema | Promise<ResourceSchema>;
52
+ /**
53
+ * Named subsets a caller may ask for, each with its own schema.
54
+ *
55
+ * A fragment is not a filter: it is a contract of its own, documented and validated like the full
56
+ * one. That is what makes `?view=summary` safe to expose.
57
+ */
58
+ fragments?: (context: ResourceContext) => Record<string, ResourceSchema> | Promise<Record<string, ResourceSchema>>;
59
+ /**
60
+ * Optional hook to shape or complete the model before it meets the schema.
61
+ *
62
+ * Asynchronous, and resolved from the container, so it may reach any service: fetch a relation,
63
+ * translate a label, compute a total. Whatever it returns is what the schema then validates.
64
+ */
65
+ data?: (model: Model, context: ResourceContext) => unknown | Promise<unknown>;
66
+ /** Project one model. */
67
+ item: (model: Model, context?: ResourceContext) => Promise<Output>;
68
+ /** Project a collection. */
69
+ collection: (models: Model[], context?: ResourceContext) => Promise<Output[]>;
70
+ /** Project into a `{ data, meta }` envelope. */
71
+ response: (models: Model | Model[], context?: ResourceContext, meta?: Record<string, unknown>) => Promise<ResourceEnvelope<Output | Output[]>>;
36
72
  }
73
+ /** What to do when the data does not match the contract the resource published. */
74
+ export type ContractViolationPolicy = 'throw' | 'warn';
@@ -1,17 +1,39 @@
1
1
  import { Resource } from './Resource.js';
2
- import { ResourceContext, ResourceOutput } from './declarations.js';
2
+ import { IContractChecker } from './ContractChecker.js';
3
+ import { ContractViolationPolicy, ResourceContext, ResourceOutput, ResourceSchema } from './declarations.js';
3
4
  /**
4
- * The imperative/functional way to define a resource — a plain transform function instead of a
5
- * class. Returns a full {@link Resource} (so you still get `item`/`collection`/`response` and
6
- * sparse fieldsets for free).
5
+ * What an imperatively-defined resource declares.
7
6
  *
8
- * @param transform - Maps a model to its public shape.
7
+ * The same three things a class declares, so neither paradigm can do something the other cannot.
8
+ */
9
+ export interface ResourceDefinition<Model = unknown> {
10
+ /** The contract: what this resource exposes. A schema, or a function returning one. */
11
+ schema: ResourceSchema | ((context: ResourceContext) => ResourceSchema | Promise<ResourceSchema>);
12
+ /** Named subsets a caller may ask for, each with its own schema. */
13
+ fragments?: Record<string, ResourceSchema> | ((context: ResourceContext) => Record<string, ResourceSchema> | Promise<Record<string, ResourceSchema>>);
14
+ /** Optional hook to shape or complete the model before it meets the schema. */
15
+ data?: (model: Model, context: ResourceContext) => unknown | Promise<unknown>;
16
+ }
17
+ /**
18
+ * The imperative way to define a resource: an object instead of a class.
19
+ *
20
+ * Parity is the rule, so this declares exactly what a class declares and gets exactly what a class
21
+ * gets. It needs nothing injected: this module reads schemas with its own checker.
22
+ *
23
+ * @param definition - The schema, and optionally fragments and a `data()` hook.
24
+ * @param dependencies - Optional explicit services, for a resource used outside a request.
9
25
  * @returns A resource.
10
26
  *
11
27
  * @example
12
28
  * ```ts
13
- * const userResource = defineResource<User>((user) => ({ id: user.id, name: user.name }))
14
- * userResource.collection(users, { fields: ['id'] })
29
+ * export const userResource = defineResource<User>({
30
+ * schema: z.object({ id: z.number(), name: z.string() }),
31
+ * fragments: { summary: z.object({ id: z.number() }) },
32
+ * data: async (user) => ({ ...user, posts: await posts.titlesOf(user.id) })
33
+ * })
15
34
  * ```
16
35
  */
17
- export declare function defineResource<Model = unknown, Output extends ResourceOutput = ResourceOutput>(transform: (model: Model, context: ResourceContext) => Output): Resource<Model, Output>;
36
+ export declare function defineResource<Model = unknown, Output extends ResourceOutput = ResourceOutput>(definition: ResourceDefinition<Model>, dependencies?: {
37
+ checker?: IContractChecker;
38
+ onViolation?: ContractViolationPolicy;
39
+ }): Resource<Model, Output>;
@@ -0,0 +1,31 @@
1
+ import { ErrorOptions, RuntimeError } from '@stone-js/core';
2
+ /** One reason the data did not match the contract. */
3
+ export interface ContractIssue {
4
+ message: string;
5
+ path: Array<string | number>;
6
+ }
7
+ /** Options carrying what failed. */
8
+ export interface ResourceContractErrorOptions extends ErrorOptions {
9
+ issues?: ContractIssue[];
10
+ }
11
+ /**
12
+ * Raised when what a handler produced does not match the schema its resource published.
13
+ *
14
+ * This is a server-side fault, deliberately. The resource's schema is a promise made to every caller
15
+ * and to the published contract; data that breaks it means the application is about to answer
16
+ * something it documented it would not. Returning it anyway is the failure — a client cannot detect
17
+ * it, and a consumer generated from the contract will break on a field that was supposed to be there.
18
+ *
19
+ * It fires on a genuine breach, not on a difference: a schema strips what it does not describe, so
20
+ * extra fields are simply not exposed. Reaching this means something the contract requires is missing
21
+ * or has the wrong type.
22
+ */
23
+ export declare class ResourceContractError extends RuntimeError {
24
+ /** What failed, so a log says which field rather than "validation failed". */
25
+ readonly issues: ContractIssue[];
26
+ /**
27
+ * @param message - What went wrong.
28
+ * @param options - Additional error options, including the issues.
29
+ */
30
+ constructor(message: string, options?: ResourceContractErrorOptions);
31
+ }
package/dist/helpers.d.ts CHANGED
@@ -32,13 +32,26 @@ export declare function except<T extends ResourceOutput>(object: T, keys: string
32
32
  */
33
33
  export declare function applyFields<T extends ResourceOutput>(output: T, fields?: string[]): Partial<T>;
34
34
  /**
35
- * Builds a {@link ResourceContext} from an incoming event's `fields` and `include` query
36
- * parameters (comma-separated). Agnostic: the event only needs a `get(key)` method.
35
+ * Build a {@link ResourceContext} from an incoming event.
36
+ *
37
+ * The parameter names are configuration, not convention: an API that already answers `?view=` or
38
+ * `?only=` keeps its own vocabulary instead of gaining a second one. Defaults are `fields`, `include`
39
+ * and `view`.
40
+ *
41
+ * The authenticated principal is read too, because deciding what a caller may see is the most common
42
+ * reason two callers get different shapes — and a resource that cannot see who is asking has to be
43
+ * told by the handler, which is exactly the plumbing this module exists to remove.
44
+ *
45
+ * Agnostic: the event only needs `get(key)`.
37
46
  *
38
47
  * @param event - Anything with `get(key)` (an `IncomingHttpEvent`, a URL search wrapper, …).
48
+ * @param blueprint - The blueprint carrying the parameter names, when there is one.
39
49
  * @param extra - Extra context to merge in.
40
50
  * @returns The resource context.
41
51
  */
42
52
  export declare function contextFromEvent(event: {
43
53
  get: <T>(key: string, fallback?: T) => T;
54
+ getUser?: <T>() => T;
55
+ }, blueprint?: {
56
+ get: <T>(key: string, fallback?: T) => T;
44
57
  }, extra?: ResourceContext): ResourceContext;
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from './ContractChecker.js';
1
2
  export * from './Resource.js';
2
3
  export * from './declarations.js';
3
4
  export * from './decorators/ApiResource.js';
@@ -5,6 +6,7 @@ export * from './decorators/Resources.js';
5
6
  export * from './decorators/Returns.js';
6
7
  export * from './decorators/constants.js';
7
8
  export * from './defineResource.js';
9
+ export * from './errors/ResourceContractError.js';
8
10
  export * from './helpers.js';
9
11
  export * from './middleware/BlueprintMiddleware.js';
10
12
  export * from './middleware/ResourceRouteMiddleware.js';