Comparing version 1.3.0 to 1.4.0
# Changelog | ||
## 1.4 | ||
- Type validation for query parameters [#19](https://github.com/danvk/crosswalk/issues/19). | ||
Thanks to @CarterGrimmeisen for the contribution! | ||
- Security update for `y18n` [#20](https://github.com/danvk/crosswalk/pull/20) | ||
- Add an option for a custom 400 Invalid Request handler [#19](https://github.com/danvk/crosswalk/pull/19) | ||
- Add `--noExtraProps` to README with some explanatory Q&A. | ||
## 1.3 | ||
@@ -4,0 +12,0 @@ |
@@ -1,11 +0,12 @@ | ||
export interface Endpoint<Request, Response> { | ||
export interface Endpoint<Request, Response, Query = null> { | ||
request: Request; | ||
response: Response; | ||
query: Query; | ||
} | ||
export declare type GetEndpoint<Response> = Endpoint<null, Response>; | ||
export declare type GetEndpoint<Response, Query = null> = Endpoint<null, Response, Query>; | ||
export declare type HTTPVerb = 'get' | 'post' | 'put' | 'delete' | 'patch'; | ||
export interface APISpec { | ||
[path: string]: { | ||
[method in HTTPVerb]?: Endpoint<any, any>; | ||
[method in HTTPVerb]?: Endpoint<any, any, any>; | ||
}; | ||
} |
@@ -77,3 +77,3 @@ "use strict"; | ||
var _d = followApiRef(apiSpec, ref), name_1 = _d[0], schema = _d[1]; | ||
var _e = schema.properties, request = _e.request, response = _e.response; | ||
var _e = schema.properties, request = _e.request, response = _e.response, query = _e.query; | ||
var parameters = extractPathParams(endpoint); | ||
@@ -87,2 +87,12 @@ if ((request === null || request === void 0 ? void 0 : request.type) !== 'null') { | ||
} | ||
if (query.properties) { | ||
for (var _f = 0, _g = Object.entries(query.properties); _f < _g.length; _f++) { | ||
var _h = _g[_f], key = _h[0], value = _h[1]; | ||
parameters.push({ | ||
name: key, | ||
in: 'query', | ||
schema: value, | ||
}); | ||
} | ||
} | ||
var swagger = __assign(__assign({ summary: ref.description }, (parameters.length && { parameters: parameters })), { responses: { | ||
@@ -89,0 +99,0 @@ // TODO: do I need to break this down by status? |
@@ -6,13 +6,65 @@ "use strict"; | ||
exports.STATUS_CODES = [ | ||
100, 101, 102, 103, 200, 201, | ||
202, 203, 204, 205, 206, 207, | ||
208, 226, 300, 301, 302, 303, | ||
304, 305, 307, 308, 400, 401, | ||
402, 403, 404, 405, 406, 407, | ||
408, 409, 410, 411, 412, 413, | ||
414, 415, 416, 417, 418, 421, | ||
422, 423, 424, 425, 426, 428, | ||
429, 431, 451, 500, 501, 502, | ||
503, 504, 505, 506, 507, 508, | ||
509, 510, 511 | ||
100, | ||
101, | ||
102, | ||
103, | ||
200, | ||
201, | ||
202, | ||
203, | ||
204, | ||
205, | ||
206, | ||
207, | ||
208, | ||
226, | ||
300, | ||
301, | ||
302, | ||
303, | ||
304, | ||
305, | ||
307, | ||
308, | ||
400, | ||
401, | ||
402, | ||
403, | ||
404, | ||
405, | ||
406, | ||
407, | ||
408, | ||
409, | ||
410, | ||
411, | ||
412, | ||
413, | ||
414, | ||
415, | ||
416, | ||
417, | ||
418, | ||
421, | ||
422, | ||
423, | ||
424, | ||
425, | ||
426, | ||
428, | ||
429, | ||
431, | ||
451, | ||
500, | ||
501, | ||
502, | ||
503, | ||
504, | ||
505, | ||
506, | ||
507, | ||
508, | ||
509, | ||
510, | ||
511, | ||
]; |
/** Type-safe wrapper around fetch() for REST APIs */ | ||
import { HTTPVerb } from './api-spec'; | ||
import { ExtractRouteParams, SafeKey, DeepReadonly, PathsForMethod } from './utils'; | ||
declare type ExtractRouteParamsVarArgs<T extends string> = {} extends ExtractRouteParams<T> ? [] : [params: Readonly<ExtractRouteParams<T>>]; | ||
declare type PlaceholderEmpty = null | { | ||
[pathParam: string]: never; | ||
}; | ||
declare type ParamVarArgs<Params, Query> = DeepReadonly<[ | ||
Query | ||
] extends [null] ? [{}] extends [Params] ? [] : [params: Params] : [{}, {}] extends [Params, Query] ? [ | ||
params?: PlaceholderEmpty, | ||
query?: Query | ||
] : [ | ||
params: [{}] extends Params ? PlaceholderEmpty : Params, | ||
...query: [{}] extends [Query] ? [query?: Query] : [query: Query] | ||
]>; | ||
/** Utility for safely constructing API URLs */ | ||
export declare function apiUrlMaker<API>(prefix?: string): <Path extends keyof API>(endpoint: Path & string) => (...paramsList: ExtractRouteParamsVarArgs<Path & string>) => string; | ||
export declare function apiUrlMaker<API>(prefix?: string): <Args extends [endpoint: keyof API, method?: AllMethods | undefined], Path extends Args[0] = Args[0], P extends API[Path] = API[Path], AllMethods extends keyof P = keyof P>(...[endpoint, _method]: Args) => (...paramsList: DeepReadonly<[SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>["query" & keyof SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>]] extends [null] ? [{}] extends [string extends Path & string ? Record<Path & string, Path & string> : Path & string extends `${infer _Start}:${infer Param}/${infer Rest}` ? { [k in Param | keyof ExtractRouteParams<Rest>]: string; } : Path & string extends `${infer _Start_1}:${infer Param_1}` ? { [k_1 in Param_1]: string; } : {}] ? [] : [params: string extends Path & string ? Record<Path & string, Path & string> : Path & string extends `${infer _Start}:${infer Param}/${infer Rest}` ? { [k in Param | keyof ExtractRouteParams<Rest>]: string; } : Path & string extends `${infer _Start_1}:${infer Param_1}` ? { [k_1 in Param_1]: string; } : {}] : [{}, {}] extends [string extends Path & string ? Record<Path & string, Path & string> : Path & string extends `${infer _Start}:${infer Param}/${infer Rest}` ? { [k in Param | keyof ExtractRouteParams<Rest>]: string; } : Path & string extends `${infer _Start_1}:${infer Param_1}` ? { [k_1 in Param_1]: string; } : {}, SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>["query" & keyof SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>]] ? [params?: PlaceholderEmpty | undefined, query?: SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>["query" & keyof SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>] | undefined] : [params: [{}] extends (string extends Path & string ? Record<Path & string, Path & string> : Path & string extends `${infer _Start}:${infer Param}/${infer Rest}` ? { [k in Param | keyof ExtractRouteParams<Rest>]: string; } : Path & string extends `${infer _Start_1}:${infer Param_1}` ? { [k_1 in Param_1]: string; } : {}) ? PlaceholderEmpty : string extends Path & string ? Record<Path & string, Path & string> : Path & string extends `${infer _Start}:${infer Param}/${infer Rest}` ? { [k in Param | keyof ExtractRouteParams<Rest>]: string; } : Path & string extends `${infer _Start_1}:${infer Param_1}` ? { [k_1 in Param_1]: string; } : {}, ...query: [{}] extends [SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>["query" & keyof SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>]] ? [query?: SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>["query" & keyof SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>] | undefined] : [query: SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>["query" & keyof SafeKey<P, Args[1] extends undefined ? "get" : Args[1]>]]]>) => string; | ||
export interface Options { | ||
@@ -15,9 +26,11 @@ /** Prefix to add to all API endpoints (e.g. /api/v0) */ | ||
export declare function typedApi<API>(options?: Options): { | ||
get: <Path extends PathsForMethod<API, "get">>(endpoint: Path) => (...params: ExtractRouteParamsVarArgs<Path & string>) => Promise<SafeKey<SafeKey<API[Path], "get">, "response">>; | ||
delete: <Path_1 extends PathsForMethod<API, "delete">>(endpoint: Path_1) => (...params: ExtractRouteParamsVarArgs<Path_1 & string>) => Promise<SafeKey<SafeKey<API[Path_1], "delete">, "response">>; | ||
post: <Path_2 extends PathsForMethod<API, "post">>(endpoint: Path_2) => (queryParams: ExtractRouteParams<Path_2 & string>, body: DeepReadonly<SafeKey<SafeKey<API[Path_2], "post">, "request">>) => Promise<SafeKey<SafeKey<API[Path_2], "post">, "response">>; | ||
patch: <Path_3 extends PathsForMethod<API, "patch">>(endpoint: Path_3) => (queryParams: ExtractRouteParams<Path_3 & string>, body: DeepReadonly<SafeKey<SafeKey<API[Path_3], "patch">, "request">>) => Promise<SafeKey<SafeKey<API[Path_3], "patch">, "response">>; | ||
put: <Path_4 extends PathsForMethod<API, "put">>(endpoint: Path_4) => (queryParams: ExtractRouteParams<Path_4 & string>, body: DeepReadonly<SafeKey<SafeKey<API[Path_4], "put">, "request">>) => Promise<SafeKey<SafeKey<API[Path_4], "put">, "response">>; | ||
request: <Method extends HTTPVerb>(method: Method, path: PathsForMethod<API, Method>) => (queryParams: ExtractRouteParams<PathsForMethod<API, Method>>, body: DeepReadonly<SafeKey<SafeKey<API[PathsForMethod<API, Method>], Method>, "request">>) => Promise<SafeKey<SafeKey<API[PathsForMethod<API, Method>], Method>, "response">>; | ||
get: <Path extends PathsForMethod<API, "get">>(endpoint: Path) => (...params: DeepReadonly<[API[Path]["get" & keyof API[Path]]["query" & keyof API[Path]["get" & keyof API[Path]]]] extends [null] ? [{}] extends [ExtractRouteParams<Path & string>] ? [] : [params: ExtractRouteParams<Path & string>] : [{}, {}] extends [ExtractRouteParams<Path & string>, API[Path]["get" & keyof API[Path]]["query" & keyof API[Path]["get" & keyof API[Path]]]] ? [params?: PlaceholderEmpty | undefined, query?: API[Path]["get" & keyof API[Path]]["query" & keyof API[Path]["get" & keyof API[Path]]] | undefined] : [params: [{}] extends ExtractRouteParams<Path & string> ? PlaceholderEmpty : ExtractRouteParams<Path & string>, ...query: [{}] extends [API[Path]["get" & keyof API[Path]]["query" & keyof API[Path]["get" & keyof API[Path]]]] ? [query?: API[Path]["get" & keyof API[Path]]["query" & keyof API[Path]["get" & keyof API[Path]]] | undefined] : [query: API[Path]["get" & keyof API[Path]]["query" & keyof API[Path]["get" & keyof API[Path]]]]]>) => Promise<API[Path]["get" & keyof API[Path]]["response" & keyof API[Path]["get" & keyof API[Path]]]>; | ||
delete: <Path_1 extends PathsForMethod<API, "delete">>(endpoint: Path_1) => (...params: DeepReadonly<[API[Path_1]["delete" & keyof API[Path_1]]["query" & keyof API[Path_1]["delete" & keyof API[Path_1]]]] extends [null] ? [{}] extends [ExtractRouteParams<Path_1 & string>] ? [] : [params: ExtractRouteParams<Path_1 & string>] : [{}, {}] extends [ExtractRouteParams<Path_1 & string>, API[Path_1]["delete" & keyof API[Path_1]]["query" & keyof API[Path_1]["delete" & keyof API[Path_1]]]] ? [params?: PlaceholderEmpty | undefined, query?: API[Path_1]["delete" & keyof API[Path_1]]["query" & keyof API[Path_1]["delete" & keyof API[Path_1]]] | undefined] : [params: [{}] extends ExtractRouteParams<Path_1 & string> ? PlaceholderEmpty : ExtractRouteParams<Path_1 & string>, ...query: [{}] extends [API[Path_1]["delete" & keyof API[Path_1]]["query" & keyof API[Path_1]["delete" & keyof API[Path_1]]]] ? [query?: API[Path_1]["delete" & keyof API[Path_1]]["query" & keyof API[Path_1]["delete" & keyof API[Path_1]]] | undefined] : [query: API[Path_1]["delete" & keyof API[Path_1]]["query" & keyof API[Path_1]["delete" & keyof API[Path_1]]]]]>) => Promise<API[Path_1]["delete" & keyof API[Path_1]]["response" & keyof API[Path_1]["delete" & keyof API[Path_1]]]>; | ||
post: <Path_2 extends PathsForMethod<API, "post">>(endpoint: Path_2) => (params: ExtractRouteParams<Path_2 & string>, body: DeepReadonly<SafeKey<API[Path_2]["post" & keyof API[Path_2]], "request">>, ...queryArgs: [API[Path_2]["post" & keyof API[Path_2]]["query" & keyof API[Path_2]["post" & keyof API[Path_2]]]] extends [{}] ? [] : [query?: API[Path_2]["post" & keyof API[Path_2]]["query" & keyof API[Path_2]["post" & keyof API[Path_2]]] | undefined]) => Promise<API[Path_2]["post" & keyof API[Path_2]]["response" & keyof API[Path_2]["post" & keyof API[Path_2]]]>; | ||
patch: <Path_3 extends PathsForMethod<API, "patch">>(endpoint: Path_3) => (params: ExtractRouteParams<Path_3 & string>, body: DeepReadonly<SafeKey<API[Path_3]["patch" & keyof API[Path_3]], "request">>, ...queryArgs: [API[Path_3]["patch" & keyof API[Path_3]]["query" & keyof API[Path_3]["patch" & keyof API[Path_3]]]] extends [{}] ? [] : [query?: API[Path_3]["patch" & keyof API[Path_3]]["query" & keyof API[Path_3]["patch" & keyof API[Path_3]]] | undefined]) => Promise<API[Path_3]["patch" & keyof API[Path_3]]["response" & keyof API[Path_3]["patch" & keyof API[Path_3]]]>; | ||
put: <Path_4 extends PathsForMethod<API, "put">>(endpoint: Path_4) => (params: ExtractRouteParams<Path_4 & string>, body: DeepReadonly<SafeKey<API[Path_4]["put" & keyof API[Path_4]], "request">>, ...queryArgs: [API[Path_4]["put" & keyof API[Path_4]]["query" & keyof API[Path_4]["put" & keyof API[Path_4]]]] extends [{}] ? [] : [query?: API[Path_4]["put" & keyof API[Path_4]]["query" & keyof API[Path_4]["put" & keyof API[Path_4]]] | undefined]) => Promise<API[Path_4]["put" & keyof API[Path_4]]["response" & keyof API[Path_4]["put" & keyof API[Path_4]]]>; | ||
request: <Method extends HTTPVerb>(method: Method, path: PathsForMethod<API, Method>) => (params: ExtractRouteParams<Extract<import("./utils").Unionize<API>, { | ||
v: Record<Method, any>; | ||
}>["k"] & keyof API & string>, body: DeepReadonly<SafeKey<API[PathsForMethod<API, Method>][Method & keyof API[PathsForMethod<API, Method>]], "request">>, ...queryArgs: [API[PathsForMethod<API, Method>][Method & keyof API[PathsForMethod<API, Method>]]["query" & keyof API[PathsForMethod<API, Method>][Method & keyof API[PathsForMethod<API, Method>]]]] extends [{}] ? [] : [query?: API[PathsForMethod<API, Method>][Method & keyof API[PathsForMethod<API, Method>]]["query" & keyof API[PathsForMethod<API, Method>][Method & keyof API[PathsForMethod<API, Method>]]] | undefined]) => Promise<API[PathsForMethod<API, Method>][Method & keyof API[PathsForMethod<API, Method>]]["response" & keyof API[PathsForMethod<API, Method>][Method & keyof API[PathsForMethod<API, Method>]]]>; | ||
}; | ||
export {}; |
@@ -45,3 +45,9 @@ "use strict"; | ||
if (prefix === void 0) { prefix = ''; } | ||
return function (endpoint) { | ||
/** This assumes GET for the type of the query params unless another method is specified. */ | ||
return function () { | ||
var _a = []; | ||
for (var _i = 0; _i < arguments.length; _i++) { | ||
_a[_i] = arguments[_i]; | ||
} | ||
var endpoint = _a[0], _method = _a[1]; | ||
var toPath = path_to_regexp_1.compile(endpoint); | ||
@@ -53,3 +59,5 @@ return function () { | ||
} | ||
return prefix + toPath(paramsList[0]); | ||
var params = paramsList; | ||
var queryString = params[1] ? '' + new URLSearchParams(params[1]) : ''; | ||
return prefix + toPath(params[0]) + (queryString ? '?' + queryString : ''); | ||
}; | ||
@@ -86,5 +94,10 @@ }; | ||
var requestWithBody = function (method) { return function (endpoint) { | ||
var makeUrl = urlMaker(endpoint); | ||
return function (queryParams, body) { | ||
return fetcher(makeUrl(queryParams), method, body); | ||
var makeUrl = urlMaker(endpoint, method); | ||
return function () { | ||
var _a = []; | ||
for (var _i = 0; _i < arguments.length; _i++) { | ||
_a[_i] = arguments[_i]; | ||
} | ||
var params = _a[0], body = _a[1], query = _a[2]; | ||
return fetcher(makeUrl(params, query), method, body); | ||
}; | ||
@@ -98,3 +111,3 @@ }; }; | ||
} | ||
return requestWithBody(method)(endpoint)(params === null || params === void 0 ? void 0 : params[0], null); | ||
return requestWithBody(method)(endpoint)(params === null || params === void 0 ? void 0 : params[0], null, params === null || params === void 0 ? void 0 : params[1]); | ||
}; | ||
@@ -101,0 +114,0 @@ }; }; |
/** Type-safe wrapper around Express router for REST APIs */ | ||
/// <reference types="qs" /> | ||
import Ajv from 'ajv'; | ||
@@ -13,5 +12,21 @@ import express from 'express'; | ||
} | ||
declare type AnyEndpoint = Endpoint<any, any>; | ||
declare type ExpressRequest<Path extends string, Spec> = unknown & express.Request<ExtractRouteParams<Path>, SafeKey<Spec, 'response'>, SafeKey<Spec, 'request'>>; | ||
declare type AnyEndpoint = Endpoint<any, any, any>; | ||
declare type ExpressRequest<Path extends string, Spec> = unknown & express.Request<ExtractRouteParams<Path>, SafeKey<Spec, 'response'>, SafeKey<Spec, 'request'>, SafeKey<Spec, 'query'>>; | ||
declare type ExpressResponse<Spec> = unknown & express.Response<SafeKey<Spec, 'response'>>; | ||
export interface InvalidRequestHandlerArgs { | ||
/** Which part of the request was invalid (request body or query parameters)? */ | ||
which: 'body' | 'query'; | ||
request: express.Request; | ||
response: express.Response; | ||
/** The invalid payload object */ | ||
payload: unknown; | ||
/** Ajv validator that found the problem. */ | ||
ajv: Ajv.Ajv; | ||
/** List of errors with the payload, as reported by Ajv. */ | ||
errors: Ajv.ErrorObject[]; | ||
} | ||
export declare function defaultInvalidRequestHandler({ response, payload, ajv, errors, }: InvalidRequestHandlerArgs): void; | ||
export interface TypedRouterOptions { | ||
invalidRequestHandler: (obj: InvalidRequestHandlerArgs) => void; | ||
} | ||
export declare class TypedRouter<API> { | ||
@@ -25,12 +40,13 @@ router: express.Router; | ||
}[]; | ||
constructor(router: express.Router, apiSchema?: any); | ||
get: <Path extends PathsForMethod<API, "get">, Spec extends SafeKey<API[Path], "get"> = SafeKey<API[Path], "get">>(route: Path, handler: (params: ExtractRouteParams<Path>, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, import("qs").ParsedQs, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
delete: <Path extends PathsForMethod<API, "delete">, Spec extends SafeKey<API[Path], "delete"> = SafeKey<API[Path], "delete">>(route: Path, handler: (params: ExtractRouteParams<Path>, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, import("qs").ParsedQs, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
post: <Path extends PathsForMethod<API, "post">, Spec extends SafeKey<API[Path], "post"> = SafeKey<API[Path], "post">>(route: Path, handler: (params: ExtractRouteParams<Path>, body: SafeKey<Spec, "request">, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, import("qs").ParsedQs, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
patch: <Path extends PathsForMethod<API, "patch">, Spec extends SafeKey<API[Path], "patch"> = SafeKey<API[Path], "patch">>(route: Path, handler: (params: ExtractRouteParams<Path>, body: SafeKey<Spec, "request">, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, import("qs").ParsedQs, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
put: <Path extends PathsForMethod<API, "put">, Spec extends SafeKey<API[Path], "put"> = SafeKey<API[Path], "put">>(route: Path, handler: (params: ExtractRouteParams<Path>, body: SafeKey<Spec, "request">, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, import("qs").ParsedQs, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
handleInvalidRequest: TypedRouterOptions['invalidRequestHandler']; | ||
constructor(router: express.Router, apiSchema?: any, options?: TypedRouterOptions); | ||
get: <Path extends PathsForMethod<API, "get">, Spec extends SafeKey<API[Path], "get"> = SafeKey<API[Path], "get">>(route: Path, handler: (params: ExtractRouteParams<Path>, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, SafeKey<Spec, "query">, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
delete: <Path extends PathsForMethod<API, "delete">, Spec extends SafeKey<API[Path], "delete"> = SafeKey<API[Path], "delete">>(route: Path, handler: (params: ExtractRouteParams<Path>, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, SafeKey<Spec, "query">, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
post: <Path extends PathsForMethod<API, "post">, Spec extends SafeKey<API[Path], "post"> = SafeKey<API[Path], "post">>(route: Path, handler: (params: ExtractRouteParams<Path>, body: SafeKey<Spec, "request">, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, SafeKey<Spec, "query">, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
patch: <Path extends PathsForMethod<API, "patch">, Spec extends SafeKey<API[Path], "patch"> = SafeKey<API[Path], "patch">>(route: Path, handler: (params: ExtractRouteParams<Path>, body: SafeKey<Spec, "request">, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, SafeKey<Spec, "query">, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
put: <Path extends PathsForMethod<API, "put">, Spec extends SafeKey<API[Path], "put"> = SafeKey<API[Path], "put">>(route: Path, handler: (params: ExtractRouteParams<Path>, body: SafeKey<Spec, "request">, request: express.Request<ExtractRouteParams<Path>, SafeKey<Spec, "response">, SafeKey<Spec, "request">, SafeKey<Spec, "query">, Record<string, any>>, response: express.Response<SafeKey<Spec, "response">, Record<string, any>>) => Promise<Spec extends AnyEndpoint ? Spec["response"] : never>) => void; | ||
/** Register a handler on the router for the given path and verb */ | ||
registerEndpoint<Method extends HTTPVerb, Path extends PathsForMethod<API, Method>, Spec extends SafeKey<API[Path], Method> = SafeKey<API[Path], Method>>(method: Method, route: Path, handler: (params: ExtractRouteParams<Path>, body: SafeKey<Spec, 'request'>, request: ExpressRequest<Path, Spec>, response: ExpressResponse<Spec>) => Promise<Spec extends AnyEndpoint ? Spec['response'] : never>): void; | ||
/** Get a validation function for request bodies for the endpoint, or null if not applicable. */ | ||
getValidator(route: string, method: HTTPVerb): Ajv.ValidateFunction | null; | ||
getValidator(route: string, method: HTTPVerb, property: 'request' | 'query'): Ajv.ValidateFunction | null; | ||
/** Throw if any routes declared in the API spec have not been implemented. */ | ||
@@ -37,0 +53,0 @@ assertAllRoutesRegistered(): void; |
@@ -11,2 +11,4 @@ "use strict"; | ||
return function (d, b) { | ||
if (typeof b !== "function" && b !== null) | ||
throw new TypeError("Class extends value " + String(b) + " is not a constructor or null"); | ||
extendStatics(d, b); | ||
@@ -32,3 +34,3 @@ function __() { this.constructor = d; } | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.TypedRouter = exports.HTTPError = void 0; | ||
exports.TypedRouter = exports.defaultInvalidRequestHandler = exports.HTTPError = void 0; | ||
var ajv_1 = __importDefault(require("ajv")); | ||
@@ -57,4 +59,14 @@ var status_codes_1 = require("./status-codes"); | ||
}; }; | ||
function defaultInvalidRequestHandler(_a) { | ||
var response = _a.response, payload = _a.payload, ajv = _a.ajv, errors = _a.errors; | ||
response.status(400).json({ | ||
error: ajv.errorsText(errors), | ||
errors: errors, | ||
invalidRequest: payload, | ||
}); | ||
} | ||
exports.defaultInvalidRequestHandler = defaultInvalidRequestHandler; | ||
var TypedRouter = /** @class */ (function () { | ||
function TypedRouter(router, apiSchema) { | ||
function TypedRouter(router, apiSchema, options) { | ||
var _a; | ||
// TODO(danvk): consider replacing get() with a streamlined implementation | ||
@@ -72,2 +84,3 @@ this.get = registerWithoutBody('get', this); | ||
} | ||
this.handleInvalidRequest = (_a = options === null || options === void 0 ? void 0 : options.invalidRequestHandler) !== null && _a !== void 0 ? _a : defaultInvalidRequestHandler; | ||
this.registrations = []; | ||
@@ -78,3 +91,4 @@ } | ||
var _this = this; | ||
var validate = this.getValidator(route, method); | ||
var bodyValidate = this.getValidator(route, method, 'request'); | ||
var queryValidate = this.getValidator(route, method, 'query'); | ||
this.registrations.push({ path: route, method: method }); | ||
@@ -87,13 +101,26 @@ this.router[method](route, function () { | ||
var req = _a[0], response = _a[1], next = _a[2]; | ||
var body = req.body; | ||
if (validate && !validate(body)) { | ||
return response.status(400).json({ | ||
error: _this.ajv.errorsText(validate.errors), | ||
errors: validate.errors, | ||
invalidRequest: body, | ||
var body = req.body, query = req.query; | ||
if (bodyValidate && !bodyValidate(body)) { | ||
return _this.handleInvalidRequest({ | ||
which: 'body', | ||
request: req, | ||
response: response, | ||
ajv: _this.ajv, | ||
payload: body, | ||
errors: bodyValidate.errors, | ||
}); | ||
} | ||
if (queryValidate && !queryValidate(query)) { | ||
return _this.handleInvalidRequest({ | ||
which: 'query', | ||
request: req, | ||
response: response, | ||
ajv: _this.ajv, | ||
payload: query, | ||
errors: queryValidate.errors, | ||
}); | ||
} | ||
if (req.app.get('env') === 'test') { | ||
// eslint-disable-next-line no-console | ||
console.debug(method, route, 'params=', req.params, 'body=', body); | ||
console.debug(method, route, 'params=', req.params, 'body=', body, 'query=', query); | ||
} | ||
@@ -125,3 +152,3 @@ handler(req.params, body, req, response) | ||
/** Get a validation function for request bodies for the endpoint, or null if not applicable. */ | ||
TypedRouter.prototype.getValidator = function (route, method) { | ||
TypedRouter.prototype.getValidator = function (route, method, property) { | ||
var _a; | ||
@@ -139,22 +166,21 @@ var apiSchema = this.apiSchema; | ||
var endpointTypes = apiSchema.definitions[endpoint].properties; | ||
var requestType = endpointTypes.request; | ||
if (requestType.$ref) { | ||
requestType = requestType.$ref; // allow either references or inline types | ||
var validateType = endpointTypes[property]; | ||
if (validateType.$ref) { | ||
validateType = validateType.$ref; // allow either references or inline types | ||
} | ||
else if (requestType.type && requestType.type === 'null') { | ||
requestType = null; // no request body, no validation | ||
else if (validateType.type && validateType.type === 'null') { | ||
validateType = null; // no request body, no validation | ||
} | ||
if (requestType && this.ajv) { | ||
if (validateType && this.ajv) { | ||
var validate = void 0; | ||
if (typeof requestType === 'string') { | ||
validate = (_a = this.ajv.getSchema(requestType)) !== null && _a !== void 0 ? _a : null; | ||
if (typeof validateType === 'string') { | ||
validate = (_a = this.ajv.getSchema(validateType)) !== null && _a !== void 0 ? _a : null; | ||
} | ||
else { | ||
// Create a new AJV validate for inline object types. | ||
// This assumes these will never reference other type definitions. | ||
var requestAjv = new ajv_1.default(); | ||
validate = requestAjv.compile(__assign({ '$schema': apiSchema.$schema, definitions: apiSchema.definitions }, requestType)); | ||
var requestAjv = new ajv_1.default({ coerceTypes: property === 'query' }); | ||
validate = requestAjv.compile(__assign({ $schema: apiSchema.$schema, definitions: apiSchema.definitions }, validateType)); | ||
} | ||
if (!validate) { | ||
throw new Error("Unable to get schema for '" + requestType + "'"); | ||
throw new Error("Unable to get schema for '" + validateType + "'"); | ||
} | ||
@@ -161,0 +187,0 @@ return validate; |
{ | ||
"name": "crosswalk", | ||
"version": "1.3.0", | ||
"version": "1.4.0", | ||
"description": "Type-safe express routing with TypeScript", | ||
@@ -12,4 +12,4 @@ "main": "dist/index.js", | ||
"test": "jest", | ||
"lint": "prettier --check src/**/*.ts", | ||
"prettier": "prettier --write src/**/*.ts", | ||
"lint": "prettier --check 'src/**/*.ts'", | ||
"prettier": "prettier --write 'src/**/*.ts'", | ||
"update-test-schema": "typescript-json-schema --required --noExtraProps --strictNullChecks src/__tests__/api.ts API --out src/__tests__/api.schema.json" | ||
@@ -16,0 +16,0 @@ }, |
102
README.md
@@ -47,3 +47,3 @@ # Crosswalk: safe routes for Express and TypeScript | ||
'/users': { | ||
get: GetEndpoint<UsersResponse>; | ||
get: GetEndpoint<UsersResponse, {query?: string}>; // Response/query parameter types | ||
post: Endpoint<CreateUserRequest, User>; | ||
@@ -64,3 +64,3 @@ }; | ||
export function registerAPI(router: TypedRouter<API>) { | ||
router.get('/users', async () => users; | ||
router.get('/users', async ({}, req, res, {query}) => filterUsersByName(users, query)); | ||
router.post('/users', async ({}, userInput) => createUser(userInput)); | ||
@@ -86,2 +86,3 @@ router.get('/users/:userId', async ({userId}) => getUserById(userId)); | ||
- Types for route parameters (via TypeScript 4.1's template literal types) | ||
- Types for query parameters (and automatic coercion of non-string parameters) | ||
- A check that each endpoint's implementation returns a Promise for the | ||
@@ -129,5 +130,8 @@ expected response type. | ||
const urlMaker = apiUrlMaker<API>('/api/v0'); | ||
const getUserUrl = urlMaker('/users/:userId'); | ||
const fredUrl = getUserUrl({userId: 'fred'}); | ||
const getUserByIdUrl = urlMaker('/users/:userId'); | ||
const fredUrl = getUserByIdUrl({userId: 'fred'}); | ||
// /api/v0/users/fred | ||
const userUrl = urlMaker('/users'); | ||
const fredSearchUrl = userUrl(null, {query: 'fred'}); | ||
// /api/v0/users?query=fred | ||
``` | ||
@@ -140,3 +144,3 @@ | ||
typescript-json-schema --required --strictNullChecks api.ts API --out api.schema.json | ||
typescript-json-schema --required --strictNullChecks --noExtraProps api.ts API --out api.schema.json | ||
@@ -224,2 +228,26 @@ Then pass this to the `TypeRouter` when you create it in `server.ts`: | ||
## Options | ||
The `TypedRouter` class takes the following options. | ||
### invalidRequestHandler | ||
By default, if request validation fails, crosswalk returns a 400 status code and a descriptive | ||
error. If you'd like to do something else, you may specify your own `invalidRequestHandler`. For | ||
example, you might like to log the error or omit validation details from the response in prod. | ||
This is the default implementation (`crosswalk.defaultInvalidRequestHandler`): | ||
```ts | ||
new TypedRouter<API>(app, apiSchema, { | ||
handleInvalidRequest({response, payload, ajv, errors}) { | ||
response.status(400).json({ | ||
error: ajv.errorsText(errors), | ||
errors, | ||
invalidRequest: payload, | ||
}); | ||
} | ||
}); | ||
``` | ||
## Questions | ||
@@ -283,2 +311,39 @@ | ||
**Should I set `noExtraProps` with `typescript-json-schema`?** | ||
There are many options you can set when you run [`typescript-json-schema`][tsjs]. You should think | ||
carefully about these as they have an impact on the runtime behavior of your code. | ||
You should set `strictNullChecks` to whatever it's set to in your `tsconfig.json`. (It should | ||
really be set to `true`!). This ensures that the runtime checking and static type checking are in | ||
agreement. | ||
The `noExtraProps` option is more interesting. TypeScript uses a "structural" or "duck" typing | ||
system. This means that an object may have the declared properties in its type, _but it could have | ||
others, too_! | ||
```ts | ||
interface Hero { | ||
heroName: string; | ||
} | ||
const superman = { | ||
heroName: 'Superman', | ||
alterEgo: 'Clark Kent', | ||
}; | ||
declare function getHeroDetails(hero: Hero): string; | ||
getHeroDetails(superman); // ok! | ||
``` | ||
This is simply the way that TypeScript works, and so it must be the way that crosswalk statically | ||
enforces your request types. If you're comfortable with this behavior, leave `noExtraProps` off. | ||
If you _do_ specify `noExtraProps`, additional properties on a request will result in the request | ||
being rejected at runtime with a 400 HTTP response. This has pros and cons. The pros are that it | ||
will catch more user errors (e.g. misspelling an optional property name) and allows the server to | ||
be more confident about the shape of its input (`Object.keys` won't produce surprises). The con | ||
is that your runtime behavior is divergent from the static type checking, so client code that | ||
passes the type checker might produce failing requests at runtime. Until TypeScript gets | ||
["exact" types][exact], it will not be able to fully model `noExtraProps` statically. | ||
**What's with the name?** | ||
@@ -313,28 +378,2 @@ | ||
## TODO | ||
- [ ] Options for request logging | ||
- [ ] Add an option for more express-like callbacks (w/ only request, response) | ||
- [ ] Support fancier paths | ||
- [ ] Set up: | ||
- [ ] eslint | ||
- [x] prettier | ||
- [x] CI | ||
- [x] Add helper methods for all HTTP verbs | ||
- [x] Look into cleaning up generics | ||
- [x] Set up better type tests | ||
- [x] Narrow types of request.params, request.body in handlers | ||
- [x] Write unit tests | ||
- [x] Decide on a name | ||
- [x] Figure out how to handle `@types` deps (peer deps?) | ||
- [x] Decide on a parameter ordering for methods | ||
- [x] Should TypedRouter be a class ~or a function~? | ||
- [x] Plug into cityci | ||
- [x] Add a check that all endpoints are implemented | ||
- [x] Make a demo project, maybe TODO or based on GraphQL demo | ||
- [x] Add helpers for constructing URLs | ||
- [x] Look into generating API docs, e.g. w/ Swagger | ||
- [x] Make the runtime validation part optional | ||
- [x] Plug in TS 4.1 template literal types | ||
[tsjs]: https://github.com/YousefED/typescript-json-schema | ||
@@ -350,1 +389,2 @@ [ajv]: https://ajv.js.org/ | ||
[swl]: https://sidewalklabs.com/ | ||
[exact]: https://github.com/microsoft/TypeScript/issues/12936 |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
278645
1370
382