@mittwald/api-client-commons 0.0.0-development-860f5d6-20261009 → 0.0.0-development-fd38fc6-20261012
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/dist/esm/core/ApiClientError.test.js +53 -0
- package/dist/esm/core/OpenAPIPath.test.js +51 -0
- package/dist/esm/core/Request.test.js +102 -0
- package/dist/esm/interceptors/accessToken.test.js +59 -0
- package/dist/esm/interceptors/consistencyHandling.test.js +131 -0
- package/dist/esm/testing/expectStatus.test.js +26 -0
- package/dist/esm/types/assertStatus.test.js +46 -0
- package/dist/esm/types/extractTotalCountHeader.test.js +38 -0
- package/dist/types/core/ApiClientError.test.d.ts +1 -0
- package/dist/types/core/OpenAPIPath.test.d.ts +1 -0
- package/dist/types/interceptors/accessToken.test.d.ts +1 -0
- package/dist/types/interceptors/consistencyHandling.test.d.ts +1 -0
- package/dist/types/testing/expectStatus.test.d.ts +1 -0
- package/dist/types/types/assertStatus.test.d.ts +1 -0
- package/dist/types/types/extractTotalCountHeader.test.d.ts +1 -0
- package/package.json +4 -3
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AxiosError, AxiosHeaders } from "axios";
|
|
2
|
+
import ApiClientError from "./ApiClientError.js";
|
|
3
|
+
const makeResponse = (status = 500) => {
|
|
4
|
+
const config = {
|
|
5
|
+
url: "v2/projects",
|
|
6
|
+
method: "GET",
|
|
7
|
+
headers: new AxiosHeaders(),
|
|
8
|
+
};
|
|
9
|
+
return {
|
|
10
|
+
status,
|
|
11
|
+
statusText: "Internal Server Error",
|
|
12
|
+
data: { message: "boom" },
|
|
13
|
+
headers: new AxiosHeaders(),
|
|
14
|
+
config,
|
|
15
|
+
request: { some: "request" },
|
|
16
|
+
mediaType: "application/json",
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
describe("constructor", () => {
|
|
20
|
+
test("is recognizable as ApiClientError and as AxiosError", () => {
|
|
21
|
+
const error = new ApiClientError("nope");
|
|
22
|
+
expect(error).toBeInstanceOf(ApiClientError);
|
|
23
|
+
expect(error).toBeInstanceOf(AxiosError);
|
|
24
|
+
expect(error).toBeInstanceOf(Error);
|
|
25
|
+
});
|
|
26
|
+
test("uses its own name instead of the inherited one", () => {
|
|
27
|
+
expect(new ApiClientError("nope").name).toBe("ApiClientError");
|
|
28
|
+
});
|
|
29
|
+
test("keeps message and code", () => {
|
|
30
|
+
const error = new ApiClientError("nope", "ERR_NOPE");
|
|
31
|
+
expect(error.message).toBe("nope");
|
|
32
|
+
expect(error.code).toBe("ERR_NOPE");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("fromResponse", () => {
|
|
36
|
+
test("carries over config, request and response of the failed call", () => {
|
|
37
|
+
const response = makeResponse();
|
|
38
|
+
const error = ApiClientError.fromResponse("unexpected status", response);
|
|
39
|
+
expect(error.message).toBe("unexpected status");
|
|
40
|
+
expect(error.config).toBe(response.config);
|
|
41
|
+
expect(error.request).toBe(response.request);
|
|
42
|
+
expect(error.response).toBe(response);
|
|
43
|
+
});
|
|
44
|
+
test("exposes the response status via the response", () => {
|
|
45
|
+
const error = ApiClientError.fromResponse("unexpected status", makeResponse(404));
|
|
46
|
+
expect(error.response?.status).toBe(404);
|
|
47
|
+
});
|
|
48
|
+
test("produces an instance that is catchable as ApiClientError", () => {
|
|
49
|
+
expect(() => {
|
|
50
|
+
throw ApiClientError.fromResponse("unexpected status", makeResponse());
|
|
51
|
+
}).toThrow(ApiClientError);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import OpenAPIPath from "./OpenAPIPath.js";
|
|
2
|
+
describe("buildUrl", () => {
|
|
3
|
+
test("strips the leading slash so the path stays relative to the baseURL", () => {
|
|
4
|
+
expect(new OpenAPIPath("/v2/projects").buildUrl()).toBe("v2/projects");
|
|
5
|
+
});
|
|
6
|
+
test("keeps a path that has no leading slash untouched", () => {
|
|
7
|
+
expect(new OpenAPIPath("v2/projects").buildUrl()).toBe("v2/projects");
|
|
8
|
+
});
|
|
9
|
+
test("substitutes a single path parameter", () => {
|
|
10
|
+
const path = new OpenAPIPath("/v2/projects/{projectId}", {
|
|
11
|
+
projectId: "abc",
|
|
12
|
+
});
|
|
13
|
+
expect(path.buildUrl()).toBe("v2/projects/abc");
|
|
14
|
+
});
|
|
15
|
+
test("substitutes multiple path parameters", () => {
|
|
16
|
+
const path = new OpenAPIPath("/v2/projects/{projectId}/apps/{appId}", {
|
|
17
|
+
projectId: "abc",
|
|
18
|
+
appId: "def",
|
|
19
|
+
});
|
|
20
|
+
expect(path.buildUrl()).toBe("v2/projects/abc/apps/def");
|
|
21
|
+
});
|
|
22
|
+
test("stringifies numeric path parameters", () => {
|
|
23
|
+
const path = new OpenAPIPath("/v2/items/{index}", { index: 42 });
|
|
24
|
+
expect(path.buildUrl()).toBe("v2/items/42");
|
|
25
|
+
});
|
|
26
|
+
test("URL-encodes path parameter values", () => {
|
|
27
|
+
const path = new OpenAPIPath("/v2/domains/{hostname}", {
|
|
28
|
+
hostname: "foo bar/baz?x=1",
|
|
29
|
+
});
|
|
30
|
+
expect(path.buildUrl()).toBe("v2/domains/foo%20bar%2Fbaz%3Fx%3D1");
|
|
31
|
+
});
|
|
32
|
+
test("leaves placeholders in place when no parameters are given", () => {
|
|
33
|
+
expect(new OpenAPIPath("/v2/projects/{projectId}").buildUrl()).toBe("v2/projects/{projectId}");
|
|
34
|
+
});
|
|
35
|
+
test("leaves unrelated placeholders in place", () => {
|
|
36
|
+
const path = new OpenAPIPath("/v2/projects/{projectId}/apps/{appId}", {
|
|
37
|
+
projectId: "abc",
|
|
38
|
+
});
|
|
39
|
+
expect(path.buildUrl()).toBe("v2/projects/abc/apps/{appId}");
|
|
40
|
+
});
|
|
41
|
+
test("only replaces the first occurrence of a repeated placeholder", () => {
|
|
42
|
+
const path = new OpenAPIPath("/v2/{id}/{id}", { id: "abc" });
|
|
43
|
+
expect(path.buildUrl()).toBe("v2/abc/{id}");
|
|
44
|
+
});
|
|
45
|
+
test("does not mutate the raw path between calls", () => {
|
|
46
|
+
const path = new OpenAPIPath("/v2/projects/{projectId}", {
|
|
47
|
+
projectId: "abc",
|
|
48
|
+
});
|
|
49
|
+
expect(path.buildUrl()).toBe(path.buildUrl());
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import Request from "./Request.js";
|
|
2
|
+
import { AxiosError } from "axios";
|
|
2
3
|
import { jest } from "@jest/globals";
|
|
3
4
|
const requestFn = jest.fn();
|
|
4
5
|
const mockedAxios = {
|
|
@@ -118,3 +119,104 @@ describe("dates in requests", () => {
|
|
|
118
119
|
expect(config.data).toBe(data);
|
|
119
120
|
});
|
|
120
121
|
});
|
|
122
|
+
describe("axios config", () => {
|
|
123
|
+
test("resolves path parameters into the url", () => {
|
|
124
|
+
const request = new Request({ path: "/v2/projects/{projectId}", operationId: "get", method: "GET" }, { projectId: "abc" });
|
|
125
|
+
expect(request.requestConfig.url).toBe("v2/projects/abc");
|
|
126
|
+
});
|
|
127
|
+
test("passes the method through", () => {
|
|
128
|
+
const request = new Request({
|
|
129
|
+
path: "/v2/projects",
|
|
130
|
+
operationId: "create",
|
|
131
|
+
method: "POST",
|
|
132
|
+
});
|
|
133
|
+
expect(request.requestConfig.method).toBe("POST");
|
|
134
|
+
});
|
|
135
|
+
test("always accepts every status so error responses are returned", () => {
|
|
136
|
+
const request = new Request({
|
|
137
|
+
path: "/",
|
|
138
|
+
operationId: "test",
|
|
139
|
+
method: "GET",
|
|
140
|
+
});
|
|
141
|
+
const { validateStatus } = request.requestConfig;
|
|
142
|
+
expect(validateStatus(500)).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
test("forwards the request body as data", () => {
|
|
145
|
+
const request = new Request({ path: "/v2/projects", operationId: "create", method: "POST" }, { data: { description: "test" } });
|
|
146
|
+
expect(request.requestConfig.data).toEqual({ description: "test" });
|
|
147
|
+
});
|
|
148
|
+
test("leaves data undefined when the request object has no body", () => {
|
|
149
|
+
const request = new Request({ path: "/v2/projects", operationId: "get", method: "GET" }, { queryParameters: { limit: 1 } });
|
|
150
|
+
expect(request.requestConfig.data).toBeUndefined();
|
|
151
|
+
});
|
|
152
|
+
test("stringifies header values", () => {
|
|
153
|
+
const request = new Request({ path: "/", operationId: "test", method: "GET" }, { headers: { "x-limit": 5, "x-flag": true, "x-name": "foo" } });
|
|
154
|
+
expect(request.requestConfig.headers).toEqual({
|
|
155
|
+
"x-limit": "5",
|
|
156
|
+
"x-flag": "true",
|
|
157
|
+
"x-name": "foo",
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
test("leaves headers undefined when none are given", () => {
|
|
161
|
+
const request = new Request({
|
|
162
|
+
path: "/",
|
|
163
|
+
operationId: "test",
|
|
164
|
+
method: "GET",
|
|
165
|
+
});
|
|
166
|
+
expect(request.requestConfig.headers).toBeUndefined();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
describe("query parameter edge cases", () => {
|
|
170
|
+
const op = { path: "/", operationId: "test", method: "GET" };
|
|
171
|
+
const paramsOf = (queryParameters) => new Request(op, { queryParameters }).requestConfig.params;
|
|
172
|
+
test("omits undefined values", () => {
|
|
173
|
+
expect(paramsOf({ foo: "bar", skipped: undefined })?.toString()).toBe("foo=bar");
|
|
174
|
+
});
|
|
175
|
+
test("keeps null values as the string 'null'", () => {
|
|
176
|
+
expect(paramsOf({ foo: null })?.toString()).toBe("foo=null");
|
|
177
|
+
});
|
|
178
|
+
test("is undefined when no query parameters are given", () => {
|
|
179
|
+
expect(new Request(op).requestConfig.params).toBeUndefined();
|
|
180
|
+
});
|
|
181
|
+
test("passes an URLSearchParams instance through unchanged", () => {
|
|
182
|
+
const searchParams = new URLSearchParams({ foo: "bar" });
|
|
183
|
+
expect(paramsOf(searchParams)).toBe(searchParams);
|
|
184
|
+
});
|
|
185
|
+
test("parses a query string", () => {
|
|
186
|
+
expect(paramsOf("foo=bar&baz=1")?.toString()).toBe("foo=bar&baz=1");
|
|
187
|
+
});
|
|
188
|
+
test("rejects a query parameter type it cannot serialize", () => {
|
|
189
|
+
expect(() => paramsOf(42)).toThrow("Unexpected query parameter type (number)");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
describe("execute", () => {
|
|
193
|
+
const op = { path: "/", operationId: "test", method: "GET" };
|
|
194
|
+
test("resolves with the axios response", async () => {
|
|
195
|
+
const response = { status: 200, data: { ok: true } };
|
|
196
|
+
requestFn.mockResolvedValue(response);
|
|
197
|
+
await expect(new Request(op).execute(mockedAxios)).resolves.toBe(response);
|
|
198
|
+
});
|
|
199
|
+
/**
|
|
200
|
+
* `execute` intends to fall back to `error.response` for HTTP errors, but
|
|
201
|
+
* `AxiosError.from()` builds a fresh error without the response (see
|
|
202
|
+
* axios/lib/core/AxiosError.js), so the fallback never triggers. In practice
|
|
203
|
+
* this is masked by `validateStatus: () => true`, which stops axios from
|
|
204
|
+
* throwing on HTTP errors at all.
|
|
205
|
+
*/
|
|
206
|
+
test("rethrows an axios error that carries a response instead of returning it", async () => {
|
|
207
|
+
const response = { status: 404, data: { message: "nope" } };
|
|
208
|
+
const httpError = Object.assign(new AxiosError("Not Found"), { response });
|
|
209
|
+
requestFn.mockRejectedValue(httpError);
|
|
210
|
+
await expect(new Request(op).execute(mockedAxios)).rejects.toBe(httpError);
|
|
211
|
+
});
|
|
212
|
+
test("rethrows an axios error without a response, such as a network error", async () => {
|
|
213
|
+
const networkError = new AxiosError("Network Error", "ERR_NETWORK");
|
|
214
|
+
requestFn.mockRejectedValue(networkError);
|
|
215
|
+
await expect(new Request(op).execute(mockedAxios)).rejects.toBe(networkError);
|
|
216
|
+
});
|
|
217
|
+
test("rethrows a non-axios error unchanged", async () => {
|
|
218
|
+
const failure = new TypeError("boom");
|
|
219
|
+
requestFn.mockRejectedValue(failure);
|
|
220
|
+
await expect(new Request(op).execute(mockedAxios)).rejects.toBe(failure);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import nock from "nock";
|
|
3
|
+
import ApiClientBase from "../core/ApiClientBase.js";
|
|
4
|
+
import withAccessToken from "./accessToken.js";
|
|
5
|
+
const baseURL = "http://accesstoken.example.com";
|
|
6
|
+
class TestClient extends ApiClientBase {
|
|
7
|
+
getProjects = this.requestFunctionFactory({
|
|
8
|
+
path: "/v2/projects",
|
|
9
|
+
method: "GET",
|
|
10
|
+
operationId: "getProjects",
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
/** Performs a request and returns the headers the server actually received. */
|
|
14
|
+
const capturedHeaders = async (run) => {
|
|
15
|
+
const received = [];
|
|
16
|
+
const scope = nock(baseURL).get("/v2/projects").reply(200, {});
|
|
17
|
+
scope.on("request", (req) => received.push(req.headers));
|
|
18
|
+
await run();
|
|
19
|
+
scope.done();
|
|
20
|
+
return received[0];
|
|
21
|
+
};
|
|
22
|
+
const createInstance = () => axios.create({ baseURL });
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
nock.cleanAll();
|
|
25
|
+
});
|
|
26
|
+
test("sets the default x-access-token header", async () => {
|
|
27
|
+
const instance = withAccessToken(createInstance(), "secret-token");
|
|
28
|
+
const headers = await capturedHeaders(() => instance.get("/v2/projects"));
|
|
29
|
+
expect(headers["x-access-token"]).toBe("secret-token");
|
|
30
|
+
});
|
|
31
|
+
test("uses a custom header name when given", async () => {
|
|
32
|
+
const instance = withAccessToken(createInstance(), "secret-token", "x-my-token");
|
|
33
|
+
const headers = await capturedHeaders(() => instance.get("/v2/projects"));
|
|
34
|
+
expect(headers["x-my-token"]).toBe("secret-token");
|
|
35
|
+
expect(headers["x-access-token"]).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
test("does not overwrite a token that the caller set per request", async () => {
|
|
38
|
+
const instance = withAccessToken(createInstance(), "secret-token");
|
|
39
|
+
const headers = await capturedHeaders(() => instance.get("/v2/projects", {
|
|
40
|
+
headers: { "x-access-token": "per-request-token" },
|
|
41
|
+
}));
|
|
42
|
+
expect(headers["x-access-token"]).toBe("per-request-token");
|
|
43
|
+
});
|
|
44
|
+
test("sends no token when the token is undefined", async () => {
|
|
45
|
+
const instance = withAccessToken(createInstance(), undefined);
|
|
46
|
+
const headers = await capturedHeaders(() => instance.get("/v2/projects"));
|
|
47
|
+
expect(headers["x-access-token"]).toBeUndefined();
|
|
48
|
+
});
|
|
49
|
+
test("returns the given axios instance unchanged", () => {
|
|
50
|
+
const instance = createInstance();
|
|
51
|
+
expect(withAccessToken(instance, "secret-token")).toBe(instance);
|
|
52
|
+
});
|
|
53
|
+
test("configures the axios instance of an ApiClientBase and returns the client", async () => {
|
|
54
|
+
const client = new TestClient(createInstance());
|
|
55
|
+
const returned = withAccessToken(client, "secret-token");
|
|
56
|
+
expect(returned).toBe(client);
|
|
57
|
+
const headers = await capturedHeaders(() => client.getProjects());
|
|
58
|
+
expect(headers["x-access-token"]).toBe("secret-token");
|
|
59
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
import nock from "nock";
|
|
3
|
+
import ApiClientBase from "../core/ApiClientBase.js";
|
|
4
|
+
import { withEventConsistencyHandling } from "./consistencyHandling.js";
|
|
5
|
+
const baseURL = "http://consistency.example.com";
|
|
6
|
+
class TestClient extends ApiClientBase {
|
|
7
|
+
createProject = this.requestFunctionFactory({
|
|
8
|
+
path: "/v2/projects",
|
|
9
|
+
method: "POST",
|
|
10
|
+
operationId: "createProject",
|
|
11
|
+
});
|
|
12
|
+
getProjects = this.requestFunctionFactory({
|
|
13
|
+
path: "/v2/projects",
|
|
14
|
+
method: "GET",
|
|
15
|
+
operationId: "getProjects",
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
let received = [];
|
|
19
|
+
/** Registers an interceptor and records the headers each call sends. */
|
|
20
|
+
const record = (scope) => {
|
|
21
|
+
scope.on("request", (req) => received.push(req.headers));
|
|
22
|
+
return scope;
|
|
23
|
+
};
|
|
24
|
+
const ifEventReached = (index) => received[index]?.["if-event-reached"];
|
|
25
|
+
const createInstance = () => withEventConsistencyHandling(axios.create({ baseURL }));
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
received = [];
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
nock.cleanAll();
|
|
31
|
+
});
|
|
32
|
+
test("sends no if-event-reached header before any mutation happened", async () => {
|
|
33
|
+
record(nock(baseURL).get("/v2/projects").reply(200, {}));
|
|
34
|
+
await createInstance().get("/v2/projects");
|
|
35
|
+
expect(ifEventReached(0)).toBeUndefined();
|
|
36
|
+
});
|
|
37
|
+
test("replays the etag of a mutation on the next read request", async () => {
|
|
38
|
+
record(nock(baseURL)
|
|
39
|
+
.post("/v2/projects")
|
|
40
|
+
.reply(201, {}, { etag: "event-1" })
|
|
41
|
+
.get("/v2/projects")
|
|
42
|
+
.reply(200, {}));
|
|
43
|
+
const instance = createInstance();
|
|
44
|
+
await instance.post("/v2/projects", {});
|
|
45
|
+
await instance.get("/v2/projects");
|
|
46
|
+
expect(ifEventReached(0)).toBeUndefined();
|
|
47
|
+
expect(ifEventReached(1)).toBe("event-1");
|
|
48
|
+
});
|
|
49
|
+
test("keeps replaying the most recent etag", async () => {
|
|
50
|
+
record(nock(baseURL)
|
|
51
|
+
.post("/v2/projects")
|
|
52
|
+
.reply(201, {}, { etag: "event-1" })
|
|
53
|
+
.patch("/v2/projects")
|
|
54
|
+
.reply(200, {}, { etag: "event-2" })
|
|
55
|
+
.get("/v2/projects")
|
|
56
|
+
.reply(200, {}));
|
|
57
|
+
const instance = createInstance();
|
|
58
|
+
await instance.post("/v2/projects", {});
|
|
59
|
+
await instance.patch("/v2/projects", {});
|
|
60
|
+
await instance.get("/v2/projects");
|
|
61
|
+
expect(ifEventReached(2)).toBe("event-2");
|
|
62
|
+
});
|
|
63
|
+
test("does not send if-event-reached on mutating requests", async () => {
|
|
64
|
+
record(nock(baseURL)
|
|
65
|
+
.post("/v2/projects")
|
|
66
|
+
.reply(201, {}, { etag: "event-1" })
|
|
67
|
+
.delete("/v2/projects")
|
|
68
|
+
.reply(204));
|
|
69
|
+
const instance = createInstance();
|
|
70
|
+
await instance.post("/v2/projects", {});
|
|
71
|
+
await instance.delete("/v2/projects");
|
|
72
|
+
expect(ifEventReached(1)).toBeUndefined();
|
|
73
|
+
});
|
|
74
|
+
test("does not overwrite an if-event-reached header set by the caller", async () => {
|
|
75
|
+
record(nock(baseURL)
|
|
76
|
+
.post("/v2/projects")
|
|
77
|
+
.reply(201, {}, { etag: "event-1" })
|
|
78
|
+
.get("/v2/projects")
|
|
79
|
+
.reply(200, {}));
|
|
80
|
+
const instance = createInstance();
|
|
81
|
+
await instance.post("/v2/projects", {});
|
|
82
|
+
await instance.get("/v2/projects", {
|
|
83
|
+
headers: { "if-event-reached": "caller-event" },
|
|
84
|
+
});
|
|
85
|
+
expect(ifEventReached(1)).toBe("caller-event");
|
|
86
|
+
});
|
|
87
|
+
test("ignores the etag of a non-mutating response", async () => {
|
|
88
|
+
record(nock(baseURL)
|
|
89
|
+
.get("/v2/projects")
|
|
90
|
+
.reply(200, {}, { etag: "event-1" })
|
|
91
|
+
.get("/v2/projects")
|
|
92
|
+
.reply(200, {}));
|
|
93
|
+
const instance = createInstance();
|
|
94
|
+
await instance.get("/v2/projects");
|
|
95
|
+
await instance.get("/v2/projects");
|
|
96
|
+
expect(ifEventReached(1)).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
test("ignores a mutating response without an etag", async () => {
|
|
99
|
+
record(nock(baseURL)
|
|
100
|
+
.post("/v2/projects")
|
|
101
|
+
.reply(201, {})
|
|
102
|
+
.get("/v2/projects")
|
|
103
|
+
.reply(200, {}));
|
|
104
|
+
const instance = createInstance();
|
|
105
|
+
await instance.post("/v2/projects", {});
|
|
106
|
+
await instance.get("/v2/projects");
|
|
107
|
+
expect(ifEventReached(1)).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
test("tracks event ids per axios instance", async () => {
|
|
110
|
+
record(nock(baseURL)
|
|
111
|
+
.post("/v2/projects")
|
|
112
|
+
.reply(201, {}, { etag: "event-1" })
|
|
113
|
+
.get("/v2/projects")
|
|
114
|
+
.reply(200, {}));
|
|
115
|
+
await createInstance().post("/v2/projects", {});
|
|
116
|
+
await createInstance().get("/v2/projects");
|
|
117
|
+
expect(ifEventReached(1)).toBeUndefined();
|
|
118
|
+
});
|
|
119
|
+
test("configures the axios instance of an ApiClientBase and returns the client", async () => {
|
|
120
|
+
record(nock(baseURL)
|
|
121
|
+
.post("/v2/projects")
|
|
122
|
+
.reply(201, {}, { etag: "event-1" })
|
|
123
|
+
.get("/v2/projects")
|
|
124
|
+
.reply(200, {}));
|
|
125
|
+
const client = new TestClient(axios.create({ baseURL }));
|
|
126
|
+
const returned = withEventConsistencyHandling(client);
|
|
127
|
+
expect(returned).toBe(client);
|
|
128
|
+
await client.createProject();
|
|
129
|
+
await client.getProjects();
|
|
130
|
+
expect(ifEventReached(1)).toBe("event-1");
|
|
131
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { AxiosHeaders } from "axios";
|
|
2
|
+
import expectStatus from "./expectStatus.js";
|
|
3
|
+
const makeResponse = (status) => ({
|
|
4
|
+
status,
|
|
5
|
+
statusText: "",
|
|
6
|
+
data: {},
|
|
7
|
+
headers: new AxiosHeaders(),
|
|
8
|
+
config: { headers: new AxiosHeaders() },
|
|
9
|
+
request: { some: "request" },
|
|
10
|
+
mediaType: "application/json",
|
|
11
|
+
});
|
|
12
|
+
test("passes for a matching status", () => {
|
|
13
|
+
expect(() => expectStatus(makeResponse(200), 200)).not.toThrow();
|
|
14
|
+
});
|
|
15
|
+
test("fails the surrounding expectation for a mismatching status", () => {
|
|
16
|
+
expect(() => expectStatus(makeResponse(404), 200)).toThrow();
|
|
17
|
+
});
|
|
18
|
+
test("reports the mismatch as a jest assertion, not an ApiClientError", () => {
|
|
19
|
+
expect(() => expectStatus(makeResponse(404), 200)).toThrow(expect.objectContaining({ matcherResult: expect.anything() }));
|
|
20
|
+
});
|
|
21
|
+
test("narrows the response type to the asserted status", () => {
|
|
22
|
+
const response = makeResponse(204);
|
|
23
|
+
expectStatus(response, 204);
|
|
24
|
+
const status = response.status;
|
|
25
|
+
expect(status).toBe(204);
|
|
26
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { AxiosHeaders } from "axios";
|
|
2
|
+
import ApiClientError from "../core/ApiClientError.js";
|
|
3
|
+
import assertStatus from "./assertStatus.js";
|
|
4
|
+
import assertOneOfStatus from "./assertOneOfStatus.js";
|
|
5
|
+
const makeResponse = (status) => ({
|
|
6
|
+
status,
|
|
7
|
+
statusText: "",
|
|
8
|
+
data: {},
|
|
9
|
+
headers: new AxiosHeaders(),
|
|
10
|
+
config: { headers: new AxiosHeaders() },
|
|
11
|
+
request: { some: "request" },
|
|
12
|
+
mediaType: "application/json",
|
|
13
|
+
});
|
|
14
|
+
describe("assertStatus", () => {
|
|
15
|
+
test("passes for a matching status", () => {
|
|
16
|
+
expect(() => assertStatus(makeResponse(200), 200)).not.toThrow();
|
|
17
|
+
});
|
|
18
|
+
test("throws an ApiClientError for a mismatching status", () => {
|
|
19
|
+
expect(() => assertStatus(makeResponse(404), 200)).toThrow(ApiClientError);
|
|
20
|
+
});
|
|
21
|
+
test("names both the expected and the received status", () => {
|
|
22
|
+
expect(() => assertStatus(makeResponse(404), 200)).toThrow("Unexpected response status (expected 200, got: 404)");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
describe("assertOneOfStatus", () => {
|
|
26
|
+
test("passes when the status is one of the expected ones", () => {
|
|
27
|
+
expect(() => assertOneOfStatus(makeResponse(204), [200, 204])).not.toThrow();
|
|
28
|
+
});
|
|
29
|
+
test("throws when the status is not among the expected ones", () => {
|
|
30
|
+
expect(() => assertOneOfStatus(makeResponse(500), [200, 204])).toThrow("Unexpected response status (expected 200,204, got: 500)");
|
|
31
|
+
});
|
|
32
|
+
test("throws for an empty list of expected statuses", () => {
|
|
33
|
+
expect(() => assertOneOfStatus(makeResponse(200), [])).toThrow(ApiClientError);
|
|
34
|
+
});
|
|
35
|
+
test("attaches the response to the error but drops the request", () => {
|
|
36
|
+
let error;
|
|
37
|
+
try {
|
|
38
|
+
assertOneOfStatus(makeResponse(500), [200]);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
error = e;
|
|
42
|
+
}
|
|
43
|
+
expect(error?.response?.status).toBe(500);
|
|
44
|
+
expect(error?.request).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { AxiosHeaders } from "axios";
|
|
2
|
+
import ApiClientError from "../core/ApiClientError.js";
|
|
3
|
+
import { extractTotalCountHeader } from "./extractTotalCountHeader.js";
|
|
4
|
+
const makeResponse = (headers, status = 200) => ({
|
|
5
|
+
status,
|
|
6
|
+
statusText: "",
|
|
7
|
+
data: {},
|
|
8
|
+
headers,
|
|
9
|
+
config: { headers: new AxiosHeaders() },
|
|
10
|
+
request: { some: "request" },
|
|
11
|
+
mediaType: "application/json",
|
|
12
|
+
});
|
|
13
|
+
const withTotalCount = (value) => new AxiosHeaders({ "x-pagination-totalcount": value });
|
|
14
|
+
test("returns the total count as a number", () => {
|
|
15
|
+
expect(extractTotalCountHeader(makeResponse(withTotalCount("42")))).toBe(42);
|
|
16
|
+
});
|
|
17
|
+
test("reads the header case-insensitively", () => {
|
|
18
|
+
const headers = new AxiosHeaders({ "X-Pagination-TotalCount": "7" });
|
|
19
|
+
expect(extractTotalCountHeader(makeResponse(headers))).toBe(7);
|
|
20
|
+
});
|
|
21
|
+
test("returns zero for an empty result set", () => {
|
|
22
|
+
expect(extractTotalCountHeader(makeResponse(withTotalCount("0")))).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
test("rejects a non-200 response before looking at the headers", () => {
|
|
25
|
+
expect(() => extractTotalCountHeader(makeResponse(withTotalCount("42"), 404))).toThrow("Unexpected response status (expected 200, got: 404)");
|
|
26
|
+
});
|
|
27
|
+
test("rejects plain-object headers that axios did not normalize", () => {
|
|
28
|
+
expect(() => extractTotalCountHeader(makeResponse({ "x-pagination-totalcount": "42" }))).toThrow("Expected headers to be of type AxiosHeaders");
|
|
29
|
+
});
|
|
30
|
+
test("rejects a missing header", () => {
|
|
31
|
+
expect(() => extractTotalCountHeader(makeResponse(new AxiosHeaders()))).toThrow("value is not of type string (is undefined instead)");
|
|
32
|
+
});
|
|
33
|
+
test("rejects a non-numeric header value", () => {
|
|
34
|
+
expect(() => extractTotalCountHeader(makeResponse(withTotalCount("not-a-number")))).toThrow("value is not a valid number");
|
|
35
|
+
});
|
|
36
|
+
test("throws an ApiClientError, not a plain Error", () => {
|
|
37
|
+
expect(() => extractTotalCountHeader(makeResponse(withTotalCount("not-a-number")))).toThrow(ApiClientError);
|
|
38
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mittwald/api-client-commons",
|
|
3
|
-
"version": "0.0.0-development-
|
|
3
|
+
"version": "0.0.0-development-fd38fc6-20261012",
|
|
4
4
|
"author": "Mittwald CM Service GmbH & Co. KG <opensource@mittwald.de>",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Common types and utilities for mittwald API clients",
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"build:clean": "run rimraf dist",
|
|
38
38
|
"format": "run prettier --write '**/*.{ts,tsx,yaml,yml,json,md,mdx,js}'",
|
|
39
39
|
"lint": "run eslint .",
|
|
40
|
-
"test": "node --experimental-vm-modules $(yarn bin jest)"
|
|
40
|
+
"test": "node --experimental-vm-modules $(yarn bin jest)",
|
|
41
|
+
"test:coverage": "run test --coverage"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"@types/parse-path": "^7.0.3",
|
|
@@ -79,5 +80,5 @@
|
|
|
79
80
|
"optional": true
|
|
80
81
|
}
|
|
81
82
|
},
|
|
82
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "b4a8cf280ff804dee2c67e6c5919020cd0067c59"
|
|
83
84
|
}
|