{"id":"l-aws-4-2","courseId":"aws-cloud-foundations","lang":"en","kind":"lesson","summary":"Build Lantern’s RSVP API from an Amazon API Gateway HTTP API, an AWS Lambda function with a least-privilege role, and a DynamoDB table, with duplicate protection and costs that fall to near zero when idle.","html":"<p>The front end now loads from edge locations in milliseconds. But an RSVP is not a static file: it has to be checked, stored, and counted. On the old server that was a route in the Node.js app, running all day for the few minutes a week anyone RSVPed. Now it becomes three managed pieces that cost nothing while idle and scale up when an event goes viral.</p>\n<h2 id=\"sec-1\" tabindex=\"-1\">The shape of it</h2>\n<figure class=\"diagram\"><svg class=\"diagram-svg\" focusable=\"false\" viewBox=\"0 0 680 220\" role=\"img\" aria-labelledby=\"l-aws-4-2-t1\"><defs><marker id=\"arrow-l-aws-4-2-1\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto-start-reverse\"><path d=\"M0 0 L10 5 L0 10 z\" class=\"d-arrowhead\"/></marker></defs>\n  <title id=\"l-aws-4-2-t1\">The browser sends POST /events/{eventId}/rsvps to an API Gateway HTTP API. API Gateway invokes the lantern-rsvp Lambda function, which runs with an execution role and writes to the lantern-rsvps DynamoDB table. The function's logs go to CloudWatch Logs.</title>\n  <rect class=\"d-box\" x=\"10\" y=\"70\" width=\"110\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"65\" y=\"105\" text-anchor=\"middle\">Browser</text>\n  <rect class=\"d-box-accent\" x=\"170\" y=\"70\" width=\"140\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"240\" y=\"97\" text-anchor=\"middle\">API Gateway</text>\n  <text class=\"d-label-muted\" x=\"240\" y=\"117\" text-anchor=\"middle\">HTTP API</text>\n  <rect class=\"d-box-primary\" x=\"360\" y=\"70\" width=\"140\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"430\" y=\"97\" text-anchor=\"middle\">Lambda</text>\n  <text class=\"d-label-muted\" x=\"430\" y=\"117\" text-anchor=\"middle\">lantern-rsvp</text>\n  <rect class=\"d-box-success\" x=\"550\" y=\"70\" width=\"120\" height=\"60\" rx=\"10\"/>\n  <text class=\"d-label\" x=\"610\" y=\"97\" text-anchor=\"middle\">DynamoDB</text>\n  <text class=\"d-label-muted\" x=\"610\" y=\"117\" text-anchor=\"middle\">lantern-rsvps</text>\n  <rect class=\"d-box-warn\" x=\"360\" y=\"170\" width=\"140\" height=\"40\" rx=\"8\"/>\n  <text class=\"d-label\" x=\"430\" y=\"195\" text-anchor=\"middle\">CloudWatch Logs</text>\n  <path class=\"d-arrow\" d=\"M120 100 L166 100\" marker-end=\"url(#arrow-l-aws-4-2-1)\"/>\n  <path class=\"d-arrow\" d=\"M310 100 L356 100\" marker-end=\"url(#arrow-l-aws-4-2-1)\"/>\n  <path class=\"d-arrow\" d=\"M500 100 L546 100\" marker-end=\"url(#arrow-l-aws-4-2-1)\"/>\n  <path class=\"d-arrow d-dashed\" d=\"M430 130 L430 166\" marker-end=\"url(#arrow-l-aws-4-2-1)\"/>\n  <text class=\"d-label-muted\" x=\"333\" y=\"60\" text-anchor=\"middle\">resource policy</text>\n  <text class=\"d-label-muted\" x=\"523\" y=\"60\" text-anchor=\"middle\">execution role</text>\n</svg><figcaption>One request through the RSVP API</figcaption></figure>\n<p>Two routes are enough for launch:</p>\n<div class=\"table-wrap\" role=\"region\" aria-label=\"Scrollable table\" tabindex=\"0\"><table><thead>\n<tr>\n<th>Route</th>\n<th>Does</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code dir=\"ltr\">POST /events/{eventId}/rsvps</code></td>\n<td>Stores one RSVP, refuses duplicates</td>\n</tr>\n<tr>\n<td><code dir=\"ltr\">GET /events/{eventId}/rsvps</code></td>\n<td>Returns the RSVP count for the event</td>\n</tr>\n</tbody>\n</table></div><h2 id=\"sec-2\" tabindex=\"-1\">The function</h2>\n<p>API Gateway’s HTTP API passes Lambda a JSON event (payload format 2.0) with the path parameters, the method and the body as a string. The function returns a status code, headers and a string body:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"js\" data-index=\"0\"><div class=\"code-head\"><span class=\"code-title\">rsvp/index.mjs</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { DynamoDBClient } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"@aws-sdk/client-dynamodb\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">import</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { DynamoDBDocumentClient, PutCommand, QueryCommand } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">from</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"@aws-sdk/lib-dynamodb\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// Created once per execution environment and reused across invocations.</span></span>\n<span class=\"line\"><span style=\"color:#6E7781;--shiki-dark:#8B949E\">// removeUndefinedValues: an RSVP without a \"name\" is stored without that attribute.</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> db</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> DynamoDBDocumentClient.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">from</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> DynamoDBClient</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({}), {</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  marshallOptions: { removeUndefinedValues: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">true</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> TABLE</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> process.env.</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">TABLE_NAME</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">const</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> reply</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#953800;--shiki-dark:#FFA657\">statusCode</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#953800;--shiki-dark:#FFA657\">data</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> ({</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  statusCode,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  headers: { </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"content-type\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"application/json\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  body: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">JSON</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">stringify</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(data),</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">});</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">export</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> const</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> handler</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> async</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#953800;--shiki-dark:#FFA657\">event</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=></span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> eventId</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> event.pathParameters?.eventId;</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (event.requestContext.http.method </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"GET\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    const</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> res</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> =</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> db.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">send</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> QueryCommand</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      TableName: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">TABLE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      KeyConditionExpression: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"eventId = :e\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      ExpressionAttributeValues: { </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\":e\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: eventId },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      Select: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"COUNT\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    }));</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> reply</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">200</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { eventId, count: res.Count });</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  const</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> { </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">email</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">name</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">=</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> JSON</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">parse</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(event.body </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">??</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"{}\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">);</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">typeof</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> email </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">!==</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"string\"</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> ||</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> !</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">email.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">includes</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"@\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">)) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    return</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> reply</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">400</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { error: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"A valid email is required\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  try</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    await</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> db.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">send</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> PutCommand</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">({</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      TableName: </span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">TABLE</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      Item: { eventId, email: email.</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toLowerCase</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(), name, createdAt: </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">new</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> Date</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">().</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\">toISOString</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">() },</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">      ConditionExpression: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"attribute_not_exists(email)\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    }));</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  } </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">catch</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (err) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    if</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> (err.name </span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">===</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"ConditionalCheckFailedException\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">) {</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">      return</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> reply</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">409</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { error: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"You have already RSVPed to this event\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> });</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    }</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">    throw</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\"> err;</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  }</span></span>\n<span class=\"line\"><span style=\"color:#CF222E;--shiki-dark:#FF7B72\">  return</span><span style=\"color:#8250DF;--shiki-dark:#D2A8FF\"> reply</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">(</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">201</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, { eventId, email });</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">};</span></span></code></pre></div>\n<p>Notice what is <em>not</em> there: no credentials, no table ARN, no connection pool. The SDK finds the execution role’s credentials, and the table name comes from an environment variable so the same code runs against dev and production tables. The Node.js runtime includes the AWS SDK for JavaScript v3, but bundle the version you tested with your code so a runtime update can’t change it under you.</p>\n<p>One honest limit: a single Query reads at most 1 MB, so for an event with an enormous number of RSVPs the count would need pagination, or a separate counter item updated on each write. For Lantern’s events, one call is plenty.</p>\n<aside class=\"callout callout-mistake\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3\"/><path d=\"M12 9v4\"/><path d=\"M12 17h.01\"/></svg><span><span class=\"callout-kind\">Common mistake</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Letting retries create duplicates</span></p>\n<p>Browsers double-submit, users double-click, and clients retry on timeouts. Without a guard, each one is a new item. The <code dir=\"ltr\">ConditionExpression</code> makes the write succeed only if no item with that <code dir=\"ltr\">eventId</code> and <code dir=\"ltr\">email</code> exists yet, and DynamoDB evaluates it atomically, so two simultaneous requests can’t both win.</p>\n</aside>\n<h2 id=\"sec-3\" tabindex=\"-1\">The permissions</h2>\n<p>The function’s <strong>execution role</strong> gets exactly what the code calls, on exactly one table, plus the AWS managed <code dir=\"ltr\">AWSLambdaBasicExecutionRole</code> policy so it can write logs:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"json\" data-index=\"1\"><div class=\"code-head\"><span class=\"code-title\">rsvp-role-policy.json</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">{</span></span>\n<span class=\"line\"><span style=\"color:#116329;--shiki-dark:#7EE787\">  \"Version\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"2012-10-17\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#116329;--shiki-dark:#7EE787\">  \"Statement\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: [</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    {</span></span>\n<span class=\"line\"><span style=\"color:#116329;--shiki-dark:#7EE787\">      \"Sid\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"RsvpTable\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#116329;--shiki-dark:#7EE787\">      \"Effect\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"Allow\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">,</span></span>\n<span class=\"line\"><span style=\"color:#116329;--shiki-dark:#7EE787\">      \"Action\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: [</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"dynamodb:PutItem\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">, </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"dynamodb:Query\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">],</span></span>\n<span class=\"line\"><span style=\"color:#116329;--shiki-dark:#7EE787\">      \"Resource\"</span><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">: </span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">\"arn:aws:dynamodb:eu-west-1:111122223333:table/lantern-rsvps\"</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">    }</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">  ]</span></span>\n<span class=\"line\"><span style=\"color:#1F2328;--shiki-dark:#E6EDF3\">}</span></span></code></pre></div>\n<p>The other direction is the function’s <strong>resource-based policy</strong>: API Gateway may invoke it. The console adds this when you connect a route; from the CLI it looks like this, scoped to one API by its ARN:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"bash\" data-index=\"2\"><div class=\"code-head\"><span class=\"code-title\">Terminal</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">aws</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> lambda</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> add-permission</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> --function-name</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> lantern-rsvp</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --statement-id</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> allow-lantern-http-api</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --action</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> lambda:InvokeFunction</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --principal</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> apigateway.amazonaws.com</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  --source-arn</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"arn:aws:execute-api:eu-west-1:111122223333:a1b2c3d4e5/*\"</span></span></code></pre></div>\n<h2 id=\"sec-4\" tabindex=\"-1\">The API</h2>\n<p>In the API Gateway console, create an <strong>HTTP API</strong>, add the two routes with a Lambda integration to <code dir=\"ltr\">lantern-rsvp</code>, and use the auto-deployed <code dir=\"ltr\">$default</code> stage. HTTP APIs are the cheaper, simpler type. Choose a <strong>REST API</strong> instead when you need API keys with usage plans, per-client throttling, request validation, response caching or AWS WAF.</p>\n<p>Because the site is served from <code dir=\"ltr\">https://lantern.example</code> and the API lives on another domain, configure <strong>CORS</strong> on the HTTP API to allow that origin, or add the API as a second origin in the CloudFront distribution under <code dir=\"ltr\">/api/*</code> so the browser sees one domain. Then test:</p>\n<div class=\"code-block\" dir=\"ltr\" data-lang=\"bash\" data-index=\"3\"><div class=\"code-head\"><span class=\"code-title\">Terminal</span><span class=\"code-actions\"><button type=\"button\" class=\"code-btn\" data-action=\"copy\" aria-label=\"Copy code\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/><path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/></svg><span>Copy</span></button></span></div><pre class=\"shiki\" tabindex=\"0\"><code><span class=\"line\"><span style=\"color:#953800;--shiki-dark:#FFA657\">curl</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> -i</span><span style=\"color:#0550AE;--shiki-dark:#79C0FF\"> -X</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> POST</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\">  \"https://a1b2c3d4e5.execute-api.eu-west-1.amazonaws.com/events/evt-2026-street-food/rsvps\"</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  -H</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> \"content-type: application/json\"</span><span style=\"color:#CF222E;--shiki-dark:#FF7B72\"> \\</span></span>\n<span class=\"line\"><span style=\"color:#0550AE;--shiki-dark:#79C0FF\">  -d</span><span style=\"color:#0A3069;--shiki-dark:#A5D6FF\"> '{\"email\":\"rita@example.com\",\"name\":\"Rita\"}'</span></span></code></pre></div>\n<p>The first call returns <code dir=\"ltr\">201</code>; send it again and you get <code dir=\"ltr\">409</code>.</p>\n<aside class=\"callout callout-tip\" role=\"note\"><p class=\"callout-title\"><svg class=\"icon \" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5\"/><path d=\"M9 18h6\"/><path d=\"M10 22h4\"/></svg><span><span class=\"callout-kind\">Tip</span><span class=\"callout-sep\" aria-hidden=\"true\"> · </span>Why not a function URL?</span></p>\n<p>Lambda can also give a function its own HTTPS endpoint, a function URL, with no API Gateway in front. That is fine for webhooks and internal tools. Lantern uses API Gateway because it wants separate routes with their own throttling limits, a custom domain next to the site, and the option to add an authorizer later without touching the function code.</p>\n</aside>\n<h2 id=\"sec-5\" tabindex=\"-1\">What it costs, and how to cap it</h2>\n<p>Each piece bills per use: API Gateway per request, Lambda per request and GB-second, DynamoDB on-demand per read and write. A quiet week costs close to nothing, and the festival evening costs a few thousand requests’ worth. To make sure a bot can’t run up the bill or overload anything downstream, set <strong>reserved concurrency</strong> on the function (a ceiling on simultaneous copies) and a throttling limit on the API’s stage.</p>\n<p>Next you will watch this API in production: logs, metrics and alarms, and a structured review of the whole design.</p>\n","toc":[{"id":"sec-1","text":"The shape of it","level":2},{"id":"sec-2","text":"The function","level":2},{"id":"sec-3","text":"The permissions","level":2},{"id":"sec-4","text":"The API","level":2},{"id":"sec-5","text":"What it costs, and how to cap it","level":2}],"takeaways":["An HTTP API in API Gateway receives the request, invokes Lambda with a JSON event, and returns whatever status code and body the function returns.","The Lambda function needs two kinds of permission; its execution role lets it call DynamoDB, and its resource-based policy lets API Gateway invoke it.","A conditional write such as attribute_not_exists stops double-clicks and retries from creating duplicate items.","Choose HTTP APIs for simple, low-cost APIs, and REST APIs when you need API keys, per-client throttling, request validation, caching or AWS WAF.","Every piece bills per request, so an idle API costs close to nothing, and reserved concurrency caps how far a spike can scale."],"further":[{"title":"Choose between REST APIs and HTTP APIs","url":"https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html"},{"title":"Create AWS Lambda proxy integrations for HTTP APIs","url":"https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html"},{"title":"Condition expressions in DynamoDB","url":"https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Expressions.ConditionExpressions.html"},{"title":"Tutorial: Create a CRUD HTTP API with Lambda and DynamoDB","url":"https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-dynamo-db.html"}],"quiz":[{"q":"<p>The function works when tested in the Lambda console, but calls through API Gateway fail because API Gateway is not allowed to invoke it. What is missing?</p>","options":[{"text":"A statement in the function’s execution role allowing <code dir=\"ltr\">apigateway:*</code>.","why":"The execution role controls what the function may call, not who may call the function."},{"text":"A DynamoDB table policy that names API Gateway.","why":"API Gateway never talks to DynamoDB in this design; it only invokes the function."},{"text":"An IAM user for API Gateway with an access key.","why":"AWS services act through service principals and roles, never through IAM users with keys."},{"text":"A statement in the function’s resource-based policy allowing <code dir=\"ltr\">apigateway.amazonaws.com</code> to call <code dir=\"ltr\">lambda:InvokeFunction</code> for this API.","why":"Correct. Who may invoke a function is set in its resource-based policy, scoped with a source ARN to this API."}],"answer":3},{"q":"<p>An organiser double-clicks Submit and two identical RSVP requests arrive within milliseconds. How does the lesson’s function avoid storing two RSVPs?</p>","options":[{"text":"The <code dir=\"ltr\">PutItem</code> uses <code dir=\"ltr\">ConditionExpression attribute_not_exists(email)</code>, so the second write fails with <code dir=\"ltr\">ConditionalCheckFailedException</code> and returns 409.","why":"Correct. DynamoDB checks the condition atomically on the item, so exactly one of the two writes succeeds."},{"text":"API Gateway removes duplicate requests automatically.","why":"API Gateway forwards every request it receives. Deduplication is the application’s job."},{"text":"Lambda runs only one copy of the function at a time.","why":"Lambda runs as many copies as there are concurrent requests, so both requests can run at once."}],"answer":0},{"q":"<p>Lantern needs API keys so that two partner websites can submit RSVPs, each with its own usage limit. Which API Gateway type fits?</p>","options":[{"text":"An HTTP API, because it is cheaper.","why":"HTTP APIs are cheaper because they leave features out, and API keys with usage plans are one of them."},{"text":"A WebSocket API, because partners need a live connection.","why":"WebSocket APIs are for two-way, long-lived connections, and partners submitting forms do not need that."},{"text":"A REST API, which supports API keys with usage plans and per-client throttling.","why":"Correct. When you need those API management features, the REST API type is the one that has them."}],"answer":2}],"exercise":{"kind":"spot-bug","language":"js","code":"import { DynamoDBClient } from \"@aws-sdk/client-dynamodb\";\nimport { DynamoDBDocumentClient, PutCommand, ScanCommand } from \"@aws-sdk/lib-dynamodb\";\n\nconst db = DynamoDBDocumentClient.from(new DynamoDBClient({\n  credentials: { accessKeyId: \"AKIAIOSFODNN7EXAMPLE\", secretAccessKey: process.env.SECRET },\n}));\nconst TABLE = process.env.TABLE_NAME;\n\nconst reply = (statusCode, data) => ({\n  statusCode,\n  headers: { \"content-type\": \"application/json\" },\n  body: JSON.stringify(data),\n});\n\nexport const handler = async (event) => {\n  const eventId = event.pathParameters?.eventId;\n\n  if (event.requestContext.http.method === \"GET\") {\n    const res = await db.send(new ScanCommand({\n      TableName: TABLE,\n      FilterExpression: \"eventId = :e\",\n      ExpressionAttributeValues: { \":e\": eventId },\n    }));\n    return reply(200, { eventId, count: res.Items.length });\n  }\n\n  const { email, name } = JSON.parse(event.body ?? \"{}\");\n  if (typeof email !== \"string\" || !email.includes(\"@\")) {\n    return reply(400, { error: \"A valid email is required\" });\n  }\n\n  try {\n    await db.send(new PutCommand({\n      TableName: TABLE,\n      Item: { eventId, email: email.toLowerCase(), name },\n      ConditionExpression: \"attribute_exists(email)\",\n    }));\n  } catch (err) {\n    if (err.name === \"ConditionalCheckFailedException\") {\n      return reply(409, { error: \"You have already RSVPed to this event\" });\n    }\n    throw err;\n  }\n  return reply(201, { eventId, email });\n};","bugLines":[5,19,36],"title":"Review the RSVP function","prompt":"<p>This version of <code dir=\"ltr\">lantern-rsvp</code> was opened as a pull request. It looks almost identical to the lesson’s function, but three lines would cause a security incident, a growing bill, or broken RSVPs in production. Select them.</p>\n","hints":["How should a Lambda function get its credentials?","Which DynamoDB operation reads the whole table, and which reads one partition?","Read the condition expression aloud: the write succeeds only if the item … ?"],"explanation":"<p>Line 5 hard-codes an access key into the client. The function already has an execution role, and the SDK uses it automatically when you pass no credentials, so delete the <code dir=\"ltr\">credentials</code> option entirely; never put keys in code or environment variables. Line 19 counts RSVPs with a <code dir=\"ltr\">Scan</code> and a filter, which reads and bills every item in the table on every page view; use <code dir=\"ltr\">QueryCommand</code> with <code dir=\"ltr\">KeyConditionExpression: &quot;eventId = :e&quot;</code> and <code dir=\"ltr\">Select: &quot;COUNT&quot;</code>. (A Scan also stops after 1 MB, so on a big table this count would be wrong as well as expensive.) Line 36 inverts the duplicate guard: <code dir=\"ltr\">attribute_exists(email)</code> lets a write succeed only when the RSVP already exists, so every new RSVP fails with 409. It must be <code dir=\"ltr\">attribute_not_exists(email)</code>.</p>\n","checkLabels":{}},"words":651,"readMinutes":3}