API Gateway: HTTP API or REST API, and Building the First Routes

K
Kai··7 min read

The Function URL in article 01 was enough to call a function over HTTP, but it points to exactly one function and has no concept of routes. Our product needs several paths: one to create a link, one to open a link, then later more for login and the dashboard. That's API Gateway's job. This article picks the right API type then builds the first two real routes.

Goal

Understand the difference between API Gateway's HTTP API and REST API to pick the right one, then build POST /links (create a link, return a short code) and GET /{code} (301 redirect). Along the way, handle input validation, read path parameters, and let API Gateway handle CORS preflight. Resources in the free tier, deploy then delete, cost negligible.

Two API types, two philosophies

API Gateway has two different products that both take an HTTP request and call a backend: REST API (came first, feature-rich) and HTTP API (came later, minimal). The AWS docs state the trade-off plainly: "REST APIs support more features than HTTP APIs, while HTTP APIs are designed with minimal features so that they can be offered at a lower price."

When do you need REST API? The docs list: "Choose REST APIs if you need features such as API keys, per-client throttling, request validation, AWS WAF integration, or private API endpoints." Those are heavy-handed management features: issuing API keys per client, rate-limiting per client, validating requests right at the gateway, attaching WAF directly, or private endpoints inside a VPC.

For a URL shortener, we don't need that group in the first version. What we need is a public API, cheap, and importantly with a built-in way to authenticate with JWT for Cognito in a later article. This is where HTTP API clearly wins: the comparison table in the docs shows HTTP API supports a native JWT authorizer, while REST API does not (REST API has to use a Lambda authorizer to verify JWTs itself). Since article 07 uses Cognito to issue JWTs, HTTP API gives us the tidiest integration path.

                        REST API                 HTTP API
   Price                higher                   lower
   API key / usage plan  yes                      no
   Per-client throttle   yes                      no
   Request validation    yes                      no (validate in code)
   WAF attached directly yes                      no (via CloudFront)
   Native JWT authorizer no (use Lambda)          yes  ← fits Cognito
   Cognito authorizer    yes                      yes (via JWT)

A point to remember for article 16: HTTP API can't attach AWS WAF directly. When you need WAF, you put a CloudFront distribution in front and attach WAF there. We choose HTTP API and accept that detour, because the features REST API adds are all things we don't use yet still pay for.

Coding the two handlers

POST /links takes a URL, validates it, generates a short code, returns it. This article doesn't store anything yet (that's for article 04); here we focus on the API part. A helper handles generating the code and checking the URL:

import { randomBytes } from "node:crypto";

const ALPHABET =
  "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; // base62

export function generateShortCode(length = 7): string {
  const bytes = randomBytes(length);
  let code = "";
  for (let i = 0; i < length; i++) code += ALPHABET[bytes[i] % ALPHABET.length];
  return code;
}

export function normalizeUrl(input: unknown): string | null {
  if (typeof input !== "string") return null;
  try {
    const u = new URL(input);
    if (u.protocol !== "http:" && u.protocol !== "https:") return null;
    return u.toString();
  } catch {
    return null;
  }
}

Seven base62 characters give about 62⁷, more than three trillion possibilities, plenty and hard to guess. normalizeUrl only accepts http/https, blocking odd schemes like ftp: or javascript:.

The create-link handler reads the JSON body, validates, then returns the code:

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  let parsed: unknown;
  try {
    parsed = JSON.parse(event.body ?? "{}");
  } catch {
    return json(400, { error: "body khong phai JSON hop le" });
  }
  const target = normalizeUrl((parsed as Record<string, unknown>).url);
  if (!target) {
    return json(400, { error: "thieu hoac sai truong 'url' (chi nhan http/https)" });
  }
  const code = generateShortCode();
  const base = `https://${event.requestContext.domainName}`;
  return json(201, { code, shortUrl: `${base}/${code}`, target });
};

The resolve handler reads code from the path parameter then returns a 301:

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  const code = event.pathParameters?.code;
  if (!code) return { statusCode: 400, body: "thieu code" };
  const target = `https://example.com/demo-target-for/${code}`; // bai 06 thay bang tra cuu DB
  return { statusCode: 301, headers: { location: target }, body: "" };
};

On the redirect status code: 301 (Moved Permanently) tells browsers and proxies that this link is permanently bound to its target, so they're allowed to cache it. For a URL shortener that's good for performance but has an analytics trade-off, since a re-open from cache may not reach the server. The analytics article will reconsider 301 versus 302; here we use 301 to illustrate the mechanism.

Declaring the HTTP API in SAM

