<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Serverless Fanatic]]></title><description><![CDATA[Tips and tricks in keeping 100% Serverless]]></description><link>https://www.markgibaud.com</link><generator>RSS for Node</generator><lastBuildDate>Sun, 19 Apr 2026 10:57:38 GMT</lastBuildDate><atom:link href="https://www.markgibaud.com/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Hiding your AppSync GraphQL Introspection endpoint using AWS Web Application Firewall (WAF) rules]]></title><description><![CDATA[AWS AppSync is in my opinion one of the most underrated AWS services. I have found GraphQL APIs easier to manage at scale (100s of queries, mutations, subscriptions) with less overall code, cognitive load, complexity, points of failure, and less over...]]></description><link>https://www.markgibaud.com/hiding-your-appsync-graphql-introspection-endpoint-using-aws-web-application-firewall-waf-rules</link><guid isPermaLink="true">https://www.markgibaud.com/hiding-your-appsync-graphql-introspection-endpoint-using-aws-web-application-firewall-waf-rules</guid><category><![CDATA[serverless]]></category><category><![CDATA[aws-cdk]]></category><category><![CDATA[aws appsync]]></category><category><![CDATA[aws-waf]]></category><dc:creator><![CDATA[Mark Gibaud]]></dc:creator><pubDate>Thu, 23 Mar 2023 14:11:53 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/stock/unsplash/r-n8825rHuY/upload/6490414a44f4f808e4e27a9fde88472c.jpeg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p><a target="_blank" href="https://docs.aws.amazon.com/appsync/latest/devguide/what-is-appsync.html">AWS AppSync</a> is in my opinion one of the most underrated AWS services. I have found GraphQL APIs easier to manage at scale (100s of queries, mutations, subscriptions) with less overall code, cognitive load, complexity, points of failure, and less overall toil than back in the day with REST APIs. The relative simplicity of a GraphQL API, especially when backed against a NoSQL data store, with the choice of resolvers in VTL (fast) or Lambda (more expressive) interacting with a variety of data sources ultimately makes for a more streamlined developer experience.</p>
<p>Throw in the serious performance that AppSync can achieve, along with the tight integration with other AWS Services (for example, decorator-based resolving of certain schema keys <a target="_blank" href="https://docs.aws.amazon.com/appsync/latest/devguide/security-authz.html#amazon-cognito-user-pools-authorization">based on the users Cognito groups</a>) and it's clear AppSync is a huge step forward for developing and evolving web &amp; app APIs.</p>
<h2 id="heading-the-introspection-endpoint">The Introspection Endpoint</h2>
<p>All GraphQL APIs have an introspection endpoint. This is an endpoint that allows queries on the metadata of the schema for that particular GraphQL API. AppSync exposes this endpoint by default.</p>
<p>From the <a target="_blank" href="https://www.apollographql.com/blog/graphql/security/why-you-should-disable-graphql-introspection-in-production/">Apollo blog</a>:</p>
<blockquote>
<p>We believe that <strong>introspection should primarily be used as a discovery and diagnostic tool</strong> when we’re in the <strong>development phase</strong> of building out GraphQL APIs. While we don’t often use introspection <em>directly</em>, it’s important for tooling and GraphQL IDEs like Apollo Studio, GraphiQL, and Postman. Behind the scenes, GraphQL IDEs use introspection queries to power the clean user experience helpful for testing and diagnosing your graph during development.</p>
</blockquote>
<p>In a recent penetration test for a client, it surfaced that our introspection endpoint was queryable. I would go ahead and assume most penetration testers would raise this as an issue, as ultimately this API is a single place that does expose the entire surface area of your GraphQL API, which could aid bad actors in constructing a more advanced multi-faceted attack. Without this endpoint, attackers would have to laboriously move through your front-end apps and monitor network traffic, and it would be more difficult to construct the entire picture of your GraphQL API.</p>
<p>So it makes sense to only leave this introspection endpoint available in your development environments, and somehow turn it off in your production environment (and any other public environments). This is also the <a target="_blank" href="https://www.apollographql.com/blog/graphql/security/why-you-should-disable-graphql-introspection-in-production/#problems-with-introspection-in-production">general guidance from the Apollo developer team.</a></p>
<p>Luckily one of the many AWS services that integrates well with AppSync is Web Application Firewall or "WAF". With only a little more CDK code over and above the provisioning of our API, we can construct firewall rules that will block queries to the introspection endpoint.</p>
<p>Let's first provision our basic AppSync API:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> api = <span class="hljs-keyword">new</span> appsync.GraphqlApi(<span class="hljs-built_in">this</span>, <span class="hljs-string">"Api"</span>, {
      name: <span class="hljs-string">"UnintrospectableAppSyncApi"</span>,
      schema: appsync.SchemaFile.fromAsset(
        path.join(__dirname, <span class="hljs-string">"schema.graphql"</span>)
      ),
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: appsync.AuthorizationType.API_KEY,
        },
      },
    });

    <span class="hljs-keyword">new</span> CfnOutput(<span class="hljs-built_in">this</span>, <span class="hljs-string">"GraphQLAPIURL"</span>, {
      value: api.graphqlUrl,
    });
</code></pre>
<p>And with a simple <code>cdk deploy</code> we are up and running.</p>
<pre><code class="lang-bash"> ✅  AppsyncIntrospectionWafStack 

✨  Deployment time: 37.23s

