diff --git a/packages/io-ts-http/src/httpRequest.ts b/packages/io-ts-http/src/httpRequest.ts index b48bc2ec..cf47e7a3 100644 --- a/packages/io-ts-http/src/httpRequest.ts +++ b/packages/io-ts-http/src/httpRequest.ts @@ -2,12 +2,29 @@ import * as t from 'io-ts'; import { Json } from 'io-ts-types'; import { flattened, optional, optionalized } from './combinators'; +/** + * The body of a `GenericHttpRequest`: decodes only JSON, but accepts any decoded value. + * + * A route narrows the body with its own codec, and that codec may decode JSON into + * values that are not JSON themselves, e.g. `BigIntFromString` decodes to `bigint`. + * Typing the decoded body as `unknown` lets such a route intersect with + * `GenericHttpRequest`, while the `Json` output type still requires every route's body + * codec to encode to JSON. Encoding is a pass-through: the route's codec produces the + * actual output. + */ +const JsonBody = new t.Type( + Json.name, + t.unknown.is, + Json.validate, + (body) => body as Json, +); + export const GenericHttpRequest = optionalized({ // DISCUSS: renaming this to something more specific, e.g. route, or path, or routeParams, or pathParams params: t.record(t.string, t.string), query: t.record(t.string, t.union([t.string, t.array(t.string)])), headers: optional(t.record(t.string, t.string)), - body: optional(Json), + body: optional(JsonBody), }); export type HttpRequestCodec = t.Type< diff --git a/packages/io-ts-http/test/httpRequest.test.ts b/packages/io-ts-http/test/httpRequest.test.ts index bf170e94..ba35b1c2 100644 --- a/packages/io-ts-http/test/httpRequest.test.ts +++ b/packages/io-ts-http/test/httpRequest.test.ts @@ -3,7 +3,12 @@ import { strict as assert } from 'node:assert'; import * as PathReporter from 'io-ts/lib/PathReporter'; import * as NEA from 'fp-ts/NonEmptyArray'; import * as t from 'io-ts'; -import { nonEmptyArray, JsonFromString, NumberFromString } from 'io-ts-types'; +import { + nonEmptyArray, + BigIntFromString, + JsonFromString, + NumberFromString, +} from 'io-ts-types'; import { assertLeft, assertRight } from './utils'; @@ -35,6 +40,34 @@ describe('GenericHttpRequest', () => { const output = assertRight(decoded); assert.deepStrictEqual(output, example); }); + + it('accepts route body codecs whose decoded type is not JSON', () => { + const Request = t.intersection([ + h.GenericHttpRequest, + t.type({ body: t.type({ amount: BigIntFromString }) }), + ]); + + const decoded = assertRight( + Request.decode({ params: {}, query: {}, body: { amount: '100' } }), + ); + assert.equal(typeof decoded.body.amount, 'bigint'); + + // A bigint body is a valid decoded request, and it encodes back to JSON. + const request: t.TypeOf = { + params: {}, + query: {}, + body: { amount: decoded.body.amount }, + }; + assert.deepStrictEqual(Request.encode(request), { + params: {}, + query: {}, + body: { amount: '100' }, + }); + }); + + it('still requires a JSON body when decoding', () => { + assertLeft(h.GenericHttpRequest.decode({ params: {}, query: {}, body: () => 0 })); + }); }); describe('httpRequest', () => { diff --git a/packages/superagent-wrapper/test/request.test.ts b/packages/superagent-wrapper/test/request.test.ts index eee3e5ef..309b0dbe 100644 --- a/packages/superagent-wrapper/test/request.test.ts +++ b/packages/superagent-wrapper/test/request.test.ts @@ -6,7 +6,7 @@ import * as h from '@api-ts/io-ts-http'; import express from 'express'; import * as E from 'fp-ts/Either'; import * as t from 'io-ts'; -import { NumberFromString } from 'io-ts-types'; +import { BigIntFromString, NumberFromString } from 'io-ts-types'; import superagent from 'superagent'; import supertest from 'supertest'; import { URL } from 'whatwg-url'; @@ -56,6 +56,22 @@ const HeaderGetTestRoute = h.httpRoute({ }, }); +const BigIntBody = t.type({ + amount: BigIntFromString, +}); + +const PostBigIntRoute = h.httpRoute({ + path: '/bigint', + method: 'POST', + request: t.intersection([h.GenericHttpRequest, t.type({ body: BigIntBody })]), + response: { + 200: t.type({ + amount: BigIntFromString, + receivedAs: t.string, + }), + }, +}); + const TestRoutes = h.apiSpec({ 'api.v1.test': { post: PostTestRoute, @@ -63,6 +79,9 @@ const TestRoutes = h.apiSpec({ 'api.v1.getheader': { get: HeaderGetTestRoute, }, + 'api.v1.bigint': { + post: PostBigIntRoute, + }, }); const createTestServer = (port: number) => { @@ -105,6 +124,13 @@ const createTestServer = (port: number) => { } }); + testApp.post(PostBigIntRoute.path, (req, res) => { + res.send({ + amount: req.body.amount, + receivedAs: typeof req.body.amount, + }); + }); + testApp.get(HeaderGetTestRoute.path, (req, res) => { res.send( HeaderGetTestRoute.response[200].encode({ @@ -159,6 +185,19 @@ describe('decode', () => { }); }); + it('sends bigint body fields as strings', async () => { + const amount = E.getOrElseW(() => { + throw new Error('Failed to decode amount'); + })(BigIntFromString.decode('100')); + + const response = await apiClient['api.v1.bigint'] + .post({ params: {}, query: {}, body: { amount } }) + .decodeExpecting(200); + + assert.equal(response.body.receivedAs, 'string'); + assert.equal(response.body.amount, amount); + }); + it('gracefully handles unknown status codes', async () => { const response = await apiClient['api.v1.test'] .post({ id: 1337, foo: 'test', bar: 42 })