Skip to content

Commit

Permalink
feat(@whook/cors): add an error wrapper for CORS
Browse files Browse the repository at this point in the history
When an error was thrown at the router level, it had no CORS due to the fact that it did not hit any
handler and subsequently nor the handler CORS wrapper. This commit add a way to override the
errorHandler of a Whook server for both defaut and custom handlers.
  • Loading branch information
nfroidure committed Sep 26, 2021
1 parent de204fd commit 081a1cc
Show file tree
Hide file tree
Showing 14 changed files with 4,236 additions and 11,405 deletions.
15,291 changes: 3,953 additions & 11,338 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 1 addition & 2 deletions packages/whook-aws-lambda/src/wrappers/awsHTTPLambda.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@ import {
} from '@whook/http-router';
import stream from 'stream';
import qs from 'qs';
import { noop, compose } from '@whook/whook';
import { lowerCaseHeaders } from '@whook/cors';
import { noop, compose, lowerCaseHeaders } from '@whook/whook';
import type {
ServiceInitializer,
Dependencies,
Expand Down
38 changes: 31 additions & 7 deletions packages/whook-cors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@

[//]: # (::contents:start)

This [Whook](https://github.com/nfroidure/whook) wrapper provides
CORS support by adding it to your OpenAPI file and creating the
handlers that runs the OPTIONS method when you cannot do it at
the proxy/gateway level.
This [Whook](https://github.com/nfroidure/whook) wrapper provides CORS support
by adding it to your OpenAPI file and creating the handlers that runs the
OPTIONS method when you cannot do it at the proxy/gateway level.

## Usage

To use this module, simply add it to your `WRAPPERS` service
(usually in `src/services/WRAPPERS.ts`):
To use this module, simply add it to your `WRAPPERS` service (usually in
`src/services/WRAPPERS.ts`):

```diff
import { service } from 'knifecycle';
+ import { wrapHandlerWithCors } from '@whook/cors';
Expand All @@ -38,6 +38,7 @@ async function initWrappers(): Promise<WhookWrapper<any, any>[]> {
```

And add the CORS config (usually in `src/config/common/config.js`):

```diff
+ import type {
+ CORSConfig,
Expand Down Expand Up @@ -78,7 +79,30 @@ export type APIHandlerDefinition = WhookAPIHandlerDefinition<
export default CONFIG;
```

You should also use the wrapped error handler:

```diff
+ import { initErrorHandlerWithCORS } from '@whook/cors';

// ...

export async function prepareEnvironment<T extends Knifecycle<Dependencies>>(
$: T = new Knifecycle() as T,
): Promise<T> {

//...

+ // Add the CORS wrapped error handler
+ $.register(initErrorHandlerWithCORS);

return $;
}
```

Alternatively, you could wrape your custom error handler with the `wrap

Finally, you must adapt the API service to handle CORS options:

```diff
+ import { augmentAPIWithCORS } from '@whook/cors';

Expand All @@ -102,7 +126,7 @@ async function initAPI({
```

To see a real example have a look at the
[`@whook/example`](https://github.com/nfroidure/whook/tree/master/packages/whook-example).
[`@whook/example`](https://github.com/nfroidure/whook/tree/master/packages/whook-example).

[//]: # (::contents:end)

Expand Down
3 changes: 2 additions & 1 deletion packages/whook-cors/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"@whook/http-router": "^8.3.0",
"@whook/whook": "^8.4.1",
"knifecycle": "^11.1.1",
"openapi-types": "^9.3.0"
"openapi-types": "^9.3.0",
"yhttperror": "^6.0.1"
},
"devDependencies": {
"@babel/cli": "^7.13.14",
Expand Down
6 changes: 3 additions & 3 deletions packages/whook-cors/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ describe('wrapHandlerWithCORS', () => {
"access-control-allow-headers": "Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent",
"access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS",
"access-control-allow-origin": "*",
"vary": "Origin",
"vary": "origin",
},
"status": 200,
},
Expand Down Expand Up @@ -97,7 +97,7 @@ describe('wrapHandlerWithCORS', () => {
"access-control-allow-headers": "Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent",
"access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS",
"access-control-allow-origin": "*",
"vary": "Origin",
"vary": "origin",
},
"status": 200,
},
Expand Down Expand Up @@ -137,7 +137,7 @@ describe('wrapHandlerWithCORS', () => {
"access-control-allow-headers": "Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent",
"access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS",
"access-control-allow-origin": "*",
"vary": "Origin",
"vary": "origin",
},
"status": 200,
},
Expand Down
55 changes: 7 additions & 48 deletions packages/whook-cors/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import SwaggerParser from '@apidevtools/swagger-parser';
import { extractOperationSecurityParameters } from '@whook/http-router';
import initOptionsWithCORS from './handlers/optionsWithCORS';
import { wrapInitializer, alsoInject } from 'knifecycle';
import { identity } from '@whook/whook';
import { mergeVaryHeaders, lowerCaseHeaders } from '@whook/whook';
import type { ServiceInitializer, Parameters } from 'knifecycle';
import type {
WhookResponse,
Expand All @@ -11,6 +11,12 @@ import type {
WhookAPIHandlerDefinition,
} from '@whook/whook';
import type { OpenAPIV3 } from 'openapi-types';
import initErrorHandlerWithCORS, {
wrapErrorHandlerForCORS,
isGetter,
} from './services/errorHandler';

export { initErrorHandlerWithCORS, wrapErrorHandlerForCORS };

// Ensures a deterministic canonical operation
const METHOD_CORS_PRIORITY = ['head', 'get', 'post', 'put', 'delete', 'patch'];
Expand Down Expand Up @@ -56,19 +62,6 @@ export function wrapHandlerWithCORS<D, S extends WhookHandler>(
}, augmentedInitializer);
}

function isGetter(obj: unknown, prop: string): boolean {
if (typeof obj[prop] === 'undefined' || obj[prop] === null) {
// Property not defined in obj, should be safe to write this property
return false;
}
try {
return !!Object.getOwnPropertyDescriptor(obj, prop)['get'];
} catch (err) {
// Error while getting the descriptor, should be only a get
return true;
}
}

async function handleWithCORS<
R extends WhookResponse,
O extends WhookAPIHandlerDefinition<WhookAPIOperationCORSConfig>,
Expand Down Expand Up @@ -215,37 +208,3 @@ export async function augmentAPIWithCORS(
}

export { initOptionsWithCORS };

function mergeVaryHeaders(
baseHeader: string | string[],
addedValue: string,
): string {
const baseHeaderValues = (
baseHeader instanceof Array ? baseHeader : [baseHeader]
)
.map((value) =>
value
.split(',')
.filter(identity)
.map((v) => v.trim().toLowerCase()),
)
.reduce((allValues, values) => [...allValues, ...values], []);

if (baseHeaderValues.includes('*')) {
return '*';
}

return [...new Set([...baseHeaderValues, addedValue])].join(', ');
}

export function lowerCaseHeaders<T>(
object: Record<string, T>,
): Record<string, T> {
return Object.keys(object).reduce(
(finalObject, key) => ({
...finalObject,
[key.toLowerCase()]: object[key],
}),
{},
);
}
80 changes: 80 additions & 0 deletions packages/whook-cors/src/services/errorHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { initErrorHandler } from '@whook/http-router';
import { wrapInitializer, alsoInject } from 'knifecycle';
import { noop } from '@whook/whook';
import YHTTPError from 'yhttperror';
import type { LogService } from 'common-services';
import type {
WhookErrorHandler,
ErrorHandlerDependencies,
} from '@whook/http-router';
import type { WhookCORSConfig } from '..';
import { lowerCaseHeaders } from '@whook/whook';
import { mergeVaryHeaders } from '@whook/whook';

type ErrorHandlerWrapperDependencies = WhookCORSConfig & { log?: LogService };

export default alsoInject<
ErrorHandlerWrapperDependencies,
ErrorHandlerDependencies,
WhookErrorHandler
>(['?log', 'CORS'], wrapInitializer(wrapErrorHandlerForCORS, initErrorHandler));

/**
* Wrap the error handler service as a last chance to add CORS
* @param {Object} services
* The services ENV depends on
* @param {Object} services.NODE_ENV
* The injected NODE_ENV value to add it to the build env
* @param {Object} [services.PROXYED_ENV_VARS={}]
* A list of environment variable names to proxy
* @param {Object} [services.log=noop]
* An optional logging service
* @return {Promise<Object>}
* A promise of an object containing the reshaped env vars.
*/
export async function wrapErrorHandlerForCORS(
{
log = noop,
CORS,
}: ErrorHandlerWrapperDependencies & ErrorHandlerDependencies,
errorHandler: WhookErrorHandler,
): Promise<WhookErrorHandler> {
log('info', '🕱 -Wrapping the error handler for CORS.');

const wrappedErrorHandler: WhookErrorHandler = async (
transactionId,
responseSpec,
err,
) => {
// Test if setter is available, could produce another error if err only has a getter
if (!isGetter(err, 'headers')) {
(err as YHTTPError).headers = {
...lowerCaseHeaders(CORS),
// Ensures to not override existing CORS headers
// that could have been set in the handler wrapper
// with endpoint specific CORS values
...lowerCaseHeaders((err as YHTTPError).headers || {}),
vary: mergeVaryHeaders(
((err as YHTTPError).headers || {}).vary || '',
'Origin',
),
};
}
return errorHandler(transactionId, responseSpec, err);
};

return wrappedErrorHandler;
}

export function isGetter(obj: unknown, prop: string): boolean {
if (typeof obj[prop] === 'undefined' || obj[prop] === null) {
// Property not defined in obj, should be safe to write this property
return false;
}
try {
return !!Object.getOwnPropertyDescriptor(obj, prop)['get'];
} catch (err) {
// Error while getting the descriptor, should be only a get
return true;
}
}
19 changes: 15 additions & 4 deletions packages/whook-example/src/__snapshots__/index.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,22 @@ Object {
],
],
"headers": Object {
"access-control-allow-headers": "Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent",
"access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS",
"access-control-allow-origin": "*",
"cache-control": "private",
"connection": "close",
"content-type": "application/json",
"date": undefined,
"transaction-id": "1",
"transfer-encoding": "chunked",
"vary": "origin",
},
"logErrorCalls": Array [],
"logInfoCalls": Array [
Array [
"CALL",
"{\\"id\\":\\"1\\",\\"protocol\\":\\"http\\",\\"ip\\":\\"127.0.0.1\\",\\"startInBytes\\":370,\\"startOutBytes\\":0,\\"startTime\\":1390694400000,\\"url\\":\\"/v4/diag\\",\\"method\\":\\"GET\\",\\"reqHeaders\\":{\\"accept\\":\\"application/json, text/plain, */*\\",\\"authorization\\":\\"Bearer eyJ...xZ0\\",\\"user-agent\\":\\"__avoid_axios_version__\\",\\"host\\":\\"localhost:9999\\",\\"connection\\":\\"close\\"},\\"errored\\":true,\\"endTime\\":1390694400000,\\"endInBytes\\":370,\\"endOutBytes\\":530,\\"statusCode\\":404,\\"resHeaders\\":{\\"content-type\\":\\"application/json\\",\\"cache-control\\":\\"private\\"},\\"operationId\\":\\"none\\"}",
"{\\"id\\":\\"1\\",\\"protocol\\":\\"http\\",\\"ip\\":\\"127.0.0.1\\",\\"startInBytes\\":370,\\"startOutBytes\\":0,\\"startTime\\":1390694400000,\\"url\\":\\"/v4/diag\\",\\"method\\":\\"GET\\",\\"reqHeaders\\":{\\"accept\\":\\"application/json, text/plain, */*\\",\\"authorization\\":\\"Bearer eyJ...xZ0\\",\\"user-agent\\":\\"__avoid_axios_version__\\",\\"host\\":\\"localhost:9999\\",\\"connection\\":\\"close\\"},\\"errored\\":true,\\"endTime\\":1390694400000,\\"endInBytes\\":370,\\"endOutBytes\\":780,\\"statusCode\\":404,\\"resHeaders\\":{\\"content-type\\":\\"application/json\\",\\"access-control-allow-origin\\":\\"*\\",\\"access-control-allow-methods\\":\\"GET,POST,PUT,DELETE,OPTIONS\\",\\"access-control-allow-headers\\":\\"Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent\\",\\"vary\\":\\"origin\\",\\"cache-control\\":\\"private\\"},\\"operationId\\":\\"none\\"}",
],
Array [
"ERROR",
Expand Down Expand Up @@ -80,18 +84,22 @@ Object {
],
],
"headers": Object {
"access-control-allow-headers": "Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent",
"access-control-allow-methods": "GET,POST,PUT,DELETE,OPTIONS",
"access-control-allow-origin": "*",
"cache-control": "private",
"connection": "close",
"content-type": "application/json",
"date": undefined,
"transaction-id": "2",
"transfer-encoding": "chunked",
"vary": "origin",
},
"logErrorCalls": Array [],
"logInfoCalls": Array [
Array [
"CALL",
"{\\"id\\":\\"2\\",\\"protocol\\":\\"http\\",\\"ip\\":\\"127.0.0.1\\",\\"startInBytes\\":175,\\"startOutBytes\\":0,\\"startTime\\":1390694400000,\\"url\\":\\"/v4/diag\\",\\"method\\":\\"GET\\",\\"reqHeaders\\":{\\"accept\\":\\"application/json, text/plain, */*\\",\\"authorization\\":\\"Fa...n\\",\\"user-agent\\":\\"__avoid_axios_version__\\",\\"host\\":\\"localhost:9999\\",\\"connection\\":\\"close\\"},\\"errored\\":true,\\"endTime\\":1390694400000,\\"endInBytes\\":175,\\"endOutBytes\\":530,\\"statusCode\\":404,\\"resHeaders\\":{\\"content-type\\":\\"application/json\\",\\"cache-control\\":\\"private\\"},\\"operationId\\":\\"none\\"}",
"{\\"id\\":\\"2\\",\\"protocol\\":\\"http\\",\\"ip\\":\\"127.0.0.1\\",\\"startInBytes\\":175,\\"startOutBytes\\":0,\\"startTime\\":1390694400000,\\"url\\":\\"/v4/diag\\",\\"method\\":\\"GET\\",\\"reqHeaders\\":{\\"accept\\":\\"application/json, text/plain, */*\\",\\"authorization\\":\\"Fa...n\\",\\"user-agent\\":\\"__avoid_axios_version__\\",\\"host\\":\\"localhost:9999\\",\\"connection\\":\\"close\\"},\\"errored\\":true,\\"endTime\\":1390694400000,\\"endInBytes\\":175,\\"endOutBytes\\":780,\\"statusCode\\":404,\\"resHeaders\\":{\\"content-type\\":\\"application/json\\",\\"access-control-allow-origin\\":\\"*\\",\\"access-control-allow-methods\\":\\"GET,POST,PUT,DELETE,OPTIONS\\",\\"access-control-allow-headers\\":\\"Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent\\",\\"vary\\":\\"origin\\",\\"cache-control\\":\\"private\\"},\\"operationId\\":\\"none\\"}",
],
],
"status": 404,
Expand Down Expand Up @@ -124,14 +132,14 @@ Object {
"date": undefined,
"transaction-id": "0",
"transfer-encoding": "chunked",
"vary": "Origin",
"vary": "origin",
"x-node-env": "test",
},
"logErrorCalls": Array [],
"logInfoCalls": Array [
Array [
"CALL",
"{\\"id\\":\\"0\\",\\"protocol\\":\\"http\\",\\"ip\\":\\"127.0.0.1\\",\\"startInBytes\\":146,\\"startOutBytes\\":0,\\"startTime\\":1390694400000,\\"url\\":\\"/v4/ping\\",\\"method\\":\\"GET\\",\\"reqHeaders\\":{\\"accept\\":\\"application/json, text/plain, */*\\",\\"user-agent\\":\\"__avoid_axios_version__\\",\\"host\\":\\"localhost:9999\\",\\"connection\\":\\"close\\"},\\"errored\\":false,\\"endTime\\":1390694400000,\\"endInBytes\\":146,\\"endOutBytes\\":447,\\"statusCode\\":200,\\"resHeaders\\":{\\"content-type\\":\\"application/json\\",\\"X-Node-ENV\\":\\"test\\",\\"access-control-allow-origin\\":\\"*\\",\\"access-control-allow-methods\\":\\"GET,POST,PUT,DELETE,OPTIONS\\",\\"access-control-allow-headers\\":\\"Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent\\",\\"vary\\":\\"Origin\\"},\\"operationId\\":\\"getPing\\"}",
"{\\"id\\":\\"0\\",\\"protocol\\":\\"http\\",\\"ip\\":\\"127.0.0.1\\",\\"startInBytes\\":146,\\"startOutBytes\\":0,\\"startTime\\":1390694400000,\\"url\\":\\"/v4/ping\\",\\"method\\":\\"GET\\",\\"reqHeaders\\":{\\"accept\\":\\"application/json, text/plain, */*\\",\\"user-agent\\":\\"__avoid_axios_version__\\",\\"host\\":\\"localhost:9999\\",\\"connection\\":\\"close\\"},\\"errored\\":false,\\"endTime\\":1390694400000,\\"endInBytes\\":146,\\"endOutBytes\\":447,\\"statusCode\\":200,\\"resHeaders\\":{\\"content-type\\":\\"application/json\\",\\"X-Node-ENV\\":\\"test\\",\\"access-control-allow-origin\\":\\"*\\",\\"access-control-allow-methods\\":\\"GET,POST,PUT,DELETE,OPTIONS\\",\\"access-control-allow-headers\\":\\"Accept,Accept-Encoding,Accept-Language,Referrer,Content-Type,Content-Encoding,Authorization,Keep-Alive,User-Agent\\",\\"vary\\":\\"origin\\"},\\"operationId\\":\\"getPing\\"}",
],
],
"status": 200,
Expand Down Expand Up @@ -865,6 +873,9 @@ Object {
Array [
"JWT service initialized!",
],
Array [
"🕱 -Wrapping the error handler for CORS.",
],
],
}
`;
4 changes: 4 additions & 0 deletions packages/whook-example/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
initAPIDefinitions,
} from '@whook/whook';
import initHTTPRouter from '@whook/http-router';
import { initErrorHandlerWithCORS } from '@whook/cors';
import wrapHTTPRouterWithSwaggerUI from '@whook/swagger-ui';
import type { DependencyDeclaration, Dependencies } from 'knifecycle';

Expand Down Expand Up @@ -169,5 +170,8 @@ export async function prepareEnvironment<T extends Knifecycle<Dependencies>>(
constant('WHOOK_PLUGINS', ['@whook/cli', '@whook/whook', '@whook/cors']),
);

// Add the CORS wrapped error handler
$.register(initErrorHandlerWithCORS);

return $;
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,7 @@ import {
getBody,
sendBody,
} from '@whook/http-router';
import { noop, compose, identity } from '@whook/whook';
import { lowerCaseHeaders } from '@whook/cors';
import { noop, compose, identity, lowerCaseHeaders } from '@whook/whook';
import stream from 'stream';
import type { WhookQueryStringParser } from '@whook/http-router';
import type { ServiceInitializer, Dependencies, Service } from 'knifecycle';
Expand Down
2 changes: 2 additions & 0 deletions packages/whook-http-router/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import type {
WhookErrorsDescriptors,
WhookErrorDescriptor,
ErrorHandlerConfig,
ErrorHandlerDependencies,
WhookErrorHandler,
} from './services/errorHandler';
import type { ValidateFunction } from 'ajv';
Expand All @@ -84,6 +85,7 @@ function identity<T>(x: T): T {

export type {
WhookErrorHandler,
ErrorHandlerDependencies,
WhookErrorsDescriptors,
WhookErrorDescriptor,
ErrorHandlerConfig,
Expand Down
Loading

0 comments on commit 081a1cc

Please sign in to comment.