← oracle_full

fastapi_14953

failed WRONG_FIX UNSUBMITTED wrong_fix_unsubmitted(None) · None tool calls · 0 s · fastapi/fastapi

Task input

♻️ Fix JSON Schema for bytes, use `"contentMediaType": "application/octet-stream"` instead of `"format": "binary"`

♻️ Fix JSON Schema for bytes, use `"contentMediaType": "application/octet-stream"` instead of `"format": "binary"`

## Background

`format: binary` was defined in OpenAPI 3.0.x, in OpenAPI 3.1.x the schema was aligned with the latest JSON Schema, recommending instead `contentMediaType: application/octet-stream`.

I suspect the JSON Schema for `bytes` using `"format": "binary"` comes from my first implementation in Pydantic 1.x.

It was defined and suggested in OpenAPI 3.0.x (not in JSON Schema): https://spec.openapis.org/oas/v3.0.3.html#considerations-for-file-uploads

OpenAPI 3.1.x aligned support with JSON Schema draft 07, so it was suggested to upate file uploads to use the regular JSON Schema format: `"contentMediaType": "application/octet-stream"`: https://learn.openapis.org/upgrading/v3.0-to-v3.1

This is defined in JSON Schema 07: https://json-schema.org/draft-07/json-schema-validation#rfc.section.8.4

### JSON Schema 2020-12 Note

Now OpenAPI 3.2 is aligned with JSON Schema 2020-12, which is what Pydantic v2 implements (except for this, I'm implementing it there too).

It's the same as in JSON Schema draft 07, so this still applies: https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-01#name-contentmediatype

### Usage in JSON

JSON as a format actually doesn't support bytes, everything has to be in UTF-8 strings. Transporting bytes in JSON would require encoding bytes in a string, e.g. with base64.

But as JSON Schema is not only defined to declare JSON payloads but also payloads that could have a comparable structure and defined with JSON Schema, it's still there in the spec.

Tool calls (0)

#ToolArgumentsResult
No trace captured.

Patch

--- a/docs_src/json_base64_bytes/tutorial001_py310.py
+++ b/docs_src/json_base64_bytes/tutorial001_py310.py
@@ -0,0 +1,46 @@
+from fastapi import FastAPI
+from pydantic import BaseModel
+
+
+class DataInput(BaseModel):
+    description: str
+    data: bytes
+
+    model_config = {"val_json_bytes": "base64"}
+
+
+class DataOutput(BaseModel):
+    description: str
+    data: bytes
+
+    model_config = {"ser_json_bytes": "base64"}
+
+
+class DataInputOutput(BaseModel):
+    description: str
+    data: bytes
+
+    model_config = {
+        "val_json_bytes": "base64",
+        "ser_json_bytes": "base64",
+    }
+
+
+app = FastAPI()
+
+
+@app.post("/data")
+def post_data(body: DataInput):
+    content = body.data.decode("utf-8")
+    return {"description": body.description, "content": content}
+
+
+@app.get("/data")
+def get_data() -> DataOutput:
+    data = "hello".encode("utf-8")
+    return DataOutput(description="A plumbus", data=data)
+
+
+@app.post("/data-in-out")
+def post_data_in_out(body: DataInputOutput) -> DataInputOutput:
+    return body
--- a/fastapi/_compat/v2.py
+++ b/fastapi/_compat/v2.py
@@ -27,7 +27,7 @@
 )
 from pydantic._internal._typing_extra import eval_type_lenient
 from pydantic.fields import FieldInfo as FieldInfo
-from pydantic.json_schema import GenerateJsonSchema as GenerateJsonSchema
+from pydantic.json_schema import GenerateJsonSchema as _GenerateJsonSchema
 from pydantic.json_schema import JsonSchemaValue as JsonSchemaValue
 from pydantic_core import CoreSchema as CoreSchema
 from pydantic_core import PydanticUndefined
@@ -40,6 +40,23 @@
 Undefined = PydanticUndefined
 evaluate_forwardref = eval_type_lenient
 
