Skip to content

Commit 1eae9e7

Browse files
authored
Merge pull request BitGo#34 from bitgopatmcl/fix-response-inference
fix: inference of `ResponseType<HttpRoute>`
2 parents 99f0bbb + 01f9c88 commit 1eae9e7

File tree

3 files changed

+67
-34
lines changed

3 files changed

+67
-34
lines changed

packages/io-ts-http/src/httpResponse.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@ import * as t from 'io-ts';
22

33
import { Status } from '@api-ts/response';
44

5-
export type HttpResponse = t.Props;
5+
export type HttpResponse = {
6+
[K in Status]?: t.Mixed;
7+
};
68

79
export type KnownResponses<Response extends HttpResponse> = {
8-
[K in keyof Response]: K extends Status ? K : never;
10+
[K in keyof Response]: K extends Status
11+
? undefined extends Response[K]
12+
? never
13+
: K
14+
: never;
915
}[keyof Response];
1016

11-
export const HttpResponseCodes: { [K in Status]: number } = {
17+
export const HttpResponseCodes = {
1218
ok: 200,
1319
invalidRequest: 400,
1420
unauthenticated: 401,
@@ -17,7 +23,23 @@ export const HttpResponseCodes: { [K in Status]: number } = {
1723
rateLimitExceeded: 429,
1824
internalError: 500,
1925
serviceUnavailable: 503,
20-
};
26+
} as const;
27+
28+
export type HttpResponseCodes = typeof HttpResponseCodes;
29+
30+
// Create a type-level assertion that the HttpResponseCodes map contains every key
31+
// in the Status union of string literals, and no unexpected keys. Violations of
32+
// this assertion will cause compile-time errors.
33+
//
34+
// Thanks to https://stackoverflow.com/a/67027737
35+
type ShapeOf<T> = Record<keyof T, any>;
36+
type AssertKeysEqual<X extends ShapeOf<Y>, Y extends ShapeOf<X>> = never;
37+
type _AssertHttpStatusCodeIsDefinedForAllResponses = AssertKeysEqual<
38+
{ [K in Status]: number },
39+
HttpResponseCodes
40+
>;
2141

22-
export type KnownHttpStatusCodes<Response extends HttpResponse> =
23-
typeof HttpResponseCodes[KnownResponses<Response>];
42+
export type ResponseTypeForStatus<
43+
Response extends HttpResponse,
44+
S extends keyof Response,
45+
> = Response[S] extends t.Mixed ? t.TypeOf<Response[S]> : never;

packages/io-ts-http/src/httpRoute.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import * as t from 'io-ts';
22

33
import { HttpResponse, KnownResponses } from './httpResponse';
4-
import { HttpRequestCodec } from './httpRequest';
4+
import { httpRequest, HttpRequestCodec } from './httpRequest';
5+
import { Status } from '@api-ts/response';
56

67
export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE';
78

@@ -12,12 +13,16 @@ export type HttpRoute = {
1213
readonly response: HttpResponse;
1314
};
1415

16+
type ResponseItem<Status, Codec extends t.Mixed | undefined> = Codec extends t.Mixed
17+
? {
18+
type: Status;
19+
payload: t.TypeOf<Codec>;
20+
}
21+
: never;
22+
1523
export type RequestType<T extends HttpRoute> = t.TypeOf<T['request']>;
1624
export type ResponseType<T extends HttpRoute> = {
17-
[K in KnownResponses<T['response']>]: {
18-
type: K;
19-
payload: t.TypeOf<T['response'][K]>;
20-
};
25+
[K in KnownResponses<T['response']>]: ResponseItem<K, T['response'][K]>;
2126
}[KnownResponses<T['response']>];
2227

2328
export type ApiSpec = {

packages/superagent-wrapper/src/request.ts

Lines changed: 29 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ import type { Response, SuperAgent, SuperAgentRequest } from 'superagent';
55
import type { SuperTest } from 'supertest';
66
import { URL } from 'url';
77
import { pipe } from 'fp-ts/function';
8+
import { Status } from '@api-ts/response';
89

910
type SuccessfulResponses<Route extends h.HttpRoute> = {
10-
[Status in h.KnownHttpStatusCodes<Route['response']>]: {
11-
status: Status;
11+
[R in h.KnownResponses<Route['response']>]: {
12+
status: h.HttpResponseCodes[R];
1213
error?: undefined;
13-
body: t.TypeOf<Route['response'][Status]>;
14+
body: h.ResponseTypeForStatus<Route['response'], R>;
1415
original: Response;
1516
};
16-
}[h.KnownHttpStatusCodes<Route['response']>];
17+
}[h.KnownResponses<Route['response']>];
1718

1819
type DecodedResponse<Route extends h.HttpRoute> =
1920
| SuccessfulResponses<Route>
@@ -28,17 +29,16 @@ const decodedResponse = <Route extends h.HttpRoute>(res: DecodedResponse<Route>)
2829

2930
type ExpectedDecodedResponse<
3031
Route extends h.HttpRoute,
31-
Status extends h.KnownHttpStatusCodes<Route['response']>,
32-
> = {
33-
body: t.TypeOf<Route['response'][Status]>;
34-
original: Response;
35-
};
32+
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
33+
> = DecodedResponse<Route> & { status: StatusCode };
3634

3735
type PatchedRequest<Req extends SuperAgentRequest, Route extends h.HttpRoute> = Req & {
3836
decode: () => Promise<DecodedResponse<Route>>;
39-
decodeExpecting: <Status extends h.KnownHttpStatusCodes<Route['response']>>(
40-
status: Status,
41-
) => Promise<ExpectedDecodedResponse<Route, Status>>;
37+
decodeExpecting: <
38+
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
39+
>(
40+
status: StatusCode,
41+
) => Promise<ExpectedDecodedResponse<Route, StatusCode>>;
4242
};
4343

4444
type SuperagentMethod = 'get' | 'post' | 'put' | 'delete';
@@ -84,6 +84,13 @@ export const supertestRequestFactory =
8484
return supertest[method](path);
8585
};
8686

87+
const hasCodecForStatus = <S extends Status>(
88+
responses: h.HttpResponse,
89+
status: S,
90+
): responses is { [K in S]: t.Mixed } => {
91+
return status in responses && responses[status] !== undefined;
92+
};
93+
8794
const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
8895
route: Route,
8996
req: Req,
@@ -94,11 +101,11 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
94101
req.then((res) => {
95102
const { body, status: statusCode } = res;
96103

97-
let status: string | undefined;
104+
let status: Status | undefined;
98105
// DISCUSS: Should we have this as a preprocessed const in io-ts-http?
99106
for (const [name, code] of Object.entries(h.HttpResponseCodes)) {
100107
if (statusCode === code) {
101-
status = name;
108+
status = name as Status;
102109
break;
103110
}
104111
}
@@ -111,7 +118,7 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
111118
});
112119
}
113120

