Skip to content

Commit

Permalink
Merge pull request #41 from Coobaha/feat/typed-params
Browse files Browse the repository at this point in the history
feat: add typed params infered from route path
  • Loading branch information
Coobaha authored Jan 4, 2022
2 parents 68ec5de + 55de676 commit 85d3053
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 120 deletions.
28 changes: 1 addition & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ things:
- `request.headers`
- `request.querystring`
- `request.params`
- `route.path.params` are also inferred and mapped to `request.params`, it is also not possible to make a typo in schema params
- `reply` is always based on status, developer won't be able to use plain `reply.send()` but
forced to explicitly set status first, based on which response type will be inferred
- JSON schema generation from TS Schema (using [typescript-json-schema](https://github.com/YousefED/typescript-json-schema) with custom
Expand Down Expand Up @@ -191,30 +192,3 @@ addSchema(app, {
},
});
```

### Note about request.params

Route path params (string tokens) are not validated on type level. This means that it is possible to
make a typo:

```typescript
// Invalid example that demonstrates typo in params tokens
interface InvalidParams extends Schema {
paths: {
'GET /params/:ANOTHER_ID': {
// fastify will map it to { ANOTHER_ID: string }
request: {
params: {
id: number;
};
};
response: {
200: {};
};
};
};
}
```

As our schema expects params to be `{ id: number }` - typo will result in validation error if
generated JSON schemas are used or invalid runtime assumptions about data if plain types are used
41 changes: 39 additions & 2 deletions src/typed-fastify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@ type MP<T extends string | number | symbol> =
| ExtractMethodPath<T, 'PUT'>
| ExtractMethodPath<T, 'OPTIONS'>;

type ExtractParams<T extends string | number | symbol, Acc = {}> = T extends `${infer _}:${infer P}/${infer R}`
? ExtractParams<R, Acc & { [_ in P]: string }>
: T extends `${infer _}:${infer P}`
? Id<Acc & { [_ in P]: string }>
: Acc;

interface Reply<
Op extends Operation,
Status,
Expand Down Expand Up @@ -259,15 +265,26 @@ interface Router<Op extends Operation> {
// force reply to be never, as we expose it via custom reply interface
Reply: never;
}
type Id<T> = T extends infer U ? { [K in keyof U]: U[K] } : never;

interface Request<
ServiceSchema extends Schema,
Op extends Operation,
Path extends keyof ServiceSchema['paths'],
RawServer extends F.RawServerBase = F.RawServerDefault,
RawRequest extends F.RawRequestDefaultExpression<RawServer> = F.RawRequestDefaultExpression<RawServer>,
OpRequest extends Router<Op> = Router<Op>,
PathParams = OpRequest['Params'] extends never
? ExtractParams<Path>
: Id<Omit<ExtractParams<Path>, keyof OpRequest['Params']>>,
> extends Omit<
F.FastifyRequest<Router<Op>, RawServer, RawRequest>,
F.FastifyRequest<
OpRequest['Params'] extends [never]
? Omit<Router<Op>, 'Params'> & { Params: PathParams }
: Omit<Router<Op>, 'Params'> & { Params: Id<PathParams & Router<Op>['Params']> },
RawServer,
RawRequest
>,
'headers' | 'method' | 'routerMethod' | 'routerPath'
> {
readonly method: MP<Path>[0];
Expand All @@ -277,6 +294,23 @@ interface Request<
readonly headers: Get<Op['request'], 'headers'>;
readonly routerPath: MP<Path>[1];
}

type IsEqual<T, U> = (<G>() => G extends T ? 1 : 2) extends <G>() => G extends U ? 1 : 2 ? true : false;

type GetInvalidParamsValidation<
Op extends Operation,
Path extends keyof ServiceSchema['paths'],
ServiceSchema extends Schema,
DifferentKeys = Id<Omit<Router<Op>['Params'], keyof ExtractParams<Path>>>,
> = Router<Op>['Params'] extends never
? false
: IsEqual<DifferentKeys, {}> extends false
? Invalid<`request.params keys doesn't match params from router path, probably due to typo in [ ${Extract<
keyof DifferentKeys,
string
>} ] in path: [ ${Extract<MP<Path>[1], string>} ]`>
: false;

type Handler<
Op extends Operation,
Path extends keyof ServiceSchema['paths'],
Expand All @@ -285,8 +319,11 @@ type Handler<
RawRequest extends F.RawRequestDefaultExpression<RawServer> = F.RawRequestDefaultExpression<RawServer>,
RawReply extends F.RawReplyDefaultExpression<RawServer> = F.RawReplyDefaultExpression<RawServer>,
ContextConfig = F.ContextConfigDefault,
InvalidParams = GetInvalidParamsValidation<Op, Path, ServiceSchema>,
ValidSchema = [Op['response'][keyof Op['response']]] extends [never]
? Invalid<` ${Extract<Path, string>} - has no response, every path should have at least one response defined`>
? Invalid<`${Extract<Path, string>} - has no response, every path should have at least one response defined`>
: InvalidParams extends Invalid
? InvalidParams
: true,
Status extends keyof Op['response'] = keyof Op['response'],
> = ValidSchema extends true
Expand Down
117 changes: 48 additions & 69 deletions tap-snapshots/test/integration.test.ts.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,54 @@ Object {
}
`

