@autometa/http 1.2.0 → 1.3.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/src/http.ts ADDED
@@ -0,0 +1,867 @@
1
+ import { Fixture, INJECTION_SCOPE } from "@autometa/injection";
2
+ import { AxiosClient } from "./axios-client";
3
+ import { HTTPClient } from "./http-client";
4
+ import { defaultClientFactory } from "./default-client-factory";
5
+ import { HTTPRequest, HTTPRequestBuilder } from "./http-request";
6
+ import { HTTPResponse } from "./http-response";
7
+ import { MetaConfig, MetaConfigBuilder } from "./request-meta.config";
8
+ import {
9
+ HTTPAdditionalOptions,
10
+ RequestHook,
11
+ ResponseHook,
12
+ SchemaParser,
13
+ StatusCode
14
+ } from "./types";
15
+ import { transformResponse } from "./transform-response";
16
+ import { AutomationError } from "@autometa/errors";
17
+
18
+ /**
19
+ * The HTTP fixture allows requests to be built and sent to a server. In general,
20
+ * there are 2 modes of operation:
21
+ *
22
+ * * Shared Chain: The shared chain is used to configure the client for all requests, such as
23
+ * routes this instance will always be used. When a shared chain method is called, it returns
24
+ * the same instance of HTTP which can be further chained to configure the client.
25
+ * * Request Chain: The request chain is used to configure a single request, inheriting values
26
+ * set by the shared chain. When called, a new HTTP client instance is created and inherits the values
27
+ * set by it's parent.
28
+ *
29
+ * The 2 modes are intended to simplify configuring an object through an inheritance chain. For example,
30
+ * assume we have an API with 2 controller routes, `/product` and `/seller`. We can set up a Base Client
31
+ * which consumes the HTTP fixture and configures it with the base url of our API.
32
+ *
33
+ * Inheritors can further configure their HTTP instance's routes.
34
+ *
35
+ * ```ts
36
+ * \@Constructor(HTTP)
37
+ * export class BaseClient {
38
+ * constructor(protected readonly http: HTTP) {
39
+ * this.http.url("https://api.example.com");
40
+ * }
41
+ * }
42
+ *
43
+ * export class ProductClient extends BaseClient {
44
+ * constructor(http: HTTP) {
45
+ * super(http);
46
+ * this.http.sharedRoute("product");
47
+ * }
48
+ * getProduct(id: number) {
49
+ * return this.http.route(id).get();
50
+ * }
51
+ *
52
+ * export class SellerClient extends BaseClient {
53
+ * constructor(http: HTTP) {
54
+ * super(http);
55
+ * this.http.sharedRoute("seller");
56
+ * }
57
+ *
58
+ * getSeller(id: number) {
59
+ * return this.http.route(id).get();
60
+ * }
61
+ * }
62
+ * ```
63
+ *
64
+ * 'Schemas' can also be configured. A Schema is a function or an object with a `parse` method, which
65
+ * takes a response data payload and returns a validated object. Schemas are mapped to
66
+ * HTTP Status Codes, and if configured to be required the request will fail if no schema is found
67
+ * matching that code.
68
+ *
69
+ * Defining a schema function:
70
+ *
71
+ * ```
72
+ * // user.schema.ts
73
+ * export function UserSchema(data: unknown) {
74
+ * if(typeof data !== "object") {
75
+ * throw new Error("Expected an object");
76
+ * }
77
+ *
78
+ * if(typeof data.name !== "string") {
79
+ * throw new Error("Expected a string");
80
+ * }
81
+ *
82
+ * return data as { name: string };
83
+ * }
84
+ *
85
+ * // user.controller.ts
86
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
87
+ * export class UserController extends BaseController {
88
+ * constructor(private readonly http: HTTP) {
89
+ * super(http);
90
+ * this.http
91
+ * .sharedRoute("user")
92
+ * .sharedSchema(ErrorSchema, { from: 400, to: 499 });
93
+ * }
94
+ *
95
+ * getUser(id: number) {
96
+ * return this.http.route(id).schema(UserSchema, 200).get();
97
+ * // or
98
+ * return this.http
99
+ * .route(id)
100
+ * .schema(UserSchema, { from: 200, to: 299 })
101
+ * .get();
102
+ * // or
103
+ * return this.http
104
+ * .route(id)
105
+ * .schema(UserSchema, 200, 201, 202)
106
+ * .get();
107
+ * }
108
+ * }
109
+ * ```
110
+ *
111
+ * Validation libraries which use a `.parse` or `.validation`, method, such as Zod or MyZod, can also be used as schemas:
112
+ *
113
+ * ```ts
114
+ * // user.schema.ts
115
+ * import { z } from "myzod";
116
+ *
117
+ * export const UserSchema = z.object({
118
+ * name: z.string()
119
+ * });
120
+ *
121
+ * // user.controller.ts
122
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
123
+ * export class UserController extends BaseController {
124
+ * constructor(private readonly http: HTTP) {
125
+ * super(http);
126
+ * this.http
127
+ * .sharedRoute("user")
128
+ * .sharedSchema(ErrorSchema, { from: 400, to: 499 })
129
+ * }
130
+ *
131
+ * getUser(id: number) {
132
+ * return this.http.route(id).schema(UserSchema, 200).get();
133
+ * }
134
+ * }
135
+ * ```
136
+ */
137
+ @Fixture(INJECTION_SCOPE.TRANSIENT)
138
+ export class HTTP {
139
+ #request: HTTPRequestBuilder<HTTPRequest<unknown>>;
140
+ #metaConfig: MetaConfigBuilder;
141
+ constructor(
142
+ private readonly client: HTTPClient = defaultClientFactory(),
143
+ builder: HTTPRequestBuilder<
144
+ HTTPRequest<unknown>
145
+ > = new HTTPRequestBuilder(),
146
+ metaConfig: MetaConfigBuilder = new MetaConfigBuilder()
147
+ ) {
148
+ this.#request = builder.derive();
149
+ this.#metaConfig = metaConfig.derive();
150
+ }
151
+
152
+ static create(
153
+ client: HTTPClient = new AxiosClient(),
154
+ builder: HTTPRequestBuilder<
155
+ HTTPRequest<unknown>
156
+ > = new HTTPRequestBuilder(),
157
+ metaConfig: MetaConfigBuilder = new MetaConfigBuilder()
158
+ ) {
159
+ return new HTTP(client, builder, metaConfig);
160
+ }
161
+
162
+ /**
163
+ * Sets the base url of the request for this client, such as
164
+ * `https://api.example.com`, and could include always-used routes like
165
+ * the api version, such as `/v1` or `/api/v1` at the end.
166
+ *
167
+ * ```ts
168
+ *
169
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
170
+ * export abstract class BaseClient {
171
+ * constructor(protected readonly http: HTTP) {
172
+ * this.http.url("https://api.example.com");
173
+ * }
174
+ * }
175
+ * ```
176
+ * @param url
177
+ * @returns
178
+ */
179
+ url(url: string) {
180
+ this.#request.url(url);
181
+ return this;
182
+ }
183
+
184
+ /**
185
+ * If set to true, all requests derived from this client will require a schema be defined
186
+ * matching any response status code. If set to false, a schema will still be used for validation
187
+ * if defined, or the unadulterated original body will be returned if no schema matches.
188
+ *
189
+ * @param required Whether or not a schema is required for all responses.
190
+ * @returns This instance of HTTP.
191
+ */
192
+ requireSchema(required: boolean) {
193
+ this.#metaConfig.requireSchema(required);
194
+ return this;
195
+ }
196
+
197
+ /**
198
+ * If set to true, all requests derived from this client will allow plain text
199
+ * responses. If set to false, plain text responses will throw an serialization error.
200
+ *
201
+ * Useful when an endpoint returns a HTML or plain text response. If the plain text
202
+ * is the value of `true` or `false`, or a number, it will be parsed into the
203
+ * appropriate type.
204
+ *
205
+ * This method is a shared chain method, and will return the same instance of HTTP.
206
+ *
207
+ * @param allow Whether or not plain text responses are allowed.
208
+ * @returns This instance of HTTP.
209
+ */
210
+ sharedAllowPlainText(allow: boolean) {
211
+ this.#metaConfig.allowPlainText(allow);
212
+ return this;
213
+ }
214
+
215
+ /**
216
+ * If set to true, all requests derived from this client will allow plain text
217
+ * responses. If set to false, plain text responses will throw an serialization error.
218
+ *
219
+ * Useful when an endpoint returns a HTML or plain text response. If the plain text
220
+ * is the value of `true` or `false`, or a number, it will be parsed into the
221
+ * appropriate type.
222
+ *
223
+ * This method is a request chain method, and will return a new instance of HTTP.
224
+ *
225
+ * @param allow Whether or not plain text responses are allowed.
226
+ * @returns A new child instance of HTTP derived from this one.
227
+ */
228
+ allowPlainText(allow: boolean) {
229
+ return HTTP.create(
230
+ this.client,
231
+ this.#request.derive(),
232
+ this.#metaConfig.derive().allowPlainText(allow)
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Attaches a route to the request, such as `/product` or `/user`. Subsequent calls
238
+ * to this method will append the route to the existing route, such as `/product/1`.
239
+ *
240
+ * Numbers will be converted to strings automatically. Routes can be defined one
241
+ * at a time or as a spread argument.
242
+ *
243
+ * ```ts
244
+ * constructor(http: HTTP) {
245
+ * super(http);
246
+ * this.http.sharedRoute("user", id).get();
247
+ * }
248
+ *
249
+ * // or
250
+ *
251
+ * constructor(http: HTTP) {
252
+ * super(http);
253
+ * this.http
254
+ * .sharedRoute("user")
255
+ * .sharedRoute(id)
256
+ * .get();
257
+ * }
258
+ * ```
259
+ *
260
+ * This method is a shared chain method, and will return the same instance of HTTP. All
261
+ * child clients will inherit the routes defined by this method. Useful to configure
262
+ * in the constructor body.
263
+ *
264
+ * @param route A route or spread list of routes to append to the request.
265
+ * @returns This instance of HTTP.
266
+ */
267
+ sharedRoute(...route: (string | number | boolean)[]) {
268
+ this.#request.route(...route.map((r) => r.toString()));
269
+ return this;
270
+ }
271
+
272
+ /**
273
+ * Attaches a route to the request, such as `/product` or `/user`. Subsequent calls
274
+ * to this method will append the route to the existing route, such as `/product/1`.
275
+ *
276
+ * Numbers will be converted to strings automatically. Routes can be defined one
277
+ * at a time or as a spread argument.
278
+ *
279
+ * ```ts
280
+ * getUser(id: number) {
281
+ * return this.http.route("user", id).get();
282
+ * }
283
+ *
284
+ * // or
285
+ *
286
+ * getUser(id: number) {
287
+ * return this.http
288
+ * .route("user")
289
+ * .route(id)
290
+ * .get();
291
+ * }
292
+ * ```
293
+ *
294
+ * This method is a request chain method, and will return a new instance of HTTP, inheriting
295
+ * any routes previously defined and appending the new route. Useful to configure
296
+ * in class methods as part of finalizing a request.
297
+ *
298
+ * @param route A route or spread list of routes to append to the request.
299
+ * @returns A new child instance of HTTP derived from this one.
300
+ */
301
+ route(...route: (string | number | boolean)[]) {
302
+ const mapped = route.map((r) => String(r));
303
+ return HTTP.create(this.client, this.#request.derive().route(...mapped));
304
+ }
305
+
306
+ /**
307
+ * Attaches a shared schema mapping for all requests by this client. Schemas are
308
+ * mapped to HTTP Status Codes, and if configured to be required the request will fail
309
+ * if no schema is found matching that code.
310
+ *
311
+ * The status code mapping can be defined as a single code, a range of codes, or a spread list.
312
+ *
313
+ * ```ts
314
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
315
+ * export class UserController extends BaseController {
316
+ * constructor(private readonly http: HTTP) {
317
+ * super(http);
318
+ * this.http
319
+ * .sharedRoute("user")
320
+ * .sharedSchema(UserSchema, 200)
321
+ * .sharedSchema(EmptySchema, 201, 204)
322
+ * .sharedSchema(ErrorSchema, { from: 400, to: 499 });
323
+ * }
324
+ * }
325
+ * ```
326
+ *
327
+ * This method is a shared chain method, and will return the same instance of HTTP. All
328
+ * child clients will inherit the schemas defined by this method. Useful to configure
329
+ * in the constructor body.
330
+ *
331
+ * @param parser The schema parser to use for this mapping.
332
+ * @param codes A single status code, a range of status codes, or a spread list of status codes.
333
+ * @returns This instance of HTTP.
334
+ */
335
+ sharedSchema(parser: SchemaParser, ...codes: StatusCode[]): HTTP;
336
+ sharedSchema(
337
+ parser: SchemaParser,
338
+ ...range: { from: StatusCode; to: StatusCode }[]
339
+ ): HTTP;
340
+ sharedSchema(
341
+ parser: SchemaParser,
342
+ ...args: (StatusCode | { from: StatusCode; to: StatusCode })[]
343
+ ): HTTP {
344
+ this.#metaConfig.schema(parser, ...args);
345
+ return this;
346
+ }
347
+
348
+ /**
349
+ * Attaches a schema mapping for this request. Schemas are
350
+ * mapped to HTTP Status Codes, and if configured to be required the request will fail
351
+ * if no schema is found matching that code.
352
+ *
353
+ * The status code mapping can be defined as a single code, a range of codes, or a spread list.
354
+ *
355
+ * ```ts
356
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
357
+ * export class UserController extends BaseController {
358
+ * constructor(private readonly http: HTTP) {
359
+ * super(http);
360
+ * this.http
361
+ * .sharedRoute("user")
362
+ * .schema(ErrorSchema, { from: 400, to: 499 });
363
+ * }
364
+ *
365
+ * getUser(id: number) {
366
+ * return this.http.route(id).schema(UserSchema, 200).get();
367
+ * }
368
+ *
369
+ * getUsers(...ids: number[]) {
370
+ * return this.http
371
+ * .route("users")
372
+ * .schema(UserSchema, { from: 200, to: 299 })
373
+ * .schema(UserSchema, 200)
374
+ * .get();
375
+ * }
376
+ * ```
377
+ *
378
+ * This method is a request chain method, and will return a new instance of HTTP, inheriting
379
+ * any schemas previously defined and appending the new schema. Useful to configure
380
+ * in class methods as part of finalizing a request.
381
+ *
382
+ * @param parser The schema parser to use for this mapping.
383
+ * @param codes A single status code, a range of status codes, or a spread list of status codes.
384
+ * @returns A new child instance of HTTP derived from this one.
385
+ */
386
+ schema(parser: SchemaParser, ...codes: StatusCode[]): HTTP;
387
+ schema(
388
+ parser: SchemaParser,
389
+ ...range: { from: StatusCode; to: StatusCode }[]
390
+ ): HTTP;
391
+ schema(
392
+ parser: SchemaParser,
393
+ ...args: (StatusCode | { from: StatusCode; to: StatusCode })[]
394
+ ): HTTP {
395
+ return HTTP.create(
396
+ this.client,
397
+ this.#request.derive(),
398
+ this.#metaConfig.derive().schema(parser, ...args)
399
+ );
400
+ }
401
+
402
+ /**
403
+ * Attaches a shared query string parameter to all requests by this client. Query string
404
+ * parameters are key-value pairs which are appended to the request url, such as
405
+ * `https://api.example.com?name=John&age=30`.
406
+ *
407
+ * This method is a shared chain method, and will return the same instance of HTTP. All
408
+ * child clients will inherit the query string parameters defined by this method. Useful to configure
409
+ * in the constructor body.
410
+ *
411
+ * @param name The name of the query string parameter.
412
+ * @param value The value of the query string parameter.
413
+ * @returns This instance of HTTP.
414
+ */
415
+ sharedParam(name: string, value: Record<string, unknown>): HTTP;
416
+ sharedParam(name: string, ...value: (string | number | boolean)[]): HTTP;
417
+ sharedParam(
418
+ name: string,
419
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
420
+ value: any
421
+ ): HTTP {
422
+ this.#request.param(name, value);
423
+ return this;
424
+ }
425
+
426
+ /**
427
+ * `onSend` is a pre-request hook which will be executed in order of definition
428
+ * immediately before the request is sent. This hook can be used to analyze or
429
+ * log the request state.
430
+ *
431
+ * ```ts
432
+ *
433
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
434
+ * export class UserController extends BaseController {
435
+ * constructor(private readonly http: HTTP) {
436
+ * super(http);
437
+ * this.http
438
+ * .sharedRoute("user")
439
+ * .sharedOnSend("log request",
440
+ * (request) => console.log(JSON.stringify(request, null, 2))
441
+ * );
442
+ * }
443
+ * }
444
+ * ```
445
+ *
446
+ * This method is a shared chain method, and will return the same instance of HTTP. All
447
+ * child clients will inherit the onSend hooks defined by this method. Useful to configure
448
+ * in the constructor body.
449
+ *
450
+ * @param description A description of the hook, used for debugging.
451
+ * @param hook The hook to execute.
452
+ * @returns This instance of HTTP.
453
+ */
454
+ sharedOnSend(description: string, hook: RequestHook) {
455
+ this.#metaConfig.onBeforeSend(description, hook);
456
+ return this;
457
+ }
458
+
459
+ /**
460
+ * `onReceive` is a post-request hook which will be executed in order of definition
461
+ * immediately after the response is received. This hook can be used to analyze or
462
+ * log the response state.
463
+ *
464
+ * ```ts
465
+ *
466
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
467
+ * export class UserController extends BaseController {
468
+ * constructor(private readonly http: HTTP) {
469
+ * super(http);
470
+ * this.http
471
+ * .sharedRoute("user")
472
+ * .sharedOnReceive("log response",
473
+ * (response) => console.log(JSON.stringify(response, null, 2))
474
+ * );
475
+ * }
476
+ * }
477
+ * ```
478
+ *
479
+ * This method is a shared chain method, and will return the same instance of HTTP. All
480
+ * child clients will inherit the onReceive hooks defined by this method. Useful to configure
481
+ * in the constructor body.
482
+ *
483
+ * @param description A description of the hook, used for debugging.
484
+ * @param hook The hook to execute.
485
+ * @returns This instance of HTTP.
486
+ */
487
+ sharedOnReceive(description: string, hook: ResponseHook<unknown>) {
488
+ this.#metaConfig.onReceiveResponse(description, hook);
489
+ return this;
490
+ }
491
+
492
+ /**
493
+ * Attaches a query string parameter object to the request. Query string
494
+ * parameters are key-value pairs which are appended to the request url, such as
495
+ * `https://api.example.com?name=John&age=30`.
496
+ *
497
+ * This method is a shared chain method, and will return the same instance of HTTP. All
498
+ * child clients will inherit the query string parameters defined by this method. Useful to configure
499
+ * in the constructor body.
500
+ *
501
+ * ```ts
502
+ * constructor(http: HTTP) {
503
+ * super(http);
504
+ * this.http
505
+ * .sharedParams({ 'is-test': "true" })
506
+ * ```
507
+ * @param name The name of the query string parameter.
508
+ * @param value The value of the query string parameter.
509
+ * @returns This instance of HTTP.
510
+ */
511
+ sharedParams(dict: Record<string, unknown>) {
512
+ this.#request.params(dict);
513
+ return this;
514
+ }
515
+
516
+ /**
517
+ * Attaches a query string parameter to the request. Query string
518
+ * parameters are key-value pairs which are appended to the request url, such as
519
+ * `https://api.example.com?name=John&age=30`.
520
+ *
521
+ * This method is a request chain method, and will return a new instance of HTTP, inheriting
522
+ * any query string parameters previously defined and appending the new parameter. Useful to configure
523
+ * in class methods as part of finalizing a request.
524
+ *
525
+ * ```ts
526
+ * getUser(id: number) {
527
+ * return this.http
528
+ * .route(id)
529
+ * .param("name", "John")
530
+ * .param("age", 30)
531
+ * ```
532
+ *
533
+ * Note: Numbers and Booleans will be converted to strings automatically.
534
+ *
535
+ * @param name The name of the query string parameter.
536
+ * @param value The value of the query string parameter.
537
+ * @returns A new child instance of HTTP derived from this one.
538
+ */
539
+ param(name: string, value: Record<string, unknown>): HTTP;
540
+ param(name: string, ...value: (string | number | boolean)[]): HTTP;
541
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
542
+ param(name: string, value: any) {
543
+ return HTTP.create(this.client, this.#request.derive().param(name, value));
544
+ }
545
+
546
+ /**
547
+ * Attaches a query string parameter object to the request. Query string
548
+ * parameters are key-value pairs which are appended to the request url, such as
549
+ * `https://api.example.com?name=John&age=30`.
550
+ *
551
+ * This method is a shared chain method, and will return the same instance of HTTP. All
552
+ * child clients will inherit the query string parameters defined by this method. Useful to configure
553
+ * in the constructor body.
554
+ *
555
+ * ```ts
556
+ * getUser(id: number) {
557
+ * return this.http
558
+ * .route(id)
559
+ * .param({ name: "John", age: "30" })
560
+ *
561
+ * @param name The name of the query string parameter.
562
+ * @param value The value of the query string parameter.
563
+ * @returns This instance of HTTP.
564
+ */
565
+ params(dict: Record<string, string>) {
566
+ this.#request.params(dict);
567
+ return HTTP.create(this.client, this.#request.derive().params(dict));
568
+ }
569
+
570
+ /**
571
+ * Attaches a shared data payload to this client. The data payload is the body of the request,
572
+ * and can be any type. If the data payload is an object, it will be serialized to JSON.
573
+ *
574
+ * This method is a shared chain method, and will return the same instance of HTTP. All
575
+ * child clients will inherit the data payload defined by this method. Useful to configure
576
+ * in the constructor body.
577
+ *
578
+ * @param data The data payload to attach to the request.
579
+ * @returns This instance of HTTP.
580
+ */
581
+ sharedData<T>(data: T) {
582
+ this.#request.data(data);
583
+ return this;
584
+ }
585
+
586
+ /**
587
+ * Attaches a shared header to this client. Headers are string:string key-value pairs which are
588
+ * sent with the request, such as `Content-Type: application/json`.
589
+ *
590
+ * Numbers, Booleans and Null will be converted to string values automatically.
591
+ *
592
+ * A Factory function can also be provided to generate the header value at the time of request.
593
+ *
594
+ * This method is a shared chain method, and will return the same instance of HTTP. All
595
+ * child clients will inherit the header defined by this method. Useful to configure
596
+ * in the constructor body.
597
+ *
598
+ * @param name The name of the header.
599
+ * @param value The value of the header.
600
+ */
601
+ sharedHeader(
602
+ name: string,
603
+ value:
604
+ | string
605
+ | number
606
+ | boolean
607
+ | null
608
+ | (() => string | number | boolean | null)
609
+ ) {
610
+ this.#request.header(name, value);
611
+ return this;
612
+ }
613
+
614
+ header(name: string, value: string) {
615
+ return HTTP.create(this.client, this.#request.derive().header(name, value));
616
+ }
617
+
618
+ /**
619
+ * Attaches a data payload to this request. The data payload is the body of the request,
620
+ * and can be any type. If the data payload is an object, it will be serialized to JSON.
621
+ *
622
+ * This method is a request chain method, and will return a new instance of HTTP, inheriting
623
+ * any data payload previously defined and appending the new payload. Useful to configure
624
+ * in class methods as part of finalizing a request.
625
+ *
626
+ * @param data The data payload to attach to the request.
627
+ * @returns A new child instance of HTTP derived from this one.
628
+ */
629
+ data<T>(data: T) {
630
+ this.#request.data(data);
631
+ return HTTP.create(this.client, this.#request.derive().data(data));
632
+ }
633
+
634
+ /**
635
+ * `onSend` is a pre-request hook which will be executed in order of definition
636
+ * immediately before the request is sent. This hook can be used to modify the request,
637
+ * or to log the state of a request before final send-off.
638
+ *
639
+ * ```ts
640
+ *
641
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
642
+ * export class UserController extends BaseController {
643
+ * constructor(private readonly http: HTTP) {
644
+ * super(http);
645
+ * }
646
+ *
647
+ * getUser(id: number) {
648
+ * return this.http
649
+ * .route(id)
650
+ * .onSend("log request",
651
+ * (request) => console.log(JSON.stringify(request, null, 2)
652
+ * )
653
+ * .get();
654
+ * }
655
+ * ```
656
+ *
657
+ * This method is a request chain method, and will return a new instance of HTTP, inheriting
658
+ * any onSend hooks previously defined and appending the new hook. Useful to configure
659
+ * in class methods as part of finalizing a request.
660
+ *
661
+ * @param description A description of the hook, used for debugging.
662
+ * @param hook The hook to execute.
663
+ * @returns A new child instance of HTTP derived from this one.
664
+ */
665
+ onSend(description: string, hook: RequestHook) {
666
+ return HTTP.create(
667
+ this.client,
668
+ this.#request.derive(),
669
+ this.#metaConfig.derive().onBeforeSend(description, hook)
670
+ );
671
+ }
672
+
673
+ /**
674
+ * `onReceive` is a post-request hook which will be executed in order of definition
675
+ * immediately after the response is received. This hook can be used to modify the response,
676
+ * or to log the state of a response after it is received.
677
+ *
678
+ * ```ts
679
+ *
680
+ * \@Fixture(INJECTION_SCOPE.TRANSIENT)
681
+ * export class UserController extends BaseController {
682
+ * constructor(private readonly http: HTTP) {
683
+ * super(http);
684
+ * }
685
+ *
686
+ * getUser(id: number) {
687
+ * return this.http
688
+ * .route(id)
689
+ * .onReceive("log response",
690
+ * (response) => console.log(JSON.stringify(response, null, 2)
691
+ * )
692
+ * .get();
693
+ * }
694
+ * ```
695
+ *
696
+ * This method is a request chain method, and will return a new instance of HTTP, inheriting
697
+ * any onReceive hooks previously defined and appending the new hook. Useful to configure
698
+ * in class methods as part of finalizing a request.
699
+ *
700
+ * @param description A description of the hook, used for debugging.
701
+ * @param hook The hook to execute.
702
+ * @returns A new child instance of HTTP derived from this one.
703
+ */
704
+ onReceive(description: string, hook: ResponseHook<unknown>) {
705
+ return HTTP.create(
706
+ this.client,
707
+ this.#request.derive(),
708
+ this.#metaConfig.derive().onReceiveResponse(description, hook)
709
+ );
710
+ }
711
+
712
+ /**
713
+ * Executes the current request state as a GET request.
714
+ *
715
+ * @param options Additional options to pass to the underlying http client, such
716
+ * as e.g Axios configuration values.
717
+ * @returns A promise which resolves to the response.
718
+ */
719
+ get<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
720
+ return this.#makeRequest(
721
+ this.#request.derive().method("GET"),
722
+ options
723
+ ) as Promise<HTTPResponse<TResponseType>>;
724
+ }
725
+
726
+ /**
727
+ * Executes the current request state as a POST request.
728
+ *
729
+ * @param data The data payload to attach to the request.
730
+ * @param options Additional options to pass to the underlying http client, such
731
+ * as e.g Axios configuration values.
732
+ * @returns A promise which resolves to the response.
733
+ */
734
+ post<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
735
+ return this.#makeRequest(
736
+ this.#request.derive().method("POST"),
737
+ options
738
+ ) as Promise<HTTPResponse<TResponseType>>;
739
+ }
740
+
741
+ /**
742
+ * Executes the current request state as a DELETE request.
743
+ *
744
+ * @param options Additional options to pass to the underlying http client, such
745
+ * as e.g Axios configuration values.
746
+ * @returns A promise which resolves to the response.
747
+ * as e.g Axios configuration values.
748
+ */
749
+ delete<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
750
+ return this.#makeRequest(
751
+ this.#request.derive().method("DELETE"),
752
+ options
753
+ ) as Promise<HTTPResponse<TResponseType>>;
754
+ }
755
+
756
+ /**
757
+ * Executes the current request state as a PUT request.
758
+ *
759
+ * @param options Additional options to pass to the underlying http client, such
760
+ * as e.g Axios configuration values.
761
+ * @returns A promise which resolves to the response.
762
+ */
763
+ put<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
764
+ return this.#makeRequest(
765
+ this.#request.derive().method("PUT"),
766
+ options
767
+ ) as Promise<HTTPResponse<TResponseType>>;
768
+ }
769
+ /**
770
+ * Executes the current request state as a PATCH request.
771
+ *
772
+ * @param options Additional options to pass to the underlying http client, such
773
+ * as e.g Axios configuration values.
774
+ * @returns A promise which resolves to the response.
775
+ */
776
+ patch<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
777
+ return this.#makeRequest(
778
+ this.#request.derive().method("PATCH"),
779
+ options
780
+ ) as Promise<HTTPResponse<TResponseType>>;
781
+ }
782
+
783
+ head<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
784
+ return this.#makeRequest(
785
+ this.#request.derive().method("HEAD"),
786
+ options
787
+ ) as Promise<HTTPResponse<TResponseType>>;
788
+ }
789
+
790
+ options<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
791
+ return this.#makeRequest(
792
+ this.#request.derive().method("OPTIONS"),
793
+ options
794
+ ) as Promise<HTTPResponse<TResponseType>>;
795
+ }
796
+
797
+ trace<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
798
+ return this.#makeRequest(
799
+ this.#request.derive().method("TRACE"),
800
+ options
801
+ ) as Promise<HTTPResponse<TResponseType>>;
802
+ }
803
+
804
+ connect<TResponseType>(options?: HTTPAdditionalOptions<unknown>) {
805
+ return this.#makeRequest(
806
+ this.#request.derive().method("CONNECT"),
807
+ options
808
+ ) as Promise<HTTPResponse<TResponseType>>;
809
+ }
810
+
811
+ async #makeRequest(
812
+ builder: HTTPRequestBuilder<HTTPRequest<unknown>>,
813
+ options?: HTTPAdditionalOptions<unknown>
814
+ ) {
815
+ const request = builder.resolveDynamicHeaders().build();
816
+ const meta = this.#metaConfig.derive().build();
817
+ await this.runOnSendHooks(meta, request);
818
+ const result = await this.client.request<unknown, string>(request, options);
819
+ result.data = transformResponse(meta.allowPlainText, result.data);
820
+ await this.runOnReceiveHooks(meta, result);
821
+ const validated = this.#validateResponse(result, meta);
822
+ return validated;
823
+ }
824
+
825
+ private async runOnSendHooks(
826
+ meta: MetaConfig,
827
+ request: HTTPRequest<unknown>
828
+ ) {
829
+ for (const [description, hook] of meta.onSend) {
830
+ try {
831
+ await hook(request);
832
+ } catch (e) {
833
+ const cause = e as Error;
834
+ const msg = `An error occurred while sending a request in hook: '${description}'`;
835
+ throw new AutomationError(msg, { cause });
836
+ }
837
+ }
838
+ }
839
+ private async runOnReceiveHooks(
840
+ meta: MetaConfig,
841
+ response: HTTPResponse<unknown>
842
+ ) {
843
+ for (const [description, hook] of meta.onReceive) {
844
+ try {
845
+ await hook(response);
846
+ } catch (e) {
847
+ const cause = e as Error;
848
+ const msg = `An error occurred while receiving a response in hook: '${description}'`;
849
+ throw new AutomationError(msg, { cause });
850
+ }
851
+ }
852
+ }
853
+
854
+ #validateResponse<T>(
855
+ response: HTTPResponse<unknown>,
856
+ meta: MetaConfig
857
+ ): HTTPResponse<T> {
858
+ const { status, data } = response;
859
+ const validated = meta.schemas.validate(
860
+ status,
861
+ data,
862
+ meta.requireSchema
863
+ ) as T;
864
+ response.data = validated;
865
+ return response as HTTPResponse<T>;
866
+ }
867
+ }