Skip to content
Open
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
19 changes: 18 additions & 1 deletion packages/io-ts-http/src/httpRequest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown, Json, unknown>(
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> = t.Type<
Expand Down
35 changes: 34 additions & 1 deletion packages/io-ts-http/test/httpRequest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<typeof Request> = {
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', () => {
Expand Down
41 changes: 40 additions & 1 deletion packages/superagent-wrapper/test/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -56,13 +56,32 @@ 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,
},
'api.v1.getheader': {
get: HeaderGetTestRoute,
},
'api.v1.bigint': {
post: PostBigIntRoute,
},
});

const createTestServer = (port: number) => {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 })
Expand Down