spec2sdk


Namespec2sdk JSON
Version 1.0.202502171012 PyPI version JSON
download
home_pagehttps://github.com/moneymeets/spec2sdk
SummaryGenerate Pydantic models and API client code from OpenAPI 3.x specifications
upload_time2025-02-17 10:12:25
maintainerNone
docs_urlNone
authormoneymeets
requires_python<3.13,>=3.12
licenseMIT
keywords openapi pydantic code-generator openapi-codegen
VCS
bugtrack_url
requirements No requirements were recorded.
Travis-CI No Travis.
coveralls test coverage No coveralls.
            # Usage

## From command line

- Local specification `spec2sdk --schema-path path/to/api.yml --output-dir path/to/output-dir/`
- Remove specification `spec2sdk --schema-url https://example.com/path/to/api.yml --output-dir path/to/output-dir/`

## From the code

```python
from pathlib import Path
from spec2sdk.main import generate

# Local specification
generate(schema_url=Path("path/to/api.yml").absolute().as_uri(), output_dir=Path("path/to/output-dir/"))

# Remove specification
generate(schema_url="https://example.com/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 typing import Sequence

from spec2sdk.openapi.entities import DataType, StringDataType
from spec2sdk.models.converters import converters, convert_common_fields
from spec2sdk.models.entities import PythonType
from spec2sdk.models.imports import Import
from spec2sdk.main import generate


class EmailType(PythonType):
    @property
    def type_hint(self) -> str:
        return self.name or "EmailStr"

    @property
    def imports(self) -> Sequence[Import]:
        return (
            Import(name="EmailStr", package="pydantic"),
        )

    def render(self) -> str:
        return f"type {self.name} = EmailStr" if self.name else ""


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) -> EmailType:
    return EmailType(**convert_common_fields(data_type))


if __name__ == "__main__":
    generate(schema_url=Path("api.yml").absolute().as_uri(), 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)
```

# Using generated client

1. Create HTTP client. It should conform to the `HTTPClientProtocol` which can be found in the generated `http_client.py`. Below is an example of the HTTP client implemented using `httpx` library to handle HTTP requests. Assume that `sdk` is the output directory for the generated code.
```python
from http import HTTPStatus

import httpx
from httpx._types import AuthTypes, TimeoutTypes

from sdk.http_client import HTTPRequest, HTTPResponse


class HTTPClient:
    def __init__(self, *, base_url: str, auth: AuthTypes | None = None, timeout: TimeoutTypes | None = None, **kwargs):
        self._http_client = httpx.Client(auth=auth, base_url=base_url, timeout=timeout, **kwargs)

    def send_request(self, *, request: HTTPRequest) -> HTTPResponse:
        response = self._http_client.request(
            method=request.method,
            url=request.url,
            content=request.content,
            headers=request.headers,
        )
        return HTTPResponse(
            status_code=HTTPStatus(response.status_code),
            content=response.content,
            headers=response.headers.multi_items(),
        )
```
2. Create API client. It should conform to the `APIClientProtocol` which can be found in the generated `api_client.py`. Below is an example of the API client.
```python
from http import HTTPMethod, HTTPStatus
from types import NoneType
from typing import Any, Mapping, Type
from urllib.parse import urlencode

from pydantic import TypeAdapter

from sdk.api_client import APIClientResponse
from sdk.http_client import HTTPClientProtocol, HTTPRequest


class APIClient:
    def __init__(self, http_client: HTTPClientProtocol):
        self._http_client = http_client

    def serialize[T](self, *, data: T, data_type: Type[T], content_type: str | None) -> bytes:
        match content_type:
            case "application/json":
                return TypeAdapter(data_type).dump_json(data, by_alias=True)
            case _:
                return data

    def deserialize[T](self, *, data: bytes | None, data_type: Type[T], content_type: str | None) -> T:
        match content_type:
            case "application/json":
                return TypeAdapter(data_type).validate_json(data)
            case _:
                return data

    def build_url(self, path: str, query: Mapping[str, Any] | None = None) -> str:
        if query is None:
            return path

        return f"{path}?{urlencode(query, doseq=True)}"

    def send_request[I, O](
        self,
        *,
        method: HTTPMethod,
        path: str,
        query: Mapping[str, Any] | None = None,
        content_type: str | None = None,
        data: I | None = None,
        data_type: Type[I] = NoneType,
        accept: str | None = None,
        response_type: Type[O] = NoneType,
        expected_status_code: HTTPStatus = HTTPStatus.OK,
    ) -> APIClientResponse[O]:
        content = self.serialize(data=data, data_type=data_type, content_type=content_type) if data else None
        request = HTTPRequest(
            method=method,
            url=self.build_url(path, query),
            headers=(("Content-Type", content_type),) if content_type else (),
            content=content,
        )
        response = self._http_client.send_request(request=request)

        if response.status_code != expected_status_code:
            raise Exception(
                f"Response has unexpected status code. Expected {expected_status_code}, got {response.status_code}."
            )

        if accept is not None and not any(
            response_content_type := tuple(
                value for key, value in response.headers if (key.lower() == "content-type") and (accept in value)
            ),
        ):
            raise Exception(f"Response has unexpected content type. Expected {accept}, got {response_content_type}.")

        return APIClientResponse(
            http_response=response,
            data=self.deserialize(data=response.content, data_type=response_type, content_type=accept),
        )
```
3. Combine clients together to access API.
```python
from sdk.api import API

api = API(
    api_client=APIClient(
        http_client=HTTPClient(
            base_url="https://api.example.com",
            auth=BasicAuth(username="user", password="pass"),
        ),
    ),
)
```

            

