Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 28 additions & 6 deletions packages/io-ts-http/src/httpResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,19 @@ import * as t from 'io-ts';

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

export type HttpResponse = t.Props;
export type HttpResponse = {
[K in Status]?: t.Mixed;
};

export type KnownResponses<Response extends HttpResponse> = {
[K in keyof Response]: K extends Status ? K : never;
[K in keyof Response]: K extends Status
? undefined extends Response[K]
? never
: K
: never;
}[keyof Response];

export const HttpResponseCodes: { [K in Status]: number } = {
export const HttpResponseCodes = {
ok: 200,
invalidRequest: 400,
unauthenticated: 401,
Expand All @@ -17,7 +23,23 @@ export const HttpResponseCodes: { [K in Status]: number } = {
rateLimitExceeded: 429,
internalError: 500,
serviceUnavailable: 503,
};
} as const;

export type HttpResponseCodes = typeof HttpResponseCodes;

// Create a type-level assertion that the HttpResponseCodes map contains every key
// in the Status union of string literals, and no unexpected keys. Violations of
// this assertion will cause compile-time errors.
//
// Thanks to https://stackoverflow.com/a/67027737
type ShapeOf<T> = Record<keyof T, any>;
type AssertKeysEqual<X extends ShapeOf<Y>, Y extends ShapeOf<X>> = never;
type _AssertHttpStatusCodeIsDefinedForAllResponses = AssertKeysEqual<
{ [K in Status]: number },
HttpResponseCodes
>;

export type KnownHttpStatusCodes<Response extends HttpResponse> =
typeof HttpResponseCodes[KnownResponses<Response>];
export type ResponseTypeForStatus<
Response extends HttpResponse,
S extends keyof Response,
> = Response[S] extends t.Mixed ? t.TypeOf<Response[S]> : never;
15 changes: 10 additions & 5 deletions packages/io-ts-http/src/httpRoute.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as t from 'io-ts';

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

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

Expand All @@ -12,12 +13,16 @@ export type HttpRoute = {
readonly response: HttpResponse;
};

type ResponseItem<Status, Codec extends t.Mixed | undefined> = Codec extends t.Mixed
? {
type: Status;
payload: t.TypeOf<Codec>;
}
: never;

export type RequestType<T extends HttpRoute> = t.TypeOf<T['request']>;
export type ResponseType<T extends HttpRoute> = {
[K in KnownResponses<T['response']>]: {
type: K;
payload: t.TypeOf<T['response'][K]>;
};
[K in KnownResponses<T['response']>]: ResponseItem<K, T['response'][K]>;
}[KnownResponses<T['response']>];

export type ApiSpec = {
Expand Down
52 changes: 29 additions & 23 deletions packages/superagent-wrapper/src/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import type { Response, SuperAgent, SuperAgentRequest } from 'superagent';
import type { SuperTest } from 'supertest';
import { URL } from 'url';
import { pipe } from 'fp-ts/function';
import { Status } from '@api-ts/response';

type SuccessfulResponses<Route extends h.HttpRoute> = {
[Status in h.KnownHttpStatusCodes<Route['response']>]: {
status: Status;
[R in h.KnownResponses<Route['response']>]: {
status: h.HttpResponseCodes[R];
error?: undefined;
body: t.TypeOf<Route['response'][Status]>;
body: h.ResponseTypeForStatus<Route['response'], R>;
original: Response;
};
}[h.KnownHttpStatusCodes<Route['response']>];
}[h.KnownResponses<Route['response']>];

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

type ExpectedDecodedResponse<
Route extends h.HttpRoute,
Status extends h.KnownHttpStatusCodes<Route['response']>,
> = {
body: t.TypeOf<Route['response'][Status]>;
original: Response;
};
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
> = DecodedResponse<Route> & { status: StatusCode };

type PatchedRequest<Req extends SuperAgentRequest, Route extends h.HttpRoute> = Req & {
decode: () => Promise<DecodedResponse<Route>>;
decodeExpecting: <Status extends h.KnownHttpStatusCodes<Route['response']>>(
status: Status,
) => Promise<ExpectedDecodedResponse<Route, Status>>;
decodeExpecting: <
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
>(
status: StatusCode,
) => Promise<ExpectedDecodedResponse<Route, StatusCode>>;
};

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

const hasCodecForStatus = <S extends Status>(
responses: h.HttpResponse,
status: S,
): responses is { [K in S]: t.Mixed } => {
return status in responses && responses[status] !== undefined;
};

const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
route: Route,
req: Req,
Expand All @@ -94,11 +101,11 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
req.then((res) => {
const { body, status: statusCode } = res;

let status: string | undefined;
let status: Status | undefined;
// DISCUSS: Should we have this as a preprocessed const in io-ts-http?
for (const [name, code] of Object.entries(h.HttpResponseCodes)) {
if (statusCode === code) {
status = name;
status = name as Status;
break;
}
}
Expand All @@ -111,7 +118,7 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
});
}

if (route.response[status] === undefined) {
if (!hasCodecForStatus(route.response, status)) {
return decodedResponse({
// DISCUSS: what's this non-standard HTTP status code?
status: 'decodeError',
Expand All @@ -124,10 +131,12 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
route.response[status].decode(res.body),
E.map((body) =>
decodedResponse<Route>({
status: statusCode,
status: statusCode as h.HttpResponseCodes[h.KnownResponses<
Route['response']
>],
body,
original: res,
}),
} as SuccessfulResponses<Route>),
),
E.getOrElse((error) =>
// DISCUSS: what's this non-standard HTTP status code?
Expand All @@ -142,19 +151,16 @@ const patchRequest = <Req extends SuperAgentRequest, Route extends h.HttpRoute>(
});

patchedReq.decodeExpecting = <
Status extends h.KnownHttpStatusCodes<Route['response']>,
StatusCode extends h.HttpResponseCodes[h.KnownResponses<Route['response']>],
>(
status: Status,
status: StatusCode,
) =>
patchedReq.decode().then((res) => {
if (res.status !== status) {
const error = res.error ?? `Unexpected status code ${res.status}`;
throw new Error(JSON.stringify(error));
} else {
return {
body: res.body,
original: res.original,
};
return res as ExpectedDecodedResponse<Route, StatusCode>;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest I actually don't know why the type of res isn't narrowed by control flow here. Maybe just because of some complexity limit?

}
});
return patchedReq;
Expand Down