From 0f8291554e8cb03290f74b9abf5252da6b4abcf8 Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 16 Oct 2023 17:27:06 +1100 Subject: [PATCH] feat: add cors support for api resource --- src/resources/api.ts | 191 ++++++++++++++++++++++++++++++++++++++++++- src/types.ts | 10 +++ 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/src/resources/api.ts b/src/resources/api.ts index 77ddce85..b5af16be 100644 --- a/src/resources/api.ts +++ b/src/resources/api.ts @@ -26,7 +26,7 @@ import { } from '@nitric/api/proto/resource/v1/resource_pb'; import { fromGrpcError } from '../api/errors'; import resourceClient from './client'; -import { HttpMethod } from '../types'; +import { Duration, HttpMethod } from '../types'; import { make, Resource as Base } from './common'; import path from 'path'; @@ -233,6 +233,77 @@ export interface JwtSecurityDefinition extends BaseSecurityDefinition<'jwt'> { // TODO: Union type for multiple security definition mappings export type SecurityDefinition = JwtSecurityDefinition; +export interface CorsOptions { + /** + * Specifies whether credentials are included in the CORS request. + * + * @default false + */ + allowCredentials?: boolean; + /** + * The collection of allowed headers. + * + * @default Allow all headers. + * + * @example + * ```js + * // Allow all headers + * allowHeaders: ["*"] + * + * // Allow specific headers + * allowHeaders: ["Accept", "Content-Type", "Authorization"] + * ``` + */ + allowHeaders?: string[]; + /** + * The collection of allowed HTTP methods. + * + * @default Allow all methods. + * + * @example + * ```js + * // Allow all methods + * allowMethods: ["ANY"] + * + * // Allow specific methods + * allowMethods: ["GET", "POST"] + * ``` + */ + allowMethods?: HttpMethod[]; + /** + * The collection of allowed origins. + * + * @default Allow all origins. + * + * @example + * ```js + * // Allow all origins + * allowOrigins: ["*"] + * + * // Allow specific origins. Note that the url protocol, ie. "https://", is required. + * allowOrigins: ["https://domain.com"] + * ``` + */ + allowOrigins?: string[]; + /** + * The collection of exposed headers. + * + * @default No expose headers are allowed. + */ + exposeHeaders?: string[]; + /** + * Specify how long the results of a preflight response can be cached + * + * @default No caching + * + * @example + * ```js + * maxAge: "1 day" + * ``` + */ + maxAge?: Duration; +} + export interface ApiOptions { /** * The base path for all routes in the API. @@ -255,6 +326,24 @@ export interface ApiOptions { * Optional root level security for the API */ security?: Record; + + /** + * CORS support applied to all endpoints in this API + * + * @default true + * + * @example + * ```js + * const mainApi = api('main', { + * cors: { + * allowOrigins: ['*'], + * allowMethods: ['GET', 'POST'], + * allowCredentials: true, + * }, + * }); + * ``` + */ + cors?: boolean | CorsOptions; } interface ApiDetails { @@ -276,6 +365,7 @@ export class Api extends Base { SecurityDefinition >; private readonly security?: Record; + private readonly cors?: boolean | CorsOptions; constructor(name: string, options: ApiOptions = {}) { super(name); @@ -284,6 +374,7 @@ export class Api extends Base { path = '/', securityDefinitions = null, security = {} as Record, + cors = true, } = options; // prepend / to path if its not there this.path = path.replace(/^\/?/, '/'); @@ -291,6 +382,15 @@ export class Api extends Base { this.securityDefinitions = securityDefinitions; this.security = security; this.routes = []; + this.cors = cors; + + // if cors is not turned off, apply middleware to handle it + if (cors !== false) { + this.middleware = [ + corsMiddleware(typeof cors === 'object' ? cors : {}), + ...this.middleware, + ]; + } } /** @@ -323,7 +423,14 @@ export class Api extends Base { // join the api level middleware and route level (route options) middleware middleware: [...this.middleware, ...routeMiddleware], }); + + if (this.cors !== false && !this.routes.some((rr) => rr.path === r.path)) { + // register options handler + r.options([]); + } + this.routes.push(r); + return r; } @@ -541,3 +648,85 @@ export const jwt = ( const composeMiddleware = (middleware: HttpMiddleware | HttpMiddleware[]) => Array.isArray(middleware) ? middleware : middleware ? [middleware] : []; + +// Function to convert Duration to seconds +/** + * + * @param duration the duration as a string + * @returns number + */ +function durationToSeconds(duration: Duration): number { + const [amount, unit] = duration.split(' '); + + switch (unit) { + case 'second': + case 'seconds': + return parseInt(amount, 10); + case 'minute': + case 'minutes': + return parseInt(amount, 10) * 60; + case 'hour': + case 'hours': + return parseInt(amount, 10) * 3600; + case 'day': + case 'days': + return parseInt(amount, 10) * 86400; + default: + throw new Error('Invalid duration unit'); + } +} + +const defaultCorsOptions: CorsOptions = { + allowOrigins: ['*'], + allowHeaders: ['Content-Type', 'Authorization'], + allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowCredentials: false, + exposeHeaders: [], + maxAge: '300 seconds', +}; + +const corsMiddleware = (options: CorsOptions): HttpMiddleware => { + const { + allowCredentials, + allowHeaders, + allowMethods, + allowOrigins, + exposeHeaders, + maxAge, + } = { + ...defaultCorsOptions, + ...options, + }; + + return (ctx, next) => { + ctx.res.headers['Access-Control-Allow-Origin'] = allowOrigins; + + ctx.res.headers['Access-Control-Allow-Methods'] = [allowMethods.join(', ')]; + + ctx.res.headers['Access-Control-Allow-Headers'] = [allowHeaders.join(', ')]; + + if (allowCredentials) { + ctx.res.headers['Access-Control-Allow-Credentials'] = ['true']; + } + + if (exposeHeaders.length) { + ctx.res.headers['Access-Control-Expose-Headers'] = [ + exposeHeaders.join(', '), + ]; + } + + if (maxAge) { + ctx.res.headers['Access-Control-Max-Age'] = [ + durationToSeconds(maxAge).toString(), + ]; + } + + // Handle preflight requests + if (ctx.req.method === 'OPTIONS') { + // No Content + ctx.res.status = 204; + } + + return next(ctx); + }; +}; diff --git a/src/types.ts b/src/types.ts index 492e8a12..c49e2523 100644 --- a/src/types.ts +++ b/src/types.ts @@ -62,3 +62,13 @@ export type HttpMethod = | 'PUT' | 'DELETE' | 'OPTIONS'; + +export type Duration = `${number} ${ + | 'second' + | 'seconds' + | 'minute' + | 'minutes' + | 'hour' + | 'hours' + | 'day' + | 'days'}`;