diff --git a/connexion/decorators/response.py b/connexion/decorators/response.py index 0aa58870d..2fac01831 100644 --- a/connexion/decorators/response.py +++ b/connexion/decorators/response.py @@ -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 @@ -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"] diff --git a/docs/response.rst b/docs/response.rst index 42cf1e890..dd324c619 100644 --- a/docs/response.rst +++ b/docs/response.rst @@ -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 ------------------------------- diff --git a/tests/api/test_responses.py b/tests/api/test_responses.py index afcb39c49..90d7bbc06 100644 --- a/tests/api/test_responses.py +++ b/tests/api/test_responses.py @@ -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"] diff --git a/tests/fakeapi/hello/__init__.py b/tests/fakeapi/hello/__init__.py index bac0316b3..04a74c8e9 100644 --- a/tests/fakeapi/hello/__init__.py +++ b/tests/fakeapi/hello/__init__.py @@ -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 @@ -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 diff --git a/tests/fixtures/simple/openapi.yaml b/tests/fixtures/simple/openapi.yaml index 8ea742c86..09ebe1121 100644 --- a/tests/fixtures/simple/openapi.yaml +++ b/tests/fixtures/simple/openapi.yaml @@ -1310,6 +1310,35 @@ paths: responses: '200': description: Echo the validated request. + '/status-specific-content': + 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 diff --git a/tests/fixtures/simple/swagger.yaml b/tests/fixtures/simple/swagger.yaml index 38a90f7a6..9ab60415d 100644 --- a/tests/fixtures/simple/swagger.yaml +++ b/tests/fixtures/simple/swagger.yaml @@ -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