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(