ictrp-mcp-server 0.1.0
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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +37 -0
- package/README.md +208 -0
- package/README_ZH.md +189 -0
- package/dist/cli/setup-cli.d.ts +14 -0
- package/dist/cli/setup-cli.js +230 -0
- package/dist/cli/setup-cli.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +477 -0
- package/dist/index.js.map +1 -0
- package/dist/runtime/bootstrap.d.ts +99 -0
- package/dist/runtime/bootstrap.js +350 -0
- package/dist/runtime/bootstrap.js.map +1 -0
- package/dist/runtime/env-probe.d.ts +108 -0
- package/dist/runtime/env-probe.js +479 -0
- package/dist/runtime/env-probe.js.map +1 -0
- package/dist/runtime/sidecar-client.d.ts +50 -0
- package/dist/runtime/sidecar-client.js +120 -0
- package/dist/runtime/sidecar-client.js.map +1 -0
- package/dist/runtime/supervisor.d.ts +47 -0
- package/dist/runtime/supervisor.js +248 -0
- package/dist/runtime/supervisor.js.map +1 -0
- package/package.json +60 -0
- package/sidecar/ictrp_sidecar.py +602 -0
- package/sidecar/vendor/ictrp_mcp/__init__.py +3 -0
- package/sidecar/vendor/ictrp_mcp/cache/__init__.py +0 -0
- package/sidecar/vendor/ictrp_mcp/cache/store.py +313 -0
- package/sidecar/vendor/ictrp_mcp/data/__init__.py +0 -0
- package/sidecar/vendor/ictrp_mcp/data/columns.py +108 -0
- package/sidecar/vendor/ictrp_mcp/data/jsonio.py +213 -0
- package/sidecar/vendor/ictrp_mcp/data/normalize.py +348 -0
- package/sidecar/vendor/ictrp_mcp/data/query.py +307 -0
- package/sidecar/vendor/ictrp_mcp/errors.py +123 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/__init__.py +0 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/export_guard.py +269 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/htmlstate.py +143 -0
- package/sidecar/vendor/ictrp_mcp/ictrp/session.py +245 -0
- package/sidecar/vendor/ictrp_mcp/offline.py +133 -0
- package/sidecar/vendor/ictrp_mcp/provenance.py +182 -0
- package/sidecar/vendor/ictrp_mcp/server.py +368 -0
- package/sidecar/vendor/ictrp_mcp/tools.py +712 -0
- package/sidecar/vendor/pyproject.toml +25 -0
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""HTTP sidecar for the WHO ICTRP search chain.
|
|
3
|
+
|
|
4
|
+
The MCP server is a Node process. The ICTRP access logic is Python. This module
|
|
5
|
+
is the narrow bridge between them: a stdlib-only HTTP server that exposes the
|
|
6
|
+
three-step postback chain (load form -> search -> export CSV) plus the local
|
|
7
|
+
query operations over a materialized set.
|
|
8
|
+
|
|
9
|
+
Why a sidecar rather than a port of the Python package to TypeScript:
|
|
10
|
+
|
|
11
|
+
* The chain's hard-won details -- which HTML controls to carry, which redirect
|
|
12
|
+
means "blocked" versus "contract drift", how a legitimate empty CSV differs
|
|
13
|
+
from a failed one -- are already implemented and tested in ``ictrp_mcp``.
|
|
14
|
+
A second implementation would drift.
|
|
15
|
+
* The Node layer must stay small. It is the MCP surface; the Python layer is
|
|
16
|
+
the domain.
|
|
17
|
+
|
|
18
|
+
Design constraints that shape everything below:
|
|
19
|
+
|
|
20
|
+
1. **Standard library only.** No Flask, no FastAPI, no uvicorn. The only
|
|
21
|
+
third-party dependency is ``httpx``, which the vendor install step provides
|
|
22
|
+
into a private venv. A sidecar that needs a web framework is a sidecar that
|
|
23
|
+
fails to start on a machine with an unusual Python.
|
|
24
|
+
|
|
25
|
+
2. **A failure never returns zero rows.** The whole point of this service is
|
|
26
|
+
that ICTRP's CSV export silently omits records. An error must therefore be
|
|
27
|
+
an HTTP error carrying a machine-readable code, never a 200 with an empty
|
|
28
|
+
result list. The caller distinguishes "0 matches" from "could not ask" by
|
|
29
|
+
status code, and nothing else.
|
|
30
|
+
|
|
31
|
+
3. **Sets are process state, not client state.** ``ictrp_mcp``'s materialized
|
|
32
|
+
sets live in memory keyed by ``set_id``. They do not survive a restart. The
|
|
33
|
+
sidecar keeps them for its own lifetime and says so honestly: an unknown
|
|
34
|
+
``set_id`` is ``409 set_expired``, and the caller re-searches. Pretending
|
|
35
|
+
otherwise would silently under-report.
|
|
36
|
+
|
|
37
|
+
4. **No browser, ever.** ICTRP needs no JavaScript execution to yield data.
|
|
38
|
+
There is deliberately no Playwright, no Chromium, no solver. If a check for
|
|
39
|
+
that ever appears here, it is a mistake -- see docs/DESIGN notes on the
|
|
40
|
+
measured chain.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import argparse
|
|
46
|
+
import asyncio
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
import os
|
|
50
|
+
import sys
|
|
51
|
+
import threading
|
|
52
|
+
import traceback
|
|
53
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
54
|
+
from typing import Any
|
|
55
|
+
from urllib.parse import parse_qs, urlparse
|
|
56
|
+
|
|
57
|
+
# --------------------------------------------------------------------------
|
|
58
|
+
# Import the ICTRP implementation.
|
|
59
|
+
#
|
|
60
|
+
# Three resolutions, tried in order, because the same file runs in three
|
|
61
|
+
# different layouts:
|
|
62
|
+
# 1. vendored -- ``sidecar/vendor/`` inside a published npm package. First,
|
|
63
|
+
# so a published install never silently picks up an unrelated
|
|
64
|
+
# ``ictrp_mcp`` that happens to be importable;
|
|
65
|
+
# 2. development -- the repository root is the grandparent of this file and
|
|
66
|
+
# the package lives under ``src/``;
|
|
67
|
+
# 3. installed -- pip put ``ictrp_mcp`` into the private venv that
|
|
68
|
+
# ``ictrp-setup setup`` builds.
|
|
69
|
+
# --------------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
72
|
+
|
|
73
|
+
_CANDIDATES = [
|
|
74
|
+
os.path.join(_HERE, "vendor"), # published
|
|
75
|
+
os.path.join(os.path.dirname(os.path.dirname(_HERE)), "src"), # checkout
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
for _candidate in _CANDIDATES:
|
|
79
|
+
if os.path.isdir(os.path.join(_candidate, "ictrp_mcp")) and _candidate not in sys.path:
|
|
80
|
+
sys.path.insert(0, _candidate)
|
|
81
|
+
break
|
|
82
|
+
|
|
83
|
+
from ictrp_mcp import offline # noqa: E402
|
|
84
|
+
from ictrp_mcp.data import query as query_mod # noqa: E402
|
|
85
|
+
from ictrp_mcp.errors import ErrorCode, IctrpError # noqa: E402
|
|
86
|
+
from ictrp_mcp.cache.store import SetStore, default_cache_dir, normalize_query # noqa: E402
|
|
87
|
+
from ictrp_mcp.ictrp.session import run_chain # noqa: E402
|
|
88
|
+
from ictrp_mcp.provenance import Provenance # noqa: E402
|
|
89
|
+
from ictrp_mcp.tools import DEFAULT_FIELDS, IctrpService # noqa: E402
|
|
90
|
+
|
|
91
|
+
LOG = logging.getLogger("ictrp-sidecar")
|
|
92
|
+
|
|
93
|
+
# Repeated verbatim on every row-bearing response. The Node layer also attaches
|
|
94
|
+
# it, but the sidecar must not depend on the caller remembering: anything that
|
|
95
|
+
# reads this HTTP API directly deserves the same warning.
|
|
96
|
+
_INCOMPLETENESS_NOTICE = (
|
|
97
|
+
"Counts describe retrieved rows only. The ICTRP CSV export is known to omit "
|
|
98
|
+
"records the portal itself reports as matches, so absence is not evidence "
|
|
99
|
+
"of nonexistence."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
103
|
+
DEFAULT_PORT = 8849
|
|
104
|
+
|
|
105
|
+
# The sidecar holds one IctrpService for its whole life. The service owns the
|
|
106
|
+
# upstream session and the set store; recreating it per request would discard
|
|
107
|
+
# the warm connection pool for no benefit.
|
|
108
|
+
_SERVICE_LOCK = threading.Lock()
|
|
109
|
+
_SERVICE: IctrpService | None = None
|
|
110
|
+
|
|
111
|
+
# One event loop per worker thread. ``IctrpService`` binds an
|
|
112
|
+
# ``httpx.AsyncClient`` to whatever loop first drives it; using that client
|
|
113
|
+
# from a second loop raises. Since ``ThreadingHTTPServer`` spawns a thread per
|
|
114
|
+
# connection, each thread gets its own loop and the client is re-created on
|
|
115
|
+
# demand inside the service.
|
|
116
|
+
_LOOP_LOCAL = threading.local()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _run_sync(coro: Any) -> Any:
|
|
120
|
+
"""Drive ``coro`` to completion on this thread's event loop."""
|
|
121
|
+
loop = getattr(_LOOP_LOCAL, "loop", None)
|
|
122
|
+
if loop is None or loop.is_closed():
|
|
123
|
+
loop = asyncio.new_event_loop()
|
|
124
|
+
_LOOP_LOCAL.loop = loop
|
|
125
|
+
return loop.run_until_complete(coro)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_service() -> IctrpService:
|
|
129
|
+
"""Return the process-wide service, creating it on first use."""
|
|
130
|
+
global _SERVICE
|
|
131
|
+
with _SERVICE_LOCK:
|
|
132
|
+
if _SERVICE is None:
|
|
133
|
+
_SERVICE = IctrpService()
|
|
134
|
+
LOG.info("ictrp service initialized (cache=%s)", default_cache_dir())
|
|
135
|
+
return _SERVICE
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --------------------------------------------------------------------------
|
|
139
|
+
# Error mapping
|
|
140
|
+
#
|
|
141
|
+
# The wire contract is: HTTP status says how the caller should react; the JSON
|
|
142
|
+
# body's ``error_code`` says what happened. Both are needed. A 502 tells the
|
|
143
|
+
# caller "retry or report an outage"; ``upstream_blocked`` tells it "do not
|
|
144
|
+
# hammer this, a human may need to look".
|
|
145
|
+
# --------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
_STATUS_FOR_CODE = {
|
|
148
|
+
ErrorCode.INVALID_ARGUMENT: 400,
|
|
149
|
+
ErrorCode.CACHE_MISS: 409,
|
|
150
|
+
ErrorCode.NO_RESULTS: 200, # not an error -- a legitimate empty answer
|
|
151
|
+
ErrorCode.UPSTREAM_BLOCKED: 503,
|
|
152
|
+
ErrorCode.UPSTREAM_CONTRACT_DRIFT: 502,
|
|
153
|
+
ErrorCode.UPSTREAM_ERROR: 502,
|
|
154
|
+
ErrorCode.SESSION_FAILED: 502,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def error_payload(exc: IctrpError) -> dict[str, Any]:
|
|
159
|
+
return {"ok": False, **exc.to_dict()}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def http_status_for(exc: IctrpError) -> int:
|
|
163
|
+
return _STATUS_FOR_CODE.get(exc.code, 502)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class SetExpired(Exception):
|
|
167
|
+
"""Raised when a ``set_id`` is not held by this process.
|
|
168
|
+
|
|
169
|
+
Distinct from ``CACHE_MISS`` because the remedy is different: the caller
|
|
170
|
+
must re-run the search, not fix its arguments.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, set_id: str) -> None:
|
|
174
|
+
super().__init__(f"set {set_id!r} is not held by this sidecar process")
|
|
175
|
+
self.set_id = set_id
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------------------
|
|
179
|
+
# Operations
|
|
180
|
+
# --------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def op_health() -> dict[str, Any]:
|
|
184
|
+
"""Liveness plus a description of what this sidecar can actually do.
|
|
185
|
+
|
|
186
|
+
Deliberately reports no upstream contact: a health check that performs a
|
|
187
|
+
search would drive a 39 MB export every time an installer polls it.
|
|
188
|
+
"""
|
|
189
|
+
service = get_service()
|
|
190
|
+
sets = service.store.list_sets()
|
|
191
|
+
return {
|
|
192
|
+
"ok": True,
|
|
193
|
+
"service": "ictrp-sidecar",
|
|
194
|
+
"version": _version(),
|
|
195
|
+
"python": sys.version.split()[0],
|
|
196
|
+
"cache_dir": str(default_cache_dir()),
|
|
197
|
+
"sets_held": len(sets),
|
|
198
|
+
"upstream": "https://trialsearch.who.int/",
|
|
199
|
+
"browser_required": False,
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _version() -> str:
|
|
204
|
+
try:
|
|
205
|
+
from ictrp_mcp import __version__ # type: ignore[attr-defined]
|
|
206
|
+
|
|
207
|
+
return str(__version__)
|
|
208
|
+
except Exception:
|
|
209
|
+
return "0.0.0"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def op_search(
|
|
213
|
+
keyword: str,
|
|
214
|
+
limit: int = 50,
|
|
215
|
+
offset: int = 0,
|
|
216
|
+
refresh: bool = False,
|
|
217
|
+
filters: list[dict[str, Any]] | None = None,
|
|
218
|
+
sort_by: str | None = None,
|
|
219
|
+
descending: bool = False,
|
|
220
|
+
fields: list[str] | None = None,
|
|
221
|
+
) -> dict[str, Any]:
|
|
222
|
+
"""Run the full chain and materialize the result set.
|
|
223
|
+
|
|
224
|
+
This is the only operation that touches the network in a costly way. It
|
|
225
|
+
always materializes the *entire* result set, not just the requested page:
|
|
226
|
+
the export is a single ~39 MB response, so fetching it partially is not
|
|
227
|
+
possible and re-fetching it per page would be absurd.
|
|
228
|
+
|
|
229
|
+
``IctrpService.search`` is the one async method on the service, because it
|
|
230
|
+
owns an ``httpx.AsyncClient``. ``ThreadingHTTPServer`` hands each request a
|
|
231
|
+
plain thread, so the coroutine is driven here on a dedicated loop.
|
|
232
|
+
|
|
233
|
+
The loop is **per thread**, not per process. A single shared loop would be
|
|
234
|
+
entered from whichever worker thread happened to serve the request, and an
|
|
235
|
+
``httpx.AsyncClient`` bound to one loop raises when driven from another.
|
|
236
|
+
"""
|
|
237
|
+
service = get_service()
|
|
238
|
+
coro = service.search(
|
|
239
|
+
keyword=keyword,
|
|
240
|
+
limit=limit,
|
|
241
|
+
offset=offset,
|
|
242
|
+
refresh=refresh,
|
|
243
|
+
filters=filters,
|
|
244
|
+
sort_by=sort_by,
|
|
245
|
+
descending=descending,
|
|
246
|
+
fields=fields,
|
|
247
|
+
)
|
|
248
|
+
return _run_sync(coro)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def op_filter(
|
|
252
|
+
set_id: str,
|
|
253
|
+
filters: list[dict[str, Any]] | None = None,
|
|
254
|
+
sort_by: str | None = None,
|
|
255
|
+
descending: bool = False,
|
|
256
|
+
limit: int = 50,
|
|
257
|
+
offset: int = 0,
|
|
258
|
+
fields: list[str] | None = None,
|
|
259
|
+
) -> dict[str, Any]:
|
|
260
|
+
return get_service().filter_set(
|
|
261
|
+
set_id=set_id,
|
|
262
|
+
filters=filters,
|
|
263
|
+
sort_by=sort_by,
|
|
264
|
+
descending=descending,
|
|
265
|
+
limit=limit,
|
|
266
|
+
offset=offset,
|
|
267
|
+
fields=fields,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def op_field_query(
|
|
272
|
+
field: str,
|
|
273
|
+
set_id: str | None = None,
|
|
274
|
+
keyword: str | None = None,
|
|
275
|
+
limit: int = 50,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
return get_service().field_query(field=field, set_id=set_id, keyword=keyword, limit=limit)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def op_registry_summary(set_id: str) -> dict[str, Any]:
|
|
281
|
+
return get_service().registry_summary(set_id=set_id)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def op_find_duplicates(set_id: str) -> dict[str, Any]:
|
|
285
|
+
return get_service().find_duplicates(set_id=set_id)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def op_export(
|
|
289
|
+
set_id: str | None = None,
|
|
290
|
+
keyword: str | None = None,
|
|
291
|
+
fmt: str = "json",
|
|
292
|
+
filters: list[dict[str, Any]] | None = None,
|
|
293
|
+
fields: list[str] | None = None,
|
|
294
|
+
include_provenance_header: bool = True,
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
return get_service().export_records(
|
|
297
|
+
set_id=set_id,
|
|
298
|
+
keyword=keyword,
|
|
299
|
+
fmt=fmt,
|
|
300
|
+
filters=filters,
|
|
301
|
+
fields=fields,
|
|
302
|
+
include_provenance_header=include_provenance_header,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def op_cache_status(action: str = "list", set_id: str | None = None) -> dict[str, Any]:
|
|
307
|
+
"""Inspect or purge the local set cache.
|
|
308
|
+
|
|
309
|
+
``action`` is ``"list"`` or ``"purge"`` -- the service names the operation
|
|
310
|
+
rather than taking a boolean, so the wire parameter follows it.
|
|
311
|
+
"""
|
|
312
|
+
return get_service().cache_status(action=action, set_id=set_id)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def op_snapshot(
|
|
316
|
+
keyword: str | None = None,
|
|
317
|
+
set_id: str | None = None,
|
|
318
|
+
path: str | None = None,
|
|
319
|
+
if_stale: bool = True,
|
|
320
|
+
) -> dict[str, Any]:
|
|
321
|
+
return get_service().snapshot(keyword=keyword, set_id=set_id, path=path, if_stale=if_stale)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def op_bundle_status(keyword: str | None = None) -> dict[str, Any]:
|
|
325
|
+
"""Report which offline bundle, if any, serves this keyword.
|
|
326
|
+
|
|
327
|
+
``keyword`` is optional at the HTTP boundary even though the service
|
|
328
|
+
requires it. Omitting it is a legitimate question -- "what bundles ship
|
|
329
|
+
with this install?" -- so we answer it from the bundle directory directly
|
|
330
|
+
rather than letting the service raise.
|
|
331
|
+
"""
|
|
332
|
+
service = get_service()
|
|
333
|
+
if keyword:
|
|
334
|
+
return service.bundle_status(keyword=keyword)
|
|
335
|
+
return {
|
|
336
|
+
"status": "ok",
|
|
337
|
+
"keyword": None,
|
|
338
|
+
"bundle_configured": {
|
|
339
|
+
"ICTRP_BUNDLE_PATH": os.environ.get(offline.ENV_BUNDLE_PATH),
|
|
340
|
+
"ICTRP_BUNDLE_DIR": os.environ.get(offline.ENV_BUNDLE_DIR),
|
|
341
|
+
"max_age_days": offline.DEFAULT_MAX_AGE_DAYS,
|
|
342
|
+
},
|
|
343
|
+
"note": "Pass keyword=... to check whether a bundle can answer that query.",
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def op_offline_lookup(keyword: str) -> dict[str, Any]:
|
|
348
|
+
"""Answer a keyword from a bundled snapshot without any network access.
|
|
349
|
+
|
|
350
|
+
Returns ``{"ok": true, "found": false}`` rather than an error when no
|
|
351
|
+
bundle matches: "this installation ships no snapshot for that keyword" is
|
|
352
|
+
the normal state of a live install, not a fault.
|
|
353
|
+
"""
|
|
354
|
+
loaded = offline.load_bundle(keyword)
|
|
355
|
+
if loaded is None:
|
|
356
|
+
return {
|
|
357
|
+
"ok": True,
|
|
358
|
+
"found": False,
|
|
359
|
+
"keyword": keyword,
|
|
360
|
+
"bundle_configured": {
|
|
361
|
+
"ICTRP_BUNDLE_PATH": os.environ.get(offline.ENV_BUNDLE_PATH),
|
|
362
|
+
"ICTRP_BUNDLE_DIR": os.environ.get(offline.ENV_BUNDLE_DIR),
|
|
363
|
+
"max_age_days": offline.max_age_days(),
|
|
364
|
+
},
|
|
365
|
+
}
|
|
366
|
+
snapshot, path = loaded
|
|
367
|
+
trials = [t.to_dict() if hasattr(t, "to_dict") else t for t in snapshot.trials]
|
|
368
|
+
return {
|
|
369
|
+
"ok": True,
|
|
370
|
+
"found": True,
|
|
371
|
+
"keyword": keyword,
|
|
372
|
+
"path": str(path),
|
|
373
|
+
"rows_returned": len(trials),
|
|
374
|
+
"records_incomplete": True,
|
|
375
|
+
"incompleteness_notice": _INCOMPLETENESS_NOTICE,
|
|
376
|
+
"provenance": offline.snapshot_provenance(snapshot, path),
|
|
377
|
+
"trials": trials,
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# --------------------------------------------------------------------------
|
|
382
|
+
# HTTP layer
|
|
383
|
+
# --------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
_ROUTES = {
|
|
386
|
+
"/health": op_health,
|
|
387
|
+
"/cache-status": op_cache_status,
|
|
388
|
+
"/bundle-status": op_bundle_status,
|
|
389
|
+
"/offline-lookup": op_offline_lookup,
|
|
390
|
+
"/search": op_search,
|
|
391
|
+
"/filter": op_filter,
|
|
392
|
+
"/field-query": op_field_query,
|
|
393
|
+
"/registry-summary": op_registry_summary,
|
|
394
|
+
"/find-duplicates": op_find_duplicates,
|
|
395
|
+
"/export": op_export,
|
|
396
|
+
"/snapshot": op_snapshot,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
# Operations that must never be triggered by a bare GET poll. Search drives a
|
|
400
|
+
# multi-megabyte upstream export; an installer or supervisor probing "/" must
|
|
401
|
+
# not cause one.
|
|
402
|
+
_EXPENSIVE = {"/search", "/snapshot"}
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _as_bool(raw: Any, default: bool = False) -> bool:
|
|
406
|
+
if raw is None:
|
|
407
|
+
return default
|
|
408
|
+
if isinstance(raw, bool):
|
|
409
|
+
return raw
|
|
410
|
+
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _as_int(raw: Any, default: int) -> int:
|
|
414
|
+
try:
|
|
415
|
+
return int(raw)
|
|
416
|
+
except (TypeError, ValueError):
|
|
417
|
+
return default
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _json_body(value: Any) -> bytes:
|
|
421
|
+
return json.dumps(value, ensure_ascii=False, default=str).encode("utf-8")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class Handler(BaseHTTPRequestHandler):
|
|
425
|
+
protocol_version = "HTTP/1.1"
|
|
426
|
+
server_version = "ictrp-sidecar"
|
|
427
|
+
|
|
428
|
+
# -- plumbing ---------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
|
431
|
+
LOG.debug("%s - %s", self.address_string(), fmt % args)
|
|
432
|
+
|
|
433
|
+
def _send(self, status: int, payload: Any) -> None:
|
|
434
|
+
body = _json_body(payload)
|
|
435
|
+
self.send_response(status)
|
|
436
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
437
|
+
self.send_header("Content-Length", str(len(body)))
|
|
438
|
+
self.end_headers()
|
|
439
|
+
self.wfile.write(body)
|
|
440
|
+
|
|
441
|
+
def _params(self) -> dict[str, Any]:
|
|
442
|
+
"""Merge query-string and JSON-body parameters.
|
|
443
|
+
|
|
444
|
+
Both are accepted because GET is convenient for curl-based debugging
|
|
445
|
+
while POST keeps a long filter array out of the URL.
|
|
446
|
+
"""
|
|
447
|
+
merged: dict[str, Any] = {}
|
|
448
|
+
parsed = urlparse(self.path)
|
|
449
|
+
for key, values in parse_qs(parsed.query).items():
|
|
450
|
+
merged[key] = values[0] if len(values) == 1 else values
|
|
451
|
+
|
|
452
|
+
length = _as_int(self.headers.get("Content-Length"), 0)
|
|
453
|
+
if length > 0:
|
|
454
|
+
raw = self.rfile.read(length)
|
|
455
|
+
try:
|
|
456
|
+
decoded = json.loads(raw.decode("utf-8"))
|
|
457
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
458
|
+
raise ValueError(f"request body is not valid JSON: {exc}") from exc
|
|
459
|
+
if not isinstance(decoded, dict):
|
|
460
|
+
raise ValueError("request body must be a JSON object")
|
|
461
|
+
merged.update(decoded)
|
|
462
|
+
return merged
|
|
463
|
+
|
|
464
|
+
# -- dispatch ---------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
def do_GET(self) -> None: # noqa: N802
|
|
467
|
+
self._dispatch("GET")
|
|
468
|
+
|
|
469
|
+
def do_POST(self) -> None: # noqa: N802
|
|
470
|
+
self._dispatch("POST")
|
|
471
|
+
|
|
472
|
+
def _dispatch(self, method: str) -> None:
|
|
473
|
+
path = urlparse(self.path).path.rstrip("/") or "/"
|
|
474
|
+
|
|
475
|
+
if path == "/":
|
|
476
|
+
self._send(
|
|
477
|
+
200,
|
|
478
|
+
{
|
|
479
|
+
"service": "ictrp-sidecar",
|
|
480
|
+
"endpoints": sorted(_ROUTES),
|
|
481
|
+
"hint": "POST /search?keyword=... starts the chain.",
|
|
482
|
+
},
|
|
483
|
+
)
|
|
484
|
+
return
|
|
485
|
+
|
|
486
|
+
handler = _ROUTES.get(path)
|
|
487
|
+
if handler is None:
|
|
488
|
+
self._send(404, {"ok": False, "error": "not found", "path": path})
|
|
489
|
+
return
|
|
490
|
+
|
|
491
|
+
if method == "GET" and path in _EXPENSIVE:
|
|
492
|
+
# Refuse rather than run: a GET that triggers a 39 MB export is
|
|
493
|
+
# almost certainly a supervisor probe, and it would be a very
|
|
494
|
+
# expensive misunderstanding.
|
|
495
|
+
self._send(
|
|
496
|
+
405,
|
|
497
|
+
{
|
|
498
|
+
"ok": False,
|
|
499
|
+
"error": f"{path} must be called with POST",
|
|
500
|
+
"reason": "this operation drives a large upstream request",
|
|
501
|
+
},
|
|
502
|
+
)
|
|
503
|
+
return
|
|
504
|
+
|
|
505
|
+
try:
|
|
506
|
+
params = self._params()
|
|
507
|
+
except ValueError as exc:
|
|
508
|
+
self._send(400, {"ok": False, "error": str(exc)})
|
|
509
|
+
return
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
result = handler(**params)
|
|
513
|
+
except IctrpError as exc:
|
|
514
|
+
status = http_status_for(exc)
|
|
515
|
+
LOG.info("%s -> %s %s", path, status, exc.code.value)
|
|
516
|
+
self._send(status, error_payload(exc))
|
|
517
|
+
return
|
|
518
|
+
except SetExpired as exc:
|
|
519
|
+
self._send(
|
|
520
|
+
409,
|
|
521
|
+
{
|
|
522
|
+
"ok": False,
|
|
523
|
+
"error_code": "set_expired",
|
|
524
|
+
"message": str(exc),
|
|
525
|
+
"set_id": exc.set_id,
|
|
526
|
+
"hint": "Re-run /search; materialized sets do not survive a sidecar restart.",
|
|
527
|
+
},
|
|
528
|
+
)
|
|
529
|
+
return
|
|
530
|
+
except TypeError as exc:
|
|
531
|
+
# Almost always a caller passing an unexpected keyword. Surfacing
|
|
532
|
+
# it as 400 is far more useful than a 502 traceback.
|
|
533
|
+
self._send(
|
|
534
|
+
400,
|
|
535
|
+
{
|
|
536
|
+
"ok": False,
|
|
537
|
+
"error_code": "INVALID_ARGUMENT",
|
|
538
|
+
"message": f"bad parameters for {path}: {exc}",
|
|
539
|
+
},
|
|
540
|
+
)
|
|
541
|
+
return
|
|
542
|
+
except Exception as exc: # noqa: BLE001 - the boundary must not leak
|
|
543
|
+
LOG.error("unhandled error in %s:\n%s", path, traceback.format_exc())
|
|
544
|
+
self._send(
|
|
545
|
+
500,
|
|
546
|
+
{
|
|
547
|
+
"ok": False,
|
|
548
|
+
"error_code": "INTERNAL_ERROR",
|
|
549
|
+
"message": f"{type(exc).__name__}: {exc}",
|
|
550
|
+
"path": path,
|
|
551
|
+
"hint": "This is a bug in the sidecar, not an upstream failure.",
|
|
552
|
+
},
|
|
553
|
+
)
|
|
554
|
+
return
|
|
555
|
+
|
|
556
|
+
# The success path. Without this the handler would compute a perfect
|
|
557
|
+
# answer and then close the connection without writing anything, which
|
|
558
|
+
# every client reports as an opaque connection failure.
|
|
559
|
+
self._send(200, result)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def serve(host: str, port: int) -> None:
|
|
563
|
+
httpd = ThreadingHTTPServer((host, port), Handler)
|
|
564
|
+
httpd.daemon_threads = True
|
|
565
|
+
LOG.info("ictrp sidecar listening on http://%s:%d", host, port)
|
|
566
|
+
try:
|
|
567
|
+
httpd.serve_forever()
|
|
568
|
+
except KeyboardInterrupt:
|
|
569
|
+
LOG.info("interrupted; shutting down")
|
|
570
|
+
finally:
|
|
571
|
+
httpd.server_close()
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def main(argv: list[str] | None = None) -> int:
|
|
575
|
+
parser = argparse.ArgumentParser(prog="ictrp_sidecar", description=__doc__)
|
|
576
|
+
parser.add_argument("--host", default=os.environ.get("ICTRP_SIDECAR_HOST", DEFAULT_HOST))
|
|
577
|
+
parser.add_argument(
|
|
578
|
+
"--port", type=int, default=_as_int(os.environ.get("ICTRP_SIDECAR_PORT"), DEFAULT_PORT)
|
|
579
|
+
)
|
|
580
|
+
parser.add_argument("--log-level", default=os.environ.get("ICTRP_SIDECAR_LOG", "INFO"))
|
|
581
|
+
parser.add_argument(
|
|
582
|
+
"--check",
|
|
583
|
+
action="store_true",
|
|
584
|
+
help="Import and exit. Used by the installer to validate the venv.",
|
|
585
|
+
)
|
|
586
|
+
args = parser.parse_args(argv)
|
|
587
|
+
|
|
588
|
+
logging.basicConfig(
|
|
589
|
+
level=getattr(logging, str(args.log_level).upper(), logging.INFO),
|
|
590
|
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
if args.check:
|
|
594
|
+
print(json.dumps({"ok": True, "version": _version(), "python": sys.version.split()[0]}))
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
serve(args.host, args.port)
|
|
598
|
+
return 0
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
if __name__ == "__main__":
|
|
602
|
+
raise SystemExit(main())
|
|
File without changes
|