From 1deaaa88fec9632d6a91c53ee16e7549eef3662d Mon Sep 17 00:00:00 2001 From: Justin Black Date: Wed, 13 Apr 2022 15:45:33 -0700 Subject: [PATCH] [python-experimental] Allow response media types to omit schema (#12135) * Adds issue spec file and attemts to generate code from it * Adds missing schema definitions * Skips fromProperty invocation if the passed in schema is none in getContent * Makes MediaType.schema optional * Adds checking that the content type is in self.content * Sets ApiResponse body type as Unset if there is no schema for it * Handles schema = None case * Adds endpoint without response schema * Reverts version files * Adds test_response_without_schema --- .../openapitools/codegen/DefaultCodegen.java | 5 +- .../python-experimental/api_client.handlebars | 36 +++-- .../python-experimental/api_doc.handlebars | 2 +- .../python-experimental/endpoint.handlebars | 16 +- ...odels-for-testing-with-http-signature.yaml | 12 ++ .../petstore/python-experimental/README.md | 1 + .../python-experimental/docs/FakeApi.md | 56 +++++++ .../petstore_api/api/fake_api.py | 2 + .../response_without_schema.py | 139 ++++++++++++++++++ .../petstore_api/api_client.py | 36 +++-- .../tests_manual/test_fake_api.py | 60 ++++++-- 11 files changed, 321 insertions(+), 44 deletions(-) create mode 100644 samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api_endpoints/response_without_schema.py diff --git a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java index 63aca217cd7..81e09842ab5 100644 --- a/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java +++ b/modules/openapi-generator/src/main/java/org/openapitools/codegen/DefaultCodegen.java @@ -6753,7 +6753,10 @@ public class DefaultCodegen implements CodegenConfig { } } String contentType = contentEntry.getKey(); - CodegenProperty schemaProp = fromProperty(toMediaTypeSchemaName(contentType, mediaTypeSchemaSuffix), mt.getSchema()); + CodegenProperty schemaProp = null; + if (mt.getSchema() != null) { + schemaProp = fromProperty(toMediaTypeSchemaName(contentType, mediaTypeSchemaSuffix), mt.getSchema()); + } CodegenMediaType codegenMt = new CodegenMediaType(schemaProp, ceMap); cmtContent.put(contentType, codegenMt); if (schemaProp != null) { diff --git a/modules/openapi-generator/src/main/resources/python-experimental/api_client.handlebars b/modules/openapi-generator/src/main/resources/python-experimental/api_client.handlebars index b2539cec5a2..b72a961ed10 100644 --- a/modules/openapi-generator/src/main/resources/python-experimental/api_client.handlebars +++ b/modules/openapi-generator/src/main/resources/python-experimental/api_client.handlebars @@ -673,6 +673,7 @@ class Encoding: self.allow_reserved = allow_reserved +@dataclass class MediaType: """ Used to store request and response body schema information @@ -682,14 +683,8 @@ class MediaType: The encoding object SHALL only apply to requestBody objects when the media type is multipart or application/x-www-form-urlencoded. """ - - def __init__( - self, - schema: typing.Type[Schema], - encoding: typing.Optional[typing.Dict[str, Encoding]] = None, - ): - self.schema = schema - self.encoding = encoding + schema: typing.Optional[typing.Type[Schema]] = None + encoding: typing.Optional[typing.Dict[str, Encoding]] = None @dataclass @@ -808,7 +803,27 @@ class OpenApiResponse(JSONDetector): content_type = response.getheader('content-type') deserialized_body = unset streamed = response.supports_chunked_reads() + + deserialized_headers = unset + if self.headers is not None: + # TODO add header deserialiation here + pass + if self.content is not None: + if content_type not in self.content: + raise ApiValueError( + f'Invalid content_type={content_type} returned for response with ' + 'status_code={str(response.status)}' + ) + body_schema = self.content[content_type].schema + if body_schema is None: + # some specs do not define response content media type schemas + return self.response_cls( + response=response, + headers=deserialized_headers, + body=unset + ) + if self.content_type_is_json(content_type): body_data = self.__deserialize_json(response) elif content_type == 'application/octet-stream': @@ -818,16 +833,11 @@ class OpenApiResponse(JSONDetector): content_type = 'multipart/form-data' else: raise NotImplementedError('Deserialization of {} has not yet been implemented'.format(content_type)) - body_schema = self.content[content_type].schema deserialized_body = body_schema._from_openapi_data( body_data, _configuration=configuration) elif streamed: response.release_conn() - deserialized_headers = unset - if self.headers is not None: - deserialized_headers = unset - return self.response_cls( response=response, headers=deserialized_headers, diff --git a/modules/openapi-generator/src/main/resources/python-experimental/api_doc.handlebars b/modules/openapi-generator/src/main/resources/python-experimental/api_doc.handlebars index 546e61a8d86..01790e06433 100644 --- a/modules/openapi-generator/src/main/resources/python-experimental/api_doc.handlebars +++ b/modules/openapi-generator/src/main/resources/python-experimental/api_doc.handlebars @@ -175,7 +175,7 @@ default | ApiResponseForDefault | {{message}} Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- response | urllib3.HTTPResponse | Raw response | -body | {{#unless content}}Unset{{else}}typing.Union[{{#each content}}{{this.schema.baseName}}, {{/each}}]{{/unless}} | {{#unless content}}body was not defined{{/unless}} | +body | {{#unless content}}Unset{{else}}typing.Union[{{#each content}}{{#if this.schema}}{{this.schema.baseName}}{{else}}Unset{{/if}}, {{/each}}]{{/unless}} | {{#unless content}}body was not defined{{/unless}} | headers | {{#unless responseHeaders}}Unset{{else}}ResponseHeadersFor{{code}}{{/unless}} | {{#unless responseHeaders}}headers were not defined{{/unless}} | {{#each content}} {{#with this.schema}} diff --git a/modules/openapi-generator/src/main/resources/python-experimental/endpoint.handlebars b/modules/openapi-generator/src/main/resources/python-experimental/endpoint.handlebars index d27e91e8e0b..bb060d0799a 100644 --- a/modules/openapi-generator/src/main/resources/python-experimental/endpoint.handlebars +++ b/modules/openapi-generator/src/main/resources/python-experimental/endpoint.handlebars @@ -182,8 +182,8 @@ class RequestCookieParams(RequestRequiredCookieParams, RequestOptionalCookiePara request_body_{{paramName}} = api_client.RequestBody( content={ {{#each content}} - '{{{@key}}}': api_client.MediaType( - schema={{this.schema.baseName}}), + '{{{@key}}}': api_client.MediaType({{#if this.schema}} + schema={{this.schema.baseName}}{{/if}}), {{/each}} }, {{#if required}} @@ -285,7 +285,11 @@ class ApiResponseFor{{code}}(api_client.ApiResponse): {{#and responseHeaders content}} body: typing.Union[ {{#each content}} +{{#if this.schema}} {{this.schema.baseName}}, +{{else}} + Unset, +{{/if}} {{/each}} ] headers: ResponseHeadersFor{{code}} @@ -297,7 +301,11 @@ class ApiResponseFor{{code}}(api_client.ApiResponse): {{else}} body: typing.Union[ {{#each content}} +{{#if this.schema}} {{this.schema.baseName}}, +{{else}} + Unset, +{{/if}} {{/each}} ] headers: Unset = unset @@ -323,8 +331,8 @@ _response_for_{{code}} = api_client.OpenApiResponse( {{#if @first}} content={ {{/if}} - '{{{@key}}}': api_client.MediaType( - schema={{this.schema.baseName}}), + '{{{@key}}}': api_client.MediaType({{#if this.schema}} + schema={{this.schema.baseName}}{{/if}}), {{#if @last}} }, {{/if}} diff --git a/modules/openapi-generator/src/test/resources/3_0/python-experimental/petstore-with-fake-endpoints-models-for-testing-with-http-signature.yaml b/modules/openapi-generator/src/test/resources/3_0/python-experimental/petstore-with-fake-endpoints-models-for-testing-with-http-signature.yaml index af77cacb4ea..c6faa621fb3 100644 --- a/modules/openapi-generator/src/test/resources/3_0/python-experimental/petstore-with-fake-endpoints-models-for-testing-with-http-signature.yaml +++ b/modules/openapi-generator/src/test/resources/3_0/python-experimental/petstore-with-fake-endpoints-models-for-testing-with-http-signature.yaml @@ -1546,6 +1546,18 @@ paths: content: application/json; charset=utf-8: schema: {} + "/fake/responseWithoutSchema": + get: + tags: + - fake + summary: receives a response without schema + operationId: responseWithoutSchema + responses: + '200': + description: contents without schema definition + content: + application/json: {} + application/xml: {} servers: - url: 'http://{server}.swagger.io:{port}/v2' description: petstore server diff --git a/samples/openapi3/client/petstore/python-experimental/README.md b/samples/openapi3/client/petstore/python-experimental/README.md index a970abb3ec5..7038ff34dfe 100644 --- a/samples/openapi3/client/petstore/python-experimental/README.md +++ b/samples/openapi3/client/petstore/python-experimental/README.md @@ -108,6 +108,7 @@ Class | Method | HTTP request | Description *FakeApi* | [**parameter_collisions**](docs/FakeApi.md#parameter_collisions) | **POST** /fake/parameterCollisions/{1}/{aB}/{Ab}/{self}/{A-B}/ | parameter collision case *FakeApi* | [**query_parameter_collection_format**](docs/FakeApi.md#query_parameter_collection_format) | **PUT** /fake/test-query-paramters | *FakeApi* | [**ref_object_in_query**](docs/FakeApi.md#ref_object_in_query) | **GET** /fake/refObjInQuery | user list +*FakeApi* | [**response_without_schema**](docs/FakeApi.md#response_without_schema) | **GET** /fake/responseWithoutSchema | receives a response without schema *FakeApi* | [**string**](docs/FakeApi.md#string) | **POST** /fake/refs/string | *FakeApi* | [**string_enum**](docs/FakeApi.md#string_enum) | **POST** /fake/refs/enum | *FakeApi* | [**upload_download_file**](docs/FakeApi.md#upload_download_file) | **POST** /fake/uploadDownloadFile | uploads a file and downloads a file using application/octet-stream diff --git a/samples/openapi3/client/petstore/python-experimental/docs/FakeApi.md b/samples/openapi3/client/petstore/python-experimental/docs/FakeApi.md index 059e828ff7d..0b2cac727d5 100644 --- a/samples/openapi3/client/petstore/python-experimental/docs/FakeApi.md +++ b/samples/openapi3/client/petstore/python-experimental/docs/FakeApi.md @@ -28,6 +28,7 @@ Method | HTTP request | Description [**parameter_collisions**](FakeApi.md#parameter_collisions) | **POST** /fake/parameterCollisions/{1}/{aB}/{Ab}/{self}/{A-B}/ | parameter collision case [**query_parameter_collection_format**](FakeApi.md#query_parameter_collection_format) | **PUT** /fake/test-query-paramters | [**ref_object_in_query**](FakeApi.md#ref_object_in_query) | **GET** /fake/refObjInQuery | user list +[**response_without_schema**](FakeApi.md#response_without_schema) | **GET** /fake/responseWithoutSchema | receives a response without schema [**string**](FakeApi.md#string) | **POST** /fake/refs/string | [**string_enum**](FakeApi.md#string_enum) | **POST** /fake/refs/enum | [**upload_download_file**](FakeApi.md#upload_download_file) | **POST** /fake/uploadDownloadFile | uploads a file and downloads a file using application/octet-stream @@ -2547,6 +2548,61 @@ body | Unset | body was not defined | headers | Unset | headers were not defined | +void (empty response body) + +### Authorization + +No authorization required + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) + +# **response_without_schema** +> response_without_schema() + +receives a response without schema + +### Example + +```python +import petstore_api +from petstore_api.api import fake_api +from pprint import pprint +# Defining the host is optional and defaults to http://petstore.swagger.io:80/v2 +# See configuration.py for a list of all supported configuration parameters. +configuration = petstore_api.Configuration( + host = "http://petstore.swagger.io:80/v2" +) + +# Enter a context with an instance of the API client +with petstore_api.ApiClient(configuration) as api_client: + # Create an instance of the API class + api_instance = fake_api.FakeApi(api_client) + + # example, this endpoint has no required or optional parameters + try: + # receives a response without schema + api_response = api_instance.response_without_schema() + except petstore_api.ApiException as e: + print("Exception when calling FakeApi->response_without_schema: %s\n" % e) +``` +### Parameters +This endpoint does not need any parameter. + +### Return Types, Responses + +Code | Class | Description +------------- | ------------- | ------------- +n/a | api_client.ApiResponseWithoutDeserialization | When skip_deserialization is True this response is returned +200 | ApiResponseFor200 | contents without schema definition + +#### ApiResponseFor200 +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +response | urllib3.HTTPResponse | Raw response | +body | typing.Union[Unset, Unset, ] | | +headers | Unset | headers were not defined | + + void (empty response body) ### Authorization diff --git a/samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api.py b/samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api.py index 01b67230d61..1e135fc899b 100644 --- a/samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api.py +++ b/samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api.py @@ -34,6 +34,7 @@ from petstore_api.api.fake_api_endpoints.object_model_with_ref_props import Obje from petstore_api.api.fake_api_endpoints.parameter_collisions import ParameterCollisions from petstore_api.api.fake_api_endpoints.query_parameter_collection_format import QueryParameterCollectionFormat from petstore_api.api.fake_api_endpoints.ref_object_in_query import RefObjectInQuery +from petstore_api.api.fake_api_endpoints.response_without_schema import ResponseWithoutSchema from petstore_api.api.fake_api_endpoints.string import String from petstore_api.api.fake_api_endpoints.string_enum import StringEnum from petstore_api.api.fake_api_endpoints.upload_download_file import UploadDownloadFile @@ -66,6 +67,7 @@ class FakeApi( ParameterCollisions, QueryParameterCollectionFormat, RefObjectInQuery, + ResponseWithoutSchema, String, StringEnum, UploadDownloadFile, diff --git a/samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api_endpoints/response_without_schema.py b/samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api_endpoints/response_without_schema.py new file mode 100644 index 00000000000..251ef4013de --- /dev/null +++ b/samples/openapi3/client/petstore/python-experimental/petstore_api/api/fake_api_endpoints/response_without_schema.py @@ -0,0 +1,139 @@ +# coding: utf-8 + +""" + + + Generated by: https://openapi-generator.tech +""" + +from dataclasses import dataclass +import re # noqa: F401 +import sys # noqa: F401 +import typing +import urllib3 +from urllib3._collections import HTTPHeaderDict + +from petstore_api import api_client, exceptions +import decimal # noqa: F401 +from datetime import date, datetime # noqa: F401 +from frozendict import frozendict # noqa: F401 + +from petstore_api.schemas import ( # noqa: F401 + AnyTypeSchema, + ComposedSchema, + DictSchema, + ListSchema, + StrSchema, + IntSchema, + Int32Schema, + Int64Schema, + Float32Schema, + Float64Schema, + NumberSchema, + DateSchema, + DateTimeSchema, + DecimalSchema, + BoolSchema, + BinarySchema, + NoneSchema, + none_type, + Configuration, + Unset, + unset, + ComposedBase, + ListBase, + DictBase, + NoneBase, + StrBase, + IntBase, + Int32Base, + Int64Base, + Float32Base, + Float64Base, + NumberBase, + DateBase, + DateTimeBase, + BoolBase, + BinaryBase, + Schema, + _SchemaValidator, + _SchemaTypeChecker, + _SchemaEnumMaker +) + +_path = '/fake/responseWithoutSchema' +_method = 'GET' + + +@dataclass +class ApiResponseFor200(api_client.ApiResponse): + response: urllib3.HTTPResponse + body: typing.Union[ + Unset, + Unset, + ] + headers: Unset = unset + + +_response_for_200 = api_client.OpenApiResponse( + response_cls=ApiResponseFor200, + content={ + 'application/json': api_client.MediaType(), + 'application/xml': api_client.MediaType(), + }, +) +_status_code_to_response = { + '200': _response_for_200, +} +_all_accept_content_types = ( + 'application/json', + 'application/xml', +) + + +class ResponseWithoutSchema(api_client.Api): + + def response_without_schema( + self: api_client.Api, + accept_content_types: typing.Tuple[str] = _all_accept_content_types, + stream: bool = False, + timeout: typing.Optional[typing.Union[int, typing.Tuple]] = None, + skip_deserialization: bool = False, + ) -> typing.Union[ + ApiResponseFor200, + api_client.ApiResponseWithoutDeserialization + ]: + """ + receives a response without schema + :param skip_deserialization: If true then api_response.response will be set but + api_response.body and api_response.headers will not be deserialized into schema + class instances + """ + + _headers = HTTPHeaderDict() + # TODO add cookie handling + if accept_content_types: + for accept_content_type in accept_content_types: + _headers.add('Accept', accept_content_type) + + response = self.api_client.call_api( + resource_path=_path, + method=_method, + headers=_headers, + stream=stream, + timeout=timeout, + ) + + if skip_deserialization: + api_response = api_client.ApiResponseWithoutDeserialization(response=response) + else: + response_for_status = _status_code_to_response.get(str(response.status)) + if response_for_status: + api_response = response_for_status.deserialize(response, self.api_client.configuration) + else: + api_response = api_client.ApiResponseWithoutDeserialization(response=response) + + if not 200 <= response.status <= 299: + raise exceptions.ApiException(api_response=api_response) + + return api_response diff --git a/samples/openapi3/client/petstore/python-experimental/petstore_api/api_client.py b/samples/openapi3/client/petstore/python-experimental/petstore_api/api_client.py index 203b3d695c6..c8797cf4a6e 100644 --- a/samples/openapi3/client/petstore/python-experimental/petstore_api/api_client.py +++ b/samples/openapi3/client/petstore/python-experimental/petstore_api/api_client.py @@ -677,6 +677,7 @@ class Encoding: self.allow_reserved = allow_reserved +@dataclass class MediaType: """ Used to store request and response body schema information @@ -686,14 +687,8 @@ class MediaType: The encoding object SHALL only apply to requestBody objects when the media type is multipart or application/x-www-form-urlencoded. """ - - def __init__( - self, - schema: typing.Type[Schema], - encoding: typing.Optional[typing.Dict[str, Encoding]] = None, - ): - self.schema = schema - self.encoding = encoding + schema: typing.Optional[typing.Type[Schema]] = None + encoding: typing.Optional[typing.Dict[str, Encoding]] = None @dataclass @@ -812,7 +807,27 @@ class OpenApiResponse(JSONDetector): content_type = response.getheader('content-type') deserialized_body = unset streamed = response.supports_chunked_reads() + + deserialized_headers = unset + if self.headers is not None: + # TODO add header deserialiation here + pass + if self.content is not None: + if content_type not in self.content: + raise ApiValueError( + f'Invalid content_type={content_type} returned for response with ' + 'status_code={str(response.status)}' + ) + body_schema = self.content[content_type].schema + if body_schema is None: + # some specs do not define response content media type schemas + return self.response_cls( + response=response, + headers=deserialized_headers, + body=unset + ) + if self.content_type_is_json(content_type): body_data = self.__deserialize_json(response) elif content_type == 'application/octet-stream': @@ -822,16 +837,11 @@ class OpenApiResponse(JSONDetector): content_type = 'multipart/form-data' else: raise NotImplementedError('Deserialization of {} has not yet been implemented'.format(content_type)) - body_schema = self.content[content_type].schema deserialized_body = body_schema._from_openapi_data( body_data, _configuration=configuration) elif streamed: response.release_conn() - deserialized_headers = unset - if self.headers is not None: - deserialized_headers = unset - return self.response_cls( response=response, headers=deserialized_headers, diff --git a/samples/openapi3/client/petstore/python-experimental/tests_manual/test_fake_api.py b/samples/openapi3/client/petstore/python-experimental/tests_manual/test_fake_api.py index 47e61b83250..eb97a3c5410 100644 --- a/samples/openapi3/client/petstore/python-experimental/tests_manual/test_fake_api.py +++ b/samples/openapi3/client/petstore/python-experimental/tests_manual/test_fake_api.py @@ -70,29 +70,34 @@ class TestFakeApi(unittest.TestCase): def __assert_request_called_with( mock_request, url: str, + method: str = 'POST', body: typing.Optional[bytes] = None, - content_type: str = 'application/json', + content_type: typing.Optional[str] = 'application/json', fields: typing.Optional[tuple[api_client.RequestField, ...]] = None, accept_content_type: str = 'application/json', stream: bool = False, query_params: typing.Optional[typing.Tuple[typing.Tuple[str, str], ...]] = None ): - mock_request.assert_called_with( - 'POST', - url, - headers=HTTPHeaderDict( - { - 'Accept': accept_content_type, - 'Content-Type': content_type, - 'User-Agent': 'OpenAPI-Generator/1.0.0/python' - } - ), - body=body, + headers = { + 'Accept': accept_content_type, + 'User-Agent': 'OpenAPI-Generator/1.0.0/python' + } + if content_type: + headers['Content-Type'] = content_type + kwargs = dict( + headers=HTTPHeaderDict(headers), query_params=query_params, fields=fields, stream=stream, timeout=None, ) + if method != 'GET': + kwargs['body'] = body + mock_request.assert_called_with( + method, + url, + **kwargs + ) def test_array_model(self): from petstore_api.model import animal_farm, animal @@ -702,6 +707,37 @@ class TestFakeApi(unittest.TestCase): assert isinstance(api_response.body, schemas.NoneClass) assert api_response.body.is_none() + def test_response_without_schema(self): + # received response is not loaded into body because there is no deserialization schema defined + with patch.object(RESTClientObject, 'request') as mock_request: + body = None + content_type = 'application/json' + mock_request.return_value = self.__response( + self.__json_bytes(body), + ) + + api_response = self.api.response_without_schema() + self.__assert_request_called_with( + mock_request, + 'http://petstore.swagger.io:80/v2/fake/responseWithoutSchema', + method='GET', + accept_content_type='application/json, application/xml', + content_type=None + ) + + assert isinstance(api_response.body, schemas.Unset) + + + with patch.object(RESTClientObject, 'request') as mock_request: + mock_request.return_value = self.__response( + 'blah', + content_type='text/plain' + ) + + # when an incorrect content-type is sent back, and exception is raised + with self.assertRaises(exceptions.ApiValueError): + self.api.response_without_schema() + if __name__ == '__main__': unittest.main()