-
Notifications
You must be signed in to change notification settings - Fork 22
/
Copy pathrequest.ts
223 lines (194 loc) · 6.84 KB
/
request.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
import * as h from '@api-ts/io-ts-http';
import * as E from 'fp-ts/Either';
import { pipe } from 'fp-ts/pipeable';
import * as t from 'io-ts';
import * as PathReporter from 'io-ts/lib/PathReporter';
import { posix } from 'node:path';
import { URL } from 'whatwg-url';
type SuccessfulResponses<Route extends h.HttpRoute> = {
[R in keyof Route['response']]: {
status: R;
error?: undefined;
body: h.ResponseTypeForStatus<Route['response'], R>;
original: Response;
};
}[keyof Route['response']];
export type DecodedResponse<Route extends h.HttpRoute> =
| SuccessfulResponses<Route>
| {
status: 'decodeError';
error: string;
body: unknown;
original: Response;
};
export class DecodeError extends Error {
readonly decodedResponse: DecodedResponse<h.HttpRoute>;
constructor(message: string, decodedResponse: DecodedResponse<h.HttpRoute>) {
super(message);
this.decodedResponse = decodedResponse;
}
}
const decodedResponse = <Route extends h.HttpRoute>(res: DecodedResponse<Route>) => res;
type ExpectedDecodedResponse<
Route extends h.HttpRoute,
StatusCode extends keyof Route['response'],
> = DecodedResponse<Route> & { status: StatusCode };
type PatchedRequest<Req, Route extends h.HttpRoute> = Req & {
decode: () => Promise<DecodedResponse<Route>>;
decodeExpecting: <StatusCode extends keyof Route['response']>(
status: StatusCode,
) => Promise<ExpectedDecodedResponse<Route, StatusCode>>;
};
type SuperagentLike<Req> = {
[K in h.Method]: (url: string) => Req;
};
export type Response = {
body: unknown;
text: unknown;
status: number;
};
export interface SuperagentRequest<Res extends Response> extends Promise<Res> {
ok(callback: (response: Res) => boolean): this;
query(params: Record<string, string | string[]>): this;
set(name: string, value: string): this;
send(body: string): this;
}
export const substitutePathParams = (path: string, params: Record<string, string>) => {
for (const key in params) {
if (params.hasOwnProperty(key)) {
path = path.replace(`{${key}}`, params[key]);
}
}
return path;
};
export type RequestFactory<Req> = <Route extends h.HttpRoute>(
route: Route,
params: Record<string, string>,
) => Req;
export const superagentRequestFactory =
<Req>(superagent: SuperagentLike<Req>, base: string): RequestFactory<Req> =>
<Route extends h.HttpRoute>(route: Route, params: Record<string, string>) => {
const method = route.method.toLowerCase();
if (!h.Method.is(method)) {
// Not supposed to happen if the route typechecked
throw Error(`Unsupported http method "${route.method}"`);
}
const url = new URL(base);
const substitutedPath = substitutePathParams(route.path, params);
url.pathname = posix.join(url.pathname, substitutedPath);
return superagent[method](url.toString());
};
export const supertestRequestFactory =
<Req>(supertest: SuperagentLike<Req>): RequestFactory<Req> =>
<Route extends h.HttpRoute>(route: Route, params: Record<string, string>) => {
const method = route.method.toLowerCase();
if (!h.Method.is(method)) {
// Not supposed to happen if the route typechecked
throw Error(`Unsupported http method "${route.method}"`);
}
const path = substitutePathParams(route.path, params);
return supertest[method](path);
};
const hasCodecForStatus = <S extends number>(
responses: h.HttpResponse,
status: S,
): responses is { [K in S]: t.Mixed } => {
return status in responses && responses[status] !== undefined;
};
const stringify = (value: unknown): string =>
JSON.stringify(value, (_key: string, value: unknown) =>
typeof value === 'bigint' ? value.toString() : value,
);
const patchRequest = <
Req extends SuperagentRequest<Response>,
Route extends h.HttpRoute,
>(
route: Route,
req: Req,
): PatchedRequest<Req, Route> => {
const patchedReq = req as PatchedRequest<Req, Route>;
patchedReq.decode = () =>
req.then((res) => {
const { body, text, status } = res;
const bodyOrText = body || text;
if (!hasCodecForStatus(route.response, status)) {
return decodedResponse({
// DISCUSS: what's this non-standard HTTP status code?
status: 'decodeError',
error: `No codec for status ${status}`,
body: bodyOrText,
original: res,
});
}
return pipe(
route.response[status].decode(bodyOrText),
E.map((body) =>
decodedResponse<Route>({
status,
body: bodyOrText,
original: res,
} as SuccessfulResponses<Route>),
),
E.getOrElse((error) =>
// DISCUSS: what's this non-standard HTTP status code?
decodedResponse<Route>({
status: 'decodeError',
error: PathReporter.failure(error).join('\n'),
body: bodyOrText,
original: res,
}),
),
);
});
patchedReq.decodeExpecting = <StatusCode extends keyof Route['response']>(
status: StatusCode,
) =>
patchedReq.decode().then((res) => {
if (res.original.status !== status) {
const error = `Unexpected response ${res.original.status}: ${stringify(
res.original.body,
)}`;
throw new DecodeError(error, res as DecodedResponse<h.HttpRoute>);
} else if (res.status === 'decodeError') {
const error = `Could not decode response ${res.original.status}: [${stringify(
res.original.body,
)}] due to error [${res.error}]`;
throw new DecodeError(error, res as DecodedResponse<h.HttpRoute>);
} else {
return res as ExpectedDecodedResponse<Route, StatusCode>;
}
});
// Stop superagent from throwing on non-2xx status codes
patchedReq.ok(() => true);
return patchedReq;
};
export type BoundRequestFactory<
Req extends SuperagentRequest<Response>,
Route extends h.HttpRoute,
> = (params: h.RequestType<Route>) => PatchedRequest<Req, Route>;
export const requestForRoute =
<Req extends SuperagentRequest<Response>, Route extends h.HttpRoute>(
requestFactory: RequestFactory<Req>,
route: Route,
): BoundRequestFactory<Req, Route> =>
(params: h.RequestType<Route>): PatchedRequest<Req, Route> => {
const reqProps = route.request.encode(params);
let path = route.path;
for (const key in reqProps.params) {
if (reqProps.params.hasOwnProperty(key)) {
path = path.replace(`{${key}}`, reqProps.params[key]);
}
}
let request = requestFactory(route, reqProps.params).query(reqProps.query);
const headers = reqProps.headers ?? {};
for (const key in headers) {
if (headers.hasOwnProperty(key)) {
request = request.set(key, headers[key]);
}
}
if (reqProps.body) {
request.set('content-type', 'application/json');
request = request.send(stringify(reqProps.body));
}
return patchRequest(route, request);
};