From 7d0b6501118783ffcdf08cb3dea5dd1696d2ac54 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 15:40:46 +0800 Subject: [PATCH 1/4] refactor: adopt lua-resty-etcd code style conventions - Align imports with = signs, localize all builtins at file top - Use prefixed aliases (tab_insert, str_find, sub_str, etc.) - Replace OOP string method calls with localized functions - Remove LDoc annotations, use simple -- comments - Rename metatables (_VMT -> _validator_mt, _MT -> _router_mt) - Use inline version field (version = 0.1) Infrastructure: - Add .luacheckrc (std=ngx_lua, ignore 542) - Rewrite Makefile with self-documenting ### target: pattern - Add install, dev, help targets - Add master rockspec Documentation: - Rewrite README with rst-style headers, TOC, badges - Create separate api.md with detailed API documentation - Fix README to match actual code (remove undocumented options) - Add readOnly/writeOnly and form-urlencoded to feature matrix --- .luacheckrc | 5 + Makefile | 31 ++- README.md | 102 ++++++--- api.md | 91 ++++++++ lib/resty/openapi_validator/body.lua | 198 +++++++++--------- lib/resty/openapi_validator/errors.lua | 23 +- lib/resty/openapi_validator/init.lua | 71 +++---- lib/resty/openapi_validator/loader.lua | 21 +- lib/resty/openapi_validator/normalize.lua | 37 ++-- lib/resty/openapi_validator/params.lua | 128 +++++------ lib/resty/openapi_validator/refs.lua | 62 ++---- lib/resty/openapi_validator/router.lua | 109 +++++----- ...ty-openapi-validator-master-0.1-0.rockspec | 34 +++ 13 files changed, 512 insertions(+), 400 deletions(-) create mode 100644 .luacheckrc create mode 100644 api.md create mode 100644 rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..5f7e885 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,5 @@ +std = "ngx_lua" +ignore = { + "542", -- empty if branch +} +redefined = false diff --git a/Makefile b/Makefile index 5c18563..24deb2a 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,51 @@ -.PHONY: test test-unit test-conformance benchmark lint clean +INST_PREFIX ?= /usr/local/openresty +INST_LUADIR ?= $(INST_PREFIX)/lualib +INSTALL ?= install RESTY := /usr/local/openresty/bin/resty --shdict "test 1m" UNIT_TESTS := $(sort $(wildcard t/unit/test_*.lua)) CONFORMANCE_TESTS := $(sort $(wildcard t/conformance/test_*.lua)) +.PHONY: test test-unit test-conformance benchmark lint dev install clean help + +### help: Show Makefile rules +help: + @echo Makefile rules: + @echo + @grep -E '^### [-A-Za-z0-9_]+:' Makefile | sed 's/###/ /' + +### dev: Create a development ENV +dev: + luarocks install rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec --only-deps --local + +### install: Install the library to runtime +install: + $(INSTALL) -d $(INST_LUADIR)/resty/openapi_validator/ + $(INSTALL) lib/resty/openapi_validator/*.lua $(INST_LUADIR)/resty/openapi_validator/ + +### test: Run all tests test: test-unit test-conformance @echo "All tests passed." +### test-unit: Run unit tests test-unit: @echo "=== Unit tests ===" @for f in $(UNIT_TESTS); do $(RESTY) -e "dofile('$$f')" || exit 1; done +### test-conformance: Run conformance tests test-conformance: @echo "=== Conformance tests ===" @for f in $(CONFORMANCE_TESTS); do $(RESTY) -e "dofile('$$f')" || exit 1; done +### benchmark: Run microbenchmark benchmark: @$(RESTY) -e 'dofile("benchmark/bench.lua")' +### lint: Lint Lua source code lint: - @luacheck lib/ --std ngx_lua + luacheck -q lib/ +### clean: Remove build artifacts clean: - @rm -rf *.rock benchmark/logs/ benchmark/nginx.conf + rm -rf *.rock benchmark/logs/ benchmark/nginx.conf diff --git a/README.md b/README.md index f8c5089..553462c 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,66 @@ -# lua-resty-openapi-validator +Name +==== -Pure Lua OpenAPI request validator for OpenResty / LuaJIT. +lua-resty-openapi-validator - Pure Lua OpenAPI request validator for OpenResty / LuaJIT. + +![CI](https://github.com/api7/lua-resty-openapi-validator/actions/workflows/test.yml/badge.svg) +![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg) + +Table of Contents +================= + +* [Description](#description) +* [Install](#install) +* [Quick Start](#quick-start) +* [API](api.md) +* [Validation Scope](#validation-scope) +* [OpenAPI 3.1 Support](#openapi-31-support) +* [Benchmark](#benchmark) +* [Testing](#testing) + +Description +=========== Validates HTTP requests against OpenAPI 3.0 and 3.1 specifications using [lua-resty-radixtree](https://github.com/api7/lua-resty-radixtree) for path matching and [api7/jsonschema](https://github.com/api7/jsonschema) for schema validation. No Go FFI or external processes required. -## Performance +Install +======= -**~45% higher throughput** than the Go FFI-based validator under concurrent load -(single worker, 50 connections). See [benchmark/RESULTS.md](benchmark/RESULTS.md). +> Dependencies -## Installation +- [api7/jsonschema](https://github.com/api7/jsonschema) — JSON Schema Draft 4/6/7 validation +- [lua-resty-radixtree](https://github.com/api7/lua-resty-radixtree) — radix tree path routing +- [lua-cjson](https://github.com/openresty/lua-cjson) — JSON encoding/decoding + +> install by luarocks -```bash +```shell luarocks install lua-resty-openapi-validator ``` -Or add the `lib/` directory to your `lua_package_path`. +> install by source -### Dependencies +```shell +$ git clone https://github.com/api7/lua-resty-openapi-validator.git +$ cd lua-resty-openapi-validator +$ make dev +$ sudo make install +``` -- [api7/jsonschema](https://github.com/api7/jsonschema) — JSON Schema Draft 4/6/7 validation -- [lua-resty-radixtree](https://github.com/api7/lua-resty-radixtree) — radix tree path routing -- [lua-cjson](https://github.com/openresty/lua-cjson) — JSON encoding/decoding +[Back to TOC](#table-of-contents) -## Quick Start +Quick Start +=========== ```lua local ov = require("resty.openapi_validator") -- compile once (cache the result) local validator, err = ov.compile(spec_json_string, { - strict = true, -- error on unsupported 3.1 keywords (default: true) - coerce_types = true, -- coerce query/header string values to schema types (default: true) - fail_fast = false, -- return on first error (default: false) + strict = true, -- error on unsupported 3.1 keywords (default: true) }) if not validator then ngx.log(ngx.ERR, "spec compile error: ", err) @@ -59,18 +84,12 @@ if not ok then end ``` -### Selective Validation +See [API documentation](api.md) for details on all methods and options. -Skip specific validation steps: +[Back to TOC](#table-of-contents) -```lua -local ok, err = validator:validate_request(req, { - skip_query = true, -- skip query parameter validation - skip_body = true, -- skip request body validation -}) -``` - -## Validation Scope +Validation Scope +================ | Feature | Status | |---|---| @@ -78,6 +97,7 @@ local ok, err = validator:validate_request(req, { | Query parameter validation (with type coercion) | ✅ | | Header validation | ✅ | | Request body validation (JSON) | ✅ | +| Request body validation (form-urlencoded) | ✅ | | `style` / `explode` parameter serialization | ✅ | | `$ref` resolution (document-internal) | ✅ | | Circular `$ref` support | ✅ | @@ -85,12 +105,16 @@ local ok, err = validator:validate_request(req, { | `additionalProperties` | ✅ | | OpenAPI 3.0 `nullable` | ✅ | | OpenAPI 3.1 type arrays (`["string", "null"]`) | ✅ | +| `readOnly` / `writeOnly` validation | ✅ | | Response validation | ❌ (not planned for v1) | | Security scheme validation | ❌ | | External `$ref` (URLs, files) | ❌ | -| `multipart/form-data` body | ⚠️ (skipped, returns OK) | +| `multipart/form-data` body | ⚠️ basic support | + +[Back to TOC](#table-of-contents) -## OpenAPI 3.1 Support +OpenAPI 3.1 Support +=================== OpenAPI 3.1 uses JSON Schema Draft 2020-12. Since the underlying jsonschema library supports up to Draft 7, schemas are normalized at compile time: @@ -104,15 +128,29 @@ library supports up to Draft 7, schemas are normalized at compile time: | `$ref` with sibling keywords | → `allOf: [resolved, {siblings}]` | | `$dynamicRef`, `unevaluatedProperties` | Error (strict) / Warning (lenient) | -## Testing +[Back to TOC](#table-of-contents) + +Benchmark +========= -```bash +**~45% higher throughput** than the Go FFI-based validator under concurrent load +(single worker, 50 connections). See [benchmark/RESULTS.md](benchmark/RESULTS.md). + +[Back to TOC](#table-of-contents) + +Testing +======= + +```shell make test ``` -Runs 200 tests across unit tests and conformance tests ported from +Runs unit tests and conformance tests ported from [kin-openapi](https://github.com/getkin/kin-openapi). -## License +[Back to TOC](#table-of-contents) + +License +======= Apache 2.0 diff --git a/api.md b/api.md new file mode 100644 index 0000000..a5a8652 --- /dev/null +++ b/api.md @@ -0,0 +1,91 @@ +API +=== + +Table of Contents +================= + +* [compile](#compile) +* [validate_request](#validate_request) + +compile +------- + +`syntax: validator, err = ov.compile(spec_str, opts)` + +Compiles an OpenAPI specification JSON string into a reusable validator object. +The spec is parsed, `$ref` pointers are resolved, and schemas are normalized to +JSON Schema Draft 7. The returned validator should be cached and reused across +requests. + +- `spec_str`: string — raw JSON of an OpenAPI 3.0 or 3.1 specification. +- `opts`: table (optional) — compilation options: + - `strict`: boolean (default `true`) — if `true`, returns an error when + unsupported OpenAPI 3.1 keywords are encountered (`$dynamicRef`, + `unevaluatedProperties`, etc.); if `false`, these keywords are silently + dropped with a warning. + +Returns a validator object on success, or `nil` and an error string on failure. + +```lua +local ov = require("resty.openapi_validator") + +local validator, err = ov.compile(spec_json, { strict = true }) +if not validator then + ngx.log(ngx.ERR, "compile: ", err) + return +end +``` + +[Back to TOC](#table-of-contents) + +validate_request +---------------- + +`syntax: ok, err = validator:validate_request(req, skip)` + +Validates an incoming HTTP request against the compiled OpenAPI spec. Returns +`true` on success, or `false` and a formatted error string on failure. + +- `req`: table — request data with the following fields: + - `method`: string (required) — HTTP method (e.g. `"GET"`, `"POST"`) + - `path`: string (required) — request URI path (e.g. `"/users/123"`) + - `query`: table (optional) — query parameters `{ name = value | {values} }` + - `headers`: table (optional) — request headers `{ name = value }` + - `body`: string (optional) — raw request body + - `content_type`: string (optional) — Content-Type header value + +- `skip`: table (optional) — selectively skip validation steps: + - `path`: boolean — skip path parameter validation + - `query`: boolean — skip query parameter validation + - `header`: boolean — skip header validation + - `body`: boolean — skip request body validation + - `readOnly`: boolean — skip readOnly property checks in request body + - `writeOnly`: boolean — skip writeOnly property checks + +```lua +local ok, err = validator:validate_request({ + method = ngx.req.get_method(), + path = ngx.var.uri, + query = ngx.req.get_uri_args(), + headers = ngx.req.get_headers(0, true), + body = ngx.req.get_body_data(), + content_type = ngx.var.content_type, +}) + +if not ok then + ngx.status = 400 + ngx.say(err) + return +end +``` + +Skip specific validation: + +```lua +local ok, err = validator:validate_request(req, { + body = true, -- skip body validation + readOnly = true, -- skip readOnly checks +}) +``` + +[Back to TOC](#table-of-contents) diff --git a/lib/resty/openapi_validator/body.lua b/lib/resty/openapi_validator/body.lua index 084ee12..124c4ab 100644 --- a/lib/resty/openapi_validator/body.lua +++ b/lib/resty/openapi_validator/body.lua @@ -1,25 +1,30 @@ ---- Request body validation. --- Handles JSON, form-urlencoded, and multipart/form-data body parsing and validation. +-- Request body validation. +-- Handles JSON, form-urlencoded, and multipart/form-data body parsing +-- and validation. local _M = {} -local type = type -local pairs = pairs -local ipairs = ipairs -local find = string.find -local lower = string.lower -local sub = string.sub -local insert = table.insert -local tonumber = tonumber - -local cjson = require("cjson.safe") +local type = type +local pairs = pairs +local pcall = pcall +local tonumber = tonumber +local str_find = string.find +local str_lower = string.lower +local sub_str = string.sub +local str_gsub = string.gsub +local str_char = string.char +local str_match = string.match +local str_gmatch = string.gmatch +local tab_insert = table.insert + +local cjson = require("cjson.safe") local jsonschema -local has_jsonschema, _ = pcall(function() +local has_jsonschema = pcall(function() jsonschema = require("jsonschema") end) -local errors = require("resty.openapi_validator.errors") +local errors = require("resty.openapi_validator.errors") -- Schema validator cache local validator_cache = setmetatable({}, { __mode = "k" }) @@ -39,48 +44,49 @@ local function get_validator(schema) return nil end ---- Check if content type is JSON-like. + local function is_json_content_type(ct) if not ct then return false end - ct = lower(ct) - return find(ct, "application/json", 1, true) ~= nil - or find(ct, "+json", 1, true) ~= nil + ct = str_lower(ct) + return str_find(ct, "application/json", 1, true) ~= nil + or str_find(ct, "+json", 1, true) ~= nil end ---- Check if content type is form-urlencoded. + local function is_form_content_type(ct) if not ct then return false end - ct = lower(ct) - return find(ct, "application/x-www-form-urlencoded", 1, true) ~= nil + ct = str_lower(ct) + return str_find(ct, "application/x-www-form-urlencoded", 1, true) ~= nil end ---- Check if content type is multipart/form-data. + local function is_multipart_content_type(ct) if not ct then return false end - ct = lower(ct) - return find(ct, "multipart/form-data", 1, true) ~= nil + ct = str_lower(ct) + return str_find(ct, "multipart/form-data", 1, true) ~= nil end ---- URL-decode a string. + local function url_decode(s) - s = s:gsub("+", " ") - s = s:gsub("%%(%x%x)", function(hex) - return string.char(tonumber(hex, 16)) + s = str_gsub(s, "+", " ") + s = str_gsub(s, "%%(%x%x)", function(hex) + return str_char(tonumber(hex, 16)) end) return s end ---- Parse application/x-www-form-urlencoded body into a table. + +-- Parse application/x-www-form-urlencoded body into a table. local function parse_form_urlencoded(body_str) local result = {} if not body_str or body_str == "" then return result end - for pair in body_str:gmatch("[^&]+") do - local eq = find(pair, "=", 1, true) + for pair in str_gmatch(body_str, "[^&]+") do + local eq = str_find(pair, "=", 1, true) if eq then - local key = url_decode(sub(pair, 1, eq - 1)) - local val = url_decode(sub(pair, eq + 1)) + local key = url_decode(sub_str(pair, 1, eq - 1)) + local val = url_decode(sub_str(pair, eq + 1)) result[key] = val else result[url_decode(pair)] = "" @@ -89,7 +95,8 @@ local function parse_form_urlencoded(body_str) return result end ---- Coerce form values to match schema types. + +-- Coerce form values to match schema types. local function coerce_form_value(value, prop_schema) if not prop_schema or type(value) ~= "string" then return value @@ -108,7 +115,8 @@ local function coerce_form_value(value, prop_schema) return value end ---- Coerce form data values according to schema properties. + +-- Coerce form data values according to schema properties. local function coerce_form_data(data, schema) if not schema or not schema.properties then return data @@ -121,21 +129,20 @@ local function coerce_form_data(data, schema) return data end ---- Extract multipart boundary from content-type header. + +-- Extract multipart boundary from content-type header. local function extract_boundary(ct) if not ct then return nil end - local boundary = ct:match("boundary=([^;%s]+)") + local boundary = str_match(ct, "boundary=([^;%s]+)") if boundary then - -- strip surrounding quotes - boundary = boundary:gsub('^"', ''):gsub('"$', '') + boundary = str_gsub(str_gsub(boundary, '^"', ''), '"$', '') end return boundary end ---- Parse multipart/form-data body. + +-- Parse multipart/form-data body. -- Returns a table of { field_name = value }. --- For JSON content-disposition parts, value is decoded JSON. --- For file parts, returns the raw content. local function parse_multipart(body_str, boundary) if not body_str or not boundary then return {} @@ -145,36 +152,32 @@ local function parse_multipart(body_str, boundary) local delimiter = "--" .. boundary local end_delimiter = delimiter .. "--" - -- split by boundary local pos = 1 while true do - local start = find(body_str, delimiter, pos, true) + local start = str_find(body_str, delimiter, pos, true) if not start then break end - local next_start = find(body_str, delimiter, start + #delimiter, true) + local next_start = str_find(body_str, delimiter, + start + #delimiter, true) if not next_start then break end - local part = sub(body_str, start + #delimiter, next_start - 1) - -- strip leading \r\n - if sub(part, 1, 2) == "\r\n" then - part = sub(part, 3) + local part = sub_str(body_str, start + #delimiter, next_start - 1) + if sub_str(part, 1, 2) == "\r\n" then + part = sub_str(part, 3) end - -- strip trailing \r\n - if sub(part, -2) == "\r\n" then - part = sub(part, 1, -3) + if sub_str(part, -2) == "\r\n" then + part = sub_str(part, 1, -3) end - -- split headers and body - local header_end = find(part, "\r\n\r\n", 1, true) + local header_end = str_find(part, "\r\n\r\n", 1, true) if header_end then - local headers_str = sub(part, 1, header_end - 1) - local body = sub(part, header_end + 4) + local headers_str = sub_str(part, 1, header_end - 1) + local body = sub_str(part, header_end + 4) - -- parse Content-Disposition for field name - local name = headers_str:match('name="([^"]+)"') + local name = str_match(headers_str, 'name="([^"]+)"') if name then - -- check Content-Type of the part - local part_ct = headers_str:match("[Cc]ontent%-[Tt]ype:%s*([^\r\n]+)") + local part_ct = str_match(headers_str, + "[Cc]ontent%-[Tt]ype:%s*([^\r\n]+)") if part_ct and is_json_content_type(part_ct) then local decoded = cjson.decode(body) result[name] = decoded ~= nil and decoded or body @@ -185,8 +188,8 @@ local function parse_multipart(body_str, boundary) end pos = next_start - -- check if we hit the end delimiter - if sub(body_str, next_start, next_start + #end_delimiter - 1) == end_delimiter then + if sub_str(body_str, next_start, + next_start + #end_delimiter - 1) == end_delimiter then break end end @@ -194,24 +197,23 @@ local function parse_multipart(body_str, boundary) return result end ---- Find the matching body schema for a given content type from the route's content map. + +-- Find the matching body schema for a given content type. local function find_body_schema_for_content_type(route, content_type) if not route.body_content then return route.body_schema end - -- exact match if content_type then - local ct_lower = lower(content_type) + local ct_lower = str_lower(content_type) for media_type, media_obj in pairs(route.body_content) do - local mt_lower = lower(media_type) - if find(ct_lower, mt_lower, 1, true) then + local mt_lower = str_lower(media_type) + if str_find(ct_lower, mt_lower, 1, true) then return media_obj.schema end end end - -- fallback to */* if route.body_content["*/*"] then return route.body_content["*/*"].schema end @@ -219,8 +221,8 @@ local function find_body_schema_for_content_type(route, content_type) return route.body_schema end ---- Check for readOnly properties present in the request body data. --- readOnly properties should not be sent in a request. + +-- Check for readOnly properties present in the request body data. local function check_readonly_properties(data, schema, errs) if type(data) ~= "table" or type(schema) ~= "table" then return @@ -231,89 +233,77 @@ local function check_readonly_properties(data, schema, errs) end for key, prop_schema in pairs(props) do if prop_schema.readOnly and data[key] ~= nil then - insert(errs, errors.new("body", key, + tab_insert(errs, errors.new("body", key, "readOnly property '" .. key .. "' should not be sent in request")) end end end ---- Validate request body. --- @param route table matched route from router --- @param body_str string raw request body (may be nil or empty) --- @param content_type string Content-Type header value --- @param opts table|nil validation options: --- - exclude_readonly (bool): if true, skip readOnly validation --- - exclude_writeonly (bool): if true, skip writeOnly validation --- @return boolean --- @return table|nil list of error tables + +-- Validate request body. function _M.validate(route, body_str, content_type, opts) opts = opts or {} local errs = {} - -- check if body is required if route.body_required then if body_str == nil or body_str == "" then - insert(errs, errors.new("body", nil, "request body is required")) + tab_insert(errs, errors.new("body", nil, "request body is required")) return false, errs end end - -- no body schema means nothing to validate if not route.body_schema and not route.body_content then return true, nil end - -- no body provided and not required → OK if body_str == nil or body_str == "" then return true, nil end -- check content-type is declared in the spec if route.body_content and content_type then - local ct_lower = lower(content_type) + local ct_lower = str_lower(content_type) local found = false for media_type in pairs(route.body_content) do - local mt_lower = lower(media_type) - if find(ct_lower, mt_lower, 1, true) or media_type == "*/*" then + local mt_lower = str_lower(media_type) + if str_find(ct_lower, mt_lower, 1, true) + or media_type == "*/*" then found = true break end end if not found then - insert(errs, errors.new("body", nil, - "content type " .. content_type .. " is not declared in the spec")) + tab_insert(errs, errors.new("body", nil, + "content type " .. content_type + .. " is not declared in the spec")) return false, errs end end - -- get the right schema for this content type local schema = find_body_schema_for_content_type(route, content_type) if not schema then return true, nil end - -- determine content type and validate accordingly if is_json_content_type(content_type) then - -- parse JSON body local body_data, decode_err = cjson.decode(body_str) if body_data == nil and decode_err then - insert(errs, errors.new("body", nil, + tab_insert(errs, errors.new("body", nil, "invalid JSON body: " .. (decode_err or "decode error"))) return false, errs end - -- check readOnly properties in request if not opts.exclude_readonly then check_readonly_properties(body_data, schema, errs) end - -- validate against schema local validator = get_validator(schema) if validator then local ok, err = validator(body_data) if not ok then - local msg = type(err) == "string" and err or "body validation failed" - insert(errs, errors.new("body", nil, msg)) + local msg = type(err) == "string" and err + or "body validation failed" + tab_insert(errs, errors.new("body", nil, msg)) end end @@ -329,15 +319,17 @@ function _M.validate(route, body_str, content_type, opts) if validator then local ok, err = validator(form_data) if not ok then - local msg = type(err) == "string" and err or "body validation failed" - insert(errs, errors.new("body", nil, msg)) + local msg = type(err) == "string" and err + or "body validation failed" + tab_insert(errs, errors.new("body", nil, msg)) end end elseif is_multipart_content_type(content_type) then local boundary = extract_boundary(content_type) if not boundary then - insert(errs, errors.new("body", nil, "missing multipart boundary")) + tab_insert(errs, errors.new("body", nil, + "missing multipart boundary")) return false, errs end @@ -348,13 +340,13 @@ function _M.validate(route, body_str, content_type, opts) if validator then local ok, err = validator(parts) if not ok then - local msg = type(err) == "string" and err or "body validation failed" - insert(errs, errors.new("body", nil, msg)) + local msg = type(err) == "string" and err + or "body validation failed" + tab_insert(errs, errors.new("body", nil, msg)) end end else - -- unsupported content type, skip validation return true, nil end diff --git a/lib/resty/openapi_validator/errors.lua b/lib/resty/openapi_validator/errors.lua index dc3a0ed..1035440 100644 --- a/lib/resty/openapi_validator/errors.lua +++ b/lib/resty/openapi_validator/errors.lua @@ -1,24 +1,23 @@ ---- Error types for OpenAPI validation. +-- Error types for OpenAPI validation. local _M = {} ---- Create a validation error. --- @param location string "path" | "query" | "header" | "body" --- @param param string|nil parameter name (nil for body errors) --- @param message string human-readable error --- @return table +local ipairs = ipairs +local tab_concat = table.concat + + +-- Create a validation error. function _M.new(location, param, message) return { location = location, - param = param, - message = message, + param = param, + message = message, } end ---- Format a list of errors into a human-readable string. + +-- Format a list of errors into a human-readable string. -- Produces output similar to kin-openapi for compatibility. --- @param errs table list of error tables --- @return string function _M.format(errs) if not errs or #errs == 0 then return "" @@ -35,7 +34,7 @@ function _M.format(errs) parts[#parts + 1] = s end - return table.concat(parts, "\n") + return tab_concat(parts, "\n") end return _M diff --git a/lib/resty/openapi_validator/init.lua b/lib/resty/openapi_validator/init.lua index 0f94675..69c08ff 100644 --- a/lib/resty/openapi_validator/init.lua +++ b/lib/resty/openapi_validator/init.lua @@ -1,90 +1,72 @@ -local _M = { - _VERSION = "0.1.0", -} +local _M = {version = 0.1} -local loader = require("resty.openapi_validator.loader") -local refs = require("resty.openapi_validator.refs") -local normalize = require("resty.openapi_validator.normalize") +local loader = require("resty.openapi_validator.loader") +local refs = require("resty.openapi_validator.refs") +local normalize = require("resty.openapi_validator.normalize") local router_mod = require("resty.openapi_validator.router") local params_mod = require("resty.openapi_validator.params") -local body_mod = require("resty.openapi_validator.body") -local errors = require("resty.openapi_validator.errors") +local body_mod = require("resty.openapi_validator.body") +local errors = require("resty.openapi_validator.errors") -local _VMT = {} -_VMT.__index = _VMT +local setmetatable = setmetatable +local ipairs = ipairs ---- Compile an OpenAPI spec string into a validator object. +local _validator_mt = {} +_validator_mt.__index = _validator_mt + + +-- Compile an OpenAPI spec string into a validator object. -- The spec is parsed, $refs resolved, and schemas normalized to Draft 7. -- The returned table is meant to be cached and reused across requests. --- --- @param spec_str string JSON string of an OpenAPI 3.0 or 3.1 spec --- @param opts table|nil optional settings: --- - strict (bool, default true): error on unsupported 3.1 keywords --- @return table|nil compiled validator object --- @return string|nil error message function _M.compile(spec_str, opts) opts = opts or {} if opts.strict == nil then opts.strict = true end - -- 1. Parse JSON local spec, err = loader.parse(spec_str) if not spec then return nil, "failed to parse spec: " .. (err or "unknown error") end - -- 2. Detect version - local version, err = loader.detect_version(spec) + local version + version, err = loader.detect_version(spec) if not version then return nil, err end - -- 3. Resolve internal $refs - local ok, err = refs.resolve(spec) + local ok + ok, err = refs.resolve(spec) if not ok then return nil, "failed to resolve $ref: " .. err end - -- 4. Normalize schemas to Draft 7 local warnings warnings, err = normalize.normalize_spec(spec, version, opts) if err then return nil, "normalization error: " .. err end - -- 5. Build router local rtr = router_mod.new(spec) return setmetatable({ - spec = spec, - version = version, + spec = spec, + version = version, warnings = warnings, - _opts = opts, - _router = rtr, - }, _VMT), nil + _opts = opts, + _router = rtr, + }, _validator_mt), nil end ---- Validate an incoming HTTP request. --- @param self table compiled validator (from compile()) --- @param req table request data: --- - method (string, required): HTTP method --- - path (string, required): request URI path --- - query (table|nil): query args { name = value|{values} } --- - headers (table|nil): request headers { name = value } --- - body (string|nil): raw request body --- - content_type (string|nil): Content-Type header value --- @param skip table|nil { path = bool, query = bool, header = bool, body = bool } --- @return boolean --- @return string|nil formatted error string (kin-openapi compatible) -function _VMT.validate_request(self, req, skip) + +-- Validate an incoming HTTP request. +function _validator_mt.validate_request(self, req, skip) skip = skip or {} if not req.method or not req.path then return false, "method and path are required" end - -- 1. Route matching local route, path_params = self._router:match(req.method, req.path) if not route then return false, "no matching operation found for " @@ -93,7 +75,6 @@ function _VMT.validate_request(self, req, skip) local all_errs = {} - -- 2. Parameter validation (path, query, header) local param_ok, param_errs = params_mod.validate( route, path_params or {}, req.query or {}, req.headers or {}, skip @@ -104,9 +85,7 @@ function _VMT.validate_request(self, req, skip) end end - -- 3. Body validation if not skip.body then - -- build options for body validation local body_opts = {} if skip.readOnly ~= nil then body_opts.exclude_readonly = skip.readOnly diff --git a/lib/resty/openapi_validator/loader.lua b/lib/resty/openapi_validator/loader.lua index b188d59..d41fce5 100644 --- a/lib/resty/openapi_validator/loader.lua +++ b/lib/resty/openapi_validator/loader.lua @@ -1,11 +1,12 @@ -local cjson = require("cjson.safe") +local cjson = require("cjson.safe") + +local type = type +local sub_str = string.sub local _M = {} ---- Parse a JSON string into a Lua table. --- @param spec_str string raw JSON --- @return table|nil parsed spec --- @return string|nil error + +-- Parse a JSON string into a Lua table. function _M.parse(spec_str) if type(spec_str) ~= "string" or #spec_str == 0 then return nil, "spec must be a non-empty string" @@ -19,19 +20,17 @@ function _M.parse(spec_str) return spec, nil end ---- Detect OpenAPI version from parsed spec. --- @param spec table parsed OpenAPI document --- @return string|nil "3.0" or "3.1" --- @return string|nil error message + +-- Detect OpenAPI version from parsed spec. function _M.detect_version(spec) local ver = spec.openapi if type(ver) ~= "string" then return nil, "missing or invalid 'openapi' field" end - if ver:sub(1, 3) == "3.0" then + if sub_str(ver, 1, 3) == "3.0" then return "3.0", nil - elseif ver:sub(1, 3) == "3.1" then + elseif sub_str(ver, 1, 3) == "3.1" then return "3.1", nil end diff --git a/lib/resty/openapi_validator/normalize.lua b/lib/resty/openapi_validator/normalize.lua index e87acdd..f9de217 100644 --- a/lib/resty/openapi_validator/normalize.lua +++ b/lib/resty/openapi_validator/normalize.lua @@ -1,14 +1,13 @@ ---- Schema normalization: convert OpenAPI 3.0/3.1 schemas to JSON Schema Draft 7. +-- Schema normalization: convert OpenAPI 3.0/3.1 schemas to JSON Schema Draft 7. -- This module walks all schema objects in a parsed OpenAPI spec and transforms -- them so that api7/jsonschema (Draft 4/6/7) can validate them. local _M = {} -local type = type -local pairs = pairs -local ipairs = ipairs -local insert = table.insert -local remove = table.remove +local type = type +local pairs = pairs +local ipairs = ipairs +local tab_insert = table.insert -- Keywords that are Draft 2020-12 only and have no Draft 7 equivalent local UNSUPPORTED_31_KEYWORDS = { @@ -18,7 +17,7 @@ local UNSUPPORTED_31_KEYWORDS = { ["unevaluatedItems"] = true, } ---- Normalize a single schema node (3.0 → Draft 7). +-- Normalize a single schema node (3.0 → Draft 7). local function normalize_30_schema(schema, warnings) if type(schema) ~= "table" then return @@ -57,7 +56,7 @@ local function normalize_30_schema(schema, warnings) end end if not has_null then - insert(schema.type, "null") + tab_insert(schema.type, "null") end end end @@ -71,7 +70,7 @@ local function normalize_30_schema(schema, warnings) else -- exclusiveMinimum: true without minimum is invalid, remove it schema.exclusiveMinimum = nil - insert(warnings, "exclusiveMinimum: true without minimum, ignored") + tab_insert(warnings, "exclusiveMinimum: true without minimum, ignored") end elseif schema.exclusiveMinimum == false then schema.exclusiveMinimum = nil @@ -84,7 +83,7 @@ local function normalize_30_schema(schema, warnings) schema.maximum = nil else schema.exclusiveMaximum = nil - insert(warnings, "exclusiveMaximum: true without maximum, ignored") + tab_insert(warnings, "exclusiveMaximum: true without maximum, ignored") end elseif schema.exclusiveMaximum == false then schema.exclusiveMaximum = nil @@ -94,7 +93,7 @@ local function normalize_30_schema(schema, warnings) schema.example = nil end ---- Normalize a single schema node (3.1 → Draft 7). +-- Normalize a single schema node (3.1 → Draft 7). local function normalize_31_schema(schema, warnings, strict) if type(schema) ~= "table" then return nil @@ -106,7 +105,7 @@ local function normalize_31_schema(schema, warnings, strict) if strict then return "unsupported OpenAPI 3.1 keyword: " .. kw end - insert(warnings, "unsupported keyword ignored: " .. kw) + tab_insert(warnings, "unsupported keyword ignored: " .. kw) schema[kw] = nil end end @@ -151,7 +150,7 @@ local function normalize_31_schema(schema, warnings, strict) if strict then return "unsupported keyword: minContains/maxContains" end - insert(warnings, "minContains/maxContains ignored (no Draft 7 equivalent)") + tab_insert(warnings, "minContains/maxContains ignored (no Draft 7 equivalent)") schema.minContains = nil schema.maxContains = nil end @@ -173,7 +172,7 @@ local function normalize_31_schema(schema, warnings, strict) return nil end ---- Recursively walk all schema-like objects in the spec and normalize them. +-- Recursively walk all schema-like objects in the spec and normalize them. local function walk_and_normalize(node, version, warnings, strict, visited) if type(node) ~= "table" then return nil @@ -202,7 +201,7 @@ local function walk_and_normalize(node, version, warnings, strict, visited) end -- Recurse into all sub-tables - for k, v in pairs(node) do + for _, v in pairs(node) do if type(v) == "table" then local err = walk_and_normalize(v, version, warnings, strict, visited) if err then @@ -214,12 +213,8 @@ local function walk_and_normalize(node, version, warnings, strict, visited) return nil end ---- Normalize all schemas in an OpenAPI spec. --- @param spec table parsed and $ref-resolved spec --- @param version string "3.0" or "3.1" --- @param opts table { strict = true|false } --- @return table warnings list --- @return string|nil error (only on strict failure) +-- Normalize all schemas in an OpenAPI spec. + function _M.normalize_spec(spec, version, opts) local warnings = {} local strict = opts and opts.strict or false diff --git a/lib/resty/openapi_validator/params.lua b/lib/resty/openapi_validator/params.lua index 4a1611b..7a421de 100644 --- a/lib/resty/openapi_validator/params.lua +++ b/lib/resty/openapi_validator/params.lua @@ -1,4 +1,4 @@ ---- Parameter coercion and validation. +-- Parameter coercion and validation. -- Handles path, query, and header parameters: -- 1. Type coercion from string to schema-declared type -- 2. Style/explode deserialization (form, simple, label, matrix, deepObject) @@ -6,30 +6,30 @@ local _M = {} -local type = type -local tonumber = tonumber -local pairs = pairs -local ipairs = ipairs -local lower = string.lower -local find = string.find -local sub = string.sub -local gsub = string.gsub -local insert = table.insert -local concat = table.concat +local type = type +local tonumber = tonumber +local pairs = pairs +local ipairs = ipairs +local pcall = pcall +local str_lower = string.lower +local str_find = string.find +local sub_str = string.sub +local tab_insert = table.insert +local str_gmatch = string.gmatch local cjson -local has_cjson, _ = pcall(function() +local has_cjson = pcall(function() cjson = require("cjson.safe") end) local jsonschema -local has_jsonschema, _ = pcall(function() +local has_jsonschema = pcall(function() jsonschema = require("jsonschema") end) local errors = require("resty.openapi_validator.errors") --- Schema validator cache: schema_table → validator_function +-- Schema validator cache: schema_table -> validator_function local validator_cache = setmetatable({}, { __mode = "k" }) local function get_validator(schema) @@ -47,7 +47,8 @@ local function get_validator(schema) return nil end ---- Collect all possible types from a schema, including composite sub-schemas. + +-- Collect all possible types from a schema, including composite sub-schemas. local function collect_types(schema, seen) if not schema then return {} end seen = seen or {} @@ -69,8 +70,8 @@ local function collect_types(schema, seen) for _, key in ipairs({"anyOf", "oneOf", "allOf"}) do local composite = schema[key] if composite then - for _, sub in ipairs(composite) do - local sub_types = collect_types(sub, seen) + for _, sub_schema in ipairs(composite) do + local sub_types = collect_types(sub_schema, seen) for t in pairs(sub_types) do types[t] = true end @@ -81,10 +82,8 @@ local function collect_types(schema, seen) return types end ---- Coerce a string value to the type declared in schema. --- @param value string raw value from request --- @param schema table parameter schema --- @return any coerced value + +-- Coerce a string value to the type declared in schema. local function coerce_value(value, schema) if value == nil then return nil @@ -111,7 +110,7 @@ local function coerce_value(value, schema) if n then return n end - return value -- let schema validation catch the type error + return value elseif stype == "boolean" then if value == "true" or value == "1" then return true @@ -121,10 +120,8 @@ local function coerce_value(value, schema) return value end - -- no direct type — check composite schemas for possible types if not stype then local possible = collect_types(schema) - -- try coercion in order: boolean first (most specific), then number if possible["boolean"] then if value == "true" or value == "1" then return true end if value == "false" or value == "0" then return false end @@ -138,7 +135,8 @@ local function coerce_value(value, schema) return value end ---- Coerce values within an object according to its schema properties. + +-- Coerce values within an object according to its schema properties. local function coerce_object_values(obj, schema) if type(obj) ~= "table" or type(schema) ~= "table" then return obj @@ -155,41 +153,38 @@ local function coerce_object_values(obj, schema) return obj end ---- Split a string by delimiter. + +-- Split a string by delimiter. local function split(s, delim) local result = {} local from = 1 local pos while true do - pos = find(s, delim, from, true) + pos = str_find(s, delim, from, true) if not pos then - insert(result, sub(s, from)) + tab_insert(result, sub_str(s, from)) break end - insert(result, sub(s, from, pos - 1)) + tab_insert(result, sub_str(s, from, pos - 1)) from = pos + 1 end return result end ---- Parse deepObject style query parameters. + +-- Parse deepObject style query parameters. -- deepObject format: param[key]=value or param[key][subkey]=value --- @param param_name string the parameter name --- @param query_args table all query arguments --- @param schema table the parameter schema --- @return table|nil parsed nested object local function parse_deep_object(param_name, query_args, schema) local obj = {} local prefix = param_name .. "[" local found = false for key, val in pairs(query_args) do - if sub(key, 1, #prefix) == prefix then + if sub_str(key, 1, #prefix) == prefix then found = true - -- extract the bracket path: param[a][b] → {"a", "b"} local path = {} - for bracket_key in key:gmatch("%[([^%]]+)%]") do - insert(path, bracket_key) + for bracket_key in str_gmatch(key, "%[([^%]]+)%]") do + tab_insert(path, bracket_key) end if #path > 0 then @@ -201,7 +196,6 @@ local function parse_deep_object(param_name, query_args, schema) end current = current[p] end - -- use the first value if multiple local v = type(val) == "table" and val[1] or val current[path[#path]] = v end @@ -212,10 +206,8 @@ local function parse_deep_object(param_name, query_args, schema) return nil end - -- coerce values based on schema properties coerce_object_values(obj, schema) - -- also recurse into nested objects if schema.properties then for pname, pschema in pairs(schema.properties) do if type(obj[pname]) == "table" and pschema.type == "object" then @@ -227,16 +219,10 @@ local function parse_deep_object(param_name, query_args, schema) return obj end ---- Deserialize a parameter value according to its style and explode settings. + +-- Deserialize a parameter value according to its style and explode settings. -- See: https://spec.openapis.org/oas/v3.1.0#style-values --- --- Default styles per location: --- path: simple, explode=false --- query: form, explode=true --- header: simple, explode=false local function deserialize_param(raw_value, param, query_args) - -- handle content-based parameters (param.content.application/json) first, - -- since these params have no param.schema (schema lives inside content) if param.content then local json_content = param.content["application/json"] if json_content and json_content.schema and has_cjson then @@ -256,7 +242,6 @@ local function deserialize_param(raw_value, param, query_args) local explode = param.explode local loc = param["in"] - -- set defaults if not style then if loc == "query" then style = "form" @@ -280,7 +265,6 @@ local function deserialize_param(raw_value, param, query_args) if not explode then values = split(raw_value, ",") else - -- explode=true for query: handled by caller (multiple values) if type(raw_value) == "table" then values = raw_value else @@ -295,7 +279,6 @@ local function deserialize_param(raw_value, param, query_args) values = { raw_value } end - -- coerce each element for i, v in ipairs(values) do values[i] = coerce_value(v, items_schema) end @@ -306,7 +289,6 @@ local function deserialize_param(raw_value, param, query_args) return parse_deep_object(param.name, query_args or {}, schema) end - -- simple style object: key,value,key,value... if style == "simple" and not explode then local parts = split(raw_value, ",") local obj = {} @@ -315,7 +297,6 @@ local function deserialize_param(raw_value, param, query_args) end return coerce_object_values(obj, schema) elseif style == "simple" and explode then - -- key=value,key=value local parts = split(raw_value, ",") local obj = {} for _, part in ipairs(parts) do @@ -326,8 +307,6 @@ local function deserialize_param(raw_value, param, query_args) end return coerce_object_values(obj, schema) elseif style == "form" and explode then - -- explode=true for form+object: each key is a separate query param - -- handled at higher level if type(raw_value) == "table" then return coerce_object_values(raw_value, schema) end @@ -335,18 +314,11 @@ local function deserialize_param(raw_value, param, query_args) return raw_value end - -- scalar return coerce_value(raw_value, schema) end ---- Validate parameters for a matched route. --- @param route table matched route from router --- @param path_params table extracted path parameter values { name = value } --- @param query_args table query arguments from request (ngx.req.get_uri_args style) --- @param headers table request headers (lowercase keys) --- @param skip table|nil { path = bool, query = bool, header = bool } --- @return boolean --- @return table|nil list of error tables + +-- Validate parameters for a matched route. function _M.validate(route, path_params, query_args, headers, skip) skip = skip or {} query_args = query_args or {} @@ -356,40 +328,37 @@ function _M.validate(route, path_params, query_args, headers, skip) local function validate_param_group(param_list, location, raw_values) for _, param in ipairs(param_list) do local name = param.name - local style = param.style or (location == "query" and "form" or "simple") + local style = param.style + or (location == "query" and "form" or "simple") local raw - -- deepObject params are parsed from the full query args if style == "deepObject" and location == "query" then - raw = "deepObject_placeholder" -- just a non-nil marker + raw = "deepObject_placeholder" else raw = raw_values[name] end - -- for headers, try case-insensitive if location == "header" and raw == nil then - raw = raw_values[lower(name)] + raw = raw_values[str_lower(name)] end - -- check required if param.required and (raw == nil or raw == "") then - -- for deepObject, check if any key with prefix exists if style == "deepObject" and location == "query" then local prefix = name .. "[" local found = false for k in pairs(query_args) do - if sub(k, 1, #prefix) == prefix then + if sub_str(k, 1, #prefix) == prefix then found = true break end end if not found then - insert(errs, errors.new(location, name, + tab_insert(errs, errors.new(location, name, "required parameter is missing")) goto continue end else - insert(errs, errors.new(location, name, + tab_insert(errs, errors.new(location, name, "required parameter is missing")) goto continue end @@ -400,7 +369,6 @@ function _M.validate(route, path_params, query_args, headers, skip) end local schema = param.schema - -- for content-based parameters, use the content schema if not schema and param.content then local json_ct = param.content["application/json"] if json_ct then @@ -411,21 +379,19 @@ function _M.validate(route, path_params, query_args, headers, skip) goto continue end - -- deserialize and coerce local value = deserialize_param(raw, param, query_args) - -- for deepObject, nil means no matching keys found — skip if optional if value == nil and not param.required then goto continue end - -- validate with jsonschema local validator = get_validator(schema) if validator then local ok, err = validator(value) if not ok then - local msg = type(err) == "string" and err or "validation failed" - insert(errs, errors.new(location, name, msg)) + local msg = type(err) == "string" and err + or "validation failed" + tab_insert(errs, errors.new(location, name, msg)) end end diff --git a/lib/resty/openapi_validator/refs.lua b/lib/resty/openapi_validator/refs.lua index f312ec6..f7e8bd5 100644 --- a/lib/resty/openapi_validator/refs.lua +++ b/lib/resty/openapi_validator/refs.lua @@ -1,31 +1,25 @@ ---- Internal $ref resolution for OpenAPI specs. +-- Internal $ref resolution for OpenAPI specs. -- Resolves all $ref pointers within the same document, replacing them inline. --- Supports circular references via a schema registry + lazy proxy. +-- Supports circular references via a schema registry and lazy proxy. local _M = {} -local type = type -local pairs = pairs -local ipairs = ipairs -local sub = string.sub -local gsub = string.gsub -local find = string.find - ---- Resolve a JSON Pointer (RFC 6901) against a root document. --- @param root table the root spec --- @param pointer string e.g. "/components/schemas/Pet" --- @return any|nil resolved value --- @return string|nil error +local type = type +local pairs = pairs +local sub_str = string.sub +local str_gsub = string.gsub +local str_gmatch = string.gmatch + + +-- Resolve a JSON Pointer (RFC 6901) against a root document. local function resolve_pointer(root, pointer) local current = root - -- split by "/" and unescape ~ sequences - for token in pointer:gmatch("[^/]+") do - token = gsub(token, "~1", "/") - token = gsub(token, "~0", "~") + for token in str_gmatch(pointer, "[^/]+") do + token = str_gsub(token, "~1", "/") + token = str_gsub(token, "~0", "~") if type(current) ~= "table" then return nil, "cannot traverse non-table at '" .. token .. "'" end - -- try numeric index (1-based) for arrays local num = tonumber(token) if num and current[num + 1] ~= nil then current = current[num + 1] @@ -38,7 +32,8 @@ local function resolve_pointer(root, pointer) return current, nil end ---- Deep copy a table, handling nested tables. + +-- Deep copy a table, handling nested tables. local function deep_copy(orig, copies) copies = copies or {} if type(orig) ~= "table" then @@ -55,20 +50,17 @@ local function deep_copy(orig, copies) return copy end ---- Walk the spec tree and resolve all $ref nodes. + +-- Walk the spec tree and resolve all $ref nodes. -- Uses a registry to handle circular refs: each unique $ref target is -- resolved once and stored; subsequent refs to the same target reuse it. -- -- For OAS 3.1, $ref can have sibling keywords. In that case we wrap in allOf: -- { "$ref": "...", "maxLength": 5 } --- → { "allOf": [ resolved_target, { "maxLength": 5 } ] } --- --- @param spec table the root OpenAPI spec (mutated in place) --- @return boolean --- @return string|nil error +-- -> { "allOf": [ resolved_target, { "maxLength": 5 } ] } function _M.resolve(spec) - local registry = {} -- pointer → resolved table - local resolving = {} -- pointer → true (cycle detection during first resolution) + local registry = {} + local resolving = {} local function do_resolve(node, root, path) if type(node) ~= "table" then @@ -77,15 +69,14 @@ function _M.resolve(spec) local ref = node["$ref"] if ref then - -- reject external refs if type(ref) ~= "string" then return nil, "invalid $ref type at " .. path end - if sub(ref, 1, 1) ~= "#" then + if sub_str(ref, 1, 1) ~= "#" then return nil, "external $ref not supported: " .. ref .. " at " .. path end - local pointer = sub(ref, 2) -- strip leading "#" + local pointer = sub_str(ref, 2) if pointer == "" then pointer = "/" end @@ -100,7 +91,6 @@ function _M.resolve(spec) end end - -- check registry first if registry[pointer] then if has_siblings then return { allOf = { registry[pointer], siblings } }, nil @@ -110,9 +100,6 @@ function _M.resolve(spec) -- cycle detection if resolving[pointer] then - -- circular ref: create a placeholder that will be filled in - -- For now, we store an empty table as placeholder and let - -- the caller handle recursion at validation time. local placeholder = {} registry[pointer] = placeholder if has_siblings then @@ -123,16 +110,13 @@ function _M.resolve(spec) resolving[pointer] = true - -- resolve the pointer local target, err = resolve_pointer(root, pointer) if not target then return nil, "cannot resolve $ref '" .. ref .. "': " .. err end - -- deep copy to avoid mutation of shared nodes local resolved = deep_copy(target) - -- recursively resolve refs within the resolved target resolved, err = do_resolve(resolved, root, ref) if err then return nil, err @@ -147,7 +131,7 @@ function _M.resolve(spec) return resolved, nil end - -- no $ref — recurse into children + -- no $ref -- recurse into children for k, v in pairs(node) do if type(v) == "table" then local resolved, err = do_resolve(v, root, path .. "/" .. k) diff --git a/lib/resty/openapi_validator/router.lua b/lib/resty/openapi_validator/router.lua index aafa20f..f4ae5e9 100644 --- a/lib/resty/openapi_validator/router.lua +++ b/lib/resty/openapi_validator/router.lua @@ -1,44 +1,51 @@ ---- Router: maps incoming (method, path) to OpenAPI operations. +-- Router: maps incoming (method, path) to OpenAPI operations. -- Uses lua-resty-radixtree for high-performance path matching. -- Converts OpenAPI path templates ({param}) to radixtree :param syntax. local _M = {} -local _MT = { __index = _M } local radixtree = require("resty.radixtree") -local type = type -local pairs = pairs -local ipairs = ipairs -local insert = table.insert -local find = string.find -local sub = string.sub -local gsub = string.gsub -local byte = string.byte +local setmetatable = setmetatable +local tostring = tostring +local pairs = pairs +local ipairs = ipairs +local tab_insert = table.insert +local str_find = string.find +local sub_str = string.sub +local str_gsub = string.gsub +local str_byte = string.byte +local str_upper = string.upper +local str_gmatch = string.gmatch -local SLASH = byte("/") +local SLASH = str_byte("/") local HTTP_METHODS = { GET = true, POST = true, PUT = true, DELETE = true, PATCH = true, HEAD = true, OPTIONS = true, TRACE = true, } ---- Convert OpenAPI path template to radixtree format. --- e.g. "/users/{id}/posts/{postId}" → "/users/:id/posts/:postId" +local _router_mt = { __index = _M } + + +-- Convert OpenAPI path template to radixtree format. +-- e.g. "/users/{id}/posts/{postId}" -> "/users/:id/posts/:postId" local function convert_path(path_template) - return (gsub(path_template, "{([^}]+)}", ":_%1")) + return (str_gsub(path_template, "{([^}]+)}", ":_%1")) end ---- Extract param names from {param} in path template. + +-- Extract param names from {param} in path template. local function extract_param_names(path_template) local names = {} - for name in path_template:gmatch("{([^}]+)}") do - insert(names, name) + for name in str_gmatch(path_template, "{([^}]+)}") do + tab_insert(names, name) end return names end ---- Collect and organize parameters for an operation. + +-- Collect and organize parameters for an operation. local function collect_params(path_item, operation) local all_params = {} if path_item.parameters then @@ -56,13 +63,14 @@ local function collect_params(path_item, operation) for _, p in pairs(all_params) do local loc = p["in"] if by_loc[loc] then - insert(by_loc[loc], p) + tab_insert(by_loc[loc], p) end end return by_loc end ---- Find request body schema and content map. + +-- Find request body schema and content map. local function find_body_info(operation) if not operation.requestBody then return nil, nil, false @@ -73,19 +81,19 @@ local function find_body_info(operation) return nil, nil, body_required end - -- find the primary schema (prefer JSON) local primary_schema if content["application/json"] then primary_schema = content["application/json"].schema else for ct, media in pairs(content) do - if ct == "*/*" or find(ct, "json") then + if ct == "*/*" or str_find(ct, "json") then primary_schema = media.schema break end end if not primary_schema then - for _, media in pairs(content) do + -- pick the first available schema as fallback + for _, media in pairs(content) do -- luacheck: ignore 512 primary_schema = media.schema break end @@ -95,16 +103,15 @@ local function find_body_info(operation) return primary_schema, content, body_required end ---- Build a router from a compiled OpenAPI spec. --- @param spec table the parsed+normalized spec (with paths) --- @return table router object + +-- Build a router from a compiled OpenAPI spec. function _M.new(spec) local radix_routes = {} - local route_metadata = {} -- id → route detail + local route_metadata = {} local paths = spec.paths if not paths then - return setmetatable({ rx = nil, metadata = route_metadata }, _MT) + return setmetatable({ rx = nil, metadata = route_metadata }, _router_mt) end local route_id = 0 @@ -113,28 +120,29 @@ function _M.new(spec) local param_names = extract_param_names(path_template) for method, operation in pairs(path_item) do - local m = method:upper() + local m = str_upper(method) if HTTP_METHODS[m] then route_id = route_id + 1 local id = tostring(route_id) local params = collect_params(path_item, operation) - local body_schema, body_content, body_required = find_body_info(operation) + local body_schema, body_content, body_required = + find_body_info(operation) route_metadata[id] = { path_template = path_template, - param_names = param_names, - method = m, - operation = operation, - params = params, - body_schema = body_schema, - body_content = body_content, + param_names = param_names, + method = m, + operation = operation, + params = params, + body_schema = body_schema, + body_content = body_content, body_required = body_required, } - insert(radix_routes, { - paths = { radix_path }, - methods = { m }, + tab_insert(radix_routes, { + paths = { radix_path }, + methods = { m }, metadata = id, }) end @@ -142,39 +150,36 @@ function _M.new(spec) end if #radix_routes == 0 then - return setmetatable({ rx = nil, metadata = route_metadata }, _MT) + return setmetatable({ rx = nil, metadata = route_metadata }, _router_mt) end local rx = radixtree.new(radix_routes) - return setmetatable({ rx = rx, metadata = route_metadata }, _MT) + return setmetatable({ rx = rx, metadata = route_metadata }, _router_mt) end ---- Match an incoming request to a route. --- @param method string HTTP method (uppercase) --- @param path string request URI path (without query string) --- @return table|nil matched route (with params, body_schema, etc.) --- @return table|nil extracted path parameters { name = value } + +-- Match an incoming request to a route. function _M.match(self, method, path) if not self.rx then return nil, nil end - method = method:upper() + method = str_upper(method) -- strip query string if present - local qpos = find(path, "?", 1, true) + local qpos = str_find(path, "?", 1, true) if qpos then - path = sub(path, 1, qpos - 1) + path = sub_str(path, 1, qpos - 1) end -- normalize: remove trailing slash (except root) - if #path > 1 and byte(path, #path) == SLASH then - path = sub(path, 1, #path - 1) + if #path > 1 and str_byte(path, #path) == SLASH then + path = sub_str(path, 1, #path - 1) end local matched = {} local opts = { - method = method, + method = method, matched = matched, } diff --git a/rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec b/rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec new file mode 100644 index 0000000..6913357 --- /dev/null +++ b/rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec @@ -0,0 +1,34 @@ +package = "lua-resty-openapi-validator" +version = "master-0.1-0" + +source = { + url = "git+https://github.com/api7/lua-resty-openapi-validator.git", + branch = "main", +} + +description = { + summary = "Pure Lua OpenAPI request validator for OpenResty", + homepage = "https://github.com/api7/lua-resty-openapi-validator", + license = "Apache-2.0", + maintainer = "API7.ai", +} + +dependencies = { + "lua >= 5.1", + "jsonschema", + "lua-resty-radixtree", +} + +build = { + type = "builtin", + modules = { + ["resty.openapi_validator"] = "lib/resty/openapi_validator/init.lua", + ["resty.openapi_validator.loader"] = "lib/resty/openapi_validator/loader.lua", + ["resty.openapi_validator.refs"] = "lib/resty/openapi_validator/refs.lua", + ["resty.openapi_validator.normalize"] = "lib/resty/openapi_validator/normalize.lua", + ["resty.openapi_validator.router"] = "lib/resty/openapi_validator/router.lua", + ["resty.openapi_validator.params"] = "lib/resty/openapi_validator/params.lua", + ["resty.openapi_validator.body"] = "lib/resty/openapi_validator/body.lua", + ["resty.openapi_validator.errors"] = "lib/resty/openapi_validator/errors.lua", + }, +} From 31d0682e76208739a582bfc0da21068689990021 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 15:49:40 +0800 Subject: [PATCH 2/4] fix: address review feedback - router: use case-insensitive content-type matching for JSON detection - readme: remove $ prompts from shell examples (MD014) - rockspec: add lua-cjson to dependencies in both rockspecs --- README.md | 8 ++++---- lib/resty/openapi_validator/router.lua | 4 +++- rockspec/lua-resty-openapi-validator-0.1.0-1.rockspec | 1 + .../lua-resty-openapi-validator-master-0.1-0.rockspec | 1 + 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 553462c..d305cf0 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,10 @@ luarocks install lua-resty-openapi-validator > install by source ```shell -$ git clone https://github.com/api7/lua-resty-openapi-validator.git -$ cd lua-resty-openapi-validator -$ make dev -$ sudo make install +git clone https://github.com/api7/lua-resty-openapi-validator.git +cd lua-resty-openapi-validator +make dev +sudo make install ``` [Back to TOC](#table-of-contents) diff --git a/lib/resty/openapi_validator/router.lua b/lib/resty/openapi_validator/router.lua index f4ae5e9..872b1d0 100644 --- a/lib/resty/openapi_validator/router.lua +++ b/lib/resty/openapi_validator/router.lua @@ -16,6 +16,7 @@ local sub_str = string.sub local str_gsub = string.gsub local str_byte = string.byte local str_upper = string.upper +local str_lower = string.lower local str_gmatch = string.gmatch local SLASH = str_byte("/") @@ -86,7 +87,8 @@ local function find_body_info(operation) primary_schema = content["application/json"].schema else for ct, media in pairs(content) do - if ct == "*/*" or str_find(ct, "json") then + local ct_lower = str_lower(ct) + if ct == "*/*" or str_find(ct_lower, "json", 1, true) then primary_schema = media.schema break end diff --git a/rockspec/lua-resty-openapi-validator-0.1.0-1.rockspec b/rockspec/lua-resty-openapi-validator-0.1.0-1.rockspec index 47c507d..834effa 100644 --- a/rockspec/lua-resty-openapi-validator-0.1.0-1.rockspec +++ b/rockspec/lua-resty-openapi-validator-0.1.0-1.rockspec @@ -21,6 +21,7 @@ dependencies = { "lua >= 5.1", "jsonschema", "lua-resty-radixtree", + "lua-cjson", } build = { diff --git a/rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec b/rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec index 6913357..d6f4fb5 100644 --- a/rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec +++ b/rockspec/lua-resty-openapi-validator-master-0.1-0.rockspec @@ -17,6 +17,7 @@ dependencies = { "lua >= 5.1", "jsonschema", "lua-resty-radixtree", + "lua-cjson", } build = { From bf34ebdad4f1e731c1b035729e3e5630a8a9a12c Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 16:09:25 +0800 Subject: [PATCH 3/4] fix: add lint to CI, rename readOnly/writeOnly to snake_case - Add luacheck lint step to test.yml CI workflow - Rename skip.readOnly -> skip.read_only, skip.writeOnly -> skip.write_only to follow Lua snake_case convention - Update api.md and tests accordingly --- .github/workflows/test.yml | 5 +++++ api.md | 8 ++++---- lib/resty/openapi_validator/init.lua | 8 ++++---- t/conformance/test_issue689.lua | 4 ++-- t/conformance/test_validate_readonly.lua | 2 +- 5 files changed, 16 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6bf5acb..9e1dc0c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,3 +41,8 @@ jobs: run: | export PATH=$OPENRESTY_PREFIX/nginx/sbin:$OPENRESTY_PREFIX/bin:$PATH make test + + - name: Lint + run: | + sudo luarocks install luacheck + make lint diff --git a/api.md b/api.md index a5a8652..a8d9684 100644 --- a/api.md +++ b/api.md @@ -59,8 +59,8 @@ Validates an incoming HTTP request against the compiled OpenAPI spec. Returns - `query`: boolean — skip query parameter validation - `header`: boolean — skip header validation - `body`: boolean — skip request body validation - - `readOnly`: boolean — skip readOnly property checks in request body - - `writeOnly`: boolean — skip writeOnly property checks + - `read_only`: boolean — skip readOnly property checks in request body + - `write_only`: boolean — skip writeOnly property checks ```lua local ok, err = validator:validate_request({ @@ -83,8 +83,8 @@ Skip specific validation: ```lua local ok, err = validator:validate_request(req, { - body = true, -- skip body validation - readOnly = true, -- skip readOnly checks + body = true, -- skip body validation + read_only = true, -- skip readOnly checks }) ``` diff --git a/lib/resty/openapi_validator/init.lua b/lib/resty/openapi_validator/init.lua index 69c08ff..031f693 100644 --- a/lib/resty/openapi_validator/init.lua +++ b/lib/resty/openapi_validator/init.lua @@ -87,11 +87,11 @@ function _validator_mt.validate_request(self, req, skip) if not skip.body then local body_opts = {} - if skip.readOnly ~= nil then - body_opts.exclude_readonly = skip.readOnly + if skip.read_only ~= nil then + body_opts.exclude_readonly = skip.read_only end - if skip.writeOnly ~= nil then - body_opts.exclude_writeonly = skip.writeOnly + if skip.write_only ~= nil then + body_opts.exclude_writeonly = skip.write_only end local body_ok, body_errs = body_mod.validate( diff --git a/t/conformance/test_issue689.lua b/t/conformance/test_issue689.lua index cafc949..2bfba40 100644 --- a/t/conformance/test_issue689.lua +++ b/t/conformance/test_issue689.lua @@ -54,7 +54,7 @@ T.describe("issue689: non read-only property, validation disabled (valid)", func body = '{"testNoReadOnly": true}', content_type = "application/json", headers = { ["content-type"] = "application/json" }, - }, { readOnly = true }) + }, { read_only = true }) T.ok(ok, "should pass: " .. tostring(err)) end) @@ -77,7 +77,7 @@ T.describe("issue689: read-only property, validation disabled (valid)", function body = '{"testWithReadOnly": true}', content_type = "application/json", headers = { ["content-type"] = "application/json" }, - }, { readOnly = true }) + }, { read_only = true }) T.ok(ok, "should pass: " .. tostring(err)) end) diff --git a/t/conformance/test_validate_readonly.lua b/t/conformance/test_validate_readonly.lua index e832027..9c8f476 100644 --- a/t/conformance/test_validate_readonly.lua +++ b/t/conformance/test_validate_readonly.lua @@ -60,7 +60,7 @@ end) T.describe("readOnly in request with skip.readOnly: should pass", function() local v = ov.compile(make_spec("readOnly")) - local ok, err = v:validate_request(req, { readOnly = true }) + local ok, err = v:validate_request(req, { read_only = true }) T.ok(ok, "readOnly skipped, should pass: " .. tostring(err)) end) From aa7be90543ed54eed69f1452559aa4f2744afeef Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 16:14:23 +0800 Subject: [PATCH 4/4] fix: update test description to match renamed skip.read_only key --- t/conformance/test_validate_readonly.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/t/conformance/test_validate_readonly.lua b/t/conformance/test_validate_readonly.lua index 9c8f476..25f1a01 100644 --- a/t/conformance/test_validate_readonly.lua +++ b/t/conformance/test_validate_readonly.lua @@ -58,7 +58,7 @@ T.describe("readOnly in request: should fail", function() T.like(err, "readOnly", "error should mention readOnly") end) -T.describe("readOnly in request with skip.readOnly: should pass", function() +T.describe("readOnly in request with skip.read_only: should pass", function() local v = ov.compile(make_spec("readOnly")) local ok, err = v:validate_request(req, { read_only = true }) T.ok(ok, "readOnly skipped, should pass: " .. tostring(err))