aspi 2.1.0 → 2.2.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.
package/README.md CHANGED
@@ -189,15 +189,15 @@ try {
189
189
  // Result mode wins (throwable is ignored)
190
190
  const result = await api
191
191
  .post('/login')
192
- .withResult() // enables Result mode
193
- .throwable() // ignored because withResult was called later
192
+ .withResult() // ignored because throwable was called later
193
+ .throwable() // enables throwable mode
194
194
  .json<{ token: string }>();
195
195
 
196
196
  // Throwable mode wins (Result is ignored)
197
197
  const data = await api
198
198
  .get('/profile')
199
- .throwable() // enables throwable mode
200
- .withResult() // ignored because throwable was called later
199
+ .throwable() // ignored because withResult was called later
200
+ .withResult() // enables Result mode
201
201
  .json();
202
202
  ```
203
203
 
@@ -537,6 +537,220 @@ class Request<
537
537
 
538
538
  ---
539
539
 
540
+ ## Result utilities
541
+
542
+ `Result<T, E>` is a small tagged-union helper used throughout Aspi to represent success or failure without throwing.
543
+
544
+ ```ts
545
+ type Ok<T> = { __tag: 'ok'; value: T };
546
+ type Err<E> = { __tag: 'err'; error: E };
547
+ type Result<T, E> = Ok<T> | Err<E>;
548
+ ```
549
+
550
+ Creating Results
551
+
552
+ ```ts
553
+ import * as Result from './result';
554
+
555
+ const success = Result.ok(42);
556
+ const failure = Result.err('not found');
557
+ ```
558
+
559
+ Checking and Extracting
560
+
561
+ ```ts
562
+ if (Result.isOk(success)) {
563
+ console.log(success.value); // 42
564
+ }
565
+
566
+ if (Result.isErr(failure)) {
567
+ console.error(failure.error); // "not found"
568
+ }
569
+
570
+ const valueOrNull = Result.getOrNull(success); // 42 | null
571
+ const errorOrNull = Result.getErrorOrNull(failure); // "not found" | null
572
+
573
+ const valueOrFallback = Result.getOrElse(failure, 0); // 0
574
+
575
+ // Throwing
576
+ const mustHaveValue = Result.getOrThrow(success); // 42
577
+ // Result.getOrThrow(failure) throws "not found"
578
+ ```
579
+
580
+ Transforming
581
+
582
+ ```ts
583
+ // map value
584
+ const doubled = Result.map(success, (n) => n * 2); // ok(84)
585
+
586
+ // map error
587
+ const upperError = Result.mapErr(failure, (e) => e.toUpperCase()); // err("NOT FOUND")
588
+
589
+ // pattern matching
590
+ const message = Result.match(success, {
591
+ onOk: (n) => `Got ${n}`,
592
+ onErr: (e) => `Error: ${e}`,
593
+ });
594
+ // "Got 42"
595
+ ```
596
+
597
+ Tagged error helpers
598
+ When your error type is a union with a tag field, you can use helpers to handle specific variants:
599
+
600
+ ```ts
601
+ type HttpError =
602
+ | { tag: 'BAD_REQUEST'; details?: string }
603
+ | { tag: 'UNAUTHORIZED' }
604
+ | { tag: 'NOT_FOUND' };
605
+
606
+ const result: Result<number, HttpError> = Result.err({
607
+ tag: 'NOT_FOUND',
608
+ });
609
+
610
+ // Handle a specific tag
611
+ Result.catchError(result, 'NOT_FOUND', (e) => {
612
+ console.log('Missing resource');
613
+ });
614
+
615
+ // Handle multiple tags
616
+ Result.catchErrors(result, {
617
+ BAD_REQUEST: (e) => console.log('Invalid input'),
618
+ UNAUTHORIZED: () => console.log('Please log in'),
619
+ });
620
+ ```
621
+
622
+ Pipe Utility
623
+
624
+ ```ts
625
+ import { pipe } from './result';
626
+
627
+ const label = pipe(
628
+ 12345,
629
+ (cents) => cents / 100,
630
+ (amount) => amount.toFixed(2),
631
+ (str) => `$${str}`,
632
+ );
633
+ // "$123.45"
634
+ ```
635
+
636
+ ---
637
+
638
+ ## Experimental capabilities
639
+
640
+ > **Experimental:** This API is still evolving. Names and behavior may change in minor versions.
641
+
642
+ Capabilities are small plugins that wrap the low‑level fetch call for each request. They let you implement cross‑cutting behavior (logging, retries, token refresh, tracing, etc.) without changing Aspi core.
643
+
644
+ ```ts
645
+ import type { Capability } from './interceptor';
646
+ import { Aspi } from './aspi';
647
+
648
+ // Capability signature
649
+ const myCapability: Capability = ({ request }) => ({
650
+ async run(runner) {
651
+ // Called before fetch
652
+ console.log('→', request.path);
653
+
654
+ const res = await runner(); // performs fetch(url, requestInit)
655
+
656
+ // Called after fetch
657
+ console.log('←', res.status, res.statusText);
658
+
659
+ return res;
660
+ },
661
+ });
662
+ ```
663
+
664
+ Registering capabilities
665
+ Capabilities are attached at the Aspi client level and apply to all requests created from that instance:
666
+
667
+ ```ts
668
+ const api = new Aspi({ baseUrl: 'https://api.example.com' }).useCapability(
669
+ myCapability,
670
+ );
671
+
672
+ const user = await api.get('/users/1').throwable().json<User>();
673
+ ```
674
+
675
+ You can register multiple capabilities; they execute in the order they were added, each wrapping the next:
676
+
677
+ ```ts
678
+ const api = new Aspi({ baseUrl: 'https://api.example.com' })
679
+ .useCapability(loggingCapability)
680
+ .useCapability(tracingCapability)
681
+ .useCapability(refreshTokenCapability);
682
+ ```
683
+
684
+ Example: token refresh (simplified)
685
+
686
+ ```ts
687
+ import type { Capability } from './interceptor';
688
+ import { Aspi } from './aspi';
689
+ import * as Result from './result';
690
+
691
+ let tokens: { accessToken: string | null; refreshToken: string | null } = {
692
+ accessToken: null,
693
+ refreshToken: null,
694
+ };
695
+
696
+ async function refreshTokenRequest(refreshToken: string) {
697
+ const res = await new Aspi({ baseUrl: 'https://auth.example.com' })
698
+ .post('/refresh')
699
+ .bodyJson({ refreshToken })
700
+ .withResult()
701
+ .json<{ accessToken: string; refreshToken: string }>();
702
+
703
+ await Result.match(res, {
704
+ onOk: ({ data }) => {
705
+ tokens = data;
706
+ },
707
+ onErr: (err) => {
708
+ tokens = { accessToken: null, refreshToken: null };
709
+ throw err;
710
+ },
711
+ });
712
+ }
713
+
714
+ export const refreshTokenCapability: Capability = ({ request }) => {
715
+ let isRefreshing = false;
716
+
717
+ return {
718
+ async run(runner) {
719
+ // First try
720
+ const first = await runner();
721
+
722
+ if (first.status !== 401) {
723
+ return first;
724
+ }
725
+
726
+ // No refresh token → just propagate 401
727
+ if (!tokens.refreshToken || isRefreshing) {
728
+ return first;
729
+ }
730
+
731
+ isRefreshing = true;
732
+ try {
733
+ await refreshTokenRequest(tokens.refreshToken);
734
+ } finally {
735
+ isRefreshing = false;
736
+ }
737
+
738
+ if (!tokens.accessToken) {
739
+ return first;
740
+ }
741
+
742
+ // Inject new Authorization header and retry once
743
+ request.requestInit.headers = {
744
+ ...request.requestInit.headers,
745
+ Authorization: `Bearer ${tokens.accessToken}`,
746
+ };
747
+
748
+ return runner();
749
+ },
750
+ };
751
+ };
752
+ ```
753
+
540
754
  ## License
541
755
 
542
756
  MIT © Aspi contributors
package/dist/index.cjs CHANGED
@@ -315,7 +315,8 @@ var Request = class {
315
315
  #shouldBeResult = false;
316
316
  #bodySchemaIssues = [];
317
317
  #throwOnError = false;
318
- constructor(method, path, requestOptions) {
318
+ #capabilities = [];
319
+ constructor(method, path, requestOptions, capabilities = []) {
319
320
  this.#path = path;
320
321
  this.#middlewares = requestOptions.middlewares || [];
321
322
  this.#localRequestInit = {
@@ -326,6 +327,7 @@ var Request = class {
326
327
  this.#customErrorCbs = { ...requestOptions?.errorCbs || {} };
327
328
  this.#throwOnError = requestOptions.throwOnError || false;
328
329
  this.#shouldBeResult = requestOptions.shouldBeResult || false;
330
+ this.#capabilities = [...capabilities];
329
331
  }
330
332
  /**
331
333
  * Sets the base URL for the request.
@@ -1076,7 +1078,15 @@ var Request = class {
1076
1078
  let responseData = null;
1077
1079
  while (attempts <= retries) {
1078
1080
  try {
1079
- response = await fetch(url, requestInit);
1081
+ if (this.#capabilities.length > 0) {
1082
+ for (const capability of this.#capabilities) {
1083
+ response = await capability({ request }).run(
1084
+ () => fetch(url, requestInit)
1085
+ );
1086
+ }
1087
+ } else {
1088
+ response = await fetch(url, requestInit);
1089
+ }
1080
1090
  responseData = await responseParser(response);
1081
1091
  if (responseData instanceof Error) {
1082
1092
  return err(responseData);
@@ -1336,6 +1346,51 @@ var Request = class {
1336
1346
  const cfg = this.#sanitisedRetryConfig();
1337
1347
  return { ...cfg };
1338
1348
  }
1349
+ /**
1350
+ * Registers a capability for this request.
1351
+ *
1352
+ * A capability is a small wrapper around the underlying `fetch` call that can
1353
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1354
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1355
+ * execution of the network call via its `run` method.
1356
+ *
1357
+ * Capabilities are applied in the order they are registered. For each HTTP
1358
+ * call, the `Request` will:
1359
+ *
1360
+ * 1. Build an {@link AspiRequest} from the current configuration.
1361
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1362
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1363
+ * - log or trace requests,
1364
+ * - implement retry/refresh logic,
1365
+ * - short‑circuit with synthetic responses, etc.
1366
+ *
1367
+ * @param capability - The capability factory to register. It will be invoked
1368
+ * for every execution of this request with the current {@link AspiRequest}.
1369
+ *
1370
+ * @returns This {@link Request} instance, enabling fluent chaining.
1371
+ *
1372
+ * @example
1373
+ * ```ts
1374
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1375
+ *
1376
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1377
+ * async run(runner) {
1378
+ * console.log('→', request.path, request.requestInit);
1379
+ * const res = await runner();
1380
+ * console.log('←', res.status, res.statusText);
1381
+ * return res;
1382
+ * },
1383
+ * }));
1384
+ *
1385
+ * const result = await apiWithLogging
1386
+ * .get('/users')
1387
+ * .withResult()
1388
+ * .json();
1389
+ * ```
1390
+ */
1391
+ useCapability(capability) {
1392
+ return this.#capabilities.push(capability);
1393
+ }
1339
1394
  };
1340
1395
 
1341
1396
  // src/aspi.ts
@@ -1346,6 +1401,7 @@ var Aspi = class {
1346
1401
  #customErrorCbs = {};
1347
1402
  #throwOnError = false;
1348
1403
  #shouldBeResult = false;
1404
+ #capabilities = [];
1349
1405
  constructor(config) {
1350
1406
  const { retryConfig, ...requestInit } = config;
1351
1407
  this.#globalRequestInit = requestInit;
@@ -1388,17 +1444,22 @@ var Aspi = class {
1388
1444
  return this;
1389
1445
  }
1390
1446
  #createRequest(method, path) {
1391
- return new Request(method, path, {
1392
- requestConfig: {
1393
- ...this.#globalRequestInit,
1394
- method
1447
+ return new Request(
1448
+ method,
1449
+ path,
1450
+ {
1451
+ requestConfig: {
1452
+ ...this.#globalRequestInit,
1453
+ method
1454
+ },
1455
+ middlewares: this.#middlewares,
1456
+ errorCbs: this.#customErrorCbs,
1457
+ throwOnError: this.#throwOnError,
1458
+ shouldBeResult: this.#shouldBeResult,
1459
+ retryConfig: this.#retryConfig
1395
1460
  },
1396
- middlewares: this.#middlewares,
1397
- errorCbs: this.#customErrorCbs,
1398
- throwOnError: this.#throwOnError,
1399
- shouldBeResult: this.#shouldBeResult,
1400
- retryConfig: this.#retryConfig
1401
- });
1461
+ this.#capabilities
1462
+ );
1402
1463
  }
1403
1464
  /**
1404
1465
  * Makes a GET request to the specified path
@@ -1693,6 +1754,47 @@ var Aspi = class {
1693
1754
  this.#throwOnError = false;
1694
1755
  return this;
1695
1756
  }
1757
+ /**
1758
+ * Registers a capability on this {@link Aspi} instance.
1759
+ *
1760
+ * A capability is a small, pluggable unit that can intercept and wrap the
1761
+ * low‑level `fetch` call used by all requests created from this client.
1762
+ * It is invoked for every request with the constructed {@link AspiRequest},
1763
+ * and can:
1764
+ *
1765
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1766
+ * - inspect the raw {@link Response},
1767
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1768
+ * or token refresh, and
1769
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1770
+ *
1771
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1772
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1773
+ * They are applied in the order they are registered.
1774
+ *
1775
+ * @param capability - The capability factory to install on this client.
1776
+ *
1777
+ * @returns This {@link Aspi} instance for fluent chaining.
1778
+ *
1779
+ * @example
1780
+ * ```ts
1781
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1782
+ * .useCapability(({ request }) => ({
1783
+ * async run(runner) {
1784
+ * console.log('→', request.path);
1785
+ * const res = await runner();
1786
+ * console.log('←', res.status);
1787
+ * return res;
1788
+ * },
1789
+ * }));
1790
+ *
1791
+ * const user = await api.get('/users/1').throwable().json<User>();
1792
+ * ```
1793
+ */
1794
+ useCapability(capability) {
1795
+ this.#capabilities.push(capability);
1796
+ return this;
1797
+ }
1696
1798
  };
1697
1799
  // Annotate the CommonJS export names for ESM import in node:
1698
1800
  0 && (module.exports = {
package/dist/index.d.cts CHANGED
@@ -366,6 +366,72 @@ interface JSONParseError extends CustomError<'jsonParseError', {
366
366
  declare const isAspiError: <TReq extends AspiRequestInit>(error: unknown) => error is AspiError<TReq>;
367
367
  declare const isCustomError: <Tag extends string, A>(error: unknown) => error is CustomError<Tag, A>;
368
368
 
369
+ /**
370
+ * Arguments passed to a capability factory.
371
+ *
372
+ * @template T - The concrete request-init type used by this Aspi instance.
373
+ */
374
+ type CapabilityArgs<T extends AspiRequestInit = AspiRequestInit> = {
375
+ /**
376
+ * The fully constructed AspiRequest that will be used to execute `fetch`.
377
+ *
378
+ * You can:
379
+ * - inspect `request.path`, `request.requestInit`, `request.retryConfig`, etc.
380
+ * - mutate `request.requestInit.headers`, `signal`, or other properties
381
+ * before the network call is made.
382
+ */
383
+ request: AspiRequest<T>;
384
+ };
385
+ /**
386
+ * A capability is a small wrapper around the low-level `fetch` call that can
387
+ * intercept outgoing requests and incoming responses.
388
+ *
389
+ * It is a factory function that receives the current {@link AspiRequest}
390
+ * and returns an object exposing a single `run` method. The `run` method is
391
+ * responsible for invoking the provided `runner` (which performs the actual
392
+ * `fetch`) and may:
393
+ *
394
+ * - call `runner()` directly and return its result
395
+ * - call `runner()` multiple times (e.g. retry, refresh token then retry)
396
+ * - short‑circuit by *not* calling `runner()` and returning a synthetic
397
+ * {@link Response} instead
398
+ *
399
+ * Capabilities are composed in the order they are registered via
400
+ * `Aspi.useCapability`, with each capability wrapping the next one in the
401
+ * chain.
402
+ *
403
+ * @template T - The concrete request-init type used by this Aspi instance.
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * // Simple logging capability
408
+ * const loggingCapability: Capability = ({ request }) => ({
409
+ * async run(runner) {
410
+ * console.log('→', request.path, request.requestInit);
411
+ * const res = await runner();
412
+ * console.log('←', res.status, res.statusText);
413
+ * return res;
414
+ * },
415
+ * });
416
+ *
417
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
418
+ * .useCapability(loggingCapability);
419
+ * ```
420
+ */
421
+ type Capability<T extends AspiRequestInit = AspiRequestInit> = ({ request, }: CapabilityArgs<T>) => {
422
+ /**
423
+ * Executes the next step in the capability chain.
424
+ *
425
+ * @param runner - A function that, when called, performs the actual
426
+ * network request (or the next capability in the chain) and resolves to
427
+ * a {@link Response}.
428
+ *
429
+ * @returns A promise resolving to the final {@link Response} that should be
430
+ * used by Aspi for this request.
431
+ */
432
+ run: (runner: () => Promise<Response>) => Promise<Response>;
433
+ };
434
+
369
435
  type Ok<T> = {
370
436
  __tag: 'ok';
371
437
  value: T;
@@ -768,7 +834,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
768
834
  error: {};
769
835
  }> {
770
836
  #private;
771
- constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>);
837
+ constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>, capabilities?: Capability<TRequest>[]);
772
838
  /**
773
839
  * Sets the base URL for the request.
774
840
  * @param url The base URL to set
@@ -1441,6 +1507,49 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1441
1507
  retryWhile: ((request: AspiRequest<TRequest>, response: AspiResponse) => boolean | Promise<boolean>) | undefined;
1442
1508
  onRetry: ((request: AspiRequest<TRequest>, response: AspiResponse) => void) | undefined;
1443
1509
  };
1510
+ /**
1511
+ * Registers a capability for this request.
1512
+ *
1513
+ * A capability is a small wrapper around the underlying `fetch` call that can
1514
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1515
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1516
+ * execution of the network call via its `run` method.
1517
+ *
1518
+ * Capabilities are applied in the order they are registered. For each HTTP
1519
+ * call, the `Request` will:
1520
+ *
1521
+ * 1. Build an {@link AspiRequest} from the current configuration.
1522
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1523
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1524
+ * - log or trace requests,
1525
+ * - implement retry/refresh logic,
1526
+ * - short‑circuit with synthetic responses, etc.
1527
+ *
1528
+ * @param capability - The capability factory to register. It will be invoked
1529
+ * for every execution of this request with the current {@link AspiRequest}.
1530
+ *
1531
+ * @returns This {@link Request} instance, enabling fluent chaining.
1532
+ *
1533
+ * @example
1534
+ * ```ts
1535
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1536
+ *
1537
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1538
+ * async run(runner) {
1539
+ * console.log('→', request.path, request.requestInit);
1540
+ * const res = await runner();
1541
+ * console.log('←', res.status, res.statusText);
1542
+ * return res;
1543
+ * },
1544
+ * }));
1545
+ *
1546
+ * const result = await apiWithLogging
1547
+ * .get('/users')
1548
+ * .withResult()
1549
+ * .json();
1550
+ * ```
1551
+ */
1552
+ useCapability(capability: Capability<TRequest>): number;
1444
1553
  }