+
+class GenerateJsonSchema(_GenerateJsonSchema):
+    # TODO: remove when this is merged (or equivalent): https://github.com/pydantic/pydantic/pull/12841
+    # and dropping support for any version of Pydantic before that one (so, in a very long time)
+    def bytes_schema(self, schema: CoreSchema) -> JsonSchemaValue:
+        json_schema = {"type": "string", "contentMediaType": "application/octet-stream"}
+        bytes_mode = (
+            self._config.ser_json_bytes
+            if self.mode == "serialization"
+            else self._config.val_json_bytes
+        )
+        if bytes_mode == "base64":
+            json_schema["contentEncoding"] = "base64"
+        self.update_with_validations(json_schema, schema, self.ValidationsMapping.bytes)
+        return json_schema
+
+
 # TODO: remove when dropping support for Pydantic < v2.12.3
 _Attrs = {
     "default": ...,
--- a/fastapi/datastructures.py
+++ b/fastapi/datastructures.py
@@ -139,7 +139,7 @@ def _validate(cls, __input_value: Any, _: Any) -> "UploadFile":
     def __get_pydantic_json_schema__(
         cls, core_schema: Mapping[str, Any], handler: GetJsonSchemaHandler
     ) -> dict[str, Any]:
-        return {"type": "string", "format": "binary"}
+        return {"type": "string", "contentMediaType": "application/octet-stream"}
 
     @classmethod
     def __get_pydantic_core_schema__(
--- a/scripts/playwright/json_base64_bytes/image01.py
+++ b/scripts/playwright/json_base64_bytes/image01.py
@@ -0,0 +1,37 @@
+import subprocess
+import time
+
+import httpx
+from playwright.sync_api import Playwright, sync_playwright
+
+
+# Run playwright codegen to generate the code below, copy paste the sections in run()
+def run(playwright: Playwright) -> None:
+    browser = playwright.chromium.launch(headless=False)
+    # Update the viewport manually
+    context = browser.new_context(viewport={"width": 960, "height": 1080})
+    page = context.new_page()
+    page.goto("http://localhost:8000/docs")
+    page.get_by_role("button", name="POST /data Post Data").click()
+    # Manually add the screenshot
+    page.screenshot(path="docs/en/docs/img/tutorial/json-base64-bytes/image01.png")
+
+    # ---------------------
+    context.close()
+    browser.close()
+
+
+process = subprocess.Popen(
+    ["fastapi", "run", "docs_src/json_base64_bytes/tutorial001_py310.py"]
+)
+try:
+    for _ in range(3):
+        try:
+            response = httpx.get("http://localhost:8000/docs")
+        except httpx.ConnectError:
+            time.sleep(1)
+            break
+    with sync_playwright() as playwright:
+        run(playwright)
+finally:
+    process.terminate()

Test output

show
==================================== ERRORS ====================================
______ ERROR collecting tests/test_request_params/test_file/test_list.py _______
tests/test_request_params/test_file/test_list.py:15: in <module>
    @app.post("/list-bytes", operation_id="list_bytes")
     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
fastapi/routing.py:1125: in decorator
    self.add_api_route(
fastapi/routing.py:1064: in add_api_route
    route = route_class(
fastapi/routing.py:665: in __init__
    self.dependant = get_dependant(
fastapi/dependencies/utils.py:279: in get_dependant
    param_details = analyze_param(
fastapi/dependencies/utils.py:502: in analyze_param
    ensure_multipart_is_installed()
fastapi/dependencies/utils.py:108: in ensure_multipart_is_installed
    raise RuntimeError(multipart_not_installed_error) from None
E   RuntimeError: Form data requires "python-multipart" to be installed. 
E   You can install "python-multipart" with: 
E   
E   pip install python-multipart
=============================== warnings summary ===============================
../../../../../../../Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.52.1-py3-none-any/starlette/testclient.py:45
  /Users/jp/repos/kaggle-gemini-coding-agent-post-training/.envs/overlays/starlette-0.52.1-py3-none-any/starlette/testclient.py:45: DeprecationWarning: The anyio.abc.BlockingPortal alias is deprecated, use anyio.from_thread.BlockingPortal instead.
    _PortalFactoryType = Callable[[], AbstractContextManager[anyio.abc.BlockingPortal]]

-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
!!!!!!!!!!!!!!!!!!!!!!!!!! stopping after 1 failures !!!!!!!!!!!!!!!!!!!!!!!!!!!
1 warning, 1 error in 0.68s