fastapi-streams 0.0.1__py3-none-any.whl
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_streams/__init__.py +4 -0
- fastapi_streams/codegen/__init__.py +0 -0
- fastapi_streams/codegen/cli.py +120 -0
- fastapi_streams/codegen/enums.py +172 -0
- fastapi_streams/codegen/ir.py +524 -0
- fastapi_streams/codegen/names.py +88 -0
- fastapi_streams/codegen/openapi.py +154 -0
- fastapi_streams/codegen/python.py +473 -0
- fastapi_streams/codegen/schemas/LICENSE +201 -0
- fastapi_streams/codegen/schemas/README.md +8 -0
- fastapi_streams/codegen/schemas/openapi-3.2.json +1729 -0
- fastapi_streams/codegen/templates/errors.ts +26 -0
- fastapi_streams/codegen/templates/python/api.py.j2 +52 -0
- fastapi_streams/codegen/templates/python/client.py.j2 +40 -0
- fastapi_streams/codegen/templates/python/runtime.py +217 -0
- fastapi_streams/codegen/templates/sse.ts +264 -0
- fastapi_streams/codegen/templates/typescript/api.ts.j2 +52 -0
- fastapi_streams/codegen/templates/typescript/client.ts.j2 +15 -0
- fastapi_streams/codegen/templating.py +19 -0
- fastapi_streams/codegen/typescript.py +220 -0
- fastapi_streams/codegen/writer.py +64 -0
- fastapi_streams/py.typed +0 -0
- fastapi_streams/router.py +133 -0
- fastapi_streams-0.0.1.dist-info/METADATA +238 -0
- fastapi_streams-0.0.1.dist-info/RECORD +28 -0
- fastapi_streams-0.0.1.dist-info/WHEEL +4 -0
- fastapi_streams-0.0.1.dist-info/entry_points.txt +2 -0
- fastapi_streams-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Generated by fastapi-streams. Do not edit.
|
|
2
|
+
|
|
3
|
+
export class TransportError extends Error {
|
|
4
|
+
override readonly name: string = "TransportError";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class HttpError<T = unknown> extends TransportError {
|
|
8
|
+
override readonly name: string = "HttpError";
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
readonly status: number,
|
|
12
|
+
readonly details: T,
|
|
13
|
+
readonly response: Response,
|
|
14
|
+
options?: ErrorOptions,
|
|
15
|
+
) {
|
|
16
|
+
super(`HTTP ${status} ${response.statusText}`.trim(), options);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class StreamParseError extends TransportError {
|
|
21
|
+
override readonly name: string = "StreamParseError";
|
|
22
|
+
|
|
23
|
+
constructor(readonly data: string, cause: unknown) {
|
|
24
|
+
super("Invalid JSON in SSE data", { cause });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Generated by fastapi-streams. Do not edit.
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import AsyncGenerator
|
|
5
|
+
|
|
6
|
+
from pydantic import TypeAdapter
|
|
7
|
+
|
|
8
|
+
from .. import models as Models
|
|
9
|
+
from ..runtime import {{ runtime_imports | join(', ') }}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class {{ class_name }}:
|
|
13
|
+
def __init__(self, transport: Transport) -> None:
|
|
14
|
+
self._transport = transport
|
|
15
|
+
{% for operation in operations %}
|
|
16
|
+
|
|
17
|
+
def {{ operation.operation_id | snake }}(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
{% for parameter in operation.parameters %}
|
|
21
|
+
{{ parameter.name | snake }}: {{ parameter.type | py_type }}{{ ' | None = None' if not parameter.required else '' }},
|
|
22
|
+
{% endfor %}
|
|
23
|
+
{% if operation.body %}
|
|
24
|
+
body: {{ operation.body.type | py_type }}{{ ' | None = None' if not operation.body.required else '' }},
|
|
25
|
+
{% endif %}
|
|
26
|
+
options: RequestOptions | None = None,
|
|
27
|
+
) -> AsyncGenerator[{{ operation.event | py_type }}, None]:
|
|
28
|
+
query: list[tuple[str, str]] = []
|
|
29
|
+
headers: dict[str, str] = {}
|
|
30
|
+
{% for parameter in operation.parameters %}
|
|
31
|
+
{% if parameter.location == 'query' %}
|
|
32
|
+
append_query(query, {{ parameter.name | repr }}, {{ parameter.name | snake }}, explode={{ parameter.explode }}{{ ', required=True' if parameter.required else '' }})
|
|
33
|
+
{% elif parameter.location == 'header' %}
|
|
34
|
+
set_header(headers, {{ parameter.name | repr }}, {{ parameter.name | snake }}{{ ', required=True' if parameter.required else '' }})
|
|
35
|
+
{% endif %}
|
|
36
|
+
{% endfor %}
|
|
37
|
+
{% if operation.body %}
|
|
38
|
+
adapter = TypeAdapter({{ operation.body.type | py_type }})
|
|
39
|
+
content = adapter.dump_json(adapter.validate_python(body), by_alias=True, exclude_unset=True){{ ' if body is not None else None' if not operation.body.required else '' }}
|
|
40
|
+
{% endif %}
|
|
41
|
+
return self._transport.stream(
|
|
42
|
+
{{ operation | py_path }},
|
|
43
|
+
{{ operation.method | repr }},
|
|
44
|
+
TypeAdapter({{ operation.event | py_type }}),
|
|
45
|
+
query=query,
|
|
46
|
+
headers=headers,
|
|
47
|
+
{% if operation.body %}
|
|
48
|
+
body=content,
|
|
49
|
+
{% endif %}
|
|
50
|
+
options=options,
|
|
51
|
+
)
|
|
52
|
+
{% endfor %}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Generated by fastapi-streams. Do not edit.
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
|
|
7
|
+
{{ http_import }}
|
|
8
|
+
|
|
9
|
+
from .runtime import Transport
|
|
10
|
+
{% for group in groups %}
|
|
11
|
+
from .{{ group.directory }}.api import {{ group.class_name }}
|
|
12
|
+
{% endfor %}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Client:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
base_url: str,
|
|
19
|
+
*,
|
|
20
|
+
headers: Mapping[str, str] | None = None,
|
|
21
|
+
http_client: httpx.AsyncClient | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._transport = Transport(base_url, headers=headers, http_client=http_client)
|
|
24
|
+
{% for group in groups %}
|
|
25
|
+
self.{{ group.name }} = {{ group.class_name }}(self._transport)
|
|
26
|
+
{% endfor %}
|
|
27
|
+
|
|
28
|
+
async def aclose(self) -> None:
|
|
29
|
+
await self._transport.aclose()
|
|
30
|
+
|
|
31
|
+
async def __aenter__(self) -> Client:
|
|
32
|
+
return self
|
|
33
|
+
|
|
34
|
+
async def __aexit__(
|
|
35
|
+
self,
|
|
36
|
+
exc_type: type[BaseException] | None,
|
|
37
|
+
exc: BaseException | None,
|
|
38
|
+
traceback: TracebackType | None,
|
|
39
|
+
) -> None:
|
|
40
|
+
await self.aclose()
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# Generated by fastapi-streams. Do not edit.
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import codecs
|
|
5
|
+
from collections.abc import AsyncGenerator, Mapping, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from pydantic import JsonValue, TypeAdapter, ValidationError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TransportError(Exception):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HttpError(TransportError):
|
|
18
|
+
def __init__(self, response: httpx.Response, details: JsonValue) -> None:
|
|
19
|
+
self.status = response.status_code
|
|
20
|
+
self.response = response
|
|
21
|
+
self.details = details
|
|
22
|
+
super().__init__(f"HTTP {self.status} {response.reason_phrase}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StreamParseError(TransportError):
|
|
26
|
+
def __init__(self, data: str) -> None:
|
|
27
|
+
self.data = data
|
|
28
|
+
super().__init__("Invalid JSON or event schema in SSE data")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class _Default(Enum):
|
|
32
|
+
TIMEOUT = "inherit"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class RequestOptions:
|
|
37
|
+
headers: Mapping[str, str] | None = None
|
|
38
|
+
timeout: float | None | _Default = _Default.TIMEOUT
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
type ParameterValue = str | int | float | bool | None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def encode_path(value: str | int | float | bool | None) -> str:
|
|
45
|
+
from urllib.parse import quote
|
|
46
|
+
|
|
47
|
+
text = scalar(value)
|
|
48
|
+
if text in {".", ".."}:
|
|
49
|
+
raise ValueError("Path parameters cannot be dot segments")
|
|
50
|
+
return quote(text, safe="")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def scalar(value: str | int | float | bool | None) -> str:
|
|
54
|
+
if value is None:
|
|
55
|
+
return "null"
|
|
56
|
+
if isinstance(value, bool):
|
|
57
|
+
return "true" if value else "false"
|
|
58
|
+
return str(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def append_query(
|
|
62
|
+
query: list[tuple[str, str]],
|
|
63
|
+
name: str,
|
|
64
|
+
value: ParameterValue | Sequence[ParameterValue],
|
|
65
|
+
*,
|
|
66
|
+
explode: bool,
|
|
67
|
+
required: bool = False,
|
|
68
|
+
) -> None:
|
|
69
|
+
if value is None:
|
|
70
|
+
if required:
|
|
71
|
+
raise TypeError(f"Required query parameter {name} cannot be None")
|
|
72
|
+
return
|
|
73
|
+
if not isinstance(value, str | int | float | bool):
|
|
74
|
+
if explode:
|
|
75
|
+
query.extend((name, scalar(item)) for item in value)
|
|
76
|
+
else:
|
|
77
|
+
query.append((name, ",".join(scalar(item) for item in value)))
|
|
78
|
+
else:
|
|
79
|
+
query.append((name, scalar(value)))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_header(
|
|
83
|
+
headers: dict[str, str], name: str, value: ParameterValue, *, required: bool = False
|
|
84
|
+
) -> None:
|
|
85
|
+
if value is None:
|
|
86
|
+
if required:
|
|
87
|
+
raise TypeError(f"Required header parameter {name} cannot be None")
|
|
88
|
+
return
|
|
89
|
+
headers[name] = scalar(value)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def iter_sse(response: httpx.Response) -> AsyncGenerator[str, None]:
|
|
93
|
+
decoder = codecs.getincrementaldecoder("utf-8-sig")("strict")
|
|
94
|
+
line = ""
|
|
95
|
+
data: list[str] = []
|
|
96
|
+
skip_lf = False
|
|
97
|
+
|
|
98
|
+
def accept_line() -> str | None:
|
|
99
|
+
nonlocal line, data
|
|
100
|
+
current, line = line, ""
|
|
101
|
+
if not current:
|
|
102
|
+
if not data:
|
|
103
|
+
return None
|
|
104
|
+
payload, data = "\n".join(data), []
|
|
105
|
+
return payload
|
|
106
|
+
field, _, value = current.partition(":")
|
|
107
|
+
if value.startswith(" "):
|
|
108
|
+
value = value[1:]
|
|
109
|
+
if field == "data":
|
|
110
|
+
data.append(value)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
async for chunk in response.aiter_bytes():
|
|
114
|
+
for char in decoder.decode(chunk):
|
|
115
|
+
if skip_lf:
|
|
116
|
+
skip_lf = False
|
|
117
|
+
if char == "\n":
|
|
118
|
+
continue
|
|
119
|
+
if char in "\r\n":
|
|
120
|
+
skip_lf = char == "\r"
|
|
121
|
+
payload = accept_line()
|
|
122
|
+
if payload is not None:
|
|
123
|
+
yield payload
|
|
124
|
+
else:
|
|
125
|
+
line += char
|
|
126
|
+
line += decoder.decode(b"", final=True)
|
|
127
|
+
if line:
|
|
128
|
+
accept_line()
|
|
129
|
+
if data:
|
|
130
|
+
yield "\n".join(data)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Transport:
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
base_url: str,
|
|
137
|
+
*,
|
|
138
|
+
headers: Mapping[str, str] | None = None,
|
|
139
|
+
http_client: httpx.AsyncClient | None = None,
|
|
140
|
+
) -> None:
|
|
141
|
+
self.base_url = base_url.rstrip("/")
|
|
142
|
+
self.headers = httpx.Headers(headers)
|
|
143
|
+
self.http_client = http_client or httpx.AsyncClient(timeout=None)
|
|
144
|
+
self.owns_client = http_client is None
|
|
145
|
+
|
|
146
|
+
async def aclose(self) -> None:
|
|
147
|
+
if self.owns_client:
|
|
148
|
+
await self.http_client.aclose()
|
|
149
|
+
|
|
150
|
+
async def stream[T](
|
|
151
|
+
self,
|
|
152
|
+
path: str,
|
|
153
|
+
method: str,
|
|
154
|
+
adapter: TypeAdapter[T],
|
|
155
|
+
*,
|
|
156
|
+
query: list[tuple[str, str]],
|
|
157
|
+
headers: Mapping[str, str],
|
|
158
|
+
body: bytes | None = None,
|
|
159
|
+
options: RequestOptions | None = None,
|
|
160
|
+
) -> AsyncGenerator[T, None]:
|
|
161
|
+
request_headers = self.headers.copy()
|
|
162
|
+
request_headers.update(headers)
|
|
163
|
+
if options and options.headers:
|
|
164
|
+
request_headers.update(options.headers)
|
|
165
|
+
request_headers["Accept"] = "text/event-stream"
|
|
166
|
+
if body is not None:
|
|
167
|
+
request_headers["Content-Type"] = "application/json"
|
|
168
|
+
try:
|
|
169
|
+
async with self.http_client.stream(
|
|
170
|
+
method,
|
|
171
|
+
self.base_url + path,
|
|
172
|
+
params=tuple(query),
|
|
173
|
+
headers=request_headers,
|
|
174
|
+
content=body,
|
|
175
|
+
timeout=options.timeout
|
|
176
|
+
if options and not isinstance(options.timeout, _Default)
|
|
177
|
+
else httpx.USE_CLIENT_DEFAULT,
|
|
178
|
+
) as response:
|
|
179
|
+
if not response.is_success:
|
|
180
|
+
raw = bytearray()
|
|
181
|
+
failure: httpx.HTTPError | None = None
|
|
182
|
+
try:
|
|
183
|
+
async for chunk in response.aiter_bytes():
|
|
184
|
+
raw.extend(chunk[: 65536 - len(raw)])
|
|
185
|
+
if len(raw) >= 65536:
|
|
186
|
+
break
|
|
187
|
+
except httpx.HTTPError as exc:
|
|
188
|
+
failure = exc
|
|
189
|
+
text = raw.decode("utf-8", errors="replace")
|
|
190
|
+
try:
|
|
191
|
+
details = (
|
|
192
|
+
TypeAdapter[JsonValue](JsonValue).validate_json(text)
|
|
193
|
+
if text
|
|
194
|
+
else None
|
|
195
|
+
)
|
|
196
|
+
except ValidationError:
|
|
197
|
+
details = text
|
|
198
|
+
raise HttpError(response, details) from failure
|
|
199
|
+
media = (
|
|
200
|
+
response.headers.get("content-type", "")
|
|
201
|
+
.split(";", 1)[0]
|
|
202
|
+
.strip()
|
|
203
|
+
.lower()
|
|
204
|
+
)
|
|
205
|
+
if media != "text/event-stream":
|
|
206
|
+
raise TransportError(
|
|
207
|
+
f"Expected text/event-stream, received {media!r}"
|
|
208
|
+
)
|
|
209
|
+
async for payload in iter_sse(response):
|
|
210
|
+
try:
|
|
211
|
+
yield adapter.validate_json(payload)
|
|
212
|
+
except ValidationError as exc:
|
|
213
|
+
raise StreamParseError(payload) from exc
|
|
214
|
+
except httpx.HTTPError as exc:
|
|
215
|
+
raise TransportError("SSE HTTP transport failed") from exc
|
|
216
|
+
except UnicodeDecodeError as exc:
|
|
217
|
+
raise TransportError("Invalid UTF-8 in SSE stream") from exc
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Generated by fastapi-streams. Do not edit.
|
|
2
|
+
import { HttpError, StreamParseError, TransportError } from "./errors.js";
|
|
3
|
+
|
|
4
|
+
export interface ClientOptions {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
headers?: HeadersInit;
|
|
7
|
+
fetch?: typeof globalThis.fetch;
|
|
8
|
+
credentials?: RequestCredentials;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface RequestOptions {
|
|
12
|
+
headers?: HeadersInit;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StreamRequest {
|
|
17
|
+
path: string;
|
|
18
|
+
method: string;
|
|
19
|
+
query?: URLSearchParams;
|
|
20
|
+
headers?: HeadersInit;
|
|
21
|
+
body?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function encodePath(value: string | number | boolean | null): string {
|
|
25
|
+
const text = String(value);
|
|
26
|
+
if (text === "." || text === "..") {
|
|
27
|
+
throw new TypeError("Path parameters cannot be dot segments");
|
|
28
|
+
}
|
|
29
|
+
return encodeURIComponent(text);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function appendQuery(
|
|
33
|
+
query: URLSearchParams,
|
|
34
|
+
name: string,
|
|
35
|
+
value: string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined,
|
|
36
|
+
explode: boolean,
|
|
37
|
+
required = false,
|
|
38
|
+
): void {
|
|
39
|
+
if (value === undefined || value === null) {
|
|
40
|
+
if (required) throw new TypeError(`Required query parameter ${name} cannot be null or undefined`);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
if (explode) {
|
|
45
|
+
for (const item of value) query.append(name, String(item));
|
|
46
|
+
} else {
|
|
47
|
+
query.append(name, value.map(String).join(","));
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
query.append(name, String(value));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function setHeader(
|
|
55
|
+
headers: Headers,
|
|
56
|
+
name: string,
|
|
57
|
+
value: string | number | boolean | null | undefined,
|
|
58
|
+
required = false,
|
|
59
|
+
): void {
|
|
60
|
+
if (value === undefined || value === null) {
|
|
61
|
+
if (required) throw new TypeError(`Required header parameter ${name} cannot be null or undefined`);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
headers.set(name, String(value));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function readChunk(
|
|
68
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
69
|
+
signal?: AbortSignal,
|
|
70
|
+
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
|
71
|
+
signal?.throwIfAborted();
|
|
72
|
+
if (!signal) return reader.read();
|
|
73
|
+
let onAbort: () => void = () => {};
|
|
74
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
75
|
+
onAbort = () => reject(signal.reason);
|
|
76
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
77
|
+
});
|
|
78
|
+
try {
|
|
79
|
+
return await Promise.race([reader.read(), aborted]);
|
|
80
|
+
} finally {
|
|
81
|
+
signal.removeEventListener("abort", onAbort);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function* parseSSE<T>(
|
|
86
|
+
body: ReadableStream<Uint8Array>,
|
|
87
|
+
signal?: AbortSignal,
|
|
88
|
+
): AsyncGenerator<T, void, unknown> {
|
|
89
|
+
const reader = body.getReader();
|
|
90
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
91
|
+
let line = "";
|
|
92
|
+
let data: string[] = [];
|
|
93
|
+
let skipLF = false;
|
|
94
|
+
let ended = false;
|
|
95
|
+
|
|
96
|
+
function acceptLine(): string | undefined {
|
|
97
|
+
const current = line;
|
|
98
|
+
line = "";
|
|
99
|
+
if (current === "") {
|
|
100
|
+
if (data.length === 0) return undefined;
|
|
101
|
+
const payload = data.join("\n");
|
|
102
|
+
data = [];
|
|
103
|
+
return payload;
|
|
104
|
+
}
|
|
105
|
+
const colon = current.indexOf(":");
|
|
106
|
+
const field = colon < 0 ? current : current.slice(0, colon);
|
|
107
|
+
let value = colon < 0 ? "" : current.slice(colon + 1);
|
|
108
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
109
|
+
if (field === "data") data.push(value);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decode(payload: string): T {
|
|
114
|
+
try {
|
|
115
|
+
return JSON.parse(payload) as T;
|
|
116
|
+
} catch (cause) {
|
|
117
|
+
throw new StreamParseError(payload, cause);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
while (true) {
|
|
123
|
+
const chunk = await readChunk(reader, signal);
|
|
124
|
+
const text = chunk.done ? decoder.decode() : decoder.decode(chunk.value, { stream: true });
|
|
125
|
+
for (const char of text) {
|
|
126
|
+
if (skipLF) {
|
|
127
|
+
skipLF = false;
|
|
128
|
+
if (char === "\n") continue;
|
|
129
|
+
}
|
|
130
|
+
if (char === "\r" || char === "\n") {
|
|
131
|
+
skipLF = char === "\r";
|
|
132
|
+
const payload = acceptLine();
|
|
133
|
+
if (payload !== undefined) yield decode(payload);
|
|
134
|
+
} else {
|
|
135
|
+
line += char;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (chunk.done) {
|
|
139
|
+
ended = true;
|
|
140
|
+
if (line !== "") acceptLine();
|
|
141
|
+
if (data.length > 0) yield decode(data.join("\n"));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
} finally {
|
|
146
|
+
try {
|
|
147
|
+
if (!ended) await reader.cancel();
|
|
148
|
+
} finally {
|
|
149
|
+
reader.releaseLock();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function mergeHeaders(...sources: (HeadersInit | undefined)[]): Headers {
|
|
155
|
+
const result = new Headers();
|
|
156
|
+
for (const source of sources) {
|
|
157
|
+
new Headers(source).forEach((value, name) => result.set(name, value));
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function errorDetails(response: Response, signal: AbortSignal): Promise<unknown> {
|
|
163
|
+
if (!response.body) return undefined;
|
|
164
|
+
const reader = response.body.getReader();
|
|
165
|
+
const decoder = new TextDecoder();
|
|
166
|
+
let text = "";
|
|
167
|
+
let ended = false;
|
|
168
|
+
try {
|
|
169
|
+
// Error bodies can be HTML proxy pages; keep diagnostics bounded to 64 KiB.
|
|
170
|
+
while (text.length < 65536) {
|
|
171
|
+
const chunk = await readChunk(reader, signal);
|
|
172
|
+
if (chunk.done) {
|
|
173
|
+
ended = true;
|
|
174
|
+
text += decoder.decode();
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
text += decoder.decode(chunk.value, { stream: true });
|
|
178
|
+
}
|
|
179
|
+
} finally {
|
|
180
|
+
try {
|
|
181
|
+
if (!ended) await reader.cancel();
|
|
182
|
+
} finally {
|
|
183
|
+
reader.releaseLock();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
text = text.slice(0, 65536);
|
|
187
|
+
if (!text) return undefined;
|
|
188
|
+
try {
|
|
189
|
+
return JSON.parse(text) as unknown;
|
|
190
|
+
} catch {
|
|
191
|
+
return text;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function* stream<T>(
|
|
196
|
+
client: ClientOptions,
|
|
197
|
+
request: StreamRequest,
|
|
198
|
+
options: RequestOptions = {},
|
|
199
|
+
): AsyncGenerator<T, void, unknown> {
|
|
200
|
+
const controller = new AbortController();
|
|
201
|
+
const onAbort = () => controller.abort(options.signal?.reason);
|
|
202
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
203
|
+
if (options.signal?.aborted) onAbort();
|
|
204
|
+
let response: Response | undefined;
|
|
205
|
+
let bodyHandled = false;
|
|
206
|
+
try {
|
|
207
|
+
controller.signal.throwIfAborted();
|
|
208
|
+
const base = client.baseUrl.replace(/\/+$/, "");
|
|
209
|
+
const query = request.query?.toString();
|
|
210
|
+
const url = `${base}${request.path}${query ? `?${query}` : ""}`;
|
|
211
|
+
const headers = mergeHeaders(client.headers, request.headers, options.headers);
|
|
212
|
+
headers.set("Accept", "text/event-stream");
|
|
213
|
+
if (request.body !== undefined) headers.set("Content-Type", "application/json");
|
|
214
|
+
try {
|
|
215
|
+
response = await (client.fetch ?? globalThis.fetch)(url, {
|
|
216
|
+
method: request.method,
|
|
217
|
+
headers,
|
|
218
|
+
signal: controller.signal,
|
|
219
|
+
...(request.body === undefined ? {} : { body: request.body }),
|
|
220
|
+
...(client.credentials === undefined ? {} : { credentials: client.credentials }),
|
|
221
|
+
});
|
|
222
|
+
} catch (cause) {
|
|
223
|
+
controller.signal.throwIfAborted();
|
|
224
|
+
throw new TransportError("Failed to fetch SSE stream", { cause });
|
|
225
|
+
}
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
bodyHandled = true;
|
|
228
|
+
let details: unknown;
|
|
229
|
+
try {
|
|
230
|
+
details = await errorDetails(response, controller.signal);
|
|
231
|
+
} catch (cause) {
|
|
232
|
+
controller.signal.throwIfAborted();
|
|
233
|
+
throw new HttpError(response.status, undefined, response, { cause });
|
|
234
|
+
}
|
|
235
|
+
throw new HttpError(response.status, details, response);
|
|
236
|
+
}
|
|
237
|
+
const media = response.headers.get("Content-Type")?.split(";", 1)[0]?.trim().toLowerCase();
|
|
238
|
+
if (media !== "text/event-stream") {
|
|
239
|
+
throw new TransportError(`Expected text/event-stream, received ${media ?? "no Content-Type"}`);
|
|
240
|
+
}
|
|
241
|
+
if (!response.body) throw new TransportError("SSE response has no body");
|
|
242
|
+
bodyHandled = true;
|
|
243
|
+
try {
|
|
244
|
+
yield* parseSSE<T>(response.body, controller.signal);
|
|
245
|
+
} catch (cause) {
|
|
246
|
+
controller.signal.throwIfAborted();
|
|
247
|
+
if (cause instanceof TransportError) throw cause;
|
|
248
|
+
throw new TransportError("SSE stream read failed", { cause });
|
|
249
|
+
}
|
|
250
|
+
} finally {
|
|
251
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
252
|
+
try {
|
|
253
|
+
if (!bodyHandled && response?.body && !response.body.locked) {
|
|
254
|
+
try {
|
|
255
|
+
await response.body.cancel();
|
|
256
|
+
} catch (cause) {
|
|
257
|
+
throw new TransportError("Failed to close SSE response", { cause });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
} finally {
|
|
261
|
+
controller.abort();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generated by fastapi-streams. Do not edit.
|
|
2
|
+
import type * as Types from "../types.js";
|
|
3
|
+
import type { ClientOptions, RequestOptions } from "../transport/sse.js";
|
|
4
|
+
import { {{ functions | join(', ') }} } from "../transport/sse.js";
|
|
5
|
+
|
|
6
|
+
{% for operation in operations %}
|
|
7
|
+
{% if operation.parameters or operation.body %}
|
|
8
|
+
export interface {{ operation | input_name }} {
|
|
9
|
+
{% for param in operation.parameters %}
|
|
10
|
+
{{ param.argument }}{{ '' if param.required else '?' }}: {{ param.type | ts_type('Types.') }};
|
|
11
|
+
{% endfor %}
|
|
12
|
+
{% if operation.body %}
|
|
13
|
+
body{{ '' if operation.body.required else '?' }}: {{ operation.body.type | ts_type('Types.') }};
|
|
14
|
+
{% endif %}
|
|
15
|
+
}
|
|
16
|
+
{% endif %}
|
|
17
|
+
{% if operation.errors %}
|
|
18
|
+
export type {{ operation.operation_id | pascal }}Error = {{ operation | error_type }};
|
|
19
|
+
{% endif %}
|
|
20
|
+
|
|
21
|
+
{% endfor %}
|
|
22
|
+
export function {{ factory }}(client: ClientOptions) {
|
|
23
|
+
return {
|
|
24
|
+
{% for operation in operations %}
|
|
25
|
+
{{ operation.name }}(
|
|
26
|
+
{% if operation.parameters or operation.body %}
|
|
27
|
+
input: {{ operation | input_name }}{{ '' if operation | required else ' = {}' }},
|
|
28
|
+
{% endif %}
|
|
29
|
+
options: RequestOptions = {},
|
|
30
|
+
): AsyncIterable<{{ operation.event | ts_type('Types.') }}> {
|
|
31
|
+
const query = new URLSearchParams();
|
|
32
|
+
const headers = new Headers();
|
|
33
|
+
{% for param in operation.parameters %}
|
|
34
|
+
{% if param.location == 'query' %}
|
|
35
|
+
appendQuery(query, {{ param.name | json }}, input.{{ param.argument }}, {{ 'true' if param.explode else 'false' }}{{ ', true' if param.required else '' }});
|
|
36
|
+
{% elif param.location == 'header' %}
|
|
37
|
+
setHeader(headers, {{ param.name | json }}, input.{{ param.argument }}{{ ', true' if param.required else '' }});
|
|
38
|
+
{% endif %}
|
|
39
|
+
{% endfor %}
|
|
40
|
+
return stream<{{ operation.event | ts_type('Types.') }}>(client, {
|
|
41
|
+
path: {{ operation | path }},
|
|
42
|
+
method: {{ operation.method | json }},
|
|
43
|
+
query,
|
|
44
|
+
headers,
|
|
45
|
+
{% if operation.body %}
|
|
46
|
+
...(input.body === undefined ? {} : { body: JSON.stringify(input.body) }),
|
|
47
|
+
{% endif %}
|
|
48
|
+
}, options);
|
|
49
|
+
},
|
|
50
|
+
{% endfor %}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Generated by fastapi-streams. Do not edit.
|
|
2
|
+
import type { ClientOptions } from "./transport/sse.js";
|
|
3
|
+
{% for group in groups %}
|
|
4
|
+
import { {{ group.factory }} } from "./{{ group.directory }}/api.js";
|
|
5
|
+
{% endfor %}
|
|
6
|
+
|
|
7
|
+
export function createClient(options: ClientOptions) {
|
|
8
|
+
return {
|
|
9
|
+
{% for group in groups %}
|
|
10
|
+
{{ group.name }}: {{ group.factory }}(options),
|
|
11
|
+
{% endfor %}
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type Client = ReturnType<typeof createClient>;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
from jinja2 import Environment, PackageLoader, StrictUndefined
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def environment() -> Environment:
|
|
8
|
+
return Environment(
|
|
9
|
+
loader=PackageLoader("fastapi_streams.codegen", "templates"),
|
|
10
|
+
undefined=StrictUndefined,
|
|
11
|
+
autoescape=False,
|
|
12
|
+
keep_trailing_newline=True,
|
|
13
|
+
trim_blocks=True,
|
|
14
|
+
lstrip_blocks=True,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def add_filters(env: Environment, **filters: Callable[..., Any]) -> None:
|
|
19
|
+
cast(dict[str, Callable[..., Any]], env.filters).update(filters)
|