1445
1554
 
1446
1555
  /**
@@ -1744,6 +1853,44 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1744
1853
  withResult: true;
1745
1854
  throwable: false;
1746
1855
  }>>;
1856
+ /**
1857
+ * Registers a capability on this {@link Aspi} instance.
1858
+ *
1859
+ * A capability is a small, pluggable unit that can intercept and wrap the
1860
+ * low‑level `fetch` call used by all requests created from this client.
1861
+ * It is invoked for every request with the constructed {@link AspiRequest},
1862
+ * and can:
1863
+ *
1864
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1865
+ * - inspect the raw {@link Response},
1866
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1867
+ * or token refresh, and
1868
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1869
+ *
1870
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1871
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1872
+ * They are applied in the order they are registered.
1873
+ *
1874
+ * @param capability - The capability factory to install on this client.
1875
+ *
1876
+ * @returns This {@link Aspi} instance for fluent chaining.
1877
+ *
1878
+ * @example
1879
+ * ```ts
1880
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1881
+ * .useCapability(({ request }) => ({
1882
+ * async run(runner) {
1883
+ * console.log('→', request.path);
1884
+ * const res = await runner();
1885
+ * console.log('←', res.status);
1886
+ * return res;
1887
+ * },
1888
+ * }));
1889
+ *
1890
+ * const user = await api.get('/users/1').throwable().json<User>();
1891
+ * ```
1892
+ */
1893
+ useCapability(capability: Capability<TRequest>): this;
1747
1894
  }
