Skip to content

Commit 66970cb

Browse files
authored
Merge pull request #36 from triaxtec/pr/29
Convert property and endpoint names to snake_case
2 parents 50392cd + a6b59e4 commit 66970cb

26 files changed

+340
-61
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010

1111
### Fixes
1212
- Fixed some typing issues in generated clients and incorporate mypy into end to end tests (#32). Thanks @acgray!
13+
- Properly handle camelCase endpoint names and properties (#29, #36). Thanks @acgray!
1314

1415
## 0.2.1 - 2020-03-22
1516
### Fixes

README.md

+7-6
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,17 @@ You can pass a YAML (or JSON) file to openapi-python-client in order to change s
6767
are supported:
6868

6969
### class_overrides
70-
Used to change the name of generated model classes, especially useful if you have a name like ABCModel which, when
71-
converted to snake case for module naming will be a_b_c_model. This param should be a mapping of existing class name
72-
(usually a key in the "schemas" section of your OpenAPI document) to class_name and module_name.
70+
Used to change the name of generated model classes. This param should be a mapping of existing class name
71+
(usually a key in the "schemas" section of your OpenAPI document) to class_name and module_name. As an example, if the
72+
name of the a model in OpenAPI (and therefore the generated class name) was something like "_PrivateInternalLongName"
73+
and you want the generated client's model to be called "ShortName" in a module called "short_name" you could do this:
7374

7475
Example:
7576
```yaml
7677
class_overrides:
77-
ABCModel:
78-
class_name: ABCModel
79-
module_name: abc_model
78+
_PrivateInternalLongName:
79+
class_name: ShortName
80+
module_name: short_name
8081
```
8182
8283
The easiest way to find what needs to be overridden is probably to generate your client and go look at everything in the

openapi_python_client/__init__.py

+6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
import yaml
1313
from jinja2 import Environment, PackageLoader
1414

15+
from openapi_python_client import utils
16+
1517
from .openapi_parser import OpenAPI, import_string_from_reference
1618

1719
__version__ = version(__package__)
@@ -61,6 +63,8 @@ def _get_json(*, url: Optional[str], path: Optional[Path]) -> Dict[str, Any]:
6163

6264

6365
class _Project:
66+
TEMPLATE_FILTERS = {"snakecase": utils.snake_case}
67+
6468
def __init__(self, *, openapi: OpenAPI) -> None:
6569
self.openapi: OpenAPI = openapi
6670
self.env: Environment = Environment(loader=PackageLoader(__package__), trim_blocks=True, lstrip_blocks=True)
@@ -72,6 +76,8 @@ def __init__(self, *, openapi: OpenAPI) -> None:
7276
self.package_dir: Path = self.project_dir / self.package_name
7377
self.package_description = f"A client library for accessing {self.openapi.title}"
7478

79+
self.env.filters.update(self.TEMPLATE_FILTERS)
80+
7581
def build(self) -> None:
7682
""" Create the project from templates """
7783

openapi_python_client/openapi_parser/properties.py

+15-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from dataclasses import dataclass, field
22
from typing import Any, ClassVar, Dict, List, Optional
33

4+
from openapi_python_client import utils
5+
46
from .reference import Reference
57

68

@@ -15,6 +17,11 @@ class Property:
1517
constructor_template: ClassVar[Optional[str]] = None
1618
_type_string: ClassVar[str]
1719

20+
python_name: str = field(init=False)
21+
22+
def __post_init__(self) -> None:
23+
self.python_name = utils.snake_case(self.name)
24+
1825
def get_type_string(self) -> str:
1926
""" Get a string representation of type that should be used when declaring this property """
2027
if self.required:
@@ -31,13 +38,13 @@ def to_string(self) -> str:
3138
default = None
3239

3340
if default is not None:
34-
return f"{self.name}: {self.get_type_string()} = {self.default}"
41+
return f"{self.python_name}: {self.get_type_string()} = {self.default}"
3542
else:
36-
return f"{self.name}: {self.get_type_string()}"
43+
return f"{self.python_name}: {self.get_type_string()}"
3744

3845
def transform(self) -> str:
3946
""" What it takes to turn this object into a native python type """
40-
return self.name
47+
return self.python_name
4148

4249
def constructor_from_dict(self, dict_name: str) -> str:
4350
""" How to load this property from a dict (used in generated model from_dict function """
@@ -57,6 +64,7 @@ class StringProperty(Property):
5764
_type_string: ClassVar[str] = "str"
5865

5966
def __post_init__(self) -> None:
67+
super().__post_init__()
6068
if self.default is not None:
6169
self.default = f'"{self.default}"'
6270

@@ -132,6 +140,7 @@ class EnumListProperty(Property):
132140
constructor_template: ClassVar[str] = "enum_list_property.pyi"
133141

134142
def __post_init__(self) -> None:
143+
super().__post_init__()
135144
self.reference = Reference.from_ref(self.name)
136145

137146
def get_type_string(self) -> str:
@@ -149,6 +158,7 @@ class EnumProperty(Property):
149158
reference: Reference = field(init=False)
150159

151160
def __post_init__(self) -> None:
161+
super().__post_init__()
152162
self.reference = Reference.from_ref(self.name)
153163
inverse_values = {v: k for k, v in self.values.items()}
154164
if self.default is not None:
@@ -163,7 +173,7 @@ def get_type_string(self) -> str:
163173

164174
def transform(self) -> str:
165175
""" Output to the template, convert this Enum into a JSONable value """
166-
return f"{self.name}.value"
176+
return f"{self.python_name}.value"
167177

168178
def constructor_from_dict(self, dict_name: str) -> str:
169179
""" How to load this property from a dict (used in generated model from_dict function """
@@ -204,7 +214,7 @@ def get_type_string(self) -> str:
204214

205215
def transform(self) -> str:
206216
""" Convert this into a JSONable value """
207-
return f"{self.name}.to_dict()"
217+
return f"{self.python_name}.to_dict()"
208218

209219

210220
@dataclass

openapi_python_client/openapi_parser/reference.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from dataclasses import dataclass
66
from typing import Dict
77

8-
import stringcase
8+
from .. import utils
99

1010
class_overrides: Dict[str, Reference] = {}
1111

@@ -21,9 +21,9 @@ class Reference:
2121
def from_ref(ref: str) -> Reference:
2222
""" Get a Reference from the openapi #/schemas/blahblah string """
2323
ref_value = ref.split("/")[-1]
24-
class_name = stringcase.pascalcase(ref_value)
24+
class_name = utils.pascal_case(ref_value)
2525

2626
if class_name in class_overrides:
2727
return class_overrides[class_name]
2828

29-
return Reference(class_name=class_name, module_name=stringcase.snakecase(ref_value),)
29+
return Reference(class_name=class_name, module_name=utils.snake_case(ref_value),)

openapi_python_client/templates/async_endpoint_module.pyi

+9-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ from ..errors import ApiResponseError
1212
{% for endpoint in collection.endpoints %}
1313

1414

15-
async def {{ endpoint.name }}(
15+
async def {{ endpoint.name | snakecase }}(
1616
*,
1717
{# Proper client based on whether or not the endpoint requires authentication #}
1818
{% if endpoint.requires_security %}
@@ -42,7 +42,12 @@ async def {{ endpoint.name }}(
4242
{% endfor %}
4343
]:
4444
""" {{ endpoint.description }} """
45-
url = f"{client.base_url}{{ endpoint.path }}"
45+
url = "{}{{ endpoint.path }}".format(
46+
client.base_url
47+
{%- for parameter in endpoint.path_parameters -%}
48+
,{{parameter.name}}={{parameter.python_name}}
49+
{%- endfor -%}
50+
)
4651

4752
{% if endpoint.query_parameters %}
4853
params = {
@@ -54,8 +59,8 @@ async def {{ endpoint.name }}(
5459
}
5560
{% for parameter in endpoint.query_parameters %}
5661
{% if not parameter.required %}
57-
if {{ parameter.name }} is not None:
58-
params["{{ parameter.name }}"] = {{ parameter.transform() }}
62+
if {{ parameter.python_name }} is not None:
63+
params["{{ parameter.name }}"] = str({{ parameter.transform() }})
5964
{% endif %}
6065
{% endfor %}
6166
{% endif %}
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{% if property.required %}
2-
{{ property.name }} = datetime.fromisoformat(d["{{ property.name }}"])
2+
{{ property.python_name }} = datetime.fromisoformat(d["{{ property.name }}"])
33
{% else %}
4-
{{ property.name }} = None
5-
if ({{ property.name }}_string := d.get("{{ property.name }}")) is not None:
6-
{{ property.name }} = datetime.fromisoformat(cast(str, {{ property.name }}_string))
4+
{{ property.python_name }} = None
5+
if ({{ property.python_name }}_string := d.get("{{ property.name }}")) is not None:
6+
{{ property.python_name }} = datetime.fromisoformat(cast(str, {{ property.python_name }}_string))
77
{% endif %}

openapi_python_client/templates/endpoint_module.pyi

+9-4
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ from ..errors import ApiResponseError
1212
{% for endpoint in collection.endpoints %}
1313

1414

15-
def {{ endpoint.name }}(
15+
def {{ endpoint.name | snakecase }}(
1616
*,
1717
{# Proper client based on whether or not the endpoint requires authentication #}
1818
{% if endpoint.requires_security %}
@@ -42,7 +42,12 @@ def {{ endpoint.name }}(
4242
{% endfor %}
4343
]:
4444
""" {{ endpoint.description }} """
45-
url = f"{client.base_url}{{ endpoint.path }}"
45+
url = "{}{{ endpoint.path }}".format(
46+
client.base_url
47+
{%- for parameter in endpoint.path_parameters -%}
48+
,{{parameter.name}}={{parameter.python_name}}
49+
{%- endfor -%}
50+
)
4651

4752
{% if endpoint.query_parameters %}
4853
params = {
@@ -54,8 +59,8 @@ def {{ endpoint.name }}(
5459
}
5560
{% for parameter in endpoint.query_parameters %}
5661
{% if not parameter.required %}
57-
if {{ parameter.name }} is not None:
58-
params["{{ parameter.name }}"] = {{ parameter.transform() }}
62+
if {{ parameter.python_name }} is not None:
63+
params["{{ parameter.name }}"] = str({{ parameter.transform() }})
5964
{% endif %}
6065
{% endfor %}
6166
{% endif %}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
{{ property.name }} = []
2-
for {{ property.name }}_item in d.get("{{ property.name }}", []):
3-
{{ property.name }}.append({{ property.reference.class_name }}({{ property.name }}_item))
1+
{{ property.python_name }} = []
2+
for {{ property.python_name }}_item in d.get("{{ property.name }}", []):
3+
{{ property.python_name }}.append({{ property.reference.class_name }}({{ property.python_name }}_item))

openapi_python_client/templates/model.pyi

+4-4
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ class {{ schema.reference.class_name }}:
2222
"{{ property.name }}": self.{{ property.transform() }},
2323
{% endfor %}
2424
{% for property in schema.optional_properties %}
25-
"{{ property.name }}": self.{{ property.transform() }} if self.{{ property.name }} is not None else None,
26-
{% endfor %}
25+
"{{ property.name }}": self.{{ property.transform() }} if self.{{ property.python_name }} is not None else None,
26+
{% endfor %}
2727
}
2828

2929
@staticmethod
@@ -33,12 +33,12 @@ class {{ schema.reference.class_name }}:
3333
{% if property.constructor_template %}
3434
{% include property.constructor_template %}
3535
{% else %}
36-
{{ property.name }} = {{ property.constructor_from_dict("d") }}
36+
{{ property.python_name }} = {{ property.constructor_from_dict("d") }}
3737
{% endif %}
3838

3939
{% endfor %}
4040
return {{ schema.reference.class_name }}(
4141
{% for property in schema.required_properties + schema.optional_properties %}
42-
{{ property.name }}={{ property.name }},
42+
{{ property.python_name }}={{ property.python_name }},
4343
{% endfor %}
4444
)
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{% if property.required %}
2-
{{ property.name }} = {{ property.reference.class_name }}.from_dict(d["{{ property.name }}"])
2+
{{ property.python_name }} = {{ property.reference.class_name }}.from_dict(d["{{ property.name }}"])
33
{% else %}
4-
{{ property.name }} = None
5-
if ({{ property.name }}_data := d.get("{{ property.name }}")) is not None:
6-
{{ property.name }} = {{ property.reference.class_name }}.from_dict(cast(Dict, {{ property.name }}_data))
4+
{{ property.python_name }} = None
5+
if ({{ property.python_name }}_data := d.get("{{ property.name }}")) is not None:
6+
{{ property.python_name }} = {{ property.reference.class_name }}.from_dict(cast(Dict[str, Any], {{ property.python_name }}_data))
77
{% endif %}
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
{{ property.name }} = []
2-
for {{ property.name }}_item in d.get("{{ property.name }}", []):
3-
{{ property.name }}.append({{ property.reference.class_name }}.from_dict({{ property.name }}_item))
1+
{{ property.python_name }} = []
2+
for {{ property.python_name }}_item in d.get("{{ property.python_name }}", []):
3+
{{ property.python_name }}.append({{ property.reference.class_name }}.from_dict({{ property.python_name }}_item))

openapi_python_client/utils.py

+13
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import re
2+
3+
import stringcase
4+
5+
6+
def snake_case(value: str) -> str:
7+
value = re.sub(r"([A-Z]{2,})([A-Z][a-z]|[ -_]|$)", lambda m: m.group(1).title() + m.group(2), value.strip())
8+
value = re.sub(r"(^|[ _-])([A-Z])", lambda m: m.group(1) + m.group(2).lower(), value)
9+
return stringcase.snakecase(value)
10+
11+
12+
def pascal_case(value: str) -> str:
13+
return stringcase.pascalcase(value)

tests/test_end_to_end/fastapi/__init__.py

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
""" A FastAPI app used to create an OpenAPI document for end-to-end testing """
22
import json
3+
from datetime import datetime
34
from enum import Enum
45
from pathlib import Path
56
from typing import List
@@ -43,9 +44,10 @@ class AModel(BaseModel):
4344
a_list_of_enums: List[AnEnum]
4445
a_list_of_strings: List[str]
4546
a_list_of_objects: List[OtherModel]
47+
aCamelDateTime: datetime
4648

4749

48-
@test_router.get("/", response_model=List[AModel])
50+
@test_router.get("/", response_model=List[AModel], operation_id="getUserList")
4951
def get_list(statuses: List[AnEnum] = Query(...),):
5052
""" Get users, filtered by statuses """
5153
return
@@ -55,4 +57,4 @@ def get_list(statuses: List[AnEnum] = Query(...),):
5557

5658
if __name__ == "__main__":
5759
path = Path(__file__).parent / "openapi.json"
58-
path.write_text(json.dumps(app.openapi()))
60+
path.write_text(json.dumps(app.openapi(), indent=4))

0 commit comments

Comments
 (0)