fastapi-uploadstream 0.1.0rc1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fastapi_uploadstream-0.1.0rc1/LICENSE +21 -0
- fastapi_uploadstream-0.1.0rc1/PKG-INFO +169 -0
- fastapi_uploadstream-0.1.0rc1/README.md +138 -0
- fastapi_uploadstream-0.1.0rc1/pyproject.toml +77 -0
- fastapi_uploadstream-0.1.0rc1/src/fastapi_uploadstream/__init__.py +511 -0
- fastapi_uploadstream-0.1.0rc1/src/fastapi_uploadstream/py.typed +0 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT Licence
|
|
2
|
+
|
|
3
|
+
Copyright 2026 whichoneiwonder
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi_uploadstream
|
|
3
|
+
Version: 0.1.0rc1
|
|
4
|
+
Summary: 3rd party extension for streaming uploads in FastAPI
|
|
5
|
+
Keywords: fastapi,upload,streaming,http,asynchronous
|
|
6
|
+
Author-email: whichoneiwonder <whichoneiwonder@github.com>
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Web Environment
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Framework :: FastAPI
|
|
24
|
+
Classifier: Framework :: AnyIO
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: anyio>=3.6.2,<5
|
|
27
|
+
Requires-Dist: fastapi>=0.116.1
|
|
28
|
+
Requires-Dist: starlette>=1.0.1
|
|
29
|
+
Requires-Dist: typing-extensions>=4.14.1; python_version < '3.11'
|
|
30
|
+
|
|
31
|
+
# [UploadStream](https://whichoneiwonder.github.io/fastapi_uploadstream)
|
|
32
|
+
|
|
33
|
+
Streaming raw request-body helpers for FastAPI that enable efficient handling of large binary uploads without multipart complexity.
|
|
34
|
+
|
|
35
|
+
This is a third-party project, not created by the FastAPI team.
|
|
36
|
+
|
|
37
|
+
## Features
|
|
38
|
+
|
|
39
|
+
- **Streaming uploads** - Handle large files without loading them entirely into memory
|
|
40
|
+
- **Binary body support** - Accept raw binary data directly in request bodies
|
|
41
|
+
- **Content-Length aware** - Automatically extract file size from request headers
|
|
42
|
+
- **OpenAPI documentation** - Auto-generate schema documentation for streaming endpoints
|
|
43
|
+
- **File-like interface** - Use familiar `read()` methods for consuming streams
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
## Installation
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install fastapi_uploadstream
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Quick Start
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from typing import Annotated
|
|
56
|
+
from fastapi import FastAPI
|
|
57
|
+
from fastapi_uploadstream import UploadStream, StreamBody, install_uploadstream_openapi
|
|
58
|
+
|
|
59
|
+
app = FastAPI()
|
|
60
|
+
install_uploadstream_openapi(app)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@app.post("/upload")
|
|
64
|
+
async def upload_file(
|
|
65
|
+
body_content: Annotated[
|
|
66
|
+
UploadStream,
|
|
67
|
+
StreamBody(
|
|
68
|
+
media_types=["application/octet-stream"],
|
|
69
|
+
title="File upload",
|
|
70
|
+
),
|
|
71
|
+
],
|
|
72
|
+
):
|
|
73
|
+
# Read the streamed content
|
|
74
|
+
data = await body_content.read()
|
|
75
|
+
return {
|
|
76
|
+
"size": body_content.size,
|
|
77
|
+
"content_type": body_content.content_type,
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Usage
|
|
82
|
+
|
|
83
|
+
### StreamBody Dependency
|
|
84
|
+
|
|
85
|
+
The `StreamBody` dependency configures how your endpoint accepts streaming uploads:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
StreamBody(
|
|
89
|
+
media_types=["application/octet-stream", "text/plain"], # Accepted content types
|
|
90
|
+
include_in_schema=True, # Include in OpenAPI schema
|
|
91
|
+
title="Upload content", # OpenAPI title
|
|
92
|
+
)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### UploadStream Object
|
|
96
|
+
|
|
97
|
+
Once received, you interact with the stream through `UploadStream`:
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
@app.post("/upload")
|
|
101
|
+
async def handle_upload(body: Annotated[UploadStream, StreamBody(media_types=["*/*"])]):
|
|
102
|
+
# Access file metadata
|
|
103
|
+
print(f"Size: {body.size}") # From Content-Length header
|
|
104
|
+
print(f"Type: {body.content_type}")
|
|
105
|
+
print(f"Name: {body.filename}") # From x-filename header
|
|
106
|
+
|
|
107
|
+
# Read the stream in chunks
|
|
108
|
+
chunk = await body.read(1024) # Read up to 1024 bytes
|
|
109
|
+
|
|
110
|
+
# Read remaining data at once
|
|
111
|
+
remaining = await body.read() # Read all remaining data
|
|
112
|
+
|
|
113
|
+
# Read everything (resets on first call)
|
|
114
|
+
all_data = await body.read(-1) # Read all from beginning
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### OpenAPI Documentation
|
|
118
|
+
|
|
119
|
+
Call `install_uploadstream_openapi()` to add automatic schema documentation:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
app = FastAPI()
|
|
123
|
+
install_uploadstream_openapi(app)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
This adds proper OpenAPI definitions for binary request bodies on your streaming endpoints.
|
|
127
|
+
|
|
128
|
+
## When to Use
|
|
129
|
+
|
|
130
|
+
### ✅ Use UploadStream for:
|
|
131
|
+
- Large file uploads (to avoid memory overhead)
|
|
132
|
+
- Proxying streaming content to a backend API or storage
|
|
133
|
+
- Binary data streams
|
|
134
|
+
- Direct binary body requests
|
|
135
|
+
- Maintaining Compatibility with non-form-based legacy endpoints
|
|
136
|
+
|
|
137
|
+
### ❌ Use FastAPI's UploadFile for:
|
|
138
|
+
- Multipart form uploads
|
|
139
|
+
- Multiple files in one request
|
|
140
|
+
- Form fields + file combinations
|
|
141
|
+
|
|
142
|
+
## Example: File Upload with Size Tracking
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
@app.post("/upload-large")
|
|
146
|
+
async def upload_large_file(body: Annotated[UploadStream, StreamBody()]):
|
|
147
|
+
bytes_received = 0
|
|
148
|
+
chunk_size = 65536 # 64KB chunks
|
|
149
|
+
|
|
150
|
+
while True:
|
|
151
|
+
chunk = await body.read(chunk_size)
|
|
152
|
+
if not chunk:
|
|
153
|
+
break
|
|
154
|
+
bytes_received += len(chunk)
|
|
155
|
+
# Process chunk...
|
|
156
|
+
|
|
157
|
+
return {"total_bytes": bytes_received}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Requirements
|
|
161
|
+
|
|
162
|
+
- Python 3.10+
|
|
163
|
+
- FastAPI 0.116.1+
|
|
164
|
+
- anyio (for async streaming primitives)
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
169
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# [UploadStream](https://whichoneiwonder.github.io/fastapi_uploadstream)
|
|
2
|
+
|
|
3
|
+
Streaming raw request-body helpers for FastAPI that enable efficient handling of large binary uploads without multipart complexity.
|
|
4
|
+
|
|
5
|
+
This is a third-party project, not created by the FastAPI team.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Streaming uploads** - Handle large files without loading them entirely into memory
|
|
10
|
+
- **Binary body support** - Accept raw binary data directly in request bodies
|
|
11
|
+
- **Content-Length aware** - Automatically extract file size from request headers
|
|
12
|
+
- **OpenAPI documentation** - Auto-generate schema documentation for streaming endpoints
|
|
13
|
+
- **File-like interface** - Use familiar `read()` methods for consuming streams
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install fastapi_uploadstream
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick Start
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from typing import Annotated
|
|
26
|
+
from fastapi import FastAPI
|
|
27
|
+
from fastapi_uploadstream import UploadStream, StreamBody, install_uploadstream_openapi
|
|
28
|
+
|
|
29
|
+
app = FastAPI()
|
|
30
|
+
install_uploadstream_openapi(app)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.post("/upload")
|
|
34
|
+
async def upload_file(
|
|
35
|
+
body_content: Annotated[
|
|
36
|
+
UploadStream,
|
|
37
|
+
StreamBody(
|
|
38
|
+
media_types=["application/octet-stream"],
|
|
39
|
+
title="File upload",
|
|
40
|
+
),
|
|
41
|
+
],
|
|
42
|
+
):
|
|
43
|
+
# Read the streamed content
|
|
44
|
+
data = await body_content.read()
|
|
45
|
+
return {
|
|
46
|
+
"size": body_content.size,
|
|
47
|
+
"content_type": body_content.content_type,
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
### StreamBody Dependency
|
|
54
|
+
|
|
55
|
+
The `StreamBody` dependency configures how your endpoint accepts streaming uploads:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
StreamBody(
|
|
59
|
+
media_types=["application/octet-stream", "text/plain"], # Accepted content types
|
|
60
|
+
include_in_schema=True, # Include in OpenAPI schema
|
|
61
|
+
title="Upload content", # OpenAPI title
|
|
62
|
+
)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### UploadStream Object
|
|
66
|
+
|
|
67
|
+
Once received, you interact with the stream through `UploadStream`:
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
@app.post("/upload")
|
|
71
|
+
async def handle_upload(body: Annotated[UploadStream, StreamBody(media_types=["*/*"])]):
|
|
72
|
+
# Access file metadata
|
|
73
|
+
print(f"Size: {body.size}") # From Content-Length header
|
|
74
|
+
print(f"Type: {body.content_type}")
|
|
75
|
+
print(f"Name: {body.filename}") # From x-filename header
|
|
76
|
+
|
|
77
|
+
# Read the stream in chunks
|
|
78
|
+
chunk = await body.read(1024) # Read up to 1024 bytes
|
|
79
|
+
|
|
80
|
+
# Read remaining data at once
|
|
81
|
+
remaining = await body.read() # Read all remaining data
|
|
82
|
+
|
|
83
|
+
# Read everything (resets on first call)
|
|
84
|
+
all_data = await body.read(-1) # Read all from beginning
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### OpenAPI Documentation
|
|
88
|
+
|
|
89
|
+
Call `install_uploadstream_openapi()` to add automatic schema documentation:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
app = FastAPI()
|
|
93
|
+
install_uploadstream_openapi(app)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This adds proper OpenAPI definitions for binary request bodies on your streaming endpoints.
|
|
97
|
+
|
|
98
|
+
## When to Use
|
|
99
|
+
|
|
100
|
+
### ✅ Use UploadStream for:
|
|
101
|
+
- Large file uploads (to avoid memory overhead)
|
|
102
|
+
- Proxying streaming content to a backend API or storage
|
|
103
|
+
- Binary data streams
|
|
104
|
+
- Direct binary body requests
|
|
105
|
+
- Maintaining Compatibility with non-form-based legacy endpoints
|
|
106
|
+
|
|
107
|
+
### ❌ Use FastAPI's UploadFile for:
|
|
108
|
+
- Multipart form uploads
|
|
109
|
+
- Multiple files in one request
|
|
110
|
+
- Form fields + file combinations
|
|
111
|
+
|
|
112
|
+
## Example: File Upload with Size Tracking
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
@app.post("/upload-large")
|
|
116
|
+
async def upload_large_file(body: Annotated[UploadStream, StreamBody()]):
|
|
117
|
+
bytes_received = 0
|
|
118
|
+
chunk_size = 65536 # 64KB chunks
|
|
119
|
+
|
|
120
|
+
while True:
|
|
121
|
+
chunk = await body.read(chunk_size)
|
|
122
|
+
if not chunk:
|
|
123
|
+
break
|
|
124
|
+
bytes_received += len(chunk)
|
|
125
|
+
# Process chunk...
|
|
126
|
+
|
|
127
|
+
return {"total_bytes": bytes_received}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Requirements
|
|
131
|
+
|
|
132
|
+
- Python 3.10+
|
|
133
|
+
- FastAPI 0.116.1+
|
|
134
|
+
- anyio (for async streaming primitives)
|
|
135
|
+
|
|
136
|
+
## License
|
|
137
|
+
|
|
138
|
+
MIT
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "fastapi_uploadstream"
|
|
3
|
+
version = "0.1.0rc1"
|
|
4
|
+
description = "3rd party extension for streaming uploads in FastAPI"
|
|
5
|
+
license = 'MIT'
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
authors = [{ name = "whichoneiwonder", email = "whichoneiwonder@github.com" }]
|
|
8
|
+
requires-python = ">=3.10"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"anyio>=3.6.2,<5",
|
|
11
|
+
"fastapi>=0.116.1",
|
|
12
|
+
"starlette>=1.0.1",
|
|
13
|
+
"typing-extensions>=4.14.1; python_version < '3.11'",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Environment :: Web Environment",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Programming Language :: Python :: 3.14",
|
|
27
|
+
"Programming Language :: Python :: 3.15",
|
|
28
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
29
|
+
"Framework :: FastAPI",
|
|
30
|
+
"Framework :: AnyIO",
|
|
31
|
+
]
|
|
32
|
+
keywords = [
|
|
33
|
+
"fastapi",
|
|
34
|
+
"upload",
|
|
35
|
+
"streaming",
|
|
36
|
+
"http",
|
|
37
|
+
"asynchronous",
|
|
38
|
+
]
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["flit_core>=3.2,<4"]
|
|
41
|
+
build-backend = "flit_core.buildapi"
|
|
42
|
+
|
|
43
|
+
[dependency-groups]
|
|
44
|
+
dev = [
|
|
45
|
+
"httpx>=0.28.1",
|
|
46
|
+
"pytest>=9.0.3",
|
|
47
|
+
"python-multipart>=0.0.28",
|
|
48
|
+
"ruff>=0.12.7",
|
|
49
|
+
"trio>=0.33.0",
|
|
50
|
+
]
|
|
51
|
+
standard = [
|
|
52
|
+
"fastapi[standard]>=0.116.1",
|
|
53
|
+
]
|
|
54
|
+
docs = [
|
|
55
|
+
"mkdocs-material>=9.7.6",
|
|
56
|
+
"mkdocstrings-python>=2.0.3",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff]
|
|
60
|
+
line-length = 120
|
|
61
|
+
lint.select = [
|
|
62
|
+
"E",
|
|
63
|
+
"F",
|
|
64
|
+
"W",
|
|
65
|
+
"C90",
|
|
66
|
+
"B",
|
|
67
|
+
"B9",
|
|
68
|
+
"I",
|
|
69
|
+
"Q",
|
|
70
|
+
"S",
|
|
71
|
+
"T",
|
|
72
|
+
"ERA",
|
|
73
|
+
"ANN",
|
|
74
|
+
"FAST",
|
|
75
|
+
"RUF",
|
|
76
|
+
]
|
|
77
|
+
lint.per-file-ignores = { "tests/**.py" = ["S101"] }
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""Streaming raw request-body helpers for FastAPI.
|
|
2
|
+
|
|
3
|
+
This package provides a dependency-first runtime wrapper plus an optional
|
|
4
|
+
OpenAPI hook that documents those dependencies as binary request bodies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import AsyncIterator, Callable, Iterable
|
|
9
|
+
from typing import Any, Literal
|
|
10
|
+
|
|
11
|
+
from anyio import EndOfStream, create_memory_object_stream, create_task_group
|
|
12
|
+
from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
|
|
13
|
+
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
|
14
|
+
from fastapi.params import Body as BodyFieldInfo
|
|
15
|
+
from fastapi.params import File as FileFieldInfo
|
|
16
|
+
from fastapi.params import Form as FormFieldInfo
|
|
17
|
+
from fastapi.routing import APIRoute
|
|
18
|
+
from starlette.requests import ClientDisconnect
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def size_from_request(request: Request) -> int | None:
|
|
22
|
+
"""Return the request Content-Length header as an integer when possible."""
|
|
23
|
+
if content_size := request.headers.get("content-length"):
|
|
24
|
+
try:
|
|
25
|
+
return int(content_size)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return None
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_media_types(media_types: str | Iterable[str]) -> tuple[str, ...]:
|
|
32
|
+
"""Normalize media type strings to a deduplicated tuple.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
media_types: A single media type string or iterable of media type strings.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A tuple of normalized media types, or ("*/*",) if no valid types provided.
|
|
39
|
+
"""
|
|
40
|
+
if isinstance(media_types, str):
|
|
41
|
+
normalized = [media_types.strip()]
|
|
42
|
+
else:
|
|
43
|
+
normalized = [value.strip() for value in media_types if value.strip()]
|
|
44
|
+
|
|
45
|
+
if not normalized:
|
|
46
|
+
return ("*/*",)
|
|
47
|
+
|
|
48
|
+
return tuple(dict.fromkeys(normalized))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _media_type_matches(content_type: str, allowed_media_type: str) -> bool:
|
|
52
|
+
"""Check if a content type matches an allowed media type pattern.
|
|
53
|
+
|
|
54
|
+
Supports wildcard matching for type patterns like "application/*".
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
content_type: The actual content type from the request.
|
|
58
|
+
allowed_media_type: The pattern to match against (may include "*").
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if the content type matches the allowed pattern.
|
|
62
|
+
"""
|
|
63
|
+
if allowed_media_type == "*/*":
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
content_base = content_type.split(";", 1)[0].strip().lower()
|
|
67
|
+
allowed_base = allowed_media_type.strip().lower()
|
|
68
|
+
|
|
69
|
+
if allowed_base.endswith("/*"):
|
|
70
|
+
prefix = allowed_base[:-1]
|
|
71
|
+
return content_base.startswith(prefix)
|
|
72
|
+
|
|
73
|
+
return content_base == allowed_base
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UploadStream:
|
|
77
|
+
"""A streaming view of a raw request body.
|
|
78
|
+
|
|
79
|
+
This object intentionally mimics only the subset of UploadFile behavior that
|
|
80
|
+
is meaningful for one-pass, non-multipart request streaming.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
def __init__(
|
|
84
|
+
self,
|
|
85
|
+
request: Request,
|
|
86
|
+
receiver: MemoryObjectReceiveStream[bytes | ClientDisconnect],
|
|
87
|
+
cancel_receive: Callable[[], object],
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Wrap the incoming request stream as a file-like binary reader."""
|
|
90
|
+
self.request = request
|
|
91
|
+
self.filename = request.headers.get("x-filename")
|
|
92
|
+
self.size = size_from_request(request)
|
|
93
|
+
self.content_type = request.headers.get("content-type", "*/*")
|
|
94
|
+
|
|
95
|
+
self._receiver = receiver
|
|
96
|
+
self._cancel_receive = cancel_receive
|
|
97
|
+
self._buffer = bytearray()
|
|
98
|
+
self._eof = False
|
|
99
|
+
self._closed = False
|
|
100
|
+
|
|
101
|
+
async def _pull_chunk(self) -> bytes:
|
|
102
|
+
"""Pull the next chunk from the receive stream.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
The next chunk of bytes, or empty bytes if EOF is reached.
|
|
106
|
+
"""
|
|
107
|
+
if self._eof:
|
|
108
|
+
return b""
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
item = await self._receiver.receive()
|
|
112
|
+
if isinstance(item, ClientDisconnect):
|
|
113
|
+
raise item
|
|
114
|
+
return item
|
|
115
|
+
except EndOfStream:
|
|
116
|
+
self._eof = True
|
|
117
|
+
return b""
|
|
118
|
+
|
|
119
|
+
async def read(self, size: int = -1) -> bytes:
|
|
120
|
+
"""Read bytes from the streamed request body."""
|
|
121
|
+
if self._closed:
|
|
122
|
+
return b""
|
|
123
|
+
|
|
124
|
+
if size == 0:
|
|
125
|
+
return b""
|
|
126
|
+
if size < -1:
|
|
127
|
+
raise ValueError("size must be >= -1")
|
|
128
|
+
|
|
129
|
+
if size == -1:
|
|
130
|
+
chunks = [bytes(self._buffer)]
|
|
131
|
+
self._buffer.clear()
|
|
132
|
+
while True:
|
|
133
|
+
chunk = await self._pull_chunk()
|
|
134
|
+
if not chunk:
|
|
135
|
+
break
|
|
136
|
+
chunks.append(chunk)
|
|
137
|
+
return b"".join(chunks)
|
|
138
|
+
|
|
139
|
+
while len(self._buffer) < size and not self._eof:
|
|
140
|
+
chunk = await self._pull_chunk()
|
|
141
|
+
if not chunk:
|
|
142
|
+
break
|
|
143
|
+
self._buffer.extend(chunk)
|
|
144
|
+
|
|
145
|
+
if not self._buffer:
|
|
146
|
+
return b""
|
|
147
|
+
|
|
148
|
+
out = bytes(self._buffer[:size])
|
|
149
|
+
del self._buffer[:size]
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
async def seek(self, offset: int) -> None:
|
|
153
|
+
"""Advance the stream by consuming and discarding bytes.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
offset: Number of bytes to advance. Must be non-negative.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
ValueError: If offset is negative.
|
|
160
|
+
"""
|
|
161
|
+
if offset < 0:
|
|
162
|
+
raise ValueError(f"Negative seek position: {offset}")
|
|
163
|
+
if offset == 0:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
remaining = offset
|
|
167
|
+
while remaining > 0:
|
|
168
|
+
chunk = await self.read(remaining)
|
|
169
|
+
if not chunk:
|
|
170
|
+
break
|
|
171
|
+
remaining -= len(chunk)
|
|
172
|
+
|
|
173
|
+
async def iter_chunks(self, chunk_size: int = 64 * 1024) -> AsyncIterator[bytes]:
|
|
174
|
+
"""Yield the request body in chunks of specified size.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
chunk_size: Size of each chunk in bytes. Defaults to 64KB. Must be positive.
|
|
178
|
+
|
|
179
|
+
Yields:
|
|
180
|
+
Chunks of bytes until EOF.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: If chunk_size is not positive.
|
|
184
|
+
"""
|
|
185
|
+
if chunk_size <= 0:
|
|
186
|
+
raise ValueError("chunk_size must be > 0")
|
|
187
|
+
|
|
188
|
+
while True:
|
|
189
|
+
chunk = await self.read(chunk_size)
|
|
190
|
+
if not chunk:
|
|
191
|
+
break
|
|
192
|
+
yield chunk
|
|
193
|
+
|
|
194
|
+
async def close(self) -> None:
|
|
195
|
+
"""Stop background request pumping and close the receive stream."""
|
|
196
|
+
if self._closed:
|
|
197
|
+
return
|
|
198
|
+
|
|
199
|
+
self._closed = True
|
|
200
|
+
self._cancel_receive()
|
|
201
|
+
await self._receiver.aclose()
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class StreamBodyParam(BodyFieldInfo):
|
|
205
|
+
"""Callable dependency wrapper used with StreamBody(...)."""
|
|
206
|
+
|
|
207
|
+
def __init__(
|
|
208
|
+
self,
|
|
209
|
+
*,
|
|
210
|
+
media_types: str | Iterable[str] = "*/*",
|
|
211
|
+
title: str | None = None,
|
|
212
|
+
description: str | None = None,
|
|
213
|
+
examples: list[Any] | None = None,
|
|
214
|
+
openapi_examples: dict[str, Any] | None = None,
|
|
215
|
+
deprecated: bool | str | None = None,
|
|
216
|
+
include_in_schema: bool = True,
|
|
217
|
+
json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None,
|
|
218
|
+
channel_buffer_size: int = 2048,
|
|
219
|
+
client_disconnect: Literal["eof", "raise"] = "raise",
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Capture runtime and documentation settings for a raw body dependency."""
|
|
222
|
+
self.media_types = _normalize_media_types(media_types)
|
|
223
|
+
self.channel_buffer_size = channel_buffer_size
|
|
224
|
+
self.client_disconnect = client_disconnect
|
|
225
|
+
super().__init__(
|
|
226
|
+
title=title,
|
|
227
|
+
description=description,
|
|
228
|
+
examples=examples,
|
|
229
|
+
openapi_examples=openapi_examples,
|
|
230
|
+
deprecated=deprecated,
|
|
231
|
+
include_in_schema=include_in_schema,
|
|
232
|
+
json_schema_extra=json_schema_extra or {},
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
async def __call__(self, request: Request) -> AsyncIterator[UploadStream]:
|
|
236
|
+
"""Yield a streaming file-like wrapper over the incoming request body."""
|
|
237
|
+
self._validate_content_type(request)
|
|
238
|
+
|
|
239
|
+
send_stream: MemoryObjectSendStream[bytes | ClientDisconnect]
|
|
240
|
+
recv_stream: MemoryObjectReceiveStream[bytes | ClientDisconnect]
|
|
241
|
+
send_stream, recv_stream = create_memory_object_stream[bytes | ClientDisconnect](self.channel_buffer_size)
|
|
242
|
+
|
|
243
|
+
async with create_task_group() as task_group:
|
|
244
|
+
upload = UploadStream(
|
|
245
|
+
request=request,
|
|
246
|
+
receiver=recv_stream,
|
|
247
|
+
cancel_receive=task_group.cancel_scope.cancel,
|
|
248
|
+
)
|
|
249
|
+
task_group.start_soon(self._recv_from_request, request, send_stream)
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
yield upload
|
|
253
|
+
finally:
|
|
254
|
+
await upload.close()
|
|
255
|
+
|
|
256
|
+
async def _recv_from_request(
|
|
257
|
+
self,
|
|
258
|
+
request: Request,
|
|
259
|
+
sender: MemoryObjectSendStream[bytes | ClientDisconnect],
|
|
260
|
+
) -> None:
|
|
261
|
+
"""Pump request body chunks into the send stream.
|
|
262
|
+
|
|
263
|
+
Runs as a background task that continuously reads from the request
|
|
264
|
+
and forwards chunks to the send stream until the body is exhausted.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
request: The incoming HTTP request.
|
|
268
|
+
sender: The send stream to forward chunks to.
|
|
269
|
+
"""
|
|
270
|
+
async with sender:
|
|
271
|
+
try:
|
|
272
|
+
async for chunk in request.stream():
|
|
273
|
+
if chunk:
|
|
274
|
+
await sender.send(chunk)
|
|
275
|
+
except ClientDisconnect:
|
|
276
|
+
if self.client_disconnect == "raise":
|
|
277
|
+
await sender.send(ClientDisconnect())
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
def _validate_content_type(self, request: Request) -> None:
|
|
281
|
+
"""Validate that the request content type matches allowed media types.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
request: The incoming HTTP request.
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
HTTPException: If content type is not in the allowed media types.
|
|
288
|
+
"""
|
|
289
|
+
content_type = request.headers.get("content-type", "*/*")
|
|
290
|
+
|
|
291
|
+
if any(_media_type_matches(content_type, allowed) for allowed in self.media_types):
|
|
292
|
+
return
|
|
293
|
+
|
|
294
|
+
allowed = ", ".join(self.media_types)
|
|
295
|
+
raise HTTPException(
|
|
296
|
+
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
|
297
|
+
detail=(f"Unsupported content-type '{content_type}'. Expected one of: {allowed}"),
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def openapi_request_body(self) -> dict[str, Any]:
|
|
301
|
+
"""Return an OpenAPI requestBody block for a binary upload."""
|
|
302
|
+
content: dict[str, Any] = {}
|
|
303
|
+
|
|
304
|
+
for media_type in self.media_types:
|
|
305
|
+
schema: dict[str, Any] = {"type": "string", "format": "binary"}
|
|
306
|
+
|
|
307
|
+
if self.title is not None:
|
|
308
|
+
schema["title"] = self.title
|
|
309
|
+
if self.deprecated:
|
|
310
|
+
schema["deprecated"] = True
|
|
311
|
+
if isinstance(self.json_schema_extra, Callable):
|
|
312
|
+
warnings.warn(
|
|
313
|
+
message=UserWarning(
|
|
314
|
+
"json_schema_extra callable overrides StreamBody annotations; "
|
|
315
|
+
"use a dict instead for compatibility"
|
|
316
|
+
),
|
|
317
|
+
stacklevel=2,
|
|
318
|
+
)
|
|
319
|
+
self.json_schema_extra(schema)
|
|
320
|
+
elif isinstance(self.json_schema_extra, dict):
|
|
321
|
+
schema.update(dict(self.json_schema_extra))
|
|
322
|
+
|
|
323
|
+
media_type_obj: dict[str, Any] = {"schema": schema}
|
|
324
|
+
|
|
325
|
+
if self.openapi_examples is not None:
|
|
326
|
+
media_type_obj["examples"] = self.openapi_examples
|
|
327
|
+
elif self.examples is not None and self.examples:
|
|
328
|
+
media_type_obj["examples"] = {f"example-{i}": {"value": v} for i, v in enumerate(self.examples)}
|
|
329
|
+
|
|
330
|
+
if self.example is not None and "examples" not in media_type_obj:
|
|
331
|
+
media_type_obj["example"] = self.example
|
|
332
|
+
|
|
333
|
+
content[media_type] = media_type_obj
|
|
334
|
+
|
|
335
|
+
request_body: dict[str, Any] = {
|
|
336
|
+
"content": content,
|
|
337
|
+
}
|
|
338
|
+
if self.description is not None:
|
|
339
|
+
request_body["description"] = self.description
|
|
340
|
+
|
|
341
|
+
return request_body
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def StreamBody(
|
|
345
|
+
*,
|
|
346
|
+
media_types: str | list[str] = "*/*",
|
|
347
|
+
title: str | None = None,
|
|
348
|
+
description: str | None = None,
|
|
349
|
+
examples: list[Any] | None = None,
|
|
350
|
+
openapi_examples: dict[str, Any] | None = None,
|
|
351
|
+
deprecated: bool | str | None = None,
|
|
352
|
+
include_in_schema: bool = True,
|
|
353
|
+
json_schema_extra: dict[str, Any] | Callable[[dict[str, Any]], None] | None = None,
|
|
354
|
+
channel_buffer_size: int = 2048,
|
|
355
|
+
client_disconnect: Literal["eof", "raise"] = "raise",
|
|
356
|
+
) -> StreamBodyParam:
|
|
357
|
+
"""Create a dependency object for streamed raw body uploads."""
|
|
358
|
+
return Depends(
|
|
359
|
+
StreamBodyParam(
|
|
360
|
+
media_types=media_types,
|
|
361
|
+
title=title,
|
|
362
|
+
description=description,
|
|
363
|
+
examples=examples,
|
|
364
|
+
openapi_examples=openapi_examples,
|
|
365
|
+
deprecated=deprecated,
|
|
366
|
+
include_in_schema=include_in_schema,
|
|
367
|
+
json_schema_extra=json_schema_extra,
|
|
368
|
+
channel_buffer_size=channel_buffer_size,
|
|
369
|
+
client_disconnect=client_disconnect,
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def install_uploadstream_openapi(app: FastAPI) -> FastAPI:
|
|
375
|
+
"""Teach FastAPI's OpenAPI generator about StreamBody dependencies."""
|
|
376
|
+
# TODO: Add an APIRouter helper that auto-installs this hook the first time a
|
|
377
|
+
# router with StreamBody dependencies is included in an app. A practical shape
|
|
378
|
+
# is `StreamBodyRouter(APIRouter)` plus `include_streambody_router(app, router)`
|
|
379
|
+
# that calls `install_uploadstream_openapi(app)` once (guarded by a private flag)
|
|
380
|
+
# before delegating to `app.include_router(router)`.
|
|
381
|
+
original_openapi = app.openapi
|
|
382
|
+
|
|
383
|
+
def custom_openapi() -> dict[str, Any]:
|
|
384
|
+
if app.openapi_schema is not None:
|
|
385
|
+
return app.openapi_schema
|
|
386
|
+
|
|
387
|
+
openapi_schema = original_openapi()
|
|
388
|
+
_inject_streambody_openapi(app, openapi_schema)
|
|
389
|
+
app.openapi_schema = openapi_schema
|
|
390
|
+
return app.openapi_schema
|
|
391
|
+
|
|
392
|
+
app.openapi = custom_openapi # type: ignore[method-assign]
|
|
393
|
+
return app
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _check_streambody_conflicts(route: APIRoute, stream_bodies: list[StreamBodyParam]) -> None:
|
|
397
|
+
"""Raise ValueError if a StreamBody dependency coexists with Body/Form/UploadFile params.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
route: The API route to validate.
|
|
401
|
+
|
|
402
|
+
Raises:
|
|
403
|
+
ValueError: If incompatible parameter types are mixed on the same endpoint.
|
|
404
|
+
"""
|
|
405
|
+
if len(stream_bodies) > 1:
|
|
406
|
+
raise ValueError(
|
|
407
|
+
f"Multiple StreamBody dependencies found on route {route.path_format}. "
|
|
408
|
+
f"Only one StreamBodyParam is allowed per endpoint to avoid OpenAPI conflicts."
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
conflicting = route.dependant.body_params
|
|
412
|
+
if not conflicting:
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
def _param_kind(field_info: object) -> str:
|
|
416
|
+
if isinstance(field_info, FileFieldInfo):
|
|
417
|
+
return "File"
|
|
418
|
+
if isinstance(field_info, FormFieldInfo):
|
|
419
|
+
return "Form"
|
|
420
|
+
if isinstance(field_info, StreamBodyParam):
|
|
421
|
+
return "StreamBody"
|
|
422
|
+
return "Body"
|
|
423
|
+
|
|
424
|
+
descriptions = [f"{p.name} ({_param_kind(p.field_info)})" for p in conflicting]
|
|
425
|
+
methods = ", ".join(route.methods or ["?"])
|
|
426
|
+
raise ValueError(
|
|
427
|
+
f"StreamBody cannot be combined with Body, Form, or File/UploadFile parameters on the "
|
|
428
|
+
f"same endpoint ({methods} {route.path_format}). "
|
|
429
|
+
f"Conflicting params: {', '.join(descriptions)}. "
|
|
430
|
+
f"Use StreamBody exclusively for request body handling on this endpoint."
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _inject_streambody_openapi(app: FastAPI, openapi_schema: dict[str, Any]) -> None:
|
|
435
|
+
"""Patch generated OpenAPI paths with binary requestBody entries.
|
|
436
|
+
|
|
437
|
+
Scans all routes for StreamBody dependencies and injects appropriate
|
|
438
|
+
binary media type definitions into their OpenAPI operation specs.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
app: The FastAPI application.
|
|
442
|
+
openapi_schema: The OpenAPI schema dict to modify in-place.
|
|
443
|
+
"""
|
|
444
|
+
paths = openapi_schema.setdefault("paths", {})
|
|
445
|
+
|
|
446
|
+
for route in app.routes:
|
|
447
|
+
if not isinstance(route, APIRoute):
|
|
448
|
+
continue
|
|
449
|
+
|
|
450
|
+
stream_bodies = _collect_streambody_dependencies(route)
|
|
451
|
+
if not stream_bodies:
|
|
452
|
+
continue
|
|
453
|
+
_check_streambody_conflicts(route, stream_bodies)
|
|
454
|
+
|
|
455
|
+
path_item = paths.get(route.path_format)
|
|
456
|
+
if not path_item:
|
|
457
|
+
continue
|
|
458
|
+
|
|
459
|
+
for method in route.methods or []:
|
|
460
|
+
operation = path_item.get(method.lower())
|
|
461
|
+
if not operation:
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
visible_stream_bodies = [stream_body for stream_body in stream_bodies if stream_body.include_in_schema]
|
|
465
|
+
if not visible_stream_bodies:
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
request_body = operation.setdefault("requestBody", {})
|
|
469
|
+
request_body.setdefault("required", True)
|
|
470
|
+
content = request_body.setdefault("content", {})
|
|
471
|
+
|
|
472
|
+
for stream_body in visible_stream_bodies:
|
|
473
|
+
rb = stream_body.openapi_request_body()
|
|
474
|
+
content.update(rb["content"])
|
|
475
|
+
if "description" in rb and "description" not in request_body:
|
|
476
|
+
request_body["description"] = rb["description"]
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _collect_streambody_dependencies(route: APIRoute) -> list[StreamBodyParam]:
|
|
480
|
+
"""Find StreamBodyParam instances reachable from an APIRoute dependency tree.
|
|
481
|
+
|
|
482
|
+
Recursively traverses the dependency graph of an endpoint to collect
|
|
483
|
+
all StreamBodyParam instances used in its dependencies.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
route: The API route to scan.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
A list of all StreamBodyParam instances found in the dependency tree.
|
|
490
|
+
"""
|
|
491
|
+
matches: list[StreamBodyParam] = []
|
|
492
|
+
|
|
493
|
+
def visit(dependant: object) -> None:
|
|
494
|
+
call = getattr(dependant, "call", None)
|
|
495
|
+
if isinstance(call, StreamBodyParam):
|
|
496
|
+
matches.append(call)
|
|
497
|
+
|
|
498
|
+
for child in getattr(dependant, "dependencies", []):
|
|
499
|
+
visit(child)
|
|
500
|
+
|
|
501
|
+
visit(route.dependant)
|
|
502
|
+
return matches
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
__all__ = [
|
|
506
|
+
"StreamBody",
|
|
507
|
+
"StreamBodyParam",
|
|
508
|
+
"UploadStream",
|
|
509
|
+
"install_uploadstream_openapi",
|
|
510
|
+
"size_from_request",
|
|
511
|
+
]
|
|
File without changes
|