@rivinia/http 0.5.0 → 0.5.1

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.
Files changed (2) hide show
  1. package/README.md +249 -132
  2. package/package.json +1 -1
package/README.md CHANGED
@@ -3,197 +3,314 @@
3
3
  [![npm version](https://img.shields.io/npm/v/@rivinia/http.svg)](https://www.npmjs.com/package/@rivinia/http)
4
4
  [![license](https://img.shields.io/npm/l/@rivinia/http.svg)](https://www.npmjs.com/package/@rivinia/http)
5
5
 
6
- Headless typed HTTP client for Angular 22+. Configurable envelope
7
- unwrapping, plain Observables **and** `httpResource` — URLs always used as
8
- given, zero styling, zero backend opinions, interceptors left to your app.
6
+ `@rivinia/http` is a thin, typed Angular HTTP client for plain `Observable`
7
+ requests and reactive `httpResource` requests. It leaves URLs, backend
8
+ contracts, and interceptors under app control. Response bodies pass through
9
+ unchanged unless a request explicitly asks to unwrap one.
9
10
 
10
- - Version: **0.5.0**
11
- - Requires: **Angular `^22.0.0`**, `rxjs` (same range Angular uses), `tslib`
12
- - Since Angular 21 `HttpClient` (fetch backend) is injectable with zero setup.
13
- Call `provideHttpClient()` / pass `httpFeatures` only when you configure
14
- interceptors or other backend features.
11
+ - Prepared patch: **0.5.1** (latest published: **0.5.0**)
12
+ - Requires Angular `^22.0.0` and RxJS `^6.5.3 || ^7.4.0`; `tslib` is installed
13
+ as a dependency.
15
14
 
16
- ## Install
15
+ ## Install and configure
17
16
 
18
17
  ```bash
19
18
  npm install @rivinia/http
20
- ng add @rivinia/http # wires app.config.ts (no questions: unwrapping is per call)
19
+ ng add @rivinia/http
21
20
  ```
22
21
 
23
- ## Quick start — pick ONE backend style
22
+ For a standalone app, `ng add` adds `provideHttpClient()` and
23
+ `provideRiviniaHttp()` to the selected project's `src/app/app.config.ts`.
24
+ It has no prompts and does not set a base URL or an envelope policy. It is
25
+ idempotent and only edits an existing app config containing a `providers: []`
26
+ array; if no config is found it warns and skips, so add the providers
27
+ manually. In a multi-project workspace it targets `defaultProject` or the
28
+ first project; use `--project <name>` to select a project.
29
+
30
+ The equivalent minimal standalone setup is:
24
31
 
25
32
  ```ts
26
- // app.config.ts — style A: nothing to configure (Angular ≥21 default)
27
- providers: [provideRiviniaHttp()];
33
+ // src/app/app.config.ts
34
+ import { provideHttpClient } from '@angular/common/http';
35
+ import { ApplicationConfig } from '@angular/core';
36
+ import { provideRiviniaHttp } from '@rivinia/http';
37
+
38
+ export const appConfig: ApplicationConfig = {
39
+ providers: [provideHttpClient(), provideRiviniaHttp()],
40
+ };
41
+ ```
28
42
 
29
- // style B: single call with interceptors (do NOT also call provideHttpClient)
30
- providers: [provideRiviniaHttp({ httpFeatures: [withInterceptors([authInterceptor])] })];
43
+ Use the same `provideHttpClient` setup to add app-owned interceptors:
31
44
 
32
- // style C: explicit backend config, like the official setup guide
33
- providers: [provideHttpClient(withInterceptors([authInterceptor])), provideRiviniaHttp()];
45
+ ```ts
46
+ import { ApplicationConfig } from '@angular/core';
47
+ import { provideHttpClient, withInterceptors } from '@angular/common/http';
48
+ import { provideRiviniaHttp } from '@rivinia/http';
49
+ import { authInterceptor } from './auth.interceptor';
50
+
51
+ export const appConfig: ApplicationConfig = {
52
+ providers: [provideHttpClient(withInterceptors([authInterceptor])), provideRiviniaHttp()],
53
+ };
34
54
  ```
35
55
 
36
- ## URLs: yours, always
37
-
38
- The library never joins, prefixes, or rewrites URLs. Compose them at the
39
- call site from wherever you want — typically your environment files
40
- (the BRT pattern):
56
+ Alternatively, pass features through `provideRiviniaHttp`:
41
57
 
42
58
  ```ts
43
- // src/environments/environment.ts / environment.prod.ts
44
- export const environment = { apiUrl: 'https://api.example.com' };
59
+ import { ApplicationConfig } from '@angular/core';
60
+ import { withInterceptors } from '@angular/common/http';
61
+ import { provideRiviniaHttp } from '@rivinia/http';
62
+ import { authInterceptor } from './auth.interceptor';
63
+
64
+ export const appConfig: ApplicationConfig = {
65
+ providers: [provideRiviniaHttp({ httpFeatures: [withInterceptors([authInterceptor])] })],
66
+ };
67
+ ```
45
68
 
46
- // feature service
47
- import { joinUrl } from '@rivinia/http';
69
+ Choose one backend setup: when using `httpFeatures`, do not also call
70
+ `provideHttpClient()`; otherwise the HTTP backend and its default XSRF
71
+ interceptor can be registered twice. If the app already configures
72
+ `provideHttpClient(...)`, keep that and call `provideRiviniaHttp()` without
73
+ `httpFeatures`.
48
74
 
49
- export class TripService {
50
- private api = injectRiviniaHttp();
51
- private url = (path: string) => joinUrl(environment.apiUrl, path);
75
+ ## URLs and typed service calls
52
76
 
53
- list = () => this.api.get<Trip[]>(this.url('trips'));
54
- create = (dto: CreateTrip) => this.api.post<Trip>(this.url('trips'), dto);
77
+ The client sends the endpoint string exactly as given. It never prepends a
78
+ base URL or rewrites a path. Compose URLs yourself, for example with
79
+ `joinUrl` and the app's environment:
80
+
81
+ ```ts
82
+ // src/environments/environment.ts
83
+ export const environment = {
84
+ apiUrl: 'https://api.example.com',
85
+ } as const;
86
+ ```
87
+
88
+ ```ts
89
+ // src/app/trip.service.ts
90
+ import { Injectable } from '@angular/core';
91
+ import { Observable } from 'rxjs';
92
+ import { injectRiviniaHttp, joinUrl } from '@rivinia/http';
93
+ import { environment } from '../environments/environment';
94
+
95
+ interface Trip {
96
+ id: number;
97
+ title: string;
55
98
  }
56
99
 
57
- // Reactive resource (auto refetch, loading/error signals)
58
- export class TripPage {
59
- page = signal(1);
60
- trips = this.api.getResource<Trip[]>(() => this.url(`trips?page=${this.page()}`));
100
+ interface CreateTrip {
101
+ title: string;
61
102
  }
62
- ```
63
103
 
64
- ```html
65
- @if (trips.hasValue()) { @for (t of trips.value(); track t.id) {
66
- <p>{{ t.title }}</p>
67
- } } @else if (trips.isLoading()) {
68
- <p>Loading…</p>
104
+ @Injectable({ providedIn: 'root' })
105
+ export class TripService {
106
+ private readonly http = injectRiviniaHttp();
107
+ private readonly tripsUrl = joinUrl(environment.apiUrl, 'trips');
108
+
109
+ list(): Observable<Trip[]> {
110
+ return this.http.get<Trip[]>(this.tripsUrl, {
111
+ params: { page: 1, search: 'coast' },
112
+ unwrap: true,
113
+ unwrapKey: 'data',
114
+ });
115
+ }
116
+
117
+ create(input: CreateTrip): Observable<Trip> {
118
+ return this.http.post<Trip>(this.tripsUrl, input, { unwrap: true, unwrapKey: 'data' });
119
+ }
69
120
  }
70
121
  ```
71
122
 
72
- ## Envelope unwrapping (explicit, per call)
123
+ Here the API is expected to respond with `{ "data": [...] }` for the list and
124
+ `{ "data": { "id": 1, "title": "..." } }` for the create. `Observable<T>` is
125
+ the compile-time type of the result; it does not validate the server payload.
126
+ `joinUrl` removes one boundary slash and passes absolute `http://` or
127
+ `https://` endpoints through unchanged. Use `params` for query values:
128
+ `null` and `undefined` entries are omitted, while strings, numbers, and
129
+ booleans are stringified.
73
130
 
74
- The library assumes nothing about your backend: responses pass through raw
75
- unless the call opts in. There is no global unwrap configuration and no
76
- default key — every unwrapping call states both facts:
131
+ `get`, `post`, `put`, `patch`, and `delete` return typed `Observable`s.
132
+ Mutation bodies are passed to Angular unchanged; `delete` accepts an optional
133
+ `body` in its options:
77
134
 
78
135
  ```ts
79
- this.api.get<Raw>(url); // raw body (default)
80
- this.api.get<Legacy>(url, { unwrap: true, unwrapKey: 'result' }); // explicit
136
+ this.http.delete<void>(joinUrl(environment.apiUrl, 'trips/1'), {
137
+ body: { reason: 'duplicate' },
138
+ });
81
139
  ```
82
140
 
83
- The type system enforces the contract: `{ unwrap: true }` without a key,
84
- a key alone, and `{ unwrap: false, unwrapKey }` all fail compilation.
85
- Different shapes per API in the same app:
141
+ The standard request options also pass `headers`, `HttpContext`, and
142
+ `withCredentials` through to Angular's `HttpClient`. Use `HttpContext` with
143
+ your own interceptors for app-specific controls such as opting out of auth.
86
144
 
87
- ```ts
88
- export class MixedService {
89
- private api = injectRiviniaHttp();
145
+ ## Reactive resources
90
146
 
91
- // main backend: { data: … } (explicit key per call)
92
- trips = () => this.api.get<Trip[]>(fullUrl('trips'), { unwrap: true, unwrapKey: 'data' });
147
+ `getResource` uses Angular's `httpResource`. The endpoint can be a string, a
148
+ signal, or a reactive function; `undefined` disables the request. URL,
149
+ `params`, `headers`, and `context` can be reactive inputs and trigger a new
150
+ request when their signal values change. Other options are read once when
151
+ the resource is created.
93
152
 
94
- // legacy backend: { result: … }
95
- legacy = () => this.api.get<Old[]>(fullUrl('old'), { unwrap: true, unwrapKey: 'result' });
153
+ ```ts
154
+ import { Component, signal } from '@angular/core';
155
+ import { injectRiviniaHttp, joinUrl } from '@rivinia/http';
156
+ import { environment } from '../environments/environment';
96
157
 
97
- // webhook endpoint: raw body (default — nothing to say)
98
- ping = () => this.api.get<string>(fullUrl('ping'));
158
+ interface Trip {
159
+ id: number;
160
+ title: string;
99
161
  }
100
- ```
101
-
102
- A missing key never passes the body through: it throws
103
- `RiviniaHttpUnwrapError` (with `key` + `endpoint`; the raw `body` is attached
104
- for diagnosis but never interpolated into the message). Catch it like any
105
- HTTP error — in `getResource` it surfaces as `resource.error()`:
106
162
 
107
- ```ts
108
- this.api.get<Trip[]>(url, { unwrap: true, unwrapKey: 'data' }).subscribe({
109
- next: (trips) => (this.list = trips),
110
- error: (e) => this.fail(e), // RiviniaHttpUnwrapError | HttpErrorResponse
111
- });
163
+ @Component({
164
+ selector: 'app-trip-list',
165
+ template: `
166
+ @if (trips.isLoading()) {
167
+ <p>Loading…</p>
168
+ } @else if (trips.hasValue()) {
169
+ @for (trip of trips.value(); track trip.id) {
170
+ <p>{{ trip.title }}</p>
171
+ }
172
+ } @else if (trips.error()) {
173
+ <p>Could not load trips.</p>
174
+ }
175
+ `,
176
+ })
177
+ export class TripListComponent {
178
+ private readonly http = injectRiviniaHttp();
179
+ readonly page = signal(1);
180
+ readonly trips = this.http.getResource<Trip[]>(joinUrl(environment.apiUrl, 'trips'), {
181
+ params: () => ({ page: this.page() }),
182
+ unwrap: true,
183
+ unwrapKey: 'data',
184
+ });
185
+ }
112
186
  ```
113
187
 
114
- `getResource` applies the same rules through `parse` (your own `parse` runs
115
- after unwrapping — ideal for Zod/Valibot validation).
188
+ In a real app, share the Trip model through a model module. `parse` is optional; when
189
+ provided it runs after envelope validation and unwrapping, and can perform
190
+ runtime validation (for example with Zod or Valibot). A parse error, HTTP
191
+ error, or library envelope error is exposed through `trips.error()`.
192
+ `defaultValue` supplies a value while idle/loading. `reportProgress` is
193
+ forwarded to the resource request; for progress events on an `Observable`,
194
+ use Angular `HttpClient` directly.
116
195
 
117
- ## Envelope failure validation (opt-in)
196
+ ## Response envelopes and errors
118
197
 
119
- Backends that report failure inside HTTP 200 (`{ data: null, isSuccess: false }`)
120
- must never flow into `next()` as success — critical in payment flows:
198
+ By default, all JSON response bodies are returned raw, even when they contain
199
+ a field named `data`. To extract a payload, opt in on that call and provide a
200
+ key:
121
201
 
122
202
  ```ts
123
- provideRiviniaHttp({ envelope: {} }); // whole app (isSuccess/error keys)
124
- this.api.get(url, { envelope: { successKey: 'ok', errorKey: 'message' } });
203
+ this.http.get<Trip[]>(url); // raw response body
204
+ this.http.get<Trip[]>(url, { unwrap: true, unwrapKey: 'data' });
125
205
  ```
126
206
 
127
- A failed envelope throws `RiviniaHttpEnvelopeError` (`body` + `code` attached;
128
- in `getResource` it surfaces as `resource.error()`). Validation runs on the raw
129
- body before unwrapping, independent of `unwrap`. Per-call policy replaces any
130
- global one wholesale.
207
+ The type contract requires `unwrapKey` when `unwrap: true`, and rejects a key
208
+ when unwrapping is off. Extraction is strict and single-level: the response
209
+ must be a non-null, non-array object with that own property. A missing key,
210
+ primitive/array/null body, or empty key throws `RiviniaHttpUnwrapError`;
211
+ it does not silently return the original body. Present values such as `null`,
212
+ `false`, `0`, and `''` are returned as-is. Nested data and sibling fields are
213
+ not modified. `unwrapAs<T>` performs the same one-level extraction outside a
214
+ request; its generic type is an unchecked cast, not runtime validation.
131
215
 
132
- ## API
216
+ An optional envelope policy checks backend-declared failures before
217
+ unwrapping. The default keys are `isSuccess` and `error`; only an object whose
218
+ `isSuccess` value is exactly `false` is considered failed:
133
219
 
134
- | Export | Kind | Purpose |
135
- | ----------------------------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
136
- | `provideRiviniaHttp(config?)` | provider | `{ envelope?, httpFeatures? }` (no unwrap config — per call only) |
137
- | `RiviniaHttp` | `@Service()` | `get/post/put/patch/delete`, `getBlob`, `getText`, `getResponse`, `postResponse`, `getResource` |
138
- | `injectRiviniaHttp()` | function | shorthand for `inject(RiviniaHttp)` |
139
- | `joinUrl(base, endpoint)` | util | slash-safe join; absolute URLs pass through |
140
- | `unwrapAs(body, key)` | util | strict single-level extraction — throws `RiviniaHttpUnwrapError` on missing key (nested keys preserved) |
141
- | `extractKey(body, key)` | util | `unwrapAs` without the generic cast (same strictness) |
142
- | types | types | `RiviniaHttpConfig`, `RiviniaRequestOptions`, `RiviniaRawOptions`, `RiviniaDeleteOptions`, `RiviniaResourceOptions`, `RiviniaEnvelopePolicy`, `ReactiveInput`, `QueryParamsInput` |
220
+ ```ts
221
+ // Apply to JSON request paths made by this configured client.
222
+ provideRiviniaHttp({ envelope: {} });
143
223
 
144
- `getResponse`/`postResponse` return the raw `HttpResponse` (status + headers,
145
- body never unwrapped — and their options REJECT unwrap fields at compile
146
- time via `RiviniaRawOptions`; compose `unwrapAs` yourself if needed). A configured
147
- `envelope` policy still validates the body on these paths: a globally declared
148
- failure rule holds on every JSON path, never just the unwrapped ones. `getText`
149
- returns raw text; binary/text bodies cannot carry envelopes, so the policy does
150
- not apply there. In `getResource`, the URL, `params`, `headers` and `context`
151
- accept values, signals or reactive functions (everything else is static).
224
+ // Or specify a per-request policy; it replaces the global policy.
225
+ this.http.get<Trip[]>(url, {
226
+ envelope: { successKey: 'ok', errorKey: 'message' },
227
+ });
228
+ ```
152
229
 
153
- `getBlob` is never unwrapped. `HttpContext` passes through untouched, so
154
- app interceptors (auth opt-out, error toasts) keep working. For caching,
155
- polling, retry and TanStack Query interop see [RECIPES.md](./RECIPES.md).
230
+ When a failure is detected, `RiviniaHttpEnvelopeError` is sent through the
231
+ Observable error channel or appears as the resource error. It carries the raw
232
+ `body` and failure `code`. Set `throwOnFailure: false` for a passive policy.
233
+ Policy validation runs before unwrapping, including on `getResponse` and
234
+ `postResponse`. Those full-response methods return raw `HttpResponse<T>`
235
+ (status, headers, and unwrapped body is not applied). `getBlob` and `getText`
236
+ also return raw bodies and do not run envelope validation.
237
+
238
+ The generic type does not validate the server response. For untrusted or
239
+ loosely typed APIs, validate the extracted value with `getResource`'s `parse`
240
+ hook, or validate in the Observable pipeline in app code. Do not log
241
+ `RiviniaHttpUnwrapError.body` or `RiviniaHttpEnvelopeError.body` without
242
+ considering that they may contain sensitive data.
243
+
244
+ ## Public API
245
+
246
+ | Export | Kind | Purpose |
247
+ | ----------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------- |
248
+ | `provideRiviniaHttp(config?)` | provider | Sets `envelope` and optional `httpFeatures`; no global unwrap option. |
249
+ | `RiviniaHttp` | class | `get`, `post`, `put`, `patch`, `delete`, `getBlob`, `getText`, `getResponse`, `postResponse`, and `getResource`. |
250
+ | `injectRiviniaHttp()` | function | Injection-context shorthand for `inject(RiviniaHttp)`. |
251
+ | `joinUrl(base, endpoint)` | utility | Removes one boundary slash when joining; absolute HTTP(S) endpoints pass through. |
252
+ | `unwrapAs(body, key)` | utility | Strict, single-level typed extraction. |
253
+ | `extractKey(body, key)` | utility | Strict extraction without a generic cast. |
254
+ | `RiviniaHttpUnwrapError` | class | Error for invalid or missing unwrap properties. |
255
+ | `RiviniaHttpEnvelopeError` | class | Error for a failed configured response envelope. |
256
+ | `RiviniaHttpConfig` | type | `envelope` and `httpFeatures` provider configuration. |
257
+ | `RiviniaEnvelopePolicy` | type | Success key, error key, and failure policy settings. |
258
+ | `RiviniaRequestOptions` | type | Options for requests that may unwrap. |
259
+ | `RiviniaRawOptions` | type | Options for methods that never unwrap. |
260
+ | `RiviniaDeleteOptions` | type | Delete options including an optional request body. |
261
+ | `RiviniaDeleteRequestOptions` | type | Delete options including the unwrap contract. |
262
+ | `RiviniaRequestBase` | type | Shared transport and envelope options. |
263
+ | `RiviniaResourceOptions` | type | Reactive resource options and unwrap contract. |
264
+ | `RiviniaResourceBase` | type | Shared resource-specific options. |
265
+ | `ReactiveInput` | type | Static value, signal, or reactive function input. |
266
+ | `QueryParamsInput` | type | `HttpParams` or a primitive-valued query object. |
267
+ | `RiviniaUnwrapOn` | type | Required key form for `{ unwrap: true }`. |
268
+ | `RiviniaUnwrapOff` | type | Form where an unwrap key is disallowed. |
269
+
270
+ ## Testing
271
+
272
+ Use Angular's HTTP testing providers. The testing provider follows
273
+ `provideHttpClient()` so the test backend replaces the real backend:
274
+
275
+ ```ts
276
+ import { provideHttpClient } from '@angular/common/http';
277
+ import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
278
+ import { TestBed } from '@angular/core/testing';
279
+ import { provideRiviniaHttp, RiviniaHttp } from '@rivinia/http';
156
280
 
157
- ## Type holes (honest, by design)
281
+ TestBed.configureTestingModule({
282
+ providers: [provideHttpClient(), provideHttpClientTesting(), provideRiviniaHttp()],
283
+ });
158
284
 
159
- `unwrapAs<T>` is an unchecked cast — a wrong `T` compiles and mistypes the
160
- payload at runtime. What IS checked is generic flow plus the unwrap contract
161
- itself (see `http-type-safety.spec.ts` `@ts-expect-error` tests). For untyped
162
- backends, pair unwrapping with `envelope` validation or a `parse` hook
163
- (Zod/Valibot — see [RECIPES.md](./RECIPES.md)).
285
+ const api = TestBed.inject(RiviniaHttp);
286
+ const requests = TestBed.inject(HttpTestingController);
287
+ api
288
+ .get<unknown[]>('https://api.test/trips', {
289
+ unwrap: true,
290
+ unwrapKey: 'data',
291
+ })
292
+ .subscribe();
293
+ requests.expectOne('https://api.test/trips').flush({ data: [] });
294
+ requests.verify();
295
+ ```
164
296
 
165
- ## Migration from global unwrapping (0.4.0 → 0.5.0, breaking)
297
+ ## Migration from 0.4.x
166
298
 
167
- Global `unwrap`/`unwrapKey` configuration is removed. Previously:
299
+ Global `unwrap` and `unwrapKey` settings were removed in 0.5.0. Move the
300
+ unwrap decision and key to each applicable request:
168
301
 
169
302
  ```ts
303
+ // Before
170
304
  provideRiviniaHttp({ unwrap: true, unwrapKey: 'data' });
171
- this.api.get<Trip[]>(url); // arrived unwrapped
172
- ```
305
+ this.http.get<Trip[]>(url);
173
306
 
174
- Now every unwrapping call states its key explicitly:
175
-
176
- ```ts
307
+ // After
177
308
  provideRiviniaHttp();
178
- this.api.get<Trip[]>(url, { unwrap: true, unwrapKey: 'data' });
309
+ this.http.get<Trip[]>(url, { unwrap: true, unwrapKey: 'data' });
179
310
  ```
180
311
 
181
- Migrate each applicable call, or the body arrives wrapped (raw). Missing
182
- keys now throw `RiviniaHttpUnwrapError` instead of passing the body through.
183
- `ng add` no longer asks about unwrapping and emits bare
184
- `provideRiviniaHttp()` — there is no automatic migration of call sites.
185
-
186
- ## Testing your app code
187
-
188
- Standard Angular tooling — nothing custom to learn:
189
-
190
- ```ts
191
- TestBed.configureTestingModule({
192
- providers: [provideHttpClient(), provideHttpClientTesting(), provideRiviniaHttp()],
193
- });
194
- api.get('https://api.test/users').subscribe();
195
- TestBed.inject(HttpTestingController).expectOne('https://api.test/users').flush({ data: [] });
196
- ```
312
+ The `ng add` schematic now emits bare providers and does not migrate request
313
+ call sites. Without the per-request options, the response remains raw.
197
314
 
198
315
  ## License
199
316
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rivinia/http",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Headless typed HTTP client for Angular: envelope unwrapping, plain Observables and httpResource.",
5
5
  "keywords": [
6
6
  "angular",