@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.
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HTTPRequest, HTTPRequestBuilder } from "./http-request";
3
+
4
+ describe("HTTP Request", () => {
5
+ it("should derive a detailed request", () => {
6
+ const request = new HTTPRequest();
7
+ request.baseUrl = "https://example.com";
8
+ request.route.push("foo", "bar");
9
+ request.params = { foo: "bar" };
10
+ request.data = { foo: "bar" };
11
+ request.headers = { foo: "bar" };
12
+ request.method = "GET";
13
+
14
+ const derived = HTTPRequest.derive(request);
15
+ expect(derived).toEqual({
16
+ baseUrl: "https://example.com",
17
+ route: ["foo", "bar"],
18
+ params: { foo: "bar" },
19
+ data: { foo: "bar" },
20
+ headers: { foo: "bar" },
21
+ method: "GET"
22
+ });
23
+ });
24
+
25
+ describe("HTTPRequestBuilder", () => {
26
+ it("should build a request from a base", () => {
27
+ const request = new HTTPRequest();
28
+ request.baseUrl = "https://example.com";
29
+ request.route.push("foo", "bar");
30
+ request.params = { foo: "bar" };
31
+ request.data = { foo: "bar" };
32
+ request.headers = { foo: "bar" };
33
+ request.method = "GET";
34
+ const builder = new HTTPRequestBuilder(request);
35
+ expect(builder.request).toEqual(request);
36
+ });
37
+
38
+ it("should build a request from scratch", () => {
39
+ const builder = new HTTPRequestBuilder()
40
+ .url("https://example.com")
41
+ .route("foo", "bar")
42
+ .param("foo", "bar")
43
+ .data({ foo: "bar" })
44
+ .header("foo", "bar")
45
+ .method("GET");
46
+ expect(builder.request).toEqual({
47
+ baseUrl: "https://example.com",
48
+ route: ["foo", "bar"],
49
+ params: { foo: "bar" },
50
+ data: { foo: "bar" },
51
+ headers: { foo: "bar" },
52
+ method: "GET"
53
+ });
54
+ });
55
+
56
+ it("should derive a request builder", () => {
57
+ const builder = new HTTPRequestBuilder()
58
+ .url("https://example.com")
59
+ .route("foo", "bar")
60
+ .param("foo", "bar")
61
+ .data({ foo: "bar" })
62
+ .header("foo", "bar")
63
+ .method("GET");
64
+ const derived = builder.derive();
65
+ expect(derived.request).toEqual(builder.request);
66
+ expect(derived.request).not.toBe(builder.request);
67
+ });
68
+ });
69
+
70
+ describe("build", () => {
71
+ it("should build a request", () => {
72
+ const request = new HTTPRequestBuilder()
73
+ .url("https://example.com")
74
+ .route("foo", "bar")
75
+ .param("foo", "bar")
76
+ .data({ foo: "bar" })
77
+ .header("foo", "bar")
78
+ .method("GET")
79
+ .build();
80
+ expect(request).toEqual({
81
+ baseUrl: "https://example.com",
82
+ route: ["foo", "bar"],
83
+ params: { foo: "bar" },
84
+ data: { foo: "bar" },
85
+ headers: { foo: "bar" },
86
+ method: "GET"
87
+ });
88
+ });
89
+ });
90
+
91
+ describe("paramList", () => {
92
+ it("should build a request with a list of params", () => {
93
+ const request = new HTTPRequestBuilder()
94
+ .url("https://example.com")
95
+ .route("foo", "bar")
96
+ .param("foo", ["bar", "baz"])
97
+ .data({ foo: "bar" })
98
+ .header("foo", "bar")
99
+ .method("GET")
100
+ .build();
101
+ expect(request).toEqual({
102
+ baseUrl: "https://example.com",
103
+ route: ["foo", "bar"],
104
+ params: { foo: ["bar", "baz"] },
105
+ data: { foo: "bar" },
106
+ headers: { foo: "bar" },
107
+ method: "GET"
108
+ });
109
+ });
110
+ });
111
+
112
+ describe("fullUrl", () => {
113
+ it("should have a full url with routes", () => {
114
+ const request = new HTTPRequestBuilder()
115
+ .url("https://example.com")
116
+ .route("foo")
117
+ .build()
118
+ .fullUrl();
119
+ expect(request).toEqual("https://example.com/foo");
120
+ });
121
+ it("should have a full url with routes and params", () => {
122
+ const request = new HTTPRequestBuilder()
123
+ .url("https://example.com")
124
+ .route("foo")
125
+ .param("gru", "bar")
126
+ .build()
127
+ .fullUrl();
128
+ expect(request).toEqual("https://example.com/foo?gru=bar");
129
+ });
130
+
131
+ it("should have a full url with a route and a param list", () => {
132
+ const request = new HTTPRequestBuilder()
133
+ .url("https://example.com")
134
+ .route("foo")
135
+ .param("bob", ["bar", "baz"])
136
+ .build()
137
+ .fullUrl();
138
+ expect(request).toEqual("https://example.com/foo?bob=bar%2Cbaz");
139
+ });
140
+ });
141
+ });
@@ -0,0 +1,164 @@
1
+ import { RequestConfig, RequestConfigBasic } from "./request.config";
2
+ import { HTTPMethod } from "./types";
3
+ import { urlJoinP } from "url-join-ts";
4
+ export class HTTPRequest<T = unknown> implements RequestConfig<T> {
5
+ headers: Record<string, string> = {};
6
+ params: Record<string, string | string[] | Record<string, unknown>> = {};
7
+ baseUrl?: string;
8
+ route: string[] = [];
9
+ method: HTTPMethod;
10
+ data: T;
11
+
12
+ constructor(config?: RequestConfigBasic) {
13
+ Object.assign(this, config);
14
+ }
15
+
16
+ /**
17
+ * Returns the full URL of the request, including the base url,
18
+ * routes, and query parameters.
19
+ *
20
+ * ```ts
21
+ * console.log(request.fullUrl())// https://example.com/foo?bar=baz?array=1,2,3
22
+ * ```
23
+ *
24
+ * Note characters may be converted to escape codes. I.e (space => %20) and (comma => %2C)
25
+ *
26
+ * N.B this method estimates what the url will be. The actual value
27
+ * might be different depending on your underlying HTTPClient and
28
+ * configuration. For example, query parameters might
29
+ * use different array formats.
30
+ */
31
+ fullUrl() {
32
+ return urlJoinP(this.baseUrl, this.route, this.params);
33
+ }
34
+
35
+ /**
36
+ * Returns a new independent copy of the request.
37
+ */
38
+ static derive(original: HTTPRequest<unknown>) {
39
+ const request = new HTTPRequest();
40
+ request.headers = { ...original.headers };
41
+ request.params = { ...original.params };
42
+ request.baseUrl = original.baseUrl;
43
+ request.route = [...original.route];
44
+ request.method = original.method;
45
+ request.data = original.data;
46
+ return request;
47
+ }
48
+ }
49
+
50
+ export class HTTPRequestBuilder<T extends HTTPRequest<unknown>> {
51
+ #request: T;
52
+ #dynamicHeaders = new Map<string, () => string | number | boolean | null>();
53
+
54
+ constructor(request: T | (() => T) = () => new HTTPRequest() as T) {
55
+ if (typeof request === "function") {
56
+ this.#request = request();
57
+ return;
58
+ }
59
+ this.#request = request;
60
+ }
61
+
62
+ static create<T extends HTTPRequest<unknown>>() {
63
+ return new HTTPRequestBuilder<T>();
64
+ }
65
+
66
+ get request() {
67
+ return this.#request;
68
+ }
69
+
70
+ resolveDynamicHeaders() {
71
+ for (const [name, value] of this.#dynamicHeaders) {
72
+ try {
73
+ this.#request.headers[name] = String(value());
74
+ } catch (e) {
75
+ const cause = e as Error;
76
+ const msg = `Failed to resolve dynamic header "${name}":
77
+ ${cause}`;
78
+ throw new Error(msg);
79
+ }
80
+ }
81
+ return this;
82
+ }
83
+
84
+ url(url: string) {
85
+ this.#request.baseUrl = url;
86
+ return this;
87
+ }
88
+
89
+ route(...route: string[]) {
90
+ this.#request.route.push(...route);
91
+ return this;
92
+ }
93
+
94
+ param(
95
+ name: string,
96
+ value:
97
+ | string
98
+ | number
99
+ | boolean
100
+ | (string | number | boolean)[]
101
+ | Record<string, string | number | boolean>
102
+ ) {
103
+ if (Array.isArray(value)) {
104
+ const asStr = value.map(String);
105
+ this.#request.params[name] = asStr;
106
+ return this;
107
+ }
108
+ if (!Array.isArray(value) && typeof value === "object") {
109
+ this.#request.params[name] = value;
110
+ return this;
111
+ }
112
+ this.#request.params[name] = String(value);
113
+ return this;
114
+ }
115
+
116
+ params(dict: Record<string, unknown>) {
117
+ Object.assign(this.#request.params, dict);
118
+ return this;
119
+ }
120
+
121
+ data<T>(data: T) {
122
+ this.#request.data = data;
123
+ return this;
124
+ }
125
+
126
+ header(
127
+ name: string,
128
+ value:
129
+ | string
130
+ | number
131
+ | boolean
132
+ | null
133
+ | (() => string | number | boolean | null)
134
+ ) {
135
+ if (typeof value === "function") {
136
+ value = value();
137
+ }
138
+ this.#request.headers[name] = String(value);
139
+ return this;
140
+ }
141
+
142
+ headers(dict: Record<string, string>) {
143
+ Object.assign(this.#request.headers, dict);
144
+ return this;
145
+ }
146
+
147
+ get() {
148
+ return this.#request;
149
+ }
150
+
151
+ method(method: HTTPMethod) {
152
+ this.#request.method = method;
153
+ return this;
154
+ }
155
+
156
+ derive(): HTTPRequestBuilder<T> {
157
+ const request = HTTPRequest.derive(this.#request);
158
+ return new HTTPRequestBuilder(request) as HTTPRequestBuilder<T>;
159
+ }
160
+
161
+ build(): HTTPRequest<T> {
162
+ return this.#request as HTTPRequest<T>;
163
+ }
164
+ }
@@ -0,0 +1,107 @@
1
+ import { HTTPRequest } from "./http-request";
2
+ import { StatusCode } from "./types";
3
+
4
+ export class HTTPResponse<T = unknown> {
5
+ status: StatusCode;
6
+ statusText: string;
7
+ data: T;
8
+ headers: Record<string, string>;
9
+ request: HTTPRequest<unknown>;
10
+
11
+ constructor() {
12
+ this.headers = {};
13
+ }
14
+
15
+ static fromRaw<T>(response: HTTPResponse<T>) {
16
+ const newResponse = new HTTPResponse<T>();
17
+ Object.assign(newResponse, response);
18
+ return response;
19
+ }
20
+
21
+ /**
22
+ * Decomposes a response, creating an exact copy of the current response,
23
+ * but with a new data value. The data can be provided directly as is, or it
24
+ * can be generated through a callback function which receives the current
25
+ * response data as an argument.
26
+ *
27
+ * ```ts
28
+ * const response = await http.get("/products");
29
+ *
30
+ * // direct value
31
+ * const products = response.data;
32
+ * const firstProduct = response.decompose(products[0]);
33
+ * // callback transformer
34
+ * const secondProduct = response.decompose((products) => products[1]);
35
+ * // callback transformer with destructuring
36
+ * const secondProduct = response.decompose(([product]) => product);
37
+ * ```
38
+ * @param value
39
+ */
40
+ decompose<K>(value: K): HTTPResponse<K>;
41
+ decompose<K>(transformFn: (response: T) => K): HTTPResponse<K>;
42
+ decompose<K>(transformFn: K | ((response: T) => K)): HTTPResponse<K> {
43
+ const value = getDecompositionValue<T>(this.data, transformFn);
44
+ return new HTTPResponseBuilder()
45
+ .status(this.status)
46
+ .statusText(this.statusText)
47
+ .headers(this.headers)
48
+ .request(this.request)
49
+ .data(value)
50
+ .build() as HTTPResponse<K>;
51
+ }
52
+ }
53
+
54
+ function getDecompositionValue<T>(data: unknown, transformFn: unknown): T {
55
+ return typeof transformFn === "function" ? transformFn(data) : transformFn;
56
+ }
57
+
58
+ export class HTTPResponseBuilder {
59
+ #response = new HTTPResponse();
60
+
61
+ static create() {
62
+ return new HTTPResponseBuilder();
63
+ }
64
+
65
+ derive() {
66
+ return HTTPResponseBuilder.create()
67
+ .data(this.#response.data)
68
+ .headers(this.#response.headers)
69
+ .request(this.#response.request)
70
+ .status(this.#response.status)
71
+ .statusText(this.#response.statusText);
72
+ }
73
+
74
+ status(code: StatusCode) {
75
+ this.#response.status = code;
76
+ return this;
77
+ }
78
+
79
+ statusText(text: string) {
80
+ this.#response.statusText = text;
81
+ return this;
82
+ }
83
+
84
+ data<T>(data: T) {
85
+ this.#response.data = data;
86
+ return this;
87
+ }
88
+
89
+ headers(dict: Record<string, string>) {
90
+ this.#response.headers = dict;
91
+ return this;
92
+ }
93
+
94
+ header(name: string, value: string) {
95
+ this.#response.headers[name] = value;
96
+ return this;
97
+ }
98
+
99
+ request(request: HTTPRequest<unknown>) {
100
+ this.#response.request = request;
101
+ return this;
102
+ }
103
+
104
+ build() {
105
+ return this.#response;
106
+ }
107
+ }
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { HTTP } from "./http";
3
+ import { HTTPClient } from "./http-client";
4
+ import { HTTPRequest, HTTPRequestBuilder } from "./http-request";
5
+ import { HTTPResponse, HTTPResponseBuilder } from "./http-response";
6
+ import { HTTPAdditionalOptions } from "./types";
7
+
8
+ describe("HTTP", () => {
9
+ describe("create", () => {
10
+ it("should create a new instance of the HTTP Client", () => {
11
+ const client = HTTP.create();
12
+ expect(client).toBeInstanceOf(HTTP);
13
+ });
14
+ });
15
+ class MockClient extends HTTPClient {
16
+ constructor(readonly testFn: ReturnType<typeof vi.fn>) {
17
+ super();
18
+ }
19
+ async request<TRequestType, TResponseType>(
20
+ request: HTTPRequest<TRequestType>,
21
+ options?: HTTPAdditionalOptions<unknown> | undefined
22
+ ): Promise<HTTPResponse<TResponseType>> {
23
+ return this.testFn(request, options) as HTTPResponse<TResponseType>;
24
+ }
25
+ }
26
+ describe("allowPlainText", () => {
27
+ it("should throw an error when allowPlainText is false", async () => {
28
+ const response = HTTPResponseBuilder.create().data("Hello World").build();
29
+ const fn = vi.fn().mockReturnValue(response);
30
+ const client = new MockClient(fn);
31
+ const http = new HTTP(client).allowPlainText(false);
32
+ await expect(() => http.get()).rejects.toThrowError(
33
+ `Could not parse a response as json, and this request was not configured to allow plain text responses.
34
+ To allow plain text responses, use the 'allowPlainText' method on the HTTP client.
35
+
36
+ Hello World`
37
+ );
38
+ });
39
+
40
+ it("should return the response when allowPlainText is true", async () => {
41
+ const response = HTTPResponseBuilder.create().data("Hello World").build();
42
+ const fn = vi.fn().mockReturnValue(response);
43
+ const client = new MockClient(fn);
44
+ const http = new HTTP(client).allowPlainText(true);
45
+ const result = await http.get();
46
+ expect(result).toBe(response);
47
+ });
48
+
49
+ it("should return a new HTTP instance", () => {
50
+ const response = HTTPResponseBuilder.create().data("Hello World").build();
51
+ const fn = vi.fn().mockReturnValue(response);
52
+ const client = new MockClient(fn);
53
+ const http = new HTTP(client).allowPlainText(true);
54
+ const result = http.allowPlainText(false);
55
+ expect(result).toBeInstanceOf(HTTP);
56
+ expect(result).not.toBe(http);
57
+ });
58
+ });
59
+
60
+ describe("hooks", () => {
61
+ describe("onSend", () => {
62
+ it("should call the hook when the request is sent", async () => {
63
+ const request = HTTPRequestBuilder.create()
64
+ .url(undefined as unknown as string)
65
+ .method("GET")
66
+ .data(undefined as unknown as string)
67
+ .build();
68
+ const response = HTTPResponseBuilder.create().build();
69
+ const fn = vi.fn().mockReturnValue(response);
70
+ const hook = vi
71
+ .fn()
72
+ .mockImplementation((data: HTTPResponse<unknown>) => {
73
+ expect(data).toStrictEqual(request);
74
+ });
75
+ const client = new MockClient(fn);
76
+ const http = new HTTP(client).onSend("test hook", hook);
77
+ await http.get();
78
+ expect(hook).toHaveBeenCalledWith(request);
79
+ });
80
+
81
+ it("should propagate errors from the hook", async () => {
82
+ const response = HTTPResponseBuilder.create().build();
83
+ const fn = vi.fn().mockReturnValue(response);
84
+ const hook = vi.fn().mockImplementation((_: HTTPResponse<unknown>) => {
85
+ throw new Error("test error");
86
+ });
87
+ const client = new MockClient(fn);
88
+ const http = new HTTP(client).onSend("test hook", hook);
89
+ await expect(() => http.get()).rejects.toThrowError(
90
+ "An error occurred while sending a request in hook: 'test hook'"
91
+ );
92
+ });
93
+ });
94
+
95
+ describe("onReceive", () => {
96
+ it("should call the hook when the response is received", async () => {
97
+ const response = HTTPResponseBuilder.create().build();
98
+ const fn = vi.fn().mockReturnValue(response);
99
+ const hook = vi
100
+ .fn()
101
+ .mockImplementation((data: HTTPResponse<unknown>) => {
102
+ expect(data).toStrictEqual(response);
103
+ });
104
+ const client = new MockClient(fn);
105
+ const http = new HTTP(client).onReceive("test hook", hook);
106
+ await http.get();
107
+ expect(hook).toHaveBeenCalledWith(response);
108
+ });
109
+
110
+ it("should propagate errors from the hook", async () => {
111
+ const response = HTTPResponseBuilder.create().build();
112
+ const fn = vi.fn().mockReturnValue(response);
113
+ const hook = vi.fn().mockImplementation((_: HTTPResponse<unknown>) => {
114
+ throw new Error("test error");
115
+ });
116
+ const client = new MockClient(fn);
117
+ const http = new HTTP(client).onReceive("test hook", hook);
118
+ await expect(() => http.get()).rejects.toThrowError(
119
+ "An error occurred while receiving a response in hook: 'test hook'"
120
+ );
121
+ });
122
+ });
123
+ });
124
+
125
+ describe("schemas", () => {
126
+ it("should throw an error if there is no schema and requireSchema is true", () => {
127
+ const response = HTTPResponseBuilder.create().status(200).build();
128
+ const fn = vi.fn().mockReturnValue(response);
129
+ const client = new MockClient(fn);
130
+ const http = new HTTP(client).requireSchema(true);
131
+ expect(() => http.get()).rejects.toThrowError(
132
+ `No parser registered for status code 200 but 'requireSchema' is true`
133
+ );
134
+ });
135
+
136
+ it("should throw an error if the response status does not match a schema", () => {
137
+ const response = HTTPResponseBuilder.create().status(200).build();
138
+ const fn = vi.fn().mockReturnValue(response);
139
+ const client = new MockClient(fn);
140
+ const http = new HTTP(client).requireSchema(true).schema(vi.fn(), 400);
141
+ expect(() => http.get()).rejects.toThrowError(
142
+ `No parser registered for status code 200 but 'requireSchema' is true`
143
+ );
144
+ });
145
+
146
+ it("should return the response if the status matches a schema", async () => {
147
+ const response = HTTPResponseBuilder.create().status(200).build();
148
+ const fn = vi.fn().mockReturnValue(response);
149
+ const client = new MockClient(fn);
150
+ const http = new HTTP(client).requireSchema(true).schema(vi.fn(), 200);
151
+ const result = await http.get();
152
+ expect(result).toBe(response);
153
+ });
154
+
155
+ it("should return the validated response if the status matches a schema", async () => {
156
+ const response = HTTPResponseBuilder.create().status(200).data("FooBar");
157
+ const validated = response.derive().data("Hello World").build();
158
+ const fn = vi.fn().mockReturnValue(response.build());
159
+ const client = new MockClient(fn);
160
+ const schema = vi.fn().mockReturnValue(validated);
161
+ const http = new HTTP(client)
162
+ .requireSchema(true)
163
+ .allowPlainText(true)
164
+ .schema(schema, 200);
165
+ const { data } = await http.get();
166
+ expect(data).toBe(validated);
167
+ });
168
+ });
169
+ });