Skip to content

Commit

Permalink
feat(http): added cookie helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
alpharder committed Jul 4, 2024
1 parent 1e0d489 commit 11e3aef
Show file tree
Hide file tree
Showing 6 changed files with 171 additions and 4 deletions.
62 changes: 62 additions & 0 deletions packages/http/src/cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { HttpRequest } from './model.js';

export type ParsedCookies = Record<string, string>;

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<ParsedCookies>((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;
}, {});
}
20 changes: 20 additions & 0 deletions packages/http/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,26 @@ export type HttpPath<T, Options extends { name?: string } = {}> = T & TypeAnnota
*/
export type HttpHeader<T, Options extends { name?: string } = {}> = 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<string>) {
* //authorization is string and required
* //use `session?: HttpCookie<string>` to make it optional
* }
* }
*
* // curl /api -H 'Cookie: session=123'
* ```
*
* To change the cookie name, use `param: HttpCookie<string, {name: 'cookieName'}>`.
*/
export type HttpCookie<T, Options extends { name?: string } = {}> = T & TypeAnnotation<'httpCookie', Options>;

/**
* Marks a parameter as HTTP query and reads the value from the request query string.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/http/src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
33 changes: 31 additions & 2 deletions packages/http/src/request-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down Expand Up @@ -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))' : '{}';
Expand All @@ -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);
Expand Down Expand Up @@ -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}`);

Expand Down
3 changes: 3 additions & 0 deletions packages/http/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpRequestPositionedParameters>);

Expand Down Expand Up @@ -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[] = [];

Expand All @@ -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);
Expand Down
54 changes: 53 additions & 1 deletion packages/http/tests/router.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1156,6 +1156,11 @@ test('parameter from header', async () => {
handle2(userId: number, groupId: HttpHeader<number, { name: 'group_id' }>) {
return [userId, groupId];
}

@http.GET('fourth/:userId')
handle4(userId: number, groupId: HttpHeader<string & MinLength<5>, { name: 'gid' }>) {
return [userId, groupId];
}
}

const httpKernel = createHttpKernel([Controller], [], [httpWorkflow.onController.listen(async (event) => {
Expand All @@ -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<string>) {
return [userId, sessionId];
}

@http.GET('second/:userId')
handle2(userId: number, sessionId: HttpCookie<string, { name: 'sid' }>) {
return [userId, sessionId];
}


@http.GET('third/:userId')
handle3(userId: number, sessionId: HttpCookie<number, { name: 'sid' }>) {
return [userId, sessionId];
}


@http.GET('minlen/:userId')
handle4(userId: number, sessionId: HttpCookie<string & MinLength<32>>) {
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;
Expand Down

0 comments on commit 11e3aef

Please sign in to comment.