-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from moneymeets/feature/add-openapi-generator
Feature/add openapi generator
- Loading branch information
Showing
35 changed files
with
2,914 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,20 @@ | ||
name: CI | ||
on: [ push ] | ||
|
||
jobs: | ||
lint-and-test: | ||
runs-on: ubuntu-22.04 | ||
timeout-minutes: 5 | ||
|
||
steps: | ||
- uses: actions/checkout@v4 | ||
with: | ||
show-progress: false | ||
|
||
- uses: moneymeets/action-setup-python-poetry@master | ||
with: | ||
ssh_key: ${{ secrets.MERGEALOT_SSH_KEY }} | ||
|
||
- uses: moneymeets/moneymeets-composite-actions/lint-python@master | ||
|
||
- run: poetry run pytest --cov |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,2 +1,174 @@ | ||
# spec2sdk | ||
Generate Pydantic models and API client code from OpenAPI 3.x specifications | ||
# Usage | ||
|
||
## From command line | ||
|
||
`spec2sdk --input-file path/to/api.yml --output-dir path/to/output-dir/` | ||
|
||
## From the code | ||
|
||
```python | ||
from pathlib import Path | ||
from spec2sdk.main import generate | ||
|
||
generate(input_file=Path("path/to/api.yml"), output_dir=Path("path/to/output-dir/")) | ||
``` | ||
|
||
# Open API specification requirements | ||
|
||
## Operation ID | ||
|
||
`operationId` must be specified for each endpoint to generate meaningful method names. It must be unique among all operations described in the API. | ||
|
||
### Input | ||
|
||
```yaml | ||
paths: | ||
/health: | ||
get: | ||
operationId: healthCheck | ||
responses: | ||
'200': | ||
description: Successful response | ||
``` | ||
### Output | ||
```python | ||
class APIClient: | ||
def health_check(self) -> None: | ||
... | ||
``` | ||
## Inline schemas | ||
Inline schemas should be annotated with the schema name in the `x-schema-name` field that doesn't overlap with the existing schema names in the specification. | ||
|
||
### Input | ||
|
||
```yaml | ||
paths: | ||
/me: | ||
get: | ||
operationId: getMe | ||
responses: | ||
'200': | ||
description: Successful response | ||
content: | ||
application/json: | ||
schema: | ||
x-schema-name: User | ||
type: object | ||
properties: | ||
name: | ||
type: string | ||
email: | ||
type: string | ||
``` | ||
|
||
### Output | ||
|
||
```python | ||
class User(Model): | ||
name: str | None = Field(default=None) | ||
email: str | None = Field(default=None) | ||
``` | ||
|
||
## Enum variable names | ||
|
||
Variable names for enums can be specified by the `x-enum-varnames` field. | ||
|
||
### Input | ||
|
||
```yaml | ||
components: | ||
schemas: | ||
Direction: | ||
x-enum-varnames: [ NORTH, SOUTH, WEST, EAST ] | ||
type: string | ||
enum: [ N, S, W, E ] | ||
``` | ||
|
||
### Output | ||
|
||
```python | ||
from enum import StrEnum | ||
class Direction(StrEnum): | ||
NORTH = "N" | ||
SOUTH = "S" | ||
WEST = "W" | ||
EAST = "E" | ||
``` | ||
|
||
# Custom types | ||
|
||
Register Python converters and renderers to implement custom types. | ||
|
||
## Input | ||
|
||
```yaml | ||
components: | ||
schemas: | ||
User: | ||
type: object | ||
properties: | ||
name: | ||
type: string | ||
email: | ||
type: string | ||
format: email | ||
``` | ||
|
||
```python | ||
from pathlib import Path | ||
from spec2sdk.parsers.entities import DataType, StringDataType | ||
from spec2sdk.generators.converters import converters | ||
from spec2sdk.generators.entities import PythonType | ||
from spec2sdk.generators.predicates import is_instance | ||
from spec2sdk.generators.imports import Import | ||
from spec2sdk.generators.models.entities import TypeRenderer | ||
from spec2sdk.generators.models.renderers import render_alias_type, renderers | ||
from spec2sdk.main import generate | ||
class EmailPythonType(PythonType): | ||
pass | ||
def is_email_format(data_type: DataType) -> bool: | ||
return isinstance(data_type, StringDataType) and data_type.format == "email" | ||
@converters.register(predicate=is_email_format) | ||
def convert_email_field(data_type: StringDataType) -> EmailPythonType: | ||
return EmailPythonType( | ||
name=None, | ||
type_hint="EmailStr", | ||
description=data_type.description, | ||
default_value=data_type.default_value, | ||
) | ||
@renderers.register(predicate=is_instance(EmailPythonType)) | ||
def render_email_field(py_type: EmailPythonType) -> TypeRenderer: | ||
return render_alias_type( | ||
py_type, | ||
extra_imports=(Import(name="EmailStr", package="pydantic"),), | ||
content="EmailStr", | ||
) | ||
if __name__ == "__main__": | ||
generate(input_file=Path("api.yml"), output_dir=Path("output")) | ||
``` | ||
|
||
## Output | ||
|
||
```python | ||
from pydantic import EmailStr, Field | ||
class User(Model): | ||
name: str | None = Field(default=None) | ||
email: EmailStr | None = Field(default=None) | ||
``` |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
[tool.poetry] | ||
name = "spec2sdk" | ||
version = "0.1.0" | ||
description = "Generate Pydantic models and API client code from OpenAPI 3.x specifications" | ||
authors = ["moneymeets <service@moneymeets.com>"] | ||
readme = "README.md" | ||
repository = "https://github.com/moneymeets/spec2sdk" | ||
packages = [ | ||
{ include="spec2sdk" }, | ||
] | ||
|
||
[tool.poetry.dependencies] | ||
python = "~3.12" | ||
|
||
jinja2 = "*" | ||
openapi-spec-validator = "*" | ||
pydantic = "~2" | ||
pyhumps = "*" | ||
ruff = "*" | ||
|
||
[tool.poetry.group.dev.dependencies] | ||
pytest = "*" | ||
pytest-cov = "*" | ||
|
||
[tool.coverage.run] | ||
branch = true | ||
source = ["."] | ||
omit = ["**/tests/**"] | ||
|
||
[tool.poetry.scripts] | ||
spec2sdk = "spec2sdk.main:main" | ||
|
||
[build-system] | ||
requires = ["poetry-core"] | ||
build-backend = "poetry.core.masonry.api" |
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
from pydantic import BaseModel, ConfigDict | ||
|
||
|
||
class Model(BaseModel): | ||
model_config = ConfigDict( | ||
frozen=True, # make instance immutable and hashable | ||
) |
Empty file.
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
from pathlib import Path | ||
from typing import Sequence | ||
|
||
from jinja2 import Environment, FileSystemLoader | ||
|
||
from spec2sdk.generators.converters import converters | ||
from spec2sdk.generators.entities import PythonType | ||
from spec2sdk.parsers.entities import Specification | ||
|
||
from ..imports import Import, render_imports | ||
from ..utils import get_root_data_types | ||
from .views import EndpointView | ||
|
||
|
||
def get_imports(py_types: Sequence[PythonType], models_import: Import) -> Sequence[Import]: | ||
def get_importable_type_names(py_type: PythonType) -> Sequence[str]: | ||
return ( | ||
(py_type.name,) | ||
if py_type.name | ||
else tuple( | ||
type_name | ||
for dependency_type in py_type.dependency_types | ||
for type_name in get_importable_type_names(dependency_type) | ||
) | ||
) | ||
|
||
return tuple( | ||
{ | ||
models_import.model_copy(update={"name": name}) | ||
for py_type in py_types | ||
for name in get_importable_type_names(py_type) | ||
}, | ||
) | ||
|
||
|
||
def generate_client(spec: Specification, models_import: Import) -> str: | ||
root_data_types = get_root_data_types(spec) | ||
root_python_types = tuple(map(converters.convert, root_data_types)) | ||
imports = get_imports(py_types=root_python_types, models_import=models_import) | ||
|
||
return ( | ||
Environment(loader=FileSystemLoader(f"{Path(__file__).parent}/templates")) | ||
.get_template("client.j2") | ||
.render( | ||
imports=render_imports(imports), | ||
endpoints=tuple( | ||
EndpointView(endpoint=endpoint, response=response) | ||
for endpoint in spec.endpoints | ||
for response in endpoint.responses | ||
), | ||
) | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,68 @@ | ||
{{ imports }} | ||
|
||
from http import HTTPMethod, HTTPStatus | ||
from pydantic import BaseModel, TypeAdapter | ||
from typing import Any, Mapping, Protocol, Sequence | ||
|
||
|
||
class HTTPClientProtocol(Protocol): | ||
def build_url(self, path: str, query: Mapping[str, Any] | None = None) -> str: | ||
... | ||
|
||
def send_request( | ||
self, | ||
method: HTTPMethod, | ||
url: str, | ||
accept: str | None = None, | ||
content_type: str | None = None, | ||
body: BaseModel | None = None, | ||
expected_status_code: HTTPStatus = HTTPStatus.OK, | ||
) -> bytes | None: | ||
... | ||
|
||
|
||
class APIClient: | ||
def __init__(self, *, http_client: HTTPClientProtocol): | ||
self._http_client = http_client | ||
|
||
{% for endpoint in endpoints %} | ||
def {{ endpoint.method_name }}(self | ||
{%- with parameters=endpoint.method_parameters -%} | ||
{%- if parameters %}, * | ||
{%- for parameter in parameters -%} | ||
, {{ parameter.name }}: {{ parameter.type_hint }} | ||
{%- if not parameter.required %} = {{ parameter.default_value }}{% endif %} | ||
{%- endfor %} | ||
{%- endif %} | ||
{%- endwith %}) -> {{ endpoint.response.type_hint }}: | ||
{% if endpoint.docstring %}""" | ||
{{ endpoint.docstring }} | ||
"""{% endif %} | ||
return {% if endpoint.response.has_content %}TypeAdapter({{ endpoint.response.type_hint }}).validate_json( | ||
{% endif %}self._http_client.send_request( | ||
method=HTTPMethod.{{ endpoint.http_method|upper }}, | ||
url=self._http_client.build_url( | ||
{% with url=endpoint.path.url -%} | ||
path={% if endpoint.path.path_parameters %}f{% endif %}"{{ url }}", | ||
{%- endwith %} | ||
{% if endpoint.path.query_parameters -%} | ||
query={ | ||
{%- for parameter in endpoint.path.query_parameters %} | ||
"{{ parameter.original_name }}": {{ parameter.name }}, | ||
{%- endfor %} | ||
}, | ||
{%- endif %} | ||
), | ||
{% if endpoint.request_body -%} | ||
content_type="{{ endpoint.request_body.content_type }}", | ||
body={{ endpoint.request_body.name }}, | ||
{%- endif %} | ||
{% if endpoint.response.media_type -%} | ||
accept="{{ endpoint.response.media_type }}", | ||
{%- endif %} | ||
{% if endpoint.response.status_code != 200 -%} | ||
expected_status_code=HTTPStatus.{{ endpoint.response.status_code.name }}, | ||
{%- endif %} | ||
{% if endpoint.response.has_content %}),{% endif %} | ||
) | ||
{% endfor %} |
Oops, something went wrong.