Skip to content

Commit

Permalink
0.6.0
Browse files Browse the repository at this point in the history
  • Loading branch information
Paul Hallett committed Sep 20, 2023
1 parent f34c19e commit e9d2b68
Show file tree
Hide file tree
Showing 16 changed files with 202 additions and 53 deletions.
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Change log

## 0.6.0

* 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.
* Significantly improved coverage of exceptions raised when trying to generate response schemas.
* Response types for a class are now sorted.
* Fixed a bug where `put` methods did not generate input data correctly.

## 0.5.2

* Fix pathing for `constants.py` - thanks to @matthewknight for the contribution!
Expand Down
7 changes: 7 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Change log

## 0.6.0

* 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.
* Significantly improved coverage of exceptions raised when trying to generate response schemas.
* Response types for a class are now sorted.
* Fixed a bug where `put` methods did not generate input data correctly.

## 0.5.2

* Fix pathing for `constants.py` - thanks to @matthewknight for the contribution!
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "clientele"
version = "0.5.2"
version = "0.6.0"
description = "Loveable API Clients from OpenAPI schemas"
authors = ["Paul Hallett <paulandrewhallett@gmail.com>"]
license = "MIT"
Expand Down
31 changes: 21 additions & 10 deletions src/client_template/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
class APIException(Exception):
"""Could not match API response to return type of this function"""

reason: str
response: httpx.Response

def __init__(self, response: httpx.Response, *args: object) -> None:
def __init__(self, response: httpx.Response, reason: str, *args: object) -> None:
self.response = response
self.reason = reason
super().__init__(*args)


Expand All @@ -26,22 +28,31 @@ def parse_url(url: str) -> str:
return url_parts.geturl()


def handle_response(func, response):
def handle_response(func: typing.Callable, response: httpx.Response):
"""
Returns a response that matches the data neatly for a function
If it can't - raises an error with details of the response.
"""
response_data = response.json()
status_code = response.status_code
# Get the response types
response_types = typing.get_type_hints(func).get("return")
if typing.get_origin(response_types) == typing.Union:
response_types = list(typing.get_args(response_types))
else:
response_types = [response_types]

for single_type in response_types:
try:
return single_type.model_validate(response_data)
except ValidationError:
continue
# As a fall back, raise an exception with the response in it
raise APIException(response=response)
# Determine, from the map, the correct response for this status code
expected_responses = func_response_code_maps[func.__name__] # noqa
if str(status_code) not in expected_responses.keys():
raise APIException(
response=response, reason="An unexpected status code was received"
)
else:
expected_response_class_name = expected_responses[str(status_code)]

# Get the correct response type and build it
response_type = [
t for t in response_types if t.__name__ == expected_response_class_name
][0]
data = response.json()
return response_type.model_validate(data)
7 changes: 4 additions & 3 deletions src/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,17 @@ def __init__(
url: Optional[str],
file: Optional[str],
) -> None:
self.http_generator = HTTPGenerator(
spec=spec, output_dir=output_dir, asyncio=asyncio
)
self.schemas_generator = SchemasGenerator(spec=spec, output_dir=output_dir)
self.clients_generator = ClientsGenerator(
spec=spec,
output_dir=output_dir,
schemas_generator=self.schemas_generator,
http_generator=self.http_generator,
asyncio=asyncio,
)
self.http_generator = HTTPGenerator(
spec=spec, output_dir=output_dir, asyncio=asyncio
)
self.spec = spec
self.asyncio = asyncio
self.output_dir = output_dir
Expand Down
20 changes: 16 additions & 4 deletions src/generators/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from pydantic import BaseModel
from rich.console import Console

