Skip to content

Commit 5e70d50

Browse files
authored
feat: Allow short function names in queue consumer config (#1206)
Lambda Wrapper's current documentation for "direct"-mode offline SQS is incorrect: queue consumer names must be the full deployed function name generated by Serverless, typically `service-stage-FunctionName`, instead of just `FunctionName`. However, this approach is more difficult for the developer. In some of our services, this had been set up with hardcoded stage values, leading to unexpected issues when trying to simulate stages other than `dev` locally. This PR allows `SQSService` to add the necessary prefix to queue consumer function names, making the previously incorrect example in the documentation work. In order to maintain backwards compatibility, the prefix will not be added if it is already present. Correctly configured projects can continue to function, while projects that are broken (or broken in some stages where stage was hardcoded) will continue to be broken. Some design decisions: - I've placed the helper for getting the Lambda function name prefix into a public method of `DependencyInjection`, alongside the `isOffline` helper. It's not SQS-specific, and will be helpful in other scenarios where we want to invoke one Lambda function from another. - All changes in `SQSService` are within the `publishOffline` method. I initially considered mutating the SQS config object within the constructor so that all `queueConsumer` values were converted to full function names (requiring no changes to `publishOffline`). However, in a deployed environment, this creates unnecessary additional work within every Lambda invocation. Jira: [ENG-3396] [ENG-3396]: https://comicrelief.atlassian.net/browse/ENG-3396
1 parent 0ae6216 commit 5e70d50

6 files changed

Lines changed: 109 additions & 15 deletions

File tree

docs/services/SQSService.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,22 +109,18 @@ To take advantage of SQS emulation, you will need to do the following in your pr
109109

110110
- Include the `queueConsumers` key in your `SQSService` config.
111111

112-
This maps the queue name to the fully qualified `FunctionName` that we want to trigger when messages are sent to that queue.
112+
This maps the queue name to the name of the Serverless function that we want to trigger when messages are sent to that queue.
113113

114114
Extending the example from above, your config might look like this:
115115

116116
```ts
117117
const lambdaWrapper = lw.configure({
118118
sqs: {
119119
queues: {
120-
// Add an entry for each queue with its AWS name.
121-
// Usually we define queue names in our serverless.yml and provide them
122-
// to the application via environment variables. If you haven't defined
123-
// types for your env vars, you'll need to coerce them to `string`.
124120
submissions: process.env.SQS_QUEUE_SUBMISSIONS as string,
125121
},
126122
queueConsumers: {
127-
// See section below about offline SQS emulation.
123+
// add an entry mapping each queue to its consumer function name
128124
submissions: 'SubmissionConsumer',
129125
},
130126
}
@@ -151,6 +147,8 @@ To take advantage of SQS emulation, you will need to do the following in your pr
151147

152148
3. If the triggered lambda incurs an exception, this will be propagated upstream, effectively killing the execution of the calling lambda.
153149

150+
4. Queue producer and consumer functions must not have custom deployed Lambda names.
151+
154152
### Local SQS mode
155153

156154
Use this mode by setting `LAMBDA_WRAPPER_OFFLINE_SQS_MODE=local`. Messages will still be sent to an SQS queue, but using a locally simulated version instead of AWS. This allows you to test your service using a tool like Localstack.

src/core/DependencyInjection.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,4 +137,39 @@ export default class DependencyInjection<TConfig extends LambdaWrapperConfig = a
137137
|| this.context.invokedFunctionArn.includes('offline')
138138
|| !!process.env.USE_SERVERLESS_OFFLINE;
139139
}
140+
141+
/**
142+
* Get the `service-stage-` prefix added by Serverless to deployed Lambda
143+
* function names. This is handy when you want to invoke other functions,
144+
* without having to hardcode the service name and stage.
145+
*
146+
* The returned prefix includes a trailing dash. To get the deployed name of
147+
* another Lambda function, concatenate its serverless function name (its key
148+
* in `serverless.yml`) onto the prefix:
149+
*
150+
* ```js
151+
* const serverlessFunctionName = 'MyFunction';
152+
* const deployedName = `${di.getLambdaPrefix()}${serverlessFunctionName}`;
153+
* ```
154+
*
155+
* This function relies on looking at the currently running Lambda function's
156+
* resource name. It will not work correctly if the Lambda function has been
157+
* given a custom resource name.
158+
*/
159+
getLambdaPrefix(): string {
160+
const stage = process.env.STAGE;
161+
if (!stage) {
162+
/* eslint-disable no-template-curly-in-string */
163+
throw new Error(
164+
'STAGE is not set\n\n'
165+
+ 'Please add to your Lambda environment:\n\n'
166+
+ ' STAGE: ${sls:stage}\n',
167+
);
168+
}
169+
if (!this.context.functionName) {
170+
throw new Error('Lambda function name is unavailable in context');
171+
}
172+
const [service] = this.context.functionName.split(`-${stage}-`);
173+
return `${service}-${stage}-`;
174+
}
140175
}

src/services/SQSService.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -478,15 +478,19 @@ export default class SQSService<
478478
throw new Error('Can only publishOffline while running serverless offline.');
479479
}
480480

481-
const FunctionName = this.queueConsumers[queue];
482-
483-
if (!FunctionName) {
481+
const shortOrLongFunctionName = this.queueConsumers[queue];
482+
if (!shortOrLongFunctionName) {
484483
throw new Error(
485484
`Queue consumer for queue ${queue} was not found. Please add it to `
486485
+ 'the sqs.queueConsumers key in your Lambda Wrapper config.',
487486
);
488487
}
489488

489+
const prefix = this.di.getLambdaPrefix();
490+
const FunctionName = shortOrLongFunctionName.startsWith(prefix)
491+
? shortOrLongFunctionName
492+
: `${prefix}${shortOrLongFunctionName}`;
493+
490494
const InvocationType = 'RequestResponse';
491495

492496
const Payload = JSON.stringify({

tests/mocks/aws/context.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2+
"functionName": "service-stage-FunctionName",
23
"invokedFunctionArn": "offline"
34
}

tests/unit/core/DependencyInjection.spec.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,39 @@ describe('unit.core.DependencyInjection', () => {
113113
expect(di.getConfiguration()).toBe(mockConfig);
114114
});
115115
});
116+
117+
describe('getLambdaPrefix', () => {
118+
describe('when STAGE and functionName are set', () => {
119+
beforeAll(() => {
120+
process.env.STAGE = 'stage';
121+
mockContext.functionName = 'service-stage-FunctionName';
122+
});
123+
124+
it('should return `service-stage-` prefix', () => {
125+
expect(di.getLambdaPrefix()).toEqual('service-stage-');
126+
});
127+
});
128+
129+
describe('when STAGE is not set', () => {
130+
beforeAll(() => {
131+
delete process.env.STAGE;
132+
mockContext.functionName = 'service-stage-FunctionName';
133+
});
134+
135+
it('should throw', () => {
136+
expect(() => di.getLambdaPrefix()).toThrow('STAGE is not set');
137+
});
138+
});
139+
140+
describe('when functionName is not set', () => {
141+
beforeAll(() => {
142+
process.env.STAGE = 'stage';
143+
mockContext.functionName = '';
144+
});
145+
146+
it('should throw', () => {
147+
expect(() => di.getLambdaPrefix()).toThrow('function name is unavailable');
148+
});
149+
});
150+
});
116151
});

tests/unit/services/SQSService.spec.ts

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {
1616
SQS_PUBLISH_FAILURE_MODES,
1717
TimerService,
1818
} from '@/src';
19+
import { mockContext } from '@/tests/mocks/aws';
1920

2021
const TEST_QUEUE = 'TEST_QUEUE';
22+
const TEST_QUEUE_2 = 'TEST_QUEUE_2';
2123

2224
const config = {
2325
dependencies: {
@@ -28,9 +30,11 @@ const config = {
2830
sqs: {
2931
queues: {
3032
[TEST_QUEUE]: 'QueueName',
33+
[TEST_QUEUE_2]: 'QueueName',
3134
},
3235
queueConsumers: {
33-
[TEST_QUEUE]: 'ConsumerFunctionName',
36+
[TEST_QUEUE]: 'ShortFunctionName',
37+
[TEST_QUEUE_2]: 'service-stage-FullFunctionName',
3438
},
3539
},
3640
};
@@ -58,9 +62,14 @@ const getService = (
5862
}: any = {},
5963
isOffline = false,
6064
): MockSQSService => {
61-
const di = new DependencyInjection(config, {}, {
62-
invokedFunctionArn: isOffline ? 'offline' : 'arn:aws:lambda:eu-west-1:0123456789:test',
63-
} as Context);
65+
const context = {
66+
...mockContext,
67+
invokedFunctionArn: isOffline
68+
? 'offline'
69+
: `arn:aws:lambda:eu-west-1:0123456789:${mockContext.functionName}`,
70+
};
71+
72+
const di = new DependencyInjection(config, {}, context);
6473

6574
const logger = di.get(LoggerService);
6675
jest.spyOn(logger, 'error').mockImplementation();
@@ -116,6 +125,8 @@ describe('unit.services.SQSService', () => {
116125
envOfflineSqsHost = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_HOST;
117126
envOfflineSqsPort = process.env.LAMBDA_WRAPPER_OFFLINE_SQS_PORT;
118127
envRegion = process.env.REGION;
128+
129+
process.env.STAGE = 'stage';
119130
});
120131

121132
afterAll(() => {
@@ -228,10 +239,20 @@ describe('unit.services.SQSService', () => {
228239
const service = getService({}, true);
229240

230241
await service.publish(TEST_QUEUE, { test: 1 });
242+
await service.publish(TEST_QUEUE_2, { test: 2 });
231243

232244
expect(service.sqs.send).not.toHaveBeenCalled();
233-
expect(service.lambda.send).toHaveBeenCalledTimes(1);
234-
expect(service.lambda.send).toHaveBeenCalledWith(expect.any(InvokeCommand));
245+
expect(service.lambda.send).toHaveBeenCalledTimes(2);
246+
247+
// when a short consumer name is given, we should add the prefix
248+
const command1: InvokeCommand = service.lambda.send.mock.calls[0][0];
249+
expect(command1).toBeInstanceOf(InvokeCommand);
250+
expect(command1.input.FunctionName).toEqual('service-stage-ShortFunctionName');
251+
252+
// when a full consumer name is given, we should _not_ add the prefix
253+
const command2: InvokeCommand = service.lambda.send.mock.calls[1][0];
254+
expect(command2).toBeInstanceOf(InvokeCommand);
255+
expect(command2.input.FunctionName).toEqual('service-stage-FullFunctionName');
235256
});
236257

237258
it('sends a local SQS request in "local" mode', async () => {

0 commit comments

Comments
 (0)