aspi 2.0.1 → 2.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/README.md CHANGED
@@ -3,13 +3,14 @@
3
3
  A tiny, type‑safe wrapper around the native **fetch** API that gives you a clean, monadic interface for HTTP requests.
4
4
  It ships with **zero runtime dependencies**, a **tiny bundle size**, and full **TypeScript** support out of the box.
5
5
 
6
- > **Why use Aspi?**
7
- > • End‑to‑end TypeScript typings (request + response)
8
- > No extra weight – only a thin wrapper around `fetch`
9
- > Chain‑of‑responsibility middleware support via `use`
10
- > • Resultbased error handling (values as errors)
11
- > • Builtin retry, header helpers, query‑string handling, and schema validation (Zod, Arktype, Valibot)
12
- > Flexible error mapping with `error` and convenience shortcuts
6
+ **Why use Aspi?**
7
+
8
+ - End‑to‑end TypeScript typings (request + response)
9
+ - No extra weight only a thin wrapper around `fetch`
10
+ - Chainof‑responsibility middleware support via `use`
11
+ - Resultbased error handling (values as errors)
12
+ - Built‑in retry, header helpers, query‑string handling, and schema validation (Zod, Arktype, Valibot)
13
+ - Flexible error mapping with `error` and convenience shortcuts
13
14
 
14
15
  ---
15
16
 
@@ -66,6 +67,67 @@ getTodo(1);
66
67
 
67
68
  ---
68
69
 
70
+ ## Why Aspi?
71
+
72
+ Most real‑world codebases end up with one or more of these issues:
73
+
74
+ 1. **Inconsistent error handling**
75
+ - Some utilities throw raw `Error`/`AxiosError`.
76
+ - Others return `{ ok: false, error }` or `null` or a custom union.
77
+ - Callers don’t know whether to use `try/catch`, check `ok`, or both.
78
+
79
+ 2. **Retry logic duplicated everywhere**
80
+ - Each service rolls its own `while (attempt <= retries)` loop.
81
+ - Status codes, backoff strategies, and retry limits slowly diverge over time.
82
+ - There is no single place to see “how do we retry HTTP calls in this app?”.
83
+
84
+ 3. **Validation pushed far from the network boundary**
85
+ - Request payloads are sometimes validated, sometimes not.
86
+ - Response validation happens deep in the business logic (if at all).
87
+ - JSON parse errors leak as raw `SyntaxError`, not structured errors.
88
+
89
+ 4. **Configuration scattered across factories and interceptors**
90
+ - Base URL helpers, auth decorators, error mappers, retry plugins, and logging interceptors all live in different files.
91
+ - Global state / interceptors can make it hard to tell what a given request will actually do.
92
+
93
+ 5. **Type systems are bolted on, not designed in**
94
+ - Generic HTTP clients often expose `any` for responses.
95
+ - Error flows are not encoded in the type system, forcing manual guards and casting.
96
+
97
+ ## How Aspi fixes them
98
+
99
+ Aspi’s design centers around three things:
100
+
101
+ 1. **Mode‑driven responses**
102
+
103
+ You decide at call‑site how you want to consume responses:
104
+ - `withResult()` → `json/text/blob` return a `Result.Result<Ok, ErrorUnion>`.
105
+ - `throwable()` → `json/text/blob` return `AspiPlainResponse` and throw on failure.
106
+ - Default → `json/text/blob` return `[ok, err]` tuples.
107
+
108
+ All error variants are **tagged** so they can be safely narrowed by `error.tag`.
109
+
110
+ 2. **Centralized, configurable retry layer**
111
+
112
+ Retry behavior is described declaratively:
113
+ - `retries`: max attempts.
114
+ - `retryDelay`: number or function `(attempt, maxAttempts, request, response) => delayMs`.
115
+ - `retryOn`: list of HTTP status codes that should trigger a retry.
116
+ - `retryWhile`: predicate `(request, response) => boolean` for custom retry conditions.
117
+ - `onRetry`: hook invoked after each retry attempt.
118
+
119
+ This configuration can be applied globally (`Aspi.setRetry`) and overridden per request (`Request.setRetry`).
120
+
121
+ 3. **Validation at the transport boundary**
122
+
123
+ Using a `StandardSchemaV1` interface, Aspi integrates with schema libraries (e.g. Zod, Valibot) to:
124
+ - Validate request bodies with `bodySchema` + `bodyJson` **before** the network call.
125
+ - Validate responses with `schema()` + `json()` **after** JSON parsing.
126
+
127
+ These failures appear as tagged `parseError` values with structured issue lists, not random runtime exceptions.
128
+
129
+ ---
130
+
69
131
  ## Using the `Result` monad
