Skip to content

Commit 0ceeed7

Browse files
authored
Merge pull request #11 from mattcoulter7/feat/openapi-overlay
Feat/openapi overlay
2 parents bed8d1e + 8f7286b commit 0ceeed7

5 files changed

Lines changed: 142 additions & 28 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "pydantic-openapi-generator"
3-
version = "2.5.1"
3+
version = "2.6.0"
44
description = "Openapi Python Generator"
55
authors = [
66
{ name = "Marco Müllner", email = "muellnermarco@gmail.com" },

src/pydantic_openapi_generator/__main__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,13 @@
5858
default=None,
5959
help="Optional YAML configuration for generated client parameters.",
6060
)
61+
@click.option(
62+
"--overlay",
63+
"overlay_paths",
64+
type=click.Path(exists=True, dir_okay=False),
65+
multiple=True,
66+
help="Optional JSON/YAML OpenAPI overlay file. Can be provided multiple times and is applied in order.",
67+
)
6168
@click.version_option(version=__version__)
6269
def main(
6370
source: str,
@@ -69,6 +76,7 @@ def main(
6976
pydantic_version: PydanticVersion = PydanticVersion.V2,
7077
formatter: Formatter = Formatter.BLACK,
7178
config_path: Optional[str] = None,
79+
overlay_paths: tuple[str, ...] = (),
7280
) -> None:
7381
"""
7482
Generate Python code from an OpenAPI 3.0+ specification.
@@ -86,6 +94,7 @@ def main(
8694
pydantic_version,
8795
formatter,
8896
config_path,
97+
overlay_paths,
8998
)
9099

91100

src/pydantic_openapi_generator/generate_data.py

Lines changed: 47 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
from pathlib import Path
2-
from typing import List, Optional, Union
2+
from typing import Any, List, Optional, Sequence, Union
33

44
import black
55
import click
@@ -53,14 +53,54 @@ def format_using_black(content: str) -> str:
5353
return isort.code(formatted_contend, line_length=FormatOptions.line_length)
5454

5555

56-
def get_open_api(source: Union[str, Path]):
56+
def load_openapi_data(source: Union[str, Path]) -> dict[str, Any]:
57+
"""
58+
Load an OpenAPI JSON/YAML document from a URL or local file path.
59+
"""
60+
if not isinstance(source, Path) and (source.startswith("http://") or source.startswith("https://")):
61+
content = httpx.get(source).text
62+
else:
63+
with open(source, "r") as f:
64+
content = f.read()
65+
66+
try:
67+
data = orjson.loads(content)
68+
except orjson.JSONDecodeError:
69+
try:
70+
data = yaml.safe_load(content)
71+
except yaml.YAMLError as e:
72+
click.echo(f"File {source} is neither a valid JSON nor YAML file: {str(e)}")
73+
raise
74+
75+
if not isinstance(data, dict):
76+
raise ValueError(f"OpenAPI data loaded from {source} must be a JSON/YAML object.")
77+
78+
return data
79+
80+
81+
def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]:
82+
"""
83+
Recursively merge two dictionaries. Overlay lists and scalar values replace base values.
84+
"""
85+
result = base.copy()
86+
for key, overlay_value in overlay.items():
87+
base_value = result.get(key)
88+
if isinstance(base_value, dict) and isinstance(overlay_value, dict):
89+
result[key] = deep_merge(base_value, overlay_value)
90+
else:
91+
result[key] = overlay_value
92+
return result
93+
94+
95+
def get_open_api(source: Union[str, Path], overlay_paths: Optional[Sequence[Union[str, Path]]] = None):
5796
"""
5897
Tries to fetch the openapi specification file from the web or load from a local file.
5998
Supports both JSON and YAML formats. Returns the according OpenAPI object.
6099
Automatically supports OpenAPI 3.0 and 3.1 specifications with intelligent version detection.
61100
62101
Args:
63102
source: URL or file path to the OpenAPI specification
103+
overlay_paths: Optional JSON/YAML overlay files to deep-merge into source in order
64104
65105
Returns:
66106
tuple: (OpenAPI object, version) where version is "3.0" or "3.1"
@@ -72,29 +112,9 @@ def get_open_api(source: Union[str, Path]):
72112
JSONDecodeError/YAMLError: If the file cannot be parsed
73113
"""
74114
try:
75-
# Handle remote files
76-
if not isinstance(source, Path) and (source.startswith("http://") or source.startswith("https://")):
77-
content = httpx.get(source).text
78-
# Try JSON first, then YAML for remote files
79-
try:
80-
data = orjson.loads(content)
81-
except orjson.JSONDecodeError:
82-
data = yaml.safe_load(content)
83-
else:
84-
# Handle local files
85-
with open(source, "r") as f:
86-
file_content = f.read()
87-
88-
# Try JSON first
89-
try:
90-
data = orjson.loads(file_content)
91-
except orjson.JSONDecodeError:
92-
# If JSON fails, try YAML
93-
try:
94-
data = yaml.safe_load(file_content)
95-
except yaml.YAMLError as e:
96-
click.echo(f"File {source} is neither a valid JSON nor YAML file: {str(e)}")
97-
raise
115+
data = load_openapi_data(source)
116+
for overlay_path in overlay_paths or ():
117+
data = deep_merge(data, load_openapi_data(overlay_path))
98118

99119
# Detect version and parse with appropriate parser
100120
version = detect_openapi_version(data)
@@ -206,12 +226,13 @@ def generate_data(
206226
pydantic_version: PydanticVersion = PydanticVersion.V2,
207227
formatter: Formatter = Formatter.BLACK,
208228
config_path: Optional[Union[str, Path]] = None,
229+
overlay_paths: Optional[Sequence[Union[str, Path]]] = None,
209230
config: Optional[PydanticOpenAPIGeneratorConfig] = None,
210231
) -> None:
211232
"""
212233
Generate Python code from an OpenAPI 3.0+ specification.
213234
"""
214-
openapi_obj, version = get_open_api(source)
235+
openapi_obj, version = get_open_api(source, overlay_paths)
215236
loaded_config = config if config is not None else load_config(config_path)
216237
click.echo(f"Generating data from {source} (OpenAPI {version})")
217238

tests/test_generate_data.py

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
from pathlib import Path
1+
import json
22
import shutil
33
import subprocess
4+
from pathlib import Path
45

56
import orjson
67
import pytest
@@ -53,6 +54,62 @@ def test_get_open_api(model_data):
5354
get_open_api(test_data_folder / "file_does_not_exist.json")
5455

5556

57+
def test_get_open_api_deep_merges_json_and_yaml_overlays_in_order(tmp_path):
58+
source_path = tmp_path / "openapi.yaml"
59+
first_overlay_path = tmp_path / "first-overlay.json"
60+
second_overlay_path = tmp_path / "second-overlay.yaml"
61+
source_path.write_text(
62+
"""
63+
openapi: 3.0.1
64+
info:
65+
title: Overlay test
66+
version: "1"
67+
paths: {}
68+
components:
69+
schemas:
70+
CommunicationDetails:
71+
type: object
72+
required:
73+
- received_date
74+
- sent_date
75+
properties:
76+
received_date:
77+
type: string
78+
format: date-time
79+
sent_date:
80+
type: string
81+
format: date-time
82+
"""
83+
)
84+
first_overlay_path.write_text(
85+
json.dumps(
86+
{
87+
"components": {
88+
"schemas": {
89+
"CommunicationDetails": {"required": ["received_date"]},
90+
}
91+
}
92+
}
93+
)
94+
)
95+
second_overlay_path.write_text(
96+
"""
97+
components:
98+
schemas:
99+
CommunicationDetails:
100+
required:
101+
- sent_date
102+
"""
103+
)
104+
105+
openapi_obj, version = get_open_api(source_path, [first_overlay_path, second_overlay_path])
106+
107+
communication_details = openapi_obj.components.schemas["CommunicationDetails"]
108+
assert version == "3.0"
109+
assert communication_details.required == ["sent_date"]
110+
assert set(communication_details.properties) == {"received_date", "sent_date"}
111+
112+
56113
def test_generate_data(model_data_with_cleanup):
57114
generate_data(test_data_path, test_result_path)
58115
assert test_result_path.exists()

tests/test_main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Test cases for the __main__ module."""
22

33
import json
4+
from unittest.mock import patch
45

56
import pytest
67
from click.testing import CliRunner
@@ -55,3 +56,29 @@ def test_main_accepts_config_path(runner: CliRunner, tmp_path) -> None:
5556

5657
assert result.exit_code == 0
5758
assert (output_path / "clients" / "sync_client.py").exists()
59+
60+
61+
def test_main_accepts_multiple_overlays_in_order(runner: CliRunner, tmp_path) -> None:
62+
source_path = tmp_path / "openapi.json"
63+
output_path = tmp_path / "generated"
64+
first_overlay_path = tmp_path / "first.yaml"
65+
second_overlay_path = tmp_path / "second.json"
66+
source_path.write_text(json.dumps(CONTRACT_SPEC))
67+
first_overlay_path.write_text("components: {}")
68+
second_overlay_path.write_text("{}")
69+
70+
with patch("pydantic_openapi_generator.__main__.generate_data") as generate_data_mock:
71+
result = runner.invoke(
72+
main,
73+
[
74+
str(source_path),
75+
str(output_path),
76+
"--overlay",
77+
str(first_overlay_path),
78+
"--overlay",
79+
str(second_overlay_path),
80+
],
81+
)
82+
83+
assert result.exit_code == 0
84+
assert generate_data_mock.call_args.args[-1] == (str(first_overlay_path), str(second_overlay_path))

0 commit comments

Comments
 (0)