luaguides

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

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.