[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();
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) {

View File

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

View File

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

View File

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

View File

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

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* | [**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

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
[**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

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.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,

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

View File

@ -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(
{
headers = {
'Accept': accept_content_type,
'Content-Type': content_type,
'User-Agent': 'OpenAPI-Generator/1.0.0/python'
}
),
body=body,
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()