Skip to content

Commit

Permalink
feat: add cors support for api resource
Browse files Browse the repository at this point in the history
  • Loading branch information
davemooreuws committed Oct 16, 2023
1 parent 92b7266 commit 0f82915
Show file tree
Hide file tree
Showing 2 changed files with 200 additions and 1 deletion.
191 changes: 190 additions & 1 deletion src/resources/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<Defs extends string> {
/**
* The base path for all routes in the API.
Expand All @@ -255,6 +326,24 @@ export interface ApiOptions<Defs extends string> {
* Optional root level security for the API
*/
security?: Record<Defs, string[]>;

/**
* 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 {
Expand All @@ -276,6 +365,7 @@ export class Api<SecurityDefs extends string> extends Base<ApiDetails> {
SecurityDefinition
>;
private readonly security?: Record<SecurityDefs, string[]>;
private readonly cors?: boolean | CorsOptions;

constructor(name: string, options: ApiOptions<SecurityDefs> = {}) {
super(name);
Expand All @@ -284,13 +374,23 @@ export class Api<SecurityDefs extends string> extends Base<ApiDetails> {
path = '/',
securityDefinitions = null,
security = {} as Record<SecurityDefs, string[]>,
cors = true,
} = options;
// prepend / to path if its not there
this.path = path.replace(/^\/?/, '/');
this.middleware = composeMiddleware(middleware);
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,
];
}
}

/**
Expand Down Expand Up @@ -323,7 +423,14 @@ export class Api<SecurityDefs extends string> extends Base<ApiDetails> {
// 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;
}

Expand Down Expand Up @@ -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);
};
};
10 changes: 10 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,13 @@ export type HttpMethod =
| 'PUT'
| 'DELETE'
| 'OPTIONS';

export type Duration = `${number} ${
| 'second'
| 'seconds'
| 'minute'
| 'minutes'
| 'hour'
| 'hours'
| 'day'
| 'days'}`;

0 comments on commit 0f82915

Please sign in to comment.