@haddowg/json-api-angular 0.0.0-stage → 1.1.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 CHANGED
@@ -1,3 +1,107 @@
1
- # Temporary Holding Version
1
+ # @haddowg/json-api-angular
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ Signal-based Angular bindings for a generated [`@haddowg/json-api-client`](../client). The client
11
+ goes into dependency injection and sends through Angular's `HttpClient`, so your interceptors run
12
+ on every request. Reads become signals through Angular's `resource()`, and writes get a small
13
+ signal-based mutation handle.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install @haddowg/json-api-angular @haddowg/json-api-client
19
+ ```
20
+
21
+ `@angular/core` and `@angular/common` (20, 21 or 22) are peer dependencies.
22
+
23
+ ## Provide the client
24
+
25
+ ```ts
26
+ import { provideHttpClient, withInterceptors } from '@angular/common/http'
27
+ import { provideJsonApiClient } from '@haddowg/json-api-angular'
28
+ import { createClient } from './api/music.gen'
29
+
30
+ export const appConfig: ApplicationConfig = {
31
+ providers: [
32
+ provideHttpClient(withInterceptors([authInterceptor])),
33
+ provideJsonApiClient(createClient, { baseUrl: '/api/v5' }),
34
+ ],
35
+ }
36
+ ```
37
+
38
+ Register the client's type once so it is typed everywhere without a token:
39
+
40
+ ```ts
41
+ import type { JsonApiClientOf } from '@haddowg/json-api-angular'
42
+
43
+ declare module '@haddowg/json-api-angular' {
44
+ interface Register {
45
+ client: JsonApiClientOf<typeof createClient>
46
+ }
47
+ }
48
+ ```
49
+
50
+ For a second client (another server), create a token with `createJsonApiClientToken()` and pass it
51
+ as the third argument to `provideJsonApiClient`, then to `injectJsonApiClient(token)` or a
52
+ resource's `client` option.
53
+
54
+ ## Read with a resource
55
+
56
+ ```ts
57
+ @Component({
58
+ template: `
59
+ @if (albums.hasValue()) {
60
+ @for (album of albums.value(); track album.id) {
61
+ <li>{{ album.title }} by {{ album.artist?.name }}</li>
62
+ }
63
+ }
64
+ @if (albums.jsonApiError(); as error) {
65
+ <p>{{ error.status }}</p>
66
+ }
67
+ `,
68
+ })
69
+ export class AlbumList {
70
+ readonly search = signal('')
71
+
72
+ readonly albums = jsonApiResource({
73
+ params: () => ({ filter: { title: this.search() } }),
74
+ loader: ({ client, params, abortSignal }) =>
75
+ client.albums.list({ ...params, include: ['artist'], signal: abortSignal }),
76
+ })
77
+ }
78
+ ```
79
+
80
+ Changing `search` starts a new read and cancels the one in flight. Return `undefined` from
81
+ `params` to hold the read until its inputs are ready. As with any Angular resource, `value()`
82
+ throws in the error state, so guard it with `hasValue()`.
83
+
84
+ `nextPage(list)` and `prevPage(list)` return the `page` params from a collection's links, ready to
85
+ set on the signal your params read. `loadAll(firstPage, { signal })` follows `next` links and
86
+ returns every member in one array.
87
+
88
+ ## Write with a mutation
89
+
90
+ ```ts
91
+ readonly rename = jsonApiMutation((client, title: string) =>
92
+ client.albums.id(this.id()).update({ title }),
93
+ )
94
+
95
+ save(title: string) {
96
+ this.rename.mutate(title).then(() => this.albums.reload(), () => {})
97
+ }
98
+ ```
99
+
100
+ `isPending()`, `error()`, `jsonApiError()`, `value()` and `reset()` track the latest call. Nothing
101
+ is cached between resources, so reload the reads a write affects.
102
+
103
+ See the [Angular guide](https://haddowg.github.io/json-api-ts/angular/) for the details.
104
+
105
+ ## License
106
+
107
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,262 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ let _angular_core = require("@angular/core");
3
+ let _haddowg_json_api_client = require("@haddowg/json-api-client");
4
+ let _angular_common_http = require("@angular/common/http");
5
+ //#region src/transport.ts
6
+ /**
7
+ * A transport that sends every request through Angular's `HttpClient`, so the app's interceptors
8
+ * run. The body comes back as text for the client to parse, exactly as with `fetchTransport`.
9
+ *
10
+ * A non-2xx response resolves with its status, headers and body, because the client maps those to
11
+ * a `JsonApiError` itself. Only a request that never got a response (status 0: network failure,
12
+ * CORS, a blocked request) rejects, with the `HttpErrorResponse`. Aborting `req.signal`
13
+ * unsubscribes, which cancels the HTTP request, and rejects with the signal's reason.
14
+ */
15
+ function httpClientTransport(http) {
16
+ return (req) => new Promise((resolve, reject) => {
17
+ const signal = req.signal;
18
+ if (signal?.aborted) {
19
+ reject(signal.reason);
20
+ return;
21
+ }
22
+ let unsubscribe;
23
+ const onAbort = () => {
24
+ unsubscribe?.();
25
+ reject(signal?.reason);
26
+ };
27
+ signal?.addEventListener("abort", onAbort, { once: true });
28
+ const settle = () => signal?.removeEventListener("abort", onAbort);
29
+ const subscription = http.request(req.method, req.url, {
30
+ body: req.body ?? null,
31
+ headers: req.headers,
32
+ observe: "response",
33
+ responseType: "text"
34
+ }).subscribe({
35
+ next: (res) => {
36
+ settle();
37
+ resolve({
38
+ status: res.status,
39
+ headers: toRecord(res.headers),
40
+ body: res.body ?? ""
41
+ });
42
+ },
43
+ error: (error) => {
44
+ settle();
45
+ if (error instanceof _angular_common_http.HttpErrorResponse && error.status !== 0) {
46
+ resolve({
47
+ status: error.status,
48
+ headers: toRecord(error.headers),
49
+ body: errorBody(error.error)
50
+ });
51
+ return;
52
+ }
53
+ reject(error);
54
+ }
55
+ });
56
+ unsubscribe = () => subscription.unsubscribe();
57
+ });
58
+ }
59
+ function toRecord(headers) {
60
+ const out = {};
61
+ for (const name of headers.keys()) {
62
+ const values = headers.getAll(name);
63
+ if (values !== null) out[name.toLowerCase()] = values.join(", ");
64
+ }
65
+ return out;
66
+ }
67
+ function errorBody(body) {
68
+ if (typeof body === "string") return body;
69
+ if (body === null || body === void 0) return "";
70
+ return JSON.stringify(body);
71
+ }
72
+ //#endregion
73
+ //#region src/provide.ts
74
+ /** The token {@link provideJsonApiClient} uses when none is given. */
75
+ const JSON_API_CLIENT = new _angular_core.InjectionToken("JSON_API_CLIENT");
76
+ /**
77
+ * Create an injection token for one client, so an app can provide several (one per server).
78
+ * Type it with the client: `createJsonApiClientToken<JsonApiClientOf<typeof createAdminClient>>()`.
79
+ */
80
+ function createJsonApiClientToken(description = "JsonApiClient") {
81
+ return new _angular_core.InjectionToken(description);
82
+ }
83
+ /**
84
+ * Provide a generated client to the environment injector. The client sends through `HttpClient`
85
+ * (so `provideHttpClient()` must also be present) unless `options.transport` overrides it.
86
+ *
87
+ * `options` may be a function, which runs in an injection context, to read values such as the
88
+ * base URL from other providers. `token` defaults to {@link JSON_API_CLIENT}; pass one from
89
+ * {@link createJsonApiClientToken} to provide a second client alongside it.
90
+ */
91
+ function provideJsonApiClient(createClient, options, token = JSON_API_CLIENT) {
92
+ return (0, _angular_core.makeEnvironmentProviders)([{
93
+ provide: token,
94
+ useFactory: () => {
95
+ const resolved = typeof options === "function" ? options() : options;
96
+ return createClient({
97
+ ...resolved,
98
+ transport: resolved.transport ?? httpClientTransport((0, _angular_core.inject)(_angular_common_http.HttpClient))
99
+ });
100
+ }
101
+ }]);
102
+ }
103
+ /**
104
+ * Inject a provided client. Without a token this returns the default client, typed by
105
+ * {@link Register}. With a token it returns that token's client.
106
+ */
107
+ function injectJsonApiClient(token, options) {
108
+ const resolved = token ?? JSON_API_CLIENT;
109
+ if (options?.injector !== void 0) return options.injector.get(resolved);
110
+ (0, _angular_core.assertInInjectionContext)(injectJsonApiClient);
111
+ return (0, _angular_core.inject)(resolved);
112
+ }
113
+ //#endregion
114
+ //#region src/mutation.ts
115
+ /**
116
+ * Wrap a client write (create, update, delete, a relationship mutation, a custom action or an
117
+ * atomic batch) in signals. Nothing is cached or invalidated: `reload()` the resources the write
118
+ * affects.
119
+ *
120
+ * ```ts
121
+ * readonly rename = jsonApiMutation((client, title: string) =>
122
+ * client.albums.id(this.id()).update({ title }),
123
+ * )
124
+ * ```
125
+ */
126
+ function jsonApiMutation(mutation, options = {}) {
127
+ if (options.injector === void 0) (0, _angular_core.assertInInjectionContext)(jsonApiMutation);
128
+ const client = (options.injector ?? (0, _angular_core.inject)(_angular_core.Injector)).get(options.client ?? JSON_API_CLIENT);
129
+ const value = (0, _angular_core.signal)(void 0);
130
+ const error = (0, _angular_core.signal)(void 0);
131
+ const pending = (0, _angular_core.signal)(0);
132
+ let generation = 0;
133
+ const mutate = async (input) => {
134
+ const current = ++generation;
135
+ pending.update((n) => n + 1);
136
+ error.set(void 0);
137
+ try {
138
+ const result = await mutation(client, input);
139
+ if (current === generation) value.set(result);
140
+ return result;
141
+ } catch (thrown) {
142
+ if (current === generation) error.set(thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown }));
143
+ throw thrown;
144
+ } finally {
145
+ pending.update((n) => Math.max(0, n - 1));
146
+ }
147
+ };
148
+ return {
149
+ mutate,
150
+ isPending: (0, _angular_core.computed)(() => pending() > 0),
151
+ error: error.asReadonly(),
152
+ jsonApiError: (0, _angular_core.computed)(() => {
153
+ const e = error();
154
+ return e instanceof _haddowg_json_api_client.JsonApiError ? e : void 0;
155
+ }),
156
+ value: value.asReadonly(),
157
+ reset: () => {
158
+ generation++;
159
+ value.set(void 0);
160
+ error.set(void 0);
161
+ pending.set(0);
162
+ }
163
+ };
164
+ }
165
+ //#endregion
166
+ //#region src/pagination.ts
167
+ /**
168
+ * The `page` params for the page after `list`, read from its `next` link, or `undefined` on the
169
+ * last page (or before anything has loaded). Works the same for every strategy, because the
170
+ * server's link already carries the right parameters. Feed the result back into the signal your
171
+ * resource's params read:
172
+ *
173
+ * ```ts
174
+ * const next = nextPage(this.albums.value())
175
+ * if (next) this.page.set(next)
176
+ * ```
177
+ */
178
+ function nextPage(list) {
179
+ return pageParams(list?.$page, list?.$page.links.next);
180
+ }
181
+ /** The `page` params for the page before `list`, from its `prev` link. See {@link nextPage}. */
182
+ function prevPage(list) {
183
+ return pageParams(list?.$page, list?.$page.links.prev);
184
+ }
185
+ const CURSOR_ROLES = ["after", "before"];
186
+ function pageParams(page, link) {
187
+ if (page === void 0 || link === void 0) return;
188
+ const keys = Object.entries(page.keys);
189
+ const query = link.includes("?") ? link.slice(link.indexOf("?") + 1).split("#")[0] : "";
190
+ const params = {};
191
+ for (const [name, value] of new URLSearchParams(query)) {
192
+ const member = /^page\[([^\]]+)\]$/.exec(name)?.[1];
193
+ if (member === void 0) continue;
194
+ const role = keys.find(([, key]) => key === member)?.[0];
195
+ const count = Number(value);
196
+ params[member] = role !== void 0 && !CURSOR_ROLES.includes(role) && value !== "" && Number.isFinite(count) ? count : value;
197
+ }
198
+ return params;
199
+ }
200
+ /**
201
+ * Follow `next` links from the first page until the last, and return every member in one plain
202
+ * array. Use it inside a loader to read a whole (small) collection:
203
+ *
204
+ * ```ts
205
+ * loader: ({ client, abortSignal }) =>
206
+ * loadAll(client.genres.list({ signal: abortSignal }), { signal: abortSignal })
207
+ * ```
208
+ */
209
+ async function loadAll(first, options = {}) {
210
+ const { signal, maxPages = Number.POSITIVE_INFINITY } = options;
211
+ let page = await first;
212
+ const all = [];
213
+ let count = 0;
214
+ while (page !== void 0 && count < maxPages) {
215
+ all.push(...page);
216
+ count++;
217
+ if (count >= maxPages) break;
218
+ signal?.throwIfAborted();
219
+ page = await page.$next(signal !== void 0 ? { signal } : void 0);
220
+ }
221
+ return all;
222
+ }
223
+ //#endregion
224
+ //#region src/resource.ts
225
+ function jsonApiResource(options) {
226
+ if (options.injector === void 0) (0, _angular_core.assertInInjectionContext)(jsonApiResource);
227
+ const injector = options.injector ?? (0, _angular_core.inject)(_angular_core.Injector);
228
+ const client = injector.get(options.client ?? JSON_API_CLIENT);
229
+ const ref = (0, _angular_core.resource)({
230
+ ...options.params !== void 0 ? { params: options.params } : {},
231
+ loader: ({ params, abortSignal, previous }) => options.loader({
232
+ client,
233
+ params,
234
+ abortSignal,
235
+ previous
236
+ }),
237
+ ...options.defaultValue !== void 0 ? { defaultValue: options.defaultValue } : {},
238
+ ...options.equal !== void 0 ? { equal: options.equal } : {},
239
+ ...options.debugName !== void 0 ? { debugName: options.debugName } : {},
240
+ injector
241
+ });
242
+ const jsonApiError = (0, _angular_core.computed)(() => {
243
+ const error = ref.error();
244
+ return error instanceof _haddowg_json_api_client.JsonApiError ? error : void 0;
245
+ });
246
+ Object.defineProperty(ref, "jsonApiError", {
247
+ value: jsonApiError,
248
+ enumerable: true
249
+ });
250
+ return ref;
251
+ }
252
+ //#endregion
253
+ exports.JSON_API_CLIENT = JSON_API_CLIENT;
254
+ exports.createJsonApiClientToken = createJsonApiClientToken;
255
+ exports.httpClientTransport = httpClientTransport;
256
+ exports.injectJsonApiClient = injectJsonApiClient;
257
+ exports.jsonApiMutation = jsonApiMutation;
258
+ exports.jsonApiResource = jsonApiResource;
259
+ exports.loadAll = loadAll;
260
+ exports.nextPage = nextPage;
261
+ exports.prevPage = prevPage;
262
+ exports.provideJsonApiClient = provideJsonApiClient;
@@ -0,0 +1,219 @@
1
+ import { EnvironmentProviders, InjectionToken, Injector, ResourceRef, ResourceStatus, Signal, ValueEqualityFn } from "@angular/core";
2
+ import { ClientOptions, Collection, ErrorCodeOf, JsonApiError, JsonApiTransport, Page, PageParamsOf, StructuralGuardError } from "@haddowg/json-api-client";
3
+ import { HttpClient } from "@angular/common/http";
4
+
5
+ //#region src/provide.d.ts
6
+ /**
7
+ * Options for {@link provideJsonApiClient}: the client's options with the transport made
8
+ * optional. Leave `transport` out to send through `HttpClient`; set it only to opt out.
9
+ */
10
+ type JsonApiClientOptions = Omit<ClientOptions, 'transport'> & {
11
+ transport?: JsonApiTransport;
12
+ };
13
+ /** A generated client factory: the bound `createClient` a codegen module exports. */
14
+ type JsonApiClientFactory<C> = (options: ClientOptions) => C;
15
+ /** The client type a generated `createClient` returns. */
16
+ type JsonApiClientOf<F> = F extends ((options: ClientOptions) => infer C) ? C : never;
17
+ /**
18
+ * Register the app's default client type once, so `injectJsonApiClient()` and `jsonApiResource()`
19
+ * are typed without a token:
20
+ *
21
+ * ```ts
22
+ * declare module '@haddowg/json-api-angular' {
23
+ * interface Register {
24
+ * client: JsonApiClientOf<typeof createClient>
25
+ * }
26
+ * }
27
+ * ```
28
+ */
29
+ interface Register {}
30
+ /** The default client's type: whatever {@link Register} declares, else `unknown`. */
31
+ type RegisteredClient = Register extends {
32
+ client: infer C;
33
+ } ? C : unknown;
34
+ /** The token {@link provideJsonApiClient} uses when none is given. */
35
+ declare const JSON_API_CLIENT: InjectionToken<unknown>;
36
+ /**
37
+ * Create an injection token for one client, so an app can provide several (one per server).
38
+ * Type it with the client: `createJsonApiClientToken<JsonApiClientOf<typeof createAdminClient>>()`.
39
+ */
40
+ declare function createJsonApiClientToken<C>(description?: string): InjectionToken<C>;
41
+ /**
42
+ * Provide a generated client to the environment injector. The client sends through `HttpClient`
43
+ * (so `provideHttpClient()` must also be present) unless `options.transport` overrides it.
44
+ *
45
+ * `options` may be a function, which runs in an injection context, to read values such as the
46
+ * base URL from other providers. `token` defaults to {@link JSON_API_CLIENT}; pass one from
47
+ * {@link createJsonApiClientToken} to provide a second client alongside it.
48
+ */
49
+ declare function provideJsonApiClient<C>(createClient: JsonApiClientFactory<C>, options: JsonApiClientOptions | (() => JsonApiClientOptions), token?: InjectionToken<C>): EnvironmentProviders;
50
+ /**
51
+ * Inject a provided client. Without a token this returns the default client, typed by
52
+ * {@link Register}. With a token it returns that token's client.
53
+ */
54
+ declare function injectJsonApiClient<C = RegisteredClient>(token?: InjectionToken<C>, options?: {
55
+ injector?: Injector;
56
+ }): C;
57
+ //#endregion
58
+ //#region src/resource.d.ts
59
+ /**
60
+ * What a read can fail with: a `JsonApiError` for a non-2xx response, a `StructuralGuardError`
61
+ * for a response that isn't a JSON:API document, or any other error (a network failure, a
62
+ * validator's error). Narrow with `instanceof`. `E` types the `JsonApiError`'s codes.
63
+ */
64
+ type JsonApiResourceError<E extends string = string> = JsonApiError<E> | StructuralGuardError | Error;
65
+ /** What the loader receives each time the params change or the resource reloads. */
66
+ interface JsonApiLoaderContext<C, P> {
67
+ client: C;
68
+ params: P;
69
+ /** Aborts when the params change again or the resource is destroyed. Pass it to the read. */
70
+ abortSignal: AbortSignal;
71
+ previous: {
72
+ status: ResourceStatus;
73
+ };
74
+ }
75
+ /** Options for {@link jsonApiResource}. */
76
+ interface JsonApiResourceOptions<T, P, C> {
77
+ /**
78
+ * A reactive function computing the read's params from signals. Return `undefined` while the
79
+ * read isn't ready (no id yet, say) and the resource stays idle. Leave it out to load once.
80
+ */
81
+ params?: () => P | undefined;
82
+ /** Make the read. Return the client call's promise, passing `abortSignal` as its `signal`. */
83
+ loader: (context: JsonApiLoaderContext<C, P>) => PromiseLike<T>;
84
+ /** The client to read with. Defaults to the one `provideJsonApiClient` registered without a token. */
85
+ client?: InjectionToken<C>;
86
+ /** The value while nothing has loaded yet. */
87
+ defaultValue?: T;
88
+ equal?: ValueEqualityFn<T>;
89
+ /** Create the resource outside an injection context. */
90
+ injector?: Injector;
91
+ debugName?: string;
92
+ }
93
+ /**
94
+ * Angular's `ResourceRef` with `error()` typed to what a JSON:API read throws, plus
95
+ * `jsonApiError()`, the error when it is a `JsonApiError` (handy in templates, which can't
96
+ * use `instanceof`). `E` is the client's error-code union, so a generated client's
97
+ * `jsonApiError()` carries its server's `ErrorCode`.
98
+ */
99
+ interface JsonApiResourceRef<T, E extends string = string> extends Omit<ResourceRef<T>, 'error' | 'hasValue'> {
100
+ readonly error: Signal<JsonApiResourceError<E> | undefined>;
101
+ readonly jsonApiError: Signal<JsonApiError<E> | undefined>;
102
+ /** Whether a value is loaded. Narrows `value()` to exclude `undefined`, in templates too. */
103
+ hasValue(this: T extends undefined ? this : never): this is JsonApiResourceRef<Exclude<T, undefined>, E>;
104
+ hasValue(): boolean;
105
+ }
106
+ /**
107
+ * A signal-based JSON:API read: a thin wrapper over Angular's `resource()` that injects the
108
+ * client and hands it to the loader. Changing a signal the params read starts a new load and
109
+ * aborts the one in flight.
110
+ *
111
+ * ```ts
112
+ * readonly albums = jsonApiResource({
113
+ * params: () => ({ filter: { title: this.search() } }),
114
+ * loader: ({ client, params, abortSignal }) =>
115
+ * client.albums.list({ ...params, include: ['artist'], signal: abortSignal }),
116
+ * })
117
+ * ```
118
+ */
119
+ declare function jsonApiResource<T, P = unknown, C = RegisteredClient>(options: JsonApiResourceOptions<T, P, C> & {
120
+ defaultValue: T;
121
+ }): JsonApiResourceRef<T, ErrorCodeOf<C>>;
122
+ declare function jsonApiResource<T, P = unknown, C = RegisteredClient>(options: JsonApiResourceOptions<T, P, C>): JsonApiResourceRef<T | undefined, ErrorCodeOf<C>>;
123
+ //#endregion
124
+ //#region src/mutation.d.ts
125
+ /** Options for {@link jsonApiMutation}. */
126
+ interface JsonApiMutationOptions<C> {
127
+ /** The client to write with. Defaults to the one `provideJsonApiClient` registered without a token. */
128
+ client?: InjectionToken<C>;
129
+ /** Create the mutation outside an injection context. */
130
+ injector?: Injector;
131
+ }
132
+ /**
133
+ * The signal-based handle {@link jsonApiMutation} returns. `E` is the client's error-code union,
134
+ * so a generated client's `jsonApiError()` carries its server's `ErrorCode`.
135
+ */
136
+ interface JsonApiMutationRef<I, T, E extends string = string> {
137
+ /**
138
+ * Run the write. Resolves with its result and rejects with its error; the signals track the
139
+ * latest call either way.
140
+ */
141
+ mutate(input: I): Promise<T>;
142
+ readonly isPending: Signal<boolean>;
143
+ readonly error: Signal<JsonApiResourceError<E> | undefined>;
144
+ /** The error when it is a `JsonApiError`; use `byPath()` on it for form errors. */
145
+ readonly jsonApiError: Signal<JsonApiError<E> | undefined>;
146
+ /** The latest successful result. */
147
+ readonly value: Signal<T | undefined>;
148
+ /** Clear the value and error. A call still in flight no longer updates the signals. */
149
+ reset(): void;
150
+ }
151
+ /**
152
+ * Wrap a client write (create, update, delete, a relationship mutation, a custom action or an
153
+ * atomic batch) in signals. Nothing is cached or invalidated: `reload()` the resources the write
154
+ * affects.
155
+ *
156
+ * ```ts
157
+ * readonly rename = jsonApiMutation((client, title: string) =>
158
+ * client.albums.id(this.id()).update({ title }),
159
+ * )
160
+ * ```
161
+ */
162
+ declare function jsonApiMutation<I, T, C = RegisteredClient>(mutation: (client: C, input: I) => PromiseLike<T>, options?: JsonApiMutationOptions<C>): JsonApiMutationRef<I, T, ErrorCodeOf<C>>;
163
+ //#endregion
164
+ //#region src/pagination.d.ts
165
+ /**
166
+ * The `page` query a link selects, typed by the `$page` it came from: the strategy's members under
167
+ * their wire keys (counts as numbers, cursors as strings), plus the menu arm the read picked.
168
+ */
169
+ type PageParams<P extends Page = Page> = PageParamsOf<P>;
170
+ /**
171
+ * The `page` params for the page after `list`, read from its `next` link, or `undefined` on the
172
+ * last page (or before anything has loaded). Works the same for every strategy, because the
173
+ * server's link already carries the right parameters. Feed the result back into the signal your
174
+ * resource's params read:
175
+ *
176
+ * ```ts
177
+ * const next = nextPage(this.albums.value())
178
+ * if (next) this.page.set(next)
179
+ * ```
180
+ */
181
+ declare function nextPage<P extends Page>(list: {
182
+ readonly $page: P;
183
+ } | undefined): PageParams<P> | undefined;
184
+ /** The `page` params for the page before `list`, from its `prev` link. See {@link nextPage}. */
185
+ declare function prevPage<P extends Page>(list: {
186
+ readonly $page: P;
187
+ } | undefined): PageParams<P> | undefined;
188
+ /** Options for {@link loadAll}. */
189
+ interface LoadAllOptions {
190
+ /** Stops the walk, rejecting with the signal's reason. Pass the loader's `abortSignal`. */
191
+ signal?: AbortSignal;
192
+ /** Stop after this many pages, as a guard against an unexpectedly large collection. */
193
+ maxPages?: number;
194
+ }
195
+ /**
196
+ * Follow `next` links from the first page until the last, and return every member in one plain
197
+ * array. Use it inside a loader to read a whole (small) collection:
198
+ *
199
+ * ```ts
200
+ * loader: ({ client, abortSignal }) =>
201
+ * loadAll(client.genres.list({ signal: abortSignal }), { signal: abortSignal })
202
+ * ```
203
+ */
204
+ declare function loadAll<T>(first: Collection<T> | PromiseLike<Collection<T>>, options?: LoadAllOptions): Promise<T[]>;
205
+ //#endregion
206
+ //#region src/transport.d.ts
207
+ /**
208
+ * A transport that sends every request through Angular's `HttpClient`, so the app's interceptors
209
+ * run. The body comes back as text for the client to parse, exactly as with `fetchTransport`.
210
+ *
211
+ * A non-2xx response resolves with its status, headers and body, because the client maps those to
212
+ * a `JsonApiError` itself. Only a request that never got a response (status 0: network failure,
213
+ * CORS, a blocked request) rejects, with the `HttpErrorResponse`. Aborting `req.signal`
214
+ * unsubscribes, which cancels the HTTP request, and rejects with the signal's reason.
215
+ */
216
+ declare function httpClientTransport(http: HttpClient): JsonApiTransport;
217
+ //#endregion
218
+ export { JSON_API_CLIENT, JsonApiClientFactory, JsonApiClientOf, JsonApiClientOptions, JsonApiLoaderContext, JsonApiMutationOptions, JsonApiMutationRef, JsonApiResourceError, JsonApiResourceOptions, JsonApiResourceRef, LoadAllOptions, PageParams, Register, RegisteredClient, createJsonApiClientToken, httpClientTransport, injectJsonApiClient, jsonApiMutation, jsonApiResource, loadAll, nextPage, prevPage, provideJsonApiClient };
219
+ //# sourceMappingURL=index.d.cts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.cts","names":[],"sources":["../src/provide.ts","../src/resource.ts","../src/mutation.ts","../src/pagination.ts","../src/transport.ts"],"mappings":"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"}
@@ -0,0 +1,219 @@
1
+ import { EnvironmentProviders, InjectionToken, Injector, ResourceRef, ResourceStatus, Signal, ValueEqualityFn } from "@angular/core";
2
+ import { ClientOptions, Collection, ErrorCodeOf, JsonApiError, JsonApiTransport, Page, PageParamsOf, StructuralGuardError } from "@haddowg/json-api-client";
3
+ import { HttpClient } from "@angular/common/http";
4
+
5
+ //#region src/provide.d.ts
6
+ /**
7
+ * Options for {@link provideJsonApiClient}: the client's options with the transport made
8
+ * optional. Leave `transport` out to send through `HttpClient`; set it only to opt out.
9
+ */
10
+ type JsonApiClientOptions = Omit<ClientOptions, 'transport'> & {
11
+ transport?: JsonApiTransport;
12
+ };
13
+ /** A generated client factory: the bound `createClient` a codegen module exports. */
14
+ type JsonApiClientFactory<C> = (options: ClientOptions) => C;
15
+ /** The client type a generated `createClient` returns. */
16
+ type JsonApiClientOf<F> = F extends ((options: ClientOptions) => infer C) ? C : never;
17
+ /**
18
+ * Register the app's default client type once, so `injectJsonApiClient()` and `jsonApiResource()`
19
+ * are typed without a token:
20
+ *
21
+ * ```ts
22
+ * declare module '@haddowg/json-api-angular' {
23
+ * interface Register {
24
+ * client: JsonApiClientOf<typeof createClient>
25
+ * }
26
+ * }
27
+ * ```
28
+ */
29
+ interface Register {}
30
+ /** The default client's type: whatever {@link Register} declares, else `unknown`. */
31
+ type RegisteredClient = Register extends {
32
+ client: infer C;
33
+ } ? C : unknown;
34
+ /** The token {@link provideJsonApiClient} uses when none is given. */
35
+ declare const JSON_API_CLIENT: InjectionToken<unknown>;
36
+ /**
37
+ * Create an injection token for one client, so an app can provide several (one per server).
38
+ * Type it with the client: `createJsonApiClientToken<JsonApiClientOf<typeof createAdminClient>>()`.
39
+ */
40
+ declare function createJsonApiClientToken<C>(description?: string): InjectionToken<C>;
41
+ /**
42
+ * Provide a generated client to the environment injector. The client sends through `HttpClient`
43
+ * (so `provideHttpClient()` must also be present) unless `options.transport` overrides it.
44
+ *
45
+ * `options` may be a function, which runs in an injection context, to read values such as the
46
+ * base URL from other providers. `token` defaults to {@link JSON_API_CLIENT}; pass one from
47
+ * {@link createJsonApiClientToken} to provide a second client alongside it.
48
+ */
49
+ declare function provideJsonApiClient<C>(createClient: JsonApiClientFactory<C>, options: JsonApiClientOptions | (() => JsonApiClientOptions), token?: InjectionToken<C>): EnvironmentProviders;
50
+ /**
51
+ * Inject a provided client. Without a token this returns the default client, typed by
52
+ * {@link Register}. With a token it returns that token's client.
53
+ */
54
+ declare function injectJsonApiClient<C = RegisteredClient>(token?: InjectionToken<C>, options?: {
55
+ injector?: Injector;
56
+ }): C;
57
+ //#endregion
58
+ //#region src/resource.d.ts
59
+ /**
60
+ * What a read can fail with: a `JsonApiError` for a non-2xx response, a `StructuralGuardError`
61
+ * for a response that isn't a JSON:API document, or any other error (a network failure, a
62
+ * validator's error). Narrow with `instanceof`. `E` types the `JsonApiError`'s codes.
63
+ */
64
+ type JsonApiResourceError<E extends string = string> = JsonApiError<E> | StructuralGuardError | Error;
65
+ /** What the loader receives each time the params change or the resource reloads. */
66
+ interface JsonApiLoaderContext<C, P> {
67
+ client: C;
68
+ params: P;
69
+ /** Aborts when the params change again or the resource is destroyed. Pass it to the read. */
70
+ abortSignal: AbortSignal;
71
+ previous: {
72
+ status: ResourceStatus;
73
+ };
74
+ }
75
+ /** Options for {@link jsonApiResource}. */
76
+ interface JsonApiResourceOptions<T, P, C> {
77
+ /**
78
+ * A reactive function computing the read's params from signals. Return `undefined` while the
79
+ * read isn't ready (no id yet, say) and the resource stays idle. Leave it out to load once.
80
+ */
81
+ params?: () => P | undefined;
82
+ /** Make the read. Return the client call's promise, passing `abortSignal` as its `signal`. */
83
+ loader: (context: JsonApiLoaderContext<C, P>) => PromiseLike<T>;
84
+ /** The client to read with. Defaults to the one `provideJsonApiClient` registered without a token. */
85
+ client?: InjectionToken<C>;
86
+ /** The value while nothing has loaded yet. */
87
+ defaultValue?: T;
88
+ equal?: ValueEqualityFn<T>;
89
+ /** Create the resource outside an injection context. */
90
+ injector?: Injector;
91
+ debugName?: string;
92
+ }
93
+ /**
94
+ * Angular's `ResourceRef` with `error()` typed to what a JSON:API read throws, plus
95
+ * `jsonApiError()`, the error when it is a `JsonApiError` (handy in templates, which can't
96
+ * use `instanceof`). `E` is the client's error-code union, so a generated client's
97
+ * `jsonApiError()` carries its server's `ErrorCode`.
98
+ */
99
+ interface JsonApiResourceRef<T, E extends string = string> extends Omit<ResourceRef<T>, 'error' | 'hasValue'> {
100
+ readonly error: Signal<JsonApiResourceError<E> | undefined>;
101
+ readonly jsonApiError: Signal<JsonApiError<E> | undefined>;
102
+ /** Whether a value is loaded. Narrows `value()` to exclude `undefined`, in templates too. */
103
+ hasValue(this: T extends undefined ? this : never): this is JsonApiResourceRef<Exclude<T, undefined>, E>;
104
+ hasValue(): boolean;
105
+ }
106
+ /**
107
+ * A signal-based JSON:API read: a thin wrapper over Angular's `resource()` that injects the
108
+ * client and hands it to the loader. Changing a signal the params read starts a new load and
109
+ * aborts the one in flight.
110
+ *
111
+ * ```ts
112
+ * readonly albums = jsonApiResource({
113
+ * params: () => ({ filter: { title: this.search() } }),
114
+ * loader: ({ client, params, abortSignal }) =>
115
+ * client.albums.list({ ...params, include: ['artist'], signal: abortSignal }),
116
+ * })
117
+ * ```
118
+ */
119
+ declare function jsonApiResource<T, P = unknown, C = RegisteredClient>(options: JsonApiResourceOptions<T, P, C> & {
120
+ defaultValue: T;
121
+ }): JsonApiResourceRef<T, ErrorCodeOf<C>>;
122
+ declare function jsonApiResource<T, P = unknown, C = RegisteredClient>(options: JsonApiResourceOptions<T, P, C>): JsonApiResourceRef<T | undefined, ErrorCodeOf<C>>;
123
+ //#endregion
124
+ //#region src/mutation.d.ts
125
+ /** Options for {@link jsonApiMutation}. */
126
+ interface JsonApiMutationOptions<C> {
127
+ /** The client to write with. Defaults to the one `provideJsonApiClient` registered without a token. */
128
+ client?: InjectionToken<C>;
129
+ /** Create the mutation outside an injection context. */
130
+ injector?: Injector;
131
+ }
132
+ /**
133
+ * The signal-based handle {@link jsonApiMutation} returns. `E` is the client's error-code union,
134
+ * so a generated client's `jsonApiError()` carries its server's `ErrorCode`.
135
+ */
136
+ interface JsonApiMutationRef<I, T, E extends string = string> {
137
+ /**
138
+ * Run the write. Resolves with its result and rejects with its error; the signals track the
139
+ * latest call either way.
140
+ */
141
+ mutate(input: I): Promise<T>;
142
+ readonly isPending: Signal<boolean>;
143
+ readonly error: Signal<JsonApiResourceError<E> | undefined>;
144
+ /** The error when it is a `JsonApiError`; use `byPath()` on it for form errors. */
145
+ readonly jsonApiError: Signal<JsonApiError<E> | undefined>;
146
+ /** The latest successful result. */
147
+ readonly value: Signal<T | undefined>;
148
+ /** Clear the value and error. A call still in flight no longer updates the signals. */
149
+ reset(): void;
150
+ }
151
+ /**
152
+ * Wrap a client write (create, update, delete, a relationship mutation, a custom action or an
153
+ * atomic batch) in signals. Nothing is cached or invalidated: `reload()` the resources the write
154
+ * affects.
155
+ *
156
+ * ```ts
157
+ * readonly rename = jsonApiMutation((client, title: string) =>
158
+ * client.albums.id(this.id()).update({ title }),
159
+ * )
160
+ * ```
161
+ */
162
+ declare function jsonApiMutation<I, T, C = RegisteredClient>(mutation: (client: C, input: I) => PromiseLike<T>, options?: JsonApiMutationOptions<C>): JsonApiMutationRef<I, T, ErrorCodeOf<C>>;
163
+ //#endregion
164
+ //#region src/pagination.d.ts
165
+ /**
166
+ * The `page` query a link selects, typed by the `$page` it came from: the strategy's members under
167
+ * their wire keys (counts as numbers, cursors as strings), plus the menu arm the read picked.
168
+ */
169
+ type PageParams<P extends Page = Page> = PageParamsOf<P>;
170
+ /**
171
+ * The `page` params for the page after `list`, read from its `next` link, or `undefined` on the
172
+ * last page (or before anything has loaded). Works the same for every strategy, because the
173
+ * server's link already carries the right parameters. Feed the result back into the signal your
174
+ * resource's params read:
175
+ *
176
+ * ```ts
177
+ * const next = nextPage(this.albums.value())
178
+ * if (next) this.page.set(next)
179
+ * ```
180
+ */
181
+ declare function nextPage<P extends Page>(list: {
182
+ readonly $page: P;
183
+ } | undefined): PageParams<P> | undefined;
184
+ /** The `page` params for the page before `list`, from its `prev` link. See {@link nextPage}. */
185
+ declare function prevPage<P extends Page>(list: {
186
+ readonly $page: P;
187
+ } | undefined): PageParams<P> | undefined;
188
+ /** Options for {@link loadAll}. */
189
+ interface LoadAllOptions {
190
+ /** Stops the walk, rejecting with the signal's reason. Pass the loader's `abortSignal`. */
191
+ signal?: AbortSignal;
192
+ /** Stop after this many pages, as a guard against an unexpectedly large collection. */
193
+ maxPages?: number;
194
+ }
195
+ /**
196
+ * Follow `next` links from the first page until the last, and return every member in one plain
197
+ * array. Use it inside a loader to read a whole (small) collection:
198
+ *
199
+ * ```ts
200
+ * loader: ({ client, abortSignal }) =>
201
+ * loadAll(client.genres.list({ signal: abortSignal }), { signal: abortSignal })
202
+ * ```
203
+ */
204
+ declare function loadAll<T>(first: Collection<T> | PromiseLike<Collection<T>>, options?: LoadAllOptions): Promise<T[]>;
205
+ //#endregion
206
+ //#region src/transport.d.ts
207
+ /**
208
+ * A transport that sends every request through Angular's `HttpClient`, so the app's interceptors
209
+ * run. The body comes back as text for the client to parse, exactly as with `fetchTransport`.
210
+ *
211
+ * A non-2xx response resolves with its status, headers and body, because the client maps those to
212
+ * a `JsonApiError` itself. Only a request that never got a response (status 0: network failure,
213
+ * CORS, a blocked request) rejects, with the `HttpErrorResponse`. Aborting `req.signal`
214
+ * unsubscribes, which cancels the HTTP request, and rejects with the signal's reason.
215
+ */
216
+ declare function httpClientTransport(http: HttpClient): JsonApiTransport;
217
+ //#endregion
218
+ export { JSON_API_CLIENT, JsonApiClientFactory, JsonApiClientOf, JsonApiClientOptions, JsonApiLoaderContext, JsonApiMutationOptions, JsonApiMutationRef, JsonApiResourceError, JsonApiResourceOptions, JsonApiResourceRef, LoadAllOptions, PageParams, Register, RegisteredClient, createJsonApiClientToken, httpClientTransport, injectJsonApiClient, jsonApiMutation, jsonApiResource, loadAll, nextPage, prevPage, provideJsonApiClient };
219
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/provide.ts","../src/resource.ts","../src/mutation.ts","../src/pagination.ts","../src/transport.ts"],"mappings":"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"}
package/dist/index.mjs ADDED
@@ -0,0 +1,254 @@
1
+ import { InjectionToken, Injector, assertInInjectionContext, computed, inject, makeEnvironmentProviders, resource, signal } from "@angular/core";
2
+ import { JsonApiError } from "@haddowg/json-api-client";
3
+ import { HttpClient, HttpErrorResponse } from "@angular/common/http";
4
+ //#region src/transport.ts
5
+ /**
6
+ * A transport that sends every request through Angular's `HttpClient`, so the app's interceptors
7
+ * run. The body comes back as text for the client to parse, exactly as with `fetchTransport`.
8
+ *
9
+ * A non-2xx response resolves with its status, headers and body, because the client maps those to
10
+ * a `JsonApiError` itself. Only a request that never got a response (status 0: network failure,
11
+ * CORS, a blocked request) rejects, with the `HttpErrorResponse`. Aborting `req.signal`
12
+ * unsubscribes, which cancels the HTTP request, and rejects with the signal's reason.
13
+ */
14
+ function httpClientTransport(http) {
15
+ return (req) => new Promise((resolve, reject) => {
16
+ const signal = req.signal;
17
+ if (signal?.aborted) {
18
+ reject(signal.reason);
19
+ return;
20
+ }
21
+ let unsubscribe;
22
+ const onAbort = () => {
23
+ unsubscribe?.();
24
+ reject(signal?.reason);
25
+ };
26
+ signal?.addEventListener("abort", onAbort, { once: true });
27
+ const settle = () => signal?.removeEventListener("abort", onAbort);
28
+ const subscription = http.request(req.method, req.url, {
29
+ body: req.body ?? null,
30
+ headers: req.headers,
31
+ observe: "response",
32
+ responseType: "text"
33
+ }).subscribe({
34
+ next: (res) => {
35
+ settle();
36
+ resolve({
37
+ status: res.status,
38
+ headers: toRecord(res.headers),
39
+ body: res.body ?? ""
40
+ });
41
+ },
42
+ error: (error) => {
43
+ settle();
44
+ if (error instanceof HttpErrorResponse && error.status !== 0) {
45
+ resolve({
46
+ status: error.status,
47
+ headers: toRecord(error.headers),
48
+ body: errorBody(error.error)
49
+ });
50
+ return;
51
+ }
52
+ reject(error);
53
+ }
54
+ });
55
+ unsubscribe = () => subscription.unsubscribe();
56
+ });
57
+ }
58
+ function toRecord(headers) {
59
+ const out = {};
60
+ for (const name of headers.keys()) {
61
+ const values = headers.getAll(name);
62
+ if (values !== null) out[name.toLowerCase()] = values.join(", ");
63
+ }
64
+ return out;
65
+ }
66
+ function errorBody(body) {
67
+ if (typeof body === "string") return body;
68
+ if (body === null || body === void 0) return "";
69
+ return JSON.stringify(body);
70
+ }
71
+ //#endregion
72
+ //#region src/provide.ts
73
+ /** The token {@link provideJsonApiClient} uses when none is given. */
74
+ const JSON_API_CLIENT = new InjectionToken("JSON_API_CLIENT");
75
+ /**
76
+ * Create an injection token for one client, so an app can provide several (one per server).
77
+ * Type it with the client: `createJsonApiClientToken<JsonApiClientOf<typeof createAdminClient>>()`.
78
+ */
79
+ function createJsonApiClientToken(description = "JsonApiClient") {
80
+ return new InjectionToken(description);
81
+ }
82
+ /**
83
+ * Provide a generated client to the environment injector. The client sends through `HttpClient`
84
+ * (so `provideHttpClient()` must also be present) unless `options.transport` overrides it.
85
+ *
86
+ * `options` may be a function, which runs in an injection context, to read values such as the
87
+ * base URL from other providers. `token` defaults to {@link JSON_API_CLIENT}; pass one from
88
+ * {@link createJsonApiClientToken} to provide a second client alongside it.
89
+ */
90
+ function provideJsonApiClient(createClient, options, token = JSON_API_CLIENT) {
91
+ return makeEnvironmentProviders([{
92
+ provide: token,
93
+ useFactory: () => {
94
+ const resolved = typeof options === "function" ? options() : options;
95
+ return createClient({
96
+ ...resolved,
97
+ transport: resolved.transport ?? httpClientTransport(inject(HttpClient))
98
+ });
99
+ }
100
+ }]);
101
+ }
102
+ /**
103
+ * Inject a provided client. Without a token this returns the default client, typed by
104
+ * {@link Register}. With a token it returns that token's client.
105
+ */
106
+ function injectJsonApiClient(token, options) {
107
+ const resolved = token ?? JSON_API_CLIENT;
108
+ if (options?.injector !== void 0) return options.injector.get(resolved);
109
+ assertInInjectionContext(injectJsonApiClient);
110
+ return inject(resolved);
111
+ }
112
+ //#endregion
113
+ //#region src/mutation.ts
114
+ /**
115
+ * Wrap a client write (create, update, delete, a relationship mutation, a custom action or an
116
+ * atomic batch) in signals. Nothing is cached or invalidated: `reload()` the resources the write
117
+ * affects.
118
+ *
119
+ * ```ts
120
+ * readonly rename = jsonApiMutation((client, title: string) =>
121
+ * client.albums.id(this.id()).update({ title }),
122
+ * )
123
+ * ```
124
+ */
125
+ function jsonApiMutation(mutation, options = {}) {
126
+ if (options.injector === void 0) assertInInjectionContext(jsonApiMutation);
127
+ const client = (options.injector ?? inject(Injector)).get(options.client ?? JSON_API_CLIENT);
128
+ const value = signal(void 0);
129
+ const error = signal(void 0);
130
+ const pending = signal(0);
131
+ let generation = 0;
132
+ const mutate = async (input) => {
133
+ const current = ++generation;
134
+ pending.update((n) => n + 1);
135
+ error.set(void 0);
136
+ try {
137
+ const result = await mutation(client, input);
138
+ if (current === generation) value.set(result);
139
+ return result;
140
+ } catch (thrown) {
141
+ if (current === generation) error.set(thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown }));
142
+ throw thrown;
143
+ } finally {
144
+ pending.update((n) => Math.max(0, n - 1));
145
+ }
146
+ };
147
+ return {
148
+ mutate,
149
+ isPending: computed(() => pending() > 0),
150
+ error: error.asReadonly(),
151
+ jsonApiError: computed(() => {
152
+ const e = error();
153
+ return e instanceof JsonApiError ? e : void 0;
154
+ }),
155
+ value: value.asReadonly(),
156
+ reset: () => {
157
+ generation++;
158
+ value.set(void 0);
159
+ error.set(void 0);
160
+ pending.set(0);
161
+ }
162
+ };
163
+ }
164
+ //#endregion
165
+ //#region src/pagination.ts
166
+ /**
167
+ * The `page` params for the page after `list`, read from its `next` link, or `undefined` on the
168
+ * last page (or before anything has loaded). Works the same for every strategy, because the
169
+ * server's link already carries the right parameters. Feed the result back into the signal your
170
+ * resource's params read:
171
+ *
172
+ * ```ts
173
+ * const next = nextPage(this.albums.value())
174
+ * if (next) this.page.set(next)
175
+ * ```
176
+ */
177
+ function nextPage(list) {
178
+ return pageParams(list?.$page, list?.$page.links.next);
179
+ }
180
+ /** The `page` params for the page before `list`, from its `prev` link. See {@link nextPage}. */
181
+ function prevPage(list) {
182
+ return pageParams(list?.$page, list?.$page.links.prev);
183
+ }
184
+ const CURSOR_ROLES = ["after", "before"];
185
+ function pageParams(page, link) {
186
+ if (page === void 0 || link === void 0) return;
187
+ const keys = Object.entries(page.keys);
188
+ const query = link.includes("?") ? link.slice(link.indexOf("?") + 1).split("#")[0] : "";
189
+ const params = {};
190
+ for (const [name, value] of new URLSearchParams(query)) {
191
+ const member = /^page\[([^\]]+)\]$/.exec(name)?.[1];
192
+ if (member === void 0) continue;
193
+ const role = keys.find(([, key]) => key === member)?.[0];
194
+ const count = Number(value);
195
+ params[member] = role !== void 0 && !CURSOR_ROLES.includes(role) && value !== "" && Number.isFinite(count) ? count : value;
196
+ }
197
+ return params;
198
+ }
199
+ /**
200
+ * Follow `next` links from the first page until the last, and return every member in one plain
201
+ * array. Use it inside a loader to read a whole (small) collection:
202
+ *
203
+ * ```ts
204
+ * loader: ({ client, abortSignal }) =>
205
+ * loadAll(client.genres.list({ signal: abortSignal }), { signal: abortSignal })
206
+ * ```
207
+ */
208
+ async function loadAll(first, options = {}) {
209
+ const { signal, maxPages = Number.POSITIVE_INFINITY } = options;
210
+ let page = await first;
211
+ const all = [];
212
+ let count = 0;
213
+ while (page !== void 0 && count < maxPages) {
214
+ all.push(...page);
215
+ count++;
216
+ if (count >= maxPages) break;
217
+ signal?.throwIfAborted();
218
+ page = await page.$next(signal !== void 0 ? { signal } : void 0);
219
+ }
220
+ return all;
221
+ }
222
+ //#endregion
223
+ //#region src/resource.ts
224
+ function jsonApiResource(options) {
225
+ if (options.injector === void 0) assertInInjectionContext(jsonApiResource);
226
+ const injector = options.injector ?? inject(Injector);
227
+ const client = injector.get(options.client ?? JSON_API_CLIENT);
228
+ const ref = resource({
229
+ ...options.params !== void 0 ? { params: options.params } : {},
230
+ loader: ({ params, abortSignal, previous }) => options.loader({
231
+ client,
232
+ params,
233
+ abortSignal,
234
+ previous
235
+ }),
236
+ ...options.defaultValue !== void 0 ? { defaultValue: options.defaultValue } : {},
237
+ ...options.equal !== void 0 ? { equal: options.equal } : {},
238
+ ...options.debugName !== void 0 ? { debugName: options.debugName } : {},
239
+ injector
240
+ });
241
+ const jsonApiError = computed(() => {
242
+ const error = ref.error();
243
+ return error instanceof JsonApiError ? error : void 0;
244
+ });
245
+ Object.defineProperty(ref, "jsonApiError", {
246
+ value: jsonApiError,
247
+ enumerable: true
248
+ });
249
+ return ref;
250
+ }
251
+ //#endregion
252
+ export { JSON_API_CLIENT, createJsonApiClientToken, httpClientTransport, injectJsonApiClient, jsonApiMutation, jsonApiResource, loadAll, nextPage, prevPage, provideJsonApiClient };
253
+
254
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/transport.ts","../src/provide.ts","../src/mutation.ts","../src/pagination.ts","../src/resource.ts"],"sourcesContent":["import { HttpErrorResponse, type HttpClient, type HttpHeaders } from '@angular/common/http'\nimport type { JsonApiTransport, TransportResponse } from '@haddowg/json-api-client'\n\n/**\n * A transport that sends every request through Angular's `HttpClient`, so the app's interceptors\n * run. The body comes back as text for the client to parse, exactly as with `fetchTransport`.\n *\n * A non-2xx response resolves with its status, headers and body, because the client maps those to\n * a `JsonApiError` itself. Only a request that never got a response (status 0: network failure,\n * CORS, a blocked request) rejects, with the `HttpErrorResponse`. Aborting `req.signal`\n * unsubscribes, which cancels the HTTP request, and rejects with the signal's reason.\n */\nexport function httpClientTransport(http: HttpClient): JsonApiTransport {\n return (req) =>\n new Promise<TransportResponse>((resolve, reject) => {\n const signal = req.signal\n if (signal?.aborted) {\n reject(signal.reason)\n return\n }\n\n let unsubscribe: (() => void) | undefined\n const onAbort = (): void => {\n unsubscribe?.()\n reject(signal?.reason)\n }\n signal?.addEventListener('abort', onAbort, { once: true })\n const settle = (): void => signal?.removeEventListener('abort', onAbort)\n\n const subscription = http\n .request(req.method, req.url, {\n body: req.body ?? null,\n headers: req.headers,\n observe: 'response',\n responseType: 'text',\n })\n .subscribe({\n next: (res) => {\n settle()\n resolve({ status: res.status, headers: toRecord(res.headers), body: res.body ?? '' })\n },\n error: (error: unknown) => {\n settle()\n if (error instanceof HttpErrorResponse && error.status !== 0) {\n resolve({\n status: error.status,\n headers: toRecord(error.headers),\n body: errorBody(error.error),\n })\n return\n }\n reject(error)\n },\n })\n unsubscribe = () => subscription.unsubscribe()\n })\n}\n\nfunction toRecord(headers: HttpHeaders): Record<string, string> {\n const out: Record<string, string> = {}\n for (const name of headers.keys()) {\n const values = headers.getAll(name)\n if (values !== null) {\n out[name.toLowerCase()] = values.join(', ')\n }\n }\n return out\n}\n\n// With responseType 'text' the error body is already a string. Anything else (an interceptor that\n// replaced it) is serialised so the client can still try to parse a JSON:API error document.\nfunction errorBody(body: unknown): string {\n if (typeof body === 'string') {\n return body\n }\n if (body === null || body === undefined) {\n return ''\n }\n return JSON.stringify(body)\n}\n","import { HttpClient } from '@angular/common/http'\nimport {\n assertInInjectionContext,\n type EnvironmentProviders,\n inject,\n InjectionToken,\n type Injector,\n makeEnvironmentProviders,\n} from '@angular/core'\nimport type { ClientOptions, JsonApiTransport } from '@haddowg/json-api-client'\nimport { httpClientTransport } from './transport'\n\n/**\n * Options for {@link provideJsonApiClient}: the client's options with the transport made\n * optional. Leave `transport` out to send through `HttpClient`; set it only to opt out.\n */\nexport type JsonApiClientOptions = Omit<ClientOptions, 'transport'> & {\n transport?: JsonApiTransport\n}\n\n/** A generated client factory: the bound `createClient` a codegen module exports. */\nexport type JsonApiClientFactory<C> = (options: ClientOptions) => C\n\n/** The client type a generated `createClient` returns. */\nexport type JsonApiClientOf<F> = F extends (options: ClientOptions) => infer C ? C : never\n\n/**\n * Register the app's default client type once, so `injectJsonApiClient()` and `jsonApiResource()`\n * are typed without a token:\n *\n * ```ts\n * declare module '@haddowg/json-api-angular' {\n * interface Register {\n * client: JsonApiClientOf<typeof createClient>\n * }\n * }\n * ```\n */\n// oxlint-disable-next-line typescript/no-empty-object-type -- filled in by module augmentation\nexport interface Register {}\n\n/** The default client's type: whatever {@link Register} declares, else `unknown`. */\nexport type RegisteredClient = Register extends { client: infer C } ? C : unknown\n\n/** The token {@link provideJsonApiClient} uses when none is given. */\nexport const JSON_API_CLIENT = new InjectionToken<unknown>('JSON_API_CLIENT')\n\n/**\n * Create an injection token for one client, so an app can provide several (one per server).\n * Type it with the client: `createJsonApiClientToken<JsonApiClientOf<typeof createAdminClient>>()`.\n */\nexport function createJsonApiClientToken<C>(description = 'JsonApiClient'): InjectionToken<C> {\n return new InjectionToken<C>(description)\n}\n\n/**\n * Provide a generated client to the environment injector. The client sends through `HttpClient`\n * (so `provideHttpClient()` must also be present) unless `options.transport` overrides it.\n *\n * `options` may be a function, which runs in an injection context, to read values such as the\n * base URL from other providers. `token` defaults to {@link JSON_API_CLIENT}; pass one from\n * {@link createJsonApiClientToken} to provide a second client alongside it.\n */\nexport function provideJsonApiClient<C>(\n createClient: JsonApiClientFactory<C>,\n options: JsonApiClientOptions | (() => JsonApiClientOptions),\n token: InjectionToken<C> = JSON_API_CLIENT as InjectionToken<C>,\n): EnvironmentProviders {\n return makeEnvironmentProviders([\n {\n provide: token,\n useFactory: (): C => {\n const resolved = typeof options === 'function' ? options() : options\n return createClient({\n ...resolved,\n transport: resolved.transport ?? httpClientTransport(inject(HttpClient)),\n })\n },\n },\n ])\n}\n\n/**\n * Inject a provided client. Without a token this returns the default client, typed by\n * {@link Register}. With a token it returns that token's client.\n */\nexport function injectJsonApiClient<C = RegisteredClient>(\n token?: InjectionToken<C>,\n options?: { injector?: Injector },\n): C {\n const resolved = token ?? (JSON_API_CLIENT as InjectionToken<C>)\n if (options?.injector !== undefined) {\n return options.injector.get(resolved)\n }\n assertInInjectionContext(injectJsonApiClient)\n return inject(resolved)\n}\n","import {\n assertInInjectionContext,\n computed,\n inject,\n type InjectionToken,\n Injector,\n type Signal,\n signal,\n} from '@angular/core'\nimport { type ErrorCodeOf, JsonApiError } from '@haddowg/json-api-client'\nimport { JSON_API_CLIENT, type RegisteredClient } from './provide'\nimport type { JsonApiResourceError } from './resource'\n\n/** Options for {@link jsonApiMutation}. */\nexport interface JsonApiMutationOptions<C> {\n /** The client to write with. Defaults to the one `provideJsonApiClient` registered without a token. */\n client?: InjectionToken<C>\n /** Create the mutation outside an injection context. */\n injector?: Injector\n}\n\n/**\n * The signal-based handle {@link jsonApiMutation} returns. `E` is the client's error-code union,\n * so a generated client's `jsonApiError()` carries its server's `ErrorCode`.\n */\nexport interface JsonApiMutationRef<I, T, E extends string = string> {\n /**\n * Run the write. Resolves with its result and rejects with its error; the signals track the\n * latest call either way.\n */\n mutate(input: I): Promise<T>\n readonly isPending: Signal<boolean>\n readonly error: Signal<JsonApiResourceError<E> | undefined>\n /** The error when it is a `JsonApiError`; use `byPath()` on it for form errors. */\n readonly jsonApiError: Signal<JsonApiError<E> | undefined>\n /** The latest successful result. */\n readonly value: Signal<T | undefined>\n /** Clear the value and error. A call still in flight no longer updates the signals. */\n reset(): void\n}\n\n/**\n * Wrap a client write (create, update, delete, a relationship mutation, a custom action or an\n * atomic batch) in signals. Nothing is cached or invalidated: `reload()` the resources the write\n * affects.\n *\n * ```ts\n * readonly rename = jsonApiMutation((client, title: string) =>\n * client.albums.id(this.id()).update({ title }),\n * )\n * ```\n */\nexport function jsonApiMutation<I, T, C = RegisteredClient>(\n mutation: (client: C, input: I) => PromiseLike<T>,\n options: JsonApiMutationOptions<C> = {},\n): JsonApiMutationRef<I, T, ErrorCodeOf<C>> {\n if (options.injector === undefined) {\n assertInInjectionContext(jsonApiMutation)\n }\n const injector = options.injector ?? inject(Injector)\n const client = injector.get(options.client ?? (JSON_API_CLIENT as InjectionToken<C>))\n\n const value = signal<T | undefined>(undefined)\n const error = signal<JsonApiResourceError | undefined>(undefined)\n const pending = signal(0)\n // Each call and reset bumps this, so only the latest call writes to the signals.\n let generation = 0\n\n const mutate = async (input: I): Promise<T> => {\n const current = ++generation\n pending.update((n) => n + 1)\n error.set(undefined)\n try {\n const result = await mutation(client, input)\n if (current === generation) {\n value.set(result)\n }\n return result\n } catch (thrown) {\n if (current === generation) {\n error.set(thrown instanceof Error ? thrown : new Error(String(thrown), { cause: thrown }))\n }\n throw thrown\n } finally {\n pending.update((n) => Math.max(0, n - 1))\n }\n }\n\n return {\n mutate,\n isPending: computed(() => pending() > 0),\n error: error.asReadonly(),\n jsonApiError: computed(() => {\n const e = error()\n return e instanceof JsonApiError ? e : undefined\n }),\n value: value.asReadonly(),\n reset: () => {\n generation++\n value.set(undefined)\n error.set(undefined)\n pending.set(0)\n },\n }\n}\n","import type { Collection, Page, PageParamsOf, PaginatorRole } from '@haddowg/json-api-client'\n\n/**\n * The `page` query a link selects, typed by the `$page` it came from: the strategy's members under\n * their wire keys (counts as numbers, cursors as strings), plus the menu arm the read picked.\n */\nexport type PageParams<P extends Page = Page> = PageParamsOf<P>\n\n/**\n * The `page` params for the page after `list`, read from its `next` link, or `undefined` on the\n * last page (or before anything has loaded). Works the same for every strategy, because the\n * server's link already carries the right parameters. Feed the result back into the signal your\n * resource's params read:\n *\n * ```ts\n * const next = nextPage(this.albums.value())\n * if (next) this.page.set(next)\n * ```\n */\nexport function nextPage<P extends Page>(\n list: { readonly $page: P } | undefined,\n): PageParams<P> | undefined {\n return pageParams(list?.$page, list?.$page.links.next)\n}\n\n/** The `page` params for the page before `list`, from its `prev` link. See {@link nextPage}. */\nexport function prevPage<P extends Page>(\n list: { readonly $page: P } | undefined,\n): PageParams<P> | undefined {\n return pageParams(list?.$page, list?.$page.links.prev)\n}\n\nconst CURSOR_ROLES: readonly string[] = ['after', 'before']\n\nfunction pageParams<P extends Page>(\n page: P | undefined,\n link: string | undefined,\n): PageParams<P> | undefined {\n if (page === undefined || link === undefined) {\n return undefined\n }\n const keys = Object.entries(page.keys) as [PaginatorRole, string][]\n const query = link.includes('?') ? link.slice(link.indexOf('?') + 1).split('#')[0] : ''\n const params: Record<string, string | number> = {}\n for (const [name, value] of new URLSearchParams(query)) {\n const member = /^page\\[([^\\]]+)\\]$/.exec(name)?.[1]\n if (member === undefined) {\n continue\n }\n const role = keys.find(([, key]) => key === member)?.[0]\n const count = Number(value)\n params[member] =\n role !== undefined && !CURSOR_ROLES.includes(role) && value !== '' && Number.isFinite(count)\n ? count\n : value\n }\n return params as PageParams<P>\n}\n\n/** Options for {@link loadAll}. */\nexport interface LoadAllOptions {\n /** Stops the walk, rejecting with the signal's reason. Pass the loader's `abortSignal`. */\n signal?: AbortSignal\n /** Stop after this many pages, as a guard against an unexpectedly large collection. */\n maxPages?: number\n}\n\n/**\n * Follow `next` links from the first page until the last, and return every member in one plain\n * array. Use it inside a loader to read a whole (small) collection:\n *\n * ```ts\n * loader: ({ client, abortSignal }) =>\n * loadAll(client.genres.list({ signal: abortSignal }), { signal: abortSignal })\n * ```\n */\nexport async function loadAll<T>(\n first: Collection<T> | PromiseLike<Collection<T>>,\n options: LoadAllOptions = {},\n): Promise<T[]> {\n const { signal, maxPages = Number.POSITIVE_INFINITY } = options\n let page: Collection<T> | undefined = await first\n const all: T[] = []\n let count = 0\n while (page !== undefined && count < maxPages) {\n all.push(...page)\n count++\n if (count >= maxPages) {\n break\n }\n signal?.throwIfAborted()\n page = await page.$next(signal !== undefined ? { signal } : undefined)\n }\n return all\n}\n","// The only module that touches Angular's `resource()`. It is experimental before Angular 22, so a\n// change to its options or ref shape is absorbed here.\nimport {\n assertInInjectionContext,\n computed,\n inject,\n type InjectionToken,\n Injector,\n resource,\n type ResourceRef,\n type ResourceStatus,\n type Signal,\n type ValueEqualityFn,\n} from '@angular/core'\nimport { type ErrorCodeOf, JsonApiError, type StructuralGuardError } from '@haddowg/json-api-client'\nimport { JSON_API_CLIENT, type RegisteredClient } from './provide'\n\n/**\n * What a read can fail with: a `JsonApiError` for a non-2xx response, a `StructuralGuardError`\n * for a response that isn't a JSON:API document, or any other error (a network failure, a\n * validator's error). Narrow with `instanceof`. `E` types the `JsonApiError`'s codes.\n */\nexport type JsonApiResourceError<E extends string = string> =\n | JsonApiError<E>\n | StructuralGuardError\n | Error\n\n/** What the loader receives each time the params change or the resource reloads. */\nexport interface JsonApiLoaderContext<C, P> {\n client: C\n params: P\n /** Aborts when the params change again or the resource is destroyed. Pass it to the read. */\n abortSignal: AbortSignal\n previous: { status: ResourceStatus }\n}\n\n/** Options for {@link jsonApiResource}. */\nexport interface JsonApiResourceOptions<T, P, C> {\n /**\n * A reactive function computing the read's params from signals. Return `undefined` while the\n * read isn't ready (no id yet, say) and the resource stays idle. Leave it out to load once.\n */\n params?: () => P | undefined\n /** Make the read. Return the client call's promise, passing `abortSignal` as its `signal`. */\n loader: (context: JsonApiLoaderContext<C, P>) => PromiseLike<T>\n /** The client to read with. Defaults to the one `provideJsonApiClient` registered without a token. */\n client?: InjectionToken<C>\n /** The value while nothing has loaded yet. */\n defaultValue?: T\n equal?: ValueEqualityFn<T>\n /** Create the resource outside an injection context. */\n injector?: Injector\n debugName?: string\n}\n\n/**\n * Angular's `ResourceRef` with `error()` typed to what a JSON:API read throws, plus\n * `jsonApiError()`, the error when it is a `JsonApiError` (handy in templates, which can't\n * use `instanceof`). `E` is the client's error-code union, so a generated client's\n * `jsonApiError()` carries its server's `ErrorCode`.\n */\nexport interface JsonApiResourceRef<T, E extends string = string> extends Omit<\n ResourceRef<T>,\n 'error' | 'hasValue'\n> {\n readonly error: Signal<JsonApiResourceError<E> | undefined>\n readonly jsonApiError: Signal<JsonApiError<E> | undefined>\n /** Whether a value is loaded. Narrows `value()` to exclude `undefined`, in templates too. */\n hasValue(\n this: T extends undefined ? this : never,\n ): this is JsonApiResourceRef<Exclude<T, undefined>, E>\n hasValue(): boolean\n}\n\n/**\n * A signal-based JSON:API read: a thin wrapper over Angular's `resource()` that injects the\n * client and hands it to the loader. Changing a signal the params read starts a new load and\n * aborts the one in flight.\n *\n * ```ts\n * readonly albums = jsonApiResource({\n * params: () => ({ filter: { title: this.search() } }),\n * loader: ({ client, params, abortSignal }) =>\n * client.albums.list({ ...params, include: ['artist'], signal: abortSignal }),\n * })\n * ```\n */\nexport function jsonApiResource<T, P = unknown, C = RegisteredClient>(\n options: JsonApiResourceOptions<T, P, C> & { defaultValue: T },\n): JsonApiResourceRef<T, ErrorCodeOf<C>>\nexport function jsonApiResource<T, P = unknown, C = RegisteredClient>(\n options: JsonApiResourceOptions<T, P, C>,\n): JsonApiResourceRef<T | undefined, ErrorCodeOf<C>>\nexport function jsonApiResource<T, P, C>(\n options: JsonApiResourceOptions<T, P, C>,\n): JsonApiResourceRef<T | undefined, ErrorCodeOf<C>> {\n if (options.injector === undefined) {\n assertInInjectionContext(jsonApiResource)\n }\n const injector = options.injector ?? inject(Injector)\n const client = injector.get(options.client ?? (JSON_API_CLIENT as InjectionToken<C>))\n\n const ref = resource<T | undefined, P | undefined>({\n ...(options.params !== undefined ? { params: options.params } : {}),\n loader: ({ params, abortSignal, previous }) =>\n options.loader({ client, params: params as P, abortSignal, previous }),\n ...(options.defaultValue !== undefined ? { defaultValue: options.defaultValue } : {}),\n ...(options.equal !== undefined\n ? { equal: options.equal as ValueEqualityFn<T | undefined> }\n : {}),\n ...(options.debugName !== undefined ? { debugName: options.debugName } : {}),\n injector,\n })\n\n const jsonApiError = computed(() => {\n const error = ref.error()\n return error instanceof JsonApiError ? error : undefined\n })\n Object.defineProperty(ref, 'jsonApiError', { value: jsonApiError, enumerable: true })\n return ref as unknown as JsonApiResourceRef<T | undefined, ErrorCodeOf<C>>\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,70 @@
1
1
  {
2
2
  "name": "@haddowg/json-api-angular",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.1.0",
4
+ "description": "Signal-based Angular bindings for the JSON:API client: an HttpClient transport, DI providers, and resource/mutation helpers.",
5
+ "license": "MIT",
6
+ "author": "Gregory Haddow (https://github.com/haddowg)",
7
+ "homepage": "https://haddowg.github.io/json-api-ts/",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/haddowg/json-api-ts.git",
11
+ "directory": "packages/angular"
12
+ },
13
+ "bugs": "https://github.com/haddowg/json-api-ts/issues",
14
+ "keywords": [
15
+ "json-api",
16
+ "jsonapi",
17
+ "typescript",
18
+ "angular",
19
+ "signals",
20
+ "httpclient",
21
+ "openapi"
22
+ ],
23
+ "type": "module",
24
+ "sideEffects": false,
25
+ "exports": {
26
+ ".": {
27
+ "import": {
28
+ "types": "./dist/index.d.mts",
29
+ "default": "./dist/index.mjs"
30
+ },
31
+ "require": {
32
+ "types": "./dist/index.d.cts",
33
+ "default": "./dist/index.cjs"
34
+ }
35
+ }
36
+ },
37
+ "main": "./dist/index.cjs",
38
+ "module": "./dist/index.mjs",
39
+ "types": "./dist/index.d.cts",
40
+ "files": [
41
+ "dist"
42
+ ],
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "publishConfig": {
47
+ "access": "public",
48
+ "provenance": true
49
+ },
50
+ "dependencies": {
51
+ "@haddowg/json-api-client": "^1.2.0"
52
+ },
53
+ "peerDependencies": {
54
+ "@angular/common": "^20.0.0 || ^21.0.0 || ^22.0.0",
55
+ "@angular/core": "^20.0.0 || ^21.0.0 || ^22.0.0"
56
+ },
57
+ "devDependencies": {
58
+ "@angular/common": "^21.0.0",
59
+ "@angular/compiler": "^21.0.0",
60
+ "@angular/core": "^21.0.0",
61
+ "@angular/platform-browser": "^21.0.0",
62
+ "jsdom": "^25.0.0",
63
+ "rxjs": "^7.8.0"
64
+ },
65
+ "scripts": {
66
+ "build": "tsdown",
67
+ "typecheck": "tsc --noEmit",
68
+ "lint:publish": "publint --strict && attw --pack ."
69
+ }
6
70
  }