exports[`test/integration.test.ts TAP GET /inferredParams/:id > request path:GET /inferredParams/321/123 id:req-1 1`] = `
Object {
"Body": null,
"Headers": Object {
"host": "localhost:80",
"user-agent": "lightMyRequest",
},
"Params": Object {
"castedToNumber": "123",
"id": "321",
},
"Query": Null Object {},
"schema": Object {
"params": Object {
"properties": Object {
"castedToNumber": Object {
"type": "number",
},
},
"required": Array [
"castedToNumber",
],
"type": "object",
},
"response": Object {
"200": Object {
"type": "string",
},
},
},
}
`

exports[`test/integration.test.ts TAP GET /inferredParams/:id > response path:GET /inferredParams/321/123 id:req-1 1`] = `
Object {
"Headers": Array [
"HTTP/1.1 200 OK",
"content-type: text/plain; charset=utf-8",
"content-length: 51",
"Date: dateString",
"Connection: keep-alive",
],
"Payload": Array [
"id type is string and castedToNumber type is number",
],
}
`

exports[`test/integration.test.ts TAP POST / rejects invalid payload > error logs 1`] = `
Error: body.user.name should be string {
"validation": Array [
Expand Down Expand Up @@ -340,75 +388,6 @@ Object {
}
`

exports[`test/integration.test.ts TAP POST /paramswithtypo/:Ids/:subid > error logs 1`] = `
Error: params.id should be number {
"validation": Array [
Object {
"dataPath": ".id",
"keyword": "type",
"message": "should be number",
"params": Object {
"type": "number",
},
"schemaPath": "#/properties/id/type",
},
],
"validationContext": "params",
}
`

exports[`test/integration.test.ts TAP POST /paramswithtypo/:Ids/:subid > request path:POST /params/paramswithtypo/22 id:req-1 1`] = `
Object {
"Body": null,
"Headers": Object {
"host": "localhost:80",
"user-agent": "lightMyRequest",
},
"Params": Object {
"id": "paramswithtypo",
"subid": "22",
},
"Query": Null Object {},
"schema": Object {
"params": Object {
"properties": Object {
"id": Object {
"type": "number",
},
"subid": Object {
"type": "string",
},
},
"required": Array [
"id",
"subid",
],
"type": "object",
},
"response": Object {},
},
}
`

exports[`test/integration.test.ts TAP POST /paramswithtypo/:Ids/:subid > response path:POST /params/paramswithtypo/22 id:req-1 1`] = `
Object {
"Headers": Array [
"HTTP/1.1 400 Bad Request",
"content-type: application/json; charset=utf-8",
"content-length: 79",
"Date: dateString",
"Connection: keep-alive",
],
"Payload": Array [
Object {
"error": "Bad Request",
"message": "params.id should be number",
"statusCode": 400,
},
],
}
`

