@haddowg/json-api-client 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gregory Haddow
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,344 @@
1
+ # @haddowg/json-api-client
2
+
3
+ > **Part of the [jsonapi.rest](https://jsonapi.rest) suite** — a complete, spec-compliant
4
+ > JSON:API 1.1 stack: a framework-agnostic PHP [core](https://github.com/haddowg/json-api), a
5
+ > [Symfony bundle](https://github.com/haddowg/json-api-symfony), a
6
+ > [Laravel package](https://github.com/haddowg/json-api-laravel), and this **typed TypeScript
7
+ > client**, bound together by one conformance-tested OpenAPI 3.1 contract.
8
+ > Full documentation: **[haddowg.github.io/json-api-ts](https://haddowg.github.io/json-api-ts/)**.
9
+
10
+ The generic, framework-agnostic runtime for a typesafe JSON:API client. Parameterised by a
11
+ descriptor emitted by [`@haddowg/json-api-codegen`](../codegen) — the codegen reads your API's
12
+ OpenAPI document; this package does the de/serialisation, hydration, and the fluent surface.
13
+
14
+ Works standalone (`await client.albums.list()` needs no framework). For TanStack Query caching +
15
+ normalization, add [`@haddowg/json-api-query`](../query).
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add @haddowg/json-api-client
21
+ ```
22
+
23
+ You generally don't construct the client from this package directly — the generated module
24
+ re-exports a descriptor-bound `createClient`. The snippets below assume that generated
25
+ `createClient` (see the [codegen docs](../codegen)).
26
+
27
+ ## Creating a client
28
+
29
+ ```ts
30
+ import { createClient } from './api/music.gen'
31
+
32
+ const client = createClient({
33
+ baseUrl: 'https://music.example',
34
+ // transport?: defaults to the global `fetch` adapter when omitted
35
+ // headers?: per-request header provider (e.g. bearer auth); may be async
36
+ headers: async () => ({ Authorization: `Bearer ${await getToken()}` }),
37
+ })
38
+ ```
39
+
40
+ `ClientOptions`:
41
+
42
+ | Option | Type | Notes |
43
+ | ----------- | ------------------------------------------- | ------------------------------------------------------------ |
44
+ | `baseUrl` | `string` | Base URL the generated path templates resolve against. |
45
+ | `transport` | `(req) => Promise<res>` | The transport seam; defaults to a `fetch` adapter. |
46
+ | `headers` | `() => HeadersInit \| Promise<HeadersInit>` | Per-request header provider (bearer auth, etc.). |
47
+ | `atomic` | `AtomicDescriptor \| null` | Atomic endpoint; the generated `createClient` threads it in. |
48
+ | `validate` | `ValidationOption` | Opt-in per-field validation seam (off by default). |
49
+
50
+ ## Reads
51
+
52
+ A resource read returns a **materialised** value: attributes and `type`/`id` are flat, enumerable
53
+ props (clean to spread / `JSON.stringify`); the envelope hangs off non-enumerable `$`-accessors.
54
+
55
+ ### List a collection — filter / sort / include / fields / page
56
+
57
+ ```ts
58
+ const albums = await client.albums.list({
59
+ filter: { status: 'released' },
60
+ sort: '-releasedAt',
61
+ include: ['artist'], // widens `artist` to a hydrated resource in the result type
62
+ fields: { albums: ['title', 'status', 'artist'] }, // sparse — other attrs are statically absent
63
+ page: { number: 1 },
64
+ })
65
+
66
+ albums[0]!.title // string
67
+ albums[0]!.artist?.name // hydrated artist resource (its attributes are typed)
68
+ albums.$page.kind // 'page' | 'offset' | 'cursor' | 'none'
69
+ albums.$meta?.['page'] // raw top-level meta
70
+ ```
71
+
72
+ > A relation kept in `include` must also stay in the `fields` set for that type, or it is narrowed
73
+ > off the result type.
74
+
75
+ ### Get one — with a hydrated compound document
76
+
77
+ ```ts
78
+ const ok = await client.albums.get('1', { include: ['artist', 'tracks'] })
79
+
80
+ ok.title // string
81
+ ok.$self // resource-level self link (non-enumerable)
82
+ ok.tracks // the to-many `tracks` is a hydrated augmented array of track resources
83
+ ```
84
+
85
+ ### Resource-level `$`-accessors
86
+
87
+ A materialised resource carries (all non-enumerable):
88
+
89
+ - `$meta` — resource-level meta
90
+ - `$links` / `$self` — resource-level links (`$self` is the `self` link shorthand)
91
+ - `$document` — the shared, trimmed top-level `{ jsonapi, meta, links }`
92
+ - `$raw` — the original JSON:API resource object (escape hatch)
93
+ - `$rel(name)` — `{ data, links, meta }` for a relation (links-only relations + introspection)
94
+
95
+ ### Follow a relationship
96
+
97
+ ```ts
98
+ // The related RESOURCES (full objects): GET /albums/1/tracks
99
+ const tracks = await client.albums
100
+ .id('1')
101
+ .rel('tracks')
102
+ .related({ page: { size: 2 } })
103
+ tracks.map((t) => t.title)
104
+ tracks.$page.kind
105
+
106
+ // The LINKAGE (resource identifiers): GET /albums/1/relationships/tracks
107
+ const linkage = await client.albums.id('1').rel('tracks').get()
108
+ ```
109
+
110
+ ### Pivot data on a many-to-many edge
111
+
112
+ Each member of a `belongsToMany` to-many carries its per-edge pivot under `$pivot` (typed sugar
113
+ over `meta.pivot`); the same resource in another parent carries a different pivot.
114
+
115
+ ```ts
116
+ const ordered = await client.playlists.id(playlistId).rel('orderedTracks').related()
117
+ ordered[0]!.$pivot?.['position'] // edge-local pivot data
118
+ ```
119
+
120
+ ### `withCount` (the Countable profile)
121
+
122
+ `withCount` is constrained to the type's count tokens and only available on `list` / related
123
+ reads (no single-`get` endpoint advertises it). The client negotiates the Countable profile in
124
+ `Accept` automatically when `withCount` is present.
125
+
126
+ ```ts
127
+ const albums = await client.albums.list({ withCount: ['tracks'] })
128
+ ```
129
+
130
+ ### Pagination & cursor
131
+
132
+ All three collection surfaces (top-level lists, related collections, to-many relationship arrays)
133
+ expose the same `$page` + `$next()` / `$prev()`. Navigation is **count-free-safe** — driven by
134
+ link presence, not a total. Cursor pagination is just a paginator `kind`; page with
135
+ `page[cursor]` / `page[size]` query params and walk it with `$next()`:
136
+
137
+ ```ts
138
+ let page = await client.tracks.list({ page: { size: 50 } })
139
+ while (page) {
140
+ for (const track of page) {
141
+ /* … */
142
+ }
143
+ page = await page.$next() // undefined when there is no next link
144
+ }
145
+ ```
146
+
147
+ ## Writes
148
+
149
+ Flat ergonomic input — the client builds the JSON:API document. Create / update return the
150
+ materialised resource; delete resolves `void`.
151
+
152
+ ```ts
153
+ // POST /albums — `id` is required/optional/forbidden per the spec's per-type policy.
154
+ const created = await client.albums.create({ title: 'Kid A', status: 'released' })
155
+
156
+ // PATCH /albums/1 — a partial patch.
157
+ const updated = await client.albums.id('1').update({ title: 'OK Computer (Remaster)' })
158
+
159
+ // DELETE /albums/1 — resolves void (a 204).
160
+ await client.albums.id('1').delete()
161
+ ```
162
+
163
+ Create / update accept an optional second arg `{ include?, fields? }` to shape the materialised
164
+ response, exactly like a read.
165
+
166
+ ### Relationship mutation
167
+
168
+ A to-many relation exposes `add` / `remove` / `replace`; a to-one exposes `set(ref | null)`. A
169
+ verb is present only when the relation's endpoint advertises it (the descriptor's per-relation
170
+ `mutations` flags, derived from the bundle's `cannotAdd` / `cannotRemove` / `cannotReplace` +
171
+ endpoint exposure) — a forbidden verb is a compile error, not a runtime surprise.
172
+
173
+ ```ts
174
+ // to-many: POST / DELETE / PATCH .../relationships/tracks
175
+ await client.albums
176
+ .id('1')
177
+ .rel('tracks')
178
+ .add([{ type: 'tracks', id: '4' }])
179
+ await client.albums
180
+ .id('1')
181
+ .rel('tracks')
182
+ .remove([{ type: 'tracks', id: '4' }])
183
+ await client.albums
184
+ .id('1')
185
+ .rel('tracks')
186
+ .replace([
187
+ { type: 'tracks', id: '1' },
188
+ { type: 'tracks', id: '3' },
189
+ ])
190
+
191
+ // to-one: PATCH .../relationships/artist
192
+ await client.albums.id('1').rel('artist').set({ type: 'artists', id: '2' })
193
+ await client.albums.id('1').rel('artist').set(null) // clear
194
+ ```
195
+
196
+ > **Verb / relation collision:** relations are reachable via `.rel('name')` (and, for the common
197
+ > case, by name directly off the handle). A relation named like a reserved verb (`get`, `update`,
198
+ > `delete`, `actions`, …) is shadowed — the codegen warns at build time and you reach it via
199
+ > `.rel('name')`.
200
+
201
+ ## Custom actions
202
+
203
+ Actions are reached via `.actions.<name>`: collection-scoped off the type accessor, resource-scoped
204
+ off a handle (`.id(id)`). The body shape follows the action's declared `input`
205
+ (`none` / `document` / `raw`); a `document` output is materialised, a `none` output resolves
206
+ `undefined`.
207
+
208
+ ```ts
209
+ // Resource-scoped, document in / document out.
210
+ const reissued = await client.albums.id('1').actions.reissue({
211
+ data: { type: 'albums', attributes: { title: 'OK Computer' } },
212
+ })
213
+
214
+ // Collection-scoped, no input.
215
+ const summary = await client.albums.actions.summary()
216
+ ```
217
+
218
+ ## Atomic operations
219
+
220
+ `client.atomic(tx => …)` records ops in order and posts them as one all-or-nothing batch (the
221
+ atomic ext media type). A `tx.create(...)` handle **doubles as a `lid` ref**, so a just-created
222
+ resource wires into later ops without a server id. Results are typed positionally by op index.
223
+
224
+ ```ts
225
+ const [artist, geogaddi] = await client.atomic((tx) => {
226
+ const newArtist = tx.create({ type: 'artists', name: 'Boards of Canada' })
227
+ const newAlbum = tx.create({ type: 'albums', title: 'Geogaddi', artist: newArtist })
228
+ return [newArtist, newAlbum] as const
229
+ })
230
+
231
+ artist.data.id // '99'
232
+ geogaddi.data.title // 'Geogaddi'
233
+ ```
234
+
235
+ `tx` also exposes `tx.update({ type, id, …fields })` and `tx.delete({ type, id } | ref)`. Calling
236
+ `client.atomic` on a server that exposes no atomic endpoint throws.
237
+
238
+ ## Errors
239
+
240
+ A non-2xx throws a typed `JsonApiError` carrying `status` + `errors: JsonApiErrorObject[]`, with
241
+ expressive status matchers and pointer grouping:
242
+
243
+ ```ts
244
+ import type { JsonApiError } from '@haddowg/json-api-client'
245
+
246
+ try {
247
+ await client.albums.create({ title: '' })
248
+ } catch (e) {
249
+ const error = e as JsonApiError
250
+ error.isUnprocessable() // 422 (alias: isValidationError())
251
+ // Server pointers (`/data/attributes/title`) are remapped to your FLAT input path (`title`).
252
+ error.byPath()['title']?.[0]?.detail // 'must not be blank'
253
+ }
254
+ ```
255
+
256
+ Status matchers: `hasStatus(n)`, `is4xx()`, `is5xx()`, `isBadRequest()` /400,
257
+ `isUnauthorized()` /401, `isForbidden()` /403, `isNotFound()` /404, `isNotAcceptable()` /406,
258
+ `isConflict()` /409, `isUnsupportedMediaType()` /415, `isUnprocessable()` /
259
+ `isValidationError()` /422, `isRateLimited()` /429.
260
+
261
+ `byPath()` groups errors by the flat input path (falling back to the raw pointer, then the query
262
+ `parameter`) — the payoff for form/validation UX. Each error keeps its raw `source.pointer` as an
263
+ escape hatch; atomic errors also carry an `opIndex`.
264
+
265
+ > A response that parses but violates the JSON:API envelope shape (not a document, a member
266
+ > missing `type`/`id`) throws a `StructuralGuardError` — distinct from a server-reported
267
+ > `JsonApiError`. These light structural guards are always on; full per-field validation is opt-in
268
+ > (below).
269
+
270
+ ## Transport
271
+
272
+ The transport is a tiny `fetch`-shaped function, so any impl (native `fetch`, undici, an axios
273
+ adapter, or a test mock) drops in:
274
+
275
+ ```ts
276
+ import type {
277
+ JsonApiTransport,
278
+ TransportRequest,
279
+ TransportResponse,
280
+ } from '@haddowg/json-api-client'
281
+
282
+ const transport: JsonApiTransport = async (req: TransportRequest): Promise<TransportResponse> => {
283
+ const res = await myHttpLib(req.method, req.url, { headers: req.headers, body: req.body })
284
+ return { status: res.status, headers: res.headers, body: res.text }
285
+ }
286
+
287
+ const client = createClient({ baseUrl: 'https://music.example', transport })
288
+ ```
289
+
290
+ Retries are deliberately out of scope here — the transport (or the TanStack layer) owns them.
291
+
292
+ ## Opt-in validation
293
+
294
+ The core runtime trusts the wire (you own both ends; the envelope is invariant) and only runs the
295
+ light structural guards. Full per-field validation is **opt-in** via the `validate?` option, fed by
296
+ the codegen-emitted per-type `schemas` map. The validation engine is brought by you, never in the
297
+ core dep tree.
298
+
299
+ ### With the bundled ajv adapter
300
+
301
+ `ajv` is an optional peer dependency; the adapter lives at the `@haddowg/json-api-client/ajv`
302
+ sub-path, so importing the main entry never pulls ajv in.
303
+
304
+ ```bash
305
+ pnpm add ajv
306
+ ```
307
+
308
+ ```ts
309
+ import Ajv2020 from 'ajv/dist/2020'
310
+ import { createClient } from './api/music.gen'
311
+ import { createAjvValidator } from '@haddowg/json-api-client/ajv'
312
+ import { schemas } from './api/music.schemas.gen' // the codegen's `--schemas` output
313
+
314
+ // The bundle emits JSON Schema 2020-12, so use `Ajv2020`. `strict: false` tolerates the
315
+ // schemas' `x-enum-*` annotations; `allErrors` aggregates every failing field.
316
+ const ajv = new Ajv2020({ allErrors: true, strict: false, validateFormats: false })
317
+
318
+ const client = createClient({
319
+ baseUrl: 'https://music.example',
320
+ validate: createAjvValidator(ajv, schemas),
321
+ })
322
+ ```
323
+
324
+ Every wire resource is now validated against `schemas[type]`; a type the bundle does not cover is
325
+ skipped (graceful partial coverage). A failure throws an `AjvValidationError` carrying every
326
+ failing `{ type, id?, pointer, keyword, message }`.
327
+
328
+ ### With your own engine
329
+
330
+ `validate` also accepts the schema-driven config `{ schemas, validator }` or a bare
331
+ `(resource, schema) => void` validator that owns its own schema lookup:
332
+
333
+ ```ts
334
+ const client = createClient({
335
+ baseUrl: 'https://music.example',
336
+ validate: (resource) => {
337
+ // throw on an invalid resource; return for a valid one
338
+ },
339
+ })
340
+ ```
341
+
342
+ ## License
343
+
344
+ MIT
package/dist/ajv.cjs ADDED
@@ -0,0 +1,62 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/ajv.ts
3
+ /**
4
+ * Thrown by the ajv adapter when a wire resource fails its per-type schema. Aggregates every
5
+ * ajv error into {@link AjvValidationFailure}s (one per failing field) and renders a message
6
+ * listing the failing pointers, so a caller can both read the summary and walk `failures`.
7
+ */
8
+ var AjvValidationError = class extends Error {
9
+ /** Every failing field across the resource (ajv runs with `allErrors`-aware reporting if enabled). */
10
+ failures;
11
+ /** The JSON:API type of the resource that failed. */
12
+ type;
13
+ /** The id of the resource that failed, when present. */
14
+ resourceId;
15
+ constructor(type, id, failures) {
16
+ const at = id === void 0 ? type : `${type}:${id}`;
17
+ const pointers = failures.map((f) => `${f.pointer || "/"} ${f.message}`).join("; ");
18
+ super(`Resource ${at} failed schema validation: ${pointers}`);
19
+ this.name = "AjvValidationError";
20
+ this.type = type;
21
+ if (id !== void 0) this.resourceId = id;
22
+ this.failures = failures;
23
+ }
24
+ };
25
+ /** Map ajv's raw `ErrorObject`s for one resource to the adapter's typed failures. */
26
+ function toFailures(type, id, errors) {
27
+ return errors.map((e) => {
28
+ const failure = {
29
+ type,
30
+ pointer: e.instancePath,
31
+ keyword: e.keyword,
32
+ message: e.message ?? "is invalid"
33
+ };
34
+ if (id !== void 0) failure.id = id;
35
+ return failure;
36
+ });
37
+ }
38
+ /**
39
+ * Build the client's {@link Validator} from a user-brought ajv instance and the codegen's per-type
40
+ * `schemas` map. Each schema is compiled ONCE up front (keyed by JSON:API type) with the instance's
41
+ * configured dialect — pass `new Ajv2020()` for the 2020-12 dialect the bundle emits. The returned
42
+ * function is the bare-`Validator` seam form: it looks the compiled validator up by `resource.type`,
43
+ * skips a type the bundle does not cover (graceful partial coverage), and on a failing resource
44
+ * throws an aggregated {@link AjvValidationError} listing the failing pointers.
45
+ *
46
+ * @param ajv A configured ajv instance ({@link AjvLike}) — typically `new Ajv2020({ allErrors: true })`.
47
+ * @param schemas The codegen-emitted `schemas` map (type -> resource-object JSON Schema 2020-12 doc).
48
+ */
49
+ function createAjvValidator(ajv, schemas) {
50
+ const compiled = /* @__PURE__ */ new Map();
51
+ for (const [type, schema] of Object.entries(schemas)) compiled.set(type, ajv.compile(schema));
52
+ const validator = (resource) => {
53
+ const { type, id } = resource;
54
+ const validate = compiled.get(type);
55
+ if (validate === void 0) return;
56
+ if (!validate(resource)) throw new AjvValidationError(type, id, toFailures(type, id, validate.errors ?? []));
57
+ };
58
+ return validator;
59
+ }
60
+ //#endregion
61
+ exports.AjvValidationError = AjvValidationError;
62
+ exports.createAjvValidator = createAjvValidator;
package/dist/ajv.d.cts ADDED
@@ -0,0 +1,55 @@
1
+ import { a as Validator, n as SchemaMap } from "./validate-BmyPbJCK.cjs";
2
+ import { ValidateFunction } from "ajv";
3
+
4
+ //#region src/ajv.d.ts
5
+ /**
6
+ * The minimal ajv surface the adapter uses: `compile(schema)` -> a validate function. Any
7
+ * ajv-core instance satisfies it — typically `new Ajv2020()` (the JSON Schema 2020-12 dialect
8
+ * the bundle emits), but a base `Ajv` with the 2020 meta-schema added works too. Typed
9
+ * structurally so the adapter binds to the capability, not a specific ajv subclass.
10
+ */
11
+ interface AjvLike {
12
+ compile(schema: unknown): ValidateFunction;
13
+ }
14
+ /** One failing field: the resource it came from plus ajv's pointer, keyword, and message. */
15
+ interface AjvValidationFailure {
16
+ /** The JSON:API type of the resource that failed (`schemas[type]` was applied). */
17
+ type: string;
18
+ /** The resource id, when present (a bare identifier or a full resource object both carry one). */
19
+ id?: string;
20
+ /** ajv's `instancePath` into the resource (e.g. `/attributes/title`); empty string for the root. */
21
+ pointer: string;
22
+ /** The failing ajv keyword (e.g. `type`, `maxLength`, `enum`). */
23
+ keyword: string;
24
+ /** ajv's human-readable message (e.g. `must be string`). */
25
+ message: string;
26
+ }
27
+ /**
28
+ * Thrown by the ajv adapter when a wire resource fails its per-type schema. Aggregates every
29
+ * ajv error into {@link AjvValidationFailure}s (one per failing field) and renders a message
30
+ * listing the failing pointers, so a caller can both read the summary and walk `failures`.
31
+ */
32
+ declare class AjvValidationError extends Error {
33
+ /** Every failing field across the resource (ajv runs with `allErrors`-aware reporting if enabled). */
34
+ readonly failures: readonly AjvValidationFailure[];
35
+ /** The JSON:API type of the resource that failed. */
36
+ readonly type: string;
37
+ /** The id of the resource that failed, when present. */
38
+ readonly resourceId?: string;
39
+ constructor(type: string, id: string | undefined, failures: readonly AjvValidationFailure[]);
40
+ }
41
+ /**
42
+ * Build the client's {@link Validator} from a user-brought ajv instance and the codegen's per-type
43
+ * `schemas` map. Each schema is compiled ONCE up front (keyed by JSON:API type) with the instance's
44
+ * configured dialect — pass `new Ajv2020()` for the 2020-12 dialect the bundle emits. The returned
45
+ * function is the bare-`Validator` seam form: it looks the compiled validator up by `resource.type`,
46
+ * skips a type the bundle does not cover (graceful partial coverage), and on a failing resource
47
+ * throws an aggregated {@link AjvValidationError} listing the failing pointers.
48
+ *
49
+ * @param ajv A configured ajv instance ({@link AjvLike}) — typically `new Ajv2020({ allErrors: true })`.
50
+ * @param schemas The codegen-emitted `schemas` map (type -> resource-object JSON Schema 2020-12 doc).
51
+ */
52
+ declare function createAjvValidator(ajv: AjvLike, schemas: SchemaMap): Validator;
53
+ //#endregion
54
+ export { AjvLike, AjvValidationError, AjvValidationFailure, createAjvValidator };
55
+ //# sourceMappingURL=ajv.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ajv.d.cts","names":[],"sources":["../src/ajv.ts"],"mappings":";;;;;;;;;;UA2CiB,OAAA;EACf,OAAA,CAAQ,MAAA,YAAkB,gBAAgB;AAAA;;UAI3B,oBAAA;EAsEuB;EApEtC,IAAA;EAoEoE;EAlEpE,EAAA;EAkE6E;EAhE7E,OAAA;EAgEiC;EA9DjC,OAAA;EA8D+C;EA5D/C,OAAA;AAAA;AA4D6E;;;;;AAAA,cApDlE,kBAAA,SAA2B,KAAA;;WAE7B,QAAA,WAAmB,oBAAA;;WAEnB,IAAA;;WAEA,UAAA;cAEG,IAAA,UAAc,EAAA,sBAAwB,QAAA,WAAmB,oBAAA;AAAA;;;;;;;;;;;;iBA4CvD,kBAAA,CAAmB,GAAA,EAAK,OAAA,EAAS,OAAA,EAAS,SAAA,GAAY,SAAA"}
package/dist/ajv.d.mts ADDED
@@ -0,0 +1,55 @@
1
+ import { a as Validator, n as SchemaMap } from "./validate-BmyPbJCK.mjs";
2
+ import { ValidateFunction } from "ajv";
3
+
4
+ //#region src/ajv.d.ts
5
+ /**
6
+ * The minimal ajv surface the adapter uses: `compile(schema)` -> a validate function. Any
7
+ * ajv-core instance satisfies it — typically `new Ajv2020()` (the JSON Schema 2020-12 dialect
8
+ * the bundle emits), but a base `Ajv` with the 2020 meta-schema added works too. Typed
9
+ * structurally so the adapter binds to the capability, not a specific ajv subclass.
10
+ */
11
+ interface AjvLike {
12
+ compile(schema: unknown): ValidateFunction;
13
+ }
14
+ /** One failing field: the resource it came from plus ajv's pointer, keyword, and message. */
15
+ interface AjvValidationFailure {
16
+ /** The JSON:API type of the resource that failed (`schemas[type]` was applied). */
17
+ type: string;
18
+ /** The resource id, when present (a bare identifier or a full resource object both carry one). */
19
+ id?: string;
20
+ /** ajv's `instancePath` into the resource (e.g. `/attributes/title`); empty string for the root. */
21
+ pointer: string;
22
+ /** The failing ajv keyword (e.g. `type`, `maxLength`, `enum`). */
23
+ keyword: string;
24
+ /** ajv's human-readable message (e.g. `must be string`). */
25
+ message: string;
26
+ }
27
+ /**
28
+ * Thrown by the ajv adapter when a wire resource fails its per-type schema. Aggregates every
29
+ * ajv error into {@link AjvValidationFailure}s (one per failing field) and renders a message
30
+ * listing the failing pointers, so a caller can both read the summary and walk `failures`.
31
+ */
32
+ declare class AjvValidationError extends Error {
33
+ /** Every failing field across the resource (ajv runs with `allErrors`-aware reporting if enabled). */
34
+ readonly failures: readonly AjvValidationFailure[];
35
+ /** The JSON:API type of the resource that failed. */
36
+ readonly type: string;
37
+ /** The id of the resource that failed, when present. */
38
+ readonly resourceId?: string;
39
+ constructor(type: string, id: string | undefined, failures: readonly AjvValidationFailure[]);
40
+ }
41
+ /**
42
+ * Build the client's {@link Validator} from a user-brought ajv instance and the codegen's per-type
43
+ * `schemas` map. Each schema is compiled ONCE up front (keyed by JSON:API type) with the instance's
44
+ * configured dialect — pass `new Ajv2020()` for the 2020-12 dialect the bundle emits. The returned
45
+ * function is the bare-`Validator` seam form: it looks the compiled validator up by `resource.type`,
46
+ * skips a type the bundle does not cover (graceful partial coverage), and on a failing resource
47
+ * throws an aggregated {@link AjvValidationError} listing the failing pointers.
48
+ *
49
+ * @param ajv A configured ajv instance ({@link AjvLike}) — typically `new Ajv2020({ allErrors: true })`.
50
+ * @param schemas The codegen-emitted `schemas` map (type -> resource-object JSON Schema 2020-12 doc).
51
+ */
52
+ declare function createAjvValidator(ajv: AjvLike, schemas: SchemaMap): Validator;
53
+ //#endregion
54
+ export { AjvLike, AjvValidationError, AjvValidationFailure, createAjvValidator };
55
+ //# sourceMappingURL=ajv.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ajv.d.mts","names":[],"sources":["../src/ajv.ts"],"mappings":";;;;;;;;;;UA2CiB,OAAA;EACf,OAAA,CAAQ,MAAA,YAAkB,gBAAgB;AAAA;;UAI3B,oBAAA;EAsEuB;EApEtC,IAAA;EAoEoE;EAlEpE,EAAA;EAkE6E;EAhE7E,OAAA;EAgEiC;EA9DjC,OAAA;EA8D+C;EA5D/C,OAAA;AAAA;AA4D6E;;;;;AAAA,cApDlE,kBAAA,SAA2B,KAAA;;WAE7B,QAAA,WAAmB,oBAAA;;WAEnB,IAAA;;WAEA,UAAA;cAEG,IAAA,UAAc,EAAA,sBAAwB,QAAA,WAAmB,oBAAA;AAAA;;;;;;;;;;;;iBA4CvD,kBAAA,CAAmB,GAAA,EAAK,OAAA,EAAS,OAAA,EAAS,SAAA,GAAY,SAAA"}
package/dist/ajv.mjs ADDED
@@ -0,0 +1,62 @@
1
+ //#region src/ajv.ts
2
+ /**
3
+ * Thrown by the ajv adapter when a wire resource fails its per-type schema. Aggregates every
4
+ * ajv error into {@link AjvValidationFailure}s (one per failing field) and renders a message
5
+ * listing the failing pointers, so a caller can both read the summary and walk `failures`.
6
+ */
7
+ var AjvValidationError = class extends Error {
8
+ /** Every failing field across the resource (ajv runs with `allErrors`-aware reporting if enabled). */
9
+ failures;
10
+ /** The JSON:API type of the resource that failed. */
11
+ type;
12
+ /** The id of the resource that failed, when present. */
13
+ resourceId;
14
+ constructor(type, id, failures) {
15
+ const at = id === void 0 ? type : `${type}:${id}`;
16
+ const pointers = failures.map((f) => `${f.pointer || "/"} ${f.message}`).join("; ");
17
+ super(`Resource ${at} failed schema validation: ${pointers}`);
18
+ this.name = "AjvValidationError";
19
+ this.type = type;
20
+ if (id !== void 0) this.resourceId = id;
21
+ this.failures = failures;
22
+ }
23
+ };
24
+ /** Map ajv's raw `ErrorObject`s for one resource to the adapter's typed failures. */
25
+ function toFailures(type, id, errors) {
26
+ return errors.map((e) => {
27
+ const failure = {
28
+ type,
29
+ pointer: e.instancePath,
30
+ keyword: e.keyword,
31
+ message: e.message ?? "is invalid"
32
+ };
33
+ if (id !== void 0) failure.id = id;
34
+ return failure;
35
+ });
36
+ }
37
+ /**
38
+ * Build the client's {@link Validator} from a user-brought ajv instance and the codegen's per-type
39
+ * `schemas` map. Each schema is compiled ONCE up front (keyed by JSON:API type) with the instance's
40
+ * configured dialect — pass `new Ajv2020()` for the 2020-12 dialect the bundle emits. The returned
41
+ * function is the bare-`Validator` seam form: it looks the compiled validator up by `resource.type`,
42
+ * skips a type the bundle does not cover (graceful partial coverage), and on a failing resource
43
+ * throws an aggregated {@link AjvValidationError} listing the failing pointers.
44
+ *
45
+ * @param ajv A configured ajv instance ({@link AjvLike}) — typically `new Ajv2020({ allErrors: true })`.
46
+ * @param schemas The codegen-emitted `schemas` map (type -> resource-object JSON Schema 2020-12 doc).
47
+ */
48
+ function createAjvValidator(ajv, schemas) {
49
+ const compiled = /* @__PURE__ */ new Map();
50
+ for (const [type, schema] of Object.entries(schemas)) compiled.set(type, ajv.compile(schema));
51
+ const validator = (resource) => {
52
+ const { type, id } = resource;
53
+ const validate = compiled.get(type);
54
+ if (validate === void 0) return;
55
+ if (!validate(resource)) throw new AjvValidationError(type, id, toFailures(type, id, validate.errors ?? []));
56
+ };
57
+ return validator;
58
+ }
59
+ //#endregion
60
+ export { AjvValidationError, createAjvValidator };
61
+
62
+ //# sourceMappingURL=ajv.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ajv.mjs","names":[],"sources":["../src/ajv.ts"],"sourcesContent":["/**\n * @haddowg/json-api-client/ajv — an optional ajv adapter for the validation seam.\n *\n * Turns a user-brought ajv instance + the codegen-emitted `schemas` map into the\n * {@link Validator} the client's `validate?` option accepts, so opting into per-field\n * validation is a few lines:\n *\n * ```ts\n * import Ajv2020 from 'ajv/dist/2020'\n * import { createClient } from '@haddowg/json-api-client'\n * import { createAjvValidator } from '@haddowg/json-api-client/ajv'\n * import { schemas } from './japi.gen' // the codegen's `schemas` constant\n *\n * // The bundle emits the JSON Schema 2020-12 dialect, so use `Ajv2020`. `strict: false`\n * // tolerates the schemas' `x-enum-*` annotations; `allErrors` aggregates every failing field.\n * const ajv = new Ajv2020({ strict: false, allErrors: true })\n * const client = createClient(descriptor, {\n * baseUrl: 'https://music.example',\n * validate: createAjvValidator(ajv, schemas),\n * })\n * ```\n *\n * The schemas also carry `format` annotations (`date-time`, `date`, `uuid`, `time`, `uri`). ajv\n * ignores unknown formats by default; to ENFORCE them attach `ajv-formats`\n * (`import addFormats from 'ajv-formats'; addFormats(ajv)`). Note the wire's `time` is a\n * duration-style value the strict `time` format rejects — pass `{ mode: 'fast' }` or omit `time`\n * if you hit that, since formats are advisory and the client's posture is to validate structure\n * and types, trusting the wire on format minutiae.\n *\n * `ajv` is an OPTIONAL peer dependency of this package — it is imported here as a TYPE\n * only (`import type`), so the engine is brought by the caller and never enters the core\n * client's runtime dependency tree. This module is its own sub-path export\n * (`@haddowg/json-api-client/ajv`); importing the main entry never pulls ajv in.\n */\nimport type { ErrorObject, ValidateFunction } from 'ajv'\nimport type { SchemaMap, Validator, WireResource } from './validate'\n\n/**\n * The minimal ajv surface the adapter uses: `compile(schema)` -> a validate function. Any\n * ajv-core instance satisfies it — typically `new Ajv2020()` (the JSON Schema 2020-12 dialect\n * the bundle emits), but a base `Ajv` with the 2020 meta-schema added works too. Typed\n * structurally so the adapter binds to the capability, not a specific ajv subclass.\n */\nexport interface AjvLike {\n compile(schema: unknown): ValidateFunction\n}\n\n/** One failing field: the resource it came from plus ajv's pointer, keyword, and message. */\nexport interface AjvValidationFailure {\n /** The JSON:API type of the resource that failed (`schemas[type]` was applied). */\n type: string\n /** The resource id, when present (a bare identifier or a full resource object both carry one). */\n id?: string\n /** ajv's `instancePath` into the resource (e.g. `/attributes/title`); empty string for the root. */\n pointer: string\n /** The failing ajv keyword (e.g. `type`, `maxLength`, `enum`). */\n keyword: string\n /** ajv's human-readable message (e.g. `must be string`). */\n message: string\n}\n\n/**\n * Thrown by the ajv adapter when a wire resource fails its per-type schema. Aggregates every\n * ajv error into {@link AjvValidationFailure}s (one per failing field) and renders a message\n * listing the failing pointers, so a caller can both read the summary and walk `failures`.\n */\nexport class AjvValidationError extends Error {\n /** Every failing field across the resource (ajv runs with `allErrors`-aware reporting if enabled). */\n readonly failures: readonly AjvValidationFailure[]\n /** The JSON:API type of the resource that failed. */\n readonly type: string\n /** The id of the resource that failed, when present. */\n readonly resourceId?: string\n\n constructor(type: string, id: string | undefined, failures: readonly AjvValidationFailure[]) {\n const at = id === undefined ? type : `${type}:${id}`\n const pointers = failures.map((f) => `${f.pointer || '/'} ${f.message}`).join('; ')\n super(`Resource ${at} failed schema validation: ${pointers}`)\n this.name = 'AjvValidationError'\n this.type = type\n if (id !== undefined) {\n this.resourceId = id\n }\n this.failures = failures\n }\n}\n\n/** Map ajv's raw `ErrorObject`s for one resource to the adapter's typed failures. */\nfunction toFailures(\n type: string,\n id: string | undefined,\n errors: ErrorObject[],\n): AjvValidationFailure[] {\n return errors.map((e) => {\n const failure: AjvValidationFailure = {\n type,\n pointer: e.instancePath,\n keyword: e.keyword,\n message: e.message ?? 'is invalid',\n }\n if (id !== undefined) {\n failure.id = id\n }\n return failure\n })\n}\n\n/**\n * Build the client's {@link Validator} from a user-brought ajv instance and the codegen's per-type\n * `schemas` map. Each schema is compiled ONCE up front (keyed by JSON:API type) with the instance's\n * configured dialect — pass `new Ajv2020()` for the 2020-12 dialect the bundle emits. The returned\n * function is the bare-`Validator` seam form: it looks the compiled validator up by `resource.type`,\n * skips a type the bundle does not cover (graceful partial coverage), and on a failing resource\n * throws an aggregated {@link AjvValidationError} listing the failing pointers.\n *\n * @param ajv A configured ajv instance ({@link AjvLike}) — typically `new Ajv2020({ allErrors: true })`.\n * @param schemas The codegen-emitted `schemas` map (type -> resource-object JSON Schema 2020-12 doc).\n */\nexport function createAjvValidator(ajv: AjvLike, schemas: SchemaMap): Validator {\n // Compile every per-type schema once, keyed by type. ajv key-collision (a duplicate `$id`) is the\n // engine's concern; we compile each schema independently so one bad type does not block the rest.\n const compiled = new Map<string, ValidateFunction>()\n for (const [type, schema] of Object.entries(schemas)) {\n compiled.set(type, ajv.compile(schema))\n }\n\n // The bare-`Validator` seam form: `(resource, schema) => void`. The adapter owns its own compiled\n // lookup (by `resource.type`), so the seam's `schema` argument is unused (the runtime passes\n // `undefined` for a bare validator); it is accepted only to satisfy the {@link Validator} signature.\n const validator: Validator = (resource: WireResource): void => {\n // Read `type`/`id` up front: ajv's `ValidateFunction` is a type guard, so calling it narrows\n // `resource` in the failing branch and the props would no longer be reachable off it.\n const { type, id } = resource\n const validate = compiled.get(type)\n // A type the bundle does not cover is skipped, not failed — matches the seam's graceful posture.\n if (validate === undefined) {\n return\n }\n if (!validate(resource)) {\n const errors = validate.errors ?? []\n throw new AjvValidationError(type, id, toFailures(type, id, errors))\n }\n }\n return validator\n}\n"],"mappings":";;;;;;AAkEA,IAAa,qBAAb,cAAwC,MAAM;;CAE5C;;CAEA;;CAEA;CAEA,YAAY,MAAc,IAAwB,UAA2C;EAC3F,MAAM,KAAK,OAAO,KAAA,IAAY,OAAO,GAAG,KAAK,GAAG;EAChD,MAAM,WAAW,SAAS,KAAK,MAAM,GAAG,EAAE,WAAW,IAAI,GAAG,EAAE,SAAS,CAAC,CAAC,KAAK,IAAI;EAClF,MAAM,YAAY,GAAG,6BAA6B,UAAU;EAC5D,KAAK,OAAO;EACZ,KAAK,OAAO;EACZ,IAAI,OAAO,KAAA,GACT,KAAK,aAAa;EAEpB,KAAK,WAAW;CAClB;AACF;;AAGA,SAAS,WACP,MACA,IACA,QACwB;CACxB,OAAO,OAAO,KAAK,MAAM;EACvB,MAAM,UAAgC;GACpC;GACA,SAAS,EAAE;GACX,SAAS,EAAE;GACX,SAAS,EAAE,WAAW;EACxB;EACA,IAAI,OAAO,KAAA,GACT,QAAQ,KAAK;EAEf,OAAO;CACT,CAAC;AACH;;;;;;;;;;;;AAaA,SAAgB,mBAAmB,KAAc,SAA+B;CAG9E,MAAM,2BAAW,IAAI,IAA8B;CACnD,KAAK,MAAM,CAAC,MAAM,WAAW,OAAO,QAAQ,OAAO,GACjD,SAAS,IAAI,MAAM,IAAI,QAAQ,MAAM,CAAC;CAMxC,MAAM,aAAwB,aAAiC;EAG7D,MAAM,EAAE,MAAM,OAAO;EACrB,MAAM,WAAW,SAAS,IAAI,IAAI;EAElC,IAAI,aAAa,KAAA,GACf;EAEF,IAAI,CAAC,SAAS,QAAQ,GAEpB,MAAM,IAAI,mBAAmB,MAAM,IAAI,WAAW,MAAM,IADzC,SAAS,UAAU,CAAC,CAC+B,CAAC;CAEvE;CACA,OAAO;AACT"}