APIs are the connective tissue of modern software. Every web application, every mobile app, every microservice, every third-party integration communicates through APIs. Understanding APIs, not just how to call them but how to design, secure, version, and operate them, is foundational to building production software.

This guide covers the full stack: what APIs are and why they exist, the three dominant API paradigms (REST, GraphQL, WebSockets), how to secure them, how to scale them, how to manage them as they evolve, and how AWS API Gateway fits into all of it.

Table of Contents

  1. What an API Is - The Core Abstraction
  2. HTTP Fundamentals - The Protocol APIs Speak
  3. REST - Representational State Transfer
  4. REST API Design - Principles for Production APIs
  5. GraphQL - Query Language for APIs
  6. WebSockets - Real-Time Bidirectional Communication
  7. API Authentication and Authorisation
  8. Rate Limiting and Throttling
  9. API Versioning
  10. AWS API Gateway - Managed API Infrastructure
  11. API Gateway - REST APIs
  12. API Gateway - HTTP APIs
  13. API Gateway - WebSocket APIs
  14. API Security Patterns on AWS
  15. Observability - Monitoring APIs in Production
  16. API Design Patterns and Anti-Patterns

What an API Is - The Core Abstraction

The Problem APIs Solve

Imagine you want to display the current weather in your application. You could build your own weather stations, collect sensor data, process it, and serve it. Or you could call a weather API, a service someone else built and operates, and get the data in milliseconds.

APIs are contracts. They define what a service can do and how to ask it to do things. The implementation behind the API is hidden. You do not need to know how the weather service processes sensor data. You need to know how to ask for the weather for a given location and what the response looks like.

This abstraction is why modern software is built the way it is:

  • A mobile app calls the same API as the web app, the backend is shared
  • Different internal services communicate through APIs, each team owns their service boundary
  • Third-party functionality (payments, maps, identity) is consumed via API, you do not build what already exists

What an API Contract Defines

An API contract specifies:

  • Endpoints - Where to send requests (URLs, addresses)
  • Methods - What operations are available (read, create, update, delete)
  • Request format - What to send (headers, parameters, body structure)
  • Response format - What comes back (structure, data types, status codes)
  • Authentication - How to prove who you are
  • Error format - What failures look like and what they mean
  • Rate limits - How often you can call the API
  • Versioning - How changes to the API are communicated and managed

A well-designed API contract is stable, predictable, and honest about its limitations.

HTTP Fundamentals - The Protocol APIs Speak

Why HTTP

Most APIs, REST, GraphQL, and many others, communicate over HTTP (Hypertext Transfer Protocol). Understanding HTTP is understanding the foundation that APIs are built on.

Requests and Responses

Every HTTP interaction has a request (from client to server) and a response (from server to client).

A request contains:

  • Method - The operation being requested
  • URL - The resource being targeted
  • Headers - Metadata about the request (content type, authentication, caching)
  • Body - The data being sent (for POST, PUT, PATCH)

A response contains:

  • Status code - A number indicating what happened
  • Headers - Metadata about the response
  • Body - The data being returned

HTTP Methods

Method Purpose Has Body Idempotent Safe
GET Retrieve a resource No Yes Yes
POST Create a resource or trigger action Yes No No
PUT Replace a resource entirely Yes Yes No
PATCH Partially update a resource Yes No No
DELETE Remove a resource No Yes No

Idempotent means making the same request multiple times produces the same result. GET /users/123 always returns user 123. DELETE /users/123, whether called once or ten times, results in user 123 not existing. POST /users called twice creates two users. Not idempotent.

Safe means the operation does not change state. GETs do not change anything.

HTTP Status Codes

Status codes communicate the outcome of the request. They are grouped by range:

Range Category Examples
2xx Success 200 OK, 201 Created, 204 No Content
3xx Redirection 301 Moved Permanently, 304 Not Modified
4xx Client Error 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 429 Too Many Requests
5xx Server Error 500 Internal Server Error, 502 Bad Gateway, 503 Service Unavailable

The status code communicates intent. A client that receives 429 knows to back off and retry later. A client that receives 404 knows the resource does not exist. A client that receives 500 knows the server failed, the request was valid.

