API Gateway: HTTP API or REST API, and Building the First Routes
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.