Introduction

Welcome to the third lesson of our "Extending NGINX with Lua and OpenResty" course! You've come a long way: we started by embedding Lua code in NGINX to generate dynamic content, then learned to manipulate HTTP traffic by reading and modifying headers and parameters. Now, we're ready for something more ambitious: building a complete REST API using only Lua code within OpenResty. We'll implement all the essential operations — creating, reading, updating, and deleting resources — while handling different HTTP methods, parsing JSON data, and managing application state.

Understanding REST APIs

Before we dive into code, let's establish what we're building. A REST API (Representational State Transfer) is an architectural pattern for creating web services that treat everything as a resource. Each resource has a unique URL, and we use standard HTTP methods to perform operations on it:

  • GET: Retrieve resource(s)
  • POST: Create a new resource
  • PUT: Update an existing resource
  • DELETE: Remove a resource

We'll build an API for managing items, where each item has properties like name, description, and created_at/updated_at timestamps. Our API will accept and return JSON, the standard data format for modern web services. The beauty of doing this in OpenResty is that everything happens within NGINX itself — no separate application server needed.

Setting Up In-Memory Storage

To store our items correctly in OpenResty, we should avoid using plain Lua global variables for shared state. In NGINX/OpenResty, each worker process has its own Lua VM, so global variables are per-worker and are not reliably shared across the server.

Instead, we'll use an OpenResty shared memory dictionary for in-memory storage that is visible across all workers:

Here's how we'll use it:

  • ngx.shared.items: Accesses the shared dictionary at request time
  • We store each item under a key like item:1, item:2, etc. as a JSON string
  • We generate unique IDs with an atomic counter: items:incr("items_counter", 1, 0)

This keeps our example in-memory (no database) while matching NGINX’s multi-worker architecture. In production, you'd typically use a database, but this approach keeps our example focused on the API mechanics.

Creating the Collection Endpoint

Now, we'll create the main endpoint that handles operations on the entire items collection. This single location will respond to multiple HTTP methods:

The structure here is important:

  • default_type application/json: Tells clients to expect JSON responses
  • ngx.req.get_method(): Returns the HTTP method as a string (GET, POST, etc.)
  • We use conditional logic to route to different handlers based on the method
  • For unsupported methods, we return a 405 Method Not Allowed status

This pattern — using a single location with method-based routing — is cleaner than creating separate locations for each method.

Listing All Items

Let's implement the GET handler that returns all items in our collection:

This code performs a simple but effective operation:

  • We create an empty result array to hold our items
  • items:get_keys(0): Returns keys currently stored in the shared dictionary
  • We filter to only keys that start with item: (so we skip non-item keys like the counter)
  • We decode each stored JSON item and insert it into the result array
  • #result: Gets the array length in Lua
  • We encode the response as JSON with both the items array and a count

When a client sends GET /api/items, they'll receive a JSON object containing all items and the total count.

Creating New Items

The POST handler is more complex because it needs to read and parse the request body:

Let's break down this validation logic:

  • ngx.req.read_body(): Reads the entire request body into memory
  • ngx.req.get_body_data(): Returns the body as a string
  • We check if the body exists; if not, return 400 Bad Request
  • pcall(cjson.decode, body): Safely attempts to parse JSON, catching any errors
  • ok: Boolean indicating whether parsing succeeded
  • data: The parsed Lua table if successful

The pcall (protected call) is crucial: it prevents the server from crashing if someone sends malformed JSON. Instead, we catch the error and return a helpful message.

Note on body reading: ngx.req.get_body_data() returns nil if the request body exceeds NGINX's client_body_buffer_size and gets written to a temporary file instead. For production APIs handling large payloads, you'd need to also check ngx.req.get_body_file() to read file-buffered bodies. Our example assumes reasonably-sized JSON payloads that fit in memory.

Completing the Item Creation

Once we've validated the input, we create and store the new item:

This creation logic demonstrates several patterns:

  • We generate a unique ID using an atomic shared counter
  • data.name or "Unnamed": Provides a default value if the field is missing
  • ngx.now(): Returns the current timestamp as a floating-point number
  • We store the item in shared memory under a key like item:1
  • 201 Created: The proper status code for successful resource creation
  • We return the complete item, including the generated ID and timestamp

The client can now use the returned ID to reference this specific item in future requests.

Handling Individual Items

For operations on specific items, we need a different location that captures the item ID from the URL:

This introduces regular expression routing:

  • ~ indicates a regex location match
  • ^/api/items/(\d+)$: Matches URLs like /api/items/5 or /api/items/123
  • \d+: Captures one or more digits
  • Parentheses create a capture group
  • ngx.var[1]: Accesses the first captured group (the ID)
  • tonumber(): Converts the string to a number for building our storage key

This pattern lets us extract path parameters directly from the URL, similar to routing frameworks in other languages.

Retrieving a Single Item

The GET handler for individual items checks if the item exists:

This simple retrieval follows REST conventions:

  • We attempt to look up the item by ID in shared storage
  • If it doesn't exist, return 404 Not Found with an error message
  • If found, return the stored JSON item

The 404 status code is important: it tells the client that the ID they requested doesn't correspond to any existing resource. This is different from a 400 error, which would indicate a problem with the request format itself.

Updating Existing Items

The PUT handler allows clients to modify item properties:

The update logic combines several concepts:

  • First, verify the item exists (return 404 if not)
  • Read and parse the request body (similar to POST)
  • data.name or item.name: Updates only if a new value is provided; otherwise, keeps the existing value
  • We add an updated_at timestamp to track when the modification occurred
  • We store the updated item back into shared memory
  • Return the updated item so the client sees the final state

This approach allows partial updates: clients can send only the fields they want to change.

Deleting Items

The DELETE handler is the simplest but uses an interesting status code:

Deletion follows these conventions:

  • Verify the item exists before attempting deletion
  • items:delete(key): Removes the entry from shared storage
  • 204 No Content: Indicates successful deletion with no response body
  • We don't call ngx.say() because 204 responses shouldn't have a body

The 204 status is semantically correct: the operation succeeded, the resource no longer exists, and there's nothing to return. Clients know to check the status code rather than expecting response data.

Error Handling Throughout

Notice how error handling is consistent across all operations:

  • 400 Bad Request: For invalid input (missing body, malformed JSON)
  • 404 Not Found: When the requested resource doesn't exist
  • 405 Method Not Allowed: For unsupported HTTP methods
  • 201 Created: For successful resource creation
  • 204 No Content: For successful deletion

Proper status codes make your API predictable and easier to work with. Clients can rely on status codes to understand what happened without parsing response bodies for error messages.

Conclusion and Next Steps

We've built a complete REST API entirely within OpenResty using Lua! We covered in-memory storage with a shared dictionary, method-based routing, JSON parsing and generation, URL path parameter extraction, CRUD operations with proper HTTP semantics, and consistent error handling with appropriate status codes. This demonstrates how OpenResty can serve as a lightweight API server without needing a separate application layer. The same patterns we used here — reading request bodies, validating input, maintaining state, and generating JSON responses — form the foundation for more complex APIs.

Now it's your turn to build! The practice exercises ahead will challenge you to implement your own API endpoints, handle edge cases, and extend the functionality we've created. Get ready to transform theory into working code!

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal