aspi 2.7.0 → 2.9.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
@@ -12,7 +12,7 @@ Zero runtime dependencies. Three response modes. Full error-union types.
12
12
  ## Features
13
13
 
14
14
  - **Zero dependencies** — thin wrapper around the platform `fetch` API
15
- - **Three response modes** — tuple `[data, error]`, `Result` monad, or `throwable` (your choice per call)
15
+ - **Three response modes** — tuple `[data, error]`, `Result` monad, or `throwable` (your choice per call, plus explicit `.withTuple()` to reset)
16
16
  - **Typed error unions** — every error variant is tagged and narrowable at compile time
17
17
  - **Custom error mapping** — map any HTTP status code to a structured, typed error object
18
18
  - **Retry with back-off** — fixed or dynamic delay, status-code filtering, custom predicates
@@ -65,7 +65,7 @@ if (data) console.log(data.title);
65
65
 
66
66
  ## Response modes
67
67
 
68
- Every request can be consumed in one of three modes. Switch mode by calling `.withResult()` or `.throwable()` before the body-parser method.
68
+ Every request can be consumed in one of three modes. Switch mode by calling `.withResult()`, `.throwable()`, or `.withTuple()` before the body-parser method.
69
69
 
70
70
  ### 1. Tuple mode (default)
71
71
 
@@ -106,7 +106,7 @@ try {
106
106
  }
107
107
  ```
108
108
 
109
- > `throwable()` and `withResult()` are mutually exclusive — the **last one called wins**.
109
+ > `throwable()`, `withResult()`, and `withTuple()` are mutually exclusive — the **last one called wins**. Use `.withTuple()` to explicitly reset back to the default tuple mode after a previous `.withResult()` or `.throwable()`.
110
110
 
111
111
  ---
112
112
 
@@ -116,12 +116,12 @@ try {
116
116
 
117
117
  Every response mode surfaces the same tagged error variants:
118
118
 
119
- | Tag | When |
120
- | ---------------- | ------------------------------------------------------------ |
121
- | `aspiError` | Any non-2xx response with no matching custom handler |
122
- | `jsonParseError` | Response body could not be parsed as JSON |
123
- | `parseError` | Response failed schema validation (when `.schema()` is used) |
124
- | _custom_ | Any tag you define via `.error()` or a convenience shortcut |
119
+ | Tag | When |
120
+ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- |
121
+ | `aspiError` | Any non-2xx response with no matching custom handler |
122
+ | `jsonParseError` | Response body could not be parsed as JSON |
123
+ | `schemaParseError` | Response, request body, or query params failed schema validation (when `.schema()`, `.bodySchema()`, or `.querySchema()` is used) |
124
+ | _custom_ | Any tag you define via `.error()` or a convenience shortcut |
125
125
 
126
126
  ### Custom error mapping
127
127
 
@@ -228,7 +228,7 @@ const [data, error] = await api
228
228
  .bodyJson({ name: 'Alice', email: 'alice@example.com' })
229
229
  .json<User>();
230
230
 
231
- // If bodyJson fails validation, error.tag === 'parseError'
231
+ // If bodyJson fails validation, error.tag === 'schemaParseError'
232
232
  ```
233
233
 
234
234
  ### Query parameters
@@ -250,6 +250,39 @@ console.log(api.get('/todos').setQueryParams({ page: '2' }).url());
250
250
  // → https://api.example.com/todos?page=2
251
251
  ```
252
252
 
253
+ #### Query schema validation
254
+
255
+ Just like request bodies, query parameters can be validated with `.querySchema()`. The schema transforms the input and `setQueryParams()` is typed to accept only the schema's input shape.
256
+
257
+ ```ts
258
+ import { z } from 'zod';
259
+
260
+ const ListTodosQuery = z.object({
261
+ page: z.number().default(1),
262
+ limit: z.number().default(10),
263
+ sort: z.enum(['asc', 'desc']).default('asc'),
264
+ });
265
+
266
+ const result = await api
267
+ .get('/todos')
268
+ .querySchema(ListTodosQuery)
269
+ .setQueryParams({ page: 1, limit: 20 })
270
+ .withResult()
271
+ .json();
272
+
273
+ // If validation fails, error.tag === 'schemaParseError' and no network call is made
274
+ Result.match(result, {
275
+ onOk: ({ data }) => console.log(data),
276
+ onErr: (err) => {
277
+ if (err.tag === 'schemaParseError') {
278
+ console.error('Invalid query params:', err.data);
279
+ }
280
+ },
281
+ });
282
+ ```
283
+
284
+ If the schema transforms values (e.g. applying defaults or coercion), the transformed output is what gets serialized into the URL. Async schema validators are also supported.
285
+
253
286
  ### Headers
254
287
 
255
288
  ```ts