1748
1895
 
1749
1896
  export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
package/dist/index.d.ts CHANGED
@@ -366,6 +366,72 @@ interface JSONParseError extends CustomError<'jsonParseError', {
366
366
  declare const isAspiError: <TReq extends AspiRequestInit>(error: unknown) => error is AspiError<TReq>;
367
367
  declare const isCustomError: <Tag extends string, A>(error: unknown) => error is CustomError<Tag, A>;
368
368
 
369
+ /**
370
+ * Arguments passed to a capability factory.
371
+ *
372
+ * @template T - The concrete request-init type used by this Aspi instance.
373
+ */
374
+ type CapabilityArgs<T extends AspiRequestInit = AspiRequestInit> = {
375
+ /**
376
+ * The fully constructed AspiRequest that will be used to execute `fetch`.
377
+ *
378
+ * You can:
379
+ * - inspect `request.path`, `request.requestInit`, `request.retryConfig`, etc.
380
+ * - mutate `request.requestInit.headers`, `signal`, or other properties
381
+ * before the network call is made.
382
+ */
383
+ request: AspiRequest<T>;
384
+ };
385
+ /**
386
+ * A capability is a small wrapper around the low-level `fetch` call that can
387
+ * intercept outgoing requests and incoming responses.
388
+ *
389
+ * It is a factory function that receives the current {@link AspiRequest}
390
+ * and returns an object exposing a single `run` method. The `run` method is
391
+ * responsible for invoking the provided `runner` (which performs the actual
392
+ * `fetch`) and may:
393
+ *
394
+ * - call `runner()` directly and return its result
395
+ * - call `runner()` multiple times (e.g. retry, refresh token then retry)
396
+ * - short‑circuit by *not* calling `runner()` and returning a synthetic
397
+ * {@link Response} instead
398
+ *
399
+ * Capabilities are composed in the order they are registered via
400
+ * `Aspi.useCapability`, with each capability wrapping the next one in the
401
+ * chain.
402
+ *
403
+ * @template T - The concrete request-init type used by this Aspi instance.
404
+ *
405
+ * @example
406
+ * ```ts
407
+ * // Simple logging capability
408
+ * const loggingCapability: Capability = ({ request }) => ({
409
+ * async run(runner) {
410
+ * console.log('→', request.path, request.requestInit);
411
+ * const res = await runner();
412
+ * console.log('←', res.status, res.statusText);
413
+ * return res;
414
+ * },
415
+ * });
416
+ *
417
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
418
+ * .useCapability(loggingCapability);
419
+ * ```
420
+ */
421
+ type Capability<T extends AspiRequestInit = AspiRequestInit> = ({ request, }: CapabilityArgs<T>) => {
422
+ /**
423
+ * Executes the next step in the capability chain.
424
+ *
425
+ * @param runner - A function that, when called, performs the actual
426
+ * network request (or the next capability in the chain) and resolves to
427
+ * a {@link Response}.
428
+ *
429
+ * @returns A promise resolving to the final {@link Response} that should be
430
+ * used by Aspi for this request.
431
+ */
432
+ run: (runner: () => Promise<Response>) => Promise<Response>;
433
+ };
434
+
369
435
  type Ok<T> = {
370
436
  __tag: 'ok';
371
437
  value: T;
@@ -768,7 +834,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
768
834
  error: {};
769
835
  }> {
770
836
  #private;
771
- constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>);
837
+ constructor(method: HttpMethods, path: string, requestOptions: RequestOptions<TRequest>, capabilities?: Capability<TRequest>[]);
772
838
  /**
773
839
  * Sets the base URL for the request.
774
840
  * @param url The base URL to set
@@ -1441,6 +1507,49 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1441
1507
  retryWhile: ((request: AspiRequest<TRequest>, response: AspiResponse) => boolean | Promise<boolean>) | undefined;
1442
1508
  onRetry: ((request: AspiRequest<TRequest>, response: AspiResponse) => void) | undefined;
1443
1509
  };
1510
+ /**
1511
+ * Registers a capability for this request.
1512
+ *
1513
+ * A capability is a small wrapper around the underlying `fetch` call that can
1514
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1515
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1516
+ * execution of the network call via its `run` method.
1517
+ *
1518
+ * Capabilities are applied in the order they are registered. For each HTTP
1519
+ * call, the `Request` will:
1520
+ *
1521
+ * 1. Build an {@link AspiRequest} from the current configuration.
1522
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1523
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1524
+ * - log or trace requests,
1525
+ * - implement retry/refresh logic,
1526
+ * - short‑circuit with synthetic responses, etc.
1527
+ *
1528
+ * @param capability - The capability factory to register. It will be invoked
1529
+ * for every execution of this request with the current {@link AspiRequest}.
1530
+ *
1531
+ * @returns This {@link Request} instance, enabling fluent chaining.
1532
+ *
1533
+ * @example
1534
+ * ```ts
1535
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1536
+ *
1537
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1538
+ * async run(runner) {
1539
+ * console.log('→', request.path, request.requestInit);
1540
+ * const res = await runner();
1541
+ * console.log('←', res.status, res.statusText);
1542
+ * return res;
1543
+ * },
1544
+ * }));
1545
+ *
1546
+ * const result = await apiWithLogging
1547
+ * .get('/users')
1548
+ * .withResult()
1549
+ * .json();
1550
+ * ```
1551
+ */
1552
+ useCapability(capability: Capability<TRequest>): number;
1444
1553
  }
