feat(AWS Lambda): Add support for function URLs

This commit is contained in:
Piotr Grzesik 2022-02-08 16:30:14 +01:00
parent 26f79aaf19
commit d215517cf0
14 changed files with 528 additions and 7 deletions

View File

@ -200,6 +200,91 @@ provider:
See the documentation about [IAM](./iam.md) for function level IAM roles.
## Lambda Function URLs
A [Lambda Function URL](https://docs.aws.amazon.com/lambda/latest/dg/configuration-function-urls.html) is a simple solution to create HTTP endpoints with AWS Lambda. Function URLs are ideal for getting started with AWS Lambda, or for single-function applications like webhooks or APIs built with web frameworks.
You can create a function URL via the `url` property in the function configuration in `serverless.yml`. By setting `url` to `true`, as shown below, the URL will be public without CORS configuration.
```yaml
functions:
func:
handler: index.handler
url: true
```
Alternatively, you can configure it as an object with the `authorizer` and/or `cors` properties. The `authorizer` property can be set to `aws_iam` to enable AWS IAM authorization on your function URL.
```yaml
functions:
func:
handler: index.handler
url:
authorizer: aws_iam
```
When using IAM authorization, the URL will only accept HTTP requests with AWS credentials allowing `lambda:InvokeFunctionUrl` (similar to [API Gateway IAM authentication](https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-access-control-iam.html)).
You can also configure [CORS headers](https://developer.mozilla.org/docs/Web/HTTP/CORS) so that your function URL can be called from other domains in browsers. Setting `cors` to `true` will allow all domains via the following CORS headers:
```yaml
functions:
func:
handler: index.handler
url:
cors: true
```
| Header | Value |
| :--------------------------- | :----------------------------------------------------------------------- |
| Access-Control-Allow-Origin | \* |
| Access-Control-Allow-Headers | Content-Type, X-Amz-Date, Authorization, X-Api-Key, X-Amz-Security-Token |
| Access-Control-Allow-Methods | \* |
You can also additionally adjust your CORS configuration by setting `allowedOrigins`, `allowedHeaders`, `allowedMethods`, `allowCredentials`, `exposedResponseHeaders`, and `maxAge` properties as shown in example below.
```yaml
functions:
func:
handler: index.handler
url:
cors:
allowedOrigins:
- https://url1.com
- https://url2.com
allowedHeaders:
- Content-Type
- Authorization
allowedMethods:
- GET
allowCredentials: true
exposedResponseHeaders:
- Special-Response-Header
maxAge: 6000 # In seconds
```
In the table below you can find how the `cors` properties map to CORS headers
| Configuration property | CORS Header |
| :--------------------- | :------------------------------- |
| allowedOrigins | Access-Control-Allow-Origin |
| allowedHeaders | Access-Control-Allow-Headers |
| allowedMethods | Access-Control-Allow-Methods |
| allowCredentials | Access-Control-Allow-Credentials |
| exposedResponseHeaders | Access-Control-Expose-Headers |
| maxAge | Access-Control-Max-Age |
It is also possible to remove the values in CORS configuration that are set by default by setting them to `null` instead.
```yaml
functions:
func:
handler: index.handler
url:
cors:
allowedHeaders: null
```
## Referencing container image as a target
Alternatively lambda environment can be configured through docker images. Image published to AWS ECR registry can be referenced as lambda source (check [AWS Lambda Container Image Support](https://aws.amazon.com/blogs/aws/new-for-aws-lambda-container-image-support/)). In addition, you can also define your own images that will be built locally and uploaded to AWS ECR registry.

View File

@ -645,6 +645,21 @@ functions:
subnetIds:
- subnetId1
- subnetId2
# Lambda URL definition for this function, optional
# Can be defined as `true` which will create URL without authorizer and cors settings
url:
authorizer: 'aws_iam' # Authorizer used for calls to Lambda URL
cors: # CORS configuration for Lambda URL, can also be defined as `true` with default CORS configuration
allowedOrigins:
- *
allowedHeaders:
- Authorization
allowedMethods:
- GET
allowCredentials: true
exposedResponseHeaders:
- SomeHeader
maxAge: 3600
# Packaging rules specific to this function
package:
# Directories and files to include in the deployed package

View File

@ -88,6 +88,17 @@ module.exports = {
outputSectionItems.push(`CloudFront - ${info.cloudFront}`);
}
const functionsWithUrls = this.gatheredData.info.functions.filter((fn) => fn.url);
for (const functionWithUrl of functionsWithUrls) {
if (outputSectionItems.length === 0 && functionsWithUrls.length === 1) {
// In this situation we want to skip displaying function name as there is only one URL in whole service
outputSectionItems.push(functionWithUrl.url);
} else {
outputSectionItems.push(`${functionWithUrl.name}: ${functionWithUrl.url}`);
}
}
if (outputSectionItems.length > 1) {
this.serverless.serviceOutputs.set('endpoints', outputSectionItems);
} else if (outputSectionItems.length) {

View File

@ -75,6 +75,13 @@ module.exports = {
functionInfo.name = func;
functionInfo.deployedName = functionObj.name;
functionInfo.artifactSize = functionObj.artifactSize;
const functionUrlOutput = outputs.find(
(output) =>
output.OutputKey === this.provider.naming.getLambdaFunctionUrlOutputLogicalId(func)
);
if (functionUrlOutput) {
functionInfo.url = functionUrlOutput.OutputValue;
}
this.gatheredData.info.functions.push(functionInfo);
});

View File

@ -150,6 +150,9 @@ module.exports = {
getLambdaLogicalId(functionName) {
return `${this.getNormalizedFunctionName(functionName)}LambdaFunction`;
},
getLambdaFunctionUrlLogicalId(functionName) {
return `${this.getNormalizedFunctionName(functionName)}LambdaFunctionUrl`;
},
getLambdaEventConfigLogicalId(functionName) {
return `${this.getNormalizedFunctionName(functionName)}LambdaEvConf`;
},
@ -186,6 +189,9 @@ module.exports = {
getLambdaProvisionedConcurrencyAliasName() {
return 'provisioned';
},
getLambdaFunctionUrlOutputLogicalId(functionName) {
return `${this.getNormalizedFunctionName(functionName)}LambdaFunctionUrl`;
},
getLambdaVersionOutputLogicalId(functionName) {
return `${this.getLambdaLogicalId(functionName)}QualifiedArn`;
},
@ -516,6 +522,9 @@ module.exports = {
getLambdaHttpApiPermissionLogicalId(functionName) {
return `${this.getNormalizedFunctionName(functionName)}LambdaPermissionHttpApi`;
},
getLambdaFnUrlPermissionLogicalId(functionName) {
return `${this.getNormalizedFunctionName(functionName)}LambdaPermissionFnUrl`;
},
getLambdaAuthorizerHttpApiPermissionLogicalId(authorizerName) {
return `${this.getNormalizedResourceName(authorizerName)}LambdaAuthorizerPermissionHttpApi`;
},

View File

@ -9,9 +9,22 @@ const path = require('path');
const ServerlessError = require('../../../../serverless-error');
const deepSortObjectByKey = require('../../../../utils/deep-sort-object-by-key');
const getHashForFilePath = require('../lib/get-hash-for-file-path');
const resolveLambdaTarget = require('../../utils/resolve-lambda-target');
const parseS3URI = require('../../utils/parse-s3-uri');
const { log } = require('@serverless/utils/log');
const defaultCors = {
allowedOrigins: ['*'],
allowedHeaders: [
'Content-Type',
'X-Amz-Date',
'Authorization',
'X-Api-Key',
'X-Amz-Security-Token',
],
allowedMethods: ['*'],
};
class AwsCompileFunctions {
constructor(serverless, options) {
this.serverless = serverless;
@ -593,9 +606,94 @@ class AwsCompileFunctions {
}
}
this.compileFunctionUrl(functionName);
this.compileFunctionEventInvokeConfig(functionName);
}
compileFunctionUrl(functionName) {
const functionObject = this.serverless.service.getFunction(functionName);
const cfTemplate = this.serverless.service.provider.compiledCloudFormationTemplate;
const { url } = functionObject;
if (!url) return;
let auth = 'NONE';
let cors = null;
if (url.authorizer === 'aws_iam') {
auth = 'AWS_IAM';
}
if (url.cors) {
cors = Object.assign({}, defaultCors);
if (url.cors.allowedOrigins) {
cors.allowedOrigins = _.uniq(url.cors.allowedOrigins);
} else if (url.cors.allowedOrigins === null) {
delete cors.allowedOrigins;
}
if (url.cors.allowedHeaders) {
cors.allowedHeaders = _.uniq(url.cors.allowedHeaders);
} else if (url.cors.allowedHeaders === null) {
delete cors.allowedHeaders;
}
if (url.cors.allowedMethods) {
cors.allowedMethods = _.uniq(url.cors.allowedMethods);
} else if (url.cors.allowedMethods === null) {
delete cors.allowedMethods;
}
if (url.cors.allowCredentials) cors.allowCredentials = true;
if (url.cors.exposedResponseHeaders) {
cors.exposedResponseHeaders = _.uniq(url.cors.exposedResponseHeaders);
}
cors.maxAge = url.cors.maxAge;
}
const urlResource = {
Type: 'AWS::Lambda::Url',
Properties: {
AuthType: auth,
TargetFunctionArn: resolveLambdaTarget(functionName, functionObject),
},
};
if (cors) {
urlResource.Properties.Cors = {
AllowCredentials: cors.allowCredentials,
AllowHeaders: cors.allowedHeaders && Array.from(cors.allowedHeaders),
AllowMethods: cors.allowedMethods && Array.from(cors.allowedMethods),
AllowOrigins: cors.allowedOrigins && Array.from(cors.allowedOrigins),
ExposeHeaders: cors.exposedResponseHeaders && Array.from(cors.exposedResponseHeaders),
MaxAge: cors.maxAge,
};
}
const logicalId = this.provider.naming.getLambdaFunctionUrlLogicalId(functionName);
cfTemplate.Resources[logicalId] = urlResource;
cfTemplate.Outputs[this.provider.naming.getLambdaFunctionUrlOutputLogicalId(functionName)] = {
Description: 'Lambda Function URL',
Value: {
'Fn::GetAtt': [logicalId, 'FunctionUrl'],
},
};
if (auth === 'NONE') {
cfTemplate.Resources[this.provider.naming.getLambdaFnUrlPermissionLogicalId(functionName)] = {
Type: 'AWS::Lambda::Permission',
Properties: {
FunctionName: resolveLambdaTarget(functionName, functionObject),
Action: 'lambda:InvokeFunctionUrl',
Principal: '*',
FunctionUrlAuthType: auth,
},
};
}
}
compileFunctionEventInvokeConfig(functionName) {
const functionObject = this.serverless.service.getFunction(functionName);
const { destinations, maximumEventAge, maximumRetryAttempts } = functionObject;

View File

@ -1332,6 +1332,55 @@ class AwsProvider {
tags: { $ref: '#/definitions/awsResourceTags' },
timeout: { $ref: '#/definitions/awsLambdaTimeout' },
tracing: { $ref: '#/definitions/awsLambdaTracing' },
url: {
anyOf: [
{ type: 'boolean' },
{
type: 'object',
properties: {
authorizer: { type: 'string', enum: ['aws_iam'] },
cors: {
anyOf: [
{ type: 'boolean' },
{
type: 'object',
properties: {
allowCredentials: { type: 'boolean' },
allowedHeaders: {
type: 'array',
minItems: 1,
maxItems: 100,
items: { type: 'string' },
},
allowedMethods: {
type: 'array',
minItems: 1,
maxItems: 6,
items: { type: 'string' },
},
allowedOrigins: {
type: 'array',
minItems: 1,
maxItems: 100,
items: { type: 'string' },
},
exposedResponseHeaders: {
type: 'array',
minItems: 1,
maxItems: 100,
items: { type: 'string' },
},
maxAge: { type: 'integer', minimum: 0 },
},
additionalProperties: false,
},
],
},
},
additionalProperties: false,
},
],
},
versionFunction: { $ref: '#/definitions/awsLambdaVersioning' },
vpc: { $ref: '#/definitions/awsLambdaVpcConfig' },
httpApi: {

View File

@ -99,6 +99,7 @@ const getServiceConfig = ({ configuration, options, variableSources }) => {
})();
return {
url: Boolean(functionConfig.url),
runtime: functionRuntime,
events: functionEvents.map((eventConfig) => ({
type: isObject(eventConfig) ? Object.keys(eventConfig)[0] || null : null,

View File

@ -81,6 +81,7 @@
"@serverless/eslint-config": "^4.0.0",
"@serverless/test": "^10.0.2",
"adm-zip": "^0.5.9",
"aws4": "^1.11.0",
"chai": "^4.3.6",
"chai-as-promised": "^7.1.1",
"cos-nodejs-sdk-v5": "^2.11.6",

View File

@ -0,0 +1,113 @@
'use strict';
const { expect } = require('chai');
const awsRequest = require('@serverless/test/aws-request');
const CloudFormationService = require('aws-sdk').CloudFormation;
const fixtures = require('../../fixtures/programmatic');
const aws4 = require('aws4');
const url = require('url');
const { deployService, removeService, fetch } = require('../../utils/integration');
describe('test/integration/aws/function-url.test.js', function () {
this.timeout(1000 * 60 * 10); // Involves time-taking deploys
let stackName;
let serviceDir;
let basicEndpoint;
let otherEndpoint;
let authedEndpoint;
const stage = 'dev';
before(async () => {
const serviceData = await fixtures.setup('function', {
configExt: {
functions: {
basic: {
url: true,
},
other: {
url: {
cors: {
exposedResponseHeaders: ['x-foo'],
allowCredentials: true,
allowedMethods: ['GET'],
},
},
},
authed: {
handler: 'basic.handler',
url: {
authorizer: 'aws_iam',
},
},
},
},
});
({ servicePath: serviceDir } = serviceData);
const serviceName = serviceData.serviceConfig.service;
stackName = `${serviceName}-${stage}`;
await deployService(serviceDir);
const describeStacksResponse = await awsRequest(CloudFormationService, 'describeStacks', {
StackName: stackName,
});
basicEndpoint = describeStacksResponse.Stacks[0].Outputs.find(
(output) => output.OutputKey === 'BasicLambdaFunctionUrl'
).OutputValue;
otherEndpoint = describeStacksResponse.Stacks[0].Outputs.find(
(output) => output.OutputKey === 'OtherLambdaFunctionUrl'
).OutputValue;
authedEndpoint = describeStacksResponse.Stacks[0].Outputs.find(
(output) => output.OutputKey === 'AuthedLambdaFunctionUrl'
).OutputValue;
});
after(async () => {
if (!serviceDir) return;
await removeService(serviceDir);
});
it('should return valid response from Lambda URL', async () => {
const expectedMessage = 'Basic';
const response = await fetch(basicEndpoint, { method: 'GET' });
const jsonResponse = await response.json();
expect(jsonResponse.message).to.equal(expectedMessage);
});
it('should return valid response from Lambda URL with authorizer with valid signature', async () => {
const expectedMessage = 'Basic';
const signedParams = aws4.sign(
{
service: 'lambda',
region: 'us-east-1',
method: 'GET',
host: url.parse(authedEndpoint).hostname,
},
{
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY,
}
);
const response = await fetch(authedEndpoint, signedParams);
const jsonResponse = await response.json();
expect(jsonResponse.message).to.equal(expectedMessage);
});
it('should return invalid response from Lambda URL with authorizer without passed signature', async () => {
const expectedMessage = 'Forbidden';
const response = await fetch(authedEndpoint, { method: 'GET' });
const jsonResponse = await response.json();
expect(jsonResponse.Message).to.equal(expectedMessage);
});
it('should return expected CORS headers from Lambda URL', async () => {
const response = await fetch(otherEndpoint, {
method: 'GET',
headers: { Origin: 'https://serverless.com' },
});
const headers = response.headers;
expect(headers.get('access-control-expose-headers')).to.equal('x-foo');
expect(headers.get('access-control-allow-credentials')).to.equal('true');
});
});

View File

@ -43,6 +43,10 @@ describe('test/unit/lib/plugins/aws/info/display.test.js', () => {
OutputKey: 'LayerLambdaLayerQualifiedArn',
OutputValue: 'arn:aws:lambda:us-east-1:00000000:layer:layer:1',
},
{
OutputKey: 'WithUrlLambdaFunctionUrl',
OutputValue: 'https://sub.lambda.com',
},
],
},
],
@ -68,6 +72,12 @@ describe('test/unit/lib/plugins/aws/info/display.test.js', () => {
],
},
},
functions: {
withUrl: {
handler: 'index.handler',
url: true,
},
},
layers: {
layer: {
path: 'layer',
@ -90,6 +100,7 @@ describe('test/unit/lib/plugins/aws/info/display.test.js', () => {
'GET - https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/foo',
'POST - https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/some-post',
'GET - https://xxxxx.execute-api.us-east-1.amazonaws.com/dev/bar/{marko}',
'withUrl: https://sub.lambda.com',
]);
});
it('should register functions section', () => {
@ -97,6 +108,7 @@ describe('test/unit/lib/plugins/aws/info/display.test.js', () => {
`minimal: ${serviceName}-dev-minimal`,
`foo: ${serviceName}-dev-foo`,
`other: ${serviceName}-dev-other`,
`withUrl: ${serviceName}-dev-withUrl`,
]);
});
it('should register layers section', () => {

View File

@ -1044,6 +1044,14 @@ describe('#naming()', () => {
});
});
describe('#getLambdaFnUrlPermissionLogicalId()', () => {
it('should normalize the name and append correct suffix', () => {
expect(sdk.naming.getLambdaFnUrlPermissionLogicalId('fnName')).to.equal(
'FnNameLambdaPermissionFnUrl'
);
});
});
describe('#getHttpApiName()', () => {
it('should return the composition of service & stage name if custom name not provided and shouldStartNameWithService is true', () => {
serverless.service.service = 'myService';

View File

@ -1577,6 +1577,7 @@ describe('lib/plugins/aws/package/compile/functions/index.test.js', () => {
describe('Function properties', () => {
let cfResources;
let cfOutputs;
let naming;
let serverless;
let serviceConfig;
@ -1689,6 +1690,28 @@ describe('lib/plugins/aws/package/compile/functions/index.test.js', () => {
handler: 'index.handler',
ephemeralStorageSize: 1024,
},
fnUrl: {
handler: 'target.handler',
url: true,
},
fnUrlWithAuthAndCors: {
handler: 'target.handler',
url: {
authorizer: 'aws_iam',
cors: {
maxAge: 3600,
},
},
},
fnUrlNullifyDefaultCorsValue: {
handler: 'target.handler',
url: {
authorizer: 'aws_iam',
cors: {
allowedHeaders: null,
},
},
},
},
resources: {
Resources: {
@ -1708,6 +1731,7 @@ describe('lib/plugins/aws/package/compile/functions/index.test.js', () => {
},
});
cfResources = cfTemplate.Resources;
cfOutputs = cfTemplate.Outputs;
naming = awsNaming;
serverless = serverlessInstance;
serviceConfig = fixtureData.serviceConfig;
@ -1888,6 +1912,89 @@ describe('lib/plugins/aws/package/compile/functions/index.test.js', () => {
// https://github.com/serverless/serverless/blob/d8527d8b57e7e5f0b94ba704d9f53adb34298d99/lib/plugins/aws/package/compile/functions/index.test.js#L2381-L2397
});
it('should support `functions[].url` set to `true`', () => {
expect(cfResources[naming.getLambdaFunctionUrlLogicalId('fnUrl')].Properties).to.deep.equal({
AuthType: 'NONE',
TargetFunctionArn: {
'Fn::GetAtt': [naming.getLambdaLogicalId('fnUrl'), 'Arn'],
},
});
expect(cfOutputs[naming.getLambdaFunctionUrlLogicalId('fnUrl')].Value).to.deep.equal({
'Fn::GetAtt': [naming.getLambdaFunctionUrlOutputLogicalId('fnUrl'), 'FunctionUrl'],
});
expect(
cfResources[naming.getLambdaFnUrlPermissionLogicalId('fnUrl')].Properties
).to.deep.equal({
Action: 'lambda:InvokeFunctionUrl',
FunctionName: {
'Fn::GetAtt': ['FnUrlLambdaFunction', 'Arn'],
},
FunctionUrlAuthType: 'NONE',
Principal: '*',
});
});
it('should support `functions[].url` set to an object with authorizer and cors', () => {
expect(
cfResources[naming.getLambdaFunctionUrlLogicalId('fnUrlWithAuthAndCors')].Properties
).to.deep.equal({
AuthType: 'AWS_IAM',
TargetFunctionArn: {
'Fn::GetAtt': [naming.getLambdaLogicalId('fnUrlWithAuthAndCors'), 'Arn'],
},
Cors: {
AllowMethods: ['*'],
AllowOrigins: ['*'],
AllowHeaders: [
'Content-Type',
'X-Amz-Date',
'Authorization',
'X-Api-Key',
'X-Amz-Security-Token',
],
MaxAge: 3600,
AllowCredentials: undefined,
ExposeHeaders: undefined,
},
});
expect(
cfOutputs[naming.getLambdaFunctionUrlLogicalId('fnUrlWithAuthAndCors')].Value
).to.deep.equal({
'Fn::GetAtt': [
naming.getLambdaFunctionUrlOutputLogicalId('fnUrlWithAuthAndCors'),
'FunctionUrl',
],
});
});
it('should support nullifying default cors value with `null` for `functions[].url`', () => {
expect(
cfResources[naming.getLambdaFunctionUrlLogicalId('fnUrlNullifyDefaultCorsValue')].Properties
).to.deep.equal({
AuthType: 'AWS_IAM',
TargetFunctionArn: {
'Fn::GetAtt': [naming.getLambdaLogicalId('fnUrlNullifyDefaultCorsValue'), 'Arn'],
},
Cors: {
AllowMethods: ['*'],
AllowOrigins: ['*'],
AllowHeaders: undefined,
MaxAge: undefined,
AllowCredentials: undefined,
ExposeHeaders: undefined,
},
});
expect(
cfOutputs[naming.getLambdaFunctionUrlLogicalId('fnUrlNullifyDefaultCorsValue')].Value
).to.deep.equal({
'Fn::GetAtt': [
naming.getLambdaFunctionUrlOutputLogicalId('fnUrlNullifyDefaultCorsValue'),
'FunctionUrl',
],
});
});
it('should support `functions[].architecture`', () => {
expect(
cfResources[naming.getLambdaLogicalId('fnArch')].Properties.Architectures

View File

@ -53,6 +53,10 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => {
image:
'000000000000.dkr.ecr.sa-east-1.amazonaws.com/test-lambda-docker@sha256:6bb600b4d6e1d7cf521097177dd0c4e9ea373edb91984a505333be8ac9455d38',
},
withUrl: {
handler: 'index.handler',
url: true,
},
},
resources: {
Resources: {
@ -148,11 +152,12 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => {
},
plugins: [],
functions: [
{ runtime: 'nodejs14.x', events: [{ type: 'httpApi' }, { type: 'httpApi' }] },
{ runtime: 'nodejs14.x', events: [{ type: 'httpApi' }] },
{ runtime: 'nodejs14.x', events: [] },
{ runtime: 'nodejs14.x', events: [] },
{ runtime: '$containerimage', events: [] },
{ runtime: 'nodejs14.x', events: [{ type: 'httpApi' }, { type: 'httpApi' }], url: false },
{ runtime: 'nodejs14.x', events: [{ type: 'httpApi' }], url: false },
{ runtime: 'nodejs14.x', events: [], url: false },
{ runtime: 'nodejs14.x', events: [], url: false },
{ runtime: '$containerimage', events: [], url: false },
{ runtime: 'nodejs14.x', events: [], url: true },
],
resources: {
general: ['AWS::Logs::LogGroup', 'AWS::S3::Bucket', 'Custom'],
@ -217,8 +222,8 @@ describe('test/unit/lib/utils/telemetry/generatePayload.test.js', () => {
},
plugins: ['./custom-provider'],
functions: [
{ runtime: 'foo', events: [{ type: 'someEvent' }] },
{ runtime: 'bar', events: [] },
{ runtime: 'foo', events: [{ type: 'someEvent' }], url: false },
{ runtime: 'bar', events: [], url: false },
],
resources: undefined,
variableSources: [],