@@ -309,6 +342,8 @@ Aspi integrates with any library that implements the [StandardSchemaV1](https://
309
342
 
310
343
  Attach a schema with `.schema()` before the body-parser. The inferred output type is used automatically — you don't need to pass a generic.
311
344
 
345
+ Aspi supports both **synchronous and asynchronous** `validate` implementations, so schema libraries that return `Promise<Result>` work out of the box.
346
+
312
347
  ```ts
313
348
  import { z } from 'zod';
314
349
 
@@ -323,13 +358,15 @@ const result = await api.get('/todos/1').withResult().schema(TodoSchema).json();
323
358
  Result.match(result, {
324
359
  onOk: ({ data }) => console.log(data.title), // data: { id: number; title: string; completed: boolean }
325
360
  onErr: (err) => {
326
- if (err.tag === 'parseError') {
361
+ if (err.tag === 'schemaParseError') {
327
362
  console.error('Validation failed:', err.data); // StandardSchemaV1 issue list
328
363
  }
329
364
  },
330
365
  });
331
366
  ```
332
367
 
368
+ > `.querySchema()` and `.bodySchema()` work the same way — they validate at the edge of the request builder and short-circuit the network call on failure, producing a `schemaParseError` in the error union.
369
+
333
370
  ---
334
371
 
335
372
  ## Middleware
@@ -530,9 +567,10 @@ These methods are available on the `Aspi` instance and affect all requests creat
530
567
  | `useCapability(cap)` | Register a capability |
531
568
  | `withResult()` | Switch all requests to Result mode |
532
569
  | `throwable()` | Switch all requests to throwable mode |
570
+ | `withTuple()` | Switch all requests back to tuple mode |
533
571
  | `.error(tag, status, cb)` | Map an HTTP status to a typed error |
534
572
 
535
- Per-request methods (`api.get('/…').setQueryParams(…)`, `.schema(…)`, `.bodyJson(…)`, etc.) override the global config for that call only.
573
+ Per-request methods (`api.get('/…').setQueryParams(…)`, `.schema(…)`, `.querySchema(…)`, `.bodyJson(…)`, etc.) override the global config for that call only.
536
574
 
537
575
  ---
538
576
 
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: () => Aspi,
23
+ Aspi: () => Aspi2,
24
24
  AspiError: () => AspiError,
25
25
  CustomError: () => CustomError,
26
26
  Request: () => Request,
@@ -325,6 +325,12 @@ var Request = class {
325
325
  #timeoutMs;
326
326
  #shouldBeResult = false;
327
327
  #bodySchemaIssues = [];
328
+ #bodySchemaAsyncResult = null;
329
+ #bodySchemaAsyncBody = null;
330
+ #querySchema = null;
331
+ #querySchemaIssues = [];
332
+ #querySchemaAsyncResult = null;
333
+ #querySchemaAsyncParams = null;
328
334
  #throwOnError = false;
329
335
  #capabilities = [];
330
336
  constructor(method, path, requestOptions, capabilities = []) {
@@ -478,9 +484,9 @@ var Request = class {
478
484
  if (this.#bodySchema) {
479
485
  const data = this.#bodySchema["~standard"].validate(body);
480
486
  if (data instanceof Promise) {
481
- throw new Error("Schema validation should not return a promise");
482
- }
483
- if (data.issues) {
487
+ this.#bodySchemaAsyncResult = data;
488
+ this.#bodySchemaAsyncBody = body;
489
+ } else if (data.issues) {
484
490
  this.#bodySchemaIssues = data.issues;
485
491
  } else {
486
492
  this.#localRequestInit.body = JSON.stringify(data.value);
@@ -677,6 +683,26 @@ var Request = class {
677
683
  };
678
684
  return this;
679
685
  }
686
+ /**
687
+ * Sets a validation schema for the query parameters using a StandardSchemaV1 schema.
688
+ * @template TSchema Type parameter extending StandardSchemaV1
689
+ * @param schema The schema to validate query parameters against
690
+ * @returns The request instance for chaining with updated error type
691
+ * @example
692
+ * const querySchema = z.object({
693
+ * page: z.number(),
694
+ * limit: z.number()
695
+ * });
696
+ *
697
+ * const request = new Request('/users', config);
698
+ * request
699
+ * .querySchema(querySchema)
700
+ * .setQueryParams({ page: 1, limit: 10 });
701
+ */
702
+ querySchema(schema) {
703
+ this.#querySchema = schema;
704
+ return this;
705
+ }
680
706
  /**
681
707
  * Sets the query parameters for the request URL.
682
708
  *
@@ -711,29 +737,19 @@ var Request = class {
711
737
  * request.setQueryParams(qp);
712
738
  */
713
739
  setQueryParams(params) {
714
- let qp;
715
- if (params instanceof URLSearchParams) {
716
- qp = new URLSearchParams(params);
717
- } else if (typeof params === "string") {
718
- qp = new URLSearchParams(params);
719
- } else if (Array.isArray(params)) {
720
- qp = new URLSearchParams();
721
- for (const entry of params) {
722
- if (Array.isArray(entry) && entry.length === 2) {
723
- qp.append(String(entry[0]), String(entry[1]));
724
- }
725
- }
726
- } else if (typeof params === "object" && params !== null) {
727
- qp = new URLSearchParams();
728
- for (const [key, value] of Object.entries(
729
- params
730
- )) {
731
- qp.append(key, String(value));
740
+ if (this.#querySchema) {
741
+ const data = this.#querySchema["~standard"].validate(params);
742
+ if (data instanceof Promise) {
743
+ this.#querySchemaAsyncResult = data;
744
+ this.#querySchemaAsyncParams = params;
745
+ } else if (data.issues) {
746
+ this.#querySchemaIssues = data.issues;
747
+ } else {
748
+ this.#queryParams = this.#valueToQueryParams(data.value);
732
749
  }
733
750
  } else {
734
- qp = new URLSearchParams();
751
+ this.#queryParams = this.#valueToQueryParams(params);
735
752
  }
736
- this.#queryParams = qp;
737
753
  return this;
738
754
  }
739
755
  /**
@@ -1059,6 +1075,33 @@ var Request = class {
1059
1075
  );
1060
1076
  return this.#mapResponse(output);
1061
1077
  }
1078
+ #valueToQueryParams(value) {
1079
+ if (value instanceof URLSearchParams) {
1080
+ return new URLSearchParams(value);
1081
+ }
1082
+ if (typeof value === "string") {
1083
+ return new URLSearchParams(value);
1084
+ }
1085
+ if (Array.isArray(value)) {
1086
+ const qp = new URLSearchParams();
1087
+ for (const entry of value) {
1088
+ if (Array.isArray(entry) && entry.length === 2) {
1089
+ qp.append(String(entry[0]), String(entry[1]));
1090
+ }
1091
+ }
1092
+ return qp;
1093
+ }
1094
+ if (typeof value === "object" && value !== null) {
1095
+ const qp = new URLSearchParams();
1096
+ for (const [key, val] of Object.entries(
1097
+ value
1098
+ )) {
1099
+ qp.append(key, String(val));
1100
+ }
1101
+ return qp;
1102
+ }
1103
+ return new URLSearchParams();
1104
+ }
1062
1105
  #url() {
1063
1106
  if (this.#path.startsWith("http://") || this.#path.startsWith("https://")) {
1064
1107
  const absolute = new URL(this.#path);
@@ -1158,6 +1201,49 @@ var Request = class {
1158
1201
  this.#shouldBeResult = true;
1159
1202
  return this;
1160
1203
  }
1204
+ /**
1205
+ * Switches the request into **tuple** mode (the default).
1206
+ *
1207
+ * In tuple mode the response helpers (`json`, `text`, `blob`, …) resolve to a
1208
+ * tuple `[value, error]` where exactly one element is non‑null. This is useful
1209
+ * when you want an explicit opt‑in method to reset from `withResult()` or
1210
+ * `throwable()` back to the default tuple behaviour.
1211
+ *
1212
+ * Calling `withTuple` disables both Result mode and throwable mode.
1213
+ *
1214
+ * @returns {Request<
1215
+ * Method,
1216
+ * TRequest,
1217
+ * Merge<
1218
+ * Omit<Opts, 'withResult' | 'throwable'>,
1219
+ * {
1220
+ * withResult: false;
1221
+ * throwable: false;
1222
+ * }
1223
+ * >
1224
+ * >} The same {@link Request} instance, now typed with `withResult: false` and
1225
+ * `throwable: false` for fluent chaining.
1226
+ *
1227
+ * @example
1228
+ * ```ts
1229
+ * const request = new Request('/users', config);
1230
+ *
1231
+ * const [user, err] = await request
1232
+ * .withTuple() // explicitly use tuple mode
1233
+ * .json<User>();
1234
+ *
1235
+ * if (err) {
1236
+ * console.error(err);
1237
+ * } else {
1238
+ * console.log(user);
1239
+ * }
1240
+ * ```
1241
+ */
1242
+ withTuple() {
1243
+ this.#throwOnError = false;
1244
+ this.#shouldBeResult = false;
1245
+ return this;
1246
+ }
1161
1247
  #mapResponse(value) {
1162
1248
  if (this.#shouldBeResult) {
1163
1249
  return value;
@@ -1175,11 +1261,36 @@ var Request = class {
1175
1261
  return response.ok || response.status >= 300 && response.status < 400;
1176
1262
  }
1177
1263
  async #makeRequest(responseParser, isJson = false) {
1264
+ if (this.#bodySchemaAsyncResult) {
1265
+ const data = await this.#bodySchemaAsyncResult;
1266
+ if (data.issues) {
1267
+ this.#bodySchemaIssues = data.issues;
1268
+ } else {
1269
+ this.#localRequestInit.body = JSON.stringify(data.value);
1270
+ }
1271
+ this.#bodySchemaAsyncResult = null;
1272
+ this.#bodySchemaAsyncBody = null;
1273
+ }
1178
1274
  if (this.#bodySchemaIssues.length) {
1179
1275
  return err(
1180
1276
  new CustomError("schemaParseError", this.#bodySchemaIssues)
1181
1277
  );
1182
1278
  }
1279
+ if (this.#querySchemaAsyncResult) {
1280
+ const data = await this.#querySchemaAsyncResult;
1281
+ if (data.issues) {
1282
+ this.#querySchemaIssues = data.issues;
1283
+ } else {
1284
+ this.#queryParams = this.#valueToQueryParams(data.value);
1285
+ }
1286
+ this.#querySchemaAsyncResult = null;
1287
+ this.#querySchemaAsyncParams = null;
1288
+ }
1289
+ if (this.#querySchemaIssues.length) {
1290
+ return err(
1291
+ new CustomError("schemaParseError", this.#querySchemaIssues)
1292
+ );
1293
+ }
1183
1294
  const request = this.#request();
1184
1295
  const { retries, retryDelay, retryOn, retryWhile, onRetry } = this.#sanitisedRetryConfig();
1185
1296
  try {
@@ -1312,10 +1423,7 @@ var Request = class {
1312
1423
  );
1313
1424
  }
1314
1425
  if (isJson && this.#schema) {
1315
- const data = this.#schema["~standard"].validate(responseData);
1316
- if (data instanceof Promise) {
1317
- throw new Error("Schema validation should not return a promise");
1318
- }
1426
+ const data = await this.#schema["~standard"].validate(responseData);
1319
1427
  if (data.issues) {
1320
1428
  return err(new CustomError("schemaParseError", data.issues));
1321
1429
  }
@@ -1527,7 +1635,7 @@ var Request = class {
1527
1635
  };
1528
1636
 
1529
1637
  // src/aspi.ts
1530
- var Aspi = class {
1638
+ var Aspi2 = class {
1531
1639
  #globalRequestInit;
1532
1640
  #middlewares = [];
1533
1641
  #retryConfig;
@@ -1887,6 +1995,20 @@ var Aspi = class {
1887
1995
  this.#throwOnError = false;
1888
1996
  return this;
1889
1997
  }
1998
+ /**
1999
+ * Configures all subsequent requests to return the default tuple `[value, error]`.
2000
+ *
2001
+ * This is the default behaviour, but `withTuple()` is provided as an explicit
2002
+ * reset when you have previously called {@link withResult} or {@link throwable}
2003
+ * on the {@link Aspi} instance.
2004
+ *
2005
+ * @returns The Aspi instance with tuple handling enabled.
2006
+ */
2007
+ withTuple() {
2008
+ this.#shouldBeResult = false;
2009
+ this.#throwOnError = false;
2010
+ return this;
2011
+ }
1890
2012
  /**
1891
2013
  * Registers a capability on this {@link Aspi} instance.
1892
2014
  *
package/dist/index.d.cts CHANGED
@@ -1139,6 +1139,28 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1139
1139
  error<Tag extends string, A extends {}>(tag: Tag, status: HttpErrorStatus, cb: CustomErrorCb<TRequest, A>): Request<Method, TRequest, Merge<Omit<Opts, "error">, {
1140
1140
  error: { [K in Tag | keyof Opts["error"]]: K extends Tag ? CustomError<Tag, A> : Opts["error"][K]; };
1141
1141
  }>>;
1142
+ /**
1143
+ * Sets a validation schema for the query parameters using a StandardSchemaV1 schema.
1144
+ * @template TSchema Type parameter extending StandardSchemaV1
1145
+ * @param schema The schema to validate query parameters against
1146
+ * @returns The request instance for chaining with updated error type
1147
+ * @example
1148
+ * const querySchema = z.object({
1149
+ * page: z.number(),
1150
+ * limit: z.number()
1151
+ * });
1152
+ *
1153
+ * const request = new Request('/users', config);
1154
+ * request
1155
+ * .querySchema(querySchema)
1156
+ * .setQueryParams({ page: 1, limit: 10 });
1157
+ */
1158
+ querySchema<TSchema extends StandardSchemaV1>(schema: TSchema): Request<Method, TRequest, Omit<Opts, "querySchema"> & {
1159
+ querySchema: TSchema;
1160
+ error: Opts["error"] & {
1161
+ schemaParseError: CustomError<"schemaParseError", StandardSchemaV1.FailureResult["issues"]>;
1162
+ };
1163
+ }>;
1142
1164
  /**
1143
1165
  * Sets the query parameters for the request URL.
1144
1166
  *
@@ -1172,7 +1194,9 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1172
1194
  * const qp = new URLSearchParams({ page: '1' });
1173
1195
  * request.setQueryParams(qp);
1174
1196
  */
1175
- setQueryParams<T = any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1197
+ setQueryParams<T extends Opts extends {
1198
+ querySchema: infer S extends StandardSchemaV1;
1199
+ } ? StandardSchemaV1.InferInput<S> : any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1176
1200
  queryParams: T;
1177
1201
  }>>;
1178
1202
  /**
@@ -1564,6 +1588,48 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1564
1588
  withResult: true;
1565
1589
  throwable: false;
1566
1590
  }>>;
1591
+ /**
1592
+ * Switches the request into **tuple** mode (the default).
1593
+ *
1594
+ * In tuple mode the response helpers (`json`, `text`, `blob`, …) resolve to a
1595
+ * tuple `[value, error]` where exactly one element is non‑null. This is useful
1596
+ * when you want an explicit opt‑in method to reset from `withResult()` or
1597
+ * `throwable()` back to the default tuple behaviour.
1598
+ *
1599
+ * Calling `withTuple` disables both Result mode and throwable mode.
1600
+ *
1601
+ * @returns {Request<
1602
+ * Method,
1603
+ * TRequest,
1604
+ * Merge<
1605
+ * Omit<Opts, 'withResult' | 'throwable'>,
1606
+ * {
1607
+ * withResult: false;
1608
+ * throwable: false;
1609
+ * }
1610
+ * >
1611
+ * >} The same {@link Request} instance, now typed with `withResult: false` and
1612
+ * `throwable: false` for fluent chaining.
1613
+ *
1614
+ * @example
1615
+ * ```ts
1616
+ * const request = new Request('/users', config);
1617
+ *
1618
+ * const [user, err] = await request
1619
+ * .withTuple() // explicitly use tuple mode
1620
+ * .json<User>();
1621
+ *
1622
+ * if (err) {
1623
+ * console.error(err);
1624
+ * } else {
1625
+ * console.log(user);
1626
+ * }
1627
+ * ```
1628
+ */
1629
+ withTuple(): Request<Method, TRequest, Merge<Omit<Opts, "withResult" | "throwable">, {
1630
+ withResult: false;
1631
+ throwable: false;
1632
+ }>>;
1567
1633
  /**
1568
1634
  * Returns the underlying {@link AspiRequest} object that will be used for the fetch call.
1569
1635
  *
@@ -1967,6 +2033,19 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1967
2033
  withResult: true;
1968
2034
  throwable: false;
1969
2035
  }>>;
2036
+ /**
2037
+ * Configures all subsequent requests to return the default tuple `[value, error]`.
2038
+ *
2039
+ * This is the default behaviour, but `withTuple()` is provided as an explicit
2040
+ * reset when you have previously called {@link withResult} or {@link throwable}
2041
+ * on the {@link Aspi} instance.
2042
+ *
2043
+ * @returns The Aspi instance with tuple handling enabled.
2044
+ */
2045
+ withTuple(): Aspi<TRequest, Merge<Omit<Opts, "withResult" | "throwable">, {
2046
+ withResult: false;
2047
+ throwable: false;
2048
+ }>>;
1970
2049
  /**
1971
2050
  * Registers a capability on this {@link Aspi} instance.
1972
2051
  *
package/dist/index.d.ts CHANGED
@@ -1139,6 +1139,28 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1139
1139
  error<Tag extends string, A extends {}>(tag: Tag, status: HttpErrorStatus, cb: CustomErrorCb<TRequest, A>): Request<Method, TRequest, Merge<Omit<Opts, "error">, {
1140
1140
  error: { [K in Tag | keyof Opts["error"]]: K extends Tag ? CustomError<Tag, A> : Opts["error"][K]; };
1141
1141
  }>>;
1142
+ /**
1143
+ * Sets a validation schema for the query parameters using a StandardSchemaV1 schema.
1144
+ * @template TSchema Type parameter extending StandardSchemaV1
1145
+ * @param schema The schema to validate query parameters against
1146
+ * @returns The request instance for chaining with updated error type
1147
+ * @example
1148
+ * const querySchema = z.object({
1149
+ * page: z.number(),
1150
+ * limit: z.number()
1151
+ * });
1152
+ *
1153
+ * const request = new Request('/users', config);
1154
+ * request
1155
+ * .querySchema(querySchema)
1156
+ * .setQueryParams({ page: 1, limit: 10 });
1157
+ */
1158
+ querySchema<TSchema extends StandardSchemaV1>(schema: TSchema): Request<Method, TRequest, Omit<Opts, "querySchema"> & {
1159
+ querySchema: TSchema;
1160
+ error: Opts["error"] & {
1161
+ schemaParseError: CustomError<"schemaParseError", StandardSchemaV1.FailureResult["issues"]>;
1162
+ };
1163
+ }>;
1142
1164
  /**
1143
1165
  * Sets the query parameters for the request URL.
1144
1166
  *
@@ -1172,7 +1194,9 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1172
1194
  * const qp = new URLSearchParams({ page: '1' });
1173
1195
  * request.setQueryParams(qp);
1174
1196
  */
1175
- setQueryParams<T = any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1197
+ setQueryParams<T extends Opts extends {
1198
+ querySchema: infer S extends StandardSchemaV1;
1199
+ } ? StandardSchemaV1.InferInput<S> : any>(params: T): Request<Method, TRequest, Merge<Omit<Opts, "queryParams">, {
1176
1200
  queryParams: T;
1177
1201
  }>>;
1178
1202
  /**
@@ -1564,6 +1588,48 @@ declare class Request<Method extends HttpMethods, TRequest extends AspiRequestIn
1564
1588
  withResult: true;
1565
1589
  throwable: false;
1566
1590
  }>>;
1591
+ /**
1592
+ * Switches the request into **tuple** mode (the default).
1593
+ *
1594
+ * In tuple mode the response helpers (`json`, `text`, `blob`, …) resolve to a
1595
+ * tuple `[value, error]` where exactly one element is non‑null. This is useful
1596
+ * when you want an explicit opt‑in method to reset from `withResult()` or
1597
+ * `throwable()` back to the default tuple behaviour.
1598
+ *
1599
+ * Calling `withTuple` disables both Result mode and throwable mode.
1600
+ *
1601
+ * @returns {Request<
1602
+ * Method,
1603
+ * TRequest,
1604
+ * Merge<
1605
+ * Omit<Opts, 'withResult' | 'throwable'>,
1606
+ * {
1607
+ * withResult: false;
1608
+ * throwable: false;
1609
+ * }
1610
+ * >
1611
+ * >} The same {@link Request} instance, now typed with `withResult: false` and
1612
+ * `throwable: false` for fluent chaining.
1613
+ *
1614
+ * @example
1615
+ * ```ts
1616
+ * const request = new Request('/users', config);
1617
+ *
1618
+ * const [user, err] = await request
1619
+ * .withTuple() // explicitly use tuple mode
1620
+ * .json<User>();
1621
+ *
1622
+ * if (err) {
1623
+ * console.error(err);
1624
+ * } else {
1625
+ * console.log(user);
1626
+ * }
1627
+ * ```
1628
+ */
1629
+ withTuple(): Request<Method, TRequest, Merge<Omit<Opts, "withResult" | "throwable">, {
1630
+ withResult: false;
1631
+ throwable: false;
1632
+ }>>;
1567
1633
  /**
1568
1634
  * Returns the underlying {@link AspiRequest} object that will be used for the fetch call.
1569
1635
  *
@@ -1967,6 +2033,19 @@ declare class Aspi<TRequest extends AspiRequestInit = AspiRequestInit, Opts exte
1967
2033
  withResult: true;
1968
2034
  throwable: false;
1969
2035
  }>>;
2036
+ /**
2037
+ * Configures all subsequent requests to return the default tuple `[value, error]`.
2038
+ *
2039
+ * This is the default behaviour, but `withTuple()` is provided as an explicit
2040
+ * reset when you have previously called {@link withResult} or {@link throwable}
2041
+ * on the {@link Aspi} instance.
2042
+ *
2043
+ * @returns The Aspi instance with tuple handling enabled.
2044
+ */
2045
+ withTuple(): Aspi<TRequest, Merge<Omit<Opts, "withResult" | "throwable">, {
2046
+ withResult: false;
2047
+ throwable: false;
2048
+ }>>;
1970
2049
  /**
1971
2050
  * Registers a capability on this {@link Aspi} instance.
1972
2051
  *
package/dist/index.js CHANGED
@@ -295,6 +295,12 @@ var Request = class {
295
295
  #timeoutMs;
296
296
  #shouldBeResult = false;
297
297
  #bodySchemaIssues = [];
298
+ #bodySchemaAsyncResult = null;
299
+ #bodySchemaAsyncBody = null;
300
+ #querySchema = null;
301
+ #querySchemaIssues = [];
302
+ #querySchemaAsyncResult = null;
303
+ #querySchemaAsyncParams = null;
298
304
  #throwOnError = false;
299
305
  #capabilities = [];
300
306
  constructor(method, path, requestOptions, capabilities = []) {
@@ -448,9 +454,9 @@ var Request = class {
448
454
  if (this.#bodySchema) {
449
455
  const data = this.#bodySchema["~standard"].validate(body);
450
456
  if (data instanceof Promise) {
451
- throw new Error("Schema validation should not return a promise");
452
- }
453
- if (data.issues) {
457
+ this.#bodySchemaAsyncResult = data;
458
+ this.#bodySchemaAsyncBody = body;
459
+ } else if (data.issues) {
454
460
  this.#bodySchemaIssues = data.issues;
455
461
  } else {
456
462
  this.#localRequestInit.body = JSON.stringify(data.value);
@@ -647,6 +653,26 @@ var Request = class {
647
653
  };
648
654
  return this;
649
655
  }
656
+ /**
657
+ * Sets a validation schema for the query parameters using a StandardSchemaV1 schema.
658
+ * @template TSchema Type parameter extending StandardSchemaV1
659
+ * @param schema The schema to validate query parameters against
660
+ * @returns The request instance for chaining with updated error type
661
+ * @example
662
+ * const querySchema = z.object({
663
+ * page: z.number(),
664
+ * limit: z.number()
665
+ * });
666
+ *
667
+ * const request = new Request('/users', config);
668
+ * request
669
+ * .querySchema(querySchema)
670
+ * .setQueryParams({ page: 1, limit: 10 });
671
+ */
672
+ querySchema(schema) {
673
+ this.#querySchema = schema;
674
+ return this;
675
+ }
650
676
  /**
651
677
  * Sets the query parameters for the request URL.
652
678
  *
@@ -681,29 +707,19 @@ var Request = class {
681
707
  * request.setQueryParams(qp);
682
708
  */
683
709
  setQueryParams(params) {
684
- let qp;
685
- if (params instanceof URLSearchParams) {
686
- qp = new URLSearchParams(params);
687
- } else if (typeof params === "string") {
688
- qp = new URLSearchParams(params);
689
- } else if (Array.isArray(params)) {
690
- qp = new URLSearchParams();
691
- for (const entry of params) {
692
- if (Array.isArray(entry) && entry.length === 2) {
693
- qp.append(String(entry[0]), String(entry[1]));
694
- }
695
- }
696
- } else if (typeof params === "object" && params !== null) {
697
- qp = new URLSearchParams();
698
- for (const [key, value] of Object.entries(
699
- params
700
- )) {
701
- qp.append(key, String(value));
710
+ if (this.#querySchema) {
711
+ const data = this.#querySchema["~standard"].validate(params);
712
+ if (data instanceof Promise) {
713
+ this.#querySchemaAsyncResult = data;
714
+ this.#querySchemaAsyncParams = params;
715
+ } else if (data.issues) {
716
+ this.#querySchemaIssues = data.issues;
717
+ } else {
718
+ this.#queryParams = this.#valueToQueryParams(data.value);
702
719
  }
703
720
  } else {
704
- qp = new URLSearchParams();
721
+ this.#queryParams = this.#valueToQueryParams(params);
705
722
  }
706
- this.#queryParams = qp;
707
723
  return this;
708
724
  }
709
725
  /**
@@ -1029,6 +1045,33 @@ var Request = class {
1029
1045
  );
1030
1046
  return this.#mapResponse(output);
1031
1047
  }
1048
+ #valueToQueryParams(value) {
1049
+ if (value instanceof URLSearchParams) {
1050
+ return new URLSearchParams(value);
1051
+ }
1052
+ if (typeof value === "string") {
1053
+ return new URLSearchParams(value);
1054
+ }
1055
+ if (Array.isArray(value)) {
1056
+ const qp = new URLSearchParams();
1057
+ for (const entry of value) {
1058
+ if (Array.isArray(entry) && entry.length === 2) {
1059
+ qp.append(String(entry[0]), String(entry[1]));
1060
+ }
1061
+ }
1062
+ return qp;
1063
+ }
1064
+ if (typeof value === "object" && value !== null) {
1065
+ const qp = new URLSearchParams();
1066
+ for (const [key, val] of Object.entries(
1067
+ value
1068
+ )) {
1069
+ qp.append(key, String(val));
1070
+ }
1071
+ return qp;
1072
+ }
1073
+ return new URLSearchParams();
1074
+ }
1032
1075
  #url() {
1033
1076
  if (this.#path.startsWith("http://") || this.#path.startsWith("https://")) {
1034
1077
  const absolute = new URL(this.#path);
@@ -1128,6 +1171,49 @@ var Request = class {
1128
1171
  this.#shouldBeResult = true;
1129
1172
  return this;
1130
1173
  }
1174
+ /**
1175
+ * Switches the request into **tuple** mode (the default).
1176
+ *
1177
+ * In tuple mode the response helpers (`json`, `text`, `blob`, …) resolve to a
1178
+ * tuple `[value, error]` where exactly one element is non‑null. This is useful
1179
+ * when you want an explicit opt‑in method to reset from `withResult()` or
1180
+ * `throwable()` back to the default tuple behaviour.
1181
+ *
1182
+ * Calling `withTuple` disables both Result mode and throwable mode.
1183
+ *
1184
+ * @returns {Request<
1185
+ * Method,
1186
+ * TRequest,
1187
+ * Merge<
1188
+ * Omit<Opts, 'withResult' | 'throwable'>,
1189
+ * {
1190
+ * withResult: false;
1191
+ * throwable: false;
1192
+ * }
1193
+ * >
1194
+ * >} The same {@link Request} instance, now typed with `withResult: false` and
1195
+ * `throwable: false` for fluent chaining.
1196
+ *
1197
+ * @example
1198
+ * ```ts
1199
+ * const request = new Request('/users', config);
1200
+ *
1201
+ * const [user, err] = await request
1202
+ * .withTuple() // explicitly use tuple mode
1203
+ * .json<User>();
1204
+ *
1205
+ * if (err) {
1206
+ * console.error(err);
1207
+ * } else {
1208
+ * console.log(user);
1209
+ * }
1210
+ * ```
1211
+ */
1212
+ withTuple() {
1213
+ this.#throwOnError = false;
1214
+ this.#shouldBeResult = false;
1215
+ return this;
1216
+ }
1131
1217
  #mapResponse(value) {
1132
1218
  if (this.#shouldBeResult) {
1133
1219
  return value;
@@ -1145,11 +1231,36 @@ var Request = class {
1145
1231
  return response.ok || response.status >= 300 && response.status < 400;
1146
1232
  }
1147
1233
  async #makeRequest(responseParser, isJson = false) {
1234
+ if (this.#bodySchemaAsyncResult) {
1235
+ const data = await this.#bodySchemaAsyncResult;
1236
+ if (data.issues) {
1237
+ this.#bodySchemaIssues = data.issues;
1238
+ } else {
1239
+ this.#localRequestInit.body = JSON.stringify(data.value);
1240
+ }
1241
+ this.#bodySchemaAsyncResult = null;
1242
+ this.#bodySchemaAsyncBody = null;
1243
+ }
1148
1244
  if (this.#bodySchemaIssues.length) {
1149
1245
  return err(
1150
1246
  new CustomError("schemaParseError", this.#bodySchemaIssues)
1151
1247
  );
1152
1248
  }
1249
+ if (this.#querySchemaAsyncResult) {
1250
+ const data = await this.#querySchemaAsyncResult;
1251
+ if (data.issues) {
1252
+ this.#querySchemaIssues = data.issues;
1253
+ } else {
1254
+ this.#queryParams = this.#valueToQueryParams(data.value);
1255
+ }
1256
+ this.#querySchemaAsyncResult = null;
1257
+ this.#querySchemaAsyncParams = null;
1258
+ }
1259
+ if (this.#querySchemaIssues.length) {
1260
+ return err(
1261
+ new CustomError("schemaParseError", this.#querySchemaIssues)
1262
+ );
1263
+ }
1153
1264
  const request = this.#request();
1154
1265
  const { retries, retryDelay, retryOn, retryWhile, onRetry } = this.#sanitisedRetryConfig();
1155
1266
  try {
@@ -1282,10 +1393,7 @@ var Request = class {
1282
1393
  );
1283
1394
  }
1284
1395
  if (isJson && this.#schema) {
1285
- const data = this.#schema["~standard"].validate(responseData);
1286
- if (data instanceof Promise) {
1287
- throw new Error("Schema validation should not return a promise");
1288
- }
1396
+ const data = await this.#schema["~standard"].validate(responseData);
1289
1397
  if (data.issues) {
1290
1398
  return err(new CustomError("schemaParseError", data.issues));
1291
1399
  }
@@ -1497,7 +1605,7 @@ var Request = class {
1497
1605
  };
1498
1606
 
1499
1607
  // src/aspi.ts
1500
- var Aspi = class {
1608
+ var Aspi2 = class {
1501
1609
  #globalRequestInit;
1502
1610
  #middlewares = [];
1503
1611
  #retryConfig;
@@ -1857,6 +1965,20 @@ var Aspi = class {
1857
1965
  this.#throwOnError = false;
1858
1966
  return this;
1859
1967
  }
1968
+ /**
1969
+ * Configures all subsequent requests to return the default tuple `[value, error]`.
1970
+ *
1971
+ * This is the default behaviour, but `withTuple()` is provided as an explicit
1972
+ * reset when you have previously called {@link withResult} or {@link throwable}
1973
+ * on the {@link Aspi} instance.
1974
+ *
1975
+ * @returns The Aspi instance with tuple handling enabled.
1976
+ */
1977
+ withTuple() {
1978
+ this.#shouldBeResult = false;
1979
+ this.#throwOnError = false;
1980
+ return this;
1981
+ }
1860
1982
  /**
1861
1983
  * Registers a capability on this {@link Aspi} instance.
1862
1984
  *
@@ -1900,7 +2022,7 @@ var Aspi = class {
1900
2022
  }
1901
2023
  };
1902
2024
  export {
1903
- Aspi,
2025
+ Aspi2 as Aspi,
1904
2026
  AspiError,
1905
2027
  CustomError,
1906
2028
  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.7.0",
4
+ "version": "2.9.0",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
7
7
  "devDependencies": {