114-
if (route.response[status] === undefined) {
121+
if (!hasCodecForStatus(route.response, status)) {
115122
return decodedResponse({
116123
// DISCUSS: what's this non-standard HTTP status code?
117124
status: 'decodeError',
@@ -124,10 +131,12 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
124131
route.response[status].decode(res.body),
125132
E.map((body) =>
126133
decodedResponse<Route>({
127-
status: statusCode,
134+
status: statusCode as h.HttpResponseCodes[h.KnownResponses<
135+
Route['response']
136+
>],
128137
body,
129138
original: res,
130-
}),
139+
} as SuccessfulResponses<Route>),
131140
),
132141
E.getOrElse((error) =>
133142
// DISCUSS: what's this non-standard HTTP status code?
@@ -142,19 +151,16 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
142151
});
143152

144153
patchedReq.decodeExpecting = <
145-
Status extends h.KnownHttpStatusCodes<Route['response']>,
154+
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
146155
>(
147-
status: Status,
156+
status: StatusCode,
148157
) =>
149158
patchedReq.decode().then((res) => {
150159
if (res.status !== status) {
151160
const error = res.error ?? `Unexpected status code ${res.status}`;
152161
throw new Error(JSON.stringify(error));
153162
} else {
154-
return {
155-
body: res.body,
156-
original: res.original,
157-
};
163+
return res as ExpectedDecodedResponse<Route, StatusCode>;
158164
}
159165
});
160166
return patchedReq;

0 commit comments

Comments
 (0)