diff --git a/README.md b/README.md index ac551be..6bad510 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/src/typed-fastify.ts b/src/typed-fastify.ts index ab335a6..d9ada14 100644 --- a/src/typed-fastify.ts +++ b/src/typed-fastify.ts @@ -120,6 +120,12 @@ type MP = | ExtractMethodPath | ExtractMethodPath; +type ExtractParams = T extends `${infer _}:${infer P}/${infer R}` + ? ExtractParams + : T extends `${infer _}:${infer P}` + ? Id + : Acc; + interface Reply< Op extends Operation, Status, @@ -259,6 +265,7 @@ interface Router { // force reply to be never, as we expose it via custom reply interface Reply: never; } +type Id = T extends infer U ? { [K in keyof U]: U[K] } : never; interface Request< ServiceSchema extends Schema, @@ -266,8 +273,18 @@ interface Request< Path extends keyof ServiceSchema['paths'], RawServer extends F.RawServerBase = F.RawServerDefault, RawRequest extends F.RawRequestDefaultExpression = F.RawRequestDefaultExpression, + OpRequest extends Router = Router, + PathParams = OpRequest['Params'] extends never + ? ExtractParams + : Id, keyof OpRequest['Params']>>, > extends Omit< - F.FastifyRequest, RawServer, RawRequest>, + F.FastifyRequest< + OpRequest['Params'] extends [never] + ? Omit, 'Params'> & { Params: PathParams } + : Omit, 'Params'> & { Params: Id['Params']> }, + RawServer, + RawRequest + >, 'headers' | 'method' | 'routerMethod' | 'routerPath' > { readonly method: MP[0]; @@ -277,6 +294,23 @@ interface Request< readonly headers: Get; readonly routerPath: MP[1]; } + +type IsEqual = (() => G extends T ? 1 : 2) extends () => G extends U ? 1 : 2 ? true : false; + +type GetInvalidParamsValidation< + Op extends Operation, + Path extends keyof ServiceSchema['paths'], + ServiceSchema extends Schema, + DifferentKeys = Id['Params'], keyof ExtractParams>>, +> = Router['Params'] extends never + ? false + : IsEqual 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[1], string>} ]`> + : false; + type Handler< Op extends Operation, Path extends keyof ServiceSchema['paths'], @@ -285,8 +319,11 @@ type Handler< RawRequest extends F.RawRequestDefaultExpression = F.RawRequestDefaultExpression, RawReply extends F.RawReplyDefaultExpression = F.RawReplyDefaultExpression, ContextConfig = F.ContextConfigDefault, + InvalidParams = GetInvalidParamsValidation, ValidSchema = [Op['response'][keyof Op['response']]] extends [never] - ? Invalid<` ${Extract} - has no response, every path should have at least one response defined`> + ? Invalid<`${Extract} - 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 diff --git a/tap-snapshots/test/integration.test.ts.test.cjs b/tap-snapshots/test/integration.test.ts.test.cjs index 80b38a5..4f8c076 100644 --- a/tap-snapshots/test/integration.test.ts.test.cjs +++ b/tap-snapshots/test/integration.test.ts.test.cjs @@ -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 [ @@ -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, diff --git a/test/integration.test.ts b/test/integration.test.ts index 83755ae..bce013f 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -31,8 +31,10 @@ const defaultService: Service = { '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({ @@ -69,7 +71,13 @@ const buildApp = async (t: Test, service?: Service) => { 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}`, @@ -81,6 +89,7 @@ const buildApp = async (t: Test, service?: Service) => { }, err: (err) => { if (err.constructor.name !== 'Error') { + console.log(err); t.fail('should not happen', err); } else { t.matchSnapshot(err, 'error logs'); @@ -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'); }); diff --git a/test/invalid-schema.test-d.ts b/test/invalid-schema.test-d.ts index fe57b6a..3641466 100644 --- a/test/invalid-schema.test-d.ts +++ b/test/invalid-schema.test-d.ts @@ -3,6 +3,16 @@ import type { RequestHandler, Schema, Service } from '../src'; interface InvalidSchema extends Schema { paths: { 'GET /invalid': {}; + 'POST /typoInParam/:uuid': { + request: { + params: { + id: string; + }; + }; + response: { + 200: {}; + }; + }; }; } @@ -10,18 +20,25 @@ type GetInvalid = RequestHandler; //@ts-expect-error const getInvalid: GetInvalid['AsRoute'] = (req: any, res: any) => {}; +//@ts-expect-error +const postTypoInParam: RequestHandler['AsRoute'] = (req: any, res: any) => {}; function never(_: never) { throw Error('never'); } never(getInvalid); +never(postTypoInParam); const service: Service = { //@ts-expect-error 'GET /invalid': () => { return undefined; }, + //@ts-expect-error + 'POST /typoInParam/:uuid': () => { + return undefined; + }, }; console.log(service); diff --git a/test/test_schema.gen.json b/test/test_schema.gen.json index 1bec7b2..e34c385 100644 --- a/test/test_schema.gen.json +++ b/test/test_schema.gen.json @@ -230,7 +230,7 @@ } } }, - "POST /paramswithtypo/:Ids/:subid": { + "GET /inferredParams/:id/:castedToNumber": { "request": { "type": "object", "required": [ @@ -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" } \ No newline at end of file diff --git a/test/test_schema.ts b/test/test_schema.ts index c0ab020..af88137 100644 --- a/test/test_schema.ts +++ b/test/test_schema.ts @@ -82,15 +82,16 @@ export interface TestSchema extends Schema { }; }; }; - 'POST /paramswithtypo/:Ids/:subid': { + 'GET /inferredParams/:id/:castedToNumber': { request: { params: { - id: number; - subid: string; + castedToNumber: number; }; }; response: { - 200: {}; + 200: { + content: string; + }; }; }; }; diff --git a/test/typed-fastify.test-d.ts b/test/typed-fastify.test-d.ts index 214d90d..1171186 100644 --- a/test/typed-fastify.test-d.ts +++ b/test/typed-fastify.test-d.ts @@ -1,4 +1,4 @@ -import { expectAssignable, expectType } from 'tsd'; +import { expectAssignable, expectType, expectNotType } from 'tsd'; import type { RequestHandler, Schema, Service } from '../src'; type User = { @@ -594,10 +594,45 @@ expectType>({ interface Params extends Schema { paths: { + 'GET /params/:ids/:subid': { + response: { + 200: {}; + }; + }; + 'GET /mixed/:shouldBeNumber/:string': { + request: { + params: { + shouldBeNumber: number; + }; + }; + response: { + 200: {}; + }; + }; + 'POST /validation/:present/:missing': { + request: { + params: { + present: string; + }; + }; + response: { + 200: {}; + }; + }; + 'POST /typoInParam/:present': { + request: { + params: { + presentTypo: string; + }; + }; + response: { + 200: {}; + }; + }; 'POST /params/:ids/:subid': { request: { params: { - id: string; + ids: string; subid: string; }; }; @@ -609,6 +644,28 @@ interface Params extends Schema { } expectType>({ + 'GET /params/:ids/:subid': (req, reply) => { + req.params.subid; + req.params.ids; + //@ts-expect-error + req.params.subId; + //@ts-expect-error + req.params.wrong; + return reply.status(200).send(); + }, + 'GET /mixed/:shouldBeNumber/:string': (req, reply) => { + expectType(req.params.string); + expectType(req.params.shouldBeNumber); + expectNotType(req.params.shouldBeNumber); + return reply.status(200).send(); + }, + 'POST /validation/:present/:missing': (req, reply) => { + return reply.status(200).send(); + }, + //@ts-expect-error + 'POST /typoInParam/:present': (req, reply) => { + return reply.status(200).send(); + }, 'POST /params/:ids/:subid': (req, reply) => { req.params.subid; return reply.status(200).send();