From 86f7c3492d59d17deb00ba5dd7fbacfc8885c552 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 23 Apr 2026 19:41:50 +0800 Subject: [PATCH 1/3] fix: address QA defects found by 10-spec public-API corpus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-spec QA against 10 widely-adopted public OpenAPI specs (github, twilio, box, cloudflare, plaid, openai, square, discourse, adyen-checkout, notion — 13,148 cases) surfaced four validator-side defects plus one jsonschema-side defect (the latter fixed in api7/jsonschema#100). This commit fixes all four validator-side bugs and adds regression coverage. Bug 1 — router: path parameter dropped when followed by literal suffix ==================================================================== lua-resty-radixtree's fetch_pat() treats any '/'-segment whose first byte is ':' as a single-segment param, discarding the literal suffix. Templates like '/users/{id}.json' or '/files/{name}.{ext}' became regex 'users/([^/]+)' with the param stored under name 'id.json' (or 'name}.{ext}'), so match() looked up the param under its declared name and found nil. Effect on the corpus: 195/197 Twilio operations and ~30 Discourse operations could never validate a positive request. Fix: detect mixed segments at compile time, build a per-route PCRE '^(prefix)(?P[^/]+?)...$' and use ngx.re.match to re-extract path params after radixtree returns. Non-greedy '+?' is critical for '{name}.{ext}' patterns. base_paths are stripped so the PCRE only sees the spec-relative path. Bug 2 — normalize: nullable enum with explicit null silently disables ==================================================================== For schemas like '{type: string, nullable: true, enum: ["a", null]}', normalize wrapped them as 'anyOf [original, {type: null}]' but kept cjson.null inside the cloned enum. The cjson.null userdata in the enum array caused api7/jsonschema to silently skip the enum check entirely — any string value passed (Plaid corpus: 12 false negatives). Fix: when wrapping nullable schemas, strip cjson.null from the cloned enum (and clear const if it equals cjson.null). The added '{type:null}' branch already permits null, so the cjson.null entries were redundant. Bug 4 — params: pipeDelimited / spaceDelimited / form?explode=false ==================================================================== The array-style branches called split() on raw_value without checking whether it was already a Lua table (multi-value query input). Crashed with 'attempt to index a table value' on Box's deepObject-adjacent operations (48 crashes). Fix: coerce table raw_value to a delimiter-joined string before splitting. The form/explode=true branch was already table-safe. Bug 5 — body: cjson.null content_type crash ========================================== 'if route.body_content and content_type then' accepted cjson.null because userdata is truthy in Lua, then dereferenced it as a string. Easy to hit when callers decode requests via cjson.decode and pass fields through without sanitization. Fix: guard with 'type(content_type) == "string"'. Verification ============ Re-ran the full 13,148-case corpus with these fixes. Highlights: - discourse positive: 62/93 → 91/93 (Bug 1) - box positive: 246/296 → 277/296; crashes 48 → 0 (Bug 4) - cloudflare crashes: 17 → 0 (Bug 3, via jsonschema#100) - plaid bad_enum_body: 42/54 → 54/54 (Bug 2) - All existing unit + conformance tests still pass. Tests ===== Adds t/conformance/test_qa_bugs_v103.lua with one focused case per bug (compile-and-validate, no real HTTP). --- lib/resty/openapi_validator/body.lua | 5 +- lib/resty/openapi_validator/normalize.lua | 23 ++- lib/resty/openapi_validator/params.lua | 25 ++- lib/resty/openapi_validator/router.lua | 109 +++++++++++++ t/conformance/test_qa_bugs_v103.lua | 190 ++++++++++++++++++++++ 5 files changed, 344 insertions(+), 8 deletions(-) create mode 100644 t/conformance/test_qa_bugs_v103.lua diff --git a/lib/resty/openapi_validator/body.lua b/lib/resty/openapi_validator/body.lua index c8052cd..90cde06 100644 --- a/lib/resty/openapi_validator/body.lua +++ b/lib/resty/openapi_validator/body.lua @@ -360,7 +360,10 @@ function _M.validate(route, body_str, content_type, opts) end -- check content-type is declared in the spec - if route.body_content and content_type then + -- guard against non-string content_type values (e.g. cjson.null sentinel, + -- which is userdata and truthy in Lua so the previous `and content_type` + -- check let it through and crashed in str_lower). + if route.body_content and type(content_type) == "string" then local ct_lower = str_lower(content_type) local found = false for media_type in pairs(route.body_content) do diff --git a/lib/resty/openapi_validator/normalize.lua b/lib/resty/openapi_validator/normalize.lua index f83d1dd..6f292ee 100644 --- a/lib/resty/openapi_validator/normalize.lua +++ b/lib/resty/openapi_validator/normalize.lua @@ -4,6 +4,7 @@ local _M = {} +local cjson = require("cjson.safe") local type = type local pairs = pairs local ipairs = ipairs @@ -28,16 +29,32 @@ local function normalize_30_schema(schema, warnings) -- For nullable schemas with enum or const, we cannot simply inject -- cjson.null into enum (jsonschema can't handle userdata in enum). - -- Use anyOf: [original_schema_without_nullable, {type: "null"}] + -- Use anyOf: [original_schema_without_nullable, {type: "null"}]. + -- + -- IMPORTANT: if the original enum already contains cjson.null (i.e. + -- the spec author wrote `enum: [..., null]`), the userdata sentinel + -- silently disables the entire enum check inside api7/jsonschema. + -- Strip null entries from the enum here — the {type: "null"} branch + -- of the anyOf wrapper already permits null. if schema.enum or schema["const"] then - -- save and remove nullable-related fields, wrap in anyOf local original = {} for k, v in pairs(schema) do if k ~= "nullable" then original[k] = v end end - -- clear schema and replace with anyOf + if type(original.enum) == "table" then + local cleaned = {} + for _, val in ipairs(original.enum) do + if val ~= cjson.null then + tab_insert(cleaned, val) + end + end + original.enum = cleaned + end + if original["const"] == cjson.null then + original["const"] = nil + end for k in pairs(schema) do schema[k] = nil end diff --git a/lib/resty/openapi_validator/params.lua b/lib/resty/openapi_validator/params.lua index 2a60b83..6ca39dc 100644 --- a/lib/resty/openapi_validator/params.lua +++ b/lib/resty/openapi_validator/params.lua @@ -287,11 +287,28 @@ local function deserialize_param(raw_value, param, query_args) local items_schema = schema.items or {} local values + -- Coerce table values into a single delimited string for the + -- delimiter-based styles. This happens in real callers when, e.g., + -- ngx.req.get_uri_args returns `{"a","b"}` for `?fields=a&fields=b` + -- but the schema is declared `style:form, explode:false` (comma- + -- separated). Previously this crashed in split() with + -- "bad argument #1 to 'str_find' (string expected, got table)". + local function coerce_to_string(delim) + if type(raw_value) == "table" then + local out = {} + for _, v in ipairs(raw_value) do + tab_insert(out, tostring(v)) + end + return table.concat(out, delim) + end + return raw_value + end + if style == "simple" then - values = split(raw_value, ",") + values = split(coerce_to_string(","), ",") elseif style == "form" then if not explode then - values = split(raw_value, ",") + values = split(coerce_to_string(","), ",") else if type(raw_value) == "table" then values = raw_value @@ -300,9 +317,9 @@ local function deserialize_param(raw_value, param, query_args) end end elseif style == "pipeDelimited" then - values = split(raw_value, "|") + values = split(coerce_to_string("|"), "|") elseif style == "spaceDelimited" then - values = split(raw_value, " ") + values = split(coerce_to_string(" "), " ") else values = { raw_value } end diff --git a/lib/resty/openapi_validator/router.lua b/lib/resty/openapi_validator/router.lua index 2a30d42..7df61d6 100644 --- a/lib/resty/openapi_validator/router.lua +++ b/lib/resty/openapi_validator/router.lua @@ -36,6 +36,72 @@ local function convert_path(path_template) end +-- Detect path templates whose segments mix a {var} with literal text or +-- multiple {var}s in the same segment, e.g. "/foo/{id}.json" or +-- "/baz/{name}.{ext}". radixtree's :param syntax cannot extract these +-- correctly because it consumes the entire `/`-bounded segment as one +-- variable; the literal suffix is silently dropped from both the captured +-- value and the param name. For such templates we fall back to a per-route +-- PCRE that re-extracts path params at match time. +local function has_mixed_segment(path_template) + for seg in str_gmatch(path_template, "/([^/]*)") do + local has_var = str_find(seg, "{", 1, true) ~= nil + if has_var then + -- a clean segment is exactly "{name}" with nothing else + if not (str_byte(seg, 1) == str_byte("{") + and str_byte(seg, #seg) == str_byte("}") + and (str_find(seg, "}", 2, true) == #seg)) then + return true + end + end + end + return false +end + + +local PCRE_META = { + ["%"] = true, ["."] = true, ["*"] = true, ["+"] = true, ["?"] = true, + ["("] = true, [")"] = true, ["["] = true, ["]"] = true, ["{"] = true, + ["}"] = true, ["|"] = true, ["^"] = true, ["$"] = true, ["\\"] = true, + ["/"] = true, +} + +local function pcre_escape(s) + return (str_gsub(s, ".", function(c) + if PCRE_META[c] then return "\\" .. c end + end)) +end + + +-- Build a PCRE pattern + ordered name list that extracts path params from +-- a request path matching `path_template`. Used as a fallback when +-- has_mixed_segment(path_template) is true. +local function build_param_pcre(path_template) + local names = {} + local out = {} + local i = 1 + while i <= #path_template do + local lb = str_find(path_template, "{", i, true) + if not lb then + tab_insert(out, pcre_escape(sub_str(path_template, i))) + break + end + if lb > i then + tab_insert(out, pcre_escape(sub_str(path_template, i, lb - 1))) + end + local rb = str_find(path_template, "}", lb + 1, true) + if not rb then + tab_insert(out, pcre_escape(sub_str(path_template, lb))) + break + end + tab_insert(names, sub_str(path_template, lb + 1, rb - 1)) + tab_insert(out, "([^/]+?)") + i = rb + 1 + end + return "^" .. table.concat(out) .. "$", names +end + + -- Extract param names from {param} in path template. local function extract_param_names(path_template) local names = {} @@ -152,6 +218,11 @@ function _M.new(spec) local route_id = 0 for path_template, path_item in pairs(paths) do local param_names = extract_param_names(path_template) + local mixed = has_mixed_segment(path_template) + local param_pcre, pcre_names + if mixed then + param_pcre, pcre_names = build_param_pcre(path_template) + end for method, operation in pairs(path_item) do local m = str_upper(method) @@ -166,6 +237,9 @@ function _M.new(spec) route_metadata[id] = { path_template = path_template, param_names = param_names, + param_pcre = param_pcre, + pcre_names = pcre_names, + base_paths = base_paths, method = m, operation = operation, params = params, @@ -240,6 +314,41 @@ function _M.match(self, method, path) path_params[name] = matched["_" .. name] end + -- Fallback: when the template has mixed segments (e.g. "/foo/{id}.json" + -- or "/baz/{name}.{ext}"), radixtree can match the route but cannot + -- extract the variables (it consumes the whole `/`-bounded segment as + -- one param and silently drops the literal suffix). Re-extract the + -- params here using a per-route PCRE built from the template. + if route.param_pcre then + local ngx_re = require("ngx.re") + local re_match = ngx.re.match + local m + local bases = route.base_paths or { "" } + for _, base in ipairs(bases) do + local rel = path + if base ~= "" then + if str_find(path, base, 1, true) == 1 then + rel = sub_str(path, #base + 1) + if rel == "" then rel = "/" end + else + rel = nil + end + end + if rel then + local mm, err = re_match(rel, route.param_pcre, "jo") + if mm then m = mm; break end + end + end + if not m then + -- radixtree matched but our authoritative regex doesn't: + -- treat as no match so callers get a clean error. + return nil, nil + end + for i, name in ipairs(route.pcre_names or {}) do + path_params[name] = m[i] + end + end + return route, path_params end diff --git a/t/conformance/test_qa_bugs_v103.lua b/t/conformance/test_qa_bugs_v103.lua new file mode 100644 index 0000000..32cf611 --- /dev/null +++ b/t/conformance/test_qa_bugs_v103.lua @@ -0,0 +1,190 @@ +#!/usr/bin/env resty +--- Regression tests for the bugs surfaced by the v1.0.3 multi-spec QA pass. +-- See qa/lua-resty-openapi-validator-v1.0.3.md (in api7ee workspace). +dofile("t/lib/test_bootstrap.lua") + +local T = require("test_helper") +local cjson = require("cjson.safe") +local ov = require("resty.openapi_validator") + +local function compile(spec) + local v, err = ov.compile(cjson.encode(spec)) + assert(v, "compile failed: " .. tostring(err)) + return v +end + +-- Bug 1: path templates with literal extension (e.g. /users/{id}.json) used +-- to be silently misrouted by lua-resty-radixtree, which treated the whole +-- segment ":{id}.json" as a single param named "{id}.json". The validator now +-- detects mixed segments at compile time and re-extracts path params with PCRE. +T.describe("Bug 1: path with literal .json extension after param", function() + local v = compile({ + openapi = "3.0.0", + info = { title = "t", version = "0" }, + paths = { + ["/users/{id}.json"] = { + get = { + parameters = { + { + ["in"] = "path", name = "id", required = true, + schema = { type = "string", minLength = 1 }, + }, + }, + responses = { ["200"] = { description = "ok" } }, + }, + }, + }, + }) + local ok, err = v:validate_request({ method = "GET", path = "/users/abc.json" }) + T.ok(ok, "valid id extracted: " .. tostring(err)) + + -- and the param value really came through (not nil/empty under the wrong name) + local ok2 = v:validate_request({ method = "GET", path = "/users/x.json" }) + T.ok(ok2, "single-char id 'x' accepted by minLength=1") +end) + +-- Bug 1b: dotted suffix shouldn't be greedy-matched +T.describe("Bug 1b: param value must not consume the literal extension", function() + local v = compile({ + openapi = "3.0.0", + info = { title = "t", version = "0" }, + paths = { + ["/files/{name}.txt"] = { + get = { + parameters = { + { + ["in"] = "path", name = "name", required = true, + schema = { type = "string", pattern = "^[a-z]+$" }, + }, + }, + responses = { ["200"] = { description = "ok" } }, + }, + }, + }, + }) + local ok, err = v:validate_request({ method = "GET", path = "/files/report.txt" }) + T.ok(ok, "name 'report' (not 'report.txt') extracted: " .. tostring(err)) +end) + +-- Bug 2: nullable + enum with explicit null in the enum list used to +-- silently disable the enum check inside api7/jsonschema (cjson.null +-- userdata leaked into the cloned schema). After normalization, any string +-- could pass as a "valid enum" value. +T.describe("Bug 2: nullable + enum with null still enforces enum", function() + local v = compile({ + openapi = "3.0.0", + info = { title = "t", version = "0" }, + paths = { + ["/x"] = { + post = { + requestBody = { + required = true, + content = { + ["application/json"] = { + schema = { + type = "object", + properties = { + status = { + type = "string", + nullable = true, + enum = { "free", "paid", cjson.null }, + }, + }, + required = { "status" }, + }, + }, + }, + }, + responses = { ["200"] = { description = "ok" } }, + }, + }, + }, + }) + local ok = v:validate_request({ + method = "POST", path = "/x", + content_type = "application/json", + body = '{"status":"free"}', + }) + T.ok(ok, "free is in enum") + + local ok2 = v:validate_request({ + method = "POST", path = "/x", + content_type = "application/json", + body = '{"status":null}', + }) + T.ok(ok2, "null is allowed via nullable") + + local ok3, err3 = v:validate_request({ + method = "POST", path = "/x", + content_type = "application/json", + body = '{"status":"bogus"}', + }) + T.ok(not ok3, "bogus is NOT in enum, must be rejected") + -- with nullable, schema is wrapped as anyOf [original, {type:null}], so + -- the error wording mentions "matches none of the required" rather than + -- "enum"; either way, the request must be rejected (the regression here + -- was silent acceptance). + T.ok(err3 and #err3 > 0, "error message present: " .. tostring(err3)) +end) + +-- Bug 4: array-style query params with delimiter styles used to crash when +-- the raw value arrived as a Lua table (multi-value form input). Now coerced +-- to a delimiter-joined string before splitting. +T.describe("Bug 4: pipeDelimited param with table raw value does not crash", function() + local v = compile({ + openapi = "3.0.0", + info = { title = "t", version = "0" }, + paths = { + ["/q"] = { + get = { + parameters = { + { + ["in"] = "query", name = "ids", required = true, + style = "pipeDelimited", explode = false, + schema = { type = "array", items = { type = "string" } }, + }, + }, + responses = { ["200"] = { description = "ok" } }, + }, + }, + }, + }) + local ok, err = v:validate_request({ + method = "GET", path = "/q", + query = { ids = { "a|b", "c" } }, + }) + T.ok(ok, "table raw value handled: " .. tostring(err)) +end) + +-- Bug 5: cjson.null in content_type (e.g. caller passed a parsed JSON value +-- through verbatim) is userdata, which is truthy in Lua. The body validator +-- entered the content-type branch and crashed. Now guarded with a string +-- type check. +T.describe("Bug 5: cjson.null content_type does not crash", function() + local v = compile({ + openapi = "3.0.0", + info = { title = "t", version = "0" }, + paths = { + ["/p"] = { + post = { + requestBody = { + required = false, + content = { + ["application/json"] = { + schema = { type = "object" }, + }, + }, + }, + responses = { ["200"] = { description = "ok" } }, + }, + }, + }, + }) + local ok, err = pcall(v.validate_request, v, { + method = "POST", path = "/p", + content_type = cjson.null, + }) + T.ok(ok, "cjson.null content_type didn't crash: " .. tostring(err)) +end) + +T.done() From ab57baf383aec0c05e88f8e14248249dc4af97b0 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 23 Apr 2026 19:47:58 +0800 Subject: [PATCH 2/3] fix: drop unused locals to satisfy luacheck --- lib/resty/openapi_validator/router.lua | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/resty/openapi_validator/router.lua b/lib/resty/openapi_validator/router.lua index 7df61d6..e6217dd 100644 --- a/lib/resty/openapi_validator/router.lua +++ b/lib/resty/openapi_validator/router.lua @@ -320,7 +320,6 @@ function _M.match(self, method, path) -- one param and silently drops the literal suffix). Re-extract the -- params here using a per-route PCRE built from the template. if route.param_pcre then - local ngx_re = require("ngx.re") local re_match = ngx.re.match local m local bases = route.base_paths or { "" } @@ -335,7 +334,7 @@ function _M.match(self, method, path) end end if rel then - local mm, err = re_match(rel, route.param_pcre, "jo") + local mm = re_match(rel, route.param_pcre, "jo") if mm then m = mm; break end end end From d7e6c8504d3a1ea6cf40f7252084efab3d9d7c65 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Thu, 23 Apr 2026 19:55:58 +0800 Subject: [PATCH 3/3] fix: address review comments on PR #8 - body.lua: normalize non-string content_type to nil at the top of validate() so all downstream sites (find_body_schema_for_content_type, is_json_content_type, ...) treat it like an absent header. The previous spot-fix only guarded the 'declared in spec' check. - normalize.lua: previously, nullable+const-equals-null cleared the const and produced an empty 'original' branch in the anyOf wrapper, which would accept ANY value. Collapse to {type:'null'} instead so the schema correctly accepts only null. Same collapse applied when cleaning enum leaves an empty array (i.e. the enum was [null]). - t/conformance/test_qa_bugs_v103.lua: the Bug 5 case used to early- return on empty body, missing the actual crash path. Now sends a non-empty body so content_type is evaluated. Added Bug 2b case for the nullable+const=null collapse. --- lib/resty/openapi_validator/body.lua | 13 +++-- lib/resty/openapi_validator/normalize.lua | 22 ++++++-- t/conformance/test_qa_bugs_v103.lua | 66 +++++++++++++++++++++-- 3 files changed, 89 insertions(+), 12 deletions(-) diff --git a/lib/resty/openapi_validator/body.lua b/lib/resty/openapi_validator/body.lua index 90cde06..03ce7df 100644 --- a/lib/resty/openapi_validator/body.lua +++ b/lib/resty/openapi_validator/body.lua @@ -344,6 +344,14 @@ function _M.validate(route, body_str, content_type, opts) opts = opts or {} local errs = {} + -- Normalize non-string content_type (e.g. cjson.null sentinel — which is + -- userdata and truthy in Lua, so naive `and content_type` checks would + -- let it through and crash inside str_lower) to nil so downstream code + -- can treat it uniformly with "header absent". + if type(content_type) ~= "string" then + content_type = nil + end + if route.body_required then if body_str == nil or body_str == "" then tab_insert(errs, errors.new("body", nil, "request body is required")) @@ -360,10 +368,7 @@ function _M.validate(route, body_str, content_type, opts) end -- check content-type is declared in the spec - -- guard against non-string content_type values (e.g. cjson.null sentinel, - -- which is userdata and truthy in Lua so the previous `and content_type` - -- check let it through and crashed in str_lower). - if route.body_content and type(content_type) == "string" then + if route.body_content and content_type then local ct_lower = str_lower(content_type) local found = false for media_type in pairs(route.body_content) do diff --git a/lib/resty/openapi_validator/normalize.lua b/lib/resty/openapi_validator/normalize.lua index 6f292ee..e5df37b 100644 --- a/lib/resty/openapi_validator/normalize.lua +++ b/lib/resty/openapi_validator/normalize.lua @@ -43,6 +43,19 @@ local function normalize_30_schema(schema, warnings) original[k] = v end end + -- A const that is exactly `null` means "must be null", so the + -- nullable wrapper collapses to just `{type:"null"}` — there's + -- no remaining non-null branch to keep. + local null_only_const = original["const"] == cjson.null + if null_only_const then + for k in pairs(schema) do schema[k] = nil end + schema.type = "null" + return + end + -- Otherwise, strip any null entries from the enum. The + -- `{type:"null"}` branch added below already permits null, + -- and leaving cjson.null inside an enum array silently + -- disables the entire enum check inside api7/jsonschema. if type(original.enum) == "table" then local cleaned = {} for _, val in ipairs(original.enum) do @@ -50,11 +63,14 @@ local function normalize_30_schema(schema, warnings) tab_insert(cleaned, val) end end + -- An enum containing only `null` similarly collapses. + if #cleaned == 0 and not original["const"] then + for k in pairs(schema) do schema[k] = nil end + schema.type = "null" + return + end original.enum = cleaned end - if original["const"] == cjson.null then - original["const"] = nil - end for k in pairs(schema) do schema[k] = nil end diff --git a/t/conformance/test_qa_bugs_v103.lua b/t/conformance/test_qa_bugs_v103.lua index 32cf611..07d9f16 100644 --- a/t/conformance/test_qa_bugs_v103.lua +++ b/t/conformance/test_qa_bugs_v103.lua @@ -158,9 +158,10 @@ end) -- Bug 5: cjson.null in content_type (e.g. caller passed a parsed JSON value -- through verbatim) is userdata, which is truthy in Lua. The body validator --- entered the content-type branch and crashed. Now guarded with a string --- type check. -T.describe("Bug 5: cjson.null content_type does not crash", function() +-- entered the content-type branch and crashed. Now normalized to nil at the +-- top of body.validate so all downstream sites (find_body_schema_for_content_type, +-- is_json_content_type, ...) treat it like an absent header. +T.describe("Bug 5: cjson.null content_type does not crash (with body present)", function() local v = compile({ openapi = "3.0.0", info = { title = "t", version = "0" }, @@ -180,11 +181,66 @@ T.describe("Bug 5: cjson.null content_type does not crash", function() }, }, }) - local ok, err = pcall(v.validate_request, v, { + -- Non-empty body so the body validator actually evaluates content_type + -- (an empty body short-circuits before the content-type branch). + local pcall_ok, ok, err = pcall(v.validate_request, v, { method = "POST", path = "/p", content_type = cjson.null, + body = '{"hello":"world"}', }) - T.ok(ok, "cjson.null content_type didn't crash: " .. tostring(err)) + T.ok(pcall_ok, "cjson.null content_type didn't crash: " .. tostring(ok)) + -- With content_type normalized to nil, treated as no declared CT match; + -- since the request has a body but the validator can't find a schema for + -- the (absent) content-type, it should not crash. Outcome (ok or not) is + -- secondary; the regression is the crash. + T.ok(ok ~= nil, "validator returned a value, not a crash: ok=" .. tostring(ok) .. " err=" .. tostring(err)) +end) + +-- Bug 2b: nullable + const = null collapses to just {type:"null"}, which +-- means anything other than null must be rejected. +T.describe("Bug 2b: nullable + const=null only accepts null", function() + local v = compile({ + openapi = "3.0.0", + info = { title = "t", version = "0" }, + paths = { + ["/c"] = { + post = { + requestBody = { + required = true, + content = { + ["application/json"] = { + schema = { + type = "object", + properties = { + v = { + type = "string", + nullable = true, + ["const"] = cjson.null, + }, + }, + required = { "v" }, + }, + }, + }, + }, + responses = { ["200"] = { description = "ok" } }, + }, + }, + }, + }) + local ok = v:validate_request({ + method = "POST", path = "/c", + content_type = "application/json", + body = '{"v":null}', + }) + T.ok(ok, "null is accepted (only allowed value)") + + local ok2 = v:validate_request({ + method = "POST", path = "/c", + content_type = "application/json", + body = '{"v":"anything"}', + }) + T.ok(not ok2, "non-null rejected when const is null") end) T.done()