Skip to content

Commit df87529

Browse files
jarvis9443Copilot
andcommitted
feat: implement OpenAPI discriminator support for oneOf/anyOf
Add resolve_discriminator() in body.lua that selects the correct oneOf/anyOf branch based on the discriminator property value, avoiding false positives/negatives when branches aren't mutually exclusive. The ref resolver now annotates resolved nodes with _ref so the discriminator mapping can match branches by their original $ref path. Falls back to enum-based matching when mapping is absent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 27907f2 commit df87529

3 files changed

Lines changed: 160 additions & 4 deletions

File tree

lib/resty/openapi_validator/body.lua

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,105 @@ local function find_body_schema_for_content_type(route, content_type)
222222
end
223223

224224

225+
-- Check if a schema (or its allOf sub-schemas) declares the discriminator
226+
-- property with an enum that contains the given value.
227+
local function branch_matches_discriminator_enum(branch, prop_name, value)
228+
local function check_props(s)
229+
local p = s.properties and s.properties[prop_name]
230+
if p and p.enum then
231+
for _, v in ipairs(p.enum) do
232+
if v == value then
233+
return true
234+
end
235+
end
236+
end
237+
return false
238+
end
239+
240+
if check_props(branch) then
241+
return true
242+
end
243+
244+
if branch.allOf then
245+
for _, sub in ipairs(branch.allOf) do
246+
if check_props(sub) then
247+
return true
248+
end
249+
end
250+
end
251+
252+
return false
253+
end
254+
255+
256+
-- Find the branch whose _ref matches the mapping target.
257+
local function find_branch_by_mapping(branches, mapping, value)
258+
local target_ref = mapping[value]
259+
if not target_ref then
260+
return nil
261+
end
262+
263+
for _, branch in ipairs(branches) do
264+
if branch._ref == target_ref then
265+
return branch
266+
end
267+
if branch.allOf then
268+
for _, sub in ipairs(branch.allOf) do
269+
if sub._ref == target_ref then
270+
return branch
271+
end
272+
end
273+
end
274+
end
275+
276+
return nil
277+
end
278+
279+
280+
-- Resolve an OpenAPI discriminator to select the correct oneOf/anyOf branch.
281+
-- Returns (selected_schema, nil) on success, or (nil, error_string) on failure.
282+
-- Returns (nil, nil) when the schema has no discriminator.
283+
local function resolve_discriminator(schema, body_data)
284+
local disc = schema.discriminator
285+
if not disc or not disc.propertyName then
286+
return nil, nil
287+
end
288+
289+
local prop_name = disc.propertyName
290+
local branches = schema.oneOf or schema.anyOf
291+
if not branches then
292+
return nil, nil
293+
end
294+
295+
if type(body_data) ~= "table" then
296+
return nil, "discriminator property '" .. prop_name .. "' is missing"
297+
end
298+
299+
local value = body_data[prop_name]
300+
if value == nil then
301+
return nil, "discriminator property '" .. prop_name .. "' is missing"
302+
end
303+
304+
-- try mapping-based lookup first (uses _ref annotations from ref resolver)
305+
if disc.mapping then
306+
local branch = find_branch_by_mapping(branches, disc.mapping, value)
307+
if branch then
308+
return branch, nil
309+
end
310+
end
311+
312+
-- fallback: match by enum on the discriminator property
313+
for _, branch in ipairs(branches) do
314+
if branch_matches_discriminator_enum(branch, prop_name, value) then
315+
return branch, nil
316+
end
317+
end
318+
319+
return nil, "discriminator value '" .. tostring(value)
320+
.. "' does not match any schema"
321+
end
322+
323+
225324
-- Check for readOnly properties present in the request body data.
226325
local function check_readonly_properties(data, schema, errs)
227326
if type(data) ~= "table" or type(schema) ~= "table" then
@@ -297,7 +396,15 @@ function _M.validate(route, body_str, content_type, opts)
297396
check_readonly_properties(body_data, schema, errs)
298397
end
299398

300-
local validator = get_validator(schema)
399+
local effective_schema = schema
400+
local disc_schema, disc_err = resolve_discriminator(schema, body_data)
401+
if disc_err then
402+
tab_insert(errs, errors.new("body", nil, disc_err))
403+
elseif disc_schema then
404+
effective_schema = disc_schema
405+
end
406+
407+
local validator = get_validator(effective_schema)
301408
if validator then
302409
local ok, err = validator(body_data)
303410
if not ok then

lib/resty/openapi_validator/refs.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ function _M.resolve(spec)
122122
return nil, err
123123
end
124124

125+
resolved._ref = ref
125126
registry[pointer] = resolved
126127
resolving[pointer] = nil
127128

t/conformance/test_validation_discriminator.lua

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ local spec = cjson.encode({
8181
-- by default, so the body satisfies both allOf compositions. This is correct
8282
-- jsonschema oneOf behavior — kin-openapi relies on discriminator logic to
8383
-- pick the right branch.
84-
T.describe("discriminator: objA body matches multiple oneOf branches (no discriminator support)", function()
84+
T.describe("discriminator: objA body resolved via discriminator", function()
8585
local v, err = ov.compile(spec)
8686
T.ok(v, "spec should compile: " .. tostring(err))
8787

@@ -94,8 +94,56 @@ T.describe("discriminator: objA body matches multiple oneOf branches (no discrim
9494
}),
9595
content_type = "application/json",
9696
})
97-
T.ok(not ok, "should fail: matches both oneOf branches without discriminator")
98-
T.like(verr, "matches both", "error mentions matching multiple schemas")
97+
T.ok(ok, "should pass with discriminator support: " .. tostring(verr))
98+
end)
99+
100+
T.describe("discriminator: objB body resolved via discriminator", function()
101+
local v, err = ov.compile(spec)
102+
T.ok(v, "spec should compile: " .. tostring(err))
103+
104+
local ok, verr = v:validate_request({
105+
method = "PUT",
106+
path = "/blob",
107+
body = cjson.encode({
108+
discr = "objB",
109+
value = 42,
110+
}),
111+
content_type = "application/json",
112+
})
113+
T.ok(ok, "objB should pass: " .. tostring(verr))
114+
end)
115+
116+
T.describe("discriminator: unknown discriminator value fails", function()
117+
local v, err = ov.compile(spec)
118+
T.ok(v, "spec should compile: " .. tostring(err))
119+
120+
local ok, verr = v:validate_request({
121+
method = "PUT",
122+
path = "/blob",
123+
body = cjson.encode({
124+
discr = "objC",
125+
base64 = "data",
126+
}),
127+
content_type = "application/json",
128+
})
129+
T.ok(not ok, "should fail for unknown discriminator value")
130+
T.like(verr, "does not match", "error mentions no matching schema")
131+
end)
132+
133+
T.describe("discriminator: missing discriminator property fails", function()
134+
local v, err = ov.compile(spec)
135+
T.ok(v, "spec should compile: " .. tostring(err))
136+
137+
local ok, verr = v:validate_request({
138+
method = "PUT",
139+
path = "/blob",
140+
body = cjson.encode({
141+
base64 = "data",
142+
}),
143+
content_type = "application/json",
144+
})
145+
T.ok(not ok, "should fail for missing discriminator property")
146+
T.like(verr, "missing", "error mentions missing property")
99147
end)
100148

101149
-- When schemas use additionalProperties: false + required fields, oneOf

0 commit comments

Comments
 (0)