UNPKG

serverless-offline-sns

Version:

Serverless plugin to run a local SNS server and call lambdas with events notifications.

352 lines (351 loc) 13.3 kB
import fetch from "node-fetch"; import { URL } from "url"; import bodyParser from "body-parser"; import _ from "lodash"; import xml from "xml"; import { arrayify, createAttr, createMetadata, createSnsTopicEvent, parseMessageAttributes, parseAttributes, createMessageId, validatePhoneNumber, topicArnFromName, formatMessageAttributes, } from "./helpers.js"; import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs"; export class SNSServer { topics; subscriptions; pluginDebug; port; server; app; region; accountId; constructor(debug, app, region, accountId) { this.pluginDebug = debug; this.topics = []; this.subscriptions = []; this.app = app; this.region = region; this.routes(); this.accountId = accountId; } routes() { this.debug("configuring route"); this.app.use(bodyParser.json({ limit: "10mb" })); // for parsing application/json this.app.use(bodyParser.urlencoded({ extended: true, limit: "10mb" })); // for parsing application/x-www-form-urlencoded 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)); if (req.body.Action === "ListSubscriptions") { this.debug("sending: " + xml(this.listSubscriptions(), { indent: "\t" })); res.send(xml(this.listSubscriptions())); } else if (req.body.Action === "ListTopics") { this.debug("sending: " + xml(this.listTopics(), { indent: "\t" })); res.send(xml(this.listTopics())); } else if (req.body.Action === "CreateTopic") { res.send(xml(this.createTopic(req.body.Name))); } else if (req.body.Action === "Subscribe") { res.send(xml(this.subscribe(req.body.Endpoint, req.body.Protocol, req.body.TopicArn, req.body))); } else if (req.body.Action === "Publish") { const target = this.extractTarget(req.body); if (req.body.MessageStructure === "json") { const json = JSON.parse(req.body.Message); if (typeof json.default !== "string") { throw new Error("Messages must have default key"); } } res.send(xml(this.publish(target, req.body.Subject, req.body.Message, req.body.MessageStructure, parseMessageAttributes(req.body), req.body.MessageGroupId))); } else if (req.body.Action === "Unsubscribe") { res.send(xml(this.unsubscribe(req.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"]); arn = this.convertPseudoParams(arn); const existingSubscription = this.subscriptions.find((subscription) => { 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, Owner: "", Attributes: attributes, Policies: filterPolicies, }; this.subscriptions.push(sub); subscriptionArn = sub.SubscriptionArn; } else { subscriptionArn = existingSubscription.SubscriptionArn; } return { SubscribeResponse: [ createAttr(), createMetadata(), { SubscribeResult: [ { SubscriptionArn: subscriptionArn, }, ], }, ], }; } evaluatePolicies(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: " + 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; } publishHttp(event, sub, raw) { return 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(), }, }) .then((res) => this.debug(res)) .catch((ex) => this.debug(ex)); } 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 }); if (sub["Attributes"]["RawMessageDelivery"] === "true") { const sendMsgReq = new SendMessageCommand({ QueueUrl: sub.Endpoint, MessageBody: event, MessageAttributes: formatMessageAttributes(messageAttributes), ...(messageGroupId && { MessageGroupId: messageGroupId }), }); return new Promise((resolve, reject) => { sqs .send(sendMsgReq).then(() => { resolve(); }); }); } else { const records = JSON.parse(event).Records ?? []; const messagePromises = records.map((record) => { const sendMsgReq = new SendMessageCommand({ QueueUrl: sub.Endpoint, MessageBody: JSON.stringify(record.Sns), MessageAttributes: formatMessageAttributes(messageAttributes), ...(messageGroupId && { MessageGroupId: messageGroupId }), }); return new Promise((resolve, reject) => { sqs .send(sendMsgReq).then(() => { resolve(); }); }); }); return new Promise((resolve, reject) => { Promise.all(messagePromises).then(() => resolve()); }); } } publish(topicArn, subject, message, messageStructure, messageAttributes, messageGroupId) { const messageId = createMessageId(); 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)) { this.debug("Filter policies failed. Skipping subscription: " + sub.Endpoint); return; } 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") { 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"); } }