pipelex-api 0.71.0__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.
- pipelex_api/__init__.py +0 -0
- pipelex_api/api.toml +21 -0
- pipelex_api/api_config.py +144 -0
- pipelex_api/bundle.py +243 -0
- pipelex_api/disclosure.py +43 -0
- pipelex_api/error_types.py +80 -0
- pipelex_api/error_uri.py +45 -0
- pipelex_api/errors.py +130 -0
- pipelex_api/exception_handlers.py +693 -0
- pipelex_api/json_body.py +182 -0
- pipelex_api/limits.py +67 -0
- pipelex_api/main.py +221 -0
- pipelex_api/method_cache.py +241 -0
- pipelex_api/method_source.py +215 -0
- pipelex_api/middleware.py +209 -0
- pipelex_api/openapi_responses.py +186 -0
- pipelex_api/openapi_schema.py +83 -0
- pipelex_api/problem_document.py +134 -0
- pipelex_api/py.typed +0 -0
- pipelex_api/routes/__init__.py +23 -0
- pipelex_api/routes/health.py +24 -0
- pipelex_api/routes/pipelex/__init__.py +21 -0
- pipelex_api/routes/pipelex/agent/__init__.py +11 -0
- pipelex_api/routes/pipelex/agent/concept.py +60 -0
- pipelex_api/routes/pipelex/agent/models.py +49 -0
- pipelex_api/routes/pipelex/agent/pipe_spec.py +59 -0
- pipelex_api/routes/pipelex/build/__init__.py +11 -0
- pipelex_api/routes/pipelex/build/inputs.py +192 -0
- pipelex_api/routes/pipelex/build/output.py +163 -0
- pipelex_api/routes/pipelex/build/runner.py +236 -0
- pipelex_api/routes/pipelex/codegen.py +164 -0
- pipelex_api/routes/pipelex/crate_ops.py +331 -0
- pipelex_api/routes/pipelex/pipe_io.py +186 -0
- pipelex_api/routes/pipelex/pipeline.py +938 -0
- pipelex_api/routes/pipelex/resolve.py +81 -0
- pipelex_api/routes/pipelex/tools.py +111 -0
- pipelex_api/routes/pipelex/utils.py +6 -0
- pipelex_api/routes/pipelex/validate.py +473 -0
- pipelex_api/routes/version.py +51 -0
- pipelex_api/schemas/__init__.py +0 -0
- pipelex_api/schemas/models.py +653 -0
- pipelex_api/security.py +284 -0
- pipelex_api-0.71.0.dist-info/METADATA +188 -0
- pipelex_api-0.71.0.dist-info/RECORD +46 -0
- pipelex_api-0.71.0.dist-info/WHEEL +4 -0
- pipelex_api-0.71.0.dist-info/licenses/LICENSE +95 -0
pipelex_api/json_body.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""JSON request bodies, bounded in nesting depth before any parser reads them.
|
|
2
|
+
|
|
3
|
+
`json.loads` recurses once per nested array or object. Up to Python 3.13 a deeply nested body made it
|
|
4
|
+
raise `RecursionError` at a fixed count; from Python 3.14 the guard is the thread's actual stack size,
|
|
5
|
+
so the same body overflows on a small stack and parses on a large one, to be refused later for some
|
|
6
|
+
other reason or accepted. The server therefore bounds the nesting itself, before parsing: a body nested
|
|
7
|
+
deeper than `MAX_JSON_NESTING_DEPTH` answers the same 422 `InvalidJSON` on every interpreter and stack.
|
|
8
|
+
|
|
9
|
+
Every JSON body the server parses goes through `ensure_json_nesting_within_limit`, by one of two doors:
|
|
10
|
+
|
|
11
|
+
- A route that declares a typed body has it parsed by FastAPI. Its router is built with
|
|
12
|
+
`route_class=JsonBodyRoute`, which checks the body before FastAPI parses it, and
|
|
13
|
+
`tests/unit/test_json_body.py` fails when a body-declaring route of the app is not one.
|
|
14
|
+
- A route that reads its raw body, as the run routes do, parses it with `decode_json_body`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Callable, Coroutine
|
|
20
|
+
from itertools import accumulate, cycle
|
|
21
|
+
from operator import mul
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from fastapi import Request, Response, params
|
|
25
|
+
from fastapi.routing import APIRoute
|
|
26
|
+
from typing_extensions import override
|
|
27
|
+
|
|
28
|
+
from pipelex_api.error_types import ErrorType
|
|
29
|
+
from pipelex_api.errors import raise_validation_error
|
|
30
|
+
from pipelex_api.limits import MAX_JSON_NESTING_DEPTH
|
|
31
|
+
|
|
32
|
+
# The two escape sequences that can hide a quote. Once they are gone, every quote left delimits a string.
|
|
33
|
+
_ESCAPED_BACKSLASH = b"\\\\"
|
|
34
|
+
_ESCAPED_QUOTE = b'\\"'
|
|
35
|
+
# Only quotes and brackets matter to nesting, and an object nests like an array.
|
|
36
|
+
_NOT_STRUCTURAL = bytes(byte for byte in range(256) if byte not in b'"[]{}')
|
|
37
|
+
_BRACES_AS_BRACKETS = bytes.maketrans(b"{}", b"[]")
|
|
38
|
+
_ADJACENT_QUOTES = b'""'
|
|
39
|
+
_QUOTE = b'"'
|
|
40
|
+
_INNERMOST_PAIR = b"[]"
|
|
41
|
+
_OPEN_BRACKET = ord("[")
|
|
42
|
+
_BRACKET_RUN = re.compile(rb"\[+|\]+")
|
|
43
|
+
# Peeling stops once a pass removes less than this fraction of the brackets the scan started with.
|
|
44
|
+
_PEEL_STOP_DIVISOR = 8
|
|
45
|
+
# The stages that build a list per piece, splitting at quotes and measuring runs, work a chunk at a time,
|
|
46
|
+
# so no list grows with the body and its memory stays bounded however the body is shaped.
|
|
47
|
+
_CHUNK_BYTES = 1 << 20
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _as_utf8(body: bytes) -> bytes:
|
|
51
|
+
"""Return the body in UTF-8, the encoding the scan reads.
|
|
52
|
+
|
|
53
|
+
`json.loads` given bytes also accepts UTF-16 and UTF-32, which FastAPI passes it as they come, and in
|
|
54
|
+
which a quote or an escape is not the single byte the scan looks for. Such a body is transcoded the way
|
|
55
|
+
`json.loads` decodes it. A body that does not decode is answered empty: the parser fails on the same
|
|
56
|
+
bytes before it reads a single bracket.
|
|
57
|
+
"""
|
|
58
|
+
encoding = json.detect_encoding(body)
|
|
59
|
+
if encoding in {"utf-8", "utf-8-sig"}:
|
|
60
|
+
return body
|
|
61
|
+
try:
|
|
62
|
+
return body.decode(encoding, "surrogatepass").encode("utf-8", "surrogatepass")
|
|
63
|
+
except UnicodeError:
|
|
64
|
+
return b""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _brackets_outside_strings(body: bytes) -> bytes:
|
|
68
|
+
r"""Return the body's brackets that lie outside strings, in order, with every brace written as a bracket.
|
|
69
|
+
|
|
70
|
+
The escapes that can hide a quote are dropped first: removing every `\\` and then every `\"`, each
|
|
71
|
+
left to right, pairs a run of backslashes the way a parser does. The body is then cut down to its quotes
|
|
72
|
+
and brackets. Adjacent quotes enclose no bracket, whether they open and close one string or close one and
|
|
73
|
+
open the next, so dropping them changes nothing and spares the split below a piece per short string.
|
|
74
|
+
With every quote left a delimiter, the pieces between quotes alternate outside and inside strings,
|
|
75
|
+
starting outside, and the outside ones hold the brackets that nest. The skeleton is split a chunk at a
|
|
76
|
+
time, each chunk starting inside a string when the quotes before it are odd in number. An unterminated
|
|
77
|
+
string runs to the end, as it does for a parser.
|
|
78
|
+
"""
|
|
79
|
+
unescaped = body.replace(_ESCAPED_BACKSLASH, b"").replace(_ESCAPED_QUOTE, b"")
|
|
80
|
+
skeleton = unescaped.translate(_BRACES_AS_BRACKETS, _NOT_STRUCTURAL).replace(_ADJACENT_QUOTES, b"")
|
|
81
|
+
outside: list[bytes] = []
|
|
82
|
+
in_string = False
|
|
83
|
+
for start in range(0, len(skeleton), _CHUNK_BYTES):
|
|
84
|
+
pieces = skeleton[start : start + _CHUNK_BYTES].split(_QUOTE)
|
|
85
|
+
outside.append(b"".join(pieces[1 if in_string else 0 :: 2]))
|
|
86
|
+
# A chunk split into an even number of pieces holds an odd number of quotes.
|
|
87
|
+
in_string ^= len(pieces) % 2 == 0
|
|
88
|
+
return b"".join(outside)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def json_nesting_exceeds(body: bytes, *, max_depth: int) -> bool:
|
|
92
|
+
"""Tell whether a JSON body nests its arrays and objects more than `max_depth` levels deep, without parsing it.
|
|
93
|
+
|
|
94
|
+
The answer is exact for a valid JSON document. For an invalid one it is never lower than the depth a
|
|
95
|
+
parser reaches before it stops, so no body this lets through makes `json.loads` recurse past `max_depth`.
|
|
96
|
+
|
|
97
|
+
It costs time linear in the body and never loops in Python over its bytes: a multi-megabyte body is
|
|
98
|
+
mostly strings, which `bytes` operations skip at C speed, and the brackets left are measured in two
|
|
99
|
+
stages. First, each pass of `replace(b"[]", b"")` removes the innermost level of every array and object
|
|
100
|
+
at once, which leaves the depth of every remaining bracket unchanged and lowers the deepest point by
|
|
101
|
+
exactly one. Passes continue while they remove much, which settles flat and shallow documents
|
|
102
|
+
outright. Then the runs of opening and closing brackets that remain, few by then, are summed in turn,
|
|
103
|
+
and the deepest running total plus the levels peeled is the body's depth.
|
|
104
|
+
"""
|
|
105
|
+
brackets = _brackets_outside_strings(_as_utf8(body))
|
|
106
|
+
stop_below = len(brackets) // _PEEL_STOP_DIVISOR
|
|
107
|
+
peeled = 0
|
|
108
|
+
while brackets:
|
|
109
|
+
shorter = brackets.replace(_INNERMOST_PAIR, b"")
|
|
110
|
+
removed = len(brackets) - len(shorter)
|
|
111
|
+
if not removed:
|
|
112
|
+
break
|
|
113
|
+
brackets = shorter
|
|
114
|
+
peeled += 1
|
|
115
|
+
if peeled > max_depth:
|
|
116
|
+
return True
|
|
117
|
+
if removed < stop_below:
|
|
118
|
+
break
|
|
119
|
+
depth = 0
|
|
120
|
+
for start in range(0, len(brackets), _CHUNK_BYTES):
|
|
121
|
+
chunk = brackets[start : start + _CHUNK_BYTES]
|
|
122
|
+
# Maximal runs alternate between opening and closing brackets, so their signs alternate too.
|
|
123
|
+
signs = cycle((1, -1) if chunk[0] == _OPEN_BRACKET else (-1, 1))
|
|
124
|
+
running = list(accumulate(map(mul, map(len, _BRACKET_RUN.findall(chunk)), signs), initial=depth))
|
|
125
|
+
if max(running) + peeled > max_depth:
|
|
126
|
+
return True
|
|
127
|
+
depth = running[-1]
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def ensure_json_nesting_within_limit(body: bytes) -> None:
|
|
132
|
+
"""Refuse a request body nested deeper than `MAX_JSON_NESTING_DEPTH` with a 422 `InvalidJSON`."""
|
|
133
|
+
if json_nesting_exceeds(body, max_depth=MAX_JSON_NESTING_DEPTH):
|
|
134
|
+
raise_validation_error(
|
|
135
|
+
message=f"Request body is nested too deeply: its JSON arrays and objects may nest at most {MAX_JSON_NESTING_DEPTH} levels.",
|
|
136
|
+
error_type=ErrorType.INVALID_JSON,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def decode_json_body(body: bytes) -> Any:
|
|
141
|
+
"""Parse a raw request body as plain JSON, refusing it with a 422 `InvalidJSON` when it cannot be.
|
|
142
|
+
|
|
143
|
+
For a route that reads its body itself rather than declaring a typed one. The refusals are caller
|
|
144
|
+
mistakes, never a sanitized 500:
|
|
145
|
+
- a body nested deeper than `MAX_JSON_NESTING_DEPTH`, checked before parsing;
|
|
146
|
+
- `UnicodeDecodeError`: the bytes are not valid UTF-8;
|
|
147
|
+
- `ValueError`: `json.JSONDecodeError`, which subclasses it;
|
|
148
|
+
- `RecursionError`: the depth check leaves the parser nothing deep enough to raise it, so this only
|
|
149
|
+
stands as a second line of defence.
|
|
150
|
+
"""
|
|
151
|
+
ensure_json_nesting_within_limit(body)
|
|
152
|
+
try:
|
|
153
|
+
return json.loads(body.decode("utf-8"))
|
|
154
|
+
except (UnicodeDecodeError, ValueError, RecursionError) as exc:
|
|
155
|
+
raise_validation_error(
|
|
156
|
+
message=f"Request body is not valid JSON: {exc!s}",
|
|
157
|
+
error_type=ErrorType.INVALID_JSON,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class JsonBodyRoute(APIRoute):
|
|
162
|
+
"""An `APIRoute` that checks a declared JSON body's nesting depth before FastAPI parses it.
|
|
163
|
+
|
|
164
|
+
FastAPI parses a typed body with `json.loads` and turns any failure but a `JSONDecodeError` into a
|
|
165
|
+
bare 400, so the check runs in front of FastAPI's own handler, where its 422 reaches the API's
|
|
166
|
+
exception handlers like any other `ApiError`. The body it reads is cached on the request, so FastAPI
|
|
167
|
+
does not read it twice. A route that declares no body, or a form body, is left as it is: one that
|
|
168
|
+
reads its raw body parses it with `decode_json_body`.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
@override
|
|
172
|
+
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
|
173
|
+
handler = super().get_route_handler()
|
|
174
|
+
if self.body_field is None or isinstance(self.body_field.field_info, params.Form):
|
|
175
|
+
# No body, or a form body, which FastAPI never hands to `json.loads`.
|
|
176
|
+
return handler
|
|
177
|
+
|
|
178
|
+
async def bounded_handler(request: Request) -> Response:
|
|
179
|
+
ensure_json_nesting_within_limit(await request.body())
|
|
180
|
+
return await handler(request)
|
|
181
|
+
|
|
182
|
+
return bounded_handler
|
pipelex_api/limits.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Centralized, env-tunable size limits for incoming requests.
|
|
2
|
+
|
|
3
|
+
Every endpoint that accepts user-supplied content bounds it via constants
|
|
4
|
+
imported from this module. Values are read once at import time — change
|
|
5
|
+
requires a process restart.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pipelex import log
|
|
9
|
+
from pipelex.system.environment import get_optional_env
|
|
10
|
+
|
|
11
|
+
DEFAULT_MAX_REQUEST_BODY_MIB = 100
|
|
12
|
+
DEFAULT_MAX_MTHDS_FILE_KIB = 1024 # 1 MiB per .mthds file
|
|
13
|
+
DEFAULT_MAX_MTHDS_FILES_PER_REQUEST = 16
|
|
14
|
+
DEFAULT_MAX_PIPE_CODE_LEN = 256
|
|
15
|
+
MAX_METHOD_REF_LEN = 512 # `method_ref` selector strings; a fixed schema bound, not env-tunable
|
|
16
|
+
# How deep the arrays and objects of a JSON request body may nest, the body's own envelope included
|
|
17
|
+
# (`{"inputs": {"x": [1]}}` nests three levels). Every route checks it before parsing, with
|
|
18
|
+
# `pipelex_api.json_body`, because how deep `json.loads` can recurse depends on the interpreter and,
|
|
19
|
+
# from Python 3.14, on the thread's stack size. Real inputs nest a few dozen levels at most. The bound
|
|
20
|
+
# also sits well below pydantic's own JSON parser, which refuses a document nested past 200 levels and
|
|
21
|
+
# reads a run's inputs again downstream, a few envelope levels deeper, where a transported PipeFunc
|
|
22
|
+
# request and the trace event logs are parsed. A fixed bound, not env-tunable: raising it would hand the
|
|
23
|
+
# parser's stack back to the caller.
|
|
24
|
+
MAX_JSON_NESTING_DEPTH = 128
|
|
25
|
+
DEFAULT_MAX_CALLBACK_URLS = 5
|
|
26
|
+
DEFAULT_MAX_CALLBACK_URL_LEN = 2048
|
|
27
|
+
DEFAULT_MAX_AGENT_SPEC_KIB = 256 # 256 KiB for JSON concept/pipe specs
|
|
28
|
+
DEFAULT_MAX_BUNDLE_FILES = 128 # entries in a materialized method bundle (.mthds + .py + requirements.txt)
|
|
29
|
+
DEFAULT_MAX_BUNDLE_TOTAL_KIB = 8 * 1024 # 8 MiB decompressed across the whole bundle (zip-bomb guard)
|
|
30
|
+
DEFAULT_MAX_METHOD_CACHE_CLONES = 64 # cached method-package clones (one per resolved commit SHA)
|
|
31
|
+
DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB = 512 * 1024 # 512 MiB across all cached clones
|
|
32
|
+
DEFAULT_MAX_METHOD_CACHE_AGE_HOURS = 24 # a cached clone unused for this long is evicted
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_positive_int(env_var: str, default: int) -> int:
|
|
36
|
+
raw = get_optional_env(env_var)
|
|
37
|
+
if not raw:
|
|
38
|
+
return default
|
|
39
|
+
try:
|
|
40
|
+
parsed = int(raw)
|
|
41
|
+
except ValueError:
|
|
42
|
+
log.warning(f"Invalid {env_var}={raw!r}, falling back to {default}")
|
|
43
|
+
return default
|
|
44
|
+
if parsed <= 0:
|
|
45
|
+
log.warning(f"{env_var} must be positive (got {parsed}), falling back to {default}")
|
|
46
|
+
return default
|
|
47
|
+
return parsed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
MAX_REQUEST_BODY_MIB = _read_positive_int("MAX_REQUEST_BODY_MIB", DEFAULT_MAX_REQUEST_BODY_MIB)
|
|
51
|
+
MAX_REQUEST_BODY_BYTES = MAX_REQUEST_BODY_MIB * 1024 * 1024
|
|
52
|
+
|
|
53
|
+
MAX_MTHDS_FILE_BYTES = _read_positive_int("MAX_MTHDS_FILE_KIB", DEFAULT_MAX_MTHDS_FILE_KIB) * 1024
|
|
54
|
+
MAX_MTHDS_FILES_PER_REQUEST = _read_positive_int("MAX_MTHDS_FILES_PER_REQUEST", DEFAULT_MAX_MTHDS_FILES_PER_REQUEST)
|
|
55
|
+
MAX_PIPE_CODE_LEN = _read_positive_int("MAX_PIPE_CODE_LEN", DEFAULT_MAX_PIPE_CODE_LEN)
|
|
56
|
+
|
|
57
|
+
MAX_CALLBACK_URLS = _read_positive_int("MAX_CALLBACK_URLS", DEFAULT_MAX_CALLBACK_URLS)
|
|
58
|
+
MAX_CALLBACK_URL_LEN = _read_positive_int("MAX_CALLBACK_URL_LEN", DEFAULT_MAX_CALLBACK_URL_LEN)
|
|
59
|
+
|
|
60
|
+
MAX_AGENT_SPEC_BYTES = _read_positive_int("MAX_AGENT_SPEC_KIB", DEFAULT_MAX_AGENT_SPEC_KIB) * 1024
|
|
61
|
+
|
|
62
|
+
MAX_BUNDLE_FILES = _read_positive_int("MAX_BUNDLE_FILES", DEFAULT_MAX_BUNDLE_FILES)
|
|
63
|
+
MAX_BUNDLE_TOTAL_BYTES = _read_positive_int("MAX_BUNDLE_TOTAL_KIB", DEFAULT_MAX_BUNDLE_TOTAL_KIB) * 1024
|
|
64
|
+
|
|
65
|
+
MAX_METHOD_CACHE_CLONES = _read_positive_int("MAX_METHOD_CACHE_CLONES", DEFAULT_MAX_METHOD_CACHE_CLONES)
|
|
66
|
+
MAX_METHOD_CACHE_TOTAL_BYTES = _read_positive_int("MAX_METHOD_CACHE_TOTAL_KIB", DEFAULT_MAX_METHOD_CACHE_TOTAL_KIB) * 1024
|
|
67
|
+
MAX_METHOD_CACHE_AGE_SECONDS = _read_positive_int("MAX_METHOD_CACHE_AGE_HOURS", DEFAULT_MAX_METHOD_CACHE_AGE_HOURS) * 3600
|
pipelex_api/main.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""FastAPI app init, middleware, and router registration.
|
|
2
|
+
|
|
3
|
+
App construction lives here; the failure → HTTP response mapping lives in
|
|
4
|
+
`pipelex_api.exception_handlers` (registered against this app below). Routes
|
|
5
|
+
therefore no longer need to catch and shape errors themselves — anything
|
|
6
|
+
they raise lands in the right handler by exception class.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import AsyncGenerator
|
|
10
|
+
from contextlib import asynccontextmanager
|
|
11
|
+
from importlib.metadata import PackageNotFoundError
|
|
12
|
+
from importlib.metadata import version as package_version
|
|
13
|
+
|
|
14
|
+
from fastapi import Depends, FastAPI
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from mthds.protocol.protocol import PROTOCOL_VERSION
|
|
17
|
+
from pipelex.interpreter_plugins.builtins import BUILTIN_PLUGINS, CORE_UNCONDITIONAL_PLUGIN_NAMES, ENTRY_POINT_GROUPS
|
|
18
|
+
from pipelex.pipelex import Pipelex
|
|
19
|
+
from pipelex.plugins.discovery import build_registrar
|
|
20
|
+
from pipelex.plugins.registrar import HttpErrorMapperFn
|
|
21
|
+
from pipelex.system.configuration.config_loader import config_manager
|
|
22
|
+
from pipelex.system.configuration.configs import PipelexConfig
|
|
23
|
+
from pipelex.system.environment import get_optional_env
|
|
24
|
+
from pipelex.system.runtime import IntegrationMode
|
|
25
|
+
from pydantic import BaseModel, Field
|
|
26
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
27
|
+
|
|
28
|
+
from pipelex_api.api_config import get_api_config, resolve_boot_orchestrator
|
|
29
|
+
from pipelex_api.disclosure import resolve_disclosure_mode
|
|
30
|
+
from pipelex_api.exception_handlers import register_exception_handlers
|
|
31
|
+
from pipelex_api.middleware import RequestIdMiddleware, request_body_size_middleware
|
|
32
|
+
from pipelex_api.openapi_schema import PipelexFastAPI
|
|
33
|
+
from pipelex_api.routes import router as api_router
|
|
34
|
+
from pipelex_api.routes.health import router as health_router
|
|
35
|
+
from pipelex_api.routes.version import router as version_router
|
|
36
|
+
from pipelex_api.security import get_auth_dependency
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@asynccontextmanager
|
|
40
|
+
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|
41
|
+
# Resolve the deployment's orchestration mode BEFORE booting, so the process can boot
|
|
42
|
+
# under the matching orchestrator. `orchestration_mode` selects the dispatch arm; a
|
|
43
|
+
# non-`direct` (async/boot) orchestrator — e.g. "temporal" — must additionally claim the
|
|
44
|
+
# process-global execution hub slots at boot, which the shared WorkflowExecutor requires
|
|
45
|
+
# (`is_*_boot_active()`): without it, a `temporal`-mode runner resolves the Temporal
|
|
46
|
+
# dispatch arm but the underlying pipe-run stack is still in-process, and dispatch fails
|
|
47
|
+
# with AsyncExecutionNotEnabledError. The base `direct` mode names no orchestrator and
|
|
48
|
+
# boots in-process (boot_orchestrator=None). `resolve_boot_orchestrator` derives boot from
|
|
49
|
+
# the deployment config and refuses a config whose single boot can't service its own
|
|
50
|
+
# per-request override policy (a `direct` default with override on) — so boot and dispatch
|
|
51
|
+
# can never be set inconsistently.
|
|
52
|
+
#
|
|
53
|
+
# Loading `api.toml` here also fails the app fast on a malformed config / baked override
|
|
54
|
+
# (the same posture as ERROR_DISCLOSURE), now even before the singleton exists. The loader
|
|
55
|
+
# only needs `runtime_manager.environment` (from PIPELEX_ENV), which resolves without a
|
|
56
|
+
# live singleton. get_api_config() is @cache'd, so the warm here is reused everywhere.
|
|
57
|
+
boot_orchestrator = resolve_boot_orchestrator(get_api_config())
|
|
58
|
+
Pipelex.make(integration_mode=IntegrationMode.FASTAPI, boot_orchestrator=boot_orchestrator)
|
|
59
|
+
try:
|
|
60
|
+
yield
|
|
61
|
+
finally:
|
|
62
|
+
Pipelex.teardown_if_needed()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_cors_origins() -> tuple[list[str], bool]:
|
|
66
|
+
"""Read CORS_ALLOW_ORIGINS env var. Returns (origins, allow_credentials).
|
|
67
|
+
|
|
68
|
+
Default: wildcard origins, credentials disabled — the only valid combination
|
|
69
|
+
when origins is `*` (browsers reject credentials with wildcard). To enable
|
|
70
|
+
credentials, set CORS_ALLOW_ORIGINS to a comma-separated allowlist.
|
|
71
|
+
"""
|
|
72
|
+
raw = get_optional_env("CORS_ALLOW_ORIGINS")
|
|
73
|
+
if not raw or raw.strip() == "*":
|
|
74
|
+
return ["*"], False
|
|
75
|
+
origins = [origin.strip() for origin in raw.split(",") if origin.strip()]
|
|
76
|
+
if not origins:
|
|
77
|
+
return ["*"], False
|
|
78
|
+
return origins, True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# Resolve and validate ERROR_DISCLOSURE once, at module/startup: an unrecognized
|
|
82
|
+
# value raises here and the production app fails to boot rather than silently
|
|
83
|
+
# defaulting. Passed into `register_exception_handlers` below; the resolved
|
|
84
|
+
# mode drives how much of an error report reaches a client. Kept module-level
|
|
85
|
+
# so the production fail-fast lives on this single import path — only this
|
|
86
|
+
# module triggers it, not `pipelex_api.exception_handlers` (which lets tests register
|
|
87
|
+
# the handlers without inheriting the env-validation crash).
|
|
88
|
+
ERROR_DISCLOSURE_MODE = resolve_disclosure_mode()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _resolve_http_error_mappers() -> dict[type[Exception], HttpErrorMapperFn]:
|
|
92
|
+
"""Resolve the orchestrator plugins' HTTP-error mappers for this deployment.
|
|
93
|
+
|
|
94
|
+
Runs the pure, repeatable `build_registrar` against the loaded config (the same
|
|
95
|
+
standalone pattern `pipelex plugins list` uses) and reads back the
|
|
96
|
+
`{exc_type: to_error_report}` map each installed orchestrator plugin contributed
|
|
97
|
+
via `PluginRegistrar.add_http_error_mapper`. The base installs no orchestrator
|
|
98
|
+
plugin, so this is an empty map and no transport-error handler is registered; a
|
|
99
|
+
flavor (e.g. the Temporal one) contributes its plugin's mapper here, and only at
|
|
100
|
+
this point — at app construction, where the plugin (and therefore its SDK) is by
|
|
101
|
+
definition installed — is the plugin's exc-type provider thunk run. Resolved once
|
|
102
|
+
at module import so a duplicate/broken plugin fails the app fast, mirroring the
|
|
103
|
+
`ERROR_DISCLOSURE` fail-fast above.
|
|
104
|
+
|
|
105
|
+
Safe at import because the map is a pure function of *installed* plugins (entry
|
|
106
|
+
points + `config.runtime.plugins.disabled`) — never of the `boot_orchestrator` that
|
|
107
|
+
`Pipelex.make` selects at boot — so an import-time resolution yields the identical
|
|
108
|
+
map a post-boot one would. `boot_orchestrator=None` is passed for exactly that
|
|
109
|
+
reason: it gates only a plugin's hub-slot claims, which this throwaway registrar
|
|
110
|
+
never applies, while the HTTP-error mapper a plugin contributes is unconditional.
|
|
111
|
+
Naming the deployment's real orchestrator here would change nothing in the map and
|
|
112
|
+
would drag `api.toml` loading onto the import path.
|
|
113
|
+
|
|
114
|
+
`load_config_validated` rather than a load followed by a `model_validate`, because
|
|
115
|
+
that is the tolerant entry point the boot itself uses: a configuration a pipelex
|
|
116
|
+
schema change left behind is replayed through its migration ledger in memory and
|
|
117
|
+
re-validated, so a stale-but-explainable config warns instead of stopping the boot.
|
|
118
|
+
Validating the raw dict here would refuse, at import, a config the very next step
|
|
119
|
+
(`Pipelex.make` in `lifespan`) accepts — the app would never start over a file
|
|
120
|
+
pipelex offers to migrate.
|
|
121
|
+
"""
|
|
122
|
+
config = config_manager.load_config_validated(config_cls=PipelexConfig)
|
|
123
|
+
return build_registrar(
|
|
124
|
+
config=config,
|
|
125
|
+
boot_orchestrator=None,
|
|
126
|
+
builtin_plugins=BUILTIN_PLUGINS,
|
|
127
|
+
core_unconditional_plugin_names=CORE_UNCONDITIONAL_PLUGIN_NAMES,
|
|
128
|
+
entry_point_groups=ENTRY_POINT_GROUPS,
|
|
129
|
+
).get_http_error_mappers()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
HTTP_ERROR_MAPPERS = _resolve_http_error_mappers()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _own_version() -> str:
|
|
136
|
+
"""This server package's version — best-effort for app metadata."""
|
|
137
|
+
try:
|
|
138
|
+
return package_version("pipelex-api")
|
|
139
|
+
except PackageNotFoundError:
|
|
140
|
+
return "0.0.0"
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
fastapi_app = PipelexFastAPI(
|
|
144
|
+
redirect_slashes=False,
|
|
145
|
+
lifespan=lifespan,
|
|
146
|
+
title="Pipelex API",
|
|
147
|
+
version=_own_version(),
|
|
148
|
+
summary=f"The source-available Pipelex runner — implements MTHDS Protocol v{PROTOCOL_VERSION}.",
|
|
149
|
+
description=(
|
|
150
|
+
f"This server implements the [MTHDS Protocol](https://mthds.ai) v{PROTOCOL_VERSION} "
|
|
151
|
+
"(`POST /execute`, `POST /start`, `POST /validate`, `GET /models`, `GET /version` — "
|
|
152
|
+
"marked `x-mthds-protocol: true`) plus the Pipelex API extensions: resolve and codegen "
|
|
153
|
+
"(`/resolve`, `/codegen`), build tooling (`/build/*`), and editor tooling (`/lint`, `/format`). "
|
|
154
|
+
"Contract layering: MTHDS Protocol ⊂ Pipelex API (this server) ⊂ Pipelex hosted API. "
|
|
155
|
+
"All endpoints are served under the `/v1` base path; "
|
|
156
|
+
"every error is an RFC 7807 `application/problem+json` problem document, documented per "
|
|
157
|
+
"operation as a `ProblemDocument`."
|
|
158
|
+
),
|
|
159
|
+
license_info={"name": "Elastic License 2.0", "identifier": "Elastic-2.0"},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Order matters: Starlette's `add_middleware` PREPENDS (see
|
|
163
|
+
# `user_middleware.insert(0, ...)` in `starlette.applications`), so the LAST
|
|
164
|
+
# `add_middleware` call becomes the OUTERMOST wrapper. Body-size is registered
|
|
165
|
+
# first so CORS ends up wrapping it: a 413 short-circuit from the body-size
|
|
166
|
+
# middleware still passes back through CORSMiddleware on the way out, so a
|
|
167
|
+
# cross-origin browser POST sees the RFC 7807 413 with the
|
|
168
|
+
# `Access-Control-Allow-Origin` header it needs — not a generic CORS error
|
|
169
|
+
# that swallows the response.
|
|
170
|
+
fastapi_app.add_middleware(BaseHTTPMiddleware, dispatch=request_body_size_middleware)
|
|
171
|
+
|
|
172
|
+
cors_origins, cors_allow_credentials = _resolve_cors_origins()
|
|
173
|
+
fastapi_app.add_middleware(
|
|
174
|
+
CORSMiddleware,
|
|
175
|
+
allow_origins=cors_origins,
|
|
176
|
+
allow_credentials=cors_allow_credentials,
|
|
177
|
+
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
178
|
+
allow_headers=["*"],
|
|
179
|
+
expose_headers=["*"],
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
fastapi_app.include_router(health_router)
|
|
183
|
+
|
|
184
|
+
# `GET /v1/version` is the protocol handshake — ALWAYS public, mounted without
|
|
185
|
+
# the auth dependency exactly like `/health` (clients call it for feature
|
|
186
|
+
# detection before they have credentials).
|
|
187
|
+
fastapi_app.include_router(version_router, prefix="/v1")
|
|
188
|
+
|
|
189
|
+
# Register all other routes WITH authentication (auto-selects based on AUTH_MODE env var: none/jwt/api_key).
|
|
190
|
+
# The API mounts at `/v1` — the SDKs compose `{MTHDS_BASE_URL}/v1/{endpoint}` (master D10); no `/api/v1`
|
|
191
|
+
# mount and no alias remain.
|
|
192
|
+
auth_dependency = get_auth_dependency()
|
|
193
|
+
fastapi_app.include_router(api_router, prefix="/v1", dependencies=[Depends(auth_dependency)])
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ServiceIdentity(BaseModel):
|
|
197
|
+
"""Body of `GET /` — the service identity banner."""
|
|
198
|
+
|
|
199
|
+
message: str = Field(..., description="Name of the service answering on this origin.")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@fastapi_app.get("/", summary="Service identity banner", tags=["health"])
|
|
203
|
+
async def root() -> ServiceIdentity:
|
|
204
|
+
"""Identify the service answering on this origin. No auth required.
|
|
205
|
+
|
|
206
|
+
A human-facing banner for someone who lands on the bare origin — not a
|
|
207
|
+
liveness probe (that is `GET /health`) and not the protocol handshake
|
|
208
|
+
(`GET /v1/version`). It reads nothing and can only succeed.
|
|
209
|
+
"""
|
|
210
|
+
return ServiceIdentity(message="Pipelex API")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
register_exception_handlers(fastapi_app, disclosure_mode=ERROR_DISCLOSURE_MODE, http_error_mappers=HTTP_ERROR_MAPPERS)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# RequestIdMiddleware wraps the *entire* FastAPI app — including Starlette's
|
|
217
|
+
# ServerErrorMiddleware, which `add_middleware` could only ever nest inside.
|
|
218
|
+
# This is what makes it genuinely outermost: the request-id contextvars are
|
|
219
|
+
# bound, and `X-Request-ID` is echoed, on every response — the catch-all 500
|
|
220
|
+
# included. `app` is the ASGI entrypoint (uvicorn loads `pipelex_api.main:app`).
|
|
221
|
+
app = RequestIdMiddleware(fastapi_app)
|