From 54a1348e99ce5f65c27ee2c73ebefbbe5cbd3196 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 21:48:16 +0800 Subject: [PATCH 1/4] fix(params): handle deepObject style with anyOf/oneOf schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, query params with style=deepObject and a top-level anyOf or oneOf schema (no top-level type:object) were always rejected, even when optional and absent. The deserialize_param object branch only triggers when stype == 'object', so anyOf schemas fell through to coerce_value on the placeholder string and failed the anyOf. This affects common real-world specs — e.g. Stripe's range_query_specs (created, transacted_at, arrival_date, ...) accept either a Unix timestamp (integer) or a {gt, gte, lt, lte} object via deepObject style. 50 Stripe operations were affected. Fix: when style=deepObject and the schema has anyOf/oneOf, try parse_deep_object against each object branch first; if no param[...] keys are present, try a scalar branch against the bare param value. Adds three regression tests covering: missing optional, object-form deepObject, and integer-form scalar. --- lib/resty/openapi_validator/params.lua | 31 ++++++++++++++ t/unit/test_params.lua | 57 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/lib/resty/openapi_validator/params.lua b/lib/resty/openapi_validator/params.lua index 7a421de..2bd5a57 100644 --- a/lib/resty/openapi_validator/params.lua +++ b/lib/resty/openapi_validator/params.lua @@ -255,6 +255,37 @@ local function deserialize_param(raw_value, param, query_args) local stype = schema.type + -- deepObject style with anyOf/oneOf: try the object branch via parse_deep_object; + -- if no param[...] keys are present, try a scalar branch against the bare param value. + if style == "deepObject" and stype ~= "object" + and (schema.anyOf or schema.oneOf) then + local branches = schema.anyOf or schema.oneOf + for _, branch in ipairs(branches) do + if branch.type == "object" then + local obj = parse_deep_object(param.name, query_args or {}, branch) + if obj ~= nil then + return obj + end + end + end + local scalar_raw = (query_args or {})[param.name] + if scalar_raw ~= nil then + if type(scalar_raw) == "table" then + scalar_raw = scalar_raw[1] + end + for _, branch in ipairs(branches) do + if branch.type and branch.type ~= "object" then + local v = coerce_value(scalar_raw, branch) + if v ~= nil then + return v + end + end + end + return scalar_raw + end + return nil + end + if stype == "array" then local items_schema = schema.items or {} local values diff --git a/t/unit/test_params.lua b/t/unit/test_params.lua index ce6e878..f02a098 100644 --- a/t/unit/test_params.lua +++ b/t/unit/test_params.lua @@ -162,4 +162,61 @@ T.describe("params: error format", function() T.like(formatted, "path parameter 'id'", "format includes path param") end) +-- Test: deepObject query param with anyOf {object, integer} schema. +-- Common in real-world specs (e.g. Stripe range_query_specs: created, etc.) +-- where a filter accepts either a Unix timestamp or {gt, gte, lt, lte} object. +T.describe("params: deepObject anyOf optional missing", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { anyOf = { + { type = "object", properties = { + gt = { type = "integer" }, gte = { type = "integer" }, + lt = { type = "integer" }, lte = { type = "integer" }, + } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, {}, {}, {}) + T.ok(ok, "missing optional deepObject anyOf accepted") + T.ok(not errs or #errs == 0, "no errors") +end) + +T.describe("params: deepObject anyOf object branch", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { anyOf = { + { type = "object", properties = { + gt = { type = "integer" }, lte = { type = "integer" }, + } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, + {}, { ["created[gt]"] = "1700000000", ["created[lte]"] = "1800000000" }, {}) + T.ok(ok, "deepObject object form accepted") + T.ok(not errs or #errs == 0, "no errors") +end) + +T.describe("params: deepObject anyOf integer branch", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { anyOf = { + { type = "object", properties = { + gt = { type = "integer" }, + } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, + {}, { ["created"] = "1700000000" }, {}) + T.ok(ok, "deepObject scalar (integer branch) accepted") + T.ok(not errs or #errs == 0, "no errors") +end) + T.done() From ff1fab351719ca45cfe4ef8eb45f251134b72404 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 22:02:23 +0800 Subject: [PATCH 2/4] address review: simplify scalar branch + add oneOf and negative regression tests - Replace the per-branch coerce_value loop with a single coerce_value call against the full anyOf/oneOf schema. coerce_value returns the input as-is on failed coercion, so picking the first 'non-nil' result was meaningless; the downstream jsonschema validator already evaluates every branch. - Add deepObject + oneOf object/integer regression tests (mirror anyOf). - Add negative regression: deepObject anyOf with an unmatched scalar value must be rejected. --- lib/resty/openapi_validator/params.lua | 14 ++----- t/unit/test_params.lua | 53 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/lib/resty/openapi_validator/params.lua b/lib/resty/openapi_validator/params.lua index 2bd5a57..459173c 100644 --- a/lib/resty/openapi_validator/params.lua +++ b/lib/resty/openapi_validator/params.lua @@ -256,7 +256,9 @@ local function deserialize_param(raw_value, param, query_args) local stype = schema.type -- deepObject style with anyOf/oneOf: try the object branch via parse_deep_object; - -- if no param[...] keys are present, try a scalar branch against the bare param value. + -- if no param[...] keys are present, coerce the bare param value against the + -- full schema (collect_types handles anyOf/oneOf branches) and let the + -- downstream jsonschema validator pick the matching branch. if style == "deepObject" and stype ~= "object" and (schema.anyOf or schema.oneOf) then local branches = schema.anyOf or schema.oneOf @@ -273,15 +275,7 @@ local function deserialize_param(raw_value, param, query_args) if type(scalar_raw) == "table" then scalar_raw = scalar_raw[1] end - for _, branch in ipairs(branches) do - if branch.type and branch.type ~= "object" then - local v = coerce_value(scalar_raw, branch) - if v ~= nil then - return v - end - end - end - return scalar_raw + return coerce_value(scalar_raw, schema) end return nil end diff --git a/t/unit/test_params.lua b/t/unit/test_params.lua index f02a098..db33f98 100644 --- a/t/unit/test_params.lua +++ b/t/unit/test_params.lua @@ -219,4 +219,57 @@ T.describe("params: deepObject anyOf integer branch", function() T.ok(not errs or #errs == 0, "no errors") end) +-- Same shape as the anyOf cases but using oneOf, to lock in both branches +-- of the runtime path. +T.describe("params: deepObject oneOf object branch", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { oneOf = { + { type = "object", properties = { + gt = { type = "integer" }, lte = { type = "integer" }, + } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, + {}, { ["created[gt]"] = "1700000000" }, {}) + T.ok(ok, "deepObject oneOf object form accepted") + T.ok(not errs or #errs == 0, "no errors") +end) + +T.describe("params: deepObject oneOf integer branch", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { oneOf = { + { type = "object", properties = { gt = { type = "integer" } } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, + {}, { ["created"] = "1700000000" }, {}) + T.ok(ok, "deepObject oneOf scalar (integer branch) accepted") + T.ok(not errs or #errs == 0, "no errors") +end) + +-- Negative: a value that matches none of the anyOf branches must be rejected. +T.describe("params: deepObject anyOf rejects unmatched scalar", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { anyOf = { + { type = "object", properties = { gt = { type = "integer" } } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, + {}, { ["created"] = "not-a-number" }, {}) + T.ok(not ok, "non-integer scalar rejected") + T.ok(errs and #errs >= 1, "error reported") +end) + T.done() From 8d3c87bb8505b8f9b685b732e60fdcde0aa6e37e Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 22:09:23 +0800 Subject: [PATCH 3/4] address review: recognise object branches with type union (e.g. {object,null}) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit pointed out that 'branch.type == "object"' misses union type arrays produced by nullable normalisation (type = {"object", "null"}). Added a small helper that treats branch.type as either a string or a list. Added a regression test covering the nullable object branch. (Did not adopt the proposal to per-branch validate and pick the matching object branch: parse_deep_object is schema-agnostic about which keys it collects, so all object branches see the same parsed object, and the downstream jsonschema validator on the full anyOf/oneOf already evaluates every branch. Branch order only affects per-property type coercion via coerce_object_values, and in practice deepObject filters use uniform property types — adding per-branch jsonschema validation here would duplicate work without changing acceptance behaviour.) --- lib/resty/openapi_validator/params.lua | 12 +++++++++++- t/unit/test_params.lua | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/resty/openapi_validator/params.lua b/lib/resty/openapi_validator/params.lua index 459173c..a5a6421 100644 --- a/lib/resty/openapi_validator/params.lua +++ b/lib/resty/openapi_validator/params.lua @@ -262,8 +262,18 @@ local function deserialize_param(raw_value, param, query_args) if style == "deepObject" and stype ~= "object" and (schema.anyOf or schema.oneOf) then local branches = schema.anyOf or schema.oneOf + local function branch_has_object(b) + local bt = b and b.type + if bt == "object" then return true end + if type(bt) == "table" then + for _, t in ipairs(bt) do + if t == "object" then return true end + end + end + return false + end for _, branch in ipairs(branches) do - if branch.type == "object" then + if branch_has_object(branch) then local obj = parse_deep_object(param.name, query_args or {}, branch) if obj ~= nil then return obj diff --git a/t/unit/test_params.lua b/t/unit/test_params.lua index db33f98..1a8f576 100644 --- a/t/unit/test_params.lua +++ b/t/unit/test_params.lua @@ -272,4 +272,24 @@ T.describe("params: deepObject anyOf rejects unmatched scalar", function() T.ok(errs and #errs >= 1, "error reported") end) +-- Union type arrays from nullable normalization, e.g. type = {"object","null"}, +-- must still be recognised as an object branch. +T.describe("params: deepObject anyOf nullable object branch", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { anyOf = { + { type = { "object", "null" }, properties = { + gt = { type = "integer" }, + } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, + {}, { ["created[gt]"] = "1700000000" }, {}) + T.ok(ok, "nullable object branch parsed") + T.ok(not errs or #errs == 0, "no errors") +end) + T.done() From 28fca89cbef1f5db029c92f96b75c995aef8a6af Mon Sep 17 00:00:00 2001 From: Jarvis Date: Tue, 21 Apr 2026 22:21:17 +0800 Subject: [PATCH 4/4] address review: detect object branches via collect_types CodeRabbit pointed out that the local branch_has_object helper would miss object branches expressed via composition (e.g. anyOf:[{allOf:[{type:object,...}]}, ...]). Replaced the ad-hoc check with collect_types(b)['object'], which already recursively walks anyOf/oneOf/allOf and handles union type arrays. Added a regression test using an allOf-wrapped object branch. --- lib/resty/openapi_validator/params.lua | 9 +-------- t/unit/test_params.lua | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/lib/resty/openapi_validator/params.lua b/lib/resty/openapi_validator/params.lua index a5a6421..2a60b83 100644 --- a/lib/resty/openapi_validator/params.lua +++ b/lib/resty/openapi_validator/params.lua @@ -263,14 +263,7 @@ local function deserialize_param(raw_value, param, query_args) and (schema.anyOf or schema.oneOf) then local branches = schema.anyOf or schema.oneOf local function branch_has_object(b) - local bt = b and b.type - if bt == "object" then return true end - if type(bt) == "table" then - for _, t in ipairs(bt) do - if t == "object" then return true end - end - end - return false + return collect_types(b)["object"] == true end for _, branch in ipairs(branches) do if branch_has_object(branch) then diff --git a/t/unit/test_params.lua b/t/unit/test_params.lua index 1a8f576..9d5aa59 100644 --- a/t/unit/test_params.lua +++ b/t/unit/test_params.lua @@ -292,4 +292,27 @@ T.describe("params: deepObject anyOf nullable object branch", function() T.ok(not errs or #errs == 0, "no errors") end) +-- Object branch expressed via composition (allOf) must also be detected, so +-- parse_deep_object is invoked and the value reaches the union validator +-- (instead of being dropped or coerced as a scalar). +T.describe("params: deepObject anyOf composed (allOf) object branch", function() + local route = make_route({ + { name = "created", ["in"] = "query", required = false, + style = "deepObject", explode = true, + schema = { anyOf = { + { allOf = { + { type = "object", properties = { + gt = { type = "string" }, + } }, + } }, + { type = "integer" }, + } } }, + }, "query") + + local ok, errs = params_mod.validate(route, + {}, { ["created[gt]"] = "abc" }, {}) + T.ok(ok, "composed object branch parsed") + T.ok(not errs or #errs == 0, "no errors") +end) + T.done()