semblance-foundry 0.1.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.
@@ -0,0 +1,17 @@
1
+ """
2
+ Unofficial local simulation of selected Palantir Foundry API v2 ontology operations.
3
+
4
+ Not affiliated with, endorsed by, or equivalent to Palantir Foundry.
5
+ """
6
+
7
+ from semblance_foundry.app import FoundryMock
8
+ from semblance_foundry.config import FoundryMockConfig, TokenGrant
9
+ from semblance_foundry.testing import FoundryMockContext, foundry_test_client
10
+
11
+ __all__ = [
12
+ "FoundryMock",
13
+ "FoundryMockConfig",
14
+ "FoundryMockContext",
15
+ "TokenGrant",
16
+ "foundry_test_client",
17
+ ]
@@ -0,0 +1,89 @@
1
+ """FoundryMock factory and ASGI construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ from collections.abc import Callable
7
+ from importlib.metadata import PackageNotFoundError
8
+ from importlib.metadata import version as pkg_version
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from fastapi import FastAPI
13
+
14
+ from semblance_foundry.auth import FoundryMiddleware
15
+ from semblance_foundry.compatibility import manifest_json
16
+ from semblance_foundry.config import FoundryMockConfig
17
+ from semblance_foundry.errors import register_exception_handlers
18
+ from semblance_foundry.fixtures.loaders import (
19
+ apply_fixture,
20
+ bundled_acme_path,
21
+ load_fixture_file,
22
+ )
23
+ from semblance_foundry.ids import PageTokenCodec, rid_secret
24
+ from semblance_foundry.services.ontologies import create_ontology_router
25
+ from semblance_foundry.state import FoundryState, OntologyMutations
26
+
27
+ _BUNDLED_FIXTURES = {"acme": bundled_acme_path}
28
+
29
+
30
+ def _distribution_version() -> str:
31
+ try:
32
+ return pkg_version("semblance-foundry")
33
+ except PackageNotFoundError:
34
+ return "0.1.0"
35
+
36
+
37
+ class FoundryMock:
38
+ """Fixture-backed local simulation of selected Foundry API v2 operations."""
39
+
40
+ def __init__(self, config: FoundryMockConfig | None = None) -> None:
41
+ self.config = config or FoundryMockConfig()
42
+ self.state = FoundryState(seed=self.config.seed)
43
+ self.page_token_codec = PageTokenCodec(rid_secret(self.config.seed))
44
+ self._rng = random.Random(self.config.seed)
45
+ self.ontologies = OntologyMutations(self.state)
46
+
47
+ def load_fixture(self, path: str | Path) -> FoundryMock:
48
+ doc = load_fixture_file(path)
49
+ apply_fixture(doc, self.state)
50
+ return self
51
+
52
+ def load_bundled_fixture(self, name: str = "acme") -> FoundryMock:
53
+ """Load a fixture shipped with the package (currently ``acme``)."""
54
+ loader = _BUNDLED_FIXTURES.get(name)
55
+ if loader is None:
56
+ known = ", ".join(sorted(_BUNDLED_FIXTURES))
57
+ raise ValueError(
58
+ f"unknown bundled fixture {name!r}; expected one of: {known}"
59
+ )
60
+ return self.load_fixture(loader())
61
+
62
+ def register_query(self, api_name: str, fn: Callable[..., Any]) -> None:
63
+ """Register an allow-listed Python callback for query execute."""
64
+ self.state.register_query(api_name, fn)
65
+
66
+ def as_fastapi(self) -> FastAPI:
67
+ app = FastAPI(
68
+ title="semblance-foundry",
69
+ description=(
70
+ "Unofficial local simulation of selected Palantir Foundry "
71
+ "API v2 ontology operations. Not affiliated with Palantir."
72
+ ),
73
+ version=_distribution_version(),
74
+ )
75
+ app.state.foundry_mock = self
76
+ register_exception_handlers(app, self.config.seed)
77
+ app.add_middleware(
78
+ FoundryMiddleware,
79
+ config=self.config,
80
+ rng=self._rng,
81
+ error_counter={"n": 0},
82
+ )
83
+ app.include_router(create_ontology_router())
84
+
85
+ @app.get("/.well-known/foundry-mock-compatibility.json")
86
+ def compatibility() -> dict[str, Any]:
87
+ return manifest_json()
88
+
89
+ return app
@@ -0,0 +1,167 @@
1
+ """Auth, request-id, latency, error injection, and rate-limit middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ import time
8
+ from collections import defaultdict
9
+ from collections.abc import Callable
10
+ from threading import Lock
11
+ from typing import Any
12
+
13
+ from starlette.middleware.base import BaseHTTPMiddleware
14
+ from starlette.requests import Request
15
+ from starlette.responses import JSONResponse, Response
16
+
17
+ from semblance_foundry.config import FoundryMockConfig
18
+ from semblance_foundry.errors import foundry_error_body, next_error_instance_id
19
+
20
+ REQUEST_ID_HEADER = "X-Request-ID"
21
+
22
+
23
+ class _SlidingWindow:
24
+ def __init__(self) -> None:
25
+ self._timestamps: dict[tuple[str, str], list[float]] = defaultdict(list)
26
+ self._lock = Lock()
27
+
28
+ def allow(self, path: str, method: str, limit: float) -> bool:
29
+ if limit <= 0:
30
+ return True
31
+ now = time.monotonic()
32
+ key = (path, method)
33
+ with self._lock:
34
+ ts_list = self._timestamps[key]
35
+ ts_list[:] = [t for t in ts_list if now - t < 1.0]
36
+ if len(ts_list) >= limit:
37
+ return False
38
+ ts_list.append(now)
39
+ return True
40
+
41
+
42
+ def _bearer_token(header: str | None) -> str | None:
43
+ if header is None or header == "":
44
+ return None
45
+ parts = header.split(None, 1)
46
+ if len(parts) != 2 or parts[0].lower() != "bearer":
47
+ raise ValueError("malformed")
48
+ token = parts[1].strip()
49
+ if not token:
50
+ raise ValueError("empty")
51
+ return token
52
+
53
+
54
+ class FoundryMiddleware(BaseHTTPMiddleware):
55
+ """Auth, request-id, optional latency / error injection / rate limit."""
56
+
57
+ def __init__(
58
+ self,
59
+ app: Any,
60
+ config: FoundryMockConfig,
61
+ rng: random.Random,
62
+ error_counter: dict[str, int],
63
+ ) -> None:
64
+ super().__init__(app)
65
+ self._config = config
66
+ self._rng = rng
67
+ self._error_counter = error_counter
68
+ self._limiter = _SlidingWindow()
69
+
70
+ def _instance_id(self) -> str:
71
+ self._error_counter["n"] = self._error_counter.get("n", 0) + 1
72
+ return next_error_instance_id(
73
+ self._config.seed, self._error_counter["n"] + 10_000
74
+ )
75
+
76
+ async def dispatch(
77
+ self, request: Request, call_next: Callable[[Request], Any]
78
+ ) -> Response:
79
+ incoming = request.headers.get(REQUEST_ID_HEADER)
80
+ request_id = incoming if incoming else f"req-{self._config.seed}-{id(request)}"
81
+ request.state.request_id = request_id
82
+
83
+ if self._config.rate_limit is not None:
84
+ if not self._limiter.allow(
85
+ request.url.path, request.method, self._config.rate_limit
86
+ ):
87
+ body = foundry_error_body(
88
+ error_code="PERMISSION_DENIED",
89
+ error_name="RateLimitExceeded",
90
+ error_instance_id=self._instance_id(),
91
+ parameters={},
92
+ )
93
+ response = JSONResponse(status_code=429, content=body)
94
+ response.headers[REQUEST_ID_HEADER] = request_id
95
+ return response
96
+
97
+ auth_error = self._check_auth(request)
98
+ if auth_error is not None:
99
+ auth_error.headers[REQUEST_ID_HEADER] = request_id
100
+ return auth_error
101
+
102
+ if self._config.latency_ms or self._config.jitter_ms:
103
+ delay = self._config.latency_ms / 1000.0
104
+ if self._config.jitter_ms:
105
+ delay += self._rng.uniform(0, self._config.jitter_ms) / 1000.0
106
+ if delay > 0:
107
+ await asyncio.sleep(delay)
108
+
109
+ if self._config.error_rate > 0 and self._rng.random() < self._config.error_rate:
110
+ codes = self._config.error_codes or [500]
111
+ status = self._rng.choice(codes)
112
+ body = foundry_error_body(
113
+ error_code="INTERNAL",
114
+ error_name="InjectedError",
115
+ error_instance_id=self._instance_id(),
116
+ parameters={"statusCode": status},
117
+ )
118
+ response = JSONResponse(status_code=status, content=body)
119
+ response.headers[REQUEST_ID_HEADER] = request_id
120
+ return response
121
+
122
+ response = await call_next(request)
123
+ response.headers[REQUEST_ID_HEADER] = request_id
124
+ return response # type: ignore[no-any-return]
125
+
126
+ def _check_auth(self, request: Request) -> JSONResponse | None:
127
+ mode = self._config.auth
128
+ if mode == "disabled":
129
+ return None
130
+ header = request.headers.get("authorization")
131
+ try:
132
+ token = _bearer_token(header)
133
+ except ValueError:
134
+ return JSONResponse(
135
+ status_code=401,
136
+ content=foundry_error_body(
137
+ error_code="UNAUTHORIZED",
138
+ error_name="InvalidCredentials",
139
+ error_instance_id=self._instance_id(),
140
+ parameters={},
141
+ ),
142
+ )
143
+ if mode == "optional":
144
+ return None
145
+ # strict
146
+ if token is None:
147
+ return JSONResponse(
148
+ status_code=401,
149
+ content=foundry_error_body(
150
+ error_code="UNAUTHORIZED",
151
+ error_name="MissingCredentials",
152
+ error_instance_id=self._instance_id(),
153
+ parameters={},
154
+ ),
155
+ )
156
+ allowed = {g.token for g in self._config.tokens}
157
+ if token not in allowed:
158
+ return JSONResponse(
159
+ status_code=401,
160
+ content=foundry_error_body(
161
+ error_code="UNAUTHORIZED",
162
+ error_name="InvalidCredentials",
163
+ error_instance_id=self._instance_id(),
164
+ parameters={},
165
+ ),
166
+ )
167
+ return None
@@ -0,0 +1,105 @@
1
+ """CLI: serve, validate, fixture init, operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from semblance_foundry.app import FoundryMock
10
+ from semblance_foundry.config import FoundryMockConfig
11
+ from semblance_foundry.fixtures.loaders import bundled_acme_path, load_fixture_file
12
+ from semblance_foundry.registry import registered_operations
13
+
14
+
15
+ def cmd_serve(args: argparse.Namespace) -> None:
16
+ config = FoundryMockConfig(seed=args.seed, auth=args.auth)
17
+ mock = FoundryMock(config)
18
+ mock.load_fixture(args.fixture or bundled_acme_path())
19
+ app = mock.as_fastapi()
20
+ try:
21
+ import uvicorn
22
+ except ImportError as exc:
23
+ raise SystemExit(
24
+ "uvicorn not found. Install semblance (pulls uvicorn)."
25
+ ) from exc
26
+ uvicorn.run(app, host=args.host, port=args.port)
27
+
28
+
29
+ def cmd_validate(args: argparse.Namespace) -> None:
30
+ try:
31
+ load_fixture_file(args.fixture)
32
+ except Exception as exc:
33
+ raise SystemExit(f"Invalid fixture: {exc}") from exc
34
+ print("OK")
35
+
36
+
37
+ def cmd_fixture_init(args: argparse.Namespace) -> None:
38
+ dest = Path(args.output)
39
+ if dest.exists() and not args.force:
40
+ raise SystemExit(f"{dest} already exists (use --force to overwrite)")
41
+ dest.write_text(bundled_acme_path().read_text(encoding="utf-8"), encoding="utf-8")
42
+ print(f"Wrote {dest}")
43
+
44
+
45
+ def cmd_operations(_args: argparse.Namespace) -> None:
46
+ rows = registered_operations()
47
+ width = max((len(op.operation_id) for op in rows), default=8)
48
+ print(f"{'operation':<{width}} {'level':<16} method path")
49
+ for op in rows:
50
+ print(
51
+ f"{op.operation_id:<{width}} {op.support_level:<16} "
52
+ f"{op.method:<6} {op.path}"
53
+ )
54
+
55
+
56
+ def build_parser() -> argparse.ArgumentParser:
57
+ parser = argparse.ArgumentParser(
58
+ prog="semblance-foundry",
59
+ description=(
60
+ "Unofficial local Foundry API v2 ontology mock. "
61
+ "Not affiliated with Palantir."
62
+ ),
63
+ )
64
+ sub = parser.add_subparsers(dest="command", required=True)
65
+
66
+ serve = sub.add_parser("serve", help="Run a local mock server")
67
+ serve.add_argument(
68
+ "--fixture",
69
+ default=None,
70
+ help="YAML or JSON fixture path (default: bundled acme example)",
71
+ )
72
+ serve.add_argument("--host", default="127.0.0.1")
73
+ serve.add_argument("--port", type=int, default=8765)
74
+ serve.add_argument("--seed", type=int, default=42)
75
+ serve.add_argument(
76
+ "--auth",
77
+ default="optional",
78
+ choices=["disabled", "optional", "strict"],
79
+ )
80
+ serve.set_defaults(func=cmd_serve)
81
+
82
+ validate = sub.add_parser("validate", help="Validate a fixture without serving")
83
+ validate.add_argument("fixture")
84
+ validate.set_defaults(func=cmd_validate)
85
+
86
+ init = sub.add_parser("fixture", help="Fixture helpers")
87
+ init_sub = init.add_subparsers(dest="fixture_command", required=True)
88
+ init_cmd = init_sub.add_parser("init", help="Write the bundled example fixture")
89
+ init_cmd.add_argument("--output", required=True)
90
+ init_cmd.add_argument("--force", action="store_true")
91
+ init_cmd.set_defaults(func=cmd_fixture_init)
92
+
93
+ ops = sub.add_parser("operations", help="Print the compatibility table")
94
+ ops.set_defaults(func=cmd_operations)
95
+ return parser
96
+
97
+
98
+ def main(argv: list[str] | None = None) -> None:
99
+ parser = build_parser()
100
+ args = parser.parse_args(argv)
101
+ args.func(args)
102
+
103
+
104
+ if __name__ == "__main__":
105
+ main(sys.argv[1:])
@@ -0,0 +1,30 @@
1
+ """Load and publish the per-operation compatibility manifest."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import lru_cache
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import yaml
10
+
11
+ _MANIFEST_PATH = Path(__file__).resolve().parent / "compatibility.yaml"
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def load_manifest() -> dict[str, Any]:
16
+ data = yaml.safe_load(_MANIFEST_PATH.read_text(encoding="utf-8"))
17
+ if not isinstance(data, dict):
18
+ raise ValueError("compatibility.yaml must be a mapping")
19
+ return data
20
+
21
+
22
+ def manifest_json() -> dict[str, Any]:
23
+ return load_manifest()
24
+
25
+
26
+ def operations_table() -> list[dict[str, Any]]:
27
+ ops = load_manifest().get("operations", [])
28
+ if not isinstance(ops, list):
29
+ return []
30
+ return [op for op in ops if isinstance(op, dict)]
@@ -0,0 +1,102 @@
1
+ packageVersion: "0.1.0"
2
+ documentedAt: "2026-08-18"
3
+ source: "https://www.palantir.com/docs/foundry/api/v2"
4
+ operations:
5
+ - operationId: ListOntologies
6
+ method: GET
7
+ path: /api/v2/ontologies
8
+ supportLevel: exact
9
+ docsUrl: https://www.palantir.com/docs/foundry/api/v2/ontologies-v2-resources/ontologies/list-ontologies
10
+ tests:
11
+ - tests/contract/test_ontologies.py::test_list_ontologies
12
+ - operationId: GetOntology
13
+ method: GET
14
+ path: /api/v2/ontologies/{ontology}
15
+ supportLevel: exact
16
+ docsUrl: https://www.palantir.com/docs/foundry/api/v2/ontologies-v2-resources/ontologies/get-ontology
17
+ tests:
18
+ - tests/contract/test_ontologies.py::test_get_ontology
19
+ - operationId: ListObjectTypes
20
+ method: GET
21
+ path: /api/v2/ontologies/{ontology}/objectTypes
22
+ supportLevel: exact
23
+ docsUrl: https://www.palantir.com/docs/foundry/api/v2/ontologies-v2-resources/object-types/list-object-types
24
+ tests:
25
+ - tests/contract/test_object_types.py::test_list_object_types
26
+ - operationId: GetObjectType
27
+ method: GET
28
+ path: /api/v2/ontologies/{ontology}/objectTypes/{objectType}
29
+ supportLevel: exact
30
+ tests:
31
+ - tests/contract/test_object_types.py::test_get_object_type
32
+ - operationId: ListObjects
33
+ method: GET
34
+ path: /api/v2/ontologies/{ontology}/objects/{objectType}
35
+ supportLevel: exact
36
+ docsUrl: https://www.palantir.com/docs/foundry/api/v2/ontologies-v2-resources/ontology-objects/list-objects
37
+ tests:
38
+ - tests/contract/test_objects.py::test_list_objects
39
+ - operationId: GetObject
40
+ method: GET
41
+ path: /api/v2/ontologies/{ontology}/objects/{objectType}/{primaryKey}
42
+ supportLevel: exact
43
+ tests:
44
+ - tests/contract/test_objects.py::test_get_object
45
+ - operationId: SearchObjects
46
+ method: POST
47
+ path: /api/v2/ontologies/{ontology}/objects/{objectType}/search
48
+ supportLevel: representative
49
+ docsUrl: https://www.palantir.com/docs/foundry/api/v2/ontologies-v2-resources/ontology-objects/search-objects
50
+ notes: eq and and-of-eq filters only
51
+ tests:
52
+ - tests/contract/test_search.py::test_search_eq
53
+ - operationId: ListLinkedObjects
54
+ method: GET
55
+ path: /api/v2/ontologies/{ontology}/objects/{objectType}/{primaryKey}/links/{linkType}
56
+ supportLevel: exact
57
+ tests:
58
+ - tests/contract/test_links.py::test_list_linked_objects
59
+ - operationId: ListActionTypes
60
+ method: GET
61
+ path: /api/v2/ontologies/{ontology}/actionTypes
62
+ supportLevel: exact
63
+ notes: metadata only; apply is unimplemented
64
+ tests:
65
+ - tests/contract/test_actions_queries.py::test_list_action_types
66
+ - operationId: GetActionType
67
+ method: GET
68
+ path: /api/v2/ontologies/{ontology}/actionTypes/{actionType}
69
+ supportLevel: exact
70
+ tests:
71
+ - tests/contract/test_actions_queries.py::test_get_action_type
72
+ - operationId: ListQueryTypes
73
+ method: GET
74
+ path: /api/v2/ontologies/{ontology}/queryTypes
75
+ supportLevel: exact
76
+ tests:
77
+ - tests/contract/test_actions_queries.py::test_list_query_types
78
+ - operationId: GetQueryType
79
+ method: GET
80
+ path: /api/v2/ontologies/{ontology}/queryTypes/{queryApiName}
81
+ supportLevel: exact
82
+ tests:
83
+ - tests/contract/test_actions_queries.py::test_get_query_type
84
+ - operationId: ExecuteQuery
85
+ method: POST
86
+ path: /api/v2/ontologies/{ontology}/queries/{queryApiName}/execute
87
+ supportLevel: representative
88
+ notes: static fixture result or allow-listed Python callback
89
+ tests:
90
+ - tests/contract/test_actions_queries.py::test_execute_query
91
+ - operationId: ApplyAction
92
+ method: POST
93
+ path: /api/v2/ontologies/{ontology}/actions/{actionType}/apply
94
+ supportLevel: unsupported
95
+ tests:
96
+ - tests/contract/test_unsupported.py::test_apply_strict_501
97
+ - operationId: ApplyActionBatch
98
+ method: POST
99
+ path: /api/v2/ontologies/{ontology}/actions/{actionType}/applyBatch
100
+ supportLevel: unsupported
101
+ tests:
102
+ - tests/contract/test_unsupported.py::test_apply_batch_strict_501
@@ -0,0 +1,43 @@
1
+ """Typed configuration for FoundryMock."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+ AuthMode = Literal["disabled", "optional", "strict"]
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class TokenGrant:
13
+ """A token accepted in strict auth mode. Never log or echo ``token``."""
14
+
15
+ token: str
16
+ scopes: tuple[str, ...] = ()
17
+
18
+
19
+ @dataclass
20
+ class FoundryMockConfig:
21
+ """Runtime options for a Foundry mock server."""
22
+
23
+ seed: int | None = 42
24
+ auth: AuthMode = "optional"
25
+ stateful: bool = True
26
+ tokens: tuple[TokenGrant, ...] = ()
27
+ error_rate: float = 0.0
28
+ error_codes: list[int] | None = None
29
+ latency_ms: float = 0.0
30
+ jitter_ms: float = 0.0
31
+ rate_limit: float | None = None
32
+ default_page_size: int = 100
33
+ max_page_size: int = 1000
34
+
35
+ def __post_init__(self) -> None:
36
+ if self.error_codes is None:
37
+ self.error_codes = [500]
38
+ if self.auth not in ("disabled", "optional", "strict"):
39
+ raise ValueError(
40
+ f"auth must be 'disabled', 'optional', or 'strict', got {self.auth!r}"
41
+ )
42
+ if not 0.0 <= self.error_rate <= 1.0:
43
+ raise ValueError("error_rate must be between 0 and 1")
@@ -0,0 +1,131 @@
1
+ """Foundry-style error envelope and exception mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from typing import Any
7
+
8
+ from fastapi import Request
9
+ from fastapi.exceptions import RequestValidationError
10
+ from fastapi.responses import JSONResponse
11
+ from starlette.exceptions import HTTPException as StarletteHTTPException
12
+
13
+
14
+ class FoundryError(Exception):
15
+ """Raised to produce a Foundry-compliant error response."""
16
+
17
+ def __init__(
18
+ self,
19
+ status_code: int,
20
+ error_code: str,
21
+ error_name: str,
22
+ parameters: dict[str, Any] | None = None,
23
+ error_instance_id: str | None = None,
24
+ ) -> None:
25
+ self.status_code = status_code
26
+ self.error_code = error_code
27
+ self.error_name = error_name
28
+ self.parameters = parameters or {}
29
+ self.error_instance_id = error_instance_id
30
+ super().__init__(error_name)
31
+
32
+ def to_body(self, instance_id: str) -> dict[str, Any]:
33
+ return {
34
+ "errorCode": self.error_code,
35
+ "errorName": self.error_name,
36
+ "errorInstanceId": instance_id,
37
+ "parameters": self.parameters,
38
+ }
39
+
40
+
41
+ def next_error_instance_id(seed: int | None, counter: int) -> str:
42
+ """Deterministic errorInstanceId from seed + counter."""
43
+ return str(
44
+ uuid.uuid5(
45
+ uuid.NAMESPACE_URL,
46
+ f"semblance-foundry:error:{seed}:{counter}",
47
+ )
48
+ )
49
+
50
+
51
+ def foundry_error_body(
52
+ *,
53
+ error_code: str,
54
+ error_name: str,
55
+ error_instance_id: str,
56
+ parameters: dict[str, Any] | None = None,
57
+ ) -> dict[str, Any]:
58
+ return {
59
+ "errorCode": error_code,
60
+ "errorName": error_name,
61
+ "errorInstanceId": error_instance_id,
62
+ "parameters": parameters or {},
63
+ }
64
+
65
+
66
+ def register_exception_handlers(app: Any, seed: int | None) -> None:
67
+ """Attach Foundry JSON error handlers to a FastAPI app."""
68
+
69
+ counter = {"n": 0}
70
+
71
+ def _instance_id() -> str:
72
+ counter["n"] += 1
73
+ return next_error_instance_id(seed, counter["n"])
74
+
75
+ @app.exception_handler(FoundryError)
76
+ async def foundry_error_handler(
77
+ request: Request, exc: FoundryError
78
+ ) -> JSONResponse:
79
+ instance_id = exc.error_instance_id or _instance_id()
80
+ return JSONResponse(
81
+ status_code=exc.status_code,
82
+ content=exc.to_body(instance_id),
83
+ )
84
+
85
+ @app.exception_handler(RequestValidationError)
86
+ async def validation_handler(
87
+ request: Request, exc: RequestValidationError
88
+ ) -> JSONResponse:
89
+ return JSONResponse(
90
+ status_code=400,
91
+ content=foundry_error_body(
92
+ error_code="INVALID_ARGUMENT",
93
+ error_name="InvalidArgument",
94
+ error_instance_id=_instance_id(),
95
+ parameters={"errors": exc.errors()},
96
+ ),
97
+ )
98
+
99
+ @app.exception_handler(StarletteHTTPException)
100
+ async def http_exception_handler(
101
+ request: Request, exc: StarletteHTTPException
102
+ ) -> JSONResponse:
103
+ if exc.status_code == 404:
104
+ return JSONResponse(
105
+ status_code=404,
106
+ content=foundry_error_body(
107
+ error_code="NOT_FOUND",
108
+ error_name="NotFound",
109
+ error_instance_id=_instance_id(),
110
+ parameters={"path": str(request.url.path)},
111
+ ),
112
+ )
113
+ if exc.status_code == 405:
114
+ return JSONResponse(
115
+ status_code=405,
116
+ content=foundry_error_body(
117
+ error_code="INVALID_ARGUMENT",
118
+ error_name="MethodNotAllowed",
119
+ error_instance_id=_instance_id(),
120
+ parameters={"path": str(request.url.path)},
121
+ ),
122
+ )
123
+ return JSONResponse(
124
+ status_code=exc.status_code,
125
+ content=foundry_error_body(
126
+ error_code="UNKNOWN",
127
+ error_name="HttpException",
128
+ error_instance_id=_instance_id(),
129
+ parameters={},
130
+ ),
131
+ )