Skip to content

Commit a0d7a3c

Browse files
Fix #49 (#70)
* Support external $ref with JSON Pointer fragment (issue #49) Refs of the form 'file.yaml#/components/parameters/foo' previously failed because the fragment was included in the file path. Now the reference is split at '#', the external file is loaded, and the JSON Pointer fragment is used to extract the correct sub-object. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Apply black formatting --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 08b2d47 commit a0d7a3c

File tree

5 files changed

+104
-1
lines changed

5 files changed

+104
-1
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
- Fix [#47](https://github.com/Neoteroi/essentials-openapi/issues/47): remove `wordwrap`
1919
filters from all templates as they break links and mermaid chart code blocks in
2020
descriptions, reported by @ElementalWarrior.
21+
- Fix [#49](https://github.com/Neoteroi/essentials-openapi/issues/49): support `$ref`
22+
values of the form `file.yaml#/fragment/path` (external file with JSON Pointer
23+
fragment), reported by @mbklein.
2124

2225
## [1.3.0] - 2025-11-19
2326

openapidocs/mk/v3/__init__.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,18 +275,46 @@ def _handle_obj_ref(self, obj, source_path):
275275
Handles a dictionary containing a $ref property, resolving the reference if it
276276
is to a file. This is used to read specification files when they are split into
277277
multiple items.
278+
279+
Supports three forms:
280+
- ``#/internal/ref`` — internal ref, left as-is
281+
- ``path/to/file.yaml`` — entire external file
282+
- ``path/to/file.yaml#/fragment/path`` — fragment within an external file
278283
"""
279284
assert isinstance(obj, dict)
280285
if "$ref" in obj:
281286
reference = obj["$ref"]
282287
if isinstance(reference, str) and not reference.startswith("#/"):
283-
referred_file = Path(os.path.abspath(source_path / reference))
288+
# Split off an optional JSON Pointer fragment (#/...)
289+
if "#" in reference:
290+
file_part, fragment = reference.split("#", 1)
291+
else:
292+
file_part, fragment = reference, ""
293+
294+
referred_file = Path(os.path.abspath(source_path / file_part))
284295

285296
if referred_file.exists():
286297
logger.debug("Handling $ref source: %s", reference)
287298
else:
288299
raise OpenAPIFileNotFoundError(reference, referred_file)
300+
289301
sub_fragment = read_from_source(str(referred_file))
302+
303+
if fragment:
304+
# Resolve the JSON Pointer (RFC 6901) into the loaded data.
305+
# Strip the leading '/' then split on '/'.
306+
keys = fragment.lstrip("/").split("/")
307+
for key in keys:
308+
if (
309+
not isinstance(sub_fragment, dict)
310+
or key not in sub_fragment
311+
):
312+
raise OpenAPIDocumentationHandlerError(
313+
f"Cannot resolve fragment '{fragment}' in {referred_file}: "
314+
f"key '{key}' not found."
315+
)
316+
sub_fragment = sub_fragment[key]
317+
290318
return self._transform_data(sub_fragment, referred_file.parent)
291319
else:
292320
return obj
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
openapi: 3.0.0
3+
info:
4+
title: Fragment Refs API
5+
description: API using external $ref with fragment pointers
6+
version: v1
7+
paths:
8+
/collections:
9+
get:
10+
operationId: getCollections
11+
summary: Get collections
12+
tags:
13+
- Collection
14+
parameters:
15+
- $ref: "./types.yaml#/components/parameters/page"
16+
- $ref: "./types.yaml#/components/parameters/size"
17+
responses:
18+
"200":
19+
$ref: "./types.yaml#/components/responses/SearchResponse"
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
components:
3+
parameters:
4+
page:
5+
name: page
6+
in: query
7+
description: Page number
8+
required: false
9+
schema:
10+
type: integer
11+
default: 1
12+
size:
13+
name: size
14+
in: query
15+
description: Page size
16+
required: false
17+
schema:
18+
type: integer
19+
default: 20
20+
responses:
21+
SearchResponse:
22+
description: Successful search response
23+
content:
24+
application/json:
25+
schema:
26+
type: object
27+
properties:
28+
total:
29+
type: integer
30+
items:
31+
type: array
32+
items:
33+
type: object

tests/test_mk_v3.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,26 @@ def test_v3_markdown_gen_split_file():
4141
assert compatible_str(html, expected_result)
4242

4343

44+
def test_v3_external_ref_with_fragment():
45+
"""
46+
Regression test for https://github.com/Neoteroi/essentials-openapi/issues/49
47+
$ref values of the form 'file.yaml#/path/to/item' should resolve the fragment
48+
within the external file rather than failing with a file-not-found error.
49+
"""
50+
source = get_resource_file_path("spec-fragments/openapi.yaml")
51+
data = get_file_yaml("spec-fragments/openapi.yaml")
52+
53+
handler = OpenAPIV3DocumentationHandler(data, source=source)
54+
output = handler.write()
55+
56+
# Parameters resolved from types.yaml#/components/parameters/...
57+
assert "<code>page</code>" in output
58+
assert "<code>size</code>" in output
59+
# Response resolved from types.yaml#/components/responses/SearchResponse
60+
assert '=== "200 OK"' in output
61+
assert '"total"' in output
62+
63+
4464
def test_file_ref_raises_for_missing_file():
4565
with pytest.raises(OpenAPIFileNotFoundError):
4666
OpenAPIV3DocumentationHandler(

0 commit comments

Comments
 (0)