diff --git a/CHANGELOG.md b/CHANGELOG.md
index 88077df..35548c3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fix [#47](https://github.com/Neoteroi/essentials-openapi/issues/47): remove `wordwrap`
filters from all templates as they break links and mermaid chart code blocks in
descriptions, reported by @ElementalWarrior.
+- Fix [#49](https://github.com/Neoteroi/essentials-openapi/issues/49): support `$ref`
+ values of the form `file.yaml#/fragment/path` (external file with JSON Pointer
+ fragment), reported by @mbklein.
## [1.3.0] - 2025-11-19
diff --git a/openapidocs/mk/v3/__init__.py b/openapidocs/mk/v3/__init__.py
index ccdd9f5..04419b2 100644
--- a/openapidocs/mk/v3/__init__.py
+++ b/openapidocs/mk/v3/__init__.py
@@ -275,18 +275,46 @@ def _handle_obj_ref(self, obj, source_path):
Handles a dictionary containing a $ref property, resolving the reference if it
is to a file. This is used to read specification files when they are split into
multiple items.
+
+ Supports three forms:
+ - ``#/internal/ref`` — internal ref, left as-is
+ - ``path/to/file.yaml`` — entire external file
+ - ``path/to/file.yaml#/fragment/path`` — fragment within an external file
"""
assert isinstance(obj, dict)
if "$ref" in obj:
reference = obj["$ref"]
if isinstance(reference, str) and not reference.startswith("#/"):
- referred_file = Path(os.path.abspath(source_path / reference))
+ # Split off an optional JSON Pointer fragment (#/...)
+ if "#" in reference:
+ file_part, fragment = reference.split("#", 1)
+ else:
+ file_part, fragment = reference, ""
+
+ referred_file = Path(os.path.abspath(source_path / file_part))
if referred_file.exists():
logger.debug("Handling $ref source: %s", reference)
else:
raise OpenAPIFileNotFoundError(reference, referred_file)
+
sub_fragment = read_from_source(str(referred_file))
+
+ if fragment:
+ # Resolve the JSON Pointer (RFC 6901) into the loaded data.
+ # Strip the leading '/' then split on '/'.
+ keys = fragment.lstrip("/").split("/")
+ for key in keys:
+ if (
+ not isinstance(sub_fragment, dict)
+ or key not in sub_fragment
+ ):
+ raise OpenAPIDocumentationHandlerError(
+ f"Cannot resolve fragment '{fragment}' in {referred_file}: "
+ f"key '{key}' not found."
+ )
+ sub_fragment = sub_fragment[key]
+
return self._transform_data(sub_fragment, referred_file.parent)
else:
return obj
diff --git a/tests/res/spec-fragments/openapi.yaml b/tests/res/spec-fragments/openapi.yaml
new file mode 100644
index 0000000..db7e5de
--- /dev/null
+++ b/tests/res/spec-fragments/openapi.yaml
@@ -0,0 +1,19 @@
+---
+openapi: 3.0.0
+info:
+ title: Fragment Refs API
+ description: API using external $ref with fragment pointers
+ version: v1
+paths:
+ /collections:
+ get:
+ operationId: getCollections
+ summary: Get collections
+ tags:
+ - Collection
+ parameters:
+ - $ref: "./types.yaml#/components/parameters/page"
+ - $ref: "./types.yaml#/components/parameters/size"
+ responses:
+ "200":
+ $ref: "./types.yaml#/components/responses/SearchResponse"
diff --git a/tests/res/spec-fragments/types.yaml b/tests/res/spec-fragments/types.yaml
new file mode 100644
index 0000000..fe19279
--- /dev/null
+++ b/tests/res/spec-fragments/types.yaml
@@ -0,0 +1,33 @@
+---
+components:
+ parameters:
+ page:
+ name: page
+ in: query
+ description: Page number
+ required: false
+ schema:
+ type: integer
+ default: 1
+ size:
+ name: size
+ in: query
+ description: Page size
+ required: false
+ schema:
+ type: integer
+ default: 20
+ responses:
+ SearchResponse:
+ description: Successful search response
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ total:
+ type: integer
+ items:
+ type: array
+ items:
+ type: object
diff --git a/tests/test_mk_v3.py b/tests/test_mk_v3.py
index ae3e9ea..c77d9d9 100644
--- a/tests/test_mk_v3.py
+++ b/tests/test_mk_v3.py
@@ -41,6 +41,26 @@ def test_v3_markdown_gen_split_file():
assert compatible_str(html, expected_result)
+def test_v3_external_ref_with_fragment():
+ """
+ Regression test for https://github.com/Neoteroi/essentials-openapi/issues/49
+ $ref values of the form 'file.yaml#/path/to/item' should resolve the fragment
+ within the external file rather than failing with a file-not-found error.
+ """
+ source = get_resource_file_path("spec-fragments/openapi.yaml")
+ data = get_file_yaml("spec-fragments/openapi.yaml")
+
+ handler = OpenAPIV3DocumentationHandler(data, source=source)
+ output = handler.write()
+
+ # Parameters resolved from types.yaml#/components/parameters/...
+ assert "page" in output
+ assert "size" in output
+ # Response resolved from types.yaml#/components/responses/SearchResponse
+ assert '=== "200 OK"' in output
+ assert '"total"' in output
+
+
def test_file_ref_raises_for_missing_file():
with pytest.raises(OpenAPIFileNotFoundError):
OpenAPIV3DocumentationHandler(