Skip to content

Commit

Permalink
Merge pull request #1 from moneymeets/feature/add-openapi-generator
Browse files Browse the repository at this point in the history
Feature/add openapi generator
  • Loading branch information
catcombo authored Aug 30, 2024
2 parents d74e06f + da546e3 commit c2f7f74
Show file tree
Hide file tree
Showing 35 changed files with 2,914 additions and 2 deletions.
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
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
176 changes: 174 additions & 2 deletions README.md
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)
```
976 changes: 976 additions & 0 deletions poetry.lock

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions pyproject.toml
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 added spec2sdk/__init__.py
Empty file.
7 changes: 7 additions & 0 deletions spec2sdk/base.py
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 added spec2sdk/generators/__init__.py
Empty file.
Empty file.
52 changes: 52 additions & 0 deletions spec2sdk/generators/client/generators.py
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
),
)
)
68 changes: 68 additions & 0 deletions spec2sdk/generators/client/templates/client.j2
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 %}
Loading

0 comments on commit c2f7f74

Please sign in to comment.