Skip to content

fix(params): handle deepObject style with anyOf/oneOf schemas#7

Merged
nic-6443 merged 4 commits intomainfrom
fix/deepobject-anyof-param
Apr 21, 2026
Merged

fix(params): handle deepObject style with anyOf/oneOf schemas#7
nic-6443 merged 4 commits intomainfrom
fix/deepobject-anyof-param

Conversation

@jarvis9443
Copy link
Copy Markdown
Contributor

@jarvis9443 jarvis9443 commented Apr 21, 2026

What

Fix a bug where query parameters with style: deepObject and a top-level anyOf/oneOf schema (no top-level type: object) were always rejected, even when the parameter was optional and absent.

Why

deserialize_param's object branch only triggers when stype == "object". For an anyOf schema there's no top-level type, so the request fell through to coerce_value("deepObject_placeholder", schema) and failed the anyOf (the placeholder string matches neither the object nor any scalar branch).

Common in real-world specs — e.g. the Stripe API spec uses this pattern for every range filter (created, transacted_at, arrival_date, canceled_at, current_period_end, effective_at, amount):

- name: created
  in: query
  required: false
  style: deepObject
  schema:
    anyOf:
      - type: object
        properties:
          gt: { type: integer }
          gte: { type: integer }
          lt: { type: integer }
          lte: { type: integer }
      - type: integer

A QA pass against the full Stripe OpenAPI spec found this bug affects ≥50 operations. Reproducer:

local v = ov.compile(spec)
v:validate_request({ method = "GET", path = "/v1/accounts" })
-- expected: true
-- got:     false, "query parameter 'created': object matches none of the required"

How

In deserialize_param, when style == "deepObject" and the schema has anyOf/oneOf:

  1. For each object branch, try parse_deep_object. If it returns a non-nil result (i.e. the user supplied param[key]=value keys), use it.
  2. Otherwise, if the user supplied a bare ?param=value, try coercing it against each non-object branch (e.g. integer/number).
  3. Otherwise return nil — and let the existing required-check logic handle it (works because the optional-param branch in validate_param_group returns nil instead of an error).

Tests

Added 3 regression tests in t/unit/test_params.lua:

  • deepObject anyOf optional missing — no param supplied, optional → accepted
  • deepObject anyOf object branch?created[gt]=1700000000&created[lte]=1800000000 → accepted
  • deepObject anyOf integer branch?created=1700000000 → accepted

make test passes (27 unit tests + all conformance).

Impact on Stripe corpus

Re-running the full Stripe per-operation corpus (587 operations × 1 valid request each) before/after this PR:

Before After
Positive cases accepted 494/587 (84.2%) 544/587 (92.7%)
Negative cases rejected 382/382 (100%) 382/382 (100%)

