[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
This commit is contained in:
Justin Black 2022-04-13 15:45:33 -07:00 committed by GitHub
parent 7851dfe148
commit 1deaaa88fe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 321 additions and 44 deletions

View File

@ -6753,7 +6753,10 @@ public class DefaultCodegen implements CodegenConfig {
} }
} }
String contentType = contentEntry.getKey(); 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); CodegenMediaType codegenMt = new CodegenMediaType(schemaProp, ceMap);
cmtContent.put(contentType, codegenMt); cmtContent.put(contentType, codegenMt);
if (schemaProp != null) { if (schemaProp != null) {

View File

@ -673,6 +673,7 @@ class Encoding:
self.allow_reserved = allow_reserved self.allow_reserved = allow_reserved
@dataclass
class MediaType: class MediaType:
""" """
Used to store request and response body schema information 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 The encoding object SHALL only apply to requestBody objects when the media type is
multipart or application/x-www-form-urlencoded. multipart or application/x-www-form-urlencoded.
""" """
schema: typing.Optional[typing.Type[Schema]] = None
def __init__( encoding: typing.Optional[typing.Dict[str, Encoding]] = None
self,
schema: typing.Type[Schema],
encoding: typing.Optional[typing.Dict[str, Encoding]] = None,
):
self.schema = schema
self.encoding = encoding
@dataclass @dataclass
@ -808,7 +803,27 @@ class OpenApiResponse(JSONDetector):
content_type = response.getheader('content-type') content_type = response.getheader('content-type')
deserialized_body = unset deserialized_body = unset
streamed = response.supports_chunked_reads() 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 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): if self.content_type_is_json(content_type):
body_data = self.__deserialize_json(response) body_data = self.__deserialize_json(response)
elif content_type == 'application/octet-stream': elif content_type == 'application/octet-stream':
@ -818,16 +833,11 @@ class OpenApiResponse(JSONDetector):
content_type = 'multipart/form-data' content_type = 'multipart/form-data'
else: else:
raise NotImplementedError('Deserialization of {} has not yet been implemented'.format(content_type)) 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( deserialized_body = body_schema._from_openapi_data(
body_data, _configuration=configuration) body_data, _configuration=configuration)
elif streamed: elif streamed:
response.release_conn() response.release_conn()
deserialized_headers = unset
if self.headers is not None:
deserialized_headers = unset
return self.response_cls( return self.response_cls(
response=response, response=response,
headers=deserialized_headers, headers=deserialized_headers,

View File

@ -175,7 +175,7 @@ default | ApiResponseForDefault | {{message}}
Name | Type | Description | Notes Name | Type | Description | Notes
------------- | ------------- | ------------- | ------------- ------------- | ------------- | ------------- | -------------
response | urllib3.HTTPResponse | Raw response | 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}} | headers | {{#unless responseHeaders}}Unset{{else}}ResponseHeadersFor{{code}}{{/unless}} | {{#unless responseHeaders}}headers were not defined{{/unless}} |
{{#each content}} {{#each content}}
{{#with this.schema}} {{#with this.schema}}

View File

@ -182,8 +182,8 @@ class RequestCookieParams(RequestRequiredCookieParams, RequestOptionalCookiePara
request_body_{{paramName}} = api_client.RequestBody( request_body_{{paramName}} = api_client.RequestBody(
content={ content={
{{#each content}} {{#each content}}
'{{{@key}}}': api_client.MediaType( '{{{@key}}}': api_client.MediaType({{#if this.schema}}
schema={{this.schema.baseName}}), schema={{this.schema.baseName}}{{/if}}),
{{/each}} {{/each}}
}, },
{{#if required}} {{#if required}}
@ -285,7 +285,11 @@ class ApiResponseFor{{code}}(api_client.ApiResponse):
{{#and responseHeaders content}} {{#and responseHeaders content}}
body: typing.Union[ body: typing.Union[
{{#each content}} {{#each content}}
{{#if this.schema}}
{{this.schema.baseName}}, {{this.schema.baseName}},
{{else}}
Unset,
{{/if}}
{{/each}} {{/each}}
] ]
headers: ResponseHeadersFor{{code}} headers: ResponseHeadersFor{{code}}
@ -297,7 +301,11 @@ class ApiResponseFor{{code}}(api_client.ApiResponse):
{{else}} {{else}}
body: typing.Union[ body: typing.Union[
{{#each content}} {{#each content}}
{{#if this.schema}}
{{this.schema.baseName}}, {{this.schema.baseName}},
{{else}}
Unset,
{{/if}}
{{/each}} {{/each}}
] ]
headers: Unset = unset headers: Unset = unset
@ -323,8 +331,8 @@ _response_for_{{code}} = api_client.OpenApiResponse(
{{#if @first}} {{#if @first}}
content={ content={
{{/if}} {{/if}}
'{{{@key}}}': api_client.MediaType( '{{{@key}}}': api_client.MediaType({{#if this.schema}}
schema={{this.schema.baseName}}), schema={{this.schema.baseName}}{{/if}}),
{{#if @last}} {{#if @last}}
}, },
{{/if}} {{/if}}

View File

@ -1546,6 +1546,18 @@ paths:
content: content:
application/json; charset=utf-8: application/json; charset=utf-8:
schema: {} 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: servers:
- url: 'http://{server}.swagger.io:{port}/v2' - url: 'http://{server}.swagger.io:{port}/v2'
description: petstore server description: petstore server

View File

@ -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* | [**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* | [**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* | [**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**](docs/FakeApi.md#string) | **POST** /fake/refs/string |
*FakeApi* | [**string_enum**](docs/FakeApi.md#string_enum) | **POST** /fake/refs/enum | *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 *FakeApi* | [**upload_download_file**](docs/FakeApi.md#upload_download_file) | **POST** /fake/uploadDownloadFile | uploads a file and downloads a file using application/octet-stream

View File

@ -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 [**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 | [**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 [**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**](FakeApi.md#string) | **POST** /fake/refs/string |
[**string_enum**](FakeApi.md#string_enum) | **POST** /fake/refs/enum | [**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 [**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 | 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) void (empty response body)
### Authorization ### Authorization

View File

@ -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.parameter_collisions import ParameterCollisions
from petstore_api.api.fake_api_endpoints.query_parameter_collection_format import QueryParameterCollectionFormat 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.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 import String
from petstore_api.api.fake_api_endpoints.string_enum import StringEnum from petstore_api.api.fake_api_endpoints.string_enum import StringEnum
from petstore_api.api.fake_api_endpoints.upload_download_file import UploadDownloadFile from petstore_api.api.fake_api_endpoints.upload_download_file import UploadDownloadFile
@ -66,6 +67,7 @@ class FakeApi(
ParameterCollisions, ParameterCollisions,
QueryParameterCollectionFormat, QueryParameterCollectionFormat,
RefObjectInQuery, RefObjectInQuery,
ResponseWithoutSchema,
String, String,
StringEnum, StringEnum,
UploadDownloadFile, UploadDownloadFile,

View File

@ -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

View File

@ -677,6 +677,7 @@ class Encoding:
self.allow_reserved = allow_reserved self.allow_reserved = allow_reserved
@dataclass
class MediaType: class MediaType:
""" """
Used to store request and response body schema information 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 The encoding object SHALL only apply to requestBody objects when the media type is
multipart or application/x-www-form-urlencoded. multipart or application/x-www-form-urlencoded.
""" """
schema: typing.Optional[typing.Type[Schema]] = None
def __init__( encoding: typing.Optional[typing.Dict[str, Encoding]] = None
self,
schema: typing.Type[Schema],
encoding: typing.Optional[typing.Dict[str, Encoding]] = None,
):
self.schema = schema
self.encoding = encoding
@dataclass @dataclass
@ -812,7 +807,27 @@ class OpenApiResponse(JSONDetector):
content_type = response.getheader('content-type') content_type = response.getheader('content-type')
deserialized_body = unset deserialized_body = unset
streamed = response.supports_chunked_reads() 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 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): if self.content_type_is_json(content_type):
body_data = self.__deserialize_json(response) body_data = self.__deserialize_json(response)
elif content_type == 'application/octet-stream': elif content_type == 'application/octet-stream':
@ -822,16 +837,11 @@ class OpenApiResponse(JSONDetector):
content_type = 'multipart/form-data' content_type = 'multipart/form-data'
else: else:
raise NotImplementedError('Deserialization of {} has not yet been implemented'.format(content_type)) 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( deserialized_body = body_schema._from_openapi_data(
body_data, _configuration=configuration) body_data, _configuration=configuration)
elif streamed: elif streamed:
response.release_conn() response.release_conn()
deserialized_headers = unset
if self.headers is not None:
deserialized_headers = unset
return self.response_cls( return self.response_cls(
response=response, response=response,
headers=deserialized_headers, headers=deserialized_headers,

View File

@ -70,29 +70,34 @@ class TestFakeApi(unittest.TestCase):
def __assert_request_called_with( def __assert_request_called_with(
mock_request, mock_request,
url: str, url: str,
method: str = 'POST',
body: typing.Optional[bytes] = None, 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, fields: typing.Optional[tuple[api_client.RequestField, ...]] = None,
accept_content_type: str = 'application/json', accept_content_type: str = 'application/json',
stream: bool = False, stream: bool = False,
query_params: typing.Optional[typing.Tuple[typing.Tuple[str, str], ...]] = None query_params: typing.Optional[typing.Tuple[typing.Tuple[str, str], ...]] = None
): ):
mock_request.assert_called_with( headers = {
'POST',
url,
headers=HTTPHeaderDict(
{
'Accept': accept_content_type, 'Accept': accept_content_type,
'Content-Type': content_type,
'User-Agent': 'OpenAPI-Generator/1.0.0/python' 'User-Agent': 'OpenAPI-Generator/1.0.0/python'
} }
), if content_type:
body=body, headers['Content-Type'] = content_type
kwargs = dict(
headers=HTTPHeaderDict(headers),
query_params=query_params, query_params=query_params,
fields=fields, fields=fields,
stream=stream, stream=stream,
timeout=None, timeout=None,
) )
if method != 'GET':
kwargs['body'] = body
mock_request.assert_called_with(
method,
url,
**kwargs
)
def test_array_model(self): def test_array_model(self):
from petstore_api.model import animal_farm, animal from petstore_api.model import animal_farm, animal
@ -702,6 +707,37 @@ class TestFakeApi(unittest.TestCase):
assert isinstance(api_response.body, schemas.NoneClass) assert isinstance(api_response.body, schemas.NoneClass)
assert api_response.body.is_none() 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__': if __name__ == '__main__':
unittest.main() unittest.main()