Skip to content

Additional features #722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 20 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9bf6a97
Add an option to define an output path when generating or updating th…
JasperS2307 May 26, 2022
a4038b8
Merge remote-tracking branch 'output-dir/cli-argument-output-dir'
lee-elenbaas Jan 31, 2023
74750be
Use parameter.description for properties if the parameter's schema do…
nats Jan 5, 2023
af88e6a
Fix UNSET+Unset missing from __all__ export in types.py.jinja
nats Jan 21, 2023
8c4397f
Add "data: oai.Operation" attribute to Endpoint
nats Jan 21, 2023
bb9c3f3
Include parameter description from parent schema in Property object
nats Jan 23, 2023
6cbe325
Bump version number
lee-elenbaas Jan 31, 2023
7b1c131
Allow to override tag as the grouping mechanism per endpoint using th…
lee-elenbaas Feb 1, 2023
0b84ee9
Add response data into the template data
lee-elenbaas Feb 6, 2023
452740d
Add ability to throw exception on failed responses
lee-elenbaas Feb 7, 2023
635e62e
failed_response->failed_status
lee-elenbaas Feb 7, 2023
a0d5bf7
Fix reading of the x-response-type property
lee-elenbaas Feb 7, 2023
99cd9ec
Change response type: failed->failure
lee-elenbaas Feb 7, 2023
393833b
Add support for x-focus-path to directly get to the relevent data fro…
lee-elenbaas Feb 7, 2023
667fff3
Add support for utility functions for endpoint
lee-elenbaas Feb 8, 2023
ff772ef
Allow separate folder for utility functions, with fallback to templat…
lee-elenbaas Feb 8, 2023
44fec5b
Identify secure endpoint using default security
lee-elenbaas Feb 28, 2023
37290e9
Improve the customization per call
lee-elenbaas Feb 28, 2023
aaf11f2
copy Reference schema description from param value
lee-elenbaas Mar 7, 2023
7aa467d
Fix generation of empty responses:
lee-elenbaas May 28, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ For a full example you can look at the `end_to_end_tests` directory which has an
## OpenAPI features supported

1. All HTTP Methods
1. JSON and form bodies, path and query parameters
1. File uploads with multipart/form-data bodies
1. float, string, int, date, datetime, string enums, and custom schemas or lists containing any of those
1. html/text or application/json responses containing any of the previous types
1. Bearer token security
2. JSON and form bodies, path and query parameters
3. File uploads with multipart/form-data bodies
4. float, string, int, date, datetime, string enums, and custom schemas or lists containing any of those
5. html/text or application/json responses containing any of the previous types
6. Bearer token security
7. Default security definition

## Configuration

Expand Down
36 changes: 34 additions & 2 deletions openapi_python_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ def __init__(
meta: MetaType,
config: Config,
custom_template_path: Optional[Path] = None,
utility_functions_template_path: Optional[Path] = None,
file_encoding: str = "utf-8",
output_path: Optional[Path] = None,
) -> None:
self.openapi: GeneratorData = openapi
self.meta: MetaType = meta
Expand All @@ -73,16 +75,32 @@ def __init__(
)
else:
loader = package_loader
if utility_functions_template_path is not None:
utility_functions_loader = ChoiceLoader(
[
FileSystemLoader(str(utility_functions_template_path)),
loader
]
)
else:
utility_functions_loader = loader
self.env: Environment = Environment(
loader=loader,
trim_blocks=True,
lstrip_blocks=True,
extensions=["jinja2.ext.loopcontrols"],
keep_trailing_newline=True,
)
self.utility_functions_env: Environment = Environment(
loader=utility_functions_loader,
trim_blocks=True,
lstrip_blocks=True,
extensions=["jinja2.ext.loopcontrols"],
keep_trailing_newline=True,
)

self.project_name: str = config.project_name_override or f"{utils.kebab_case(openapi.title).lower()}-client"
self.project_dir: Path = Path.cwd()
self.project_dir: Path = Path.cwd() if output_path is None else Path(output_path).absolute()
if meta != MetaType.NONE:
self.project_dir /= self.project_name

Expand Down Expand Up @@ -281,22 +299,24 @@ def _build_api(self) -> None:
endpoint_template = self.env.get_template(
"endpoint_module.py.jinja", globals={"isbool": lambda obj: obj.get_base_type_string() == "bool"}
)
endpoint_init_template = self.env.get_template("endpoint_init.py.jinja")
for tag, collection in endpoint_collections_by_tag.items():
tag_dir = api_dir / tag
tag_dir.mkdir()