The remaining 43 failures are all generator-side artefacts (Stripe's deeply-nested additionalProperties: false bodies and array-of-object deepObject arrays not synthesized correctly), not validator bugs.

Summary by CodeRabbit

  • Bug Fixes

    • Improved validation for deepObject-style query parameters when schemas use composite types (anyOf/oneOf), correctly handling object vs. scalar branches and nullable/object combinations; preserves existing behaviors for other styles.
  • Tests

    • Added unit tests covering object-form, scalar-form, nullable branches, optional omissions, and failing validation scenarios for these query parameters.

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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 45f4b5ec-8b2c-4091-89b9-6f24066fa957

📥 Commits

Reviewing files that changed from the base of the PR and between 8d3c87b and 28fca89.

📒 Files selected for processing (2)
  • lib/resty/openapi_validator/params.lua
  • t/unit/test_params.lua
✅ Files skipped from review due to trivial changes (1)
  • lib/resty/openapi_validator/params.lua
🚧 Files skipped from review as they are similar to previous changes (1)
  • t/unit/test_params.lua

📝 Walkthrough

Walkthrough

Extend deepObject handling in parameter deserialization when the parameter schema uses anyOf/oneOf: prefer object-typed branches and run parse_deep_object on them; if none parse, fall back to reading and coercing the raw query value against the composite schema. Unit tests added for these cases.

Changes

Cohort / File(s) Summary
DeepObject Parameter Deserialization
lib/resty/openapi_validator/params.lua
Extend deserialize_param for style == "deepObject" when schema contains anyOf/oneOf: iterate branches, pick first branch whose collected types include "object" (including type = {"object","null"}), attempt parse_deep_object(...) and return the parsed object if successful; otherwise unwrap query_args[param.name] and coerce_value the scalar/raw value against the composite schema.
Parameter Deserialization Tests
t/unit/test_params.lua
Add unit tests covering deepObject with anyOf/oneOf: missing param acceptance, object-form parsing (created[gt]/created[lte]), scalar branch coercion (integer), nullable-object branch handling, composed-object (allOf) branch handling, and a failing scalar case asserting validation errors.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • nic-6443
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning PR lacks E2E/integration tests demonstrating full OpenAPI validation workflow with deepObject parameters; only unit tests of isolated deserialize_param function provided. Add integration tests loading complete OpenAPI schemas with deepObject style and anyOf/oneOf constraints, constructing HTTP requests, invoking full validator, and verifying complete request validation flows.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly describes the main fix: handling deepObject style with anyOf/oneOf schemas. It is concise, specific, and clearly summarizes the primary change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed The pull request adds query parameter validation with deepObject style and anyOf/oneOf schema support. No sensitive data logging, unencrypted secrets, authorization bypasses, TLS misconfigurations, or secret reference vulnerabilities detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/deepobject-anyof-param

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
t/unit/test_params.lua (1)

165-220: Add a matching oneOf regression case.

The new tests cover anyOf well, but the runtime path also handles oneOf. Adding one deepObject + oneOf case would lock this fix against regressions in both branches.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@t/unit/test_params.lua` around lines 165 - 220, Add a regression test
mirroring the existing deepObject anyOf cases but using oneOf: create a new
T.describe block that builds a route with make_route where the "created" query
param uses style="deepObject", explode=true and schema = { oneOf = { {
type="object", properties = { gt={type="integer"}, lte={type="integer"} } }, {
type="integer" } } }; then call params_mod.validate with both the object-form
params (e.g. ["created[gt]"]="1700000000", ["created[lte]"]="1800000000") and a
scalar-form params (e.g. ["created"]="1700000000") in separate tests, asserting
ok is true and errs is nil/empty just like the anyOf tests to ensure oneOf
runtime path remains covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/resty/openapi_validator/params.lua`:
- Around line 276-281: The loop over branches uses coerce_value(scalar_raw,
branch) and accepts any non-nil return (v ~= nil), but coerce_value can return
non-nil even when coercion failed, causing the first scalar branch to win;
change coerce_value to return two values (coerced_value, ok) or otherwise expose
a success flag, then in the branches loop check the success flag (e.g., local v,
ok = coerce_value(scalar_raw, branch); if ok then return v end) so only truly
successful coercions for a branch are accepted.

---

Nitpick comments:
In `@t/unit/test_params.lua`:
- Around line 165-220: Add a regression test mirroring the existing deepObject
anyOf cases but using oneOf: create a new T.describe block that builds a route
with make_route where the "created" query param uses style="deepObject",
explode=true and schema = { oneOf = { { type="object", properties = {
gt={type="integer"}, lte={type="integer"} } }, { type="integer" } } }; then call
params_mod.validate with both the object-form params (e.g.
["created[gt]"]="1700000000", ["created[lte]"]="1800000000") and a scalar-form
params (e.g. ["created"]="1700000000") in separate tests, asserting ok is true
and errs is nil/empty just like the anyOf tests to ensure oneOf runtime path
remains covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5dcdebe9-3e67-464a-85e3-6dc5c8638457

📥 Commits

Reviewing files that changed from the base of the PR and between 2c34681 and 54a1348.

📒 Files selected for processing (2)
  • lib/resty/openapi_validator/params.lua
  • t/unit/test_params.lua

Comment thread lib/resty/openapi_validator/params.lua Outdated
…ssion 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.
@jarvis9443
Copy link
Copy Markdown
Contributor Author

Also addressed the oneOf nitpick — added two oneOf regression tests (object branch + integer branch) mirroring the anyOf cases. make test passes (30/30) and the Stripe per-op corpus still gives 544/587 (92.7%) positive, 382/382 (100%) negative.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/resty/openapi_validator/params.lua`:
- Around line 265-279: The deepObject branch check only matches branch.type ==
"object" and thus misses array-valued type declarations like {"object","null"};
update the loop over branches to treat branch.type as either a string or an
array and detect whether it "contains" "object" (e.g., if branch.type ==
"object" or if type(branch.type) == "table" and any element == "object"), then
call parse_deep_object(param.name, query_args or {}, branch) and return the
parsed object if non-nil; keep the existing scalar handling (scalar_raw ->
coerce_value(scalar_raw, schema)) unchanged. Ensure you still iterate with
ipairs(branches) so behavior remains deterministic.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 46cfae49-b8e8-4d67-a70b-16beda3ce6c8

📥 Commits

Reviewing files that changed from the base of the PR and between 54a1348 and ff1fab3.

📒 Files selected for processing (2)
  • lib/resty/openapi_validator/params.lua
  • t/unit/test_params.lua
🚧 Files skipped from review as they are similar to previous changes (1)
  • t/unit/test_params.lua

Comment thread lib/resty/openapi_validator/params.lua
…ct,null})

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.)
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/resty/openapi_validator/params.lua`:
- Around line 265-276: The branch detection currently only checks branch.type
and misses object schemas expressed via composition; update the
branch_has_object function to recursively inspect a branch for object types by
traversing composition keys (allOf, anyOf, oneOf), nested schemas, and common
object indicators (properties, additionalProperties) in addition to branch.type;
ensure branch_has_object(branch) returns true if any nested schema or composed
entry has type "object" (including when type is an array), so the loop over
branches correctly detects deepObject-capable object branches.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 45dee1a6-564b-481f-b000-ef79102d8b03

📥 Commits

Reviewing files that changed from the base of the PR and between ff1fab3 and 8d3c87b.

📒 Files selected for processing (2)
  • lib/resty/openapi_validator/params.lua
  • t/unit/test_params.lua
✅ Files skipped from review due to trivial changes (1)
  • t/unit/test_params.lua

Comment thread lib/resty/openapi_validator/params.lua
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.
@nic-6443 nic-6443 merged commit aa8a1ff into main Apr 21, 2026
3 checks passed
@nic-6443 nic-6443 deleted the fix/deepobject-anyof-param branch April 21, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants