Skip to content

Commit

Permalink
feat(AWS HTTP API): CORS configuration
Browse files Browse the repository at this point in the history
  • Loading branch information
medikoo committed Feb 17, 2020
1 parent adcf5be commit ca69387
Show file tree
Hide file tree
Showing 4 changed files with 266 additions and 1 deletion.
40 changes: 40 additions & 0 deletions docs/providers/aws/events/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,43 @@ functions:
method: GET
path: /get/for/any/{param}
```

## CORS Setup

With HTTP API we may configure CORS headers that'll be effective for all configured endpoints.

Default CORS configuration can be turned on with:

```yaml
provider:
httpApi:
cors: true
```

It'll result with headers as:

| Header | Value |
| :--------------------------- | :------------------------------------------------------------------------------------------ |
| Access-Control-Allow-Origin | \* |
| Access-Control-Allow-Headers | Content-Type, X-Amz-Date, Authorization, X-Api-Key, X-Amz-Security-Token, X-Amz-User-Agent) |
| Access-Control-Allow-Methods | OPTIONS, _(...all defined in endpoints)_ |

If there's a need to fine tune CORS headers, then each can be configured individually as follows:

```yaml
provider:
httpApi:
cors:
allowedOrigins:
- https://url1.com
- https://url2.com
allowedHeaders:
- Content-Type
- Authorization
allowedMethods:
- GET
allowCredentials: true
exposedResponseHeaders:
- Special-Response-Header
maxAge: 6000 # In seconds
```
61 changes: 61 additions & 0 deletions lib/plugins/aws/package/compile/events/httpApi/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,20 @@ const resolveTargetConfig = memoizee(({ functionLogicalId, functionAlias }) => {
return { 'Fn::Join': [':', [functionArnGetter, functionAlias.name]] };
});

const defaultCors = {
allowedOrigins: new Set(['*']),
allowedHeaders: new Set([
'Content-Type',
'X-Amz-Date',
'Authorization',
'X-Api-Key',
'X-Amz-Security-Token',
'X-Amz-User-Agent',
]),
};

const toSet = item => new Set(Array.isArray(item) ? item : [item]);

class HttpApiEvents {
constructor(serverless) {
this.serverless = serverless;
Expand All @@ -40,6 +54,17 @@ class HttpApiEvents {
properties.RouteKey = '$default';
properties.Target = resolveTargetConfig(this.config.routes.get('*'));
}
const cors = this.config.cors;
if (cors) {
properties.CorsConfiguration = {
AllowCredentials: cors.allowCredentials,
AllowHeaders: Array.from(cors.allowedHeaders),
AllowMethods: Array.from(cors.allowedMethods),
AllowOrigins: Array.from(cors.allowedOrigins),
ExposeHeaders: cors.exposedResponseHeaders && Array.from(cors.exposedResponseHeaders),
MaxAge: cors.maxAge,
};
}
this.cfTemplate.Resources[this.provider.naming.getHttpApiLogicalId()] = {
Type: 'AWS::ApiGatewayV2::Api',
Properties: properties,
Expand Down Expand Up @@ -111,6 +136,33 @@ Object.defineProperties(
resolveConfiguration: d(function() {
const routes = new Map();
this.config = { routes };
const userConfig = this.serverless.service.provider.httpApi || {};
let cors = null;
let shouldFillCorsMethods = false;
const userCors = userConfig.cors;
if (userCors) {
cors = this.config.cors = {};
if (userConfig.cors === true) {
Object.assign(cors, defaultCors);
shouldFillCorsMethods = true;
} else {
cors.allowedOrigins = userCors.allowedOrigins
? toSet(userCors.allowedOrigins)
: defaultCors.allowedOrigins;
cors.allowedHeaders = userCors.allowedHeaders
? toSet(userCors.allowedHeaders)
: defaultCors.allowedHeaders;
if (userCors.allowedMethods) cors.allowedMethods = toSet(userCors.allowedMethods);
else shouldFillCorsMethods = true;
if (userCors.allowCredentials) cors.allowCredentials = true;
if (userCors.exposedResponseHeaders) {
cors.exposedResponseHeaders = toSet(userCors.exposedResponseHeaders);
}
cors.maxAge = userCors.maxAge;
}
if (shouldFillCorsMethods) cors.allowedMethods = new Set(['OPTIONS']);
}

for (const [functionName, functionData] of _.entries(this.serverless.service.functions)) {
const routeTargetData = {
functionName,
Expand Down Expand Up @@ -202,6 +254,15 @@ Object.defineProperties(
}
}
routes.set(routeKey, routeTargetData);
if (shouldFillCorsMethods) {
if (event.resolvedMethod === 'ANY') {
for (const allowedMethod of allowedMethods) {
cors.allowedMethods.add(allowedMethod);
}
} else {
cors.allowedMethods.add(event.resolvedMethod);
}
}
}
}
}),
Expand Down
151 changes: 151 additions & 0 deletions lib/plugins/aws/package/compile/events/httpApi/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ describe('HttpApiEvents', () => {
expect(resource.Properties).to.not.have.property('RouteKey');
expect(resource.Properties).to.not.have.property('Target');
});
it('Should not configure cors when not asked to', () => {
const resource = cfResources[naming.getHttpApiLogicalId()];
expect(resource.Properties).to.not.have.property('CorsConfiguration');
});
it('Should configure stage resource', () => {
const resource = cfResources[naming.getHttpApiStageLogicalId()];
expect(resource.Type).to.equal('AWS::ApiGatewayV2::Stage');
Expand Down Expand Up @@ -136,4 +140,151 @@ describe('HttpApiEvents', () => {
expect(resource.Properties.Action).to.equal('lambda:InvokeFunction');
});
});

describe('Cors', () => {
let cfCors;

describe('`true` configuration', () => {
before(() =>
fixtures.extend('httpApi', { provider: { httpApi: { cors: true } } }).then(fixturePath =>
runServerless({
cwd: fixturePath,
cliArgs: ['package'],
}).then(serverless => {
cfCors =
serverless.service.provider.compiledCloudFormationTemplate.Resources[
serverless.getProvider('aws').naming.getHttpApiLogicalId()
].Properties.CorsConfiguration;
})
)
);
it('Should not set AllowCredentials', () => expect(cfCors.AllowCredentials).to.equal());
it('Should include default set of headers at AllowHeaders', () =>
expect(cfCors.AllowHeaders).to.include('Content-Type'));
it('Should include "OPTIONS" method at AllowMethods', () =>
expect(cfCors.AllowMethods).to.include('OPTIONS'));
it('Should include used method at AllowMethods', () => {
expect(cfCors.AllowMethods).to.include('GET');
expect(cfCors.AllowMethods).to.include('POST');
});
it('Should not include not used method at AllowMethods', () => {
expect(cfCors.AllowMethods).to.not.include('PATCH');
expect(cfCors.AllowMethods).to.not.include('DELETE');
});
it('Should allow all origins at AllowOrigins', () =>
expect(cfCors.AllowOrigins).to.include('*'));
it('Should not set ExposeHeaders', () => expect(cfCors.ExposeHeaders).to.equal());
it('Should not set MaxAge', () => expect(cfCors.MaxAge).to.equal());
});

describe('Object configuration #1', () => {
before(() =>
fixtures
.extend('httpApi', {
provider: {
httpApi: {
cors: {
allowedOrigins: 'https://serverless.com',
exposedResponseHeaders: ['Content-Length', 'X-Kuma-Revision'],
},
},
},
})
.then(fixturePath =>
runServerless({
cwd: fixturePath,
cliArgs: ['package'],
}).then(serverless => {
cfCors =
serverless.service.provider.compiledCloudFormationTemplate.Resources[
serverless.getProvider('aws').naming.getHttpApiLogicalId()
].Properties.CorsConfiguration;
})
)
);
it('Should not set AllowCredentials', () => expect(cfCors.AllowCredentials).to.equal());
it('Should include default set of headers at AllowHeaders', () =>
expect(cfCors.AllowHeaders).to.include('Content-Type'));
it('Should include "OPTIONS" method at AllowMethods', () =>
expect(cfCors.AllowMethods).to.include('OPTIONS'));
it('Should include used method at AllowMethods', () => {
expect(cfCors.AllowMethods).to.include('GET');
expect(cfCors.AllowMethods).to.include('POST');
});
it('Should not include not used method at AllowMethods', () => {
expect(cfCors.AllowMethods).to.not.include('PATCH');
expect(cfCors.AllowMethods).to.not.include('DELETE');
});
it('Should respect allowedOrigins', () =>
expect(cfCors.AllowOrigins).to.deep.equal(['https://serverless.com']));
it('Should respect exposedResponseHeaders', () =>
expect(cfCors.ExposeHeaders).to.deep.equal(['Content-Length', 'X-Kuma-Revision']));
it('Should not set MaxAge', () => expect(cfCors.MaxAge).to.equal());
});

describe('Object configuration #2', () => {
before(() =>
fixtures
.extend('httpApi', {
provider: {
httpApi: {
cors: {
allowCredentials: true,
allowedHeaders: ['Authorization'],
allowedMethods: ['GET'],
maxAge: 300,
},
},
},
})
.then(fixturePath =>
runServerless({
cwd: fixturePath,
cliArgs: ['package'],
}).then(serverless => {
cfCors =
serverless.service.provider.compiledCloudFormationTemplate.Resources[
serverless.getProvider('aws').naming.getHttpApiLogicalId()
].Properties.CorsConfiguration;
})
)
);
it('Should respect allowCredentials', () => expect(cfCors.AllowCredentials).to.equal(true));
it('Should respect allowedHeaders', () =>
expect(cfCors.AllowHeaders).to.deep.equal(['Authorization']));
it('Should respect allowedMethods', () => expect(cfCors.AllowMethods).to.deep.equal(['GET']));
it('Should fallback AllowOrigins to default', () =>
expect(cfCors.AllowOrigins).to.include('*'));
it('Should not set ExposeHeaders', () => expect(cfCors.ExposeHeaders).to.equal());
it('Should respect maxAge', () => expect(cfCors.MaxAge).to.equal(300));
});

describe('With a catch-all route', () => {
before(() =>
fixtures
.extend('httpApiCatchAll', {
provider: {
httpApi: {
cors: true,
},
},
})
.then(fixturePath =>
runServerless({
cwd: fixturePath,
cliArgs: ['package'],
}).then(serverless => {
cfCors =
serverless.service.provider.compiledCloudFormationTemplate.Resources[
serverless.getProvider('aws').naming.getHttpApiLogicalId()
].Properties.CorsConfiguration;
})
)
);
it('Should respect all allowedMethods', () =>
expect(cfCors.AllowMethods.sort()).to.deep.equal(
['GET', 'POST', 'PUT', 'PATCH', 'OPTIONS', 'HEAD', 'DELETE'].sort()
));
});
});
});
15 changes: 14 additions & 1 deletion tests/integration-all/http-api/tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ describe('HTTP API Integration Test', function() {
tmpDirPath = getTmpDirPath();
log.debug('temporary path %s', tmpDirPath);
const serverlessConfig = await createTestService(tmpDirPath, {
templateDir: fixtures.map.httpApi,
templateDir: await fixtures.extend('httpApi', {
provider: { httpApi: { cors: { exposedResponseHeaders: 'X-foo' } } },
}),
});
serviceName = serverlessConfig.service;
stackName = `${serviceName}-${stage}`;
Expand Down Expand Up @@ -85,6 +87,17 @@ describe('HTTP API Integration Test', function() {
const response = await fetch(testEndpoint, { method: 'GET' });
expect(response.status).to.equal(404);
});

it('should support CORS when indicated', async () => {
const testEndpoint = `${endpoint}/foo`;

const response = await fetch(testEndpoint, {
method: 'GET',
headers: { Origin: 'https://serverless.com' },
});
expect(response.headers.get('access-control-allow-origin')).to.equal('*');
expect(response.headers.get('access-control-expose-headers')).to.equal('x-foo');
});
});

describe('Catch-all endpoints', () => {
Expand Down

0 comments on commit ca69387

Please sign in to comment.