serverless-offline-sns
Version:
Serverless plugin to run a local SNS server and call lambdas with events notifications.
436 lines (435 loc) • 17.1 kB
JavaScript
import express from "express";
import fetch from "node-fetch";
import { URL } from "url";
import _ from "lodash";
import xml from "xml";
import { arrayify, createAttr, createMetadata, createSnsTopicEvent, parseMessageAttributes, parseAttributes, createMessageId, validatePhoneNumber, topicArnFromName, formatMessageAttributes, } from "./helpers.js";
import { SQSClient, SendMessageCommand, GetQueueUrlCommand } from "@aws-sdk/client-sqs";
export class SNSServer {
topics;
subscriptions;
pluginDebug;
app;
region;
accountId;
retry;
retryInterval;
constructor(debug, app, region, accountId, retry = 0, retryInterval = 0) {
this.pluginDebug = debug;
this.topics = [];
this.subscriptions = [];
this.app = app;
this.region = region;
this.routes();
this.accountId = accountId;
this.retry = retry;
this.retryInterval = retryInterval;
}
routes() {
this.debug("configuring route");
this.app.use(express.json({ limit: "10mb" }));
this.app.use(express.urlencoded({ extended: true, limit: "10mb" }));
this.app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
this.app.all("/", (req, res) => {
this.debug("hello request");
this.debug(JSON.stringify(req.body));
this.debug(JSON.stringify(this.subscriptions));
const body = req.body;
if (!body) {
res.status(200).send();
return;
}
if (body.Action === "ListSubscriptions") {
this.debug("sending: " + xml(this.listSubscriptions(), { indent: "\t" }));
res.send(xml(this.listSubscriptions()));
}
else if (body.Action === "ListTopics") {
this.debug("sending: " + xml(this.listTopics(), { indent: "\t" }));
res.send(xml(this.listTopics()));
}
else if (body.Action === "CreateTopic") {
res.send(xml(this.createTopic(body.Name)));
}
else if (body.Action === "Subscribe") {
res.send(xml(this.subscribe(body.Endpoint, body.Protocol, body.TopicArn, body)));
}
else if (body.Action === "PublishBatch") {
res.send(xml(this.publishBatch(body)));
}
else if (body.Action === "Publish") {
const target = this.extractTarget(body);
if (body.MessageStructure === "json") {
const json = JSON.parse(body.Message);
if (typeof json.default !== "string") {
throw new Error("Messages must have default key");
}
}
res.send(xml(this.publish(target, body.Subject, body.Message, body.MessageStructure, parseMessageAttributes(body), body.MessageGroupId)));
}
else if (body.Action === "Unsubscribe") {
res.send(xml(this.unsubscribe(body.SubscriptionArn)));
}
else {
res.send(xml({
NotImplementedResponse: [createAttr(), createMetadata()],
}));
}
this.debug(JSON.stringify(this.subscriptions));
});
}
listTopics() {
this.debug("Topics: " + JSON.stringify(this.topics));
return {
ListTopicsResponse: [
createAttr(),
createMetadata(),
{
ListTopicsResult: [
{
Topics: this.topics.map((topic) => {
return {
member: arrayify({
TopicArn: topic.TopicArn,
}),
};
}),
},
],
},
],
};
}
listSubscriptions() {
this.debug(this.subscriptions.map((sub) => {
return {
member: [sub],
};
}));
return {
ListSubscriptionsResponse: [
createAttr(),
createMetadata(),
{
ListSubscriptionsResult: [
{
Subscriptions: this.subscriptions.map((sub) => {
return {
member: arrayify({
Endpoint: sub.Endpoint,
TopicArn: sub.TopicArn,
Owner: sub.Owner,
Protocol: sub.Protocol,
SubscriptionArn: sub.SubscriptionArn,
}),
};
}),
},
],
},
],
};
}
unsubscribe(arn) {
this.debug(JSON.stringify(this.subscriptions));
this.debug("unsubscribing: " + arn);
this.subscriptions = this.subscriptions.filter((sub) => sub.SubscriptionArn !== arn);
return {
UnsubscribeResponse: [createAttr(), createMetadata()],
};
}
createTopic(topicName) {
const topicArn = topicArnFromName(topicName, this.region, this.accountId);
const topic = {
TopicArn: topicArn,
};
if (!this.topics.find(({ TopicArn }) => TopicArn === topicArn)) {
this.topics.push(topic);
}
return {
CreateTopicResponse: [
createAttr(),
createMetadata(),
{
CreateTopicResult: [
{
TopicArn: topicArn,
},
],
},
],
};
}
subscribe(endpoint, protocol, arn, body) {
const attributes = parseAttributes(body);
const filterPolicies = attributes["FilterPolicy"]
? JSON.parse(attributes["FilterPolicy"])
: undefined;
arn = this.convertPseudoParams(arn);
const existingSubscription = this.subscriptions.find((subscription) => {
if (protocol === "sqs") {
return (attributes["QueueName"] === subscription["Attributes"]["QueueName"] &&
subscription.Endpoint === endpoint &&
subscription.TopicArn === arn);
}
return (subscription.Endpoint === endpoint && subscription.TopicArn === arn);
});
let subscriptionArn;
if (!existingSubscription) {
const sub = {
SubscriptionArn: arn + ":" + Math.floor(Math.random() * (1000000 - 1)),
Protocol: protocol,
TopicArn: arn,
Endpoint: endpoint,
queueName: attributes["QueueName"],
Owner: "",
Attributes: attributes,
Policies: filterPolicies,
filterPolicyScope: attributes["FilterPolicyScope"],
};
this.subscriptions.push(sub);
subscriptionArn = sub.SubscriptionArn;
}
else {
subscriptionArn = existingSubscription.SubscriptionArn;
}
return {
SubscribeResponse: [
createAttr(),
createMetadata(),
{
SubscribeResult: [
{
SubscriptionArn: subscriptionArn,
},
],
},
],
};
}
evaluatePolicies(policies, messageAttrs, message, filterPolicyScope) {
if (filterPolicyScope === "MessageBody") {
return this.evaluatePoliciesOnBody(policies, message);
}
return this.evaluatePoliciesOnAttributes(policies, messageAttrs);
}
evaluatePoliciesOnAttributes(policies, messageAttrs) {
let shouldSend = false;
for (const [k, v] of Object.entries(policies)) {
if (!messageAttrs[k]) {
shouldSend = false;
break;
}
let attrs;
if (messageAttrs[k].Type.endsWith(".Array")) {
attrs = JSON.parse(messageAttrs[k].Value);
}
else {
attrs = [messageAttrs[k].Value];
}
if (_.intersection(v, attrs).length > 0) {
this.debug("filterPolicy Passed: " + JSON.stringify(v) + " matched message attrs: " + JSON.stringify(attrs));
shouldSend = true;
}
else {
shouldSend = false;
break;
}
}
if (!shouldSend) {
this.debug("filterPolicy Failed: " + JSON.stringify(policies) + " did not match message attrs: " + JSON.stringify(messageAttrs));
}
return shouldSend;
}
evaluatePoliciesOnBody(policies, message) {
let body;
try {
body = JSON.parse(message);
}
catch {
this.debug("filterPolicy (MessageBody) Failed: message is not valid JSON");
return false;
}
for (const [k, v] of Object.entries(policies)) {
const bodyValue = body[k];
if (bodyValue === undefined) {
this.debug("filterPolicy (MessageBody) Failed: key " + k + " not found in message body");
return false;
}
if (_.intersection(v, [bodyValue]).length === 0) {
this.debug("filterPolicy (MessageBody) Failed: " + JSON.stringify(v) + " did not match body value: " + JSON.stringify(bodyValue));
return false;
}
}
this.debug("filterPolicy (MessageBody) Passed: " + JSON.stringify(policies));
return true;
}
async publishHttp(event, sub, raw) {
const doFetch = () => fetch(sub.Endpoint, {
method: "POST",
body: event,
headers: {
"x-amz-sns-rawdelivery": "" + raw,
"Content-Type": "text/plain; charset=UTF-8",
"Content-Length": Buffer.byteLength(event).toString(),
},
});
for (let attempt = 0; attempt <= this.retry; attempt++) {
try {
const res = await doFetch();
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
this.debug(res);
return;
}
catch (ex) {
this.debug(`HTTP delivery failed (attempt ${attempt + 1}/${this.retry + 1}): ${String(ex)}`);
if (attempt < this.retry && this.retryInterval > 0) {
await new Promise((res) => setTimeout(res, this.retryInterval));
}
}
}
}
async publishSqs(event, sub, messageAttributes, messageGroupId) {
const subEndpointUrl = new URL(sub.Endpoint);
const sqsEndpoint = `${subEndpointUrl.protocol}//${subEndpointUrl.host}/`;
const sqs = new SQSClient({ endpoint: sqsEndpoint, region: this.region });
let QueueUrl;
if (sub.queueName) {
const getQueueUrlResult = await sqs.send(new GetQueueUrlCommand({ QueueName: sub.queueName }));
QueueUrl = getQueueUrlResult.QueueUrl ?? sub.Endpoint;
}
else {
QueueUrl = sub.Endpoint;
}
const sendMsgReq = new SendMessageCommand({
QueueUrl,
MessageBody: event,
MessageAttributes: formatMessageAttributes(messageAttributes),
...(messageGroupId && { MessageGroupId: messageGroupId }),
});
return new Promise((resolve) => {
void sqs.send(sendMsgReq).then(() => resolve());
});
}
publishBatch(body) {
const topicArn = body.TopicArn;
const prefix = "PublishBatchRequestEntries.member.";
const indices = Object.keys(body)
.filter((key) => key.startsWith(prefix))
.reduce((prev, key) => {
const idx = key.replace(prefix, "").match(/.*?(?=\.|$)/i)[0];
return prev.includes(idx) ? prev : [...prev, idx];
}, []);
const successful = [];
const failed = [];
for (const idx of indices) {
const ep = `${prefix}${idx}.`;
const id = body[`${ep}Id`];
const message = body[`${ep}Message`] || "";
const subject = body[`${ep}Subject`] || "";
const messageStructure = body[`${ep}MessageStructure`] || "";
const messageGroupId = body[`${ep}MessageGroupId`];
const entryBody = { MessageStructure: messageStructure };
Object.keys(body)
.filter((key) => key.startsWith(`${ep}MessageAttributes.`))
.forEach((key) => { entryBody[key.replace(ep, "")] = body[key]; });
const messageAttributes = parseMessageAttributes(entryBody);
try {
const result = this.publish(topicArn, subject, message, messageStructure, messageAttributes, messageGroupId);
const messageId = result.PublishResponse[1].PublishResult[0].MessageId;
successful.push({ Id: id, MessageId: messageId });
}
catch (err) {
failed.push({ Id: id, Code: "InternalError", SenderFault: "false", Message: String(err) });
}
}
return {
PublishBatchResponse: [
createAttr(),
{
PublishBatchResult: [
{ Successful: successful.map(({ Id, MessageId }) => ({ member: [{ Id }, { MessageId }] })) },
{ Failed: failed.map(({ Id, Code, SenderFault, Message }) => ({ member: [{ Id }, { Code }, { SenderFault }, { Message }] })) },
],
},
createMetadata(),
],
};
}
publish(topicArn, subject, message, messageStructure, messageAttributes, messageGroupId) {
const messageId = createMessageId();
void Promise.all(this.subscriptions
.filter((sub) => sub.TopicArn === topicArn)
.map((sub) => {
const isRaw = sub.Attributes["RawMessageDelivery"] === "true";
if (sub.Policies &&
!this.evaluatePolicies(sub.Policies, messageAttributes, message, sub.filterPolicyScope)) {
this.debug("Filter policies failed. Skipping subscription: " + sub.Endpoint);
return Promise.resolve();
}
this.debug("fetching: " + sub.Endpoint);
let event;
if (isRaw) {
event = message;
}
else {
event = JSON.stringify(createSnsTopicEvent(topicArn, sub.SubscriptionArn, subject, message, messageId, messageStructure, messageAttributes, messageGroupId));
}
this.debug("event: " + event);
if (!sub.Protocol) {
sub.Protocol = "http";
}
const protocol = sub.Protocol.toLowerCase();
if (protocol === "http" || protocol === "lambda") {
return this.publishHttp(event, sub, isRaw);
}
if (protocol === "sqs") {
return this.publishSqs(event, sub, messageAttributes, messageGroupId);
}
throw new Error(`Protocol '${protocol}' is not supported by serverless-offline-sns`);
}));
return {
PublishResponse: [
createAttr(),
{
PublishResult: [
{
MessageId: messageId,
},
],
},
createMetadata(),
],
};
}
extractTarget(body) {
if (!body.PhoneNumber) {
const target = body.TopicArn || body.TargetArn;
if (!target) {
throw new Error("TopicArn or TargetArn is missing");
}
return this.convertPseudoParams(target);
}
else {
return validatePhoneNumber(body.PhoneNumber);
}
}
convertPseudoParams(topicArn) {
const awsRegex = /#{AWS::([a-zA-Z]+)}/g;
return topicArn.replace(awsRegex, this.accountId);
}
debug(msg) {
if (msg instanceof Object) {
try {
msg = JSON.stringify(msg);
}
catch (ex) { }
}
this.pluginDebug(msg, "server");
}
}