Using status codes correctly is not optional. Returning 200 with an error body is one of the most common API design mistakes. It forces clients to inspect the body to know if the request succeeded, a subtle, expensive ambiguity.

Headers

Headers are key-value metadata attached to requests and responses.

Common request headers:

Content-Type: application/json # Format of the request body Accept: application/json # Format the client can receive Authorization: Bearer eyJhbGciOiJSUzI # Authentication credential X-Request-ID: f47ac10b-58cc-4372 # Unique ID for tracing User-Agent: MyApp/1.0.0 # Client identification 

Common response headers:

Content-Type: application/json Cache-Control: max-age=300, must-revalidate X-Request-ID: f47ac10b-58cc-4372 # Echo back for tracing X-RateLimit-Limit: 1000 # Rate limit ceiling X-RateLimit-Remaining: 987 # Requests remaining in window X-RateLimit-Reset: 1713312000 # When the window resets (Unix timestamp) 

REST - Representational State Transfer

What REST Is

REST is an architectural style, not a protocol, not a standard, not a specification. It is a set of constraints that, when followed, produce APIs that are scalable, stateless, and easy to understand.

Roy Fielding defined REST in his doctoral dissertation in 2000. The six constraints are:

  1. Client-Server - The client and server are separate. The client does not know how the server stores data. The server does not know what the client does with the data.
  2. Stateless - Every request contains all information needed to process it. The server does not store client session state between requests. Authentication credentials are sent with every request.
  3. Cacheable - Responses must indicate whether they can be cached. Caching reduces load and improves performance.
  4. Uniform Interface - The API interface is consistent. Resources are identified by URLs. Resources are manipulated through representations (JSON, XML). Messages are self-descriptive.
  5. Layered System - The client cannot tell whether it is connected directly to the server or to an intermediary (load balancer, cache, API gateway). Each layer only knows about the layer it interacts with.
  6. Code on Demand (optional) - Servers can send executable code to clients (JavaScript in browsers).

Most APIs called "REST APIs" implement constraints 1-4 and call it REST. That is fine for practical purposes.

Resources and Representations

In REST, everything is a resource. A user is a resource. An order is a resource. A payment is a resource. Resources are identified by URLs.

A representation is the data format used to transfer the resource state, typically JSON.

Resource: User Identifier: /users/88721 Representation: {"id": 88721, "name": "Kachi", "email": "kachi@example.com"} 

Operations on resources use HTTP methods:

GET /users # List all users POST /users # Create a user GET /users/88721 # Get user 88721 PUT /users/88721 # Replace user 88721 entirely PATCH /users/88721 # Partially update user 88721 DELETE /users/88721 # Delete user 88721 GET /users/88721/orders # Orders belonging to user 88721 POST /users/88721/orders # Create an order for user 88721 GET /users/88721/orders/456 # Specific order 456 for user 88721 

This is the core of REST: URLs are nouns (resources), methods are verbs (actions).

REST API Design - Principles for Production APIs

URL Design

Use nouns, not verbs:

Good: GET /users Bad: GET /getUsers Good: POST /orders Bad: POST /createOrder 

Use plural nouns consistently:

Good: /users, /orders, /products Bad: /user, /order, /product (inconsistent) 

Use hierarchy for relationships:

/users/{userId}/orders # Orders for a user /orders/{orderId}/items # Items in an order /organisations/{orgId}/members # Members of an organisation 

Avoid deep nesting - maximum two levels:

Good: GET /orders/{orderId}/items/{itemId} Bad: GET /users/{userId}/orders/{orderId}/items/{itemId}/reviews 

Beyond two levels, the URL becomes hard to read and the API becomes tightly coupled to the data model. Provide shortcuts:

GET /items/{itemId} # Direct access, not buried in the hierarchy GET /items/{itemId}/reviews # Related resource 

Request and Response Design

Always return the full resource on create and update:

POST /users Content-Type: application/json { "name": "Kachi", "email": "kachi@example.com" } 
HTTP/1.1 201 Created Content-Type: application/json Location: /users/88721 { "id": 88721, "name": "Kachi", "email": "kachi@example.com", "createdAt": "2024-04-16T09:00:00Z", "updatedAt": "2024-04-16T09:00:00Z" } 

