├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .python-version ├── CHANGELOG.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.md ├── LICENSE ├── Makefile ├── README.md ├── clientele ├── cli.py ├── generators │ ├── README.md │ ├── __init__.py │ ├── basic │ │ ├── __init__.py │ │ ├── generator.py │ │ ├── templates │ │ │ ├── client_py.jinja2 │ │ │ ├── config_py.jinja2 │ │ │ ├── http_py.jinja2 │ │ │ ├── manifest.jinja2 │ │ │ └── schemas_py.jinja2 │ │ └── writer.py │ └── standard │ │ ├── __init__.py │ │ ├── generator.py │ │ ├── generators │ │ ├── __init__.py │ │ ├── clients.py │ │ ├── http.py │ │ └── schemas.py │ │ ├── templates │ │ ├── async_methods.jinja2 │ │ ├── basic_client.jinja2 │ │ ├── bearer_client.jinja2 │ │ ├── client.jinja2 │ │ ├── client_py.jinja2 │ │ ├── config_py.jinja2 │ │ ├── get_method.jinja2 │ │ ├── http_py.jinja2 │ │ ├── manifest.jinja2 │ │ ├── post_method.jinja2 │ │ ├── schema_class.jinja2 │ │ ├── schema_helpers.jinja2 │ │ ├── schemas_py.jinja2 │ │ └── sync_methods.jinja2 │ │ ├── utils.py │ │ └── writer.py ├── settings.py └── utils.py ├── docs ├── CHANGELOG.md ├── clientele.jpeg ├── compatibility.md ├── design.md ├── examples.md ├── index.md ├── install.md ├── testing.md └── usage.md ├── example_openapi_specs ├── best.json ├── simple.json ├── test_303.yaml └── twilio.json ├── mkdocs.yml ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── async_test_client ├── MANIFEST.md ├── __init__.py ├── client.py ├── config.py ├── http.py └── schemas.py ├── generators └── standard │ └── test_utils.py ├── test_async_generated_client.py ├── test_client ├── MANIFEST.md ├── __init__.py ├── client.py ├── config.py ├── http.py └── schemas.py └── test_generated_client.py /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.10", "3.11", "3.12"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | #---------------------------------------------- 14 | # check-out repo and set-up python 15 | #---------------------------------------------- 16 | - name: Check out repository 17 | uses: actions/checkout@v3 18 | - name: Set up python 19 | id: setup-python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{matrix.python-version}} 23 | #---------------------------------------------- 24 | # ----- install & configure poetry ----- 25 | #---------------------------------------------- 26 | - name: Install Poetry 27 | uses: snok/install-poetry@v1 28 | with: 29 | virtualenvs-create: true 30 | virtualenvs-in-project: true 31 | installer-parallel: true 32 | 33 | #---------------------------------------------- 34 | # load cached venv if cache exists 35 | #---------------------------------------------- 36 | - name: Load cached venv 37 | id: cached-poetry-dependencies 38 | uses: actions/cache@v3 39 | with: 40 | path: .venv 41 | key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }} 42 | #---------------------------------------------- 43 | # install dependencies if cache does not exist 44 | #---------------------------------------------- 45 | - name: Install dependencies 46 | if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' 47 | run: poetry install --no-interaction --no-root 48 | #---------------------------------------------- 49 | # Do the actuall checks 50 | #---------------------------------------------- 51 | - name: Ruff format 52 | run: | 53 | poetry run ruff format --check . 54 | - name: Ruff linting 55 | run: | 56 | poetry run ruff . 57 | - name: Test with pytest 58 | run: | 59 | poetry run pytest -vv 60 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/ 2 | .mypy_cache/ 3 | env.ini 4 | .ruff_cache/ 5 | .pytest_cache/ 6 | .idea/ 7 | __pycache__ 8 | .DS_Store 9 | test_output/ 10 | dist/ 11 | site/ 12 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.11.9 2 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 0.9.0 4 | 5 | - Support `patch` methods 6 | - Fix `config.py` file being overwritten when generating new clients 7 | 8 | ## 0.8.3 9 | 10 | - Fix bug with headers assignment 11 | 12 | ## 0.8.2 13 | 14 | - Improved json support 15 | 16 | ## 0.8.1 17 | 18 | - Function parameters no longer format to snake_case to maintain consistency with the OpenAPI schema. 19 | 20 | ## 0.8.0 21 | 22 | - Improved support for Async clients which prevents a weird bug when running more than one event loop. Based on the suggestions from [this httpx issue](https://github.com/encode/httpcore/discussions/659). 23 | - We now use [`ruff format`](https://astral.sh/blog/the-ruff-formatter) for coding formatting (not the client output). 24 | - `Decimal` support now extends to Decimal input values. 25 | - Input and Output schemas will now have properties that directly match those provided by the OpenAPI schema. This fixes a bug where previously, the snake-case formatting did not match up with what the API expected to send or receive. 26 | 27 | ## 0.7.1 28 | 29 | - Support for `Decimal` types. 30 | 31 | ## 0.7.0 32 | 33 | - Updated all files to use the templates engine. 34 | - Generator files have been reorganised in clientele to support future templates. 35 | - `constants.py` has been renamed to `config.py` to better reflect how it is used. It is not generated from a template like the other files. 36 | - If you are using Python 3.10 or later, the `typing.Unions` types will generate as the short hand `|` instead. 37 | - To regenerate a client (and to prevent accidental overrides) you must now pass `--regen t` or `-r t` to the `generate` command. This is automatically added to the line in `MANIFEST.md` to help. 38 | - Clientele will now automatically run [black](https://black.readthedocs.io/en/stable/) code formatter once a client is generated or regenerated. 39 | - Clientele will now generate absolute paths to refer to adjacent files in the generated client, instead of relative paths. This assumes you are running the `clientele` command in the root directory of your project. 40 | - A lot of documentation and docs strings updates so that code in the generated client is easier to understand. 41 | - Improved the utility for snake-casing enum keys. Tests added for the functions. 42 | - Python 3.12 support. 43 | - Add a "basic" client using the command `generate-basic`. This can be used to keep a consistent file structure for an API that does not use OpenAPI. 44 | 45 | ## 0.6.3 46 | 47 | - Packaged application installs in the correct location. Resolving [#6](https://github.com/phalt/clientele/issues/6) 48 | - Updated pyproject.toml to include a better selection of links. 49 | 50 | ## 0.6.2 51 | 52 | - Ignore optional URL query parameters if they are `None`. 53 | 54 | ## 0.6.1 55 | 56 | - Added `from __future__ import annotations` in files to help with typing evaluation. 57 | - Update to use pydantic 2.4. 58 | - A bunch of documentation and readme updates. 59 | - Small wording and grammar fixes. 60 | 61 | ## 0.6.0 62 | 63 | - Significantly improved handling for response schemas. Responses from API endpoints now look at the HTTP status code to pick the correct response schema to generate from the HTTP json data. When regenerating, you will notice a bit more logic generated in the `http.py` file to handle this. 64 | - Significantly improved coverage of exceptions raised when trying to generate response schemas. 65 | - Response types for a class are now sorted. 66 | - Fixed a bug where `put` methods did not generate input data correctly. 67 | 68 | ## 0.5.2 69 | 70 | - Fix pathing for `constants.py` - thanks to @matthewknight for the contribution! 71 | - Added `CONTRIBUTORS.md` 72 | 73 | ## 0.5.1 74 | 75 | - Support for HTTP PUT methods 76 | - Headers objects use `exclude_unset` to avoid passing `None` values as headers, which httpx does not support. 77 | 78 | Additionally, an async test client is now included in the test suite. It has identical tests to the standard one but uses the async client instead. 79 | 80 | ## 0.5.0 81 | 82 | ### Please delete the constants.py file when updating to this version to have new features take affect 83 | 84 | - Paths are resolved correctly when generating clients in nested directories. 85 | - `additional_headers()` is now applied to every client, allowing you to set up headers for all requests made by your client. 86 | - When the client cannot match an HTTP response to a return type for the function it will now raise an `http.APIException`. This object will have the `response` attached to it for inspection by the developer. 87 | - `MANIFEST` is now renamed to `MANIFEST.md` and will include install information for Clientele, as well as information on the command used to generate the client. 88 | 89 | ## 0.4.4 90 | 91 | Examples and documentation now includes a very complex example schema built using [FastAPI](https://fastapi.tiangolo.com/) that offers the following variations: 92 | 93 | - Simple request / response (no input just an output) 94 | - A request with a URL/Path parameter. 95 | - Models with `int`, `str`, `list`, `dict`, references to other models, enums, and `list`s of other models and enums. 96 | - A request with query parameters. 97 | - A response model that has optional parameters. 98 | - An HTTP POST request that takes an input model. 99 | - An HTTP POST request that takes path parameters and also an input model. 100 | - An HTTP GET request that requires an HTTP header, and returns it. 101 | - An HTTP GET endpoint that returns the HTTP bearer authorization token (also makes clientele generate the http authentication for this schema). 102 | 103 | A huge test suite has been added to the CI pipeline for this project using a copy of the generated client from the schema above. 104 | 105 | ## 0.4.3 106 | 107 | - `Enums` now inherit from `str` as well so that they serialize to JSON properly. See [this little nugget](https://hultner.se/quickbits/2018-03-12-python-json-serializable-enum.html). 108 | 109 | ## 0.4.2 110 | 111 | - Correctly use `model_rebuild` for complex schemas where there are nested schemas, his may be necessary when one of the annotations is a ForwardRef which could not be resolved during the initial attempt to build the schema. 112 | - Do not raise for status, instead attempt to return the response if it cannot match a response type. 113 | 114 | ## 0.4.1 115 | 116 | - Correctly generate lists of nested schema classes 117 | - Correctly build response schemas that are emphemeral (such as when they just return an array of other schemas, or when they have no $ref). 118 | 119 | ## 0.4.0 120 | 121 | - Change install suggestion to use [pipx](https://github.com/pypa/pipx) as it works best as a global CLI tool. 122 | - Improved support for OpenAPI 3.0.3 schemas (a test version is available in the example_openapi_specs directory). 123 | - `validate` command for validating an OpenAPI schema will work with clientele. 124 | - `version` command for showing the current version of clientele. 125 | - Supports HTTP DELETE methods. 126 | - Big refactor of how methods are generated to reduce duplicate code. 127 | - Support optional header parameters in all request functions (where they are required). 128 | - Very simple Oauth2 support - if it is discovered will set up HTTP Bearer auth for you. 129 | - Uses `dict` and `list` instead of `typing.Dict` and `typing.List` respectively. 130 | - Improved schema generation when schemas have $ref to other models. 131 | 132 | ## 0.3.2 133 | 134 | - Minor changes to function name generation to make it more consistent. 135 | - Optional parameters in schemas are working properly. 136 | 137 | ## 0.3.1 138 | 139 | - Fixes a bug when generating HTTP Authentication schema. 140 | - Fixes a bug when generating input classes for post functions, when the input schema doesn't exist yet. 141 | - Generates pythonic function names in clients now, always (like `lower_case_snake_case`). 142 | 143 | ## 0.3.0 144 | 145 | - Now generates a `MANIFEST` file with information about the build versions 146 | - Added a `constants.py` file to the output if one does not exist yet, which can be used to store values that you do not want to change between subsequent re-generations of the clientele client, such as the API base url. 147 | - Authentication patterns now use `constants.py` for constants values. 148 | - Removed `ipython` from package dependencies and moved to dev dependencies. 149 | - Documentation! [https://phalt.github.io/clientele/](https://phalt.github.io/clientele/) 150 | 151 | ## 0.2.0 152 | 153 | - Improved CLI output 154 | - Code organisation is now sensible and not just one giant file 155 | - Now supports an openapi spec generated from a dotnet project (`Microsoft.OpenApi.Models`) 156 | - async client support fully working 157 | - HTTP Bearer support 158 | - HTTP Basic support 159 | 160 | ## 0.1.0 161 | 162 | - Initial version 163 | - Mostly works with a simple FastAPI generated spec (3.0.2) 164 | - Works with Twilio's spec (see example_openapi_specs/ directory) (3.0.1) 165 | - Almost works with stripes 166 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | First things first: thank you for contributing! This project will be succesful thanks to everyone who contributes, and we're happy to have you. 4 | 5 | ## Bug or issue? 6 | 7 | To raise a bug or issue please use [our GitHub](https://github.com/phalt/clientele/issues). 8 | 9 | Please check the issue has not be raised before by using the search feature. 10 | 11 | When submitting an issue or bug, please make sure you provide thorough detail on: 12 | 13 | 1. The version of clientele you are using 14 | 2. Any errors or outputs you see in your terminal 15 | 3. The OpenAPI schema you are using (this is particularly important). 16 | 17 | ## Contribution 18 | 19 | If you want to directly contribute you can do so in two ways: 20 | 21 | 1. Documentation 22 | 2. Code 23 | 24 | ### Documentation 25 | 26 | We use [mkdocs](https://www.mkdocs.org/) and [GitHub pages](https://pages.github.com/) to deploy our docs. 27 | 28 | Fixing grammar, spelling mistakes, or expanding the documentation to cover features that are not yet documented, are all valuable contributions. 29 | 30 | Please see the **Set up** instructions below to run the docs locally on your computer. 31 | 32 | ### Code 33 | 34 | Contribution by writing code for new features, or fixing bugs, is a great way to contribute to the project. 35 | 36 | #### Set up 37 | 38 | Clone the repo: 39 | 40 | ```sh 41 | git@github.com:phalt/clientele.git 42 | cd clientele 43 | ``` 44 | 45 | Move to a feature branch: 46 | 47 | ```sh 48 | git branch -B my-branch-name 49 | ``` 50 | 51 | Install all the dependencies: 52 | 53 | ```sh 54 | python3.11 -m venv .venv 55 | source .venv/bin/activate 56 | make install 57 | ``` 58 | 59 | To make sure you have things set up correctly, please run the tests: 60 | 61 | ```sh 62 | make test 63 | ``` 64 | 65 | ### Preparing changes for review 66 | 67 | Once you have made changes, here is a good check list to run through to get it published for review: 68 | 69 | Regenerate the test clients to see what has changed, and if tests pass: 70 | 71 | ```sh 72 | make generate-test-clients 73 | make test 74 | ``` 75 | 76 | Check your `git diff` to see if anything drastic has changed. If changes happen that you did not expect, something has gone wrong. We want to make sure the clients do not change drastically when adding new features unless it is intended. 77 | 78 | Format and lint the code: 79 | 80 | ```sh 81 | make format 82 | ``` 83 | 84 | Note that, the auto-generated black formatted code will be changed again because this project uses `ruff` for additional formatting. That's okay. 85 | 86 | Make sure you add to `CHANGELOG.md` and `docs/CHANGELOG.md` what changes you have made. 87 | 88 | Make sure you add your name to `CONTRIBUTORS.md` as well! 89 | 90 | ### Making a pull request 91 | 92 | Please push your changes up to a feature branch and make a new [pull request](https://github.com/phalt/clientele/compare) on GitHub. 93 | 94 | Please add a description to the PR and some information about why the change is being made. 95 | 96 | After a review you might need to make more changes. 97 | 98 | Once accepted, a core contributor will merge your changes! 99 | -------------------------------------------------------------------------------- /CONTRIBUTORS.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | - [Paul Hallett](https://github.com/phalt) 4 | - [Matthew Knight](https://github.com/matthewknight) 5 | - [Pradish Bijukchhe](https://github.com/pradishb) 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Paul Hallett 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo Developer commands for Clientele 3 | @echo 4 | @grep -E '^[ .a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 5 | @echo 6 | 7 | install: ## Install requirements ready for development 8 | poetry install 9 | 10 | deploy-docs: ## Build and deploy the documentation 11 | mkdocs build 12 | mkdocs gh-deploy 13 | 14 | release: ## Build a new version and release it 15 | poetry build 16 | poetry publish 17 | 18 | mypy: ## Run a static syntax check 19 | poetry run mypy . 20 | 21 | format: ## Format the code correctly 22 | poetry run ruff format . 23 | poetry run ruff --fix . 24 | 25 | clean: ## Clear any cache files and test files 26 | rm -rf .mypy_cache 27 | rm -rf .pytest_cache 28 | rm -rf .ruff_cache 29 | rm -rf test_output 30 | rm -rf site/ 31 | rm -rf dist/ 32 | rm -rf **/__pycache__ 33 | rm -rf **/*.pyc 34 | 35 | test: ## Run tests 36 | pytest -vvv 37 | 38 | shell: ## Run an ipython shell 39 | poetry run ipython 40 | 41 | generate-test-clients: ## regenerate the test clients in the tests/ directory 42 | poetry install 43 | clientele generate -f example_openapi_specs/best.json -o tests/test_client/ --regen t 44 | clientele generate -f example_openapi_specs/best.json -o tests/async_test_client/ --asyncio t --regen t 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⚜️ Clientele 2 | 3 | ## Generate loveable Python HTTP API Clients 4 | 5 | [![Package version](https://img.shields.io/pypi/v/clientele?color=%2334D058&label=latest%20version)](https://pypi.org/project/clientele) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/clientele?label=python%20support) 7 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/clientele) 8 | ![PyPI - License](https://img.shields.io/pypi/l/clientele) 9 | 10 | Clientele lets you generate fully-typed, pythonic HTTP API Clients using an OpenAPI schema. 11 | 12 | It's easy to use: 13 | 14 | ```sh 15 | # Install as a global tool - it's not a dependency! 16 | pipx install clientele 17 | # Generate a client 18 | clientele generate -u https://raw.githubusercontent.com/phalt/clientele/main/example_openapi_specs/best.json -o api_client/ 19 | ``` 20 | 21 | ## Generated code 22 | 23 | The generated code is designed by python developers, for python developers. 24 | 25 | It uses modern tooling and has a great developer experience. 26 | 27 | ```py 28 | from my_api import client, schemas 29 | 30 | # Pydantic models for inputs and outputs 31 | data = schemas.RequestDataRequest(my_input="test") 32 | 33 | # Easy to read client functions 34 | response = client.request_data_request_data_post(data=data) 35 | 36 | # Handle responses elegantly 37 | match response: 38 | case schemas.RequestDataResponse(): 39 | # Handle valid response 40 | ... 41 | case schemas.ValidationError(): 42 | # Handle validation error 43 | ... 44 | ``` 45 | 46 | The generated code is tiny - the [example schema](https://github.com/phalt/clientele/blob/main/example_openapi_specs/best.json) we use for documentation and testing only requires [250 lines of code](https://github.com/phalt/clientele/tree/main/tests/test_client) and 5 files. 47 | 48 | ## Async support 49 | 50 | You can choose to generate either a sync or an async client - we support both: 51 | 52 | ```py 53 | from my_async_api import client 54 | 55 | # Async client functions 56 | response = await client.simple_request_simple_request_get() 57 | ``` 58 | 59 | ## Other features 60 | 61 | * Written entirely in Python. 62 | * Designed to work with [FastAPI](https://fastapi.tiangolo.com/)'s and [drf-spectacular](https://github.com/tfranzel/drf-spectacular)'s OpenAPI schema generator. 63 | * The generated client only depends on [httpx](https://www.python-httpx.org/) and [Pydantic 2.4](https://docs.pydantic.dev/latest/). 64 | * HTTP Basic and HTTP Bearer authentication support. 65 | * Support your own configuration - we provide an entry point that will never be overwritten. 66 | * Designed for easy testing with [respx](https://lundberg.github.io/respx/). 67 | * API updated? Just run the same command again and check the git diff. 68 | * Automatically formats the generated client with [black](https://black.readthedocs.io/en/stable/index.html). 69 | -------------------------------------------------------------------------------- /clientele/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | @click.group() 5 | def cli_group(): 6 | """ 7 | Clientele: Generate loveable Python HTTP API Clients 8 | https://github.com/phalt/clientele 9 | """ 10 | 11 | 12 | @click.command() 13 | def version(): 14 | """ 15 | Print the current version of clientele 16 | """ 17 | from clientele.settings import VERSION 18 | 19 | print(f"clientele {VERSION}") 20 | 21 | 22 | @click.command() 23 | @click.option("-u", "--url", help="URL to openapi schema (json file)", required=False) 24 | @click.option("-f", "--file", help="Path to openapi schema (json file)", required=False) 25 | def validate(url, file): 26 | """ 27 | Validate an OpenAPI schema. Will error if anything is wrong with the schema 28 | """ 29 | from json import JSONDecodeError 30 | 31 | import yaml 32 | from httpx import Client 33 | from openapi_core import Spec 34 | from rich.console import Console 35 | 36 | console = Console() 37 | 38 | assert url or file, "Must pass either a URL or a file" 39 | 40 | if url: 41 | client = Client() 42 | response = client.get(url) 43 | try: 44 | data = response.json() 45 | except JSONDecodeError: 46 | # It's probably yaml 47 | data = yaml.safe_load(response.content) 48 | spec = Spec.from_dict(data) 49 | else: 50 | with open(file, "r") as f: 51 | Spec.from_file(f) 52 | console.log(f"Found API specification: {spec['info']['title']} | version {spec['info']['version']}") 53 | major, _, _ = spec["openapi"].split(".") 54 | if int(major) < 3: 55 | console.log(f"[red]Clientele only supports OpenAPI version 3.0.0 and up, and you have {spec['openapi']}") 56 | return 57 | console.log("schema validated successfully! You can generate a client with it") 58 | 59 | 60 | @click.command() 61 | @click.option("-u", "--url", help="URL to openapi schema (URL)", required=False) 62 | @click.option("-f", "--file", help="Path to openapi schema (json or yaml file)", required=False) 63 | @click.option("-o", "--output", help="Directory for the generated client", required=True) 64 | @click.option("-a", "--asyncio", help="Generate async client", required=False) 65 | @click.option("-r", "--regen", help="Regenerate client", required=False) 66 | def generate(url, file, output, asyncio, regen): 67 | """ 68 | Generate a new client from an OpenAPI schema 69 | """ 70 | from json import JSONDecodeError 71 | 72 | import yaml 73 | from httpx import Client 74 | from openapi_core import Spec 75 | from rich.console import Console 76 | 77 | console = Console() 78 | 79 | from clientele.generators.standard.generator import StandardGenerator 80 | 81 | assert url or file, "Must pass either a URL or a file" 82 | 83 | if url: 84 | client = Client() 85 | response = client.get(url) 86 | try: 87 | data = response.json() 88 | except JSONDecodeError: 89 | # It's probably yaml 90 | data = yaml.safe_load(response.content) 91 | spec = Spec.from_dict(data) 92 | else: 93 | with open(file, "r") as f: 94 | spec = Spec.from_file(f) 95 | console.log(f"Found API specification: {spec['info']['title']} | version {spec['info']['version']}") 96 | major, _, _ = spec["openapi"].split(".") 97 | if int(major) < 3: 98 | console.log(f"[red]Clientele only supports OpenAPI version 3.0.0 and up, and you have {spec['openapi']}") 99 | return 100 | generator = StandardGenerator(spec=spec, asyncio=asyncio, regen=regen, output_dir=output, url=url, file=file) 101 | if generator.prevent_accidental_regens(): 102 | generator.generate() 103 | console.log("\n[green]⚜️ Client generated! ⚜️ \n") 104 | console.log("[yellow]REMEMBER: install `httpx` `pydantic`, and `respx` to use your new client") 105 | 106 | 107 | @click.command() 108 | @click.option("-o", "--output", help="Directory for the generated client", required=True) 109 | def generate_basic(output): 110 | """ 111 | Generate a "basic" file structure, no code. 112 | """ 113 | from rich.console import Console 114 | 115 | from clientele.generators.basic.generator import BasicGenerator 116 | 117 | console = Console() 118 | 119 | console.log(f"Generating basic client at {output}...") 120 | 121 | generator = BasicGenerator(output_dir=output) 122 | 123 | generator.generate() 124 | 125 | 126 | cli_group.add_command(generate) 127 | cli_group.add_command(generate_basic) 128 | cli_group.add_command(version) 129 | cli_group.add_command(validate) 130 | 131 | if __name__ == "__main__": 132 | cli_group() 133 | -------------------------------------------------------------------------------- /clientele/generators/README.md: -------------------------------------------------------------------------------- 1 | # Generators 2 | 3 | In the future, this will be the directory for all the possible generators that clientele supports. 4 | 5 | Copy the basic template if you want to start your own. 6 | 7 | ## Standard 8 | 9 | The standard generator 10 | 11 | ## Basic 12 | 13 | A basic client with a file structure and not much else. 14 | -------------------------------------------------------------------------------- /clientele/generators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/clientele/generators/__init__.py -------------------------------------------------------------------------------- /clientele/generators/basic/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/clientele/generators/basic/__init__.py -------------------------------------------------------------------------------- /clientele/generators/basic/generator.py: -------------------------------------------------------------------------------- 1 | from os import remove 2 | from os.path import exists 3 | 4 | from clientele import settings, utils 5 | from clientele.generators.basic import writer 6 | 7 | 8 | class BasicGenerator: 9 | """ 10 | Generates a "basic" HTTP client, which is just a file structure 11 | and some useful imports. 12 | 13 | This generator can be used as a template for future generators. 14 | 15 | It is also a great way to generate a file structure for consistent HTTP API clients 16 | that are not OpenAPI but you want to keep the same file structure. 17 | """ 18 | 19 | def __init__(self, output_dir: str) -> None: 20 | self.output_dir = output_dir 21 | 22 | self.file_name_writer_tuple = ( 23 | ("config.py", "config_py.jinja2", writer.write_to_config), 24 | ("client.py", "client_py.jinja2", writer.write_to_client), 25 | ("http.py", "http_py.jinja2", writer.write_to_http), 26 | ("schemas.py", "schemas_py.jinja2", writer.write_to_schemas), 27 | ) 28 | 29 | def generate(self) -> None: 30 | client_project_directory_path = utils.get_client_project_directory_path(output_dir=self.output_dir) 31 | if exists(f"{self.output_dir}/MANIFEST.md"): 32 | remove(f"{self.output_dir}/MANIFEST.md") 33 | manifest_template = writer.templates.get_template("manifest.jinja2") 34 | manifest_content = manifest_template.render(command=f"-o {self.output_dir}", clientele_version=settings.VERSION) 35 | writer.write_to_manifest(content=manifest_content + "\n", output_dir=self.output_dir) 36 | writer.write_to_init(output_dir=self.output_dir) 37 | for ( 38 | client_file, 39 | client_template_file, 40 | write_func, 41 | ) in self.file_name_writer_tuple: 42 | if exists(f"{self.output_dir}/{client_file}"): 43 | remove(f"{self.output_dir}/{client_file}") 44 | template = writer.templates.get_template(client_template_file) 45 | content = template.render( 46 | client_project_directory_path=client_project_directory_path, 47 | ) 48 | write_func(content, output_dir=self.output_dir) 49 | -------------------------------------------------------------------------------- /clientele/generators/basic/templates/client_py.jinja2: -------------------------------------------------------------------------------- 1 | """ 2 | API Client functions. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import typing # noqa 8 | 9 | from {{client_project_directory_path}} import http, schemas # noqa 10 | -------------------------------------------------------------------------------- /clientele/generators/basic/templates/config_py.jinja2: -------------------------------------------------------------------------------- 1 | """ 2 | API Client configuration. 3 | """ 4 | -------------------------------------------------------------------------------- /clientele/generators/basic/templates/http_py.jinja2: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP layer management. 3 | """ 4 | 5 | import typing 6 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 7 | import httpx # noqa 8 | 9 | from {{client_project_directory_path}} import config as c # noqa 10 | 11 | 12 | class APIException(Exception): 13 | """Could not match API response to return type of this function""" 14 | 15 | reason: str 16 | response: httpx.Response 17 | 18 | def __init__(self, response: httpx.Response, reason: str, *args: object) -> None: 19 | self.response = response 20 | self.reason = reason 21 | super().__init__(*args) 22 | 23 | 24 | def parse_url(url: str) -> str: 25 | """ 26 | Returns the full URL from a string. 27 | 28 | Will filter out any optional query parameters if they are None. 29 | """ 30 | api_url = f"{c.api_base_url()}{url}" 31 | url_parts = urlparse(url=api_url) 32 | # Filter out "None" optional query parameters 33 | filtered_query_params = { 34 | k: v for k, v in parse_qs(url_parts.query).items() if v[0] not in ["None", ""] 35 | } 36 | filtered_query_string = urlencode(filtered_query_params, doseq=True) 37 | return urlunparse( 38 | ( 39 | url_parts.scheme, 40 | url_parts.netloc, 41 | url_parts.path, 42 | url_parts.params, 43 | filtered_query_string, 44 | url_parts.fragment, 45 | ) 46 | ) 47 | 48 | client = httpx.Client() 49 | 50 | 51 | def get(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 52 | """Issue an HTTP GET request""" 53 | return client.get(parse_url(url), headers=headers) 54 | 55 | 56 | def post(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 57 | """Issue an HTTP POST request""" 58 | return client.post(parse_url(url), json=data, headers=headers) 59 | 60 | 61 | def put(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 62 | """Issue an HTTP PUT request""" 63 | return client.put(parse_url(url), json=data, headers=headers) 64 | 65 | 66 | def patch(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 67 | """Issue an HTTP PATCH request""" 68 | return client.patch(parse_url(url), json=data, headers=headers) 69 | 70 | 71 | def delete(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 72 | """Issue an HTTP DELETE request""" 73 | return client.delete(parse_url(url), headers=headers) 74 | -------------------------------------------------------------------------------- /clientele/generators/basic/templates/manifest.jinja2: -------------------------------------------------------------------------------- 1 | # Manifest 2 | 3 | Generated with [https://github.com/phalt/clientele](https://github.com/phalt/clientele) 4 | Install with pipx: 5 | 6 | ```sh 7 | pipx install clientele 8 | ``` 9 | 10 | CLIENTELE VERSION: {{clientele_version}} 11 | 12 | Regenerate using this command: 13 | 14 | ```sh 15 | clientele generate-basic {{command}} 16 | ``` 17 | -------------------------------------------------------------------------------- /clientele/generators/basic/templates/schemas_py.jinja2: -------------------------------------------------------------------------------- 1 | """ 2 | API Schemas. 3 | """ 4 | 5 | from __future__ import annotations 6 | 7 | import typing # noqa 8 | from enum import Enum # noqa 9 | 10 | import pydantic # noqa 11 | -------------------------------------------------------------------------------- /clientele/generators/basic/writer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from jinja2 import Environment, PackageLoader 4 | 5 | templates = Environment(loader=PackageLoader("clientele", "generators/basic/templates/")) 6 | 7 | 8 | def write_to_schemas(content: str, output_dir: str) -> None: 9 | path = Path(output_dir) / "schemas.py" 10 | _write_to(path, content) 11 | 12 | 13 | def write_to_http(content: str, output_dir: str) -> None: 14 | path = Path(output_dir) / "http.py" 15 | _write_to(path, content) 16 | 17 | 18 | def write_to_client(content: str, output_dir: str) -> None: 19 | path = Path(output_dir) / "client.py" 20 | _write_to(path, content) 21 | 22 | 23 | def write_to_manifest(content: str, output_dir: str) -> None: 24 | path = Path(output_dir) / "MANIFEST.md" 25 | _write_to(path, content) 26 | 27 | 28 | def write_to_config(content: str, output_dir: str) -> None: 29 | path = Path(output_dir) / "config.py" 30 | _write_to(path, content) 31 | 32 | 33 | def write_to_init(output_dir: str) -> None: 34 | path = Path(output_dir) / "__init__.py" 35 | _write_to(path, "") 36 | 37 | 38 | def _write_to( 39 | path: Path, 40 | content: str, 41 | ) -> None: 42 | path.parent.mkdir(parents=True, exist_ok=True) 43 | with path.open("a+") as f: 44 | f.write(content) 45 | -------------------------------------------------------------------------------- /clientele/generators/standard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/clientele/generators/standard/__init__.py -------------------------------------------------------------------------------- /clientele/generators/standard/generator.py: -------------------------------------------------------------------------------- 1 | from os import remove 2 | from os.path import exists 3 | from pathlib import Path 4 | from typing import Optional 5 | 6 | import black 7 | from openapi_core import Spec 8 | from rich.console import Console 9 | 10 | from clientele import settings, utils 11 | from clientele.generators.standard import writer 12 | from clientele.generators.standard.generators import clients, http, schemas 13 | 14 | console = Console() 15 | 16 | 17 | class StandardGenerator: 18 | """ 19 | The standard Clientele generator. 20 | 21 | Produces a Python HTTP Client library. 22 | """ 23 | 24 | spec: Spec 25 | asyncio: bool 26 | regen: bool 27 | schemas_generator: schemas.SchemasGenerator 28 | clients_generator: clients.ClientsGenerator 29 | http_generator: http.HTTPGenerator 30 | output_dir: str 31 | file: Optional[str] 32 | url: Optional[str] 33 | 34 | def __init__( 35 | self, 36 | spec: Spec, 37 | output_dir: str, 38 | asyncio: bool, 39 | regen: bool, 40 | url: Optional[str], 41 | file: Optional[str], 42 | ) -> None: 43 | self.http_generator = http.HTTPGenerator(spec=spec, output_dir=output_dir, asyncio=asyncio) 44 | self.schemas_generator = schemas.SchemasGenerator(spec=spec, output_dir=output_dir) 45 | self.clients_generator = clients.ClientsGenerator( 46 | spec=spec, 47 | output_dir=output_dir, 48 | schemas_generator=self.schemas_generator, 49 | http_generator=self.http_generator, 50 | asyncio=asyncio, 51 | ) 52 | self.spec = spec 53 | self.asyncio = asyncio 54 | self.regen = regen 55 | self.output_dir = output_dir 56 | self.file = file 57 | self.url = url 58 | self.file_name_writer_tuple = ( 59 | ("config.py", "config_py.jinja2", writer.write_to_config), 60 | ("client.py", "client_py.jinja2", writer.write_to_client), 61 | ("http.py", "http_py.jinja2", writer.write_to_http), 62 | ("schemas.py", "schemas_py.jinja2", writer.write_to_schemas), 63 | ) 64 | 65 | def generate_templates_files(self): 66 | new_unions = settings.PY_VERSION[1] > 10 67 | client_project_directory_path = utils.get_client_project_directory_path(output_dir=self.output_dir) 68 | writer.write_to_init(output_dir=self.output_dir) 69 | for ( 70 | client_file, 71 | client_template_file, 72 | write_func, 73 | ) in self.file_name_writer_tuple: 74 | if exists(f"{self.output_dir}/{client_file}"): 75 | if client_file == "config.py": # do not replace config.py if exists 76 | continue 77 | remove(f"{self.output_dir}/{client_file}") 78 | template = writer.templates.get_template(client_template_file) 79 | content = template.render( 80 | client_project_directory_path=client_project_directory_path, 81 | new_unions=new_unions, 82 | ) 83 | write_func(content, output_dir=self.output_dir) 84 | # Manifest file 85 | if exists(f"{self.output_dir}/MANIFEST.md"): 86 | remove(f"{self.output_dir}/MANIFEST.md") 87 | template = writer.templates.get_template("manifest.jinja2") 88 | generate_command = f'{f"-u {self.url}" if self.url else ""}{f"-f {self.file}" if self.file else ""} -o {self.output_dir} {"--asyncio t" if self.asyncio else ""} --regen t' # noqa 89 | content = ( 90 | template.render( 91 | api_version=self.spec["info"]["version"], 92 | openapi_version=self.spec["openapi"], 93 | clientele_version=settings.VERSION, 94 | command=generate_command, 95 | ) 96 | + "\n" 97 | ) 98 | writer.write_to_manifest(content, output_dir=self.output_dir) 99 | 100 | def prevent_accidental_regens(self) -> bool: 101 | if exists(self.output_dir): 102 | if not self.regen: 103 | console.log("[red]WARNING! If you want to regenerate, please pass --regen t") 104 | return False 105 | return True 106 | 107 | def format_client(self) -> None: 108 | directory = Path(self.output_dir) 109 | for f in directory.glob("*.py"): 110 | black.format_file_in_place(f, fast=False, mode=black.Mode(), write_back=black.WriteBack.YES) 111 | 112 | def generate(self) -> None: 113 | self.generate_templates_files() 114 | self.schemas_generator.generate_schema_classes() 115 | self.clients_generator.generate_paths() 116 | self.http_generator.generate_http_content() 117 | self.schemas_generator.write_helpers() 118 | self.format_client() 119 | -------------------------------------------------------------------------------- /clientele/generators/standard/generators/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/clientele/generators/standard/generators/__init__.py -------------------------------------------------------------------------------- /clientele/generators/standard/generators/clients.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | from typing import Optional 3 | 4 | from openapi_core import Spec 5 | from pydantic import BaseModel 6 | from rich.console import Console 7 | 8 | from clientele.generators.standard import utils, writer 9 | from clientele.generators.standard.generators import http, schemas 10 | 11 | console = Console() 12 | 13 | 14 | class ParametersResponse(BaseModel): 15 | # Parameters that need to be passed in the URL query 16 | query_args: dict[str, str] 17 | # Parameters that need to be passed as variables in the function 18 | path_args: dict[str, str] 19 | # Parameters that are needed in the headers object 20 | headers_args: dict[str, str] 21 | 22 | def get_path_args_as_string(self): 23 | # Get all the path arguments, and the query arguments and make a big string out of them. 24 | args = list(self.path_args.items()) + list(self.query_args.items()) 25 | return ", ".join(f"{k}: {v}" for k, v in args) 26 | 27 | 28 | class ClientsGenerator: 29 | """ 30 | Handles all the content generated in the clients.py file. 31 | """ 32 | 33 | method_template_map: dict[str, str] 34 | results: dict[str, int] 35 | spec: Spec 36 | output_dir: str 37 | schemas_generator: schemas.SchemasGenerator 38 | http_generator: http.HTTPGenerator 39 | 40 | def __init__( 41 | self, 42 | spec: Spec, 43 | output_dir: str, 44 | schemas_generator: schemas.SchemasGenerator, 45 | http_generator: http.HTTPGenerator, 46 | asyncio: bool, 47 | ) -> None: 48 | self.spec = spec 49 | self.output_dir = output_dir 50 | self.results = defaultdict(int) 51 | self.schemas_generator = schemas_generator 52 | self.http_generator = http_generator 53 | self.asyncio = asyncio 54 | self.method_template_map = dict( 55 | get="get_method.jinja2", 56 | delete="get_method.jinja2", 57 | post="post_method.jinja2", 58 | put="post_method.jinja2", 59 | patch="post_method.jinja2", 60 | ) 61 | 62 | def generate_paths(self) -> None: 63 | for path in self.spec["paths"].items(): 64 | self.write_path_to_client(path=path) 65 | console.log(f"Generated {self.results['get']} GET methods...") 66 | console.log(f"Generated {self.results['post']} POST methods...") 67 | console.log(f"Generated {self.results['put']} PUT methods...") 68 | console.log(f"Generated {self.results['patch']} PATCH methods...") 69 | console.log(f"Generated {self.results['delete']} DELETE methods...") 70 | 71 | def generate_parameters(self, parameters: list[dict], additional_parameters: list[dict]) -> ParametersResponse: 72 | param_keys = [] 73 | query_args = {} 74 | path_args = {} 75 | headers_args = {} 76 | all_parameters = parameters + additional_parameters 77 | for param in all_parameters: 78 | if param.get("$ref"): 79 | # Get the actual parameter it is referencing 80 | param = utils.get_param_from_ref(spec=self.spec, param=param) 81 | clean_key = param["name"] 82 | if clean_key in param_keys: 83 | continue 84 | in_ = param.get("in") 85 | required = param.get("required", False) or in_ != "query" 86 | if in_ == "query": 87 | # URL query string values 88 | if required: 89 | query_args[clean_key] = utils.get_type(param["schema"]) 90 | else: 91 | query_args[clean_key] = f"typing.Optional[{utils.get_type(param['schema'])}]" 92 | elif in_ == "path": 93 | # Function arguments 94 | if required: 95 | path_args[clean_key] = utils.get_type(param["schema"]) 96 | else: 97 | path_args[clean_key] = f"typing.Optional[{utils.get_type(param['schema'])}]" 98 | elif in_ == "header": 99 | # Header object arguments 100 | headers_args[param["name"]] = utils.get_type(param["schema"]) 101 | param_keys.append(clean_key) 102 | return ParametersResponse( 103 | query_args=query_args, 104 | path_args=path_args, 105 | headers_args=headers_args, 106 | ) 107 | 108 | def get_response_class_names(self, responses: dict, func_name: str) -> list[str]: 109 | """ 110 | Generates a list of response class for this operation. 111 | For each response found, also generate the schema by calling 112 | the schema generator. 113 | Returns a list of names of the classes generated. 114 | """ 115 | status_code_map: dict[str, str] = {} 116 | response_classes = [] 117 | for status_code, details in responses.items(): 118 | for _, content in details.get("content", {}).items(): 119 | class_name = "" 120 | if ref := content["schema"].get("$ref", False): 121 | # An object reference, so should be generated 122 | # by the schema generator later. 123 | class_name = utils.class_name_titled(utils.schema_ref(ref)) 124 | elif title := content["schema"].get("title", False): 125 | # This usually means we have an object that isn't 126 | # $ref so we need to create the schema class here 127 | class_name = utils.class_name_titled(title) 128 | self.schemas_generator.make_schema_class(class_name, schema=content["schema"]) 129 | else: 130 | # At this point we're just making things up! 131 | # It is likely it isn't an object it is just a simple resonse. 132 | class_name = utils.class_name_titled(func_name + status_code + "Response") 133 | # We need to generate the class at this point because it does not exist 134 | self.schemas_generator.make_schema_class( 135 | func_name + status_code + "Response", 136 | schema={"properties": {"test": content["schema"]}}, 137 | ) 138 | status_code_map[status_code] = class_name 139 | response_classes.append(class_name) 140 | self.http_generator.add_status_codes_to_bundle(func_name=func_name, status_code_map=status_code_map) 141 | return sorted(list(set(response_classes))) 142 | 143 | def get_input_class_names(self, inputs: dict) -> list[str]: 144 | """ 145 | Generates a list of input class for this operation. 146 | """ 147 | input_classes = [] 148 | for _, details in inputs.items(): 149 | for encoding, content in details.get("content", {}).items(): 150 | class_name = "" 151 | if ref := content["schema"].get("$ref", False): 152 | class_name = utils.class_name_titled(utils.schema_ref(ref)) 153 | elif title := content["schema"].get("title", False): 154 | class_name = title 155 | else: 156 | # No idea, using the encoding? 157 | class_name = encoding 158 | class_name = utils.class_name_titled(class_name) 159 | input_classes.append(class_name) 160 | return list(set(input_classes)) 161 | 162 | def generate_response_types(self, responses: dict, func_name: str) -> str: 163 | response_class_names = self.get_response_class_names(responses=responses, func_name=func_name) 164 | if len(response_class_names) > 1: 165 | return utils.union_for_py_ver([f"schemas.{r}" for r in response_class_names]) 166 | elif len(response_class_names) == 0: 167 | return "None" 168 | else: 169 | return f"schemas.{response_class_names[0]}" 170 | 171 | def generate_input_types(self, request_body: dict) -> str: 172 | input_class_names = self.get_input_class_names(inputs={"": request_body}) 173 | for input_class in input_class_names: 174 | if input_class not in self.schemas_generator.schemas.keys(): 175 | # It doesn't exist! Generate the schema for it 176 | self.schemas_generator.generate_input_class(schema=request_body) 177 | if len(input_class_names) > 1: 178 | return utils.union_for_py_ver([f"schemas.{r}" for r in input_class_names]) 179 | elif len(input_class_names) == 0: 180 | return "None" 181 | else: 182 | return f"schemas.{input_class_names[0]}" 183 | 184 | def generate_function( 185 | self, 186 | operation: dict, 187 | method: str, 188 | url: str, 189 | additional_parameters: list[dict], 190 | summary: Optional[str], 191 | ): 192 | func_name = utils.get_func_name(operation, url) 193 | response_types = self.generate_response_types(responses=operation["responses"], func_name=func_name) 194 | function_arguments = self.generate_parameters( 195 | parameters=operation.get("parameters", []), 196 | additional_parameters=additional_parameters, 197 | ) 198 | if query_args := function_arguments.query_args: 199 | api_url = url + utils.create_query_args(list(query_args.keys())) 200 | else: 201 | api_url = url 202 | if method in ["post", "put", "patch"] and not operation.get("requestBody"): 203 | data_class_name = "None" 204 | elif method in ["post", "put", "patch"]: 205 | data_class_name = self.generate_input_types(operation.get("requestBody", {})) 206 | else: 207 | data_class_name = None 208 | self.results[method] += 1 209 | template = writer.templates.get_template(self.method_template_map[method]) 210 | if headers := function_arguments.headers_args: 211 | header_class_name = self.schemas_generator.generate_headers_class( 212 | properties=headers, 213 | func_name=func_name, 214 | ) 215 | else: 216 | header_class_name = None 217 | content = template.render( 218 | asyncio=self.asyncio, 219 | func_name=func_name, 220 | function_arguments=function_arguments.get_path_args_as_string(), 221 | response_types=response_types, 222 | data_class_name=data_class_name, 223 | header_class_name=header_class_name, 224 | api_url=api_url, 225 | method=method, 226 | summary=operation.get("summary", summary), 227 | ) 228 | writer.write_to_client(content=content, output_dir=self.output_dir) 229 | 230 | def write_path_to_client(self, path: dict) -> None: 231 | url, operations = path 232 | for method, operation in operations.items(): 233 | if method.lower() in self.method_template_map.keys(): 234 | self.generate_function( 235 | operation=operation, 236 | method=method, 237 | url=url, 238 | additional_parameters=operations.get("parameters", []), 239 | summary=operations.get("summary", None), 240 | ) 241 | -------------------------------------------------------------------------------- /clientele/generators/standard/generators/http.py: -------------------------------------------------------------------------------- 1 | from collections import defaultdict 2 | 3 | from openapi_core import Spec 4 | from rich.console import Console 5 | 6 | from clientele.generators.standard import writer 7 | 8 | console = Console() 9 | 10 | 11 | def env_var(output_dir: str, key: str) -> str: 12 | output_dir = output_dir.replace("/", "") 13 | return f"{output_dir.upper()}_{key.upper()}" 14 | 15 | 16 | class HTTPGenerator: 17 | """ 18 | Handles all the content generated in the clients.py file. 19 | """ 20 | 21 | def __init__(self, spec: Spec, output_dir: str, asyncio: bool) -> None: 22 | self.spec = spec 23 | self.output_dir = output_dir 24 | self.results: dict[str, int] = defaultdict(int) 25 | self.asyncio = asyncio 26 | self.function_and_status_codes_bundle: dict[str, dict[str, str]] = {} 27 | 28 | def add_status_codes_to_bundle(self, func_name: str, status_code_map: dict[str, str]) -> None: 29 | """ 30 | Build a huge map of each function and it's status code responses. 31 | At the end of the client generation you should call http_generator.generate_http_content() 32 | """ 33 | self.function_and_status_codes_bundle[func_name] = status_code_map 34 | 35 | def writeable_function_and_status_codes_bundle(self) -> str: 36 | return f"\nfunc_response_code_maps = {self.function_and_status_codes_bundle}" 37 | 38 | def generate_http_content(self) -> None: 39 | writer.write_to_http(self.writeable_function_and_status_codes_bundle(), self.output_dir) 40 | client_generated = False 41 | client_type = "AsyncClient" if self.asyncio else "Client" 42 | if security_schemes := self.spec["components"].get("securitySchemes"): 43 | console.log("client has authentication...") 44 | for _, info in security_schemes.items(): 45 | if ( 46 | info["type"] == "http" 47 | and info["scheme"].lower() in ["basic", "bearer"] 48 | and client_generated is False 49 | ): 50 | client_generated = True 51 | if info["scheme"] == "bearer": 52 | template = writer.templates.get_template("bearer_client.jinja2") 53 | content = template.render( 54 | client_type=client_type, 55 | ) 56 | else: # Can only be "basic" at this point 57 | template = writer.templates.get_template("basic_client.jinja2") 58 | content = template.render( 59 | client_type=client_type, 60 | ) 61 | console.log(f"[yellow]Please see {self.output_dir}config.py to set authentication variables") 62 | elif info["type"] == "oauth2": 63 | template = writer.templates.get_template("bearer_client.jinja2") 64 | content = template.render( 65 | client_type=client_type, 66 | ) 67 | client_generated = True 68 | if client_generated is False: 69 | console.log(f"Generating {'async' if self.asyncio else 'sync'} client...") 70 | template = writer.templates.get_template("client.jinja2") 71 | content = template.render(client_type=client_type) 72 | client_generated = True 73 | writer.write_to_http(content, output_dir=self.output_dir) 74 | if self.asyncio: 75 | content = writer.templates.get_template("async_methods.jinja2").render() 76 | else: 77 | content = writer.templates.get_template("sync_methods.jinja2").render() 78 | writer.write_to_http(content, output_dir=self.output_dir) 79 | -------------------------------------------------------------------------------- /clientele/generators/standard/generators/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from openapi_core import Spec 4 | from rich.console import Console 5 | 6 | from clientele.generators.standard import utils, writer 7 | 8 | console = Console() 9 | 10 | 11 | class SchemasGenerator: 12 | """ 13 | Handles all the content generated in the schemas.py file. 14 | """ 15 | 16 | spec: Spec 17 | schemas: dict[str, str] 18 | output_dir: str 19 | 20 | def __init__(self, spec: Spec, output_dir: str) -> None: 21 | self.spec = spec 22 | self.schemas = {} 23 | self.output_dir = output_dir 24 | 25 | generated_response_class_names: list[str] = [] 26 | 27 | def generate_enum_properties(self, properties: dict) -> str: 28 | """ 29 | Generate a string list of the properties for this enum. 30 | """ 31 | content = "" 32 | for arg, arg_details in properties.items(): 33 | content = content + f""" {utils.snake_case_prop(arg.upper())} = {utils.get_type(arg_details)}\n""" 34 | return content 35 | 36 | def generate_headers_class(self, properties: dict, func_name: str) -> str: 37 | """ 38 | Generate a headers class that can be used by a function. 39 | Returns the name of the class that has been created. 40 | Headers are special because they usually want to output keys that 41 | have - separators and python detests that, so we're using 42 | the alias trick to get around that 43 | """ 44 | template = writer.templates.get_template("schema_class.jinja2") 45 | class_name = f"{utils.class_name_titled(func_name)}Headers" 46 | string_props = "\n".join( 47 | f' {utils.snake_case_prop(k)}: {v} = pydantic.Field(serialization_alias="{k}")' 48 | for k, v in properties.items() 49 | ) 50 | content = template.render(class_name=class_name, properties=string_props, enum=False) 51 | writer.write_to_schemas( 52 | content, 53 | output_dir=self.output_dir, 54 | ) 55 | return f"typing.Optional[schemas.{utils.class_name_titled(func_name)}Headers]" 56 | 57 | def generate_class_properties(self, properties: dict, required: Optional[list] = None) -> str: 58 | """ 59 | Generate a string list of the properties for this pydantic class. 60 | """ 61 | content = "" 62 | for arg, arg_details in properties.items(): 63 | arg_type = utils.get_type(arg_details) 64 | is_optional = required and arg not in required 65 | type_string = is_optional and f"typing.Optional[{arg_type}]" or arg_type 66 | content = content + f""" {arg}: {type_string}\n""" 67 | return content 68 | 69 | def generate_input_class(self, schema: dict) -> None: 70 | if content := schema.get("content"): 71 | for encoding, input_schema in content.items(): 72 | class_name = "" 73 | if ref := input_schema["schema"].get("$ref", False): 74 | class_name = utils.class_name_titled(utils.schema_ref(ref)) 75 | elif title := input_schema["schema"].get("title", False): 76 | class_name = utils.class_name_titled(title) 77 | else: 78 | # No idea, using the encoding? 79 | class_name = utils.class_name_titled(encoding) 80 | properties = self.generate_class_properties( 81 | properties=input_schema["schema"].get("properties", {}), 82 | required=input_schema["schema"].get("required", None), 83 | ) 84 | template = writer.templates.get_template("schema_class.jinja2") 85 | out_content = template.render(class_name=class_name, properties=properties, enum=False) 86 | writer.write_to_schemas( 87 | out_content, 88 | output_dir=self.output_dir, 89 | ) 90 | 91 | def make_schema_class(self, schema_key: str, schema: dict) -> None: 92 | schema_key = utils.class_name_titled(schema_key) 93 | enum = False 94 | properties: str = "" 95 | if all_of := schema.get("allOf"): 96 | # This schema uses "all of" the properties inside it 97 | for other_ref in all_of: 98 | is_ref = other_ref.get("$ref", False) 99 | if is_ref: 100 | other_schema_key = utils.class_name_titled(utils.schema_ref(is_ref)) 101 | if other_schema_key in self.schemas: 102 | properties += self.schemas[other_schema_key] 103 | else: 104 | # It's a ref but we've just not made it yet 105 | schema_model = utils.get_schema_from_ref(spec=self.spec, ref=is_ref) 106 | properties += self.generate_class_properties( 107 | properties=schema_model.get("properties", {}), 108 | required=schema_model.get("required", None), 109 | ) 110 | else: 111 | # It's not a ref and we need to figure out what it is 112 | if other_ref.get("type") == "object": 113 | properties += self.generate_class_properties( 114 | properties=other_ref.get("properties", {}), 115 | required=other_ref.get("required", None), 116 | ) 117 | elif schema.get("enum"): 118 | enum = True 119 | properties = self.generate_enum_properties({v: {"type": f'"{v}"'} for v in schema["enum"]}) 120 | else: 121 | properties = self.generate_class_properties( 122 | properties=schema.get("properties", {}), 123 | required=schema.get("required", None), 124 | ) 125 | self.schemas[schema_key] = properties 126 | template = writer.templates.get_template("schema_class.jinja2") 127 | content = template.render(class_name=schema_key, properties=properties, enum=enum) 128 | writer.write_to_schemas( 129 | content, 130 | output_dir=self.output_dir, 131 | ) 132 | 133 | def write_helpers(self) -> None: 134 | template = writer.templates.get_template("schema_helpers.jinja2") 135 | content = template.render() 136 | writer.write_to_schemas( 137 | content, 138 | output_dir=self.output_dir, 139 | ) 140 | 141 | def generate_schema_classes(self) -> None: 142 | """ 143 | Generates all Pydantic schema classes. 144 | """ 145 | for schema_key, schema in self.spec["components"]["schemas"].items(): 146 | self.make_schema_class(schema_key=schema_key, schema=schema) 147 | console.log(f"Generated {len(self.schemas.items())} schemas...") 148 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/async_methods.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | async def get(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 4 | """Issue an HTTP GET request""" 5 | if headers: 6 | client_headers.update(headers) 7 | async with httpx.AsyncClient(headers=client_headers) as async_client: 8 | return await async_client.get(parse_url(url)) 9 | 10 | 11 | async def post(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 12 | """Issue an HTTP POST request""" 13 | if headers: 14 | client_headers.update(headers) 15 | json_data = json.loads(json.dumps(data, default=json_serializer)) 16 | async with httpx.AsyncClient(headers=client_headers) as async_client: 17 | return await async_client.post(parse_url(url), json=json_data) 18 | 19 | 20 | async def put(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 21 | """Issue an HTTP PUT request""" 22 | if headers: 23 | client_headers.update(headers) 24 | json_data = json.loads(json.dumps(data, default=json_serializer)) 25 | async with httpx.AsyncClient(headers=client_headers) as async_client: 26 | return await async_client.put(parse_url(url), json=json_data) 27 | 28 | 29 | async def delete(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 30 | """Issue an HTTP DELETE request""" 31 | if headers: 32 | client_headers.update(headers) 33 | async with httpx.AsyncClient(headers=client_headers) as async_client: 34 | return await async_client.delete(parse_url(url)) 35 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/basic_client.jinja2: -------------------------------------------------------------------------------- 1 | client_headers = c.additional_headers() 2 | client = httpx.{{client_type}}(auth=(c.get_user_key(), c.get_pass_key()), headers=client_headers) 3 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/bearer_client.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | auth_key = c.get_bearer_token() 4 | client_headers = c.additional_headers() 5 | client_headers.update(Authorization=f'Bearer {auth_key}') 6 | client = httpx.{{client_type}}(headers=client_headers) 7 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/client.jinja2: -------------------------------------------------------------------------------- 1 | 2 | client_headers = c.additional_headers() 3 | client = httpx.{{client_type}}(headers=client_headers) 4 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/client_py.jinja2: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing # noqa 4 | 5 | from {{client_project_directory_path}} import http, schemas # noqa 6 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/config_py.jinja2: -------------------------------------------------------------------------------- 1 | """ 2 | This file will never be updated on subsequent clientele runs. 3 | Use it as a space to store configuration and constants. 4 | 5 | DO NOT CHANGE THE FUNCTION NAMES 6 | """ 7 | 8 | 9 | def additional_headers() -> dict: 10 | """ 11 | Modify this function to provide additional headers to all 12 | HTTP requests made by this client. 13 | """ 14 | return {} 15 | 16 | 17 | def api_base_url() -> str: 18 | """ 19 | Modify this function to provide the current api_base_url. 20 | """ 21 | return "http://localhost" 22 | 23 | 24 | def get_user_key() -> str: 25 | """ 26 | HTTP Basic authentication. 27 | Username parameter 28 | """ 29 | return "user" 30 | 31 | 32 | def get_pass_key() -> str: 33 | """ 34 | HTTP Basic authentication. 35 | Password parameter 36 | """ 37 | return "password" 38 | 39 | 40 | def get_bearer_token() -> str: 41 | """ 42 | HTTP Bearer authentication. 43 | Used by many authentication methods - token, jwt, etc. 44 | Does not require the "Bearer" content, just the key as a string. 45 | """ 46 | return "token" 47 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/get_method.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{asyncio and "async " or ""}}def {{func_name}}({{function_arguments}}{% if header_class_name %}{% if function_arguments%}, {% endif %}headers: {{header_class_name}}{% endif %}) -> {{response_types}}: 4 | {% if summary %}""" {{summary}} """{% endif %} 5 | {% if header_class_name %}headers_dict = headers and headers.model_dump(by_alias=True, exclude_unset=True) or None {% else%}{% endif %} 6 | response = {{asyncio and "await " or ""}}http.{{method}}(url=f"{{api_url}}"{% if header_class_name %}, headers=headers_dict{% endif %}) 7 | return http.handle_response({{func_name}}, response) 8 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/http_py.jinja2: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | {% if new_unions %} 4 | import types 5 | {% endif %} 6 | import typing 7 | from decimal import Decimal 8 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 9 | 10 | import httpx 11 | 12 | from {{client_project_directory_path}} import config as c # noqa 13 | 14 | 15 | def json_serializer(obj): 16 | if isinstance(obj, Decimal): 17 | return str(obj) 18 | 19 | 20 | class APIException(Exception): 21 | """Could not match API response to return type of this function""" 22 | 23 | reason: str 24 | response: httpx.Response 25 | 26 | def __init__(self, response: httpx.Response, reason: str, *args: object) -> None: 27 | self.response = response 28 | self.reason = reason 29 | super().__init__(*args) 30 | 31 | 32 | def parse_url(url: str) -> str: 33 | """ 34 | Returns the full URL from a string. 35 | 36 | Will filter out any optional query parameters if they are None. 37 | """ 38 | api_url = f"{c.api_base_url()}{url}" 39 | url_parts = urlparse(url=api_url) 40 | # Filter out "None" optional query parameters 41 | filtered_query_params = { 42 | k: v for k, v in parse_qs(url_parts.query).items() if v[0] not in ["None", ""] 43 | } 44 | filtered_query_string = urlencode(filtered_query_params, doseq=True) 45 | return urlunparse( 46 | ( 47 | url_parts.scheme, 48 | url_parts.netloc, 49 | url_parts.path, 50 | url_parts.params, 51 | filtered_query_string, 52 | url_parts.fragment, 53 | ) 54 | ) 55 | 56 | 57 | def handle_response(func, response): 58 | """ 59 | Returns a schema object that matches the JSON data from the response. 60 | 61 | If it can't find a matching schema it will raise an error with details of the response. 62 | """ 63 | status_code = response.status_code 64 | # Get the response types 65 | response_types = typing.get_type_hints(func)["return"] 66 | {% if new_unions %} 67 | if typing.get_origin(response_types) in [typing.Union, types.UnionType]: 68 | {% else %} 69 | if typing.get_origin(response_types) == typing.Union: 70 | {% endif %} 71 | response_types = list(typing.get_args(response_types)) 72 | else: 73 | response_types = [response_types] 74 | 75 | # Determine, from the map, the correct response for this status code 76 | expected_responses = func_response_code_maps[func.__name__] # noqa 77 | if str(status_code) not in expected_responses.keys(): 78 | raise APIException( 79 | response=response, reason="An unexpected status code was received" 80 | ) 81 | else: 82 | expected_response_class_name = expected_responses[str(status_code)] 83 | 84 | # Get the correct response type and build it 85 | response_type = [ 86 | t for t in response_types if t.__name__ == expected_response_class_name 87 | ][0] 88 | data = response.json() 89 | return response_type.model_validate(data) 90 | 91 | # Func map 92 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/manifest.jinja2: -------------------------------------------------------------------------------- 1 | # Manifest 2 | 3 | Generated with [https://github.com/phalt/clientele](https://github.com/phalt/clientele) 4 | Install with pipx: 5 | 6 | ```sh 7 | pipx install clientele 8 | ``` 9 | 10 | API VERSION: {{api_version}} 11 | OPENAPI VERSION: {{openapi_version}} 12 | CLIENTELE VERSION: {{clientele_version}} 13 | 14 | Regenerate using this command: 15 | 16 | ```sh 17 | clientele generate {{command}} 18 | ``` 19 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/post_method.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | {{asyncio and "async " or ""}}def {{func_name}}({% if function_arguments %}{{function_arguments}}, {% endif %}data: {{data_class_name}}{% if header_class_name%}, headers: {{header_class_name}}{% endif %}) -> {{response_types}}: 4 | {% if summary %}""" {{summary}} """{% endif %} 5 | {% if header_class_name %}headers_dict = headers and headers.model_dump(by_alias=True, exclude_unset=True) or None {% endif %} 6 | response = {{asyncio and "await " or ""}}http.{{method}}(url=f"{{api_url}}", data=data.model_dump(){% if header_class_name %}, headers=headers_dict{% endif %}) 7 | return http.handle_response({{func_name}}, response) 8 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/schema_class.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | class {{class_name}}({{"str, Enum" if enum else "pydantic.BaseModel"}}): 4 | {{properties if properties else " pass"}} 5 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/schema_helpers.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | def get_subclasses_from_same_file() -> list[typing.Type[pydantic.BaseModel]]: 4 | """ 5 | Due to how Python declares classes in a module, 6 | we need to update_forward_refs for all the schemas generated 7 | here in the situation where there are nested classes. 8 | """ 9 | calling_frame = inspect.currentframe() 10 | if not calling_frame: 11 | return [] 12 | else: 13 | calling_frame = calling_frame.f_back 14 | module = inspect.getmodule(calling_frame) 15 | 16 | subclasses = [] 17 | for _, c in inspect.getmembers(module): 18 | if inspect.isclass(c) and issubclass(c, pydantic.BaseModel) and c != pydantic.BaseModel: 19 | subclasses.append(c) 20 | 21 | return subclasses 22 | 23 | 24 | subclasses: list[typing.Type[pydantic.BaseModel]] = get_subclasses_from_same_file() 25 | for c in subclasses: 26 | c.model_rebuild() 27 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/schemas_py.jinja2: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import typing 5 | from enum import Enum # noqa 6 | from decimal import Decimal #noqa 7 | 8 | import pydantic 9 | -------------------------------------------------------------------------------- /clientele/generators/standard/templates/sync_methods.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | def get(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 4 | """Issue an HTTP GET request""" 5 | if headers: 6 | client_headers.update(headers) 7 | return client.get(parse_url(url), headers=client_headers) 8 | 9 | 10 | def post(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 11 | """Issue an HTTP POST request""" 12 | if headers: 13 | client_headers.update(headers) 14 | json_data = json.loads(json.dumps(data, default=json_serializer)) 15 | return client.post(parse_url(url), json=json_data, headers=client_headers) 16 | 17 | 18 | def put(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 19 | """Issue an HTTP PUT request""" 20 | if headers: 21 | client_headers.update(headers) 22 | json_data = json.loads(json.dumps(data, default=json_serializer)) 23 | return client.put(parse_url(url), json=json_data, headers=client_headers) 24 | 25 | 26 | def patch(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 27 | """Issue an HTTP PATCH request""" 28 | if headers: 29 | client_headers.update(headers) 30 | json_data = json.loads(json.dumps(data, default=json_serializer)) 31 | return client.patch(parse_url(url), json=json_data, headers=client_headers) 32 | 33 | 34 | def delete(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 35 | """Issue an HTTP DELETE request""" 36 | if headers: 37 | client_headers.update(headers) 38 | return client.delete(parse_url(url), headers=client_headers) 39 | -------------------------------------------------------------------------------- /clientele/generators/standard/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from openapi_core import Spec 4 | 5 | from clientele import settings 6 | 7 | 8 | class DataType: 9 | INTEGER = "integer" 10 | NUMBER = "number" 11 | STRING = "string" 12 | BOOLEAN = "boolean" 13 | ARRAY = "array" 14 | OBJECT = "object" 15 | ONE_OF = "oneOf" 16 | ANY_OF = "anyOf" 17 | 18 | 19 | def class_name_titled(input_str: str) -> str: 20 | """ 21 | Make the input string suitable for a class name 22 | """ 23 | # Capitalize the first letter always 24 | input_str = input_str[:1].title() + input_str[1:] 25 | # Remove any bad characters with an empty space 26 | for badstr in [".", "-", "_", ">", "<", "/"]: 27 | input_str = input_str.replace(badstr, " ") 28 | if " " in input_str: 29 | # Capitalize all the spaces 30 | input_str = input_str.title() 31 | # Remove all the spaces! 32 | input_str = input_str.replace(" ", "") 33 | return input_str 34 | 35 | 36 | def snake_case_prop(input_str: str) -> str: 37 | """ 38 | Clean a property to not have invalid characters. 39 | Returns a "snake_case" version of the input string 40 | """ 41 | # These strings appear in some OpenAPI schemas 42 | for dropchar in [">", "<"]: 43 | input_str = input_str.replace(dropchar, "") 44 | # These we can just convert to an underscore 45 | for badstr in ["-", "."]: 46 | input_str = input_str.replace(badstr, "_") 47 | # python keywords need to be converted 48 | reserved_words = ["from"] 49 | if input_str in reserved_words: 50 | input_str = input_str + "_" 51 | # Retain all-uppercase strings, otherwise convert to camel case 52 | if not input_str.isupper(): 53 | input_str = "".join(["_" + i.lower() if i.isupper() else i for i in input_str]).lstrip("_") 54 | return input_str 55 | 56 | 57 | def _split_upper(s): 58 | res = re.findall(".[^A-Z]*", s) 59 | if len(res) > 1: 60 | return "_".join(res) 61 | return res[0] 62 | 63 | 64 | def _snake_case(s): 65 | for badchar in ["/", "-", "."]: 66 | s = s.replace(badchar, "_") 67 | s = _split_upper(s) 68 | if s[0] == "_": 69 | s = s[1:] 70 | return s.lower() 71 | 72 | 73 | def get_func_name(operation: dict, path: str) -> str: 74 | if operation.get("operationId"): 75 | return _snake_case(operation["operationId"].split("__")[0]) 76 | return _snake_case(path) 77 | 78 | 79 | def get_type(t): 80 | t_type = t.get("type") 81 | t_format = t.get("format") 82 | 83 | if t_type == DataType.STRING: 84 | return "str" 85 | if t_type in [DataType.INTEGER, DataType.NUMBER]: 86 | # Check formatting for a decimal type 87 | if t_format == "decimal": 88 | return "Decimal" 89 | return "int" 90 | if t_type == DataType.BOOLEAN: 91 | return "bool" 92 | if t_type == DataType.OBJECT: 93 | return "dict[str, typing.Any]" 94 | if t_type == DataType.ARRAY: 95 | inner_class = get_type(t.get("items")) 96 | return f"list[{inner_class}]" 97 | if ref := t.get("$ref"): 98 | return f'"{class_name_titled(ref.replace("#/components/schemas/", ""))}"' 99 | if t_type is None: 100 | # In this case, make it an "Any" 101 | return "typing.Any" 102 | # Note: enums have type {'type': '"EXAMPLE"'} so fall through here 103 | return t_type 104 | 105 | 106 | def create_query_args(query_args: list[str]) -> str: 107 | return "?" + "&".join([f"{p}=" + "{" + p + "}" for p in query_args]) 108 | 109 | 110 | def schema_ref(ref: str) -> str: 111 | return ref.replace("#/components/schemas/", "") 112 | 113 | 114 | def param_ref(ref: str) -> str: 115 | return ref.replace("#/components/parameters/", "") 116 | 117 | 118 | def get_param_from_ref(spec: Spec, param: dict) -> dict: 119 | ref = param.get("$ref", "") 120 | stripped_name = param_ref(ref) 121 | return spec["components"]["parameters"][stripped_name] 122 | 123 | 124 | def get_schema_from_ref(spec: Spec, ref: str) -> dict: 125 | stripped_name = schema_ref(ref) 126 | return spec["components"]["schemas"][stripped_name] 127 | 128 | 129 | def union_for_py_ver(union_items: list) -> str: 130 | minor = settings.PY_VERSION[1] 131 | if int(minor) >= 10: 132 | return " | ".join(union_items) 133 | else: 134 | return f"typing.Union[{', '.join(union_items)}]" 135 | -------------------------------------------------------------------------------- /clientele/generators/standard/writer.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from jinja2 import Environment, PackageLoader 4 | 5 | templates = Environment(loader=PackageLoader("clientele", "generators/standard/templates/")) 6 | 7 | 8 | def write_to_schemas(content: str, output_dir: str) -> None: 9 | path = Path(output_dir) / "schemas.py" 10 | _write_to(path, content) 11 | 12 | 13 | def write_to_http(content: str, output_dir: str) -> None: 14 | path = Path(output_dir) / "http.py" 15 | _write_to(path, content) 16 | 17 | 18 | def write_to_client(content: str, output_dir: str) -> None: 19 | path = Path(output_dir) / "client.py" 20 | _write_to(path, content) 21 | 22 | 23 | def write_to_manifest(content: str, output_dir: str) -> None: 24 | path = Path(output_dir) / "MANIFEST.md" 25 | _write_to(path, content) 26 | 27 | 28 | def write_to_config(content: str, output_dir: str) -> None: 29 | path = Path(output_dir) / "config.py" 30 | _write_to(path, content) 31 | 32 | 33 | def write_to_init(output_dir: str) -> None: 34 | path = Path(output_dir) / "__init__.py" 35 | _write_to(path, "") 36 | 37 | 38 | def _write_to( 39 | path: Path, 40 | content: str, 41 | ) -> None: 42 | path.parent.mkdir(parents=True, exist_ok=True) 43 | with path.open("a+") as f: 44 | f.write(content) 45 | -------------------------------------------------------------------------------- /clientele/settings.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | VERSION = "0.9.0" 4 | 5 | 6 | def split_ver(): 7 | return [int(v) for v in platform.python_version().split(".")] 8 | 9 | 10 | PY_VERSION = split_ver() 11 | -------------------------------------------------------------------------------- /clientele/utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_client_project_directory_path(output_dir: str) -> str: 5 | """ 6 | Returns a dot-notation path for the client directory. 7 | Assumes that the `clientele` command is being run in the 8 | project root directory. 9 | """ 10 | return ".".join(os.path.join(output_dir).split("/")[:-1]) 11 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 0.9.0 4 | 5 | - Support `patch` methods 6 | - Fix `config.py` file being overwritten when generating new clients 7 | 8 | ## 0.8.3 9 | 10 | - Fix bug with headers assignment 11 | 12 | ## 0.8.2 13 | 14 | - Improved json support 15 | 16 | ## 0.8.1 17 | 18 | - Function parameters no longer format to snake_case to maintain consistency with the OpenAPI schema. 19 | 20 | ## 0.8.0 21 | 22 | - Improved support for Async clients which prevents a weird bug when running more than one event loop. Based on the suggestions from [this httpx issue](https://github.com/encode/httpcore/discussions/659). 23 | - We now use [`ruff format`](https://astral.sh/blog/the-ruff-formatter) for coding formatting (not the client output). 24 | - `Decimal` support now extends to Decimal input values. 25 | - Input and Output schemas will now have properties that directly match those provided by the OpenAPI schema. This fixes a bug where previously, the snake-case formatting did not match up with what the API expected to send or receive. 26 | 27 | ## 0.7.1 28 | 29 | - Support for `Decimal` types. 30 | 31 | ## 0.7.0 32 | 33 | - Updated all files to use the templates engine. 34 | - Generator files have been reorganised in clientele to support future templates. 35 | - `constants.py` has been renamed to `config.py` to better reflect how it is used. It is not generated from a template like the other files. 36 | - If you are using Python 3.10 or later, the `typing.Unions` types will generate as the short hand `|` instead. 37 | - To regenerate a client (and to prevent accidental overrides) you must now pass `--regen t` or `-r t` to the `generate` command. This is automatically added to the line in `MANIFEST.md` to help. 38 | - Clientele will now automatically run [black](https://black.readthedocs.io/en/stable/) code formatter once a client is generated or regenerated. 39 | - Clientele will now generate absolute paths to refer to adjacent files in the generated client, instead of relative paths. This assumes you are running the `clientele` command in the root directory of your project. 40 | - A lot of documentation and docs strings updates so that code in the generated client is easier to understand. 41 | - Improved the utility for snake-casing enum keys. Tests added for the functions. 42 | - Python 3.12 support. 43 | - Add a "basic" client using the command `generate-basic`. This can be used to keep a consistent file structure for an API that does not use OpenAPI. 44 | 45 | ## 0.6.3 46 | 47 | - Packaged application installs in the correct location. Resolving [#6](https://github.com/phalt/clientele/issues/6) 48 | - Updated pyproject.toml to include a better selection of links. 49 | 50 | ## 0.6.2 51 | 52 | - Ignore optional URL query parameters if they are `None`. 53 | 54 | ## 0.6.1 55 | 56 | - Added `from __future__ import annotations` in files to help with typing evaluation. 57 | - Update to use pydantic 2.4. 58 | - A bunch of documentation and readme updates. 59 | - Small wording and grammar fixes. 60 | 61 | ## 0.6.0 62 | 63 | - Significantly improved handling for response schemas. Responses from API endpoints now look at the HTTP status code to pick the correct response schema to generate from the HTTP json data. When regenerating, you will notice a bit more logic generated in the `http.py` file to handle this. 64 | - Significantly improved coverage of exceptions raised when trying to generate response schemas. 65 | - Response types for a class are now sorted. 66 | - Fixed a bug where `put` methods did not generate input data correctly. 67 | 68 | ## 0.5.2 69 | 70 | - Fix pathing for `constants.py` - thanks to @matthewknight for the contribution! 71 | - Added `CONTRIBUTORS.md` 72 | 73 | ## 0.5.1 74 | 75 | - Support for HTTP PUT methods 76 | - Headers objects use `exclude_unset` to avoid passing `None` values as headers, which httpx does not support. 77 | 78 | Additionally, an async test client is now included in the test suite. It has identical tests to the standard one but uses the async client instead. 79 | 80 | ## 0.5.0 81 | 82 | ### Please delete the constants.py file when updating to this version to have new features take affect 83 | 84 | - Paths are resolved correctly when generating clients in nested directories. 85 | - `additional_headers()` is now applied to every client, allowing you to set up headers for all requests made by your client. 86 | - When the client cannot match an HTTP response to a return type for the function it will now raise an `http.APIException`. This object will have the `response` attached to it for inspection by the developer. 87 | - `MANIFEST` is now renamed to `MANIFEST.md` and will include install information for Clientele, as well as information on the command used to generate the client. 88 | 89 | ## 0.4.4 90 | 91 | Examples and documentation now includes a very complex example schema built using [FastAPI](https://fastapi.tiangolo.com/) that offers the following variations: 92 | 93 | - Simple request / response (no input just an output) 94 | - A request with a URL/Path parameter. 95 | - Models with `int`, `str`, `list`, `dict`, references to other models, enums, and `list`s of other models and enums. 96 | - A request with query parameters. 97 | - A response model that has optional parameters. 98 | - An HTTP POST request that takes an input model. 99 | - An HTTP POST request that takes path parameters and also an input model. 100 | - An HTTP GET request that requires an HTTP header, and returns it. 101 | - An HTTP GET endpoint that returns the HTTP bearer authorization token (also makes clientele generate the http authentication for this schema). 102 | 103 | A huge test suite has been added to the CI pipeline for this project using a copy of the generated client from the schema above. 104 | 105 | ## 0.4.3 106 | 107 | - `Enums` now inherit from `str` as well so that they serialize to JSON properly. See [this little nugget](https://hultner.se/quickbits/2018-03-12-python-json-serializable-enum.html). 108 | 109 | ## 0.4.2 110 | 111 | - Correctly use `model_rebuild` for complex schemas where there are nested schemas, his may be necessary when one of the annotations is a ForwardRef which could not be resolved during the initial attempt to build the schema. 112 | - Do not raise for status, instead attempt to return the response if it cannot match a response type. 113 | 114 | ## 0.4.1 115 | 116 | - Correctly generate lists of nested schema classes 117 | - Correctly build response schemas that are emphemeral (such as when they just return an array of other schemas, or when they have no $ref). 118 | 119 | ## 0.4.0 120 | 121 | - Change install suggestion to use [pipx](https://github.com/pypa/pipx) as it works best as a global CLI tool. 122 | - Improved support for OpenAPI 3.0.3 schemas (a test version is available in the example_openapi_specs directory). 123 | - `validate` command for validating an OpenAPI schema will work with clientele. 124 | - `version` command for showing the current version of clientele. 125 | - Supports HTTP DELETE methods. 126 | - Big refactor of how methods are generated to reduce duplicate code. 127 | - Support optional header parameters in all request functions (where they are required). 128 | - Very simple Oauth2 support - if it is discovered will set up HTTP Bearer auth for you. 129 | - Uses `dict` and `list` instead of `typing.Dict` and `typing.List` respectively. 130 | - Improved schema generation when schemas have $ref to other models. 131 | 132 | ## 0.3.2 133 | 134 | - Minor changes to function name generation to make it more consistent. 135 | - Optional parameters in schemas are working properly. 136 | 137 | ## 0.3.1 138 | 139 | - Fixes a bug when generating HTTP Authentication schema. 140 | - Fixes a bug when generating input classes for post functions, when the input schema doesn't exist yet. 141 | - Generates pythonic function names in clients now, always (like `lower_case_snake_case`). 142 | 143 | ## 0.3.0 144 | 145 | - Now generates a `MANIFEST` file with information about the build versions 146 | - Added a `constants.py` file to the output if one does not exist yet, which can be used to store values that you do not want to change between subsequent re-generations of the clientele client, such as the API base url. 147 | - Authentication patterns now use `constants.py` for constants values. 148 | - Removed `ipython` from package dependencies and moved to dev dependencies. 149 | - Documentation! [https://phalt.github.io/clientele/](https://phalt.github.io/clientele/) 150 | 151 | ## 0.2.0 152 | 153 | - Improved CLI output 154 | - Code organisation is now sensible and not just one giant file 155 | - Now supports an openapi spec generated from a dotnet project (`Microsoft.OpenApi.Models`) 156 | - async client support fully working 157 | - HTTP Bearer support 158 | - HTTP Basic support 159 | 160 | ## 0.1.0 161 | 162 | - Initial version 163 | - Mostly works with a simple FastAPI generated spec (3.0.2) 164 | - Works with Twilio's spec (see example_openapi_specs/ directory) (3.0.1) 165 | - Almost works with stripes 166 | -------------------------------------------------------------------------------- /docs/clientele.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/docs/clientele.jpeg -------------------------------------------------------------------------------- /docs/compatibility.md: -------------------------------------------------------------------------------- 1 | # 💱 Compatibility 2 | 3 | ## Great compatibility 4 | 5 | Any standard `3.0.x` implementation works very well. 6 | 7 | We have tested Clientele with: 8 | 9 | * [FastAPI](https://fastapi.tiangolo.com/tutorial/first-steps/?h=openapi#what-is-openapi-for) - our target audience, so 100% compatibility guaranteed. 10 | * [drf-spectacular](https://github.com/tfranzel/drf-spectacular) works great as well, you can see which schemas we tested in [this GitHub issue](https://github.com/phalt/clientele/issues/23). 11 | * [Microsoft's OpenAPI spec](https://learn.microsoft.com/en-us/azure/api-management/import-api-from-oas?tabs=portal) has also been battle tested and works well. 12 | 13 | ## No compatibility 14 | 15 | We do not support `2.x` aka "Swagger" - this format is quite different and deprecated. 16 | 17 | ## A note on compatbility 18 | 19 | When we were building Clientele, we discovered that, despite a fantastic [specification](https://www.openapis.org/), OpenAPI has a lot of poor implementations. 20 | 21 | As pythonistas, we started with the auto-generated OpenAPI schemas provided by [FastAPI](https://fastapi.tiangolo.com/), and then we branched out to large APIs like [Twilio](https://www.twilio.com/docs/openapi) to test what we built. 22 | 23 | Despite the effort, we still keep finding subtly different OpenAPI implementations. 24 | 25 | Because of this we cannot guarentee 100% compatibility with an API, but we can give you a good indication of what we've tested. 26 | -------------------------------------------------------------------------------- /docs/design.md: -------------------------------------------------------------------------------- 1 | # 🎨 Design 2 | 3 | ## OpenAPI code generators 4 | 5 | Every few years we check the HTTP API landscape to see what has changed, and what new tooling is available. A part of this research is seeing how far [OpenAPI client generators](https://www.openapis.org/) have come. 6 | 7 | In the early years of OpenAPI, the "last mile" (i.e. - generating, and using, a client library) had a pretty poor experience: 8 | 9 | * The generated code was difficult to read, leading to problems when debugging the code. It was often not idiomatic to the language. 10 | 11 | * It was often bloated and repetitive, making changes tedious to support your specific project. 12 | 13 | * Also, you had to install a lot of things like Java to generate the code. 14 | 15 | * And finally, some API services still weren't publishing an OpenAPI schema. Or, the schema they published was different from the standard, so would not work with code generators. 16 | 17 | This experience wasn't ideal, and was often such an impedance that it put us off using them. We would prefer instead to take a template of our own choosing, refined from years of working with HTTP APIs, and adapt it to whatever new API we were consuming. 18 | 19 | ## The landscape in 2023 20 | 21 | In the early part of 2023, we had to build an integration with a new HTTP API. So, like we did in the past, we used it as an opportunity to asses the landscape of OpenAPI client generators. 22 | 23 | And this was our summary at the time of writing: 24 | 25 | * API tools and providers had adopted OpenAPI very well. For example - tools like [FastAPI](https://fastapi.tiangolo.com/) and [drf-spectacular](https://github.com/tfranzel/drf-spectacular) now make it easy for the most popular python web frameworks to publish OpenAPI schemas. 26 | 27 | * There are a lot of options for generating clients. They all meet the need of "generating a python client using the schema". But, almost universally, they have a poor developer experience. 28 | 29 | After evaluating many python client generators, we opted to use **none** of them and hand craft the API client ourselves. We used the OpenAPI schema as a source to develop the input and output objects. Then we wrote a small functional abstraction over the paths. 30 | 31 | Looking back over our organised, pythonic, minimal client integration, we had an idea: 32 | 33 | If this is the type of client we would like a generator to produce; how hard could it be to work backwards and build one? 34 | 35 | This was the start of Clientele. 36 | 37 | ## Clientele 38 | 39 | As python developers ourselves, we know what makes good, readable, idiomatic python. 40 | 41 | We also feel like we have a grasp on the best tools to be using in our python projects. 42 | 43 | By starting with a client library that we _like_ to use, and working backwards, we were able to build a client generator that produced something that python developers actually wanted. 44 | 45 | But what is it exactly that we aimed to do with Clientele, why is this the OpenAPI Python client that you should use? 46 | 47 | ### Strongly-typed inputs and outputs 48 | 49 | OpenAPI prides itself on being able to describe the input and output objects in it's schema. 50 | 51 | This means you can build strongly-typed interfaces to the API. This helps to solve some common niggles when using an API - such as casting a value to a string when it should be an integer. 52 | 53 | With Clientele, we opted to use [Pydantic](https://docs.pydantic.dev/latest/) to build the models from the OpenAPI schema. 54 | 55 | Pydantic doesn't only describe the shape of an object, it also validates the attributes as well. If an API sends back the wrong attributes at run time, Pydantic will error and provide a detail description about what went wrong. 56 | 57 | ### Idiomatic Python 58 | 59 | A lot of the client generators we tested produced a lot of poor code. 60 | 61 | It was clear in a few cases that the client generator was built without understanding or knowledge of good [python conventions](https://realpython.com/lessons/zen-of-python/). 62 | 63 | In more than one case we also discovered the client generator would work by reading a file at run time. This is a very cool piece of engineering, but it is impractical to use. When you develop with these clients, the available functions and objects don't exist and you can't use an IDE's auto-complete feature. 64 | 65 | Clientele set out to be as pythonic as possible. We use modern tools, idiomatic conventions, and provide some helpful bonuses like [Black](https://github.com/psf/black/) auto-formatting. 66 | 67 | ### Easy to understand 68 | 69 | Eventually a developer will need to do some debugging, and sometimes they'll need to do it in the generated client code. 70 | 71 | A lot of the other client generators make obscure or obtuse code that is hard to pick apart and debug. 72 | 73 | Now, there is a suggestion that developers shouldn't _have to_ look at this, and that is fair. But the reality doesn't match that expectation. Personally; we like to know what generated code is doing. We want to trust that it will work, and that we can adapt around it if needs be. An interesting quirk of any code generator is you can't always inspect the source when evaluating to use it. Any other tool - you'd just go to GitHub and have a read around, but you can't with code generators. 74 | 75 | So the code that is generated needs to be easy to understand. 76 | 77 | Clientele doesn't do any tricks, or magic, or anything complex. The generated code has documentation and is designed to be readable. It is only a small layer on top of already well established tools, such as [HTTPX](https://github.com/encode/httpx). 78 | 79 | In fact, we have some generated clients in our [project repository](https://github.com/phalt/clientele/tree/main/tests) so you can see what it looks like. We even have example [tests](https://github.com/phalt/clientele/blob/main/tests/test_generated_client.py) for you to learn how to integrate with it. 80 | 81 | It is that way because we know you will need to inspect it in the future. We want you to know that this is a sane and sensible tool. 82 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # 🪄 Client example 2 | 3 | Let's build an API Client using clientele. 4 | 5 | Our [GitHub](https://github.com/phalt/clientele/tree/main/example_openapi_specs) has a bunch of schemas that are proven to work with clientele, so let's use one of those! 6 | 7 | ## Generate the client 8 | 9 | In your project's root directory: 10 | 11 | ```sh 12 | clientele generate -u https://raw.githubusercontent.com/phalt/clientele/main/example_openapi_specs/best.json -o my_client/ 13 | ``` 14 | 15 | !!! note 16 | 17 | The example above uses one of our test schemas, and will work if you copy/paste it! 18 | 19 | The `-u` parameter expects a URL, you can provide a path to a file with `-f` instead if you download the file. 20 | 21 | The `-o` parameter is the output directory of the generated client. 22 | 23 | Run it now and you will see this output: 24 | 25 | ```sh 26 | my_client/ 27 | __init__.py 28 | client.py 29 | config.py 30 | http.py 31 | MANIFEST 32 | schemas.py 33 | ``` 34 | 35 | Let's go over each file and talk about what it does. 36 | 37 | ## Client 38 | 39 | ### GET functions 40 | 41 | The `client.py` file provides all the API functions from the OpenAPI schema. Functions are a combination of the path and the HTTP method for those paths. So, a path with two HTTP methods will be turned into two python functions. 42 | 43 | ```py title="my_client/client.py" linenums="1" 44 | from my_client import http, schemas 45 | 46 | 47 | def simple_request_simple_request_get() -> schemas.SimpleResponse: 48 | """Simple Request""" 49 | 50 | response = http.get(url="/simple-request") 51 | return http.handle_response(simple_request_simple_request_get, response) 52 | 53 | ... 54 | ``` 55 | 56 | We can see one of the functions here, `simple_request_simple_request_get`, is for a straight-forward HTTP GET request without any input arguments, and it returns a schema object. 57 | 58 | Here is how you might use it: 59 | 60 | ```py 61 | from my_client import client 62 | 63 | client.simple_request_simple_request_get() 64 | >>> SimpleResponse(name='Hello, clientele') 65 | ``` 66 | 67 | ### POST and PUT functions 68 | 69 | A more complex example is shown just below. 70 | 71 | This is for an HTTP POST method, and it requires an input property called `data` that is an instance of a schema, and returns one of many potential responses. If the endpoint has url parameters or query parameters, they will appear as input arguments to the function alongside the `data` argument. 72 | 73 | ```py 74 | def request_data_request_data_post( 75 | data: schemas.RequestDataRequest 76 | ) -> schemas.RequestDataResponse | schemas.HTTPValidationError: 77 | """Request Data""" 78 | 79 | response = http.post(url="/request-data", data=data.model_dump()) 80 | return http.handle_response(request_data_request_data_post, response) 81 | ``` 82 | 83 | Here is how you might use it: 84 | 85 | ```py 86 | from my_client import client, schemas 87 | 88 | data = schemas.RequestDataRequest(my_input="Hello, world") 89 | response = client.request_data_request_data_post(data=data) 90 | >>> RequestDataResponse(your_input='Hello, world') 91 | ``` 92 | 93 | Clientele also supports the major HTTP methods PUT and DELETE in the same way. 94 | 95 | ### URL and Query parameters 96 | 97 | If your endpoint takes [path parameters](https://learn.openapis.org/specification/parameters.html#parameter-location) (aka URL parameters) then clientele will turn them into parameters in the function: 98 | 99 | ```py 100 | from my_client import client 101 | 102 | client.parameter_request_simple_request(your_input="gibberish") 103 | >>> ParameterResponse(your_input='gibberish') 104 | ``` 105 | 106 | Query parameters will also be generated the same way. See [this example](https://github.com/phalt/clientele/blob/0.4.4/tests/test_client/client.py#L71) for a function that takes a required query parameter. 107 | 108 | Note that, optional parameters that are not passed will be omitted when the URL is generated by Clientele. 109 | 110 | ### Handling responses 111 | 112 | Because we're using Pydantic to manage the input data, we get a strongly-typed response object. 113 | This works beautifully with the new [structural pattern matching](https://peps.python.org/pep-0636/) feature in Python 3.10 and up: 114 | 115 | ```py 116 | 117 | response = client.request_data_request_data_post(data=data) 118 | 119 | # Handle responses elegantly 120 | match response: 121 | case schemas.RequestDataResponse(): 122 | # Handle valid response 123 | ... 124 | case schemas.ValidationError(): 125 | # Handle validation error 126 | ... 127 | ``` 128 | 129 | ### API Exceptions 130 | 131 | Clientele keeps a mapping of the paths and their potential response codes. When it gets a response code that fits into the map, it generates the pydantic object associated to it. 132 | 133 | If the HTTP response code is an unintended one, it will not match a return type. In this case, the function will raise an `http.APIException`. 134 | 135 | ```py 136 | from my_client import client, http 137 | try: 138 | good_response = my_client.get_my_thing() 139 | except http.APIException as e: 140 | # The API got a response code we didn't expect 141 | print(e.response.status_code) 142 | ``` 143 | 144 | The `response` object will be attached to this exception class for your own debugging. 145 | 146 | ## Schemas 147 | 148 | The `schemas.py` file has all the possible schemas, request and response, and even Enums, for the API. These are taken from OpenAPI's schemas objects and turned into Python classes. They are all subclassed from pydantic's `BaseModel`. 149 | 150 | Here are a few examples: 151 | 152 | ```py title="my_client/schemas.py" linenums="1" 153 | import pydantic 154 | from enum import Enum 155 | 156 | 157 | class ParameterResponse(pydantic.BaseModel): 158 | your_input: str 159 | 160 | class RequestDataRequest(pydantic.BaseModel): 161 | my_input: str 162 | 163 | class RequestDataResponse(pydantic.BaseModel): 164 | my_input: str 165 | 166 | # Enums subclass str so they serialize to JSON nicely 167 | class ExampleEnum(str, Enum): 168 | ONE = "ONE" 169 | TWO = "TWO" 170 | ``` 171 | 172 | ## Configuration 173 | 174 | One of the problems with auto-generated clients is that you often need to configure them, and 175 | if you try and regenerate the client at some point in the future then your configuration gets wiped clean and you have to do it all over again. 176 | 177 | Clientele solves this problem by providing an _entry point_ for configuration that will never be overwritten - `config.py`. 178 | 179 | When you first generate the project, you will see a file called `config.py` and it will offer configuration functions a bit like this: 180 | 181 | ```python 182 | """ 183 | This file will never be updated on subsequent clientele runs. 184 | Use it as a space to store configuration and constants. 185 | 186 | DO NOT CHANGE THE FUNCTION NAMES 187 | """ 188 | 189 | 190 | def api_base_url() -> str: 191 | """ 192 | Modify this function to provide the current api_base_url. 193 | """ 194 | return "http://localhost" 195 | ``` 196 | 197 | Subsequent runs of the `generate` command with `--regen t` will not change this file if it exists, so you are free to modify the defaults to suit your needs. 198 | 199 | For example, if you need to source the base url of your API for different configurations, you can modify the `api_base_url` function like this: 200 | 201 | ```py 202 | 203 | from my_project import my_config 204 | 205 | def api_base_url() -> str: 206 | """ 207 | Modify this function to provide the current api_base_url. 208 | """ 209 | if my_config.debug: 210 | return "http://localhost:8000" 211 | elif my_config.production: 212 | return "http://my-production-url.com" 213 | ``` 214 | 215 | Just keep the function names the same and you're good to go. 216 | 217 | ### Authentication 218 | 219 | If your OpenAPI spec provides security information for the following authentication methods: 220 | 221 | * HTTP Bearer 222 | * HTTP Basic 223 | 224 | Then clientele will provide you information on the environment variables you need to set to 225 | make this work during the generation. For example: 226 | 227 | ```sh 228 | Please see my_client/config.py to set authentication variables 229 | ``` 230 | 231 | The `config.py` file will have entry points for you to configure, for example, HTTP Bearer authentication will need the `get_bearer_token` function to be updated, something like this: 232 | 233 | ```py 234 | 235 | def get_bearer_token() -> str: 236 | """ 237 | HTTP Bearer authentication. 238 | Used by many authentication methods - token, jwt, etc. 239 | Does not require the "Bearer" content, just the key as a string. 240 | """ 241 | from os import environ 242 | return environ.get("MY_AUTHENTICATION_TOKEN") 243 | ``` 244 | 245 | ### Additional headers 246 | 247 | If you want to pass specific headers with all requests made by the client, you can configure the `additional_headers` function in `config.py` to do this. 248 | 249 | ```py 250 | def additional_headers() -> dict: 251 | """ 252 | Modify this function ot provide additional headers to all 253 | HTTP requests made by this client. 254 | """ 255 | return {} 256 | ``` 257 | 258 | Please note that if you are using this with authentication headers, then authentication headers **will overwrite these defaults** if they keys match. 259 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # ⚜️ Clientele 2 | 3 | ## Generate loveable Python HTTP API Clients 4 | 5 | [![Package version](https://img.shields.io/pypi/v/clientele?color=%2334D058&label=latest%20version)](https://pypi.org/project/clientele) 6 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/clientele?label=python%20support) 7 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/clientele) 8 | ![PyPI - License](https://img.shields.io/pypi/l/clientele) 9 | 10 | Clientele lets you generate fully-typed, pythonic HTTP API Clients using an OpenAPI schema. 11 | 12 | It's easy to use: 13 | 14 | ```sh 15 | # Install as a global tool - it's not a dependency! 16 | pipx install clientele 17 | # Generate a client 18 | clientele generate -u https://raw.githubusercontent.com/phalt/clientele/main/example_openapi_specs/best.json -o api_client/ 19 | ``` 20 | 21 | ## Generated code 22 | 23 | The generated code is designed by python developers, for python developers. 24 | 25 | It uses modern tooling and has a great developer experience. 26 | 27 | ```py 28 | from my_api import client, schemas 29 | 30 | # Pydantic models for inputs and outputs 31 | data = schemas.RequestDataRequest(my_input="test") 32 | 33 | # Easy to read client functions 34 | response = client.request_data_request_data_post(data=data) 35 | 36 | # Handle responses elegantly 37 | match response: 38 | case schemas.RequestDataResponse(): 39 | # Handle valid response 40 | ... 41 | case schemas.ValidationError(): 42 | # Handle validation error 43 | ... 44 | ``` 45 | 46 | The generated code is tiny - the [example schema](https://github.com/phalt/clientele/blob/main/example_openapi_specs/best.json) we use for documentation and testing only requires [250 lines of code](https://github.com/phalt/clientele/tree/main/tests/test_client) and 5 files. 47 | 48 | ## Async support 49 | 50 | You can choose to generate either a sync or an async client - we support both: 51 | 52 | ```py 53 | from my_async_api import client 54 | 55 | # Async client functions 56 | response = await client.simple_request_simple_request_get() 57 | ``` 58 | 59 | ## Other features 60 | 61 | * Written entirely in Python. 62 | * Designed to work with [FastAPI](https://fastapi.tiangolo.com/)'s and [drf-spectacular](https://github.com/tfranzel/drf-spectacular)'s OpenAPI schema generator. 63 | * The generated client only depends on [httpx](https://www.python-httpx.org/) and [Pydantic 2.4](https://docs.pydantic.dev/latest/). 64 | * HTTP Basic and HTTP Bearer authentication support. 65 | * Support your own configuration - we provide an entry point that will never be overwritten. 66 | * Designed for easy testing with [respx](https://lundberg.github.io/respx/). 67 | * API updated? Just run the same command again and check the git diff. 68 | * Automatically formats the generated client with [black](https://black.readthedocs.io/en/stable/index.html). 69 | -------------------------------------------------------------------------------- /docs/install.md: -------------------------------------------------------------------------------- 1 | # 🏗️ Install 2 | 3 | We recommend installing with [pipx](https://github.com/pypa/pipx) as a global CLI command: 4 | 5 | ```sh 6 | pipx install clientele 7 | ``` 8 | 9 | Once installed you can run `clientele version` to make sure you have the latest version: 10 | 11 | ```sh 12 | > clientele version 13 | clientele 0.9.0 14 | ``` 15 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | Clientele is designed for easy testing, and our [own test suite](https://github.com/phalt/clientele/blob/0.4.4/tests/test_generated_client.py) is a great example of how easily you can write mock tests for your API Client. 4 | 5 | ```python 6 | import pytest 7 | from httpx import Response 8 | from respx import MockRouter 9 | 10 | from .test_client import client, constants, schemas 11 | 12 | BASE_URL = constants.api_base_url() 13 | 14 | 15 | @pytest.mark.respx(base_url=BASE_URL) 16 | def test_simple_request_simple_request_get(respx_mock: MockRouter): 17 | # Given 18 | mocked_response = {"status": "hello world"} 19 | mock_path = "/simple-request" 20 | respx_mock.get(mock_path).mock( 21 | return_value=Response(json=mocked_response, status_code=200) 22 | ) 23 | # When 24 | response = client.simple_request_simple_request_get() 25 | # Then 26 | assert isinstance(response, schemas.SimpleResponse) 27 | assert len(respx_mock.calls) == 1 28 | call = respx_mock.calls[0] 29 | assert call.request.url == BASE_URL + mock_path 30 | ``` 31 | 32 | We recommend you install [respx](https://lundberg.github.io/respx/) for writing your tests. 33 | -------------------------------------------------------------------------------- /docs/usage.md: -------------------------------------------------------------------------------- 1 | 2 | # 📝 Use Clientele 3 | 4 | !!! note 5 | 6 | You can type `clientele COMMAND --help` at anytime to see explicit information about the available arguments. 7 | 8 | ## `generate` 9 | 10 | Generate a Python HTTP Client from an OpenAPI Schema. 11 | 12 | ### From a URL 13 | 14 | Use the `-u` or `--url` argument. 15 | 16 | `-o` or `--output` is the target directory for the generate client. 17 | 18 | ```sh 19 | clientele generate -u https://raw.githubusercontent.com/phalt/clientele/main/example_openapi_specs/best.json -o my_client/ 20 | ``` 21 | 22 | !!! note 23 | 24 | The example above uses one of our test schemas, and will work if you copy/paste it! 25 | 26 | ### From a file 27 | 28 | Alternatively you can provide a local file using the `-f` or `--file` argument. 29 | 30 | ```sh 31 | clientele generate -f path/to/file.json -o my_client/ 32 | ``` 33 | 34 | ### Async.io 35 | 36 | If you prefer an [asyncio](https://docs.python.org/3/library/asyncio.html) client, just pass `--asyncio t` to your command. 37 | 38 | ```sh 39 | clientele generate -f path/to/file.json -o my_client/ --asyncio t 40 | ``` 41 | 42 | ### Regenerating 43 | 44 | At times you may wish to regenerate the client. This could be because the API has updated or you just want to use a newer version of clientele. 45 | 46 | To force a regeneration you must pass the `--regen` or `-r` argument, for example: 47 | 48 | ```sh 49 | clientele generate -f example_openapi_specs/best.json -o my_client/ --regen t 50 | ``` 51 | 52 | !!! note 53 | 54 | You can copy and paste the command from the `MANIFEST.md` file in your previously-generated client for a quick and easy regeneration. 55 | 56 | ## `validate` 57 | 58 | Validate lets you check if an OpenAPI schema will work with clientele. 59 | 60 | !!! note 61 | 62 | Some OpenAPI schema generators do not conform to the [specification](https://spec.openapis.org/oas/v3.1.0). 63 | 64 | Clientele uses [openapi-core](https://openapi-core.readthedocs.io/en/latest/) to validate the schema. 65 | 66 | ### From a URL 67 | 68 | Use the `-u` or `--url` argument. 69 | 70 | `-o` or `--output` is the target directory for the generate client. 71 | 72 | ```sh 73 | clientele validate -u http://path.com/to/openapi.json 74 | ``` 75 | 76 | ### From a file path 77 | 78 | Alternatively you can provide a local file using the `-f` or `--file` argument. 79 | 80 | ```sh 81 | clientele validate -f /path/to/openapi.json 82 | ``` 83 | 84 | ## generate-basic 85 | 86 | The `generate-basic` command can be used to generate a basic file structure for an HTTP client. 87 | 88 | It does not required an OpenAPI schema, just a path. 89 | 90 | This command serves two reasons: 91 | 92 | 1. You may have an HTTP API without an OpenAPI schema and you want to keep a consistent file structure with other Clientele clients. 93 | 2. The generator for this basic client can be extended for your own client in the future, if you choose. 94 | 95 | ```sh 96 | clientele generate-basic -o my_client/ 97 | ``` 98 | 99 | ## `version` 100 | 101 | Print the current version of Clientele: 102 | 103 | ```sh 104 | > clientele version 105 | Clientele 0.9.0 106 | ``` 107 | -------------------------------------------------------------------------------- /example_openapi_specs/best.json: -------------------------------------------------------------------------------- 1 | { 2 | "components": { 3 | "schemas": { 4 | "AnotherModel": { 5 | "description": "Another model used as a nested example", 6 | "properties": { 7 | "key": { 8 | "title": "Key", 9 | "type": "string" 10 | } 11 | }, 12 | "required": [ 13 | "key" 14 | ], 15 | "title": "AnotherModel", 16 | "type": "object" 17 | }, 18 | "ComplexModelResponse": { 19 | "description": "A complex response with a breadth of response types", 20 | "properties": { 21 | "a_dict_response": { 22 | "additionalProperties": { 23 | "type": "string" 24 | }, 25 | "title": "A Dict Response", 26 | "type": "object" 27 | }, 28 | "a_enum": { 29 | "$ref": "#/components/schemas/ExampleEnum" 30 | }, 31 | "a_list_of_enums": { 32 | "items": { 33 | "$ref": "#/components/schemas/ExampleEnum" 34 | }, 35 | "type": "array" 36 | }, 37 | "a_list_of_numbers": { 38 | "items": { 39 | "type": "integer" 40 | }, 41 | "title": "A List Of Numbers", 42 | "type": "array" 43 | }, 44 | "a_list_of_other_models": { 45 | "items": { 46 | "$ref": "#/components/schemas/AnotherModel" 47 | }, 48 | "title": "A List Of Other Models", 49 | "type": "array" 50 | }, 51 | "a_list_of_strings": { 52 | "items": { 53 | "type": "string" 54 | }, 55 | "title": "A List Of Strings", 56 | "type": "array" 57 | }, 58 | "a_number": { 59 | "title": "A Number", 60 | "type": "integer" 61 | }, 62 | "a_string": { 63 | "title": "A String", 64 | "type": "string" 65 | }, 66 | "a_decimal": { 67 | "title": "A Decimal", 68 | "type": "number", 69 | "format": "decimal" 70 | }, 71 | "another_model": { 72 | "$ref": "#/components/schemas/AnotherModel" 73 | } 74 | }, 75 | "required": [ 76 | "a_string", 77 | "a_number", 78 | "a_decimal", 79 | "a_list_of_strings", 80 | "a_list_of_numbers", 81 | "another_model", 82 | "a_list_of_other_models", 83 | "a_dict_response", 84 | "a_enum", 85 | "a_list_of_enums" 86 | ], 87 | "title": "ComplexModelResponse", 88 | "type": "object" 89 | }, 90 | "DeleteResponse": { 91 | "description": "A delete response, which is also empty! ", 92 | "properties": {}, 93 | "title": "DeleteResponse", 94 | "type": "object" 95 | }, 96 | "ExampleEnum": { 97 | "description": "An example Enum", 98 | "enum": [ 99 | "ONE", 100 | "TWO" 101 | ], 102 | "title": "ExampleEnum" 103 | }, 104 | "HeadersResponse": { 105 | "description": "A model that returns the X-TEST header from a request ", 106 | "properties": { 107 | "x_test": { 108 | "title": "X Test", 109 | "type": "string" 110 | } 111 | }, 112 | "required": [ 113 | "x_test" 114 | ], 115 | "title": "HeadersResponse", 116 | "type": "object" 117 | }, 118 | "HTTPValidationError": { 119 | "properties": { 120 | "detail": { 121 | "items": { 122 | "$ref": "#/components/schemas/ValidationError" 123 | }, 124 | "title": "Detail", 125 | "type": "array" 126 | } 127 | }, 128 | "title": "HTTPValidationError", 129 | "type": "object" 130 | }, 131 | "OptionalParametersResponse": { 132 | "description": "A response with optional parameters", 133 | "properties": { 134 | "optional_parameter": { 135 | "title": "Optional Parameter", 136 | "type": "string" 137 | }, 138 | "required_parameter": { 139 | "title": "Required Parameter", 140 | "type": "string" 141 | } 142 | }, 143 | "required": [ 144 | "required_parameter" 145 | ], 146 | "title": "OptionalParametersResponse", 147 | "type": "object" 148 | }, 149 | "ParameterResponse": { 150 | "description": "Returns the parameter sent to it", 151 | "properties": { 152 | "your_input": { 153 | "title": "Your Input", 154 | "type": "string" 155 | } 156 | }, 157 | "required": [ 158 | "your_input" 159 | ], 160 | "title": "ParameterResponse", 161 | "type": "object" 162 | }, 163 | "RequestDataAndParameterResponse": { 164 | "description": "A response for the POST endpoint that also takes a path parameter ", 165 | "properties": { 166 | "my_input": { 167 | "title": "My Input", 168 | "type": "string" 169 | }, 170 | "path_parameter": { 171 | "title": "Path Parameter", 172 | "type": "string" 173 | } 174 | }, 175 | "required": [ 176 | "my_input", 177 | "path_parameter" 178 | ], 179 | "title": "RequestDataAndParameterResponse", 180 | "type": "object" 181 | }, 182 | "RequestDataRequest": { 183 | "description": "Input data for the test POST endpoint ", 184 | "properties": { 185 | "my_input": { 186 | "title": "My Input", 187 | "type": "string" 188 | }, 189 | "my_decimal_input": { 190 | "title": "A Decimal input", 191 | "type": "number", 192 | "format": "decimal" 193 | }, 194 | }, 195 | "required": [ 196 | "my_input", "my_decimal_input" 197 | ], 198 | "title": "RequestDataRequest", 199 | "type": "object" 200 | }, 201 | "RequestDataResponse": { 202 | "description": "A response for the POST endpoint ", 203 | "properties": { 204 | "my_input": { 205 | "title": "My Input", 206 | "type": "string" 207 | } 208 | }, 209 | "required": [ 210 | "my_input" 211 | ], 212 | "title": "RequestDataResponse", 213 | "type": "object" 214 | }, 215 | "SecurityRequiredResponse": { 216 | "description": "Returns the token passed in the HTTP AUTHORIZATION headers ", 217 | "properties": { 218 | "token": { 219 | "title": "Token", 220 | "type": "string" 221 | } 222 | }, 223 | "required": [ 224 | "token" 225 | ], 226 | "title": "SecurityRequiredResponse", 227 | "type": "object" 228 | }, 229 | "SimpleQueryParametersResponse": { 230 | "description": "A response for query parameters request", 231 | "properties": { 232 | "your_query": { 233 | "title": "Your Query", 234 | "type": "string" 235 | } 236 | }, 237 | "required": [ 238 | "your_query" 239 | ], 240 | "title": "SimpleQueryParametersResponse", 241 | "type": "object" 242 | }, 243 | "OptionalQueryParametersResponse": { 244 | "description": "A response for query parameters request that has an optional parameter", 245 | "properties": { 246 | "your_query": { 247 | "title": "Your Query", 248 | "type": "string" 249 | } 250 | }, 251 | "required": ["your_query"], 252 | "title": "OptionalQueryParametersResponse", 253 | "type": "object" 254 | }, 255 | "SimpleResponse": { 256 | "description": "A simple response", 257 | "properties": { 258 | "status": { 259 | "title": "Status", 260 | "type": "string" 261 | } 262 | }, 263 | "required": [ 264 | "status" 265 | ], 266 | "title": "SimpleResponse", 267 | "type": "object" 268 | }, 269 | "ValidationError": { 270 | "properties": { 271 | "loc": { 272 | "items": { 273 | "anyOf": [ 274 | { 275 | "type": "string" 276 | }, 277 | { 278 | "type": "integer" 279 | } 280 | ] 281 | }, 282 | "title": "Location", 283 | "type": "array" 284 | }, 285 | "msg": { 286 | "title": "Message", 287 | "type": "string" 288 | }, 289 | "type": { 290 | "title": "Error Type", 291 | "type": "string" 292 | } 293 | }, 294 | "required": [ 295 | "loc", 296 | "msg", 297 | "type" 298 | ], 299 | "title": "ValidationError", 300 | "type": "object" 301 | } 302 | }, 303 | "securitySchemes": { 304 | "HTTPBearer": { 305 | "scheme": "bearer", 306 | "type": "http" 307 | } 308 | } 309 | }, 310 | "info": { 311 | "title": "Example OpenAPI schema server for clientele testing", 312 | "version": "0.1.0" 313 | }, 314 | "openapi": "3.0.2", 315 | "paths": { 316 | "/complex-model-request": { 317 | "get": { 318 | "description": "A request that returns a complex model demonstrating various response types", 319 | "operationId": "complex_model_request_complex_model_request_get", 320 | "responses": { 321 | "200": { 322 | "content": { 323 | "application/json": { 324 | "schema": { 325 | "$ref": "#/components/schemas/ComplexModelResponse" 326 | } 327 | } 328 | }, 329 | "description": "Successful Response" 330 | } 331 | }, 332 | "summary": "Complex Model Request" 333 | } 334 | }, 335 | "/header-request": { 336 | "get": { 337 | "operationId": "header_request_header_request_get", 338 | "parameters": [ 339 | { 340 | "description": "test header", 341 | "in": "header", 342 | "name": "x-test", 343 | "required": false, 344 | "schema": { 345 | "description": "test header", 346 | "title": "X-Test" 347 | } 348 | } 349 | ], 350 | "responses": { 351 | "200": { 352 | "content": { 353 | "application/json": { 354 | "schema": { 355 | "$ref": "#/components/schemas/HeadersResponse" 356 | } 357 | } 358 | }, 359 | "description": "Successful Response" 360 | }, 361 | "422": { 362 | "content": { 363 | "application/json": { 364 | "schema": { 365 | "$ref": "#/components/schemas/HTTPValidationError" 366 | } 367 | } 368 | }, 369 | "description": "Validation Error" 370 | } 371 | }, 372 | "summary": "Header Request" 373 | } 374 | }, 375 | "/optional-parameters": { 376 | "get": { 377 | "description": "A response with a a model that has optional input values", 378 | "operationId": "optional_parameters_request_optional_parameters_get", 379 | "responses": { 380 | "200": { 381 | "content": { 382 | "application/json": { 383 | "schema": { 384 | "$ref": "#/components/schemas/OptionalParametersResponse" 385 | } 386 | } 387 | }, 388 | "description": "Successful Response" 389 | } 390 | }, 391 | "summary": "Optional Parameters Request" 392 | } 393 | }, 394 | "/request-data": { 395 | "post": { 396 | "description": "An endpoint that takes input data from an HTTP POST request and returns it", 397 | "operationId": "request_data_request_data_post", 398 | "requestBody": { 399 | "content": { 400 | "application/json": { 401 | "schema": { 402 | "$ref": "#/components/schemas/RequestDataRequest" 403 | } 404 | } 405 | }, 406 | "required": true 407 | }, 408 | "responses": { 409 | "200": { 410 | "content": { 411 | "application/json": { 412 | "schema": { 413 | "$ref": "#/components/schemas/RequestDataResponse" 414 | } 415 | } 416 | }, 417 | "description": "Successful Response" 418 | }, 419 | "422": { 420 | "content": { 421 | "application/json": { 422 | "schema": { 423 | "$ref": "#/components/schemas/HTTPValidationError" 424 | } 425 | } 426 | }, 427 | "description": "Validation Error" 428 | } 429 | }, 430 | "summary": "Request Data" 431 | }, 432 | "put": { 433 | "description": "An endpoint that takes input data from an HTTP PUT request and returns it", 434 | "operationId": "request_data_request_data_put", 435 | "requestBody": { 436 | "content": { 437 | "application/json": { 438 | "schema": { 439 | "$ref": "#/components/schemas/RequestDataRequest" 440 | } 441 | } 442 | }, 443 | "required": true 444 | }, 445 | "responses": { 446 | "200": { 447 | "content": { 448 | "application/json": { 449 | "schema": { 450 | "$ref": "#/components/schemas/RequestDataResponse" 451 | } 452 | } 453 | }, 454 | "description": "Successful Response" 455 | }, 456 | "422": { 457 | "content": { 458 | "application/json": { 459 | "schema": { 460 | "$ref": "#/components/schemas/HTTPValidationError" 461 | } 462 | } 463 | }, 464 | "description": "Validation Error" 465 | } 466 | }, 467 | "summary": "Request Data" 468 | } 469 | }, 470 | "/request-data/{path_parameter}": { 471 | "post": { 472 | "description": "An endpoint that takes input data from an HTTP POST request and also a path parameter", 473 | "operationId": "request_data_path_request_data__path_parameter__post", 474 | "parameters": [ 475 | { 476 | "in": "path", 477 | "name": "path_parameter", 478 | "required": true, 479 | "schema": { 480 | "title": "Path Parameter", 481 | "type": "string" 482 | } 483 | } 484 | ], 485 | "requestBody": { 486 | "content": { 487 | "application/json": { 488 | "schema": { 489 | "$ref": "#/components/schemas/RequestDataRequest" 490 | } 491 | } 492 | }, 493 | "required": true 494 | }, 495 | "responses": { 496 | "200": { 497 | "content": { 498 | "application/json": { 499 | "schema": { 500 | "$ref": "#/components/schemas/RequestDataAndParameterResponse" 501 | } 502 | } 503 | }, 504 | "description": "Successful Response" 505 | }, 506 | "422": { 507 | "content": { 508 | "application/json": { 509 | "schema": { 510 | "$ref": "#/components/schemas/HTTPValidationError" 511 | } 512 | } 513 | }, 514 | "description": "Validation Error" 515 | } 516 | }, 517 | "summary": "Request Data Path" 518 | } 519 | }, 520 | "/request-delete": { 521 | "delete": { 522 | "operationId": "request_delete_request_delete_delete", 523 | "responses": { 524 | "200": { 525 | "content": { 526 | "application/json": { 527 | "schema": { 528 | "$ref": "#/components/schemas/DeleteResponse" 529 | } 530 | } 531 | }, 532 | "description": "Successful Response" 533 | } 534 | }, 535 | "summary": "Request Delete" 536 | } 537 | }, 538 | "/security-required": { 539 | "get": { 540 | "operationId": "security_required_request_security_required_get", 541 | "responses": { 542 | "200": { 543 | "content": { 544 | "application/json": { 545 | "schema": { 546 | "$ref": "#/components/schemas/SecurityRequiredResponse" 547 | } 548 | } 549 | }, 550 | "description": "Successful Response" 551 | } 552 | }, 553 | "security": [ 554 | { 555 | "HTTPBearer": [] 556 | } 557 | ], 558 | "summary": "Security Required Request" 559 | } 560 | }, 561 | "/simple-query": { 562 | "get": { 563 | "description": "A request with a query parameters", 564 | "operationId": "query_request_simple_query_get", 565 | "parameters": [ 566 | { 567 | "in": "query", 568 | "name": "yourInput", 569 | "required": true, 570 | "schema": { 571 | "title": "Your Input", 572 | "type": "string" 573 | } 574 | } 575 | ], 576 | "responses": { 577 | "200": { 578 | "content": { 579 | "application/json": { 580 | "schema": { 581 | "$ref": "#/components/schemas/SimpleQueryParametersResponse" 582 | } 583 | } 584 | }, 585 | "description": "Successful Response" 586 | }, 587 | "422": { 588 | "content": { 589 | "application/json": { 590 | "schema": { 591 | "$ref": "#/components/schemas/HTTPValidationError" 592 | } 593 | } 594 | }, 595 | "description": "Validation Error" 596 | } 597 | }, 598 | "summary": "Query Request" 599 | } 600 | }, 601 | "/optional-query": { 602 | "get": { 603 | "description": "A request with a query parameters that are optional", 604 | "operationId": "query_request_optional_query_get", 605 | "parameters": [ 606 | { 607 | "in": "query", 608 | "name": "yourInput", 609 | "required": false, 610 | "schema": { 611 | "title": "Your Input", 612 | "type": "string" 613 | } 614 | } 615 | ], 616 | "responses": { 617 | "200": { 618 | "content": { 619 | "application/json": { 620 | "schema": { 621 | "$ref": "#/components/schemas/OptionalQueryParametersResponse" 622 | } 623 | } 624 | }, 625 | "description": "Successful Response" 626 | }, 627 | "422": { 628 | "content": { 629 | "application/json": { 630 | "schema": { 631 | "$ref": "#/components/schemas/HTTPValidationError" 632 | } 633 | } 634 | }, 635 | "description": "Validation Error" 636 | } 637 | }, 638 | "summary": "Optional Query Request" 639 | } 640 | }, 641 | "/simple-request": { 642 | "get": { 643 | "description": "A simple API request with no parameters.", 644 | "operationId": "simple_request_simple_request_get", 645 | "responses": { 646 | "200": { 647 | "content": { 648 | "application/json": { 649 | "schema": { 650 | "$ref": "#/components/schemas/SimpleResponse" 651 | } 652 | } 653 | }, 654 | "description": "Successful Response" 655 | } 656 | }, 657 | "summary": "Simple Request" 658 | } 659 | }, 660 | "/simple-request/{your_input}": { 661 | "get": { 662 | "description": "A request with a URL parameter", 663 | "operationId": "parameter_request_simple_request__your_input__get", 664 | "parameters": [ 665 | { 666 | "in": "path", 667 | "name": "your_input", 668 | "required": true, 669 | "schema": { 670 | "title": "Your Input", 671 | "type": "string" 672 | } 673 | } 674 | ], 675 | "responses": { 676 | "200": { 677 | "content": { 678 | "application/json": { 679 | "schema": { 680 | "$ref": "#/components/schemas/ParameterResponse" 681 | } 682 | } 683 | }, 684 | "description": "Successful Response" 685 | }, 686 | "422": { 687 | "content": { 688 | "application/json": { 689 | "schema": { 690 | "$ref": "#/components/schemas/HTTPValidationError" 691 | } 692 | } 693 | }, 694 | "description": "Validation Error" 695 | } 696 | }, 697 | "summary": "Parameter Request" 698 | } 699 | } 700 | } 701 | } 702 | -------------------------------------------------------------------------------- /example_openapi_specs/simple.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.2", 3 | "info": { 4 | "title": "Simple API Example from FastAPI", 5 | "version": "0.1.0" 6 | }, 7 | "paths": { 8 | "/health-check": { 9 | "get": { 10 | "summary": "Health Check", 11 | "description": "Standard health check.", 12 | "operationId": "health_check_health_check_get", 13 | "responses": { 14 | "200": { 15 | "description": "Successful Response", 16 | "content": { 17 | "application/json": { 18 | "schema": { 19 | "$ref": "#/components/schemas/HealthCheckResponse" 20 | } 21 | } 22 | } 23 | } 24 | } 25 | } 26 | }, 27 | "/test-input": { 28 | "post": { 29 | "summary": "Test Input", 30 | "description": "A POST API endpoint for testing", 31 | "operationId": "test_input_test_input_post", 32 | "requestBody": { 33 | "content": { 34 | "application/json": { 35 | "schema": { 36 | "$ref": "#/components/schemas/TestInputData" 37 | } 38 | } 39 | }, 40 | "required": true 41 | }, 42 | "responses": { 43 | "200": { 44 | "description": "Successful Response", 45 | "content": { 46 | "application/json": { 47 | "schema": { 48 | "$ref": "#/components/schemas/TestInputResponse" 49 | } 50 | } 51 | } 52 | }, 53 | "422": { 54 | "description": "Validation Error", 55 | "content": { 56 | "application/json": { 57 | "schema": { 58 | "$ref": "#/components/schemas/HTTPValidationError" 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | }, 67 | "components": { 68 | "schemas": { 69 | "HTTPValidationError": { 70 | "title": "HTTPValidationError", 71 | "type": "object", 72 | "properties": { 73 | "detail": { 74 | "title": "Detail", 75 | "type": "array", 76 | "items": { 77 | "$ref": "#/components/schemas/ValidationError" 78 | } 79 | } 80 | } 81 | }, 82 | "HealthCheckResponse": { 83 | "title": "HealthCheckResponse", 84 | "type": "object", 85 | "properties": { 86 | "status": { 87 | "title": "Status", 88 | "type": "string" 89 | } 90 | } 91 | }, 92 | "TestInputData": { 93 | "title": "TestInputData", 94 | "required": [ 95 | "my_title" 96 | ], 97 | "type": "object", 98 | "properties": { 99 | "my_title": { 100 | "title": "My Title", 101 | "type": "string" 102 | } 103 | } 104 | }, 105 | "TestInputResponse": { 106 | "title": "TestInputResponse", 107 | "required": [ 108 | "title" 109 | ], 110 | "type": "object", 111 | "properties": { 112 | "title": { 113 | "title": "Title", 114 | "type": "string" 115 | } 116 | } 117 | }, 118 | "ValidationError": { 119 | "title": "ValidationError", 120 | "required": [ 121 | "loc", 122 | "msg", 123 | "type" 124 | ], 125 | "type": "object", 126 | "properties": { 127 | "loc": { 128 | "title": "Location", 129 | "type": "array", 130 | "items": { 131 | "anyOf": [ 132 | { 133 | "type": "string" 134 | }, 135 | { 136 | "type": "integer" 137 | } 138 | ] 139 | } 140 | }, 141 | "msg": { 142 | "title": "Message", 143 | "type": "string" 144 | }, 145 | "type": { 146 | "title": "Error Type", 147 | "type": "string" 148 | } 149 | } 150 | } 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /example_openapi_specs/test_303.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.0.3 2 | info: 3 | title: "OpenAPI 3.0.3 example" 4 | description: |- 5 | An example OpenAPI 3.0.3 schema for testing, with header parameters, oauth etc 6 | version: 1.0.1 7 | security: 8 | - Bearer: [] 9 | paths: 10 | /threads: 11 | parameters: 12 | - $ref: '#/components/parameters/request-id' 13 | post: 14 | summary: Create Thread 15 | operationId: create-thread 16 | responses: 17 | '201': 18 | description: Thread created successfully 19 | content: 20 | application/json: 21 | schema: 22 | title: Create Thread Response 23 | type: object 24 | description: The create thread response 25 | properties: 26 | thread_id: 27 | type: string 28 | description: The thread ID 29 | format: uuid 30 | required: 31 | - thread_id 32 | '400': 33 | $ref: '#/components/responses/detail-error-response' 34 | default: 35 | $ref: '#/components/responses/optional-detail-error-response' 36 | security: 37 | - Bearer: 38 | - 'create:thread' 39 | requestBody: 40 | content: 41 | application/json: 42 | schema: 43 | $ref: '#/components/schemas/create-thread-request' 44 | description: The create thread request. 45 | parameters: 46 | - schema: 47 | type: string 48 | in: header 49 | name: x-customer-ip 50 | description: The customers IP address 51 | - $ref: '#/components/parameters/idempotency-key' 52 | tags: 53 | - Quick Payments 54 | '/threads/{thread_id}': 55 | parameters: 56 | - $ref: '#/components/parameters/request-id' 57 | - schema: 58 | type: string 59 | format: uuid 60 | example: 035d4ea4-4037-4110-9861-183eae1408b4 61 | name: thread_id 62 | in: path 63 | required: true 64 | description: The thread ID 65 | get: 66 | summary: Get Thread 67 | responses: 68 | '200': 69 | description: The thread details. 70 | content: 71 | application/json: 72 | schema: 73 | $ref: '#/components/schemas/create-thread-response' 74 | '400': 75 | $ref: '#/components/responses/detail-error-response' 76 | 5XX: 77 | $ref: '#/components/responses/optional-detail-error-response' 78 | default: 79 | $ref: '#/components/responses/optional-detail-error-response' 80 | operationId: get-thread 81 | security: 82 | - Bearer: 83 | - 'view:thread' 84 | description: Get thread ID. 85 | delete: 86 | summary: Delete Thread 87 | operationId: delete-thread 88 | responses: 89 | '204': 90 | description: Deleted successfully. 91 | '400': 92 | $ref: '#/components/responses/detail-error-response' 93 | 5XX: 94 | $ref: '#/components/responses/optional-detail-error-response' 95 | default: 96 | $ref: '#/components/responses/optional-detail-error-response' 97 | security: 98 | - Bearer: 99 | - 'revoke:thread' 100 | components: 101 | schemas: 102 | thread: 103 | title: Thread Model 104 | type: object 105 | description: The model for a thread. 106 | properties: 107 | thread_id: 108 | type: string 109 | description: The thread ID 110 | format: uuid 111 | required: 112 | - thread_id 113 | create-thread-request: 114 | title: Create Thread Model 115 | description: 'The model for creating a thread' 116 | allOf: 117 | - $ref: '#/components/schemas/thread' 118 | - $ref: '#/components/schemas/thread-request' 119 | create-thread-response: 120 | title: Create Thread Model 121 | description: 'The model for creating a thread' 122 | allOf: 123 | - $ref: '#/components/schemas/thread' 124 | detail-error-response-model: 125 | title: Detail Error Response Model 126 | type: object 127 | description: The detailed error response. 128 | properties: 129 | timestamp: 130 | type: string 131 | format: date-time 132 | description: The error timestamp. 133 | status: 134 | type: integer 135 | description: The status code. 136 | required: 137 | - status 138 | thread-request: 139 | title: Thread request Model 140 | type: object 141 | description: The model for requesting a thread. 142 | properties: 143 | content: 144 | type: string 145 | description: The thread content 146 | required: 147 | - content 148 | responses: 149 | detail-error-response: 150 | description: An error response. 151 | content: 152 | application/json: 153 | schema: 154 | $ref: '#/components/schemas/detail-error-response-model' 155 | optional-detail-error-response: 156 | description: An error response. 157 | securitySchemes: 158 | Bearer: 159 | type: oauth2 160 | description: OAuth2 Access Token scopes. 161 | flows: 162 | clientCredentials: 163 | tokenUrl: 'https://localhost/oauth2/token' 164 | scopes: 165 | 'create:thread': Create thread requests 166 | 'revoke:thread': Delete thread 167 | 'view:thread': View thread 168 | parameters: 169 | request-id: 170 | name: request-id 171 | in: header 172 | required: false 173 | schema: 174 | type: string 175 | format: uuid 176 | example: a1d207f3-8e61-4c14-9fe4-843af1addd8f 177 | description: 'a request id header' 178 | idempotency-key: 179 | name: idempotency-key 180 | in: header 181 | required: false 182 | schema: 183 | type: string 184 | format: uuid 185 | example: ddc0315c-fba7-4926-ba24-b7700ed389e7 186 | description: An optional idempotency key to prevent duplicate submissions. 187 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: ⚜️ Clientele 2 | site_url: https://phalt.github.io/clientele/ 3 | theme: 4 | name: material 5 | palette: 6 | scheme: slate 7 | primary: blue grey 8 | repo_name: phalt/clientele 9 | repo_url: https://github.com/phalt/clientele 10 | nav: 11 | - Home: index.md 12 | - Install: install.md 13 | - Use: usage.md 14 | - Design: design.md 15 | - Client Example: examples.md 16 | - Testing: testing.md 17 | - Compatibility: compatibility.md 18 | - Change log: CHANGELOG.md 19 | 20 | markdown_extensions: 21 | - pymdownx.highlight: 22 | anchor_linenums: true 23 | line_spans: __span 24 | pygments_lang_class: true 25 | - pymdownx.inlinehilite 26 | - pymdownx.highlight 27 | - pymdownx.snippets 28 | - pymdownx.superfences 29 | - admonition 30 | - pymdownx.details 31 | - codehilite 32 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "clientele" 3 | version = "0.9.0" 4 | description = "Generate loveable Python HTTP API Clients" 5 | authors = ["Paul Hallett "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{include = "clientele"}] 9 | homepage = "https://phalt.github.io/clientele/" 10 | 11 | [tool.poetry.urls] 12 | changelog = "https://phalt.github.io/clientele/CHANGELOG/" 13 | documentation = "https://phalt.github.io/clientele/" 14 | issues = "https://github.com/phalt/clientele/issues" 15 | 16 | [tool.poetry.scripts] 17 | clientele = "clientele.cli:cli_group" 18 | 19 | [tool.poetry.dependencies] 20 | python = "^3.9" 21 | httpx = "^0.24.1" 22 | click = "^8.1.3" 23 | pydantic = "^2.4" 24 | rich = "^13.4.2" 25 | openapi-core = "0.18.0" 26 | pyyaml = "^6.0.1" 27 | types-pyyaml = "^6.0.12.11" 28 | jinja2 = "^3.1.2" 29 | black = "^23.9.1" 30 | 31 | [tool.poetry.group.dev.dependencies] 32 | mypy = "^1.4.0" 33 | mkdocs = "^1.4.3" 34 | ipython = "^8.14.0" 35 | mkdocs-material = "^9.1.19" 36 | respx = "^0.20.2" 37 | pytest = "^7.4.0" 38 | pytest-asyncio = "^0.21.1" 39 | ruff = "^0.1.2" 40 | 41 | [build-system] 42 | requires = ["poetry-core"] 43 | build-backend = "poetry.core.masonry.api" 44 | 45 | [tool.ruff] 46 | line-length = 120 47 | select = [ 48 | # Pyflakes 49 | "F", 50 | # Pycodestyle 51 | "E", 52 | "W", 53 | # isort 54 | "I001" 55 | ] 56 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/tests/__init__.py -------------------------------------------------------------------------------- /tests/async_test_client/MANIFEST.md: -------------------------------------------------------------------------------- 1 | # Manifest 2 | 3 | Generated with [https://github.com/phalt/clientele](https://github.com/phalt/clientele) 4 | Install with pipx: 5 | 6 | ```sh 7 | pipx install clientele 8 | ``` 9 | 10 | API VERSION: 0.1.0 11 | OPENAPI VERSION: 3.0.2 12 | CLIENTELE VERSION: 0.9.0 13 | 14 | Regenerate using this command: 15 | 16 | ```sh 17 | clientele generate -f example_openapi_specs/best.json -o tests/async_test_client/ --asyncio t --regen t 18 | ``` 19 | -------------------------------------------------------------------------------- /tests/async_test_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/tests/async_test_client/__init__.py -------------------------------------------------------------------------------- /tests/async_test_client/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing # noqa 4 | 5 | from tests.async_test_client import http, schemas # noqa 6 | 7 | 8 | async def complex_model_request_complex_model_request_get() -> schemas.ComplexModelResponse: 9 | """Complex Model Request""" 10 | 11 | response = await http.get(url="/complex-model-request") 12 | return http.handle_response(complex_model_request_complex_model_request_get, response) 13 | 14 | 15 | async def header_request_header_request_get( 16 | headers: typing.Optional[schemas.HeaderRequestHeaderRequestGetHeaders], 17 | ) -> schemas.HTTPValidationError | schemas.HeadersResponse: 18 | """Header Request""" 19 | headers_dict = headers and headers.model_dump(by_alias=True, exclude_unset=True) or None 20 | response = await http.get(url="/header-request", headers=headers_dict) 21 | return http.handle_response(header_request_header_request_get, response) 22 | 23 | 24 | async def optional_parameters_request_optional_parameters_get() -> schemas.OptionalParametersResponse: 25 | """Optional Parameters Request""" 26 | 27 | response = await http.get(url="/optional-parameters") 28 | return http.handle_response(optional_parameters_request_optional_parameters_get, response) 29 | 30 | 31 | async def request_data_request_data_post( 32 | data: schemas.RequestDataRequest, 33 | ) -> schemas.HTTPValidationError | schemas.RequestDataResponse: 34 | """Request Data""" 35 | 36 | response = await http.post(url="/request-data", data=data.model_dump()) 37 | return http.handle_response(request_data_request_data_post, response) 38 | 39 | 40 | async def request_data_request_data_put( 41 | data: schemas.RequestDataRequest, 42 | ) -> schemas.HTTPValidationError | schemas.RequestDataResponse: 43 | """Request Data""" 44 | 45 | response = await http.put(url="/request-data", data=data.model_dump()) 46 | return http.handle_response(request_data_request_data_put, response) 47 | 48 | 49 | async def request_data_path_request_data( 50 | path_parameter: str, data: schemas.RequestDataRequest 51 | ) -> schemas.HTTPValidationError | schemas.RequestDataAndParameterResponse: 52 | """Request Data Path""" 53 | 54 | response = await http.post(url=f"/request-data/{path_parameter}", data=data.model_dump()) 55 | return http.handle_response(request_data_path_request_data, response) 56 | 57 | 58 | async def request_delete_request_delete_delete() -> schemas.DeleteResponse: 59 | """Request Delete""" 60 | 61 | response = await http.delete(url="/request-delete") 62 | return http.handle_response(request_delete_request_delete_delete, response) 63 | 64 | 65 | async def security_required_request_security_required_get() -> schemas.SecurityRequiredResponse: 66 | """Security Required Request""" 67 | 68 | response = await http.get(url="/security-required") 69 | return http.handle_response(security_required_request_security_required_get, response) 70 | 71 | 72 | async def query_request_simple_query_get( 73 | yourInput: str, 74 | ) -> schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse: 75 | """Query Request""" 76 | 77 | response = await http.get(url=f"/simple-query?yourInput={yourInput}") 78 | return http.handle_response(query_request_simple_query_get, response) 79 | 80 | 81 | async def query_request_optional_query_get( 82 | yourInput: typing.Optional[str], 83 | ) -> schemas.HTTPValidationError | schemas.OptionalQueryParametersResponse: 84 | """Optional Query Request""" 85 | 86 | response = await http.get(url=f"/optional-query?yourInput={yourInput}") 87 | return http.handle_response(query_request_optional_query_get, response) 88 | 89 | 90 | async def simple_request_simple_request_get() -> schemas.SimpleResponse: 91 | """Simple Request""" 92 | 93 | response = await http.get(url="/simple-request") 94 | return http.handle_response(simple_request_simple_request_get, response) 95 | 96 | 97 | async def parameter_request_simple_request( 98 | your_input: str, 99 | ) -> schemas.HTTPValidationError | schemas.ParameterResponse: 100 | """Parameter Request""" 101 | 102 | response = await http.get(url=f"/simple-request/{your_input}") 103 | return http.handle_response(parameter_request_simple_request, response) 104 | -------------------------------------------------------------------------------- /tests/async_test_client/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file will never be updated on subsequent clientele runs. 3 | Use it as a space to store configuration and constants. 4 | 5 | DO NOT CHANGE THE FUNCTION NAMES 6 | """ 7 | 8 | 9 | def additional_headers() -> dict: 10 | """ 11 | Modify this function to provide additional headers to all 12 | HTTP requests made by this client. 13 | """ 14 | return {} 15 | 16 | 17 | def api_base_url() -> str: 18 | """ 19 | Modify this function to provide the current api_base_url. 20 | """ 21 | return "http://localhost" 22 | 23 | 24 | def get_user_key() -> str: 25 | """ 26 | HTTP Basic authentication. 27 | Username parameter 28 | """ 29 | return "user" 30 | 31 | 32 | def get_pass_key() -> str: 33 | """ 34 | HTTP Basic authentication. 35 | Password parameter 36 | """ 37 | return "password" 38 | 39 | 40 | def get_bearer_token() -> str: 41 | """ 42 | HTTP Bearer authentication. 43 | Used by many authentication methods - token, jwt, etc. 44 | Does not require the "Bearer" content, just the key as a string. 45 | """ 46 | return "token" 47 | -------------------------------------------------------------------------------- /tests/async_test_client/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import types 5 | import typing 6 | from decimal import Decimal 7 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 8 | 9 | import httpx 10 | 11 | from tests.async_test_client import config as c # noqa 12 | 13 | 14 | def json_serializer(obj): 15 | if isinstance(obj, Decimal): 16 | return str(obj) 17 | 18 | 19 | class APIException(Exception): 20 | """Could not match API response to return type of this function""" 21 | 22 | reason: str 23 | response: httpx.Response 24 | 25 | def __init__(self, response: httpx.Response, reason: str, *args: object) -> None: 26 | self.response = response 27 | self.reason = reason 28 | super().__init__(*args) 29 | 30 | 31 | def parse_url(url: str) -> str: 32 | """ 33 | Returns the full URL from a string. 34 | 35 | Will filter out any optional query parameters if they are None. 36 | """ 37 | api_url = f"{c.api_base_url()}{url}" 38 | url_parts = urlparse(url=api_url) 39 | # Filter out "None" optional query parameters 40 | filtered_query_params = {k: v for k, v in parse_qs(url_parts.query).items() if v[0] not in ["None", ""]} 41 | filtered_query_string = urlencode(filtered_query_params, doseq=True) 42 | return urlunparse( 43 | ( 44 | url_parts.scheme, 45 | url_parts.netloc, 46 | url_parts.path, 47 | url_parts.params, 48 | filtered_query_string, 49 | url_parts.fragment, 50 | ) 51 | ) 52 | 53 | 54 | def handle_response(func, response): 55 | """ 56 | Returns a schema object that matches the JSON data from the response. 57 | 58 | If it can't find a matching schema it will raise an error with details of the response. 59 | """ 60 | status_code = response.status_code 61 | # Get the response types 62 | response_types = typing.get_type_hints(func)["return"] 63 | 64 | if typing.get_origin(response_types) in [typing.Union, types.UnionType]: 65 | response_types = list(typing.get_args(response_types)) 66 | else: 67 | response_types = [response_types] 68 | 69 | # Determine, from the map, the correct response for this status code 70 | expected_responses = func_response_code_maps[func.__name__] # noqa 71 | if str(status_code) not in expected_responses.keys(): 72 | raise APIException(response=response, reason="An unexpected status code was received") 73 | else: 74 | expected_response_class_name = expected_responses[str(status_code)] 75 | 76 | # Get the correct response type and build it 77 | response_type = [t for t in response_types if t.__name__ == expected_response_class_name][0] 78 | data = response.json() 79 | return response_type.model_validate(data) 80 | 81 | 82 | # Func map 83 | func_response_code_maps = { 84 | "complex_model_request_complex_model_request_get": {"200": "ComplexModelResponse"}, 85 | "header_request_header_request_get": { 86 | "200": "HeadersResponse", 87 | "422": "HTTPValidationError", 88 | }, 89 | "optional_parameters_request_optional_parameters_get": {"200": "OptionalParametersResponse"}, 90 | "request_data_request_data_post": { 91 | "200": "RequestDataResponse", 92 | "422": "HTTPValidationError", 93 | }, 94 | "request_data_request_data_put": { 95 | "200": "RequestDataResponse", 96 | "422": "HTTPValidationError", 97 | }, 98 | "request_data_path_request_data": { 99 | "200": "RequestDataAndParameterResponse", 100 | "422": "HTTPValidationError", 101 | }, 102 | "request_delete_request_delete_delete": {"200": "DeleteResponse"}, 103 | "security_required_request_security_required_get": {"200": "SecurityRequiredResponse"}, 104 | "query_request_simple_query_get": { 105 | "200": "SimpleQueryParametersResponse", 106 | "422": "HTTPValidationError", 107 | }, 108 | "query_request_optional_query_get": { 109 | "200": "OptionalQueryParametersResponse", 110 | "422": "HTTPValidationError", 111 | }, 112 | "simple_request_simple_request_get": {"200": "SimpleResponse"}, 113 | "parameter_request_simple_request": { 114 | "200": "ParameterResponse", 115 | "422": "HTTPValidationError", 116 | }, 117 | } 118 | 119 | auth_key = c.get_bearer_token() 120 | client_headers = c.additional_headers() 121 | client_headers.update(Authorization=f"Bearer {auth_key}") 122 | client = httpx.AsyncClient(headers=client_headers) 123 | 124 | 125 | async def get(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 126 | """Issue an HTTP GET request""" 127 | if headers: 128 | client_headers.update(headers) 129 | async with httpx.AsyncClient(headers=client_headers) as async_client: 130 | return await async_client.get(parse_url(url)) 131 | 132 | 133 | async def post(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 134 | """Issue an HTTP POST request""" 135 | if headers: 136 | client_headers.update(headers) 137 | json_data = json.loads(json.dumps(data, default=json_serializer)) 138 | async with httpx.AsyncClient(headers=client_headers) as async_client: 139 | return await async_client.post(parse_url(url), json=json_data) 140 | 141 | 142 | async def put(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 143 | """Issue an HTTP PUT request""" 144 | if headers: 145 | client_headers.update(headers) 146 | json_data = json.loads(json.dumps(data, default=json_serializer)) 147 | async with httpx.AsyncClient(headers=client_headers) as async_client: 148 | return await async_client.put(parse_url(url), json=json_data) 149 | 150 | 151 | async def delete(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 152 | """Issue an HTTP DELETE request""" 153 | if headers: 154 | client_headers.update(headers) 155 | async with httpx.AsyncClient(headers=client_headers) as async_client: 156 | return await async_client.delete(parse_url(url)) 157 | -------------------------------------------------------------------------------- /tests/async_test_client/schemas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import typing 5 | from decimal import Decimal # noqa 6 | from enum import Enum # noqa 7 | 8 | import pydantic 9 | 10 | 11 | class AnotherModel(pydantic.BaseModel): 12 | key: str 13 | 14 | 15 | class ComplexModelResponse(pydantic.BaseModel): 16 | a_dict_response: dict[str, typing.Any] 17 | a_enum: "ExampleEnum" 18 | a_list_of_enums: list["ExampleEnum"] 19 | a_list_of_numbers: list[int] 20 | a_list_of_other_models: list["AnotherModel"] 21 | a_list_of_strings: list[str] 22 | a_number: int 23 | a_string: str 24 | a_decimal: Decimal 25 | another_model: "AnotherModel" 26 | 27 | 28 | class DeleteResponse(pydantic.BaseModel): 29 | pass 30 | 31 | 32 | class ExampleEnum(str, Enum): 33 | ONE = "ONE" 34 | TWO = "TWO" 35 | 36 | 37 | class HeadersResponse(pydantic.BaseModel): 38 | x_test: str 39 | 40 | 41 | class HTTPValidationError(pydantic.BaseModel): 42 | detail: list["ValidationError"] 43 | 44 | 45 | class OptionalParametersResponse(pydantic.BaseModel): 46 | optional_parameter: typing.Optional[str] 47 | required_parameter: str 48 | 49 | 50 | class ParameterResponse(pydantic.BaseModel): 51 | your_input: str 52 | 53 | 54 | class RequestDataAndParameterResponse(pydantic.BaseModel): 55 | my_input: str 56 | path_parameter: str 57 | 58 | 59 | class RequestDataRequest(pydantic.BaseModel): 60 | my_input: str 61 | my_decimal_input: Decimal 62 | 63 | 64 | class RequestDataResponse(pydantic.BaseModel): 65 | my_input: str 66 | 67 | 68 | class SecurityRequiredResponse(pydantic.BaseModel): 69 | token: str 70 | 71 | 72 | class SimpleQueryParametersResponse(pydantic.BaseModel): 73 | your_query: str 74 | 75 | 76 | class OptionalQueryParametersResponse(pydantic.BaseModel): 77 | your_query: str 78 | 79 | 80 | class SimpleResponse(pydantic.BaseModel): 81 | status: str 82 | 83 | 84 | class ValidationError(pydantic.BaseModel): 85 | loc: list[typing.Any] 86 | msg: str 87 | type: str 88 | 89 | 90 | class HeaderRequestHeaderRequestGetHeaders(pydantic.BaseModel): 91 | x_test: typing.Any = pydantic.Field(serialization_alias="x-test") 92 | 93 | 94 | def get_subclasses_from_same_file() -> list[typing.Type[pydantic.BaseModel]]: 95 | """ 96 | Due to how Python declares classes in a module, 97 | we need to update_forward_refs for all the schemas generated 98 | here in the situation where there are nested classes. 99 | """ 100 | calling_frame = inspect.currentframe() 101 | if not calling_frame: 102 | return [] 103 | else: 104 | calling_frame = calling_frame.f_back 105 | module = inspect.getmodule(calling_frame) 106 | 107 | subclasses = [] 108 | for _, c in inspect.getmembers(module): 109 | if inspect.isclass(c) and issubclass(c, pydantic.BaseModel) and c != pydantic.BaseModel: 110 | subclasses.append(c) 111 | 112 | return subclasses 113 | 114 | 115 | subclasses: list[typing.Type[pydantic.BaseModel]] = get_subclasses_from_same_file() 116 | for c in subclasses: 117 | c.model_rebuild() 118 | -------------------------------------------------------------------------------- /tests/generators/standard/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from clientele.generators.standard import utils 4 | 5 | 6 | @pytest.mark.parametrize( 7 | "input,expected_output", 8 | [ 9 | ("Lowercase", "lowercase"), 10 | ("bad-string", "bad_string"), 11 | (">badstring", "badstring"), 12 | ("FooBarBazz", "foo_bar_bazz"), 13 | ("RETAIN_ALL_UPPER", "RETAIN_ALL_UPPER"), 14 | ("RETAINALLUPPER", "RETAINALLUPPER"), 15 | ], 16 | ) 17 | def test_snake_case_prop(input, expected_output): 18 | assert utils.snake_case_prop(input_str=input) == expected_output 19 | -------------------------------------------------------------------------------- /tests/test_async_generated_client.py: -------------------------------------------------------------------------------- 1 | """ 2 | Identical to the normal tests but just using 3 | the async client instead 4 | """ 5 | 6 | from decimal import Decimal 7 | 8 | import pytest 9 | from httpx import Response 10 | from respx import MockRouter 11 | 12 | from .async_test_client import client, config, http, schemas 13 | 14 | BASE_URL = config.api_base_url() 15 | 16 | 17 | @pytest.mark.asyncio 18 | @pytest.mark.respx(base_url=BASE_URL) 19 | async def test_simple_request_simple_request_get(respx_mock: MockRouter): 20 | # Given 21 | mocked_response = {"status": "hello world"} 22 | mock_path = "/simple-request" 23 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 24 | # When 25 | response = await client.simple_request_simple_request_get() 26 | # Then 27 | assert isinstance(response, schemas.SimpleResponse) 28 | assert len(respx_mock.calls) == 1 29 | call = respx_mock.calls[0] 30 | assert call.request.url == BASE_URL + mock_path 31 | 32 | 33 | @pytest.mark.asyncio 34 | @pytest.mark.respx(base_url=BASE_URL) 35 | async def test_simple_request_simple_request_get_raises_exception( 36 | respx_mock: MockRouter, 37 | ): 38 | # Given 39 | mocked_response = {"bad": "response"} 40 | mock_path = "/simple-request" 41 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=404)) 42 | # Then 43 | with pytest.raises(http.APIException) as raised_exception: 44 | await client.simple_request_simple_request_get() 45 | assert isinstance(raised_exception.value, http.APIException) 46 | # Make sure we have the response on the exception 47 | assert raised_exception.value.response.status_code == 404 48 | assert len(respx_mock.calls) == 1 49 | call = respx_mock.calls[0] 50 | assert call.request.url == BASE_URL + mock_path 51 | 52 | 53 | @pytest.mark.asyncio 54 | @pytest.mark.respx(base_url=BASE_URL) 55 | async def test_optional_parameters_request_optional_parameters_get( 56 | respx_mock: MockRouter, 57 | ): 58 | # Given 59 | mocked_response = {"optional_parameter": None, "required_parameter": "Hello"} 60 | mock_path = "/optional-parameters" 61 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 62 | # When 63 | response = await client.optional_parameters_request_optional_parameters_get() 64 | # Then 65 | assert isinstance(response, schemas.OptionalParametersResponse) 66 | assert len(respx_mock.calls) == 1 67 | call = respx_mock.calls[0] 68 | assert call.request.url == BASE_URL + mock_path 69 | 70 | 71 | @pytest.mark.asyncio 72 | @pytest.mark.respx(base_url=BASE_URL) 73 | async def test_parameter_request_simple_request(respx_mock: MockRouter): 74 | # Given 75 | your_input = "hello world" 76 | mocked_response = {"your_input": your_input} 77 | mock_path = f"/simple-request/{your_input}" 78 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 79 | # When 80 | response = await client.parameter_request_simple_request(your_input=your_input) 81 | # Then 82 | assert isinstance(response, schemas.ParameterResponse) 83 | assert len(respx_mock.calls) == 1 84 | call = respx_mock.calls[0] 85 | assert call.request.url == BASE_URL + mock_path 86 | 87 | 88 | @pytest.mark.asyncio 89 | @pytest.mark.respx(base_url=BASE_URL) 90 | async def test_query_request_simple_query_get(respx_mock: MockRouter): 91 | # Given 92 | your_input = "hello world" 93 | mocked_response = {"your_query": your_input} 94 | mock_path = "/simple-query?yourInput=hello+world" 95 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 96 | # When 97 | response = await client.query_request_simple_query_get(yourInput=your_input) 98 | # Then 99 | assert isinstance(response, schemas.SimpleQueryParametersResponse) 100 | assert len(respx_mock.calls) == 1 101 | call = respx_mock.calls[0] 102 | assert call.request.url == BASE_URL + mock_path 103 | 104 | 105 | @pytest.mark.asyncio 106 | @pytest.mark.respx(base_url=BASE_URL) 107 | async def test_query_request_optional_query_get(respx_mock: MockRouter): 108 | # Given 109 | your_input = None 110 | mocked_response = {"your_query": "test"} 111 | # NOTE: omits None query parameter 112 | mock_path = "/optional-query" 113 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 114 | # When 115 | response = await client.query_request_optional_query_get(yourInput=your_input) 116 | # Then 117 | assert isinstance(response, schemas.OptionalQueryParametersResponse) 118 | assert len(respx_mock.calls) == 1 119 | call = respx_mock.calls[0] 120 | assert call.request.url == BASE_URL + mock_path 121 | 122 | 123 | @pytest.mark.asyncio 124 | @pytest.mark.respx(base_url=BASE_URL) 125 | async def test_complex_model_request_complex_model_request_get(respx_mock: MockRouter): 126 | # Given 127 | mocked_response = { 128 | "a_dict_response": {"dict": "response"}, 129 | "a_enum": "ONE", 130 | "a_list_of_enums": ["ONE", "TWO"], 131 | "a_list_of_numbers": [1, 2, 3], 132 | "a_list_of_other_models": [{"key": "first"}], 133 | "a_list_of_strings": ["hello", "world"], 134 | "a_number": 13, 135 | "a_decimal": 0.4, 136 | "a_string": "hello world", 137 | "another_model": {"key": "value"}, 138 | } 139 | mock_path = "/complex-model-request" 140 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 141 | # When 142 | response = await client.complex_model_request_complex_model_request_get() 143 | # Then 144 | assert isinstance(response, schemas.ComplexModelResponse) 145 | expected_dump_data = { 146 | "a_dict_response": {"dict": "response"}, 147 | "a_enum": schemas.ExampleEnum.ONE, 148 | "a_list_of_enums": [schemas.ExampleEnum.ONE, schemas.ExampleEnum.TWO], 149 | "a_list_of_numbers": [1, 2, 3], 150 | "a_list_of_other_models": [{"key": "first"}], 151 | "a_list_of_strings": ["hello", "world"], 152 | "a_number": 13, 153 | "a_decimal": Decimal("0.4"), 154 | "a_string": "hello world", 155 | "another_model": {"key": "value"}, 156 | } 157 | assert response.model_dump() == expected_dump_data 158 | assert len(respx_mock.calls) == 1 159 | call = respx_mock.calls[0] 160 | assert call.request.url == BASE_URL + mock_path 161 | 162 | 163 | @pytest.mark.asyncio 164 | @pytest.mark.respx(base_url=BASE_URL) 165 | async def test_request_data_request_data_post(respx_mock: MockRouter): 166 | # Given 167 | mocked_response = {"my_input": "test"} 168 | mock_path = "/request-data" 169 | respx_mock.post(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 170 | # When 171 | data = schemas.RequestDataRequest(my_input="test", my_decimal_input=Decimal(0.1)) 172 | response = await client.request_data_request_data_post(data=data) 173 | # Then 174 | assert isinstance(response, schemas.RequestDataResponse) 175 | assert response.model_dump() == mocked_response 176 | assert len(respx_mock.calls) == 1 177 | call = respx_mock.calls[0] 178 | assert call.request.url == BASE_URL + mock_path 179 | 180 | 181 | @pytest.mark.asyncio 182 | @pytest.mark.respx(base_url=BASE_URL) 183 | async def test_request_data_request_data_put(respx_mock: MockRouter): 184 | # Given 185 | mocked_response = {"my_input": "test"} 186 | mock_path = "/request-data" 187 | respx_mock.put(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 188 | # When 189 | data = schemas.RequestDataRequest(my_input="test", my_decimal_input=Decimal(0.1)) 190 | response = await client.request_data_request_data_put(data=data) 191 | # Then 192 | assert isinstance(response, schemas.RequestDataResponse) 193 | assert response.model_dump() == mocked_response 194 | assert len(respx_mock.calls) == 1 195 | call = respx_mock.calls[0] 196 | assert call.request.url == BASE_URL + mock_path 197 | 198 | 199 | @pytest.mark.asyncio 200 | @pytest.mark.respx(base_url=BASE_URL) 201 | async def test_request_data_path_request_data(respx_mock: MockRouter): 202 | # Given 203 | path_parameter = "param" 204 | mocked_response = {"my_input": "test", "path_parameter": path_parameter} 205 | mock_path = f"/request-data/{path_parameter}" 206 | respx_mock.post(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 207 | # When 208 | data = schemas.RequestDataRequest(my_input="test", my_decimal_input=Decimal(0.1)) 209 | response = await client.request_data_path_request_data(path_parameter, data=data) 210 | # Then 211 | assert isinstance(response, schemas.RequestDataAndParameterResponse) 212 | assert response.model_dump() == mocked_response 213 | assert len(respx_mock.calls) == 1 214 | call = respx_mock.calls[0] 215 | assert call.request.url == BASE_URL + mock_path 216 | 217 | 218 | @pytest.mark.asyncio 219 | @pytest.mark.respx(base_url=BASE_URL) 220 | async def test_request_delete_request_delete_delete(respx_mock: MockRouter): 221 | # Given 222 | mocked_response = {} 223 | mock_path = "/request-delete" 224 | respx_mock.delete(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 225 | # When 226 | response = await client.request_delete_request_delete_delete() 227 | # Then 228 | assert isinstance(response, schemas.DeleteResponse) 229 | assert len(respx_mock.calls) == 1 230 | call = respx_mock.calls[0] 231 | assert call.request.url == BASE_URL + mock_path 232 | 233 | 234 | @pytest.mark.asyncio 235 | @pytest.mark.respx(base_url=BASE_URL) 236 | async def test_header_request_header_request_get(respx_mock: MockRouter): 237 | # Given 238 | input_header = "foo" 239 | mocked_response = {"x_test": input_header} 240 | mock_path = "/header-request" 241 | respx_mock.get(mock_path, headers={"x-test": input_header}).mock( 242 | return_value=Response(json=mocked_response, status_code=200) 243 | ) 244 | # When 245 | headers = schemas.HeaderRequestHeaderRequestGetHeaders(x_test=input_header) 246 | response = await client.header_request_header_request_get(headers=headers) 247 | # Then 248 | assert isinstance(response, schemas.HeadersResponse) 249 | assert response.model_dump() == mocked_response 250 | assert len(respx_mock.calls) == 1 251 | call = respx_mock.calls[0] 252 | assert call.request.url == BASE_URL + mock_path 253 | -------------------------------------------------------------------------------- /tests/test_client/MANIFEST.md: -------------------------------------------------------------------------------- 1 | # Manifest 2 | 3 | Generated with [https://github.com/phalt/clientele](https://github.com/phalt/clientele) 4 | Install with pipx: 5 | 6 | ```sh 7 | pipx install clientele 8 | ``` 9 | 10 | API VERSION: 0.1.0 11 | OPENAPI VERSION: 3.0.2 12 | CLIENTELE VERSION: 0.9.0 13 | 14 | Regenerate using this command: 15 | 16 | ```sh 17 | clientele generate -f example_openapi_specs/best.json -o tests/test_client/ --regen t 18 | ``` 19 | -------------------------------------------------------------------------------- /tests/test_client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/phalt/clientele/224597422299c0621849779daab30dc73c481cce/tests/test_client/__init__.py -------------------------------------------------------------------------------- /tests/test_client/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import typing # noqa 4 | 5 | from tests.test_client import http, schemas # noqa 6 | 7 | 8 | def complex_model_request_complex_model_request_get() -> schemas.ComplexModelResponse: 9 | """Complex Model Request""" 10 | 11 | response = http.get(url="/complex-model-request") 12 | return http.handle_response(complex_model_request_complex_model_request_get, response) 13 | 14 | 15 | def header_request_header_request_get( 16 | headers: typing.Optional[schemas.HeaderRequestHeaderRequestGetHeaders], 17 | ) -> schemas.HTTPValidationError | schemas.HeadersResponse: 18 | """Header Request""" 19 | headers_dict = headers and headers.model_dump(by_alias=True, exclude_unset=True) or None 20 | response = http.get(url="/header-request", headers=headers_dict) 21 | return http.handle_response(header_request_header_request_get, response) 22 | 23 | 24 | def optional_parameters_request_optional_parameters_get() -> schemas.OptionalParametersResponse: 25 | """Optional Parameters Request""" 26 | 27 | response = http.get(url="/optional-parameters") 28 | return http.handle_response(optional_parameters_request_optional_parameters_get, response) 29 | 30 | 31 | def request_data_request_data_post( 32 | data: schemas.RequestDataRequest, 33 | ) -> schemas.HTTPValidationError | schemas.RequestDataResponse: 34 | """Request Data""" 35 | 36 | response = http.post(url="/request-data", data=data.model_dump()) 37 | return http.handle_response(request_data_request_data_post, response) 38 | 39 | 40 | def request_data_request_data_put( 41 | data: schemas.RequestDataRequest, 42 | ) -> schemas.HTTPValidationError | schemas.RequestDataResponse: 43 | """Request Data""" 44 | 45 | response = http.put(url="/request-data", data=data.model_dump()) 46 | return http.handle_response(request_data_request_data_put, response) 47 | 48 | 49 | def request_data_path_request_data( 50 | path_parameter: str, data: schemas.RequestDataRequest 51 | ) -> schemas.HTTPValidationError | schemas.RequestDataAndParameterResponse: 52 | """Request Data Path""" 53 | 54 | response = http.post(url=f"/request-data/{path_parameter}", data=data.model_dump()) 55 | return http.handle_response(request_data_path_request_data, response) 56 | 57 | 58 | def request_delete_request_delete_delete() -> schemas.DeleteResponse: 59 | """Request Delete""" 60 | 61 | response = http.delete(url="/request-delete") 62 | return http.handle_response(request_delete_request_delete_delete, response) 63 | 64 | 65 | def security_required_request_security_required_get() -> schemas.SecurityRequiredResponse: 66 | """Security Required Request""" 67 | 68 | response = http.get(url="/security-required") 69 | return http.handle_response(security_required_request_security_required_get, response) 70 | 71 | 72 | def query_request_simple_query_get( 73 | yourInput: str, 74 | ) -> schemas.HTTPValidationError | schemas.SimpleQueryParametersResponse: 75 | """Query Request""" 76 | 77 | response = http.get(url=f"/simple-query?yourInput={yourInput}") 78 | return http.handle_response(query_request_simple_query_get, response) 79 | 80 | 81 | def query_request_optional_query_get( 82 | yourInput: typing.Optional[str], 83 | ) -> schemas.HTTPValidationError | schemas.OptionalQueryParametersResponse: 84 | """Optional Query Request""" 85 | 86 | response = http.get(url=f"/optional-query?yourInput={yourInput}") 87 | return http.handle_response(query_request_optional_query_get, response) 88 | 89 | 90 | def simple_request_simple_request_get() -> schemas.SimpleResponse: 91 | """Simple Request""" 92 | 93 | response = http.get(url="/simple-request") 94 | return http.handle_response(simple_request_simple_request_get, response) 95 | 96 | 97 | def parameter_request_simple_request( 98 | your_input: str, 99 | ) -> schemas.HTTPValidationError | schemas.ParameterResponse: 100 | """Parameter Request""" 101 | 102 | response = http.get(url=f"/simple-request/{your_input}") 103 | return http.handle_response(parameter_request_simple_request, response) 104 | -------------------------------------------------------------------------------- /tests/test_client/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file will never be updated on subsequent clientele runs. 3 | Use it as a space to store configuration and constants. 4 | 5 | DO NOT CHANGE THE FUNCTION NAMES 6 | """ 7 | 8 | 9 | def additional_headers() -> dict: 10 | """ 11 | Modify this function to provide additional headers to all 12 | HTTP requests made by this client. 13 | """ 14 | return {} 15 | 16 | 17 | def api_base_url() -> str: 18 | """ 19 | Modify this function to provide the current api_base_url. 20 | """ 21 | return "http://localhost" 22 | 23 | 24 | def get_user_key() -> str: 25 | """ 26 | HTTP Basic authentication. 27 | Username parameter 28 | """ 29 | return "user" 30 | 31 | 32 | def get_pass_key() -> str: 33 | """ 34 | HTTP Basic authentication. 35 | Password parameter 36 | """ 37 | return "password" 38 | 39 | 40 | def get_bearer_token() -> str: 41 | """ 42 | HTTP Bearer authentication. 43 | Used by many authentication methods - token, jwt, etc. 44 | Does not require the "Bearer" content, just the key as a string. 45 | """ 46 | return "token" 47 | -------------------------------------------------------------------------------- /tests/test_client/http.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import types 5 | import typing 6 | from decimal import Decimal 7 | from urllib.parse import parse_qs, urlencode, urlparse, urlunparse 8 | 9 | import httpx 10 | 11 | from tests.test_client import config as c # noqa 12 | 13 | 14 | def json_serializer(obj): 15 | if isinstance(obj, Decimal): 16 | return str(obj) 17 | 18 | 19 | class APIException(Exception): 20 | """Could not match API response to return type of this function""" 21 | 22 | reason: str 23 | response: httpx.Response 24 | 25 | def __init__(self, response: httpx.Response, reason: str, *args: object) -> None: 26 | self.response = response 27 | self.reason = reason 28 | super().__init__(*args) 29 | 30 | 31 | def parse_url(url: str) -> str: 32 | """ 33 | Returns the full URL from a string. 34 | 35 | Will filter out any optional query parameters if they are None. 36 | """ 37 | api_url = f"{c.api_base_url()}{url}" 38 | url_parts = urlparse(url=api_url) 39 | # Filter out "None" optional query parameters 40 | filtered_query_params = {k: v for k, v in parse_qs(url_parts.query).items() if v[0] not in ["None", ""]} 41 | filtered_query_string = urlencode(filtered_query_params, doseq=True) 42 | return urlunparse( 43 | ( 44 | url_parts.scheme, 45 | url_parts.netloc, 46 | url_parts.path, 47 | url_parts.params, 48 | filtered_query_string, 49 | url_parts.fragment, 50 | ) 51 | ) 52 | 53 | 54 | def handle_response(func, response): 55 | """ 56 | Returns a schema object that matches the JSON data from the response. 57 | 58 | If it can't find a matching schema it will raise an error with details of the response. 59 | """ 60 | status_code = response.status_code 61 | # Get the response types 62 | response_types = typing.get_type_hints(func)["return"] 63 | 64 | if typing.get_origin(response_types) in [typing.Union, types.UnionType]: 65 | response_types = list(typing.get_args(response_types)) 66 | else: 67 | response_types = [response_types] 68 | 69 | # Determine, from the map, the correct response for this status code 70 | expected_responses = func_response_code_maps[func.__name__] # noqa 71 | if str(status_code) not in expected_responses.keys(): 72 | raise APIException(response=response, reason="An unexpected status code was received") 73 | else: 74 | expected_response_class_name = expected_responses[str(status_code)] 75 | 76 | # Get the correct response type and build it 77 | response_type = [t for t in response_types if t.__name__ == expected_response_class_name][0] 78 | data = response.json() 79 | return response_type.model_validate(data) 80 | 81 | 82 | # Func map 83 | func_response_code_maps = { 84 | "complex_model_request_complex_model_request_get": {"200": "ComplexModelResponse"}, 85 | "header_request_header_request_get": { 86 | "200": "HeadersResponse", 87 | "422": "HTTPValidationError", 88 | }, 89 | "optional_parameters_request_optional_parameters_get": {"200": "OptionalParametersResponse"}, 90 | "request_data_request_data_post": { 91 | "200": "RequestDataResponse", 92 | "422": "HTTPValidationError", 93 | }, 94 | "request_data_request_data_put": { 95 | "200": "RequestDataResponse", 96 | "422": "HTTPValidationError", 97 | }, 98 | "request_data_path_request_data": { 99 | "200": "RequestDataAndParameterResponse", 100 | "422": "HTTPValidationError", 101 | }, 102 | "request_delete_request_delete_delete": {"200": "DeleteResponse"}, 103 | "security_required_request_security_required_get": {"200": "SecurityRequiredResponse"}, 104 | "query_request_simple_query_get": { 105 | "200": "SimpleQueryParametersResponse", 106 | "422": "HTTPValidationError", 107 | }, 108 | "query_request_optional_query_get": { 109 | "200": "OptionalQueryParametersResponse", 110 | "422": "HTTPValidationError", 111 | }, 112 | "simple_request_simple_request_get": {"200": "SimpleResponse"}, 113 | "parameter_request_simple_request": { 114 | "200": "ParameterResponse", 115 | "422": "HTTPValidationError", 116 | }, 117 | } 118 | 119 | auth_key = c.get_bearer_token() 120 | client_headers = c.additional_headers() 121 | client_headers.update(Authorization=f"Bearer {auth_key}") 122 | client = httpx.Client(headers=client_headers) 123 | 124 | 125 | def get(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 126 | """Issue an HTTP GET request""" 127 | if headers: 128 | client_headers.update(headers) 129 | return client.get(parse_url(url), headers=client_headers) 130 | 131 | 132 | def post(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 133 | """Issue an HTTP POST request""" 134 | if headers: 135 | client_headers.update(headers) 136 | json_data = json.loads(json.dumps(data, default=json_serializer)) 137 | return client.post(parse_url(url), json=json_data, headers=client_headers) 138 | 139 | 140 | def put(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 141 | """Issue an HTTP PUT request""" 142 | if headers: 143 | client_headers.update(headers) 144 | json_data = json.loads(json.dumps(data, default=json_serializer)) 145 | return client.put(parse_url(url), json=json_data, headers=client_headers) 146 | 147 | 148 | def patch(url: str, data: dict, headers: typing.Optional[dict] = None) -> httpx.Response: 149 | """Issue an HTTP PATCH request""" 150 | if headers: 151 | client_headers.update(headers) 152 | json_data = json.loads(json.dumps(data, default=json_serializer)) 153 | return client.patch(parse_url(url), json=json_data, headers=client_headers) 154 | 155 | 156 | def delete(url: str, headers: typing.Optional[dict] = None) -> httpx.Response: 157 | """Issue an HTTP DELETE request""" 158 | if headers: 159 | client_headers.update(headers) 160 | return client.delete(parse_url(url), headers=client_headers) 161 | -------------------------------------------------------------------------------- /tests/test_client/schemas.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import typing 5 | from decimal import Decimal # noqa 6 | from enum import Enum # noqa 7 | 8 | import pydantic 9 | 10 | 11 | class AnotherModel(pydantic.BaseModel): 12 | key: str 13 | 14 | 15 | class ComplexModelResponse(pydantic.BaseModel): 16 | a_dict_response: dict[str, typing.Any] 17 | a_enum: "ExampleEnum" 18 | a_list_of_enums: list["ExampleEnum"] 19 | a_list_of_numbers: list[int] 20 | a_list_of_other_models: list["AnotherModel"] 21 | a_list_of_strings: list[str] 22 | a_number: int 23 | a_string: str 24 | a_decimal: Decimal 25 | another_model: "AnotherModel" 26 | 27 | 28 | class DeleteResponse(pydantic.BaseModel): 29 | pass 30 | 31 | 32 | class ExampleEnum(str, Enum): 33 | ONE = "ONE" 34 | TWO = "TWO" 35 | 36 | 37 | class HeadersResponse(pydantic.BaseModel): 38 | x_test: str 39 | 40 | 41 | class HTTPValidationError(pydantic.BaseModel): 42 | detail: list["ValidationError"] 43 | 44 | 45 | class OptionalParametersResponse(pydantic.BaseModel): 46 | optional_parameter: typing.Optional[str] 47 | required_parameter: str 48 | 49 | 50 | class ParameterResponse(pydantic.BaseModel): 51 | your_input: str 52 | 53 | 54 | class RequestDataAndParameterResponse(pydantic.BaseModel): 55 | my_input: str 56 | path_parameter: str 57 | 58 | 59 | class RequestDataRequest(pydantic.BaseModel): 60 | my_input: str 61 | my_decimal_input: Decimal 62 | 63 | 64 | class RequestDataResponse(pydantic.BaseModel): 65 | my_input: str 66 | 67 | 68 | class SecurityRequiredResponse(pydantic.BaseModel): 69 | token: str 70 | 71 | 72 | class SimpleQueryParametersResponse(pydantic.BaseModel): 73 | your_query: str 74 | 75 | 76 | class OptionalQueryParametersResponse(pydantic.BaseModel): 77 | your_query: str 78 | 79 | 80 | class SimpleResponse(pydantic.BaseModel): 81 | status: str 82 | 83 | 84 | class ValidationError(pydantic.BaseModel): 85 | loc: list[typing.Any] 86 | msg: str 87 | type: str 88 | 89 | 90 | class HeaderRequestHeaderRequestGetHeaders(pydantic.BaseModel): 91 | x_test: typing.Any = pydantic.Field(serialization_alias="x-test") 92 | 93 | 94 | def get_subclasses_from_same_file() -> list[typing.Type[pydantic.BaseModel]]: 95 | """ 96 | Due to how Python declares classes in a module, 97 | we need to update_forward_refs for all the schemas generated 98 | here in the situation where there are nested classes. 99 | """ 100 | calling_frame = inspect.currentframe() 101 | if not calling_frame: 102 | return [] 103 | else: 104 | calling_frame = calling_frame.f_back 105 | module = inspect.getmodule(calling_frame) 106 | 107 | subclasses = [] 108 | for _, c in inspect.getmembers(module): 109 | if inspect.isclass(c) and issubclass(c, pydantic.BaseModel) and c != pydantic.BaseModel: 110 | subclasses.append(c) 111 | 112 | return subclasses 113 | 114 | 115 | subclasses: list[typing.Type[pydantic.BaseModel]] = get_subclasses_from_same_file() 116 | for c in subclasses: 117 | c.model_rebuild() 118 | -------------------------------------------------------------------------------- /tests/test_generated_client.py: -------------------------------------------------------------------------------- 1 | from decimal import Decimal 2 | 3 | import pytest 4 | from httpx import Response 5 | from respx import MockRouter 6 | 7 | from .test_client import client, config, http, schemas 8 | 9 | BASE_URL = config.api_base_url() 10 | 11 | 12 | @pytest.mark.respx(base_url=BASE_URL) 13 | def test_simple_request_simple_request_get(respx_mock: MockRouter): 14 | # Given 15 | mocked_response = {"status": "hello world"} 16 | mock_path = "/simple-request" 17 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 18 | # When 19 | response = client.simple_request_simple_request_get() 20 | # Then 21 | assert isinstance(response, schemas.SimpleResponse) 22 | assert len(respx_mock.calls) == 1 23 | call = respx_mock.calls[0] 24 | assert call.request.url == BASE_URL + mock_path 25 | 26 | 27 | @pytest.mark.respx(base_url=BASE_URL) 28 | def test_simple_request_simple_request_get_raises_exception(respx_mock: MockRouter): 29 | # Given 30 | mocked_response = {"bad": "response"} 31 | mock_path = "/simple-request" 32 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=404)) 33 | # Then 34 | with pytest.raises(http.APIException) as raised_exception: 35 | client.simple_request_simple_request_get() 36 | assert isinstance(raised_exception.value, http.APIException) 37 | # Make sure we have the response on the exception 38 | assert raised_exception.value.response.status_code == 404 39 | assert raised_exception.value.reason == "An unexpected status code was received" 40 | assert len(respx_mock.calls) == 1 41 | call = respx_mock.calls[0] 42 | assert call.request.url == BASE_URL + mock_path 43 | 44 | 45 | @pytest.mark.respx(base_url=BASE_URL) 46 | def test_optional_parameters_request_optional_parameters_get(respx_mock: MockRouter): 47 | # Given 48 | mocked_response = {"optional_parameter": None, "required_parameter": "Hello"} 49 | mock_path = "/optional-parameters" 50 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 51 | # When 52 | response = client.optional_parameters_request_optional_parameters_get() 53 | # Then 54 | assert isinstance(response, schemas.OptionalParametersResponse) 55 | assert len(respx_mock.calls) == 1 56 | call = respx_mock.calls[0] 57 | assert call.request.url == BASE_URL + mock_path 58 | 59 | 60 | @pytest.mark.respx(base_url=BASE_URL) 61 | def test_parameter_request_simple_request(respx_mock: MockRouter): 62 | # Given 63 | your_input = "hello world" 64 | mocked_response = {"your_input": your_input} 65 | mock_path = f"/simple-request/{your_input}" 66 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 67 | # When 68 | response = client.parameter_request_simple_request(your_input=your_input) 69 | # Then 70 | assert isinstance(response, schemas.ParameterResponse) 71 | assert len(respx_mock.calls) == 1 72 | call = respx_mock.calls[0] 73 | assert call.request.url == BASE_URL + mock_path 74 | 75 | 76 | @pytest.mark.respx(base_url=BASE_URL) 77 | def test_query_request_simple_query_get(respx_mock: MockRouter): 78 | # Given 79 | your_input = "hello world" 80 | mocked_response = {"your_query": your_input} 81 | mock_path = "/simple-query?yourInput=hello+world" 82 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 83 | # When 84 | response = client.query_request_simple_query_get(yourInput=your_input) 85 | # Then 86 | assert isinstance(response, schemas.SimpleQueryParametersResponse) 87 | assert len(respx_mock.calls) == 1 88 | call = respx_mock.calls[0] 89 | assert call.request.url == BASE_URL + mock_path 90 | 91 | 92 | @pytest.mark.respx(base_url=BASE_URL) 93 | def test_query_request_optional_query_get(respx_mock: MockRouter): 94 | # Given 95 | your_input = None 96 | mocked_response = {"your_query": "test"} 97 | # NOTE: omits None query parameter 98 | mock_path = "/optional-query" 99 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 100 | # When 101 | response = client.query_request_optional_query_get(yourInput=your_input) 102 | # Then 103 | assert isinstance(response, schemas.OptionalQueryParametersResponse) 104 | assert len(respx_mock.calls) == 1 105 | call = respx_mock.calls[0] 106 | assert call.request.url == BASE_URL + mock_path 107 | 108 | 109 | @pytest.mark.respx(base_url=BASE_URL) 110 | def test_complex_model_request_complex_model_request_get(respx_mock: MockRouter): 111 | # Given 112 | mocked_response = { 113 | "a_dict_response": {"dict": "response"}, 114 | "a_enum": "ONE", 115 | "a_list_of_enums": ["ONE", "TWO"], 116 | "a_list_of_numbers": [1, 2, 3], 117 | "a_list_of_other_models": [{"key": "first"}], 118 | "a_list_of_strings": ["hello", "world"], 119 | "a_number": 13, 120 | "a_decimal": 0.4, 121 | "a_string": "hello world", 122 | "another_model": {"key": "value"}, 123 | } 124 | mock_path = "/complex-model-request" 125 | respx_mock.get(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 126 | # When 127 | response = client.complex_model_request_complex_model_request_get() 128 | # Then 129 | assert isinstance(response, schemas.ComplexModelResponse) 130 | expected_dump_data = { 131 | "a_dict_response": {"dict": "response"}, 132 | "a_enum": schemas.ExampleEnum.ONE, 133 | "a_list_of_enums": [schemas.ExampleEnum.ONE, schemas.ExampleEnum.TWO], 134 | "a_list_of_numbers": [1, 2, 3], 135 | "a_list_of_other_models": [{"key": "first"}], 136 | "a_list_of_strings": ["hello", "world"], 137 | "a_number": 13, 138 | "a_decimal": Decimal("0.4"), 139 | "a_string": "hello world", 140 | "another_model": {"key": "value"}, 141 | } 142 | assert response.model_dump() == expected_dump_data 143 | assert len(respx_mock.calls) == 1 144 | call = respx_mock.calls[0] 145 | assert call.request.url == BASE_URL + mock_path 146 | 147 | 148 | @pytest.mark.respx(base_url=BASE_URL) 149 | def test_request_data_request_data_post(respx_mock: MockRouter): 150 | # Given 151 | mocked_response = {"my_input": "test"} 152 | mock_path = "/request-data" 153 | respx_mock.post(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 154 | # When 155 | data = schemas.RequestDataRequest( 156 | my_input="test", 157 | my_decimal_input=Decimal(0.1), 158 | ) 159 | response = client.request_data_request_data_post(data=data) 160 | # Then 161 | assert isinstance(response, schemas.RequestDataResponse) 162 | assert response.model_dump() == mocked_response 163 | assert len(respx_mock.calls) == 1 164 | call = respx_mock.calls[0] 165 | assert call.request.url == BASE_URL + mock_path 166 | 167 | 168 | @pytest.mark.respx(base_url=BASE_URL) 169 | def test_request_data_request_data_put(respx_mock: MockRouter): 170 | # Given 171 | mocked_response = {"my_input": "test"} 172 | mock_path = "/request-data" 173 | respx_mock.put(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 174 | # When 175 | data = schemas.RequestDataRequest(my_input="test", my_decimal_input=Decimal(0.1)) 176 | response = client.request_data_request_data_put(data=data) 177 | # Then 178 | assert isinstance(response, schemas.RequestDataResponse) 179 | assert response.model_dump() == mocked_response 180 | assert len(respx_mock.calls) == 1 181 | call = respx_mock.calls[0] 182 | assert call.request.url == BASE_URL + mock_path 183 | 184 | 185 | @pytest.mark.respx(base_url=BASE_URL) 186 | def test_request_data_path_request_data(respx_mock: MockRouter): 187 | # Given 188 | path_parameter = "param" 189 | mocked_response = {"my_input": "test", "path_parameter": path_parameter} 190 | mock_path = f"/request-data/{path_parameter}" 191 | respx_mock.post(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 192 | # When 193 | data = schemas.RequestDataRequest(my_input="test", my_decimal_input=Decimal(0.1)) 194 | response = client.request_data_path_request_data(path_parameter, data=data) 195 | # Then 196 | assert isinstance(response, schemas.RequestDataAndParameterResponse) 197 | assert response.model_dump() == mocked_response 198 | assert len(respx_mock.calls) == 1 199 | call = respx_mock.calls[0] 200 | assert call.request.url == BASE_URL + mock_path 201 | 202 | 203 | @pytest.mark.respx(base_url=BASE_URL) 204 | def test_request_delete_request_delete_delete(respx_mock: MockRouter): 205 | # Given 206 | mocked_response = {} 207 | mock_path = "/request-delete" 208 | respx_mock.delete(mock_path).mock(return_value=Response(json=mocked_response, status_code=200)) 209 | # When 210 | response = client.request_delete_request_delete_delete() 211 | # Then 212 | assert isinstance(response, schemas.DeleteResponse) 213 | assert len(respx_mock.calls) == 1 214 | call = respx_mock.calls[0] 215 | assert call.request.url == BASE_URL + mock_path 216 | 217 | 218 | @pytest.mark.respx(base_url=BASE_URL) 219 | def test_header_request_header_request_get(respx_mock: MockRouter): 220 | # Given 221 | input_header = "foo" 222 | mocked_response = {"x_test": input_header} 223 | mock_path = "/header-request" 224 | respx_mock.get(mock_path, headers={"x-test": input_header}).mock( 225 | return_value=Response(json=mocked_response, status_code=200) 226 | ) 227 | # When 228 | headers = schemas.HeaderRequestHeaderRequestGetHeaders(x_test=input_header) 229 | response = client.header_request_header_request_get(headers=headers) 230 | # Then 231 | assert isinstance(response, schemas.HeadersResponse) 232 | assert response.model_dump() == mocked_response 233 | assert len(respx_mock.calls) == 1 234 | call = respx_mock.calls[0] 235 | assert call.request.url == BASE_URL + mock_path 236 | --------------------------------------------------------------------------------