Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 11 additions & 5 deletions connexion/decorators/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,21 @@ def __call__(self, function: t.Callable) -> t.Callable:

def build_framework_response(self, handler_response):
data, status_code, headers = self._unpack_handler_response(handler_response)
content_type = self._infer_content_type(data, headers)
if not self.framework.is_framework_response(data):
is_custom_response = not self.framework.is_framework_response(data)
if status_code is None and is_custom_response:
status_code = self._infer_status_code(data)
content_type = self._infer_content_type(data, status_code, headers)
if is_custom_response:
data = self._serialize_data(data, content_type=content_type)
status_code = status_code or self._infer_status_code(data)
headers = self._update_headers(headers, content_type=content_type)
return self.framework.build_response(
data, content_type=content_type, status_code=status_code, headers=headers
)

@staticmethod
def _infer_content_type(data: t.Any, headers: dict) -> t.Optional[str]:
def _infer_content_type(
data: t.Any, status_code: int, headers: dict
) -> t.Optional[str]:
"""Infer the response content type from the returned data, headers and operation spec.

:param data: Response data
Expand All @@ -50,7 +54,9 @@ def _infer_content_type(data: t.Any, headers: dict) -> t.Optional[str]:
content_type = utils.extract_content_type(headers)

# TODO: don't default
produces = list(set(operation.produces))
produces = list(
set(operation.responses.get(str(status_code), {}).get("content", {}).keys())
) or list(set(operation.produces))
if data is not None and not produces:
produces = ["application/json"]

Expand Down
29 changes: 29 additions & 0 deletions docs/response.rst
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,35 @@ Responses defined in your OpenAPI spec:
because of backward-compatibility, and can be circumvented easily by defining a response
content type in your OpenAPI specification.

Status-Code-Specific Content-Type Inference
````````````````````````````````````````````

Connexion can infer different content types based on the specific HTTP status code being returned.
This allows you to define APIs where different status codes return different content types.

For example, you can define an API where successful responses return JSON, but error responses
return plain text:

.. code-block:: yaml

responses:
'200':
description: Success response
content:
application/json:
schema:
type: object
'400':
description: Error response
content:
text/plain:
schema:
type: string

With this specification:
- A 200 response will automatically use ``application/json`` content-type
- A 400 response will automatically use ``text/plain`` content-type

Skipping response serialization
-------------------------------

Expand Down
29 changes: 29 additions & 0 deletions tests/api/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,3 +469,32 @@ def test_oneof(simple_openapi_app):
json={"name": "jsantos"},
)
assert post_greeting.status_code == 400


def test_status_specific_content_type_inference(simple_app):
app_client = simple_app.test_client()
success_response = app_client.post(
"/v1.0/status-specific-content", json={"success": True}
)
assert success_response.status_code == 200
assert success_response.headers.get("content-type") == "application/json"
response_data = success_response.json()
assert "message" in response_data
assert "Success!" in response_data["message"]
error_response = app_client.post(
"/v1.0/status-specific-content", json={"success": False}
)
assert error_response.status_code == 400
content_type = error_response.headers.get("content-type", "")
if simple_app._spec_file == "openapi.yaml":
assert content_type.startswith(
"text/plain"
), f"OpenAPI 3.0 expected text/plain, got {content_type}"
assert "Error!" in error_response.text
else:
assert (
content_type == "application/json"
), f"Swagger 2.0 expected application/json, got {content_type}"
error_data = error_response.json()
assert "error" in error_data
assert "Error!" in error_data["error"]
17 changes: 17 additions & 0 deletions tests/fakeapi/hello/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@

import flask
from connexion import NoContent, ProblemException, context, request
from connexion.context import operation
from connexion.exceptions import OAuthProblem
from connexion.operations.openapi import OpenAPIOperation
from flask import redirect, send_file
from starlette.responses import FileResponse, RedirectResponse

Expand Down Expand Up @@ -741,6 +743,21 @@ def get_streaming_response():
return FileResponse(__file__)


def status_specific_content(body):
if body.get("success", True):
return {"message": "Success! This should be application/json"}, 200
else:
if isinstance(operation, OpenAPIOperation):
return (
"Error! This should be text/plain in OpenAPI 3.0, JSON in Swagger 2.0",
400,
)
else:
return {
"error": "Error! This should be text/plain in OpenAPI 3.0, JSON in Swagger 2.0"
}, 400


async def async_route():
return {}, 200

Expand Down
29 changes: 29 additions & 0 deletions tests/fixtures/simple/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1310,6 +1310,35 @@ paths:
responses:
'200':
description: Echo the validated request.
'/status-specific-content':
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you want to leave the single quotes off this path, to match the existing style?

post:
summary: Returns different content types based on status code
operationId: fakeapi.hello.status_specific_content
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
success:
type: boolean
responses:
'200':
description: Success response with JSON content
content:
application/json:
schema:
type: object
properties:
message:
type: string
'400':
description: Error response with plain text content
content:
text/plain:
schema:
type: string
/async-route:
get:
operationId: fakeapi.hello.async_route
Expand Down
33 changes: 33 additions & 0 deletions tests/fixtures/simple/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,39 @@ paths:
schema:
type: file

/status-specific-content:
post:
summary: In Swagger 2.0, falls back to operation-level produces
operationId: fakeapi.hello.status_specific_content
consumes:
- application/json
produces:
- application/json
parameters:
- name: body
in: body
required: true
schema:
type: object
properties:
success:
type: boolean
responses:
'200':
description: Success response with JSON content
schema:
type: object
properties:
message:
type: string
'400':
description: Error response (JSON object in Swagger 2.0)
schema:
type: object
properties:
error:
type: string

/async-route:
get:
operationId: fakeapi.hello.async_route
Expand Down