70
132
 
71
133
  If you prefer a single `Result` value instead of a tuple, call **`.withResult()`** before a body‑parser method.
package/dist/index.cjs CHANGED
@@ -20,7 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
- Aspi: () => Aspi2,
23
+ Aspi: () => Aspi,
24
24
  AspiError: () => AspiError,
25
25
  CustomError: () => CustomError,
26
26
  Request: () => Request,
@@ -322,8 +322,8 @@ var Request = class {
322
322
  ...requestOptions.requestConfig,
323
323
  method
324
324
  };
325
- this.#retryConfig = requestOptions.retryConfig;
326
- this.#customErrorCbs = requestOptions.errorCbs || {};
325
+ this.#retryConfig = { ...requestOptions?.retryConfig || {} };
326
+ this.#customErrorCbs = { ...requestOptions?.errorCbs || {} };
327
327
  this.#throwOnError = requestOptions.throwOnError || false;
328
328
  this.#shouldBeResult = requestOptions.shouldBeResult || false;
329
329
  }
@@ -680,7 +680,29 @@ var Request = class {
680
680
  * request.setQueryParams(qp);
681
681
  */
682
682
  setQueryParams(params) {
683
- this.#queryParams = new URLSearchParams(params);
683
+ let qp;
684
+ if (params instanceof URLSearchParams) {
685
+ qp = new URLSearchParams(params);
686
+ } else if (typeof params === "string") {
687
+ qp = new URLSearchParams(params);
688
+ } else if (Array.isArray(params)) {
689
+ qp = new URLSearchParams();
690
+ for (const entry of params) {
691
+ if (Array.isArray(entry) && entry.length === 2) {
692
+ qp.append(String(entry[0]), String(entry[1]));
693
+ }
694
+ }
695
+ } else if (typeof params === "object" && params !== null) {
696
+ qp = new URLSearchParams();
697
+ for (const [key, value] of Object.entries(
698
+ params
699
+ )) {
700
+ qp.append(key, String(value));
701
+ }
702
+ } else {
703
+ qp = new URLSearchParams();
704
+ }
705
+ this.#queryParams = qp;
684
706
  return this;
685
707
  }
686
708
  /**
@@ -921,11 +943,41 @@ var Request = class {
921
943
  return this.#mapResponse(output);
922
944
  }
923
945
  #url() {
946
+ if (this.#path.startsWith("http://") || this.#path.startsWith("https://")) {
947
+ const absolute = new URL(this.#path);
948
+ if (this.#queryParams) {
949
+ for (const [k, v] of this.#queryParams.entries()) {
950
+ absolute.searchParams.append(k, v);
951
+ }
952
+ }
953
+ return absolute.toString();
954
+ }
924
955
  const passedBaseUrl = typeof this.#localRequestInit.baseUrl === "string" ? this.#localRequestInit.baseUrl : this.#localRequestInit.baseUrl.toString();
925
- const baseUrl = passedBaseUrl.replace(/\/+$/, "") ?? "";
926
- const path = this.#path.replace(/^\/+/, "/");
927
- const queryString = this.#queryParams ? `?${this.#queryParams.toString()}` : "";
928
- const url = [baseUrl, path, queryString].filter(Boolean).join("");
956
+ const base = passedBaseUrl.replace(/\/+$/, "");
957
+ const [rawPathAndQuery, fragment] = this.#path.split("#", 2);
958
+ const [rawPath, existingQuery] = rawPathAndQuery.split("?", 2);
959
+ let path = rawPath || "";
960
+ path = path.replace(/^\/+/, "");
961
+ path = path.replace(/\/{2,}/g, "/");
962
+ path = path.replace(/\/+$/, "");
963
+ if (path) {
964
+ path = "/" + path.replace(/^\/+/, "");
965
+ }
966
+ const qs = new URLSearchParams(existingQuery ?? "");
967
+ if (this.#queryParams) {
968
+ for (const [k, v] of this.#queryParams.entries()) {
969
+ qs.append(k, v);
970
+ }
971
+ }
972
+ const queryString = qs.toString();
973
+ let url = base + path;
974
+ if (queryString) {
975
+ url += `?${queryString}`;
976
+ }
977
+ if (fragment) {
978
+ url += `#${fragment}`;
979
+ }
980
+ url = url.replace(/\/+$/, "");
929
981
  return url;
930
982
  }
931
983
  /**
@@ -1217,8 +1269,9 @@ var Request = class {
1217
1269
  return {
1218
1270
  response,
1219
1271
  status: response.status,
1220
- statusText: getHttpErrorStatus(response.status),
1221
- responseData
1272
+ statusLabel: getHttpErrorStatus(response.status),
1273
+ responseData,
1274
+ statusText: response.statusText
1222
1275
  };
1223
1276
  }
1224
1277
  /**
@@ -1286,7 +1339,7 @@ var Request = class {
1286
1339
  };
1287
1340
 
1288
1341
  // src/aspi.ts
1289
- var Aspi2 = class {
1342
+ var Aspi = class {
1290
1343
  #globalRequestInit;
1291
1344
  #middlewares = [];
1292
1345
  #retryConfig;
@@ -1613,7 +1666,7 @@ var Aspi2 = class {
1613
1666
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1614
1667
  */
1615
1668
  internalServerError(cb) {
1616
- return this.error("internalServerErrorError", "INTERNAL_SERVER_ERROR", cb);
1669
+ return this.error("internalServerError", "INTERNAL_SERVER_ERROR", cb);
1617
1670
  }
1618
1671
  /**
1619
1672
  * Sets the aspi to throw an error if the response status is not successful.
package/dist/index.d.cts CHANGED
@@ -295,8 +295,9 @@ interface AspiRequest<T extends AspiRequestInit> {
295
295
  */
296
296
  type AspiResponse<TData = any, IsError extends boolean = false> = Merge<{
297
297
  status: HttpErrorCodes;
298
- statusText: HttpErrorStatus;
298
+ statusLabel: HttpErrorStatus;
299
299
  response: Response;
300
+ statusText: string;
300
301
  }, IsError extends true ? {
301
302
  responseData: TData;
302
303
  } : {
@@ -1081,7 +1082,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1081
1082
  * const qp = new URLSearchParams({ page: '1' });
1082
1083
  * request.setQueryParams(qp);
1083
1084
  */
1084
- setQueryParams<T extends Record<string, string> | string[][] | string | URLSearchParams>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1085
+ setQueryParams<T = any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1085
1086
  queryParams: T;
1086
1087
  }>>;
1087
1088
  /**
@@ -1718,7 +1719,7 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1718
1719
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1719
1720
  */
1720
1721
  internalServerError<A extends {}>(cb: CustomErrorCb<TRequest, A>): Aspi<TRequest, Prettify<Opts & {
1721
- error: { [K in keyof Opts["error"] | "internalServerErrorError"]: K extends "internalServerErrorError" ? CustomError<"internalServerErrorError", A> : Opts["error"][K]; };
1722
+ error: { [K in "internalServerError" | keyof Opts["error"]]: K extends "internalServerError" ? CustomError<"internalServerError", A> : Opts["error"][K]; };
1722
1723
  }>>;
1723
1724
  /**
1724
1725
  * Sets the aspi to throw an error if the response status is not successful.
package/dist/index.d.ts CHANGED
@@ -295,8 +295,9 @@ interface AspiRequest<T extends AspiRequestInit> {
295
295
  */
296
296
  type AspiResponse<TData = any, IsError extends boolean = false> = Merge<{
297
297
  status: HttpErrorCodes;
298
- statusText: HttpErrorStatus;
298
+ statusLabel: HttpErrorStatus;
299
299
  response: Response;
300
+ statusText: string;
300
301
  }, IsError extends true ? {
301
302
  responseData: TData;
302
303
  } : {
@@ -1081,7 +1082,7 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1081
1082
  * const qp = new URLSearchParams({ page: '1' });
1082
1083
  * request.setQueryParams(qp);
1083
1084
  */
1084
- setQueryParams<T extends Record<string, string> | string[][] | string | URLSearchParams>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1085
+ setQueryParams<T = any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1085
1086
  queryParams: T;
1086
1087
  }>>;
1087
1088
  /**
@@ -1718,7 +1719,7 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1718
1719
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1719
1720
  */
1720
1721
  internalServerError<A extends {}>(cb: CustomErrorCb<TRequest, A>): Aspi<TRequest, Prettify<Opts & {
1721
- error: { [K in keyof Opts["error"] | "internalServerErrorError"]: K extends "internalServerErrorError" ? CustomError<"internalServerErrorError", A> : Opts["error"][K]; };
1722
+ error: { [K in "internalServerError" | keyof Opts["error"]]: K extends "internalServerError" ? CustomError<"internalServerError", A> : Opts["error"][K]; };
1722
1723
  }>>;
1723
1724
  /**
1724
1725
  * Sets the aspi to throw an error if the response status is not successful.
package/dist/index.js CHANGED
@@ -294,8 +294,8 @@ var Request = class {
294
294
  ...requestOptions.requestConfig,
295
295
  method
296
296
  };
297
- this.#retryConfig = requestOptions.retryConfig;
298
- this.#customErrorCbs = requestOptions.errorCbs || {};
297
+ this.#retryConfig = { ...requestOptions?.retryConfig || {} };
298
+ this.#customErrorCbs = { ...requestOptions?.errorCbs || {} };
299
299
  this.#throwOnError = requestOptions.throwOnError || false;
300
300
  this.#shouldBeResult = requestOptions.shouldBeResult || false;
301
301
  }
@@ -652,7 +652,29 @@ var Request = class {
652
652
  * request.setQueryParams(qp);
653
653
  */
654
654
  setQueryParams(params) {
655
- this.#queryParams = new URLSearchParams(params);
655
+ let qp;
656
+ if (params instanceof URLSearchParams) {
657
+ qp = new URLSearchParams(params);
658
+ } else if (typeof params === "string") {
659
+ qp = new URLSearchParams(params);
660
+ } else if (Array.isArray(params)) {
661
+ qp = new URLSearchParams();
662
+ for (const entry of params) {
663
+ if (Array.isArray(entry) && entry.length === 2) {
664
+ qp.append(String(entry[0]), String(entry[1]));
665
+ }
666
+ }
667
+ } else if (typeof params === "object" && params !== null) {
668
+ qp = new URLSearchParams();
669
+ for (const [key, value] of Object.entries(
670
+ params
671
+ )) {
672
+ qp.append(key, String(value));
673
+ }
674
+ } else {
675
+ qp = new URLSearchParams();
676
+ }
677
+ this.#queryParams = qp;
656
678
  return this;
657
679
  }
658
680
  /**
@@ -893,11 +915,41 @@ var Request = class {
893
915
  return this.#mapResponse(output);
894
916
  }
895
917
  #url() {
918
+ if (this.#path.startsWith("http://") || this.#path.startsWith("https://")) {
919
+ const absolute = new URL(this.#path);
920
+ if (this.#queryParams) {
921
+ for (const [k, v] of this.#queryParams.entries()) {
922
+ absolute.searchParams.append(k, v);
923
+ }
924
+ }
925
+ return absolute.toString();
926
+ }
896
927
  const passedBaseUrl = typeof this.#localRequestInit.baseUrl === "string" ? this.#localRequestInit.baseUrl : this.#localRequestInit.baseUrl.toString();
897
- const baseUrl = passedBaseUrl.replace(/\/+$/, "") ?? "";
898
- const path = this.#path.replace(/^\/+/, "/");
899
- const queryString = this.#queryParams ? `?${this.#queryParams.toString()}` : "";
900
- const url = [baseUrl, path, queryString].filter(Boolean).join("");
928
+ const base = passedBaseUrl.replace(/\/+$/, "");
929
+ const [rawPathAndQuery, fragment] = this.#path.split("#", 2);
930
+ const [rawPath, existingQuery] = rawPathAndQuery.split("?", 2);
931
+ let path = rawPath || "";
932
+ path = path.replace(/^\/+/, "");
933
+ path = path.replace(/\/{2,}/g, "/");
934
+ path = path.replace(/\/+$/, "");
935
+ if (path) {
936
+ path = "/" + path.replace(/^\/+/, "");
937
+ }
938
+ const qs = new URLSearchParams(existingQuery ?? "");
939
+ if (this.#queryParams) {
940
+ for (const [k, v] of this.#queryParams.entries()) {
941
+ qs.append(k, v);
942
+ }
943
+ }
944
+ const queryString = qs.toString();
945
+ let url = base + path;
946
+ if (queryString) {
947
+ url += `?${queryString}`;
948
+ }
949
+ if (fragment) {
950
+ url += `#${fragment}`;
951
+ }
952
+ url = url.replace(/\/+$/, "");
901
953
  return url;
902
954
  }
903
955
  /**
@@ -1189,8 +1241,9 @@ var Request = class {
1189
1241
  return {
1190
1242
  response,
1191
1243
  status: response.status,
1192
- statusText: getHttpErrorStatus(response.status),
1193
- responseData
1244
+ statusLabel: getHttpErrorStatus(response.status),
1245
+ responseData,
1246
+ statusText: response.statusText
1194
1247
  };
1195
1248
  }
1196
1249
  /**
@@ -1258,7 +1311,7 @@ var Request = class {
1258
1311
  };
1259
1312
 
1260
1313
  // src/aspi.ts
1261
- var Aspi2 = class {
1314
+ var Aspi = class {
1262
1315
  #globalRequestInit;
1263
1316
  #middlewares = [];
1264
1317
  #retryConfig;
@@ -1585,7 +1638,7 @@ var Aspi2 = class {
1585
1638
  * api.internalServerError((req, res) => ({ message: 'Server error occurred' }));
1586
1639
  */
1587
1640
  internalServerError(cb) {
1588
- return this.error("internalServerErrorError", "INTERNAL_SERVER_ERROR", cb);
1641
+ return this.error("internalServerError", "INTERNAL_SERVER_ERROR", cb);
1589
1642
  }
1590
1643
  /**
1591
1644
  * Sets the aspi to throw an error if the response status is not successful.
@@ -1614,7 +1667,7 @@ var Aspi2 = class {
1614
1667
  }
1615
1668
  };
1616
1669
  export {
1617
- Aspi2 as Aspi,
1670
+ Aspi,
1618
1671
  AspiError,
1619
1672
  CustomError,
1620
1673
  Request,
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.0.1",
4
+ "version": "2.1.0",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
7
7
  "devDependencies": {
@@ -32,17 +32,6 @@
32
32
  "url": "git+https://github.com/harshtalks/aspi.git"
33
33
  },
34
34
  "homepage": "https://github.com/harshtalks/aspi",
35
- "scripts": {
36
- "ci": "bun run test:run && bun run build && bun run check-format && bun run lint",
37
- "format": "prettier --write .",
38
- "check-format": "prettier --check .",
39
- "build": "tsup",
40
- "lint": "tsc",
41
- "local-release": "changeset version && changeset publish",
42
- "prepublishOnly": "bun run ci",
43
- "test": "vitest",
44
- "test:run": "vitest run"
45
- },
46
35
  "exports": {
47
36
  "./package.json": "./package.json",
48
37
  ".": {
@@ -52,5 +41,15 @@
52
41
  },
53
42
  "files": [
54
43
  "dist"
55
- ]
56
- }
44
+ ],
45
+ "scripts": {
46
+ "ci": "bun run test:run && bun run build && bun run check-format && bun run lint",
47
+ "format": "prettier --write .",
48
+ "check-format": "prettier --check .",
49
+ "build": "tsup",
50
+ "lint": "tsc",
51
+ "local-release": "changeset version && changeset publish",
52
+ "test": "vitest",
53
+ "test:run": "vitest run"
54
+ }
55
+ }