santoku-resty
View on LuaRocksOpenResty glue: santoku-flavored wrappers over lua-resty HTTP clients, RS256 and ES256 JWTs over santoku-jwt, and the OpenResty WebSocket backend
santoku-resty is the OpenResty glue layer: thin wrappers over the upstream lua-resty rocks (HTTP client, OpenSSL, WebSocket) that add santoku error handling and a smaller, uniform surface. Its JWT module runs santoku-jwt's core over santoku.resty.jwt.openssl, the OpenSSL backend for RS256 and ES256; the santoku-jwt tab covers that core. Every module here calls the ngx API and the cosocket-based upstream rocks, so it only runs inside an nginx worker: all examples on this page are display-only. nginx confs templated by santoku-mustache route requests into Lua handlers that use these modules for outbound HTTP and token checks.
santoku.resty.http: one-call requests
request wraps resty.http's request_uri in a single call: pass url, method, body, headers, and ssl_verify in one options table and get back a plain { status, headers, body } table. On transport failure it raises through santoku.error instead of returning nil plus a message.
local http = require("santoku.resty.http")
local res = http.request({
url = "https://api.example.com/things",
method = "POST",
body = '{"name":"widget"}',
headers = { ["Content-Type"] = "application/json" },
ssl_verify = true,
})
print(res.status)
print(res.headers["Content-Type"])
print(res.body)
santoku.resty.http: configure hook and structured errors
The configure option receives the underlying resty.http client before the request fires, for timeouts and other client-level settings. Because failures raise, err.pcall is the idiom for handling them without crashing the handler.
local http = require("santoku.resty.http")
local err = require("santoku.error")
local ok, e = err.pcall(http.request, {
url = "https://internal.example.com:8443/health",
configure = function (client)
client:set_timeout(5000)
end,
})
if not ok then
ngx.log(ngx.ERR, "health check failed: ", e)
ngx.status = 502
return
end
ngx.say("upstream healthy")
santoku.resty.socket: fetch without raising
The second HTTP client never raises: fetch returns ok, response where ok reflects a 2xx status. The response normalizes header keys to lowercase, exposes the body as a function, and repeats ok as a field. A transport error yields { status = 0, ok = false, error = e } with a body function that returns nil.
local socket = require("santoku.resty.socket")
local ok, res = socket.fetch("https://api.example.com/items", {
method = "GET",
headers = { ["Accept"] = "application/json" },
})
print(ok, res.status)
print(res.headers["content-type"])
if ok then
print(res.body())
else
print("failed:", res.error or res.status)
end
santoku.resty.socket: cancelable requests and sleep
request defers the work into a { cancel, await } pair. cancel before await short-circuits to { status = 0, ok = false, canceled = true }; cancel while in flight closes the underlying client. sleep wraps ngx.sleep, taking milliseconds, and yields the current request's coroutine instead of blocking the worker.
local socket = require("santoku.resty.socket")
local req = socket.request("https://api.example.com/big-report", {
method = "GET",
})
req.cancel()
local ok, res = req.await()
print(ok, res.status, res.canceled)
socket.sleep(500)
local ok2, res2 = socket.fetch("https://api.example.com/big-report")
print(ok2, res2.status)
A payments client
santoku.http takes a backend with socket's fetch and request functions, so handing it santoku.resty.socket gives a server-side client with retries and jittered backoff.
local json = require("cjson")
local str = require("santoku.string")
local http = require("santoku.http")(require("santoku.resty.socket"))
local function stripe_request (method, path, params, key)
local opts = {
method = method,
headers = {
["Authorization"] = "Bearer " .. key,
["Content-Type"] = "application/x-www-form-urlencoded",
},
}
if params then
opts.body = str.to_formdata(params)
end
local ok, res = http.fetch("https://api.stripe.com" .. path, opts)
local body = res.body()
if not ok then
return nil, "stripe error " .. res.status .. ": " .. (body or "")
end
return json.decode(body)
end
local session = stripe_request("POST", "/v1/checkout/sessions", {
mode = "subscription",
["line_items[0][quantity]"] = "1",
}, secret_key)
print(session and session.url)
santoku.resty.jwt: parse and verify
parse and verify take the compact JWT (header.payload.signature) as a client sends it; pass the token alone, never the raw Authorization header. parse splits it without verification into header, payload, signing_input and signature. verify(token, get_jwk, get_time, claim_spec) accepts RS256 and ES256, resolves the JWK by the token's kid through your callback, and checks the signature with OpenSSL. The default claim spec rejects a token whose exp is missing or not after get_time(); pass jwt.claims or your own claim_spec as the fourth argument to change that. Failures return false plus a reason: Malformed token, Unsupported alg, No kid, No jwk for kid, Unsupported key type, Key alg mismatch, Bad signature, or the failing claim's message (expired, not yet valid, wrong audience, wrong issuer).
local jwt = require("santoku.resty.jwt")
local parsed = jwt.parse(token)
print(parsed.header.kid)
local ok, data = jwt.verify(token, jwt.keyset(jwks_doc), ngx.time)
if ok then
print("sub:", data.payload.sub)
else
print("rejected:", data)
end
santoku.resty.jwt: keys, signing and a JWKS
keypair(bits) generates an RSA key (2048 bits by default) and returns the private key as PEM plus its public JWK. pem_to_jwk(pem) builds that JWK from any RSA PEM, with kty, alg RS256, use sig, base64url n and e, and the RFC 7638 thumbprint as kid. sign(claims, pem, kid) returns an RS256 compact JWT. jwks(jwk, ...) wraps public JWKs into the { keys } document you serve at a well-known path, and keyset(doc) turns a JWKS document into the kid lookup verify takes. Keep the private PEM out of the repo: load it from a secret at init time.
local cjson = require("cjson")
local jwt = require("santoku.resty.jwt")
local pem, jwk = jwt.keypair()
local token = jwt.sign({ sub = "u1", aud = "app", exp = ngx.time() + 3600 }, pem, jwk.kid)
local doc = jwt.jwks(jwk)
ngx.say(cjson.encode(doc))
local ok = jwt.verify(token, jwt.keyset(doc), ngx.time)
ngx.say(ok)
santoku.resty.jwt: a remote JWKS and claim checks
remote_jwks(url, opts) returns a kid lookup backed by a JWKS fetched over santoku.resty.http and cached per worker. It refetches after opts.ttl seconds (3600) and on an unknown kid at most once per opts.min_interval seconds (60), and keeps the last good set when a fetch fails or returns a document without a keys array. A failed fetch is retried after min_interval, not ttl. Requests that arrive while a fetch is in flight wait for it, up to opts.wait seconds (10), then read the new set. opts.time replaces ngx.now as the clock. A *_by_lua_file chunk runs again on every request, so create the lookup in a module the handler requires; created in the chunk itself, each request starts with an empty cache. opts.fetch(url) replaces the fetch and returns status and body, for example through a proxied location with ngx.location.capture. A cosocket fetch needs a resolver directive and lua_ssl_trusted_certificate in the nginx conf. claims({ aud, iss, time }) builds a claim spec that checks exp against time() (ngx.time by default), aud as a string or array member, and iss. This is the Cloudflare Access pattern: the token arrives in the Cf-Access-Jwt-Assertion header and the keys at the team's certs URL.
local jwt = require("santoku.resty.jwt")
local certs = jwt.remote_jwks("https://myteam.cloudflareaccess.com/cdn-cgi/access/certs")
local spec = jwt.claims({
aud = "my-access-app-aud-tag",
iss = "https://myteam.cloudflareaccess.com",
})
local token = ngx.var.http_cf_access_jwt_assertion
local ok, data = jwt.verify(token, certs, nil, spec)
if not ok then
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
ngx.say(data.payload.email)
santoku.resty.jwt: JWK to PEM
jwk_to_pem takes an RSA or P-256 public JWK, loads it with lua-resty-openssl's JWK reader and writes it out as a PEM public key string. The work happens in the OpenSSL linked into nginx, so the rock has no C code and needs no OpenSSL headers to build. verify reads JWKs directly; this is for callers that cache PEMs or feed other OpenSSL APIs.
local jwt = require("santoku.resty.jwt")
local pkey = require("resty.openssl.pkey")
local pem = jwt.jwk_to_pem({
kty = "RSA",
n = jwk.n,
e = jwk.e,
})
print(pem)
local key = pkey.new(pem)
print(key:get_key_type().sn, key:is_private())
A bearer-auth guard for handlers
The verify signature composes into a small middleware: read the Authorization header from ngx, strip the Bearer prefix, verify against a JWKS document, extract the subject with santoku.table, and only then run the handler body.
local str = require("santoku.string")
local tbl = require("santoku.table")
local jwt = require("santoku.resty.jwt")
local keys = jwt.keyset(require("app.jwks"))
local function with_sub (callback)
local header = ngx.req.get_headers()["authorization"]
local auth = header and str.match(header, "^Bearer%s+(%S+)$")
if not auth then
ngx.status = 401
return
end
local ok, data = jwt.verify(auth, keys, ngx.time)
if not ok and data == "expired" then
ngx.status = 401
ngx.say(data)
return
elseif not ok then
ngx.status = 401
return
end
local sub = tbl.get(data, { "payload", "sub" })
if not sub then
ngx.status = 401
return
end
return callback(sub)
end
with_sub(function (sub)
ngx.say("hello ", sub)
end)
santoku.resty.stream: a TLS stream on a cosocket
connect(opts, done) is santoku.socket.stream's contract inside an nginx worker, so a protocol client such as santoku-imap runs on either. It opens an ngx.socket.tcp to opts.host and opts.port, runs the TLS handshake with opts.sslname or the host as SNI and verification on (opts.verify = false skips it, opts.tls = false skips TLS), then calls done(true, conn) or done(false, reason). A light thread reads in 8 KiB chunks and hands each to opts.data(chunk); opts.closed(reason) fires once when the peer closes or a read or write fails. conn.write(data) returns true, or false and the reason; conn.close() shuts the socket; conn.thread is the reader thread. Connect, send and read timeouts default to 30000 ms (connect_timeout_ms, send_timeout_ms, read_timeout_ms). Verification needs lua_ssl_trusted_certificate, and a hostname needs a resolver, in the nginx conf.
local stream = require("santoku.resty.stream")
stream.connect({
host = "imap.example.com",
port = 993,
data = function (chunk) ngx.log(ngx.INFO, "read ", #chunk, " bytes") end,
closed = function (e) ngx.log(ngx.INFO, "closed: ", tostring(e)) end,
}, function (ok, conn)
if not ok then
return ngx.log(ngx.ERR, "connect failed: ", conn)
end
conn.write("A1 CAPABILITY\r\n")
ngx.sleep(1)
conn.close()
end)
santoku.resty.tcp: santoku-mysql's cosocket transport
connect(opts) is santoku.socket.tcp's contract over ngx.socket.tcp: host and port, or path for a unix socket, and timeout_ms (30000) for connect, send and read. It returns a transport with send, receive(n), starttls and close, or nil and the error. opts.pool names a keepalive pool of opts.pool_size connections, t.reused counts how often nginx handed this one out before, and keepalive(idle_ms) returns it to the pool in place of close. starttls verifies against lua_ssl_trusted_certificate, raises if given cafile or capath, and checks an IP host against the certificate's IP entries. semaphore(n) wraps ngx.semaphore with wait(ms) and post(). pooled = true tells santoku-mysql to borrow a connection per call. Like every cosocket, a transport serves only the request that opened it.
local tcp = require("santoku.resty.tcp")
local t, e = tcp.connect({ host = "10.0.0.5", port = 6379, pool = "cache", pool_size = 8 })
if not t then
return ngx.log(ngx.ERR, "connect failed: ", e)
end
t.send("PING\r\n")
ngx.say(t.receive(7) == "+PONG\r\n", " reused ", t.reused)
t.keepalive(60000)
santoku.resty.websocket.client
The module is a constructor: connect(url, nopts, copts) passes nopts to resty.websocket.client's new and copts to connect, then returns a three-method object. send transmits binary frames. receive loops internally: it answers pings with pongs, skips pongs, joins a text or binary frame with its continuation frames into one message, and returns nil after answering a close frame. Any other frame type, and any frame-level failure, raises through santoku.error.
local connect = require("santoku.resty.websocket.client")
local ws = connect("wss://feed.example.com/socket", {
timeout = 5000,
})
ws.send('{"subscribe":"updates"}')
local msg = ws.receive()
while msg do
print("frame:", msg)
msg = ws.receive()
end
ws.close()
santoku.resty.websocket.server
The server side takes one options table for resty.websocket.server's new and returns the same three methods. Its receive is the client's: whole messages, pings answered, pongs skipped. close sends a close frame to the peer.
local accept = require("santoku.resty.websocket.server")
local ws = accept({
timeout = 60000,
max_payload_len = 65535,
})
while true do
local msg = ws.receive()
if not msg then
break
end
ws.send("echo: " .. msg)
end
ws.close()
Templated nginx confs and web handlers
The nginx conf is typically a santoku-template file rendered at build time, mapping each API location to content_by_lua_file with a bundled web module. Those handlers take the inbound side with the raw ngx API and use this library for the outbound side, like the Stripe portal handler below.
local auth = require("tasks.web.auth")
local stripe = require("tasks.stripe")
local psub, user = auth.signed_header("portal")
auth.require_token(psub)
if not user.stripe_customer_id or user.stripe_customer_id == "none" then
return auth.fail(400, "no_subscription")
end
local portal_url, e = stripe.create_portal_session(
user.stripe_customer_id, return_url)
if not portal_url then
return auth.fail(500, "portal_failed", { detail = e })
end
ngx.header.content_type = "text/plain"
ngx.say(portal_url)