diff --git a/CHANGELOG.md b/CHANGELOG.md index c34813d..8b778e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Change log +## 0.6.2 + +* Ignore optional URL query parameters if they are `None`. + ## 0.6.1 * Added `from __future__ import annotations` in files to help with typing evaluation. diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c34813d..8b778e3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,9 @@ # Change log +## 0.6.2 + +* Ignore optional URL query parameters if they are `None`. + ## 0.6.1 * Added `from __future__ import annotations` in files to help with typing evaluation. diff --git a/example_openapi_specs/best.json b/example_openapi_specs/best.json index 914769f..255dbb8 100644 --- a/example_openapi_specs/best.json +++ b/example_openapi_specs/best.json @@ -229,6 +229,18 @@ "title": "SimpleQueryParametersResponse", "type": "object" }, + "OptionalQueryParametersResponse": { + "description": "A response for query parameters request that has an optional parameter", + "properties": { + "your_query": { + "title": "Your Query", + "type": "string" + } + }, + "required": ["your_query"], + "title": "OptionalQueryParametersResponse", + "type": "object" + }, "SimpleResponse": { "description": "A simple response", "properties": { @@ -575,6 +587,46 @@ "summary": "Query Request" } }, + "/optional-query": { + "get": { + "description": "A request with a query parameters that are optional", + "operationId": "query_request_optional_query_get", + "parameters": [ + { + "in": "query", + "name": "your_input", + "required": false, + "schema": { + "title": "Your Input", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OptionalQueryParametersResponse" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Optional Query Request" + } + }, "/simple-request": { "get": { "description": "A simple API request with no parameters.", diff --git a/pyproject.toml b/pyproject.toml index a2aaa34..80982ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "clientele" -version = "0.6.1" +version = "0.6.2" description = "Generate loveable Python HTTP API Clients" authors = ["Paul Hallett "] license = "MIT" diff --git a/src/client_template/http.py b/src/client_template/http.py index 5d18a77..bcd787e 100644 --- a/src/client_template/http.py +++ b/src/client_template/http.py @@ -1,5 +1,5 @@ import typing -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import httpx # noqa @@ -20,11 +20,26 @@ def __init__(self, response: httpx.Response, reason: str, *args: object) -> None def parse_url(url: str) -> str: """ - Returns the base API URL for this service + Returns the full URL from a string. + Will omit any optional query parameters passed. """ api_url = f"{c.api_base_url()}{url}" url_parts = urlparse(url=api_url) - return url_parts.geturl() + # Filter out "None" optional query parameters + filtered_query_params = { + k: v for k, v in parse_qs(url_parts.query).items() if v[0] not in ["None", ""] + } + filtered_query_string = urlencode(filtered_query_params, doseq=True) + return urlunparse( + ( + url_parts.scheme, + url_parts.netloc, + url_parts.path, + url_parts.params, + filtered_query_string, + url_parts.fragment, + ) + ) def handle_response(func, response): diff --git a/src/settings.py b/src/settings.py index 92185c9..6682bae 100644 --- a/src/settings.py +++ b/src/settings.py @@ -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.6.1" +VERSION = "0.6.2" templates = Environment(loader=PackageLoader("src", "templates")) diff --git a/tests/async_test_client/MANIFEST.md b/tests/async_test_client/MANIFEST.md index 3cec79d..d58d8e3 100644 --- a/tests/async_test_client/MANIFEST.md +++ b/tests/async_test_client/MANIFEST.md @@ -9,7 +9,7 @@ pipx install clientele API VERSION: 0.1.0 OPENAPI VERSION: 3.0.2 -CLIENTELE VERSION: 0.6.1 +CLIENTELE VERSION: 0.6.2 Generated using this command: diff --git a/tests/async_test_client/client.py b/tests/async_test_client/client.py index 2ae2b25..c928903 100644 --- a/tests/async_test_client/client.py +++ b/tests/async_test_client/client.py @@ -91,6 +91,15 @@ async def query_request_simple_query_get( return http.handle_response(query_request_simple_query_get, response) +async def query_request_optional_query_get( + your_input: typing.Optional[str], +) -> typing.Union[schemas.HTTPValidationError, schemas.OptionalQueryParametersResponse]: + """Optional Query Request""" + + response = await http.get(url=f"/optional-query?your_input={your_input}") + return http.handle_response(query_request_optional_query_get, response) + + async def simple_request_simple_request_get() -> schemas.SimpleResponse: """Simple Request""" diff --git a/tests/async_test_client/http.py b/tests/async_test_client/http.py index c2e2bdd..4603486 100644 --- a/tests/async_test_client/http.py +++ b/tests/async_test_client/http.py @@ -1,5 +1,5 @@ import typing -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import httpx # noqa @@ -20,11 +20,26 @@ def __init__(self, response: httpx.Response, reason: str, *args: object) -> None def parse_url(url: str) -> str: """ - Returns the base API URL for this service + Returns the full URL from a string. + Will omit any optional query parameters passed. """ api_url = f"{c.api_base_url()}{url}" url_parts = urlparse(url=api_url) - return url_parts.geturl() + # Filter out "None" optional query parameters + filtered_query_params = { + k: v for k, v in parse_qs(url_parts.query).items() if v[0] not in ["None", ""] + } + filtered_query_string = urlencode(filtered_query_params, doseq=True) + return urlunparse( + ( + url_parts.scheme, + url_parts.netloc, + url_parts.path, + url_parts.params, + filtered_query_string, + url_parts.fragment, + ) + ) def handle_response(func, response): @@ -86,6 +101,10 @@ def handle_response(func, response): "200": "SimpleQueryParametersResponse", "422": "HTTPValidationError", }, + "query_request_optional_query_get": { + "200": "OptionalQueryParametersResponse", + "422": "HTTPValidationError", + }, "simple_request_simple_request_get": {"200": "SimpleResponse"}, "parameter_request_simple_request": { "200": "ParameterResponse", diff --git a/tests/async_test_client/schemas.py b/tests/async_test_client/schemas.py index d892872..6a89fae 100644 --- a/tests/async_test_client/schemas.py +++ b/tests/async_test_client/schemas.py @@ -70,6 +70,10 @@ class SimpleQueryParametersResponse(pydantic.BaseModel): your_query: str +class OptionalQueryParametersResponse(pydantic.BaseModel): + your_query: str + + class SimpleResponse(pydantic.BaseModel): status: str diff --git a/tests/test_async_generated_client.py b/tests/test_async_generated_client.py index 34eefe3..0cd8d1f 100644 --- a/tests/test_async_generated_client.py +++ b/tests/test_async_generated_client.py @@ -98,7 +98,7 @@ async def test_query_request_simple_query_get(respx_mock: MockRouter): # Given your_input = "hello world" mocked_response = {"your_query": your_input} - mock_path = f"/simple-query?your_input={your_input}" + mock_path = "/simple-query?your_input=hello+world" respx_mock.get(mock_path).mock( return_value=Response(json=mocked_response, status_code=200) ) @@ -111,6 +111,26 @@ async def test_query_request_simple_query_get(respx_mock: MockRouter): assert call.request.url == BASE_URL + mock_path +@pytest.mark.asyncio +@pytest.mark.respx(base_url=BASE_URL) +async def test_query_request_optional_query_get(respx_mock: MockRouter): + # Given + your_input = None + mocked_response = {"your_query": "test"} + # NOTE: omits None query parameter + mock_path = "/optional-query" + respx_mock.get(mock_path).mock( + return_value=Response(json=mocked_response, status_code=200) + ) + # When + response = await client.query_request_optional_query_get(your_input=your_input) + # Then + assert isinstance(response, schemas.OptionalQueryParametersResponse) + assert len(respx_mock.calls) == 1 + call = respx_mock.calls[0] + assert call.request.url == BASE_URL + mock_path + + @pytest.mark.asyncio @pytest.mark.respx(base_url=BASE_URL) async def test_complex_model_request_complex_model_request_get(respx_mock: MockRouter): diff --git a/tests/test_client/MANIFEST.md b/tests/test_client/MANIFEST.md index fa3a8d9..4a683df 100644 --- a/tests/test_client/MANIFEST.md +++ b/tests/test_client/MANIFEST.md @@ -9,7 +9,7 @@ pipx install clientele API VERSION: 0.1.0 OPENAPI VERSION: 3.0.2 -CLIENTELE VERSION: 0.6.1 +CLIENTELE VERSION: 0.6.2 Generated using this command: diff --git a/tests/test_client/client.py b/tests/test_client/client.py index 1ef13c7..82b0d03 100644 --- a/tests/test_client/client.py +++ b/tests/test_client/client.py @@ -93,6 +93,15 @@ def query_request_simple_query_get( return http.handle_response(query_request_simple_query_get, response) +def query_request_optional_query_get( + your_input: typing.Optional[str], +) -> typing.Union[schemas.HTTPValidationError, schemas.OptionalQueryParametersResponse]: + """Optional Query Request""" + + response = http.get(url=f"/optional-query?your_input={your_input}") + return http.handle_response(query_request_optional_query_get, response) + + def simple_request_simple_request_get() -> schemas.SimpleResponse: """Simple Request""" diff --git a/tests/test_client/http.py b/tests/test_client/http.py index 0c06009..a6aed4f 100644 --- a/tests/test_client/http.py +++ b/tests/test_client/http.py @@ -1,5 +1,5 @@ import typing -from urllib.parse import urlparse +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse import httpx # noqa @@ -20,11 +20,26 @@ def __init__(self, response: httpx.Response, reason: str, *args: object) -> None def parse_url(url: str) -> str: """ - Returns the base API URL for this service + Returns the full URL from a string. + Will omit any optional query parameters passed. """ api_url = f"{c.api_base_url()}{url}" url_parts = urlparse(url=api_url) - return url_parts.geturl() + # Filter out "None" optional query parameters + filtered_query_params = { + k: v for k, v in parse_qs(url_parts.query).items() if v[0] not in ["None", ""] + } + filtered_query_string = urlencode(filtered_query_params, doseq=True) + return urlunparse( + ( + url_parts.scheme, + url_parts.netloc, + url_parts.path, + url_parts.params, + filtered_query_string, + url_parts.fragment, + ) + ) def handle_response(func, response): @@ -86,6 +101,10 @@ def handle_response(func, response): "200": "SimpleQueryParametersResponse", "422": "HTTPValidationError", }, + "query_request_optional_query_get": { + "200": "OptionalQueryParametersResponse", + "422": "HTTPValidationError", + }, "simple_request_simple_request_get": {"200": "SimpleResponse"}, "parameter_request_simple_request": { "200": "ParameterResponse", diff --git a/tests/test_client/schemas.py b/tests/test_client/schemas.py index d892872..6a89fae 100644 --- a/tests/test_client/schemas.py +++ b/tests/test_client/schemas.py @@ -70,6 +70,10 @@ class SimpleQueryParametersResponse(pydantic.BaseModel): your_query: str +class OptionalQueryParametersResponse(pydantic.BaseModel): + your_query: str + + class SimpleResponse(pydantic.BaseModel): status: str diff --git a/tests/test_generated_client.py b/tests/test_generated_client.py index 7a1f868..5420f2c 100644 --- a/tests/test_generated_client.py +++ b/tests/test_generated_client.py @@ -86,7 +86,7 @@ def test_query_request_simple_query_get(respx_mock: MockRouter): # Given your_input = "hello world" mocked_response = {"your_query": your_input} - mock_path = f"/simple-query?your_input={your_input}" + mock_path = "/simple-query?your_input=hello+world" respx_mock.get(mock_path).mock( return_value=Response(json=mocked_response, status_code=200) ) @@ -99,6 +99,25 @@ def test_query_request_simple_query_get(respx_mock: MockRouter): assert call.request.url == BASE_URL + mock_path +@pytest.mark.respx(base_url=BASE_URL) +def test_query_request_optional_query_get(respx_mock: MockRouter): + # Given + your_input = None + mocked_response = {"your_query": "test"} + # NOTE: omits None query parameter + mock_path = "/optional-query" + respx_mock.get(mock_path).mock( + return_value=Response(json=mocked_response, status_code=200) + ) + # When + response = client.query_request_optional_query_get(your_input=your_input) + # Then + assert isinstance(response, schemas.OptionalQueryParametersResponse) + assert len(respx_mock.calls) == 1 + call = respx_mock.calls[0] + assert call.request.url == BASE_URL + mock_path + + @pytest.mark.respx(base_url=BASE_URL) def test_complex_model_request_complex_model_request_get(respx_mock: MockRouter): # Given