exports[`test/integration.test.ts TAP POST /redirect works > request path:POST /redirect id:req-1 1`] = `
Object {
"Body": null,
Expand Down
23 changes: 16 additions & 7 deletions test/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ const defaultService: Service<TestSchema> = {
'POST /params/:id/:subid': (req, reply) => {
return reply.status(200).send();
},
'POST /paramswithtypo/:Ids/:subid': (req, reply) => {
return reply.status(200).send();
//@ts-ignore
'GET /inferredParams/:id/:castedToNumber': (req, reply) => {
const payload = `id type is ${typeof req.params.id} and castedToNumber type is ${typeof req.params.castedToNumber}`;
return reply.status(200).send(payload);
},
'POST /testframe': (req, reply) => {
return reply.status(200).send({
Expand Down Expand Up @@ -69,7 +71,13 @@ const buildApp = async (t: Test, service?: Service<TestSchema>) => {
if (res.raw.finished) {
t.matchSnapshot(
{
Payload: res.raw._lightMyRequest.payloadChunks.map((x: any) => JSON.parse(x.toString())),
Payload: res.raw._lightMyRequest.payloadChunks.map((x: any) => {
try {
return JSON.parse(x.toString());
} catch (e) {
return x.toString();
}
}),
Headers: res.raw._header?.split('\r\n').filter(Boolean),
},
`response path:${res.request.method} ${res.request.url} id:${res.request.id}`,
Expand All @@ -81,6 +89,7 @@ const buildApp = async (t: Test, service?: Service<TestSchema>) => {
},
err: (err) => {
if (err.constructor.name !== 'Error') {
console.log(err);
t.fail('should not happen', err);
} else {
t.matchSnapshot(err, 'error logs');
Expand Down Expand Up @@ -281,12 +290,12 @@ t.test('POST /testframe works', async (t) => {
t.equal(res.statusCode, 200);
});

t.test('POST /paramswithtypo/:Ids/:subid', async (t) => {
t.test('GET /inferredParams/:id', async (t) => {
const app = await buildApp(t);
const res = await app.inject({
url: '/params/paramswithtypo/22',
method: 'POST',
url: '/inferredParams/321/123',
method: 'GET',
});

t.equal(res.statusCode, 400);
t.equal(res.body, 'id type is string and castedToNumber type is number');
});
17 changes: 17 additions & 0 deletions test/invalid-schema.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,42 @@ import type { RequestHandler, Schema, Service } from '../src';
interface InvalidSchema extends Schema {
paths: {
'GET /invalid': {};
'POST /typoInParam/:uuid': {
request: {
params: {
id: string;
};
};
response: {
200: {};
};
};
};
}

type GetInvalid = RequestHandler<InvalidSchema, 'GET /invalid'>;

//@ts-expect-error
const getInvalid: GetInvalid['AsRoute'] = (req: any, res: any) => {};
//@ts-expect-error
const postTypoInParam: RequestHandler<InvalidSchema, 'POST /typoInParam/:uuid'>['AsRoute'] = (req: any, res: any) => {};

function never(_: never) {
throw Error('never');
}

never(getInvalid);
never(postTypoInParam);

const service: Service<InvalidSchema> = {
//@ts-expect-error
'GET /invalid': () => {
return undefined;
},
//@ts-expect-error
'POST /typoInParam/:uuid': () => {
return undefined;
},
};

console.log(service);
18 changes: 9 additions & 9 deletions test/test_schema.gen.json
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@
}
}
},
"POST /paramswithtypo/:Ids/:subid": {
"GET /inferredParams/:id/:castedToNumber": {
"request": {
"type": "object",
"required": [
Expand All @@ -240,22 +240,22 @@
"params": {
"type": "object",
"required": [
"id",
"subid"
"castedToNumber"
],
"properties": {
"id": {
"castedToNumber": {
"type": "number"
},
"subid": {
"type": "string"
}
}
}
}
},
"response": {}
"response": {
"200": {
"type": "string"
}
}
}
},
"$hash": "c164cd6bd2d34892c6a7ff0e78015f6d1088a6ab33515bc4b7bf80cac5b972c2__v0.5.0"
"$hash": "c0e0481a3e03369a96f41b124dd2d9ad3fa1efd144dd9bf45a4c4fac7a15820b__v0.5.0"
}
Loading

0 comments on commit 85d3053

Please sign in to comment.