1445
1554
 
1446
1555
  /**
@@ -1744,6 +1853,44 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1744
1853
  withResult: true;
1745
1854
  throwable: false;
1746
1855
  }>>;
1856
+ /**
1857
+ * Registers a capability on this {@link Aspi} instance.
1858
+ *
1859
+ * A capability is a small, pluggable unit that can intercept and wrap the
1860
+ * low‑level `fetch` call used by all requests created from this client.
1861
+ * It is invoked for every request with the constructed {@link AspiRequest},
1862
+ * and can:
1863
+ *
1864
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1865
+ * - inspect the raw {@link Response},
1866
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1867
+ * or token refresh, and
1868
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1869
+ *
1870
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1871
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1872
+ * They are applied in the order they are registered.
1873
+ *
1874
+ * @param capability - The capability factory to install on this client.
1875
+ *
1876
+ * @returns This {@link Aspi} instance for fluent chaining.
1877
+ *
1878
+ * @example
1879
+ * ```ts
1880
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1881
+ * .useCapability(({ request }) => ({
1882
+ * async run(runner) {
1883
+ * console.log('→', request.path);
1884
+ * const res = await runner();
1885
+ * console.log('←', res.status);
1886
+ * return res;
1887
+ * },
1888
+ * }));
1889
+ *
1890
+ * const user = await api.get('/users/1').throwable().json<User>();
1891
+ * ```
1892
+ */
1893
+ useCapability(capability: Capability<TRequest>): this;
1747
1894
  }