Outputs:
AppsyncIntrospectionWafStack.GraphQLAPIURL = https://i33yq5nw3fgkxixzek3ixynz4u.appsync-api.us-east-1.amazonaws.com/graphql
Stack ARN:
arn:aws:cloudformation:us-east-1:XXX:stack/AppsyncIntrospectionWafStack/a4704550-c76c-11ed-80ea-0a701dd151a5
</code></pre>
<p>Now it is worth noting that your AppSync API Introspection endpoint is under the same authentication as the rest of your API. In this case, we're using an API key for simplicity. That is of course some protection, but if you have a public API or your API callers need a Cognito account, but anybody can sign up for your app, then it is still very easy for nefarious actors to get access to your introspection endpoint contents.</p>
<p>With our API up we can now query the introspection endpoint:</p>
<pre><code class="lang-bash">curl --location --request POST <span class="hljs-string">'https://i33yq5nw3fgkxixzek3ixynz4u.appsync-api.us-east-1.amazonaws.com/graphql'</span> \
--header <span class="hljs-string">'x-api-key: da2-coib25utiffj3gzioeskpkl3ka'</span> \
--header <span class="hljs-string">'Content-Type: application/json'</span> \
--data-raw <span class="hljs-string">'{"query":"query MyQuery {\n  __schema {\n    types {\n      name\n    }\n  }\n}","variables":{}}'</span>
</code></pre>
<p>Note the <code>__schema</code> token in the body of the request.</p>
<p>And in return we see our entire GraphQL schema:</p>
<pre><code class="lang-bash">{<span class="hljs-string">"data"</span>:{<span class="hljs-string">"__schema"</span>:{<span class="hljs-string">"types"</span>:[{<span class="hljs-string">"name"</span>:<span class="hljs-string">"Query"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"Blog"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"ID"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"String"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"BlogInput"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__Schema"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__Type"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__TypeKind"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__Field"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__InputValue"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"Boolean"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__EnumValue"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__Directive"</span>},{<span class="hljs-string">"name"</span>:<span class="hljs-string">"__DirectiveLocation"</span>}]}}}
</code></pre>
<p>Now let's provision our WAF layer to hide the introspection endpoint.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">const</span> firewall = <span class="hljs-keyword">new</span> waf.CfnWebACL(<span class="hljs-built_in">this</span>, <span class="hljs-string">"waf-firewall"</span>, {
      defaultAction: {
        allow: {},
      },
      description: <span class="hljs-string">"Block GraphQL introspection queries"</span>,
      scope: <span class="hljs-string">"REGIONAL"</span>,
      visibilityConfig: {
        cloudWatchMetricsEnabled: <span class="hljs-literal">true</span>,
        metricName: <span class="hljs-string">"BlockIntrospectionMetric"</span>,
        sampledRequestsEnabled: <span class="hljs-literal">true</span>,
      },
      rules: [
        {
          name: <span class="hljs-string">"BlockIntrospectionQueries"</span>,
          priority: <span class="hljs-number">0</span>,
          action: {
            block: {},
          },
          visibilityConfig: {
            sampledRequestsEnabled: <span class="hljs-literal">true</span>,
            cloudWatchMetricsEnabled: <span class="hljs-literal">true</span>,
            metricName: <span class="hljs-string">"BlockedIntrospection"</span>,
          },
          statement: {
            byteMatchStatement: {
              fieldToMatch: {
                body: {},
              },
              positionalConstraint: <span class="hljs-string">"CONTAINS"</span>,
              searchString: <span class="hljs-string">"__schema"</span>,
              textTransformations: [
                {
                  <span class="hljs-keyword">type</span>: <span class="hljs-string">"LOWERCASE"</span>,
                  priority: <span class="hljs-number">0</span>,
                },
              ],
            },
          },
        },
      ],
    });

    <span class="hljs-keyword">new</span> CfnWebACLAssociation(<span class="hljs-built_in">this</span>, <span class="hljs-string">"web-acl-association"</span>, {
      webAclArn: firewall.attrArn,
      resourceArn: api.arn,
    });
</code></pre>
<p>Fairly straightforward stuff. We provision an instance of <code>CfnWebACL</code> with the relevant config, and then an instance of <code>CfnWebACLAssociation</code> to associate that Firewall with our AppSync API.</p>
<p>Note though that we lowercase the "__schema" string, as the matching is case-sensitive. See this <a target="_blank" href="https://securitylabs.datadoghq.com/articles/appsync-vulnerability-disclosure/">unrelated but interesting AppSync vulnerability</a> discovered by the DataDog Security team as a reminder of what can happen when you don't take casing into account!</p>
<p>With another <code>cdk deploy</code> we have our firewall up and running.</p>
<p>Now when we try the same curl command above, we get a <code>403 Forbidden</code> error.</p>
<pre><code class="lang-bash">curl --location --request POST <span class="hljs-string">'https://i33yq5nw3fgkxixzek3ixynz4u.appsync-api.us-east-1.amazonaws.com/graphql'</span> \
--header <span class="hljs-string">'x-api-key: da2-coib25utiffj3gzioeskpkl3ka'</span> \
--header <span class="hljs-string">'Content-Type: application/json'</span> \
--data-raw <span class="hljs-string">'{"query":"query MyQuery {\n  __schema {\n    types {\n      name\n    }\n  }\n}","variables":{}}'</span>

{
  <span class="hljs-string">"errors"</span> : [ {
    <span class="hljs-string">"errorType"</span> : <span class="hljs-string">"WAFForbiddenException"</span>,
    <span class="hljs-string">"message"</span> : <span class="hljs-string">"403 Forbidden"</span>
  } ]
}
</code></pre>
<p>And that's it! One less tool at the attacker's disposal. And one less thing for your penetration testers to eagerly point out. 😎</p>
<p>As always, the full code is <a target="_blank" href="https://github.com/markgibaud/appsync-introspection-waf">available on Github.</a></p>
]]></content:encoded></item><item><title><![CDATA[Using Serverless Storage-First Pattern to ship analytics events between 3rd party providers]]></title><description><![CDATA[Background
I have a client that uses mParticle's customer data platform (CDP) to ingest analytics events from various sources including a mobile app, a web app and a cloud-based backend, as well as events from 3rd party systems like Branch (mobile ap...]]></description><link>https://www.markgibaud.com/using-serverless-storage-first-pattern-to-ship-analytics-events-between-3rd-party-providers</link><guid isPermaLink="true">https://www.markgibaud.com/using-serverless-storage-first-pattern-to-ship-analytics-events-between-3rd-party-providers</guid><category><![CDATA[serverless]]></category><category><![CDATA[API Gateway]]></category><category><![CDATA[SQS]]></category><category><![CDATA[aws-cdk]]></category><dc:creator><![CDATA[Mark Gibaud]]></dc:creator><pubDate>Wed, 08 Feb 2023 15:18:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1675869941766/b31ed730-554f-4fb0-b0a7-86d82007c49f.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h3 id="heading-background">Background</h3>
<p>I have a client that uses <a target="_blank" href="https://www.mparticle.com/">mParticle's</a> customer data platform (CDP) to ingest analytics events from various sources including a mobile app, a web app and a cloud-based backend, as well as events from 3rd party systems like Branch (mobile app install attribution) or Algolia (search item click conversion).</p>
<p>You can use mParticle to set up connections to downstream 3rd party systems which would then receive all the ingested analytics events in near real-time. For example, you could forward events to Mixpanel for in-depth user/feature analysis, or Intercom to then be able to message users based on their in-app activity.</p>
<p>In the world of mParticle, analytics events are broken down into categories like screen navigation events, e-commerce/commercial events, or "custom" events that are more specific to your domain. mParticle supports forwarding most event types to most providers, but some support is still lacking.</p>
<p>The client recently integrated <a target="_blank" href="https://iterable.com/">Iterable</a> as a customer engagement solution, but unfortunately, Iterable only supports receiving <strong><em>custom</em></strong> events from mParticle, so screen/navigation events and "lifecycle" events like a mobile app "Session Start" event, were missing after the mParticle/Iterable connection was configured.</p>
<p>The marketing team very much expressed interest in being able to target users for certain messaging, based on their session behaviour and screen journeys.</p>
<p>We realized that we would need a custom solution to effectively ship analytics events from mParticle to Iterable that weren't catered for automatically by the configured connection.</p>
<p>Our first thought was to use a webhook API from mParticle, with a lambda invocation to forward the event batch to Iterable using the Iterable API. However, given the marketing team's requirements, the data flow didn't need to be real-time. Something like once per hour was likely good enough. In addition, the webhook API didn't need any kind of sophisticated response, but rather it was just "delivering" an analytics event batch with a relatively well-understood schema. Finally, we knew that the API would be called very frequently as events streamed in, and this number would increase as the user activity on the platform scaled up.</p>
<h3 id="heading-the-storage-first-pattern">The Storage-First Pattern</h3>
<p>Using the Storage First pattern you can capture the payload of incoming web requests and save them straight to SQS, SNS, EventBridge, Kinesis or a host of other AWS Services. This is viable when you don't need a lambda to perform more complex authentication/authorization, parsing, transformation and/or saving of the payload. Using the pattern in the correct circumstances allows you to reduce the latency of the response (no lambda cold start or processing time), lower cost (no lambda invocations), and also facilitates the ease-of-retry that you get with lambdas with an async event source should any processing fail.</p>
<p>Our need to batch up incoming analytics events and send them on to another 3rd party fits this use case. We can use a API Gateway SQS service integration, store the event batches in an SQS queue, and use a lambda on a <s>cron job</s> EventBridge recurring schedule 😎 to invoke the lambda. It would consume all stored messages and call the <a target="_blank" href="https://api.iterable.com/api/docs#events_trackBulk">Iterable <code>trackBulk</code> API</a>. We did observe that event payloads from mParticle were typically in the 5-10KB range so well within the 256KB SQS message size limit. This configuration would also avoid the long-polling of the queue by the lambda, which greatly increases the number of requests to the queue (remember SQS is billed by the number of requests). This configuration would result in only a handful of SQS requests every hour (the number of event batches/messages in the queue divided by 10 messages per batch) and so would be very cost-effective indeed!</p>
<p>So, once more unto the breach!</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1675850973917/343d5004-bee5-4a57-8387-d6e73cf02fd5.png" alt class="image--center mx-auto" /></p>
<p>First, we provision our queue.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// New SQS queue</span>
    <span class="hljs-keyword">const</span> sqsQueue = <span class="hljs-keyword">new</span> sqs.Queue(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-events-queue`</span>, {
      queueName: <span class="hljs-string">`<span class="hljs-subst">${id}</span>-queue`</span>,
      visibilityTimeout: Duration.seconds(<span class="hljs-number">30</span>),
      retentionPeriod: Duration.days(<span class="hljs-number">4</span>),
      receiveMessageWaitTime: Duration.seconds(<span class="hljs-number">0</span>),
      deadLetterQueue: {
        queue: <span class="hljs-keyword">new</span> sqs.Queue(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-events-dlq`</span>),
        maxReceiveCount: <span class="hljs-number">3</span>,
      },
    });
