diff --git a/packages/http/src/cookies.ts b/packages/http/src/cookies.ts new file mode 100644 index 000000000..23dfd5d28 --- /dev/null +++ b/packages/http/src/cookies.ts @@ -0,0 +1,62 @@ +import { HttpRequest } from './model.js'; + +export type ParsedCookies = Record; + +export function createLazyCookiesAccessor(requestHeaders: HttpRequest['headers']): ParsedCookies { + const cookieHeaderContents = requestHeaders['cookie']; + if (cookieHeaderContents === undefined) { + return {}; + } + + const parsedCookies: ParsedCookies = {}; + + let wasParsingPerformed = false; + function parseCookies() { + Object.assign(parsedCookies, parseCookiesFromCookieHeader(cookieHeaderContents as string)) + wasParsingPerformed = true; + } + + return new Proxy(parsedCookies, { + get(target, prop, receiver) { + if (!wasParsingPerformed) { parseCookies(); } + return Reflect.get(target, prop, receiver); + }, + + has(target, prop) { + if (!wasParsingPerformed) { parseCookies(); } + return Reflect.has(target, prop); + } + }); +} + + +const validCookieNameRegExp = /^[\w!#$%&'*.^`|~+-]+$/; +const validCookieValueRegExp = /^[ !#-:<-[\]-~]*$/; + +export function parseCookiesFromCookieHeader(cookieHeaderValue: string): ParsedCookies { + const pairs = cookieHeaderValue.trim().split(';'); + + return pairs.reduce((parsedCookie, pairStr) => { + pairStr = pairStr.trim(); + + const valueStartPos = pairStr.indexOf('='); + if (valueStartPos === -1) { + return parsedCookie; + } + + const cookieName = pairStr.substring(0, valueStartPos).trim(); + if (!validCookieNameRegExp.test(cookieName)) { + return parsedCookie; + } + + let cookieValue = pairStr.substring(valueStartPos + 1).trim(); + if (cookieValue.startsWith('"') && cookieValue.endsWith('"')) { + cookieValue = cookieValue.slice(1, -1); + } + if (validCookieValueRegExp.test(cookieValue)) { + parsedCookie[cookieName] = decodeURIComponent(cookieValue); + } + + return parsedCookie; + }, {}); +} diff --git a/packages/http/src/model.ts b/packages/http/src/model.ts index 4577081ba..778cfcd08 100644 --- a/packages/http/src/model.ts +++ b/packages/http/src/model.ts @@ -223,6 +223,26 @@ export type HttpPath = T & TypeAnnota */ export type HttpHeader = T & TypeAnnotation<'httpHeader', Options>; +/** + * Marks a parameter as HTTP cookie and reads the value from the request 'Cookie' header. + * + * @example + * ```typescript + * class Controller { + * @http.GET('/api') + * route(session: HttpCookie) { + * //authorization is string and required + * //use `session?: HttpCookie` to make it optional + * } + * } + * + * // curl /api -H 'Cookie: session=123' + * ``` + * + * To change the cookie name, use `param: HttpCookie`. + */ +export type HttpCookie = T & TypeAnnotation<'httpCookie', Options>; + /** * Marks a parameter as HTTP query and reads the value from the request query string. * diff --git a/packages/http/src/module.ts b/packages/http/src/module.ts index 309a3cd4a..bb7132e52 100644 --- a/packages/http/src/module.ts +++ b/packages/http/src/module.ts @@ -20,7 +20,8 @@ function parameterRequiresRequest(parameter: ReflectionParameter): boolean { || metaAnnotation.getForName(parameter.type, 'httpBody') || metaAnnotation.getForName(parameter.type, 'httpRequestParser') || metaAnnotation.getForName(parameter.type, 'httpPath') - || metaAnnotation.getForName(parameter.type, 'httpHeader')); + || metaAnnotation.getForName(parameter.type, 'httpHeader') + || metaAnnotation.getForName(parameter.type, 'httpCookie')); } export class HttpModule extends createModule({ diff --git a/packages/http/src/request-parser.ts b/packages/http/src/request-parser.ts index 12de43233..efc7f0788 100644 --- a/packages/http/src/request-parser.ts +++ b/packages/http/src/request-parser.ts @@ -26,6 +26,7 @@ import qs from 'qs'; // @ts-ignore import formidable from 'formidable'; import { HttpParserOptions } from './module.config.js'; +import { createLazyCookiesAccessor } from './cookies.js'; function parseBody( @@ -126,6 +127,10 @@ export class ParameterForRequestParser { return metaAnnotation.getForName(this.parameter.type, 'httpHeader') !== undefined; } + get cookie() { + return metaAnnotation.getForName(this.parameter.type, 'httpCookie') !== undefined; + } + get query() { return metaAnnotation.getForName(this.parameter.type, 'httpQuery') !== undefined; } @@ -135,8 +140,12 @@ export class ParameterForRequestParser { } get typePath(): string | undefined { - const typeOptions = metaAnnotation.getForName(this.parameter.type, 'httpQueries') || metaAnnotation.getForName(this.parameter.type, 'httpQuery') - || metaAnnotation.getForName(this.parameter.type, 'httpPath') || metaAnnotation.getForName(this.parameter.type, 'httpHeader'); + const typeOptions = metaAnnotation.getForName(this.parameter.type, 'httpQueries') + || metaAnnotation.getForName(this.parameter.type, 'httpQuery') + || metaAnnotation.getForName(this.parameter.type, 'httpPath') + || metaAnnotation.getForName(this.parameter.type, 'httpHeader') + || metaAnnotation.getForName(this.parameter.type, 'httpCookie'); + if (!typeOptions) return; const options = typeToObject(typeOptions[0]); if (isObject(options)) return options.name; @@ -204,6 +213,7 @@ export function buildRequestParser(parseOptions: HttpParserOptions, parameters: }); compiler.context.set('ValidationError', ValidationError); compiler.context.set('qs', qs); + compiler.context.set('createLazyCookiesAccessor', createLazyCookiesAccessor); let needsQueryString = !!params.find(v => v.query || v.queries || v.requestParser); const query = needsQueryString ? '_qPosition === -1 ? {} : qs.parse(_url.substr(_qPosition + 1))' : '{}'; @@ -214,6 +224,7 @@ export function buildRequestParser(parseOptions: HttpParserOptions, parameters: const _method = request.method || 'GET'; const _url = request.url || '/'; const _headers = request.headers || {}; + const _cookies = createLazyCookiesAccessor(_headers); const _qPosition = _url.indexOf('?'); let uploadedFiles = {}; const _path = _qPosition === -1 ? _url : _url.substr(0, _qPosition); @@ -311,6 +322,24 @@ export function getRequestParserCodeForParameters( parameterNames.push(`parameters.${parameter.parameter.name}`); parameterValidator.push(`${validatorVar}(parameters.${parameter.parameter.name}, {errors: validationErrors}, ${JSON.stringify(parameter.typePath || parameter.getName())});`); + + } else if (parameter.cookie) { + const converted = getSerializeFunction(parameter.parameter.parameter, serializer.deserializeRegistry, undefined, parameter.getName()); + const validator = getValidatorFunction(undefined, parameter.parameter.parameter); + const converterVar = compiler.reserveVariable('argumentConverter', converted); + const validatorVar = compiler.reserveVariable('argumentValidator', validator); + + const cookieProp = JSON.stringify(parameter.typePath || parameter.getName()); + const cookieAccessor = `_cookies[${cookieProp}]`; + + if (isOptional(parameter.parameter.parameter) || hasDefaultValue(parameter.parameter.parameter)) { + setParameters.push(`parameters.${parameter.parameter.name} = ${cookieAccessor} === undefined ? undefined : ${converterVar}(${cookieAccessor}, {loosely: true});`); + } else { + setParameters.push(`parameters.${parameter.parameter.name} = ${converterVar}(${cookieAccessor}, {loosely: true});`); + } + + parameterNames.push(`parameters.${parameter.parameter.name}`); + parameterValidator.push(`${validatorVar}(parameters.${parameter.parameter.name}, {errors: validationErrors}, ${cookieProp});`); } else { parameterNames.push(`parameters.${parameter.parameter.name}`); diff --git a/packages/http/src/router.ts b/packages/http/src/router.ts index b0e1a8b09..b21832391 100644 --- a/packages/http/src/router.ts +++ b/packages/http/src/router.ts @@ -33,6 +33,7 @@ import qs from 'qs'; import { HtmlResponse, JSONResponse, Response } from './http.js'; import { getRequestParserCodeForParameters, ParameterForRequestParser, parseRoutePathToRegex } from './request-parser.js'; import { HttpConfig } from './module.config.js'; +import { createLazyCookiesAccessor } from './cookies'; export type RouteParameterResolverForInjector = ((injector: InjectorContext) => HttpRequestPositionedParameters | Promise); @@ -704,6 +705,7 @@ export class HttpRouter { const compiler = new CompilerContext; compiler.context.set('ValidationError', ValidationError); compiler.context.set('qs', qs); + compiler.context.set('createLazyCookiesAccessor', createLazyCookiesAccessor); const code: string[] = []; @@ -716,6 +718,7 @@ export class HttpRouter { const _method = request.method || 'GET'; const _url = request.url || '/'; const _headers = request.headers || {}; + const _cookies = createLazyCookiesAccessor(_headers); const _qPosition = _url.indexOf('?'); let uploadedFiles = {}; const _path = _qPosition === -1 ? _url : _url.substr(0, _qPosition); diff --git a/packages/http/tests/router.spec.ts b/packages/http/tests/router.spec.ts index 8c5bd4f65..7c51bf8bd 100644 --- a/packages/http/tests/router.spec.ts +++ b/packages/http/tests/router.spec.ts @@ -3,7 +3,7 @@ import { dotToUrlPath, HttpRouter, RouteClassControllerAction, RouteParameterRes import { getActions, http, httpClass } from '../src/decorator.js'; import { HtmlResponse, HttpAccessDeniedError, HttpBadRequestError, HttpUnauthorizedError, httpWorkflow, JSONResponse, Response } from '../src/http.js'; import { eventDispatcher } from '@deepkit/event'; -import { HttpBody, HttpBodyValidation, HttpHeader, HttpPath, HttpQueries, HttpQuery, HttpRegExp, HttpRequest, HttpRequestParser } from '../src/model.js'; +import { HttpBody, HttpBodyValidation, HttpCookie, HttpHeader, HttpPath, HttpQueries, HttpQuery, HttpRegExp, HttpRequest, HttpRequestParser } from '../src/model.js'; import { getClassName, isObject, sleep } from '@deepkit/core'; import { createHttpKernel } from './utils.js'; import { Excluded, Group, integer, JSONEntity, Maximum, metaAnnotation, MinLength, Positive, PrimaryKey, Reference, serializer, Type, typeSettings, UnpopulatedCheck } from '@deepkit/type'; @@ -1156,6 +1156,11 @@ test('parameter from header', async () => { handle2(userId: number, groupId: HttpHeader) { return [userId, groupId]; } + + @http.GET('fourth/:userId') + handle4(userId: number, groupId: HttpHeader, { name: 'gid' }>) { + return [userId, groupId]; + } } const httpKernel = createHttpKernel([Controller], [], [httpWorkflow.onController.listen(async (event) => { @@ -1164,8 +1169,55 @@ test('parameter from header', async () => { })]); expect((await httpKernel.request(HttpRequest.GET('/first/2').header('groupId', 1))).json).toEqual([2, 1]); expect((await httpKernel.request(HttpRequest.GET('/second/2').header('group_id', 1))).json).toEqual([2, 1]); + + + const httpKernel2 = createHttpKernel([Controller]); + expect((await httpKernel2.request(HttpRequest.GET('/fourth/2').header('gid', 'foo'))).json.message).toEqual('Validation error:\ngid(minLength): Min length is 5 caused by value "foo"'); +}); + + + +test('parameter from cookie', async () => { + class Controller { + @http.GET('first/:userId') + handle(userId: number, sessionId: HttpCookie) { + return [userId, sessionId]; + } + + @http.GET('second/:userId') + handle2(userId: number, sessionId: HttpCookie) { + return [userId, sessionId]; + } + + + @http.GET('third/:userId') + handle3(userId: number, sessionId: HttpCookie) { + return [userId, sessionId]; + } + + + @http.GET('minlen/:userId') + handle4(userId: number, sessionId: HttpCookie>) { + return [userId, sessionId]; + } + } + + const httpKernel = createHttpKernel([Controller], [], [httpWorkflow.onController.listen(async (event) => { + expect(event.parameters.arguments).toEqual([2, 'foo']); + expect(event.parameters.parameters).toEqual({ userId: 2, sessionId: 'foo' }); + })]); + expect((await httpKernel.request(HttpRequest.GET('/first/2').header('cookie', 'sessionId=foo'))).json).toEqual([2, 'foo']); + expect((await httpKernel.request(HttpRequest.GET('/second/2').header('cookie', 'sid=foo'))).json).toEqual([2, 'foo']); + + const httpKernel2 = createHttpKernel([Controller]); + expect((await httpKernel2.request(HttpRequest.GET('/second/2').header('cookie', 'sid=""'))).json).toEqual([2, '']); + expect((await httpKernel2.request(HttpRequest.GET('/second/2').header('cookie', 'sid='))).json).toEqual([2, '']); + expect((await httpKernel2.request(HttpRequest.GET('/third/2').header('cookie', 'sid=12'))).json).toEqual([2, 12]); + expect((await httpKernel2.request(HttpRequest.GET('/minlen/2').header('cookie', 'sessionId=tooshort'))).json).toEqual('Validation error:\nsessionId(minLength): Min length is 32 caused by value "tooshort"'); }); + + test('parameter in for listener', async () => { class HttpSession { groupId2?: number;