Building a REST API with OpenResty
Introduction
OpenResty extends Nginx with Lua scripting, letting you handle REST API requests entirely in Lua without switching to a separate application layer. This combination provides non-blocking I/O, shared memory caching, and the performance of Nginx’s event loop.
In this tutorial, you will build a REST API from scratch using OpenResty’s Lua API, covering routing, request parsing, JSON responses, and response caching. By the end, you will have a working API that handles CRUD-style operations with proper error handling and caching.
Project Setup
Before writing any code, you need an Nginx configuration that loads your Lua handler and declares a shared memory zone for caching.
http {
lua_shared_dict api_cache 10m;
server {
listen 8080;
default_type application/json;
location /api {
content_by_lua_file /var/www/api/handler.lua;
}
}
}
Place your Lua code at /var/www/api/handler.lua. The lua_shared_dict directive creates a 10-megabyte cache named api_cache that all Nginx workers can access.
Routing Requests
REST APIs use HTTP methods and URI paths to determine what action to take. You extract the request method with ngx.req.get_method() and the path with ngx.var.uri. A simple router strips the /api prefix and matches remaining path segments.
local function route(method, path)
local clean_path = path:gsub("^/api", "")
if clean_path == "/posts" and method == "GET" then
return handle_get_posts
elseif clean_path == "/posts" and method == "POST" then
return handle_create_post
elseif clean_path:match("^/posts/%d+$") and method == "GET" then
return handle_get_post
elseif clean_path:match("^/posts/%d+$") and method == "PUT" then
return handle_update_post
elseif clean_path:match("^/posts/%d+$") and method == "DELETE" then
return handle_delete_post
else
return nil
end
end
This function returns a handler function based on method and path matching. It uses Lua’s pattern matching to capture resource IDs from paths like /posts/42.
Reading Request Bodies
OpenResty does not automatically read request bodies. If you skip this step, ngx.req.get_body_data() returns nil. Always call ngx.req.read_body() first.
local function parse_body()
ngx.req.read_body()
local body_data = ngx.req.get_body_data()
if not body_data then
local body_file = ngx.req.get_body_file()
if body_file then
local file = io.open(body_file, "r")
body_data = file:read("*all")
file:close()
end
end
if body_data then
return cjson.decode(body_data)
end
return {}
end
This function handles both in-memory bodies and bodies buffered to disk. Large request bodies exceed Nginx’s client_body_buffer_size and get written to a temporary file. The fallback to ngx.req.get_body_file() ensures you never lose a request body.
Building JSON Responses
Every REST endpoint needs to return JSON with the correct content type and an appropriate HTTP status code. A helper function keeps response formatting consistent.
local function json_response(status_code, data)
ngx.status = status_code
ngx.header["Content-Type"] = "application/json"
ngx.say(cjson.encode(data))
ngx.exit(ngx.OK)
end
You pass the HTTP status code and a Lua table. The cjson.encode function serializes the table to a JSON string, and ngx.say writes it to the response body while flushing the output buffer.
Implementing Handler Functions
With routing and response helpers in place, you can implement the individual endpoint handlers. These mock implementations store posts in a local table for demonstration purposes.
local posts = {}
local next_id = 1
local function handle_get_posts()
local cached = ngx.shared.api_cache:get("posts:all")
if cached then
return json_response(ngx.HTTP_OK, cjson.decode(cached))
end
local result = {}
for id, post in pairs(posts) do
table.insert(result, post)
end
ngx.shared.api_cache:set("posts:all", cjson.encode(result), 60)
return json_response(ngx.HTTP_OK, result)
end
local function handle_create_post()
local body = parse_body()
if not body.title or not body.content then
return json_response(ngx.HTTP_BAD_REQUEST, {
error = "title and content are required"
})
end
local id = next_id
next_id = next_id + 1
posts[id] = {
id = id,
title = body.title,
content = body.content
}
ngx.shared.api_cache:delete("posts:all")
return json_response(ngx.HTTP_CREATED, posts[id])
end
local function handle_get_post(id)
local cache_key = "posts:" .. id
local cached = ngx.shared.api_cache:get(cache_key)
if cached then
return json_response(ngx.HTTP_OK, cjson.decode(cached))
end
local post = posts[id]
if not post then
return json_response(ngx.HTTP_NOT_FOUND, {
error = "post not found"
})
end
ngx.shared.api_cache:set(cache_key, cjson.encode(post), 120)
return json_response(ngx.HTTP_OK, post)
end
local function handle_update_post(id)
local body = parse_body()
local post = posts[id]
if not post then
return json_response(ngx.HTTP_NOT_FOUND, {
error = "post not found"
})
end
if body.title then post.title = body.title end
if body.content then post.content = body.content end
ngx.shared.api_cache:delete("posts:all")
ngx.shared.api_cache:delete("posts:" .. id)
return json_response(ngx.HTTP_OK, post)
end
local function handle_delete_post(id)
local post = posts[id]
if not post then
return json_response(ngx.HTTP_NOT_FOUND, {
error = "post not found"
})
end
posts[id] = nil
ngx.shared.api_cache:delete("posts:all")
ngx.shared.api_cache:delete("posts:" .. id)
return json_response(ngx.HTTP_OK, { message = "deleted" })
end
Each handler follows a consistent pattern: check the cache, compute or retrieve the data, invalidate related cache entries on mutations, and return a JSON response with the appropriate status code.
Wiring Everything Together
The main handler function brings all the pieces into a single entry point. It determines the request method, extracts any ID from the path, calls the router, and executes the matched handler.
local cjson = require("cjson")
local function main()
local method = ngx.req.get_method()
local uri = ngx.var.uri
local id = uri:match("/api/posts/(%d+)")
local handler = route(method, uri)
if not handler then
return json_response(ngx.HTTP_NOT_FOUND, {
error = "route not found"
})
end
if id then
handler(tonumber(id))
else
handler()
end
end
main()
The tonumber conversion is necessary because path captures return strings, but your handler functions expect numeric IDs.
Making External HTTP Requests
Often a REST API needs to fetch data from upstream services. OpenResty provides two ways to make HTTP requests: the native ngx.location.capture for internal subrequests, and the lua-resty-http library for external HTTP calls.
Internal Subrequests with ngx.location.capture
For requests to internal Nginx locations, ngx.location.capture is the native approach:
local function fetch_internal_user(user_id)
local res = ngx.location.capture("/internal/users/" .. user_id)
if res.status == ngx.HTTP_OK then
return cjson.decode(res.body)
end
return nil
end
This call is fully non-blocking and shares the same Nginx context, making it efficient for internal service communication.
External Requests with lua-resty-http
For external upstream services, use the lua-resty-http library:
local http = require("resty.http")
local httpc = http.new()
local function fetch_user_data(user_id)
local res, err = httpc:request_uri(
"https://api.example.com/users/" .. user_id,
{
method = "GET",
headers = {
["Accept"] = "application/json"
}
}
)
if not res then
return nil, err
end
return cjson.decode(res.body)
end
Both approaches use the cosocket API, so your handler yields while waiting for the upstream response without blocking other requests.
Error Handling Patterns
Every API needs to handle unexpected errors gracefully. Wrap your main logic in a protected call to catch exceptions.
local ok, err = pcall(main)
if not ok then
ngx.log(ngx.ERR, "Request failed: ", err)
json_response(ngx.HTTP_INTERNAL_SERVER_ERROR, {
error = "internal server error"
})
end
Using pcall ensures a bug in your handler code does not crash the Nginx worker. You log the error for debugging and return a generic 500 response to the client.
Testing Your API
Once the Nginx configuration reloaded and workers have restarted, you can test each endpoint using curl.
curl -X POST http://localhost:8080/api/posts \
-H "Content-Type: application/json" \
-d '{"title":"Hello World","content":"First post"}'
curl http://localhost:8080/api/posts
curl http://localhost:8080/api/posts/1
curl -X PUT http://localhost:8080/api/posts/1 \
-H "Content-Type: application/json" \
-d '{"title":"Updated Title"}'
curl -X DELETE http://localhost:8080/api/posts/1
If caching is working, subsequent GET requests return results almost instantly because the shared memory cache serves them without executing the full handler logic.
See Also
- Getting Started with OpenResty — Set up your first OpenResty environment and run Lua scripts inside Nginx
- Routing and Middleware in OpenResty — Build composable routing layers and middleware chains
- Using Redis with OpenResty — Connect to Redis from Lua for persistent storage and caching
Summary
You built a complete REST API with OpenResty by combining Nginx’s request handling pipeline with Lua’s scripting capabilities. The key pieces covered were routing based on HTTP method and path, reading and parsing request bodies, serializing responses as JSON, using shared memory for caching, and making external HTTP requests with cosockets. These patterns scale from simple prototypes to production APIs that need to handle high concurrency without blocking.