</code></pre>
<ul>
<li><p>The <code>visibilityTimeout</code> should be the same timeout duration that we set for our consuming lambda. Here we've gone for 30 seconds, which should be plenty of time to process an hour's worth of analytics events at current scale. We'd need to keep an eye on the average duration of the lambda as our analytics traffic scales.</p>
</li>
<li><p>The <code>retentionPeriod</code> is set at 4 days (which is the default) and should be plenty of time to triage the lambda should it stop working, while not losing any analytics events.</p>
</li>
<li><p>The <code>receiveMessageWaitTime</code> parameter defaults to 0, so no actual need to set it here, but noting here as this disables any wait time or long-polling on any <code>ReceiveMessage</code> calls, reducing our "SQS requests" billable amount.</p>
</li>
<li><p>We provision a simple dead-letter queue. This would allow us to manually retry the processing of events at a later time, should we need to (for example, if the Iterable API went down for a prolonged period).</p>
</li>
</ul>
<p>Now, our Gateway API, Rest resource and some related IAM privileges.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// Rest API</span>
    <span class="hljs-keyword">const</span> restApi = <span class="hljs-keyword">new</span> apigw.RestApi(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-gateway`</span>, {
      restApiName: <span class="hljs-string">`<span class="hljs-subst">${id}</span>-gateway`</span>,
      description: <span class="hljs-string">"API Gateway for mParticle Iterable API Proxy"</span>,
      deployOptions: {
        stageName: <span class="hljs-string">"dev"</span>,
      },
    });

<span class="hljs-comment">// You likely want to add an authentication mechanism of some sort to your API</span>
    <span class="hljs-comment">// You can usually configure your 3rd party to use this API key in the webhook configuration</span>
    restApi.addApiKey(<span class="hljs-string">"APIKey"</span>, {
      apiKeyName: <span class="hljs-string">"APIKey"</span>,
      value: <span class="hljs-string">"2526a244-5766-4eca-aced-65643b080867"</span>,
    });

    <span class="hljs-comment">// Event batch Rest API resource</span>
    <span class="hljs-keyword">const</span> eventBatchResource = restApi.root.addResource(<span class="hljs-string">"event-batch"</span>);

    <span class="hljs-comment">// Yes it's IAM again :-)</span>
    <span class="hljs-keyword">const</span> gatewayServiceRole = <span class="hljs-keyword">new</span> iam.Role(<span class="hljs-built_in">this</span>, <span class="hljs-string">"api-gateway-role"</span>, {
      assumedBy: <span class="hljs-keyword">new</span> iam.ServicePrincipal(<span class="hljs-string">"apigateway.amazonaws.com"</span>),
    });

    <span class="hljs-comment">// This allows API Gateway to send our event body to our specific queue</span>
    gatewayServiceRole.addToPolicy(
      <span class="hljs-keyword">new</span> iam.PolicyStatement({
        resources: [sqsQueue.queueArn],
        actions: [<span class="hljs-string">"sqs:SendMessage"</span>],
      })
    );
</code></pre>
<p>Ok, now that we have our Rest resource provisioned at <code>/event-batch</code> , let's add our SQS proxy integration.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// A request template that tells API Gateway what action (SendMessage) to apply to what part of the payload (Body)</span>
    <span class="hljs-keyword">const</span> requestTemplate =
      <span class="hljs-string">'Action=SendMessage&amp;MessageBody=$util.urlEncode("$input.body")'</span>;

    <span class="hljs-keyword">const</span> AWS_ACCOUNT_ID = <span class="hljs-string">"YOUR_AWS_ACCOUNT_ID"</span>;
    <span class="hljs-keyword">const</span> awsIntegrationProps: apigw.AwsIntegrationProps = {
      service: <span class="hljs-string">"sqs"</span>,
      integrationHttpMethod: <span class="hljs-string">"POST"</span>,
      <span class="hljs-comment">// Path is where we specify the sqs queue to send to</span>
      path: <span class="hljs-string">`<span class="hljs-subst">${AWS_ACCOUNT_ID}</span>/<span class="hljs-subst">${sqsQueue.queueName}</span>`</span>,
      options: {
        passthroughBehavior: apigw.PassthroughBehavior.NEVER,
        credentialsRole: gatewayServiceRole,
        requestParameters: {
        <span class="hljs-comment">// API Gateway needs to send messages to SQS using content type form-urlencoded</span>
          <span class="hljs-string">"integration.request.header.Content-Type"</span>: <span class="hljs-string">'application/x-www-form-urlencoded'</span>,
        },
        requestTemplates: {
          <span class="hljs-string">"application/json"</span>: requestTemplate,
        },
        integrationResponses: [
          {
            statusCode: <span class="hljs-string">"200"</span>,
          },
          {
            statusCode: <span class="hljs-string">"500"</span>,
            responseTemplates: {
              <span class="hljs-string">"text/html"</span>: <span class="hljs-string">"Error"</span>,
            },
            selectionPattern: <span class="hljs-string">"500"</span>,
          },
        ],
      },
    };

<span class="hljs-comment">// Add the Rest API Method, along with the integration, </span>
<span class="hljs-comment">// Also creating the required 200 Method Response </span>
eventBatchResource.addMethod(<span class="hljs-string">"POST"</span>, <span class="hljs-keyword">new</span> apigw.AwsIntegration(awsIntegrationProps),
{ methodResponses: [{ statusCode: <span class="hljs-string">"200"</span> }] });
</code></pre>
<p>Ok great! Now with a simple <code>cdk deploy</code> we have our Gateway API, our Rest API &amp; SQS service integration, and our SQS queue itself. A simple test with curl:</p>
<pre><code class="lang-bash">curl --location --request POST <span class="hljs-string">'https://{YOUR_API_ID}.execute-api.us-east-1.amazonaws.com/dev/event-batch'</span> \
--header <span class="hljs-string">'Content-Type: application/json'</span> \
--header <span class="hljs-string">'X-API-Key: 2526a244-5766-4eca-aced-65643b080867'</span> \
--data-raw <span class="hljs-string">'{ "testEventData": "test" }'</span>
</code></pre>
<p>And we should be able to see our message in the queue:</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1675858468541/6d0034a5-b2ed-41f9-bd4b-744eec947aaa.png" alt class="image--center mx-auto" /></p>
<p>We then configured an mParticle connection to our webhook API, and started receiving our analytics event batches. All that was left was to provision our event forwarder lambda, and associated EventBridge recurring rule to invoke it.</p>
<pre><code class="lang-typescript"><span class="hljs-comment">// event forwarder lambda</span>
    <span class="hljs-keyword">const</span> forwarder = <span class="hljs-keyword">new</span> NodejsFunction(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-event-forwarder`</span>, {
      runtime: Runtime.NODEJS_16_X,
      functionName: <span class="hljs-string">`<span class="hljs-subst">${id}</span>-event-forwarder`</span>,
      entry: <span class="hljs-string">"src/functions/forwarder/forwarder.ts"</span>,
      handler: <span class="hljs-string">"handler"</span>,
      memorySize: <span class="hljs-number">512</span>,
      timeout: Duration.seconds(<span class="hljs-number">30</span>),
      architecture: Architecture.ARM_64,
      environment: {
        SQS_QUEUE_URL: sqsQueue.queueUrl,
      },
      initialPolicy: [
        <span class="hljs-keyword">new</span> PolicyStatement({
          actions: [<span class="hljs-string">"sqs:ReceiveMessage"</span>, <span class="hljs-string">"sqs:DeleteMessageBatch"</span>],
          resources: [sqsQueue.queueArn],
        }),
      ],
    });

    <span class="hljs-keyword">const</span> lambdaTarget = <span class="hljs-keyword">new</span> targets.LambdaFunction(forwarder);
    <span class="hljs-keyword">new</span> events.Rule(<span class="hljs-built_in">this</span>, <span class="hljs-string">"ForwarderScheduleRule"</span>, {
      description: <span class="hljs-string">"Forward all stored mParticle events to Iterable every hour"</span>,
      schedule: events.Schedule.rate(Duration.hours(<span class="hljs-number">1</span>)),
      targets: [lambdaTarget],
      <span class="hljs-comment">// omitting the `eventBus` property puts the rule on the default event bus</span>
    });