1748
1895
 
1749
1896
  export { Aspi, type AspiConfigBase, AspiError, type AspiPlainResponse, type AspiRequest, type AspiRequestInit, type AspiRequestInitWithoutBodyAndMethod, type AspiResponse, type AspiResultOk, type AspiRetryConfig, type BaseURL, CustomError, type CustomErrorCb, type ErrorCallbacks, type HttpErrorCodes, type HttpErrorStatus, type HttpMethods, type JSONParseError, type Merge, type Prettify, Request, type RequestOptions, type RequestTransformer, result as Result, getHttpErrorStatus, httpErrors, isAspiError, isCustomError };
package/dist/index.js CHANGED
@@ -287,7 +287,8 @@ var Request = class {
287
287
  #shouldBeResult = false;
288
288
  #bodySchemaIssues = [];
289
289
  #throwOnError = false;
290
- constructor(method, path, requestOptions) {
290
+ #capabilities = [];
291
+ constructor(method, path, requestOptions, capabilities = []) {
291
292
  this.#path = path;
292
293
  this.#middlewares = requestOptions.middlewares || [];
293
294
  this.#localRequestInit = {
@@ -298,6 +299,7 @@ var Request = class {
298
299
  this.#customErrorCbs = { ...requestOptions?.errorCbs || {} };
299
300
  this.#throwOnError = requestOptions.throwOnError || false;
300
301
  this.#shouldBeResult = requestOptions.shouldBeResult || false;
302
+ this.#capabilities = [...capabilities];
301
303
  }
302
304
  /**
303
305
  * Sets the base URL for the request.
@@ -1048,7 +1050,15 @@ var Request = class {
1048
1050
  let responseData = null;
1049
1051
  while (attempts <= retries) {
1050
1052
  try {
1051
- response = await fetch(url, requestInit);
1053
+ if (this.#capabilities.length > 0) {
1054
+ for (const capability of this.#capabilities) {
1055
+ response = await capability({ request }).run(
1056
+ () => fetch(url, requestInit)
1057
+ );
1058
+ }
1059
+ } else {
1060
+ response = await fetch(url, requestInit);
1061
+ }
1052
1062
  responseData = await responseParser(response);
1053
1063
  if (responseData instanceof Error) {
1054
1064
  return err(responseData);
@@ -1308,6 +1318,51 @@ var Request = class {
1308
1318
  const cfg = this.#sanitisedRetryConfig();
1309
1319
  return { ...cfg };
1310
1320
  }
1321
+ /**
1322
+ * Registers a capability for this request.
1323
+ *
1324
+ * A capability is a small wrapper around the underlying `fetch` call that can
1325
+ * intercept, inspect, or modify the request/response lifecycle. Each registered
1326
+ * capability receives the constructed {@link AspiRequest} and can wrap the
1327
+ * execution of the network call via its `run` method.
1328
+ *
1329
+ * Capabilities are applied in the order they are registered. For each HTTP
1330
+ * call, the `Request` will:
1331
+ *
1332
+ * 1. Build an {@link AspiRequest} from the current configuration.
1333
+ * 2. Create a runner: `() => fetch(url, requestInit)`.
1334
+ * 3. Pass that runner through each capability in sequence, allowing them to:
1335
+ * - log or trace requests,
1336
+ * - implement retry/refresh logic,
1337
+ * - short‑circuit with synthetic responses, etc.
1338
+ *
1339
+ * @param capability - The capability factory to register. It will be invoked
1340
+ * for every execution of this request with the current {@link AspiRequest}.
1341
+ *
1342
+ * @returns This {@link Request} instance, enabling fluent chaining.
1343
+ *
1344
+ * @example
1345
+ * ```ts
1346
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' });
1347
+ *
1348
+ * const apiWithLogging = api.useCapability(({ request }) => ({
1349
+ * async run(runner) {
1350
+ * console.log('→', request.path, request.requestInit);
1351
+ * const res = await runner();
1352
+ * console.log('←', res.status, res.statusText);
1353
+ * return res;
1354
+ * },
1355
+ * }));
1356
+ *
1357
+ * const result = await apiWithLogging
1358
+ * .get('/users')
1359
+ * .withResult()
1360
+ * .json();
1361
+ * ```
1362
+ */
1363
+ useCapability(capability) {
1364
+ return this.#capabilities.push(capability);
1365
+ }
1311
1366
  };
1312
1367
 
1313
1368
  // src/aspi.ts
@@ -1318,6 +1373,7 @@ var Aspi = class {
1318
1373
  #customErrorCbs = {};
1319
1374
  #throwOnError = false;
1320
1375
  #shouldBeResult = false;
1376
+ #capabilities = [];
1321
1377
  constructor(config) {
1322
1378
  const { retryConfig, ...requestInit } = config;
1323
1379
  this.#globalRequestInit = requestInit;
@@ -1360,17 +1416,22 @@ var Aspi = class {
1360
1416
  return this;
1361
1417
  }
1362
1418
  #createRequest(method, path) {
1363
- return new Request(method, path, {
1364
- requestConfig: {
1365
- ...this.#globalRequestInit,
1366
- method
1419
+ return new Request(
1420
+ method,
1421
+ path,
1422
+ {
1423
+ requestConfig: {
1424
+ ...this.#globalRequestInit,
1425
+ method
1426
+ },
1427
+ middlewares: this.#middlewares,
1428
+ errorCbs: this.#customErrorCbs,
1429
+ throwOnError: this.#throwOnError,
1430
+ shouldBeResult: this.#shouldBeResult,
1431
+ retryConfig: this.#retryConfig
1367
1432
  },
1368
- middlewares: this.#middlewares,
1369
- errorCbs: this.#customErrorCbs,
1370
- throwOnError: this.#throwOnError,
1371
- shouldBeResult: this.#shouldBeResult,
1372
- retryConfig: this.#retryConfig
1373
- });
1433
+ this.#capabilities
1434
+ );
1374
1435
  }
1375
1436
  /**
1376
1437
  * Makes a GET request to the specified path
@@ -1665,6 +1726,47 @@ var Aspi = class {
1665
1726
  this.#throwOnError = false;
1666
1727
  return this;
1667
1728
  }
1729
+ /**
1730
+ * Registers a capability on this {@link Aspi} instance.
1731
+ *
1732
+ * A capability is a small, pluggable unit that can intercept and wrap the
1733
+ * low‑level `fetch` call used by all requests created from this client.
1734
+ * It is invoked for every request with the constructed {@link AspiRequest},
1735
+ * and can:
1736
+ *
1737
+ * - inspect or mutate the outgoing request (e.g. inject auth headers),
1738
+ * - inspect the raw {@link Response},
1739
+ * - implement cross‑cutting concerns such as logging, tracing, retries,
1740
+ * or token refresh, and
1741
+ * - short‑circuit the network call by returning a synthetic {@link Response}.
1742
+ *
1743
+ * Capabilities registered on the {@link Aspi} instance are propagated to every
1744
+ * {@link Request} created via methods like {@link get}, {@link post}, etc.
1745
+ * They are applied in the order they are registered.
1746
+ *
1747
+ * @param capability - The capability factory to install on this client.
1748
+ *
1749
+ * @returns This {@link Aspi} instance for fluent chaining.
1750
+ *
1751
+ * @example
1752
+ * ```ts
1753
+ * const api = new Aspi({ baseUrl: 'https://api.example.com' })
1754
+ * .useCapability(({ request }) => ({
1755
+ * async run(runner) {
1756
+ * console.log('→', request.path);
1757
+ * const res = await runner();
1758
+ * console.log('←', res.status);
1759
+ * return res;
1760
+ * },
1761
+ * }));
1762
+ *
1763
+ * const user = await api.get('/users/1').throwable().json<User>();
1764
+ * ```
1765
+ */
1766
+ useCapability(capability) {
1767
+ this.#capabilities.push(capability);
1768
+ return this;
1769
+ }
1668
1770
  };
1669
1771
  export {
1670
1772
  Aspi,
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aspi",
3
3
  "description": "Rest API client for typescript projects with chain of responsibility design pattern.",
4
- "version": "2.1.0",
4
+ "version": "2.2.1",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
7
7
  "devDependencies": {