An AWS::Serverless::HttpApi resource defines the API and configures CORS; each function attaches to a route via an Events block of type HttpApi:

Resources:
  HttpApi:
    Type: AWS::Serverless::HttpApi
    Properties:
      CorsConfiguration:
        AllowOrigins: ["*"]
        AllowMethods: [GET, POST, OPTIONS]
        AllowHeaders: [content-type]

  CreateLinkFunction:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        EntryPoints: [handlers/create-link.ts]
    Properties:
      CodeUri: src
      Handler: handlers/create-link.handler
      Events:
        Create:
          Type: HttpApi
          Properties:
            ApiId: !Ref HttpApi
            Path: /links
            Method: POST

  ResolveLinkFunction:
    Type: AWS::Serverless::Function
    Metadata:
      BuildMethod: esbuild
      BuildProperties:
        EntryPoints: [handlers/resolve-link.ts]
    Properties:
      CodeUri: src
      Handler: handlers/resolve-link.handler
      Events:
        Resolve:
          Type: HttpApi
          Properties:
            ApiId: !Ref HttpApi
            Path: /{code}
            Method: GET

Path: /{code} declares a path variable; API Gateway captures that part and puts it into event.pathParameters.code. CodeUri: src sets the build root at src so the handlers in src/handlers can import shared functions in src/lib.

   client
     │
     ▼
  ┌──────────────── HTTP API ($default stage) ─────────────────┐
  │  POST /links    ──────────────▶ CreateLinkFunction (Lambda) │
  │  GET  /{code}   ──────────────▶ ResolveLinkFunction (Lambda)│
  │  OPTIONS *      ── CORS preflight, API Gateway answers it ──┘
  └─────────────────────────────────────────────────────────────┘

Calling it on AWS

After sam build and sam deploy, the output prints the API's base URL. POSTing a valid URL returns 201 with the code:

$ curl -X POST "$API/links" -H 'content-type: application/json' \
    -d '{"url":"https://docs.aws.amazon.com/lambda/latest/dg/welcome.html"}'
{"code":"BCczd3D","shortUrl":"https://iwx60qdop4.execute-api.ap-southeast-1.amazonaws.com/BCczd3D","target":"https://docs.aws.amazon.com/lambda/latest/dg/welcome.html"}

A URL with a wrong scheme is blocked right away with a 400:

$ curl -X POST "$API/links" -H 'content-type: application/json' -d '{"url":"ftp://nope"}'
{"error":"thieu hoac sai truong 'url' (chi nhan http/https)"}

Opening a short code returns a 301 with a location header (the -i flag to see the headers):

$ curl -i "$API/aK9xQ2z"
HTTP/2 301
location: https://example.com/demo-target-for/aK9xQ2z

API Gateway captures aK9xQ2z into the path parameter, calls the handler, the handler returns 301, and the browser will go to location.

CORS: who answers the preflight

When the dashboard (running on a different origin) calls the API with JavaScript, the browser first sends an OPTIONS request to ask whether its origin is allowed. This is the CORS preflight. With the CorsConfiguration above, API Gateway itself answers the preflight, and that request doesn't reach any Lambda:

$ curl -i -X OPTIONS "$API/links" -H 'Origin: https://example.com' \
    -H 'Access-Control-Request-Method: POST'
HTTP/2 204
access-control-allow-origin: *
access-control-allow-methods: GET,OPTIONS,POST
access-control-allow-headers: content-type

API Gateway returns 204 along with the Access-Control-Allow-* headers. Because preflight is handled at the gateway layer, your handler doesn't have to worry about it, and you're not billed a Lambda invocation per preflight. In production, AllowOrigins: ["*"] should be narrowed to the dashboard's exact domain; we leave it as * for convenience while building, and will tighten it in the security article.

🧹 Cleanup

$ sam delete --stack-name url-shortener --no-prompts --region ap-southeast-1
Deleted successfully

The HTTP API, both Lambda functions, and their IAM roles vanish along with the stack.

Wrap-up

We chose HTTP API because it's cheap, sufficient, and has a native JWT authorizer for Cognito in the next article. The first two routes already run for real: creating a link with validation, and a 301 redirect read from a path parameter. CORS preflight is handled by API Gateway, so the handler stays lean and doesn't cost an extra Lambda invocation.

The short codes generated in this article don't go anywhere yet, because there's nowhere to store them. The next article fills exactly that gap: designing the DynamoDB table. We'll start from DynamoDB's counterintuitive way of thinking, going from access patterns to table design, rather than from table to query as with a relational database.