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.
- semblance_foundry/__init__.py +17 -0
- semblance_foundry/app.py +89 -0
- semblance_foundry/auth.py +167 -0
- semblance_foundry/cli.py +105 -0
- semblance_foundry/compatibility.py +30 -0
- semblance_foundry/compatibility.yaml +102 -0
- semblance_foundry/config.py +43 -0
- semblance_foundry/errors.py +131 -0
- semblance_foundry/fixtures/__init__.py +15 -0
- semblance_foundry/fixtures/defaults/__init__.py +0 -0
- semblance_foundry/fixtures/defaults/acme.yaml +80 -0
- semblance_foundry/fixtures/loaders.py +239 -0
- semblance_foundry/ids.py +89 -0
- semblance_foundry/models/__init__.py +92 -0
- semblance_foundry/pagination.py +43 -0
- semblance_foundry/py.typed +0 -0
- semblance_foundry/registry.py +29 -0
- semblance_foundry/services/__init__.py +5 -0
- semblance_foundry/services/ontologies/__init__.py +5 -0
- semblance_foundry/services/ontologies/routes.py +551 -0
- semblance_foundry/state.py +281 -0
- semblance_foundry/testing.py +66 -0
- semblance_foundry-0.1.0.dist-info/METADATA +138 -0
- semblance_foundry-0.1.0.dist-info/RECORD +28 -0
- semblance_foundry-0.1.0.dist-info/WHEEL +5 -0
- semblance_foundry-0.1.0.dist-info/entry_points.txt +2 -0
- semblance_foundry-0.1.0.dist-info/licenses/LICENSE.md +21 -0
- semblance_foundry-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
semblance_foundry/app.py
ADDED
|
@@ -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
|
semblance_foundry/cli.py
ADDED
|
@@ -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
|
+
)
|