Skip to content

Commit 1a16dac

Browse files
committed
feat(refs): resolve $dynamicRef/$dynamicAnchor (2020-12)
Implement resolution of $dynamicRef and $dynamicAnchor keywords from JSON Schema Draft 2020-12 (used in OpenAPI 3.1 specs). During ref resolution, $dynamicAnchor targets are collected from the entire spec tree. When a $dynamicRef like '#anchor' is found, it's resolved to the matching $dynamicAnchor target inline, just like regular $ref resolution. This enables validation of schemas that use dynamic references for polymorphic type constraints.
1 parent a40982d commit 1a16dac

2 files changed

Lines changed: 62 additions & 2 deletions

File tree

lib/resty/openapi_validator/normalize.lua

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ local tab_insert = table.insert
1111

1212
-- Keywords that are Draft 2020-12 only and have no Draft 7 equivalent
1313
local UNSUPPORTED_31_KEYWORDS = {
14-
["$dynamicRef"] = true,
15-
["$dynamicAnchor"] = true,
1614
["unevaluatedProperties"] = true,
1715
["unevaluatedItems"] = true,
1816
}
@@ -99,6 +97,19 @@ local function normalize_31_schema(schema, warnings, strict)
9997
return nil
10098
end
10199

100+
-- Strip $dynamicAnchor (resolved during refs phase)
101+
schema["$dynamicAnchor"] = nil
102+
103+
-- Warn about any remaining $dynamicRef (should have been resolved)
104+
if schema["$dynamicRef"] then
105+
if strict then
106+
return "unresolved $dynamicRef: " .. schema["$dynamicRef"]
107+
end
108+
tab_insert(warnings, "unresolved $dynamicRef ignored: "
109+
.. schema["$dynamicRef"])
110+
schema["$dynamicRef"] = nil
111+
end
112+
102113
-- Check for unsupported 2020-12 keywords
103114
for kw in pairs(UNSUPPORTED_31_KEYWORDS) do
104115
if schema[kw] ~= nil then

lib/resty/openapi_validator/refs.lua

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,25 @@ local function resolve_pointer(root, pointer)
3636
end
3737

3838

39+
-- Collect all $dynamicAnchor targets from a spec tree.
40+
-- Returns a map from anchor name to the table containing the anchor.
41+
local function collect_dynamic_anchors(node, anchors, visited)
42+
if type(node) ~= "table" or visited[node] then
43+
return
44+
end
45+
visited[node] = true
46+
47+
local anchor = node["$dynamicAnchor"]
48+
if anchor then
49+
anchors[anchor] = node
50+
end
51+
52+
for _, v in pairs(node) do
53+
collect_dynamic_anchors(v, anchors, visited)
54+
end
55+
end
56+
57+
3958
-- Walk the spec tree and resolve every $ref node in place.
4059
--
4160
-- Strategy:
@@ -57,6 +76,10 @@ function _M.resolve(spec)
5776
local registry = {}
5877
local walked = {}
5978

79+
-- Collect $dynamicAnchor targets for $dynamicRef resolution
80+
local dynamic_anchors = {}
81+
collect_dynamic_anchors(spec, dynamic_anchors, {})
82+
6083
local walk
6184

6285
local function resolve_ref(ref)
@@ -116,6 +139,32 @@ function _M.resolve(spec)
116139
else
117140
node[k] = resolved
118141
end
142+
elseif v["$dynamicRef"] then
143+
-- Resolve $dynamicRef by looking up the anchor
144+
local dyn_ref = v["$dynamicRef"]
145+
local anchor_name = dyn_ref:match("^#(.+)$")
146+
if anchor_name and dynamic_anchors[anchor_name] then
147+
local target = dynamic_anchors[anchor_name]
148+
walk(target)
149+
150+
-- collect sibling keys
151+
local siblings, has_siblings
152+
for sk, sv in pairs(v) do
153+
if sk ~= "$dynamicRef" then
154+
siblings = siblings or {}
155+
siblings[sk] = sv
156+
has_siblings = true
157+
end
158+
end
159+
160+
if has_siblings then
161+
walk(siblings)
162+
node[k] = { allOf = { target, siblings } }
163+
else
164+
node[k] = target
165+
end
166+
end
167+
-- unresolved $dynamicRef: leave for normalize to warn/error
119168
else
120169
walk(v)
121170
end

0 commit comments

Comments
 (0)