from src.generators.http import HTTPGenerator
from src.generators.schemas import SchemasGenerator
from src.settings import templates
from src.utils import (
Expand Down Expand Up @@ -45,18 +46,21 @@ class ClientsGenerator:
spec: Spec
output_dir: str
schemas_generator: SchemasGenerator
http_generator: HTTPGenerator

def __init__(
self,
spec: Spec,
output_dir: str,
schemas_generator: SchemasGenerator,
http_generator: HTTPGenerator,
asyncio: bool,
) -> None:
self.spec = spec
self.output_dir = output_dir
self.results = defaultdict(int)
self.schemas_generator = schemas_generator
self.http_generator = http_generator
self.asyncio = asyncio
self.method_template_map = dict(
get="get_method.jinja2",
Expand Down Expand Up @@ -119,7 +123,11 @@ def generate_parameters(
def get_response_class_names(self, responses: dict, func_name: str) -> list[str]:
"""
Generates a list of response class for this operation.
For each response found, also generate the schema by calling
the schema generator.
Returns a list of names of the classes generated.
"""
status_code_map: dict[str, str] = {}
response_classes = []
for status_code, details in responses.items():
for _, content in details.get("content", {}).items():
Expand All @@ -144,8 +152,12 @@ def get_response_class_names(self, responses: dict, func_name: str) -> list[str]
func_name + status_code + "Response",
schema={"properties": {"test": content["schema"]}},
)
status_code_map[status_code] = class_name
response_classes.append(class_name)
return list(set(response_classes))
self.http_generator.add_status_codes_to_bundle(
func_name=func_name, status_code_map=status_code_map
)
return sorted(list(set(response_classes)))

def get_input_class_names(self, inputs: dict) -> list[str]:
"""
Expand Down Expand Up @@ -210,9 +222,9 @@ def generate_function(
api_url = url + create_query_args(list(query_args.keys()))
else:
api_url = url
if method in ["post"] and not operation.get("requestBody"):
if method in ["post", "put"] and not operation.get("requestBody"):
data_class_name = "None"
elif method in ["post"]:
elif method in ["post", "put"]:
data_class_name = self.generate_input_types(
operation.get("requestBody", {})
)
Expand Down Expand Up @@ -243,7 +255,7 @@ def generate_function(
def write_path_to_client(self, path: dict) -> None:
url, operations = path
for method, operation in operations.items():
if method in self.method_template_map.keys():
if method.lower() in self.method_template_map.keys():
self.generate_function(
operation=operation,
method=method,
Expand Down
16 changes: 16 additions & 0 deletions src/generators/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,24 @@ def __init__(self, spec: Spec, output_dir: str, asyncio: bool) -> None:
self.output_dir = output_dir
self.results: dict[str, int] = defaultdict(int)
self.asyncio = asyncio
self.function_and_status_codes_bundle: dict[str, dict[str, str]] = {}

def add_status_codes_to_bundle(
self, func_name: str, status_code_map: dict[str, str]
) -> None:
"""
Build a huge map of each function and it's status code responses.
At the end of the client generation you should call http_generator.generate_http_content()
"""
self.function_and_status_codes_bundle[func_name] = status_code_map

def writeable_function_and_status_codes_bundle(self) -> str:
return f"func_response_code_maps = {self.function_and_status_codes_bundle}"

def generate_http_content(self) -> None:
write_to_http(
self.writeable_function_and_status_codes_bundle(), self.output_dir
)
client_generated = False
client_type = "AsyncClient" if self.asyncio else "Client"
if security_schemes := self.spec["components"].get("securitySchemes"):
Expand Down
2 changes: 1 addition & 1 deletion src/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@
CLIENT_TEMPLATE_ROOT = dirname(dirname(abspath(__file__))) + "/src/client_template/"
TEMPLATES_ROOT = dirname(dirname(abspath(__file__))) + "/src/templates/"
CONSTANTS_ROOT = dirname(dirname(abspath(__file__))) + "/src/"
VERSION = "0.5.2"
VERSION = "0.6.0"

templates = Environment(loader=PackageLoader("src", "templates"))
4 changes: 2 additions & 2 deletions src/writer.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pathlib import Path, PosixPath
from pathlib import Path


def write_to_schemas(content: str, output_dir: str) -> None:
Expand All @@ -22,7 +22,7 @@ def write_to_manifest(content: str, output_dir: str) -> None:


def _write_to(
path: PosixPath,
path: Path,
content: str,
) -> None:
with path.open("a") as f:
Expand Down
2 changes: 1 addition & 1 deletion tests/async_test_client/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pipx install clientele

API VERSION: 0.1.0
OPENAPI VERSION: 3.0.2
CLIENTELE VERSION: 0.5.2
CLIENTELE VERSION: 0.6.0

Generated using this command:

Expand Down
6 changes: 3 additions & 3 deletions tests/async_test_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ async def request_data_request_data_post(


async def request_data_request_data_put(
data: None,
data: schemas.RequestDataRequest,
) -> typing.Union[schemas.HTTPValidationError, schemas.RequestDataResponse]:
"""Request Data"""

Expand Down Expand Up @@ -79,7 +79,7 @@ async def security_required_request_security_required_get() -> schemas.SecurityR

async def query_request_simple_query_get(
your_input: str,
) -> typing.Union[schemas.SimpleQueryParametersResponse, schemas.HTTPValidationError]:
) -> typing.Union[schemas.HTTPValidationError, schemas.SimpleQueryParametersResponse]:
"""Query Request"""

response = await http.get(url=f"/simple-query?your_input={your_input}")
Expand All @@ -95,7 +95,7 @@ async def simple_request_simple_request_get() -> schemas.SimpleResponse:

async def parameter_request_simple_request(
your_input: str,
) -> typing.Union[schemas.ParameterResponse, schemas.HTTPValidationError]:
) -> typing.Union[schemas.HTTPValidationError, schemas.ParameterResponse]:
"""Parameter Request"""

response = await http.get(url=f"/simple-request/{your_input}")
Expand Down
69 changes: 58 additions & 11 deletions tests/async_test_client/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
class APIException(Exception):
"""Could not match API response to return type of this function"""

reason: str
response: httpx.Response

def __init__(self, response: httpx.Response, *args: object) -> None:
def __init__(self, response: httpx.Response, reason: str, *args: object) -> None:
self.response = response
self.reason = reason
super().__init__(*args)


Expand All @@ -26,26 +28,71 @@ def parse_url(url: str) -> str:
return url_parts.geturl()


def handle_response(func, response):
def handle_response(func: typing.Callable, response: httpx.Response):
"""
Returns a response that matches the data neatly for a function
If it can't - raises an error with details of the response.
"""
response_data = response.json()
status_code = response.status_code
# Get the response types
response_types = typing.get_type_hints(func).get("return")
if typing.get_origin(response_types) == typing.Union:
response_types = list(typing.get_args(response_types))
else:
response_types = [response_types]

for single_type in response_types:
try:
return single_type.model_validate(response_data)
except ValidationError:
continue
# As a fall back, raise an exception with the response in it
raise APIException(response=response)

# Determine, from the map, the correct response for this status code
expected_responses = func_response_code_maps[func.__name__] # noqa
if str(status_code) not in expected_responses.keys():
raise APIException(
response=response, reason="An unexpected status code was received"
)
else:
expected_response_class_name = expected_responses[str(status_code)]

# Get the correct response type and build it
response_type = [
t for t in response_types if t.__name__ == expected_response_class_name
][0]
data = response.json()
return response_type.model_validate(data)


func_response_code_maps = {
"complex_model_request_complex_model_request_get": {"200": "ComplexModelResponse"},
"header_request_header_request_get": {
"200": "HeadersResponse",
"422": "HTTPValidationError",
},
"optional_parameters_request_optional_parameters_get": {
"200": "OptionalParametersResponse"
},
"request_data_request_data_post": {
"200": "RequestDataResponse",
"422": "HTTPValidationError",
},
"request_data_request_data_put": {
"200": "RequestDataResponse",
"422": "HTTPValidationError",
},
"request_data_path_request_data": {
"200": "RequestDataAndParameterResponse",
"422": "HTTPValidationError",
},
"request_delete_request_delete_delete": {"200": "DeleteResponse"},
"security_required_request_security_required_get": {
"200": "SecurityRequiredResponse"
},
"query_request_simple_query_get": {
"200": "SimpleQueryParametersResponse",
"422": "HTTPValidationError",
},
"simple_request_simple_request_get": {"200": "SimpleResponse"},
"parameter_request_simple_request": {
"200": "ParameterResponse",
"422": "HTTPValidationError",
},
}

auth_key = c.get_bearer_token()
headers = c.additional_headers()
Expand Down
2 changes: 1 addition & 1 deletion tests/test_client/MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pipx install clientele

API VERSION: 0.1.0
OPENAPI VERSION: 3.0.2
CLIENTELE VERSION: 0.5.2
CLIENTELE VERSION: 0.6.0

Generated using this command:

Expand Down
Loading

0 comments on commit e9d2b68

Please sign in to comment.