</code></pre>
<p>And now the lambda itself:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { EventBridgeEvent } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-lambda"</span>;
<span class="hljs-keyword">import</span> { ReceiveMessageCommand, SQSClient } <span class="hljs-keyword">from</span> <span class="hljs-string">"@aws-sdk/client-sqs"</span>;
<span class="hljs-keyword">import</span> axios <span class="hljs-keyword">from</span> <span class="hljs-string">"axios"</span>;

<span class="hljs-keyword">type</span> ScheduledEvent = {};

<span class="hljs-keyword">const</span> sqsClient = <span class="hljs-keyword">new</span> SQSClient({ region: <span class="hljs-string">"us-east-1"</span> });
<span class="hljs-keyword">const</span> iterableClient = axios.create({
  baseURL: <span class="hljs-string">"https://api.iterable.com/api"</span>,
  headers: {
    <span class="hljs-string">"Api-Key"</span>: <span class="hljs-string">"YOUR_ITERABLE_API_KEY"</span>,
    <span class="hljs-string">"Content-Type"</span>: <span class="hljs-string">"application/json"</span>,
  },
});

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> handler = <span class="hljs-keyword">async</span> (_event: EventBridgeEvent&lt;ScheduledEvent&gt;) =&gt; {
  <span class="hljs-keyword">const</span> receiveMessageCommand = <span class="hljs-keyword">new</span> ReceiveMessageCommand({
    QueueUrl: process.env.SQS_QUEUE_URL,
    MaxNumberOfMessages: <span class="hljs-number">10</span>,
    WaitTimeSeconds: <span class="hljs-number">0</span>,
  });

  <span class="hljs-keyword">let</span> sqsResponse = <span class="hljs-keyword">await</span> sqsClient.send(receiveMessageCommand);
  <span class="hljs-keyword">while</span> (sqsResponse.Messages &amp;&amp; sqsResponse.Messages?.length &gt; <span class="hljs-number">0</span>) {
    <span class="hljs-keyword">const</span> iterablePayload = mapEventsToIterablePayload(sqsResponse.Messages);

    <span class="hljs-comment">// https://api.iterable.com/api/docs#events_trackBulk</span>
    <span class="hljs-keyword">await</span> iterableClient.post(<span class="hljs-string">"/events/trackBulk"</span>, iterablePayload);

    <span class="hljs-keyword">const</span> deleteMessageBatchCommand = <span class="hljs-keyword">new</span> DeleteMessageBatchCommand({
      QueueUrl: process.env.SQS_QUEUE_URL,
      Entries: sqsResponse.Messages.map(<span class="hljs-function">(<span class="hljs-params">message</span>) =&gt;</span> ({
        Id: message.MessageId,
        ReceiptHandle: message.ReceiptHandle,
      })),
    });
    <span class="hljs-keyword">await</span> sqsClient.send(deleteMessageBatchCommand);

    sqsResponse = <span class="hljs-keyword">await</span> sqsClient.send(receiveMessageCommand);
  }
};
</code></pre>
<ul>
<li><p>We set the <code>MaxNumberOfMessages</code> to 10, which is the max, to maximize throughput of the lambda and minimize the number of `ReceiveMessage` calls we'll need to make.</p>
</li>
<li><p>The <code>WaitTimeSeconds</code> is arguably duplicative of our cdk queue's <code>receiveMessageWaitTime</code> as it performs the same function, but from the SDK's side. Take your pick!</p>
</li>
<li><p>The <code>mapEventsToIterablePayload</code> is unimplemented here but this is of course very domain specific. The output of that function is a json object in a schema that is accepted by the Iterable API.</p>
</li>
<li><p>Once we get a successful response from Iterable, we batch delete those now-processed messages from the queue, and then read the next batch.</p>
</li>
</ul>
<p>Once all up and running we saw our analytics events arrive in Iterable over the course of the day, with minimal Lambda &amp; SQS costs.</p>
<h3 id="heading-what-about-idempotency-or-partial-failure">What about idempotency or partial failure?</h3>
<p>Great question, as well-behaving lambdas should take this into account. In this case, we get a free pass as all the analytics events have a <code>messageId</code>. If Iterable receives multiple events with the same ID, it does not create a new event but rather overwrites the existing event. As events are effectively immutable, this is a safe operation. So if some of our messages were to be reprocessed, it wouldn't be the end of the world.</p>
<h2 id="heading-thanks-for-reading-see-also">Thanks for reading! See also</h2>
<p>Full code for this solution is on Github:</p>
<p><a target="_blank" href="https://github.com/markgibaud/mparticle-iterable-api-proxy-sqs-integration">https://github.com/markgibaud/mparticle-iterable-api-proxy-sqs-integration</a></p>
<p>Jeremy Daly introduces the Storage-First pattern way back in this 2020 blog post:</p>
<p><a target="_blank" href="https://www.jeremydaly.com/the-storage-first-pattern/">https://www.jeremydaly.com/the-storage-first-pattern/</a></p>
<p>More recently, Robert Bulmer deploys a similar pattern but using S3:</p>
<p><a target="_blank" href="https://awstip.com/storage-first-pattern-in-aws-with-api-gateway-part-1-using-s3-216e20b08353">https://awstip.com/storage-first-pattern-in-aws-with-api-gateway-part-1-using-s3-216e20b08353</a></p>
<p>Also check out the AWS Solutions Construct CDK template for a much more fully-baked solution:</p>
<p><a target="_blank" href="https://docs.aws.amazon.com/solutions/latest/constructs/aws-apigateway-sqs.html">https://docs.aws.amazon.com/solutions/latest/constructs/aws-apigateway-sqs.html</a></p>
]]></content:encoded></item><item><title><![CDATA[Using the new Amazon EventBridge Scheduler to send reminder push notifications to a mobile app (with example CDK code).]]></title><description><![CDATA[I have a client that maintains a sports club mobile app that allows club members to book tennis and squash courts. For a while, we've wanted to add push notifications to remind members of their court booking a few hours before the time, but we've alw...]]></description><link>https://www.markgibaud.com/using-the-new-amazon-eventbridge-scheduler-to-send-reminder-notifications</link><guid isPermaLink="true">https://www.markgibaud.com/using-the-new-amazon-eventbridge-scheduler-to-send-reminder-notifications</guid><category><![CDATA[AWS EventBridge]]></category><category><![CDATA[push notifications]]></category><category><![CDATA[aws-cdk]]></category><dc:creator><![CDATA[Mark Gibaud]]></dc:creator><pubDate>Tue, 24 Jan 2023 21:03:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/res/hashnode/image/upload/v1674237264894/1f03c638-b1b1-46a3-b3a9-3dd400b51396.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I have a client that maintains a sports club mobile app that allows club members to book tennis and squash courts. For a while, we've wanted to add push notifications to remind members of their court booking a few hours before the time, but we've always thought the implementation would be a little heavy. For example, one idea would be to store a reminder time in a DynamoDB database, and for a lambda function to run say once every minute and send out push notifications reminders for any bookings that it might find for that minute. Of course, that means 1440 lambda invocations every day. Not breaking the bank but not a very clean, elegant or sustainable solution either.</p>
<p>So we were delighted when the <a target="_blank" href="https://aws.amazon.com/blogs/compute/introducing-amazon-eventbridge-scheduler/">Amazon EventBridge scheduler was announced</a> in the lead-up to ReInvent 2022! The scheduler is perfect for this use case using a once-off (rather than recurring) schedule and is very simple to implement.</p>
<p>To remind folks, AWS EventBridge is a serverless event bus that makes it easy to connect up applications using data from your apps, integrated SaaS applications, and other AWS services running as part of your platform.</p>
<p>Recently added is the EventBridge Scheduler, and standalone component which allows you to schedule events to trigger at specific times or intervals, resulting in a new event put into the event bus.</p>
<h3 id="heading-the-implementation">The implementation</h3>
<p>In our sports club mobile app, you can access a menu to do several things to a booking, for example, send an email with details of your court booking, or add the event to your calendar. We added a new menu item "Set Reminder" and gave the user the option to receive the notification 1, 6 or 24 hours before their match.</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1674494835412/8d96f631-3ec4-4c2b-a6ee-41a2568f9e86.png?height=600" alt class="image--center mx-auto" /></p>
<p>The process to create the reminder is quite simple:</p>
<ol>
<li><p>The app would POST to a create reminder Rest API (AWS API Gateway endpoint). Included in the payload would be the message, the reminder time and the push notification device token for the mobile device.</p>
</li>
<li><p>That would invoke a <code>create-reminder</code> lambda which would create a one-time schedule in AWS EventBridge Scheduler. The target would be another lambda function, <code>send-reminder</code>.</p>
</li>
<li><p>At the specified time, the <code>send-reminder</code> lambda invokes from the event and in our case, calls out to Firebase to send out our reminder push notification.</p>
</li>
</ol>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1674495772828/77f78672-ac5f-4158-94a0-eaba2acbb5e8.png" alt class="image--center mx-auto" /></p>
<p>Let's get started! First, we need the infrastructure. Here is the CDK code to set up the Rest API, 2 lambda functions and EventBridge event bus, along with associated policies.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> cdk <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib"</span>;
<span class="hljs-keyword">import</span> { Duration } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib"</span>;
<span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> apigw <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib/aws-apigateway"</span>;
<span class="hljs-keyword">import</span> { Rule } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib/aws-events"</span>;
<span class="hljs-keyword">import</span> { LambdaFunction } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib/aws-events-targets"</span>;
<span class="hljs-keyword">import</span> {
  Effect,
  Policy,
  PolicyStatement,
  Role,
  ServicePrincipal,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib/aws-iam"</span>;
<span class="hljs-keyword">import</span> { Architecture, Runtime } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib/aws-lambda"</span>;
<span class="hljs-keyword">import</span> { NodejsFunction } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-cdk-lib/aws-lambda-nodejs"</span>;
<span class="hljs-keyword">import</span> { Construct } <span class="hljs-keyword">from</span> <span class="hljs-string">"constructs"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">class</span> EventBridgeRemindersStack <span class="hljs-keyword">extends</span> cdk.Stack {
  <span class="hljs-keyword">constructor</span>(<span class="hljs-params">scope: Construct, id: <span class="hljs-built_in">string</span>, props?: cdk.StackProps</span>) {
    <span class="hljs-built_in">super</span>(scope, id, props);

    <span class="hljs-comment">// Core infra: EventBridgeReminders Rest API</span>
    <span class="hljs-keyword">const</span> eventBridgeRemindersApi = <span class="hljs-keyword">new</span> apigw.RestApi(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-gateway`</span>, {
      restApiName: <span class="hljs-string">`<span class="hljs-subst">${id}</span>-gateway`</span>,
      description: <span class="hljs-string">"API for creating push notification reminders"</span>,
      deployOptions: {
        stageName: <span class="hljs-string">"dev"</span>,
      },
    });

    <span class="hljs-comment">// Core infra: Eventbridge event bus</span>
    <span class="hljs-keyword">const</span> eventBus = <span class="hljs-keyword">new</span> cdk.aws_events.EventBus(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-event-bus`</span>, {
      eventBusName: <span class="hljs-string">`<span class="hljs-subst">${id}</span>-event-bus`</span>,
    });

    <span class="hljs-comment">// need to create a service-linked role and policy for </span>
    <span class="hljs-comment">// the scheduler to be able to put events onto our bus</span>
    <span class="hljs-keyword">const</span> schedulerRole = <span class="hljs-keyword">new</span> Role(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-scheduler-role`</span>, {
      assumedBy: <span class="hljs-keyword">new</span> ServicePrincipal(<span class="hljs-string">"scheduler.amazonaws.com"</span>),
    });

    <span class="hljs-keyword">new</span> Policy(<span class="hljs-built_in">this</span>, <span class="hljs-string">`<span class="hljs-subst">${id}</span>-schedule-policy`</span>, {
      policyName: <span class="hljs-string">"ScheduleToPutEvents"</span>,
      roles: [schedulerRole],
      statements: [
        <span class="hljs-keyword">new</span> PolicyStatement({
          effect: Effect.ALLOW,
          actions: [<span class="hljs-string">"events:PutEvents"</span>],
          resources: [eventBus.eventBusArn],
        }),
      ],
    });

    <span class="hljs-comment">// Create reminder lambda</span>
    <span class="hljs-keyword">const</span> createNotificationReminderLambda = <span class="hljs-keyword">new</span> NodejsFunction(
      <span class="hljs-built_in">this</span>,
      <span class="hljs-string">"createNotificationReminder"</span>,
      {
        runtime: Runtime.NODEJS_16_X,
        functionName: <span class="hljs-string">`<span class="hljs-subst">${id}</span>-create-notification-reminder`</span>,
        entry: <span class="hljs-string">"src/functions/notificationReminder/create.ts"</span>,
        handler: <span class="hljs-string">"handler"</span>,
        memorySize: <span class="hljs-number">512</span>,
        timeout: Duration.seconds(<span class="hljs-number">3</span>),
        architecture: Architecture.ARM_64,
        environment: {
          SCHEDULE_ROLE_ARN: schedulerRole.roleArn,
          EVENTBUS_ARN: eventBus.eventBusArn,
        },
        initialPolicy: [
          <span class="hljs-comment">// Give lambda permission to create the group &amp; schedule and pass IAM role to the scheduler</span>
          <span class="hljs-keyword">new</span> PolicyStatement({
            actions: [
              <span class="hljs-string">"scheduler:CreateSchedule"</span>,
              <span class="hljs-string">"scheduler:CreateScheduleGroup"</span>,
              <span class="hljs-string">"iam:PassRole"</span>,
            ],
            resources: [<span class="hljs-string">"*"</span>],
          }),
        ],
      }
    );

    <span class="hljs-keyword">const</span> sendNotificationLambda = <span class="hljs-keyword">new</span> NodejsFunction(
      <span class="hljs-built_in">this</span>,
      <span class="hljs-string">"sendNotification"</span>,
      {
        functionName: <span class="hljs-string">`<span class="hljs-subst">${id}</span>-send-notification`</span>,
        runtime: Runtime.NODEJS_16_X,
        architecture: Architecture.ARM_64,
        handler: <span class="hljs-string">"handler"</span>,
        entry: <span class="hljs-string">"src/functions/notification/send.ts"</span>,
        memorySize: <span class="hljs-number">512</span>,
        timeout: Duration.seconds(<span class="hljs-number">3</span>),
        bundling: {
          commandHooks: {
            beforeBundling(): <span class="hljs-built_in">string</span>[] {
              <span class="hljs-keyword">return</span> [];
            },
            <span class="hljs-comment">// This is an easy way to include files in the bundle</span>
            <span class="hljs-comment">// of your lambda. A more secure method would be to</span>
            <span class="hljs-comment">// retrieve and cache this file from S3 in the lambda code</span>
            afterBundling(inputDir: <span class="hljs-built_in">string</span>, outputDir: <span class="hljs-built_in">string</span>): <span class="hljs-built_in">string</span>[] {
              <span class="hljs-keyword">return</span> [
                <span class="hljs-string">`cp <span class="hljs-subst">${inputDir}</span>/src/functions/notification/firebaseapp-config-XXXXXXX.json <span class="hljs-subst">${outputDir}</span>`</span>,
              ];
            },
            beforeInstall() {
              <span class="hljs-keyword">return</span> [];
            },
          },
        },
      }
    );

    <span class="hljs-comment">// Rule to match schedules for users and attach our email customer lambda.</span>
    <span class="hljs-keyword">new</span> Rule(<span class="hljs-built_in">this</span>, <span class="hljs-string">"ReminderNotification"</span>, {
      description: <span class="hljs-string">"Send a push notification reminding user of a booked court"</span>,
      eventPattern: {
        source: [<span class="hljs-string">"scheduler.notifications"</span>],
        detailType: [<span class="hljs-string">"ReminderNotification"</span>],
      },
      eventBus,
    }).addTarget(<span class="hljs-keyword">new</span> LambdaFunction(sendNotificationLambda));

    <span class="hljs-keyword">const</span> notificationReminderRestResource =
      eventBridgeRemindersApi.root.addResource(<span class="hljs-string">"notification-reminder"</span>);
    notificationReminderRestResource.addMethod(
      <span class="hljs-string">"POST"</span>,
      <span class="hljs-keyword">new</span> apigw.LambdaIntegration(createNotificationReminderLambda)
    );

    <span class="hljs-comment">// CDK Outputs</span>
    <span class="hljs-keyword">new</span> cdk.CfnOutput(<span class="hljs-built_in">this</span>, <span class="hljs-string">"eventBridgeRemindersApiEndpoint"</span>, {
      value: eventBridgeRemindersApi.url,
    });
  }
}
</code></pre>
<h3 id="heading-some-notes-on-this-code">Some notes on this code</h3>
<ul>
<li><p>Take note of the various IAM privileges we need to apply to the various components to make this all work. IAM is always fun!</p>
</li>
<li><p>As noted in the comments, we're using the esbuild's <code>commandHooks</code> functionality to bundle our Firebase config file. This is just for convenience, but you may want to avoid having to commit a file of secrets like that into source control, and rather have the lambda fetch that file from a secured "secrets" S3 bucket.</p>
</li>
<li><p>The EventBridge scheduler is currently only available in the "major" AWS regions, so while a <code>cdk deploy</code> on this code would work in say <code>eu-west-2</code>, when the <code>create-reminder</code> lambda invokes, you would get an "Endpoint not found" error because there is no EventBridge Scheduler endpoint yet in the London region! <a target="_blank" href="https://aws.amazon.com/about-aws/whats-new/2022/11/amazon-eventbridge-launches-new-scheduler/#:~:text=Amazon%20EventBridge%20Scheduler%20is%20generally,and%20Asia%20Pacific%20(Singapore).">Check supported regions here.</a></p>
</li>
<li><p>And of course, you'd likely want some authentication on that Rest API!</p>
</li>
</ul>
<p>Before we have a look at our lambda code, let's have a quick look at the incoming payload we are expecting:</p>
<pre><code class="lang-typescript">{
      device_token: token,
      datetime: reminderDate.format(<span class="hljs-string">'YYYY-MM-DDThh:mm:ss'</span>), <span class="hljs-comment">//eg. '2023-01-23T20:30:00'</span>
      message: <span class="hljs-string">`Your club match starts in <span class="hljs-subst">${hours}</span> <span class="hljs-subst">${
        hours === <span class="hljs-number">1</span> ? <span class="hljs-string">'hour'</span> : <span class="hljs-string">'hours'</span>
      }</span>`</span>
    }
</code></pre>
<ul>
<li><p>We leave it up to the app to specify the reminder message text.</p>
</li>
<li><p>The date here is UTC but the Scheduler also <a target="_blank" href="https://docs.aws.amazon.com/scheduler/latest/UserGuide/schedule-types.html#time-zones">supports specifying timezones</a>.</p>
</li>
</ul>
<p>Now let's have a look at the <code>create-reminder</code> lambda code.</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { APIGatewayProxyEvent, APIGatewayProxyResult } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-lambda"</span>;
<span class="hljs-keyword">import</span> {
  SchedulerClient,
  CreateScheduleCommand,
  FlexibleTimeWindowMode,
  CreateScheduleGroupCommand,
} <span class="hljs-keyword">from</span> <span class="hljs-string">"@aws-sdk/client-scheduler"</span>;
<span class="hljs-keyword">import</span> { v4 <span class="hljs-keyword">as</span> uuidv4 } <span class="hljs-keyword">from</span> <span class="hljs-string">"uuid"</span>;
<span class="hljs-keyword">import</span> { z } <span class="hljs-keyword">from</span> <span class="hljs-string">"zod"</span>;

<span class="hljs-keyword">const</span> schedulerClient = <span class="hljs-keyword">new</span> SchedulerClient({ region: <span class="hljs-string">"eu-west-1"</span> });

<span class="hljs-keyword">const</span> requestSchema = z.object({
  device_token: z.string(),
  datetime: z.string(),
  message: z.string(),
});
<span class="hljs-keyword">type</span> NotificationReminder = z.infer&lt;<span class="hljs-keyword">typeof</span> requestSchema&gt;;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> handler = <span class="hljs-keyword">async</span> (
  event: APIGatewayProxyEvent
): <span class="hljs-built_in">Promise</span>&lt;APIGatewayProxyResult&gt; =&gt; {
  <span class="hljs-keyword">if</span> (!event.body) {
    <span class="hljs-keyword">return</span> {
      statusCode: <span class="hljs-number">400</span>,
      body: <span class="hljs-built_in">JSON</span>.stringify({ message: <span class="hljs-string">"No body found"</span> }),
    };
  }

  <span class="hljs-keyword">const</span> reminder: NotificationReminder = requestSchema.parse(
    <span class="hljs-built_in">JSON</span>.parse(event.body)
  );

  <span class="hljs-keyword">try</span> {
    <span class="hljs-comment">// Create a schedule group</span>
    <span class="hljs-comment">// You could argue this should be part of the infra (cdk)</span>
    <span class="hljs-comment">// I would agree but a CDK construct for a ScheduleGroup</span>
    <span class="hljs-comment">// is not yet available</span>
    <span class="hljs-keyword">await</span> schedulerClient.send(
      <span class="hljs-keyword">new</span> CreateScheduleGroupCommand({
        Name: <span class="hljs-string">"NotificationRulesScheduleGroup"</span>,
      })
    );
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-comment">// the above would throw if that ScheduleGroup already exists</span>
}

  <span class="hljs-keyword">try</span> {
    <span class="hljs-keyword">const</span> cmd = <span class="hljs-keyword">new</span> CreateScheduleCommand({
      <span class="hljs-comment">// A rule can't have the same name as another rule</span>
      <span class="hljs-comment">// in the same Region and on the same event bus.</span>
      Name: <span class="hljs-string">`<span class="hljs-subst">${uuidv4()}</span>`</span>,
      GroupName: <span class="hljs-string">"NotificationRulesScheduleGroup"</span>,
      Target: {
        RoleArn: process.env.SCHEDULE_ROLE_ARN,
        Arn: process.env.EVENTBUS_ARN,
        EventBridgeParameters: {
          DetailType: <span class="hljs-string">"ReminderNotification"</span>,
          Source: <span class="hljs-string">"scheduler.notifications"</span>, 
        },
        Input: <span class="hljs-built_in">JSON</span>.stringify({ ...reminder }),
      },
      FlexibleTimeWindow: {
        Mode: FlexibleTimeWindowMode.OFF,
      },
      Description: <span class="hljs-string">`Send push notification to <span class="hljs-subst">${reminder.device_token}</span> at <span class="hljs-subst">${reminder.datetime}</span>`</span>,
      ScheduleExpression: <span class="hljs-string">`at(<span class="hljs-subst">${reminder.datetime}</span>)`</span>,
    });
    <span class="hljs-keyword">await</span> schedulerClient.send(cmd);
  } <span class="hljs-keyword">catch</span> (error) {
    <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"failed"</span>, error);
  }

  <span class="hljs-keyword">return</span> {
    statusCode: <span class="hljs-number">200</span>,
    body: <span class="hljs-built_in">JSON</span>.stringify({
      message: <span class="hljs-string">"Notification reminder created successfully"</span>,
    }),
  };
};
</code></pre>
<h3 id="heading-some-notes">Some notes</h3>
<ul>
<li><p>We use the excellent <a target="_blank" href="https://github.com/colinhacks/zod">zod</a> library which is a great way to get from a json payload to a Typescript type via explicitly validating against a predefined schema.</p>
</li>
<li><p>The lambda creating the Schedule Group is a temporary workaround for this not yet being available in CDK, as Schedule Group makes more sense as an infrastructure concern IMO.</p>
</li>
<li><p>We set the <code>FlexibleTimeWindow</code> to <code>OFF</code> as we want the target lambda to invoke immediately. In practice, I notice this is usually within 30 seconds, but can be up to 50 seconds. Setting the window to <code>FLEXIBLE</code> can make sense when you deliberately might want jitter in your invocations, for example restarting a fleet of EC2 instances but not wanting them all to restart at the same time!</p>
</li>
</ul>
<p>Now let's look at the <code>send-notification</code> lambda code:</p>
<pre><code class="lang-typescript"><span class="hljs-keyword">import</span> { EventBridgeEvent } <span class="hljs-keyword">from</span> <span class="hljs-string">"aws-lambda"</span>;
<span class="hljs-keyword">import</span> { NotificationReminder } <span class="hljs-keyword">from</span> <span class="hljs-string">"../notificationReminder/create"</span>;
<span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> fbAdmin <span class="hljs-keyword">from</span> <span class="hljs-string">"firebase-admin"</span>;

<span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> handler = <span class="hljs-keyword">async</span> (
  event: EventBridgeEvent&lt;<span class="hljs-string">"ReminderNotification"</span>, NotificationReminder&gt;
) =&gt; {
  <span class="hljs-keyword">if</span> (!fbAdmin.apps.length) {
    process.env.GOOGLE_APPLICATION_CREDENTIALS =
      <span class="hljs-string">"./firebaseapp-config-XXXXXXX.json"</span>;
    fbAdmin.initializeApp({ credential: fbAdmin.credential.applicationDefault() });
  }

  <span class="hljs-keyword">const</span> result = <span class="hljs-keyword">await</span> fbAdmin
    .messaging()
    .sendToDevice(event.detail.device_token, {
      notification: {
        title: <span class="hljs-string">`Court Reminder`</span>,
        body: event.detail.message,
        sound: <span class="hljs-string">"default"</span>,
      },
    });

  <span class="hljs-built_in">console</span>.log(<span class="hljs-string">"result"</span>, <span class="hljs-built_in">JSON</span>.stringify(result));
};
</code></pre>
<p>Not much to explain here - we're just using the Firebase Admin SDK to send the push notification itself, using the data from the EventBridge event.</p>
<p>And voila! We receive our notification. It's game time 😎</p>
<p><img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1674507708366/f5f30b73-b0cd-4cff-806b-eb05716b841e.jpeg?height=600" alt class="image--center mx-auto" /></p>
<p>Full source code is <a target="_blank" href="https://github.com/markgibaud/eventbridge-push-notifications">available on GitHub</a>.</p>
<h3 id="heading-and-finally">And Finally</h3>
<p>Thanks to <a target="_blank" href="https://twitter.com/boyney123">David Boyne</a> and the ServerlessLand folks for the inspiration. Check out <a target="_blank" href="https://serverlessland.com/patterns">ServerlessLand</a> for other great event-based patterns and <a target="_blank" href="https://serverlessland.com/patterns/delayed-eventbridge-events">specifically this one</a> that inspired this feature &amp; post!</p>
]]></content:encoded></item></channel></rss>