The client should not have to make a second GET request to see the resource it just created.

Use consistent error format:

{ "error": { "code": "VALIDATION_ERROR", "message": "Request validation failed", "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "details": [ { "field": "email", "code": "INVALID_FORMAT", "message": "email must be a valid email address" }, { "field": "name", "code": "REQUIRED", "message": "name is required" } ] } } 

Every error response includes:

  • A machine-readable error code (not just a human message)
  • A human-readable message
  • A request ID for support and debugging
  • Field-level detail where validation fails

Pagination for collections:

GET /orders?limit=20&cursor=eyJpZCI6MTAwfQ HTTP/1.1 200 OK Content-Type: application/json { "data": [ {"id": 101, ...}, {"id": 102, ...} ], "pagination": { "limit": 20, "nextCursor": "eyJpZCI6MTIwfQ", "hasMore": true, "total": 5432 } } 

Cursor pagination (as above) is preferred over offset pagination for large datasets. Cursor-based pagination is stable, it does not skip or repeat items when data is inserted or deleted during pagination. Offset pagination (?page=3&limit=20) is simpler but breaks when the underlying data changes between page requests.

Filtering and sorting:

# Filter GET /orders?status=pending&userId=88721 # Sorting GET /orders?sort=createdAt:desc # Field selection (reduce payload size) GET /users?fields=id,name,email # Search GET /products?q=wireless+headphones&category=electronics 

GraphQL - Query Language for APIs

The Problem REST Does Not Solve Well

You are building a mobile application. On the home screen, you need:

  • User's name and avatar
  • Their last 5 orders, with product names and totals
  • Any active promotions

With REST, this requires three API calls: GET /users/me, GET /users/me/orders?limit=5, GET /promotions?active=true. Three round trips. Each returns more data than you need, the orders endpoint returns the full order including shipping address and line item details, but you only need product names and totals.

Or, you build a purpose-built endpoint: GET /home-screen-data. But now you have an endpoint that is tightly coupled to one specific screen. Every time the home screen changes, the API changes. Every new screen needs a new endpoint.

This is the n+1 request problem and over-fetching/under-fetching, the structural limitations of REST for complex, client-driven data requirements.

What GraphQL Is

GraphQL is a query language for APIs, developed by Facebook and open-sourced in 2015. Instead of fixed endpoints that return fixed data, GraphQL exposes a single endpoint and lets clients specify exactly what data they need.

The client writes a query describing the shape of the data it wants. The server returns exactly that data, nothing more, nothing less.

The Schema

A GraphQL API is defined by its schema, a strongly typed definition of all available data and operations.

# Schema definition type User { id: ID! name: String! email: String! avatar: String orders(limit: Int = 10, status: OrderStatus): [Order!]! createdAt: DateTime! } type Order { id: ID! status: OrderStatus! total: Float! items: [OrderItem!]! shippingAddress: Address! createdAt: DateTime! } type OrderItem { id: ID! product: Product! quantity: Int! price: Float! } type Product { id: ID! name: String! price: Float! category: String! } type Promotion { id: ID! title: String! discountPercent: Float! expiresAt: DateTime! } enum OrderStatus { PENDING PROCESSING SHIPPED DELIVERED CANCELLED } # Operations type Query { me: User user(id: ID!): User orders(userId: ID!, limit: Int, status: OrderStatus): [Order!]! promotions(active: Boolean): [Promotion!]! } type Mutation { createOrder(input: CreateOrderInput!): Order! updateOrderStatus(orderId: ID!, status: OrderStatus!): Order! cancelOrder(orderId: ID!): Order! } type Subscription { orderStatusChanged(orderId: ID!): Order! } 

Queries

# Get exactly what the home screen needs, one request, one response query HomeScreenData { me { name avatar } myOrders: me { orders(limit: 5, status: PROCESSING) { id total status items { product { name } quantity } } } promotions(active: true) { id title discountPercent expiresAt } } 

Response:

{ "data": { "me": { "name": "Kachi", "avatar": "https://cdn.example.com/avatars/88721.jpg" }, "myOrders": { "orders": [ { "id": "order-456", "total": 4250.00, "status": "PROCESSING", "items": [ { "product": {"name": "Wireless Headphones"}, "quantity": 2 } ] } ] }, "promotions": [ { "id": "promo-789", "title": "10% off electronics", "discountPercent": 10, "expiresAt": "2024-04-30T23:59:59Z" } ] } } 

One request. Exactly the data needed. No over-fetching.

Mutations

mutation CreateOrder($input: CreateOrderInput!) { createOrder(input: $input) { id status total createdAt } } 

Variables sent with the mutation:

{ "input": { "items": [ {"productId": "prod-123", "quantity": 2} ], "shippingAddressId": "addr-456" } } 

Subscriptions

GraphQL subscriptions handle real-time data over WebSockets:

subscription TrackOrder($orderId: ID!) { orderStatusChanged(orderId: $orderId) { id status updatedAt } } 

When the order's status changes on the server, the subscription pushes the update to all connected clients watching that order.

GraphQL on AWS - AppSync

AWS AppSync is a managed GraphQL service. You define your schema, connect resolvers to data sources (DynamoDB, RDS, Lambda, OpenSearch, HTTP endpoints), and AppSync handles the GraphQL execution engine, WebSocket connections for subscriptions, and caching.

# AppSync resolver mapping template (VTL for DynamoDB) # Request mapping { "version": "2017-02-28", "operation": "GetItem", "key": { "id": $util.dynamodb.toDynamoDBJson($ctx.args.id) } } # Response mapping $util.toJson($ctx.result) 

REST vs GraphQL - When to Use Which

Consideration REST GraphQL
Multiple clients with different data needs Poor fit Excellent fit
Simple CRUD operations Excellent fit Overkill
Public API for third-party developers Standard and well-understood Steeper learning curve
Real-time data Via WebSockets (separate) Built-in subscriptions
Caching Simple (HTTP cache headers) More complex (per-field caching)
Error handling HTTP status codes Always 200, errors in response body
Documentation OpenAPI/Swagger Self-documenting (introspection)
File uploads Native multipart Requires workarounds

WebSockets - Real-Time Bidirectional Communication

The Problem HTTP Cannot Solve

HTTP is request-response. The client sends a request. The server responds. The server cannot push data to the client without the client requesting it.

For real-time use cases, live notifications, collaborative editing, live chat, real-time dashboards, multiplayer games, live market data, the client cannot poll often enough to feel real-time without enormous overhead. A client polling every second for a chat message that arrives once a minute makes 3,598 unnecessary requests per hour, per client.

WebSockets solve this by establishing a persistent, bidirectional connection. After the initial HTTP handshake, the connection upgrades to WebSocket protocol. Either the client or the server can send messages at any time. No polling. No repeated handshakes.

The WebSocket Handshake

# Client request to upgrade GET /ws/chat HTTP/1.1 Host: api.myapp.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 # Server response confirming upgrade HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= 

After the handshake, the TCP connection stays open. Both sides send frames, binary or text at any time.

WebSocket Server Implementation

# Python with FastAPI and WebSockets from fastapi import FastAPI, WebSocket, WebSocketDisconnect from typing import Dict, Set import json import asyncio app = FastAPI() class ConnectionManager: def __init__(self): # Room -> set of WebSocket connections  self.rooms: Dict[str, Set[WebSocket]] = {} async def connect(self, websocket: WebSocket, room_id: str): await websocket.accept() if room_id not in self.rooms: self.rooms[room_id] = set() self.rooms[room_id].add(websocket) def disconnect(self, websocket: WebSocket, room_id: str): if room_id in self.rooms: self.rooms[room_id].discard(websocket) if not self.rooms[room_id]: del self.rooms[room_id] async def broadcast_to_room(self, room_id: str, message: dict, sender: WebSocket = None): if room_id not in self.rooms: return message_str = json.dumps(message) disconnected = set() for connection in self.rooms[room_id]: if connection == sender: continue # Don't echo back to sender  try: await connection.send_text(Source:https://dev.to/leonardkachi/apis-rest-graphql-websockets-api-gateway-authentication-rate-limiting-and-versioning-35ke

Related posts