diff --git a/packages/io-ts-http/src/httpResponse.ts b/packages/io-ts-http/src/httpResponse.ts index 13941f65..bafc380e 100644 --- a/packages/io-ts-http/src/httpResponse.ts +++ b/packages/io-ts-http/src/httpResponse.ts @@ -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 = { - [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, @@ -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 = Record; +type AssertKeysEqual, Y extends ShapeOf> = never; +type _AssertHttpStatusCodeIsDefinedForAllResponses = AssertKeysEqual< + { [K in Status]: number }, + HttpResponseCodes +>; -export type KnownHttpStatusCodes = - typeof HttpResponseCodes[KnownResponses]; +export type ResponseTypeForStatus< + Response extends HttpResponse, + S extends keyof Response, +> = Response[S] extends t.Mixed ? t.TypeOf : never; diff --git a/packages/io-ts-http/src/httpRoute.ts b/packages/io-ts-http/src/httpRoute.ts index f59cfe00..509a4251 100644 --- a/packages/io-ts-http/src/httpRoute.ts +++ b/packages/io-ts-http/src/httpRoute.ts @@ -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'; @@ -12,12 +13,16 @@ export type HttpRoute = { readonly response: HttpResponse; }; +type ResponseItem = Codec extends t.Mixed + ? { + type: Status; + payload: t.TypeOf; + } + : never; + export type RequestType = t.TypeOf; export type ResponseType = { - [K in KnownResponses]: { - type: K; - payload: t.TypeOf; - }; + [K in KnownResponses]: ResponseItem; }[KnownResponses]; export type ApiSpec = { diff --git a/packages/superagent-wrapper/src/request.ts b/packages/superagent-wrapper/src/request.ts index 69396b93..337218b5 100644 --- a/packages/superagent-wrapper/src/request.ts +++ b/packages/superagent-wrapper/src/request.ts @@ -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 = { - [Status in h.KnownHttpStatusCodes]: { - status: Status; + [R in h.KnownResponses]: { + status: h.HttpResponseCodes[R]; error?: undefined; - body: t.TypeOf; + body: h.ResponseTypeForStatus; original: Response; }; -}[h.KnownHttpStatusCodes]; +}[h.KnownResponses]; type DecodedResponse = | SuccessfulResponses @@ -28,17 +29,16 @@ const decodedResponse = (res: DecodedResponse) type ExpectedDecodedResponse< Route extends h.HttpRoute, - Status extends h.KnownHttpStatusCodes, -> = { - body: t.TypeOf; - original: Response; -}; + StatusCode extends h.HttpResponseCodes[h.KnownResponses], +> = DecodedResponse & { status: StatusCode }; type PatchedRequest = Req & { decode: () => Promise>; - decodeExpecting: >( - status: Status, - ) => Promise>; + decodeExpecting: < + StatusCode extends h.HttpResponseCodes[h.KnownResponses], + >( + status: StatusCode, + ) => Promise>; }; type SuperagentMethod = 'get' | 'post' | 'put' | 'delete'; @@ -84,6 +84,13 @@ export const supertestRequestFactory = return supertest[method](path); }; +const hasCodecForStatus = ( + responses: h.HttpResponse, + status: S, +): responses is { [K in S]: t.Mixed } => { + return status in responses && responses[status] !== undefined; +}; + const patchRequest = ( route: Route, req: Req, @@ -94,11 +101,11 @@ const patchRequest = ( 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; } } @@ -111,7 +118,7 @@ const patchRequest = ( }); } - if (route.response[status] === undefined) { + if (!hasCodecForStatus(route.response, status)) { return decodedResponse({ // DISCUSS: what's this non-standard HTTP status code? status: 'decodeError', @@ -124,10 +131,12 @@ const patchRequest = ( route.response[status].decode(res.body), E.map((body) => decodedResponse({ - status: statusCode, + status: statusCode as h.HttpResponseCodes[h.KnownResponses< + Route['response'] + >], body, original: res, - }), + } as SuccessfulResponses), ), E.getOrElse((error) => // DISCUSS: what's this non-standard HTTP status code? @@ -142,19 +151,16 @@ const patchRequest = ( }); patchedReq.decodeExpecting = < - Status extends h.KnownHttpStatusCodes, + StatusCode extends h.HttpResponseCodes[h.KnownResponses], >( - 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; } }); return patchedReq;