Raw data

            {
    "_id": null,
    "home_page": "https://github.com/moneymeets/spec2sdk",
    "name": "spec2sdk",
    "maintainer": null,
    "docs_url": null,
    "requires_python": "<3.13,>=3.12",
    "maintainer_email": null,
    "keywords": "openapi, pydantic, code-generator, openapi-codegen",
    "author": "moneymeets",
    "author_email": "service@moneymeets.com",
    "download_url": "https://files.pythonhosted.org/packages/73/81/778110407cf36ab859cd7d50e54303dac2d033d4bd90467f1f42dfd4695e/spec2sdk-1.0.202502171012.tar.gz",
    "platform": null,
    "description": "# Usage\n\n## From command line\n\n- Local specification `spec2sdk --schema-path path/to/api.yml --output-dir path/to/output-dir/`\n- Remove specification `spec2sdk --schema-url https://example.com/path/to/api.yml --output-dir path/to/output-dir/`\n\n## From the code\n\n```python\nfrom pathlib import Path\nfrom spec2sdk.main import generate\n\n# Local specification\ngenerate(schema_url=Path(\"path/to/api.yml\").absolute().as_uri(), output_dir=Path(\"path/to/output-dir/\"))\n\n# Remove specification\ngenerate(schema_url=\"https://example.com/path/to/api.yml\", output_dir=Path(\"path/to/output-dir/\"))\n```\n\n# Open API specification requirements\n\n## Operation ID\n\n`operationId` must be specified for each endpoint to generate meaningful method names. It must be unique among all operations described in the API.\n\n### Input\n\n```yaml\npaths:\n  /health:\n    get:\n      operationId: healthCheck\n      responses:\n        '200':\n          description: Successful response\n```\n\n### Output\n\n```python\nclass APIClient:\n    def health_check(self) -> None:\n        ...\n```\n\n## Inline schemas\n\nInline 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.\n\n### Input\n\n```yaml\npaths:\n  /me:\n    get:\n      operationId: getMe\n      responses:\n        '200':\n          description: Successful response\n          content:\n            application/json:\n              schema:\n                x-schema-name: User\n                type: object\n                properties:\n                  name:\n                    type: string\n                  email:\n                    type: string\n```\n\n### Output\n\n```python\nclass User(Model):\n    name: str | None = Field(default=None)\n    email: str | None = Field(default=None)\n```\n\n## Enum variable names\n\nVariable names for enums can be specified by the `x-enum-varnames` field.\n\n### Input\n\n```yaml\ncomponents: \n  schemas:\n    Direction:\n      x-enum-varnames: [ NORTH, SOUTH, WEST, EAST ]\n      type: string\n      enum: [ N, S, W, E ]\n```\n\n### Output\n\n```python\nfrom enum import StrEnum\n\nclass Direction(StrEnum):\n    NORTH = \"N\"\n    SOUTH = \"S\"\n    WEST = \"W\"\n    EAST = \"E\"\n```\n\n# Custom types\n\nRegister Python converters and renderers to implement custom types.\n\n## Input\n\n```yaml\ncomponents: \n  schemas: \n    User:\n      type: object\n      properties:\n        name:\n          type: string\n        email:\n          type: string\n          format: email\n```\n\n```python\nfrom pathlib import Path\nfrom typing import Sequence\n\nfrom spec2sdk.openapi.entities import DataType, StringDataType\nfrom spec2sdk.models.converters import converters, convert_common_fields\nfrom spec2sdk.models.entities import PythonType\nfrom spec2sdk.models.imports import Import\nfrom spec2sdk.main import generate\n\n\nclass EmailType(PythonType):\n    @property\n    def type_hint(self) -> str:\n        return self.name or \"EmailStr\"\n\n    @property\n    def imports(self) -> Sequence[Import]:\n        return (\n            Import(name=\"EmailStr\", package=\"pydantic\"),\n        )\n\n    def render(self) -> str:\n        return f\"type {self.name} = EmailStr\" if self.name else \"\"\n\n\ndef is_email_format(data_type: DataType) -> bool:\n    return isinstance(data_type, StringDataType) and data_type.format == \"email\"\n\n\n@converters.register(predicate=is_email_format)\ndef convert_email_field(data_type: StringDataType) -> EmailType:\n    return EmailType(**convert_common_fields(data_type))\n\n\nif __name__ == \"__main__\":\n    generate(schema_url=Path(\"api.yml\").absolute().as_uri(), output_dir=Path(\"output\"))\n```\n\n## Output\n\n```python\nfrom pydantic import EmailStr, Field\n\nclass User(Model):\n    name: str | None = Field(default=None)\n    email: EmailStr | None = Field(default=None)\n```\n\n# Using generated client\n\n1. Create HTTP client. It should conform to the `HTTPClientProtocol` which can be found in the generated `http_client.py`. Below is an example of the HTTP client implemented using `httpx` library to handle HTTP requests. Assume that `sdk` is the output directory for the generated code.\n```python\nfrom http import HTTPStatus\n\nimport httpx\nfrom httpx._types import AuthTypes, TimeoutTypes\n\nfrom sdk.http_client import HTTPRequest, HTTPResponse\n\n\nclass HTTPClient:\n    def __init__(self, *, base_url: str, auth: AuthTypes | None = None, timeout: TimeoutTypes | None = None, **kwargs):\n        self._http_client = httpx.Client(auth=auth, base_url=base_url, timeout=timeout, **kwargs)\n\n    def send_request(self, *, request: HTTPRequest) -> HTTPResponse:\n        response = self._http_client.request(\n            method=request.method,\n            url=request.url,\n            content=request.content,\n            headers=request.headers,\n        )\n        return HTTPResponse(\n            status_code=HTTPStatus(response.status_code),\n            content=response.content,\n            headers=response.headers.multi_items(),\n        )\n```\n2. Create API client. It should conform to the `APIClientProtocol` which can be found in the generated `api_client.py`. Below is an example of the API client.\n```python\nfrom http import HTTPMethod, HTTPStatus\nfrom types import NoneType\nfrom typing import Any, Mapping, Type\nfrom urllib.parse import urlencode\n\nfrom pydantic import TypeAdapter\n\nfrom sdk.api_client import APIClientResponse\nfrom sdk.http_client import HTTPClientProtocol, HTTPRequest\n\n\nclass APIClient:\n    def __init__(self, http_client: HTTPClientProtocol):\n        self._http_client = http_client\n\n    def serialize[T](self, *, data: T, data_type: Type[T], content_type: str | None) -> bytes:\n        match content_type:\n            case \"application/json\":\n                return TypeAdapter(data_type).dump_json(data, by_alias=True)\n            case _:\n                return data\n\n    def deserialize[T](self, *, data: bytes | None, data_type: Type[T], content_type: str | None) -> T:\n        match content_type:\n            case \"application/json\":\n                return TypeAdapter(data_type).validate_json(data)\n            case _:\n                return data\n\n    def build_url(self, path: str, query: Mapping[str, Any] | None = None) -> str:\n        if query is None:\n            return path\n\n        return f\"{path}?{urlencode(query, doseq=True)}\"\n\n    def send_request[I, O](\n        self,\n        *,\n        method: HTTPMethod,\n        path: str,\n        query: Mapping[str, Any] | None = None,\n        content_type: str | None = None,\n        data: I | None = None,\n        data_type: Type[I] = NoneType,\n        accept: str | None = None,\n        response_type: Type[O] = NoneType,\n        expected_status_code: HTTPStatus = HTTPStatus.OK,\n    ) -> APIClientResponse[O]:\n        content = self.serialize(data=data, data_type=data_type, content_type=content_type) if data else None\n        request = HTTPRequest(\n            method=method,\n            url=self.build_url(path, query),\n            headers=((\"Content-Type\", content_type),) if content_type else (),\n            content=content,\n        )\n        response = self._http_client.send_request(request=request)\n\n        if response.status_code != expected_status_code:\n            raise Exception(\n                f\"Response has unexpected status code. Expected {expected_status_code}, got {response.status_code}.\"\n            )\n\n        if accept is not None and not any(\n            response_content_type := tuple(\n                value for key, value in response.headers if (key.lower() == \"content-type\") and (accept in value)\n            ),\n        ):\n            raise Exception(f\"Response has unexpected content type. Expected {accept}, got {response_content_type}.\")\n\n        return APIClientResponse(\n            http_response=response,\n            data=self.deserialize(data=response.content, data_type=response_type, content_type=accept),\n        )\n```\n3. Combine clients together to access API.\n```python\nfrom sdk.api import API\n\napi = API(\n    api_client=APIClient(\n        http_client=HTTPClient(\n            base_url=\"https://api.example.com\",\n            auth=BasicAuth(username=\"user\", password=\"pass\"),\n        ),\n    ),\n)\n```\n",
    "bugtrack_url": null,
    "license": "MIT",
    "summary": "Generate Pydantic models and API client code from OpenAPI 3.x specifications",
    "version": "1.0.202502171012",
    "project_urls": {
        "Homepage": "https://github.com/moneymeets/spec2sdk",
        "Repository": "https://github.com/moneymeets/spec2sdk"
    },
    "split_keywords": [
        "openapi",
        " pydantic",
        " code-generator",
        " openapi-codegen"
    ],
    "urls": [
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "4edac0628f75784e9bc9f2010e40fa028c54622ae020747233168c1c811bdd57",
                "md5": "29213d238774cff386a01281062a6c8d",
                "sha256": "613f1d05a09e8ed69c6e3b5fc49d574f72e78338610d4f155e0660070c538e9f"
            },
            "downloads": -1,
            "filename": "spec2sdk-1.0.202502171012-py3-none-any.whl",
            "has_sig": false,
            "md5_digest": "29213d238774cff386a01281062a6c8d",
            "packagetype": "bdist_wheel",
            "python_version": "py3",
            "requires_python": "<3.13,>=3.12",
            "size": 24111,
            "upload_time": "2025-02-17T10:12:23",
            "upload_time_iso_8601": "2025-02-17T10:12:23.185110Z",
            "url": "https://files.pythonhosted.org/packages/4e/da/c0628f75784e9bc9f2010e40fa028c54622ae020747233168c1c811bdd57/spec2sdk-1.0.202502171012-py3-none-any.whl",
            "yanked": false,
            "yanked_reason": null
        },
        {
            "comment_text": "",
            "digests": {
                "blake2b_256": "7381778110407cf36ab859cd7d50e54303dac2d033d4bd90467f1f42dfd4695e",
                "md5": "638a1fc1ac544b8bc16c04431cc22f0a",
                "sha256": "95065942496283081e8727c69d805cadbe1fa14e608695c3347e14129da608e9"
            },
            "downloads": -1,
            "filename": "spec2sdk-1.0.202502171012.tar.gz",
            "has_sig": false,
            "md5_digest": "638a1fc1ac544b8bc16c04431cc22f0a",
            "packagetype": "sdist",
            "python_version": "source",
            "requires_python": "<3.13,>=3.12",
            "size": 18567,
            "upload_time": "2025-02-17T10:12:25",
            "upload_time_iso_8601": "2025-02-17T10:12:25.848345Z",
            "url": "https://files.pythonhosted.org/packages/73/81/778110407cf36ab859cd7d50e54303dac2d033d4bd90467f1f42dfd4695e/spec2sdk-1.0.202502171012.tar.gz",
            "yanked": false,
            "yanked_reason": null
        }
    ],
    "upload_time": "2025-02-17 10:12:25",
    "github": true,
    "gitlab": false,
    "bitbucket": false,
    "codeberg": false,
    "github_user": "moneymeets",
    "github_project": "spec2sdk",
    "travis_ci": false,
    "coveralls": false,
    "github_actions": true,
    "lcname": "spec2sdk"
}
        
Elapsed time: 0.67082s