endpoint_init_path = tag_dir / "__init__.py"
endpoint_init_template = self.env.get_template("endpoint_init.py.jinja")
endpoint_init_path.write_text(
endpoint_init_template.render(endpoint_collection=collection),
encoding=self.file_encoding,
)

for endpoint in collection.endpoints:
module_path = tag_dir / f"{utils.PythonIdentifier(endpoint.name, self.config.field_prefix)}.py"
utility_functions_template = self.utility_functions_env.get_template(endpoint.utility_functions_template) if endpoint.utility_functions_template else None
module_path.write_text(
endpoint_template.render(
endpoint=endpoint,
utility_functions_code=utility_functions_template.render(endpoint=endpoint) if utility_functions_template else ''
),
encoding=self.file_encoding,
)
Expand All @@ -308,7 +328,9 @@ def _get_project_for_url_or_path( # pylint: disable=too-many-arguments
meta: MetaType,
config: Config,
custom_template_path: Optional[Path] = None,
utility_functions_template_path: Optional[Path] = None,
file_encoding: str = "utf-8",
output_path: Optional[Path] = None,
) -> Union[Project, GeneratorError]:
data_dict = _get_document(url=url, path=path, timeout=config.http_timeout)
if isinstance(data_dict, GeneratorError):
Expand All @@ -319,9 +341,11 @@ def _get_project_for_url_or_path( # pylint: disable=too-many-arguments
return Project(
openapi=openapi,
custom_template_path=custom_template_path,
utility_functions_template_path=utility_functions_template_path,
meta=meta,
file_encoding=file_encoding,
config=config,
output_path=output_path
)


Expand All @@ -332,7 +356,9 @@ def create_new_client(
meta: MetaType,
config: Config,
custom_template_path: Optional[Path] = None,
utility_functions_template_path: Optional[Path] = None,
file_encoding: str = "utf-8",
output_path: Optional[Path] = None,
) -> Sequence[GeneratorError]:
"""
Generate the client library
Expand All @@ -344,9 +370,11 @@ def create_new_client(
url=url,
path=path,
custom_template_path=custom_template_path,
utility_functions_template_path=utility_functions_template_path,
meta=meta,
file_encoding=file_encoding,
config=config,
output_path=output_path
)
if isinstance(project, GeneratorError):
return [project]
Expand All @@ -360,7 +388,9 @@ def update_existing_client(
meta: MetaType,
config: Config,
custom_template_path: Optional[Path] = None,
utility_functions_template_path: Optional[Path] = None,
file_encoding: str = "utf-8",
output_path: Optional[Path] = None,
) -> Sequence[GeneratorError]:
"""
Update an existing client library
Expand All @@ -372,9 +402,11 @@ def update_existing_client(
url=url,
path=path,
custom_template_path=custom_template_path,
utility_functions_template_path=utility_functions_template_path,
meta=meta,
file_encoding=file_encoding,
config=config,
output_path=output_path
)
if isinstance(project, GeneratorError):
return [project]
Expand Down
15 changes: 15 additions & 0 deletions openapi_python_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ def handle_errors(errors: Sequence[GeneratorError], fail_on_warning: bool = Fals
"resolve_path": True,
}

utility_functions_template_path_options = {
"help": "A path to a directory containing utility functions template(s)",
"file_okay": False,
"dir_okay": True,
"readable": True,
"resolve_path": True,
}

_meta_option = typer.Option(
MetaType.POETRY,
help="The type of metadata you want to generate.",
Expand All @@ -117,9 +125,11 @@ def generate(
url: Optional[str] = typer.Option(None, help="A URL to read the JSON from"),
path: Optional[pathlib.Path] = typer.Option(None, help="A path to the JSON file"),
custom_template_path: Optional[pathlib.Path] = typer.Option(None, **custom_template_path_options), # type: ignore
utility_functions_template_path: Optional[pathlib.Path] = typer.Option(None, **utility_functions_template_path_options), # type: ignore
meta: MetaType = _meta_option,
file_encoding: str = typer.Option("utf-8", help="Encoding used when writing generated"),
config_path: Optional[pathlib.Path] = CONFIG_OPTION,
output_path: Optional[pathlib.Path] = typer.Option(None, help="Set a path to store the api client"),
fail_on_warning: bool = False,
) -> None:
"""Generate a new OpenAPI Client library"""
Expand All @@ -144,8 +154,10 @@ def generate(
path=path,
meta=meta,
custom_template_path=custom_template_path,
utility_functions_template_path=utility_functions_template_path,
file_encoding=file_encoding,
config=config,
output_path=output_path,
)
handle_errors(errors, fail_on_warning)

Expand All @@ -156,9 +168,11 @@ def update(
url: Optional[str] = typer.Option(None, help="A URL to read the JSON from"),
path: Optional[pathlib.Path] = typer.Option(None, help="A path to the JSON file"),
custom_template_path: Optional[pathlib.Path] = typer.Option(None, **custom_template_path_options), # type: ignore
utility_functions_template_path: Optional[pathlib.Path] = typer.Option(None, **utility_functions_template_path_options), # type: ignore
meta: MetaType = _meta_option,
file_encoding: str = typer.Option("utf-8", help="Encoding used when writing generated"),
config_path: Optional[pathlib.Path] = CONFIG_OPTION,
output_path: Optional[pathlib.Path] = typer.Option(None, help="Set a path to store the api client"),
fail_on_warning: bool = False,
) -> None:
"""Update an existing OpenAPI Client library
Expand Down Expand Up @@ -189,5 +203,6 @@ def update(
custom_template_path=custom_template_path,
file_encoding=file_encoding,
config=config,
output_path=output_path,
)
handle_errors(errors, fail_on_warning)
25 changes: 21 additions & 4 deletions openapi_python_client/parser/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .. import utils
from ..config import Config
from ..utils import PythonIdentifier
from ..schema.openapi_schema_pydantic.security_requirement import SecurityRequirement
from .errors import GeneratorError, ParseError, PropertyError
from .properties import (
Class,
Expand Down Expand Up @@ -45,7 +46,7 @@ class EndpointCollection:

@staticmethod
def from_data(
*, data: Dict[str, oai.PathItem], schemas: Schemas, parameters: Parameters, config: Config
*, data: Dict[str, oai.PathItem], schemas: Schemas, parameters: Parameters, default_security: SecurityRequirement, config: Config
) -> Tuple[Dict[utils.PythonIdentifier, "EndpointCollection"], Schemas, Parameters]:
"""Parse the openapi paths data to get EndpointCollections by tag"""
endpoints_by_tag: Dict[utils.PythonIdentifier, EndpointCollection] = {}
Expand All @@ -57,7 +58,7 @@ def from_data(
operation: Optional[oai.Operation] = getattr(path_data, method)
if operation is None:
continue
tag = utils.PythonIdentifier(value=(operation.tags or ["default"])[0], prefix="tag")
tag = utils.PythonIdentifier(value=operation.__dict__.get('x-code-tag') or (operation.tags or ["default"])[0], prefix="tag")
collection = endpoints_by_tag.setdefault(tag, EndpointCollection(tag=tag))
endpoint, schemas, parameters = Endpoint.from_data(
data=operation,
Expand All @@ -67,6 +68,7 @@ def from_data(
schemas=schemas,
parameters=parameters,
config=config,
default_security=default_security
)
# Add `PathItem` parameters
if not isinstance(endpoint, ParseError):
Expand Down Expand Up @@ -109,12 +111,14 @@ class Endpoint:
Describes a single endpoint on the server
"""

data: oai.Operation
path: str
method: str
description: Optional[str]
name: str
requires_security: bool
tag: str
utility_functions_template: str
summary: Optional[str] = ""
relative_imports: Set[str] = field(default_factory=set)
query_parameters: Dict[str, Property] = field(default_factory=dict)
Expand Down Expand Up @@ -379,6 +383,12 @@ def add_parameters(

unique_parameters.add(unique_param)

# In OpenAPI specification both of a parameter, and its schema, may optionally have a description.
# openapi-python-client only uses the schema description for the parameter, so if
# the schema does not have a description we will supply the parameter's description instead.
if isinstance(param.param_schema, oai.Reference) or param.param_schema.description is None:
param.param_schema.description = param.description

prop, new_schemas = property_from_data(
name=param.name,
required=param.required,
Expand Down Expand Up @@ -484,6 +494,7 @@ def from_data(
schemas: Schemas,
parameters: Parameters,
config: Config,
default_security: SecurityRequirement,
) -> Tuple[Union["Endpoint", ParseError], Schemas, Parameters]:
"""Construct an endpoint from the OpenAPI data"""

Expand All @@ -492,14 +503,20 @@ def from_data(
else:
name = data.operationId

requires_security = bool(data.security)
if data.security is None:
requires_security = bool(default_security)

endpoint = Endpoint(
path=path,
method=method,
summary=utils.remove_string_escapes(data.summary) if data.summary else "",
description=utils.remove_string_escapes(data.description) if data.description else "",
name=name,
requires_security=bool(data.security),
requires_security=requires_security,
tag=tag,
utility_functions_template=data.__dict__.get('x-utility-functions-template'),
data=data,
)

result, schemas, parameters = Endpoint.add_parameters(
Expand Down Expand Up @@ -568,7 +585,7 @@ def from_dict(data: Dict[str, Any], *, config: Config) -> Union["GeneratorData",
if openapi.components and openapi.components.parameters:
parameters = build_parameters(components=openapi.components.parameters, parameters=parameters)
endpoint_collections_by_tag, schemas, parameters = EndpointCollection.from_data(
data=openapi.paths, schemas=schemas, parameters=parameters, config=config
data=openapi.paths, schemas=schemas, parameters=parameters, default_security=openapi.security, config=config
)

enums = (prop for prop in schemas.classes_by_name.values() if isinstance(prop, EnumProperty))
Expand Down
9 changes: 9 additions & 0 deletions openapi_python_client/parser/properties/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from itertools import chain
from typing import Any, ClassVar, Dict, Generic, Iterable, List, Optional, Set, Tuple, TypeVar, Union
from enum import Enum

import attr

Expand All @@ -35,6 +36,12 @@
)


class ResponseType(Enum):
AUTO = 'auto' # Automatically decide whether a response is a failed or success response - every response within the rance [200, 300) will be considered success, others as failed
FAILURE = 'failure' # Explicitly specified failed response
SUCCESS = 'success' # Explicitly specified success response


@attr.s(auto_attribs=True, frozen=True)
class AnyProperty(Property):
"""A property that can be any type (used for empty schemas)"""
Expand Down Expand Up @@ -595,6 +602,8 @@ def _property_from_ref(
)
if parent:
prop = attr.evolve(prop, nullable=parent.nullable)
if parent.description:
prop = attr.evolve(prop, description=parent.description)
if isinstance(prop, EnumProperty):
default = get_enum_default(prop, parent)
if isinstance(default, PropertyError):
Expand Down
1 change: 1 addition & 0 deletions openapi_python_client/parser/properties/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ def parameter_from_data(
style=data.style,
param_schema=data.param_schema,
param_in=data.param_in,
description=data.description,
)
parameters = attr.evolve(parameters, classes_by_name={**parameters.classes_by_name, name: new_param})
return new_param, parameters
Expand Down
17 changes: 15 additions & 2 deletions openapi_python_client/parser/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from .. import schema as oai
from ..utils import PythonIdentifier
from .errors import ParseError, PropertyError
from .properties import AnyProperty, Property, Schemas, property_from_data
from .properties import ResponseType, AnyProperty, Property, Schemas, property_from_data


@attr.s(auto_attribs=True, frozen=True)
Expand All @@ -19,6 +19,8 @@ class Response:
status_code: HTTPStatus
prop: Property
source: str
failed_status: bool
data: object


def _source_by_content_type(content_type: str) -> Optional[str]:
Expand Down Expand Up @@ -49,7 +51,9 @@ def empty_response(
description=description,
example=None,
),
failed_status=status_code < HTTPStatus.OK or status_code >= HTTPStatus.MULTIPLE_CHOICES,
source="None",
data=None,
)


Expand Down Expand Up @@ -95,6 +99,15 @@ def response_from_data(
schemas,
)

response_type_val = data.__dict__['x-response-type'] if 'x-response-type' in data.__dict__ else ResponseType.AUTO.value
if response_type_val not in ResponseType._value2member_map_:
return ParseError(data=data, detail=f"Unsupported x-response-type: {response_type}"), schemas
response_type = ResponseType(response_type_val)
if response_type == ResponseType.AUTO:
failed_status = status_code < HTTPStatus.OK or status_code >= HTTPStatus.MULTIPLE_CHOICES
else:
failed_status = response_type == ResponseType.FAILURE

prop, schemas = property_from_data(
name=response_name,
required=True,
Expand All @@ -107,4 +120,4 @@ def response_from_data(
if isinstance(prop, PropertyError):
return prop, schemas

return Response(status_code=status_code, prop=prop, source=source), schemas
return Response(status_code=status_code, prop=prop, source=source, failed_status=failed_status, data=data), schemas
Loading