interloper-api 0.32.0__tar.gz → 0.33.0__tar.gz
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.
- {interloper_api-0.32.0 → interloper_api-0.33.0}/PKG-INFO +1 -1
- {interloper_api-0.32.0 → interloper_api-0.33.0}/pyproject.toml +1 -1
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/app.py +2 -2
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/components.py +228 -5
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/oauth.py +27 -32
- interloper_api-0.32.0/src/interloper_api/routes/external/__init__.py +0 -40
- interloper_api-0.32.0/src/interloper_api/routes/external/resolve.py +0 -88
- {interloper_api-0.32.0 → interloper_api-0.33.0}/README.md +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/__init__.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/components.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/dependencies.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/email.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/__init__.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/admin.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/agent.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/auth.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/backfills.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/catalog.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/organisations.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/runs.py +0 -0
- {interloper_api-0.32.0 → interloper_api-0.33.0}/src/interloper_api/routes/ws.py +0 -0
|
@@ -18,7 +18,6 @@ from interloper_api.routes import (
|
|
|
18
18
|
auth,
|
|
19
19
|
backfills,
|
|
20
20
|
components,
|
|
21
|
-
external,
|
|
22
21
|
oauth,
|
|
23
22
|
organisations,
|
|
24
23
|
runs,
|
|
@@ -97,7 +96,6 @@ def create_app(
|
|
|
97
96
|
api.include_router(runs.router, prefix="/runs", tags=["runs"])
|
|
98
97
|
api.include_router(backfills.router, prefix="/backfills", tags=["backfills"])
|
|
99
98
|
api.include_router(oauth.router, tags=["oauth"])
|
|
100
|
-
api.include_router(external.router, prefix="/external", tags=["external"])
|
|
101
99
|
api.include_router(ws.router, tags=["ws"])
|
|
102
100
|
|
|
103
101
|
agent_available = False
|
|
@@ -122,4 +120,6 @@ def create_app(
|
|
|
122
120
|
|
|
123
121
|
app.include_router(api)
|
|
124
122
|
|
|
123
|
+
oauth.log_provider_status()
|
|
124
|
+
|
|
125
125
|
return app
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
"""Components API: one
|
|
1
|
+
"""Components API: one surface for every component operation.
|
|
2
|
+
|
|
3
|
+
A generic CRUD for persisted instances of every component kind, plus the
|
|
4
|
+
type-level operations that execute a component class against a candidate,
|
|
5
|
+
unsaved config — resolving a FetchField's options (``/resolve``) and
|
|
6
|
+
checking a connection (``/check``).
|
|
2
7
|
|
|
3
8
|
The response shape is kind-agnostic — identity, drift ``status``, ``config``
|
|
4
9
|
(decoded for secret kinds on detail responses), machine-owned ``state``,
|
|
@@ -9,16 +14,33 @@ come from the catalog (``/catalog``), not from this router.
|
|
|
9
14
|
|
|
10
15
|
from __future__ import annotations
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
import asyncio
|
|
18
|
+
import logging
|
|
19
|
+
from typing import Annotated, Any, Literal
|
|
13
20
|
from uuid import UUID
|
|
14
21
|
|
|
22
|
+
import httpx
|
|
15
23
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
24
|
+
from interloper.catalog.base import Catalog
|
|
16
25
|
from interloper.component import KINDS
|
|
17
|
-
from interloper.
|
|
26
|
+
from interloper.connection.base import Connection
|
|
27
|
+
from interloper.errors import (
|
|
28
|
+
CatalogKeyError,
|
|
29
|
+
ComponentDriftError,
|
|
30
|
+
ConfigError,
|
|
31
|
+
ConnectionCheckError,
|
|
32
|
+
DataNotFoundError,
|
|
33
|
+
InUseError,
|
|
34
|
+
NotFoundError,
|
|
35
|
+
)
|
|
36
|
+
from interloper.resource.fields import is_fetch_field_provider
|
|
37
|
+
from interloper.utils.concurrency import invoke
|
|
38
|
+
from interloper.utils.imports import import_from_path
|
|
18
39
|
from interloper_db import Component, ComponentStatus, Profile, Store
|
|
19
|
-
from pydantic import BaseModel
|
|
40
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
20
41
|
|
|
21
42
|
from interloper_api.dependencies import (
|
|
43
|
+
get_catalog,
|
|
22
44
|
get_current_user,
|
|
23
45
|
get_org_id,
|
|
24
46
|
get_store,
|
|
@@ -27,6 +49,8 @@ from interloper_api.dependencies import (
|
|
|
27
49
|
require_viewer,
|
|
28
50
|
)
|
|
29
51
|
|
|
52
|
+
logger = logging.getLogger(__name__)
|
|
53
|
+
|
|
30
54
|
router = APIRouter()
|
|
31
55
|
|
|
32
56
|
|
|
@@ -284,12 +308,14 @@ def delete_component(
|
|
|
284
308
|
user: Profile = Depends(get_current_user),
|
|
285
309
|
store: Store = Depends(get_store),
|
|
286
310
|
) -> dict[str, str]:
|
|
287
|
-
"""Delete a component.
|
|
311
|
+
"""Delete a component. Refused (409) while other components are bound to it."""
|
|
288
312
|
load_authorized(store.get_component, component_id, user, store, label="Component", minimum="editor")
|
|
289
313
|
try:
|
|
290
314
|
store.delete_component(component_id)
|
|
291
315
|
except NotFoundError as e:
|
|
292
316
|
raise HTTPException(status_code=404, detail=str(e))
|
|
317
|
+
except InUseError as e:
|
|
318
|
+
raise HTTPException(status_code=409, detail={"message": str(e), "used_by": e.referrers})
|
|
293
319
|
except ValueError as e:
|
|
294
320
|
raise HTTPException(status_code=400, detail=str(e))
|
|
295
321
|
return {"status": "deleted"}
|
|
@@ -366,3 +392,200 @@ def get_partition_row_counts(
|
|
|
366
392
|
partition_column=partitioning.column,
|
|
367
393
|
counts=[PartitionRowCountItem(partition=str(k), row_count=v) for k, v in sorted(counts.items())],
|
|
368
394
|
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# -- Field resolution ----------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def handle_error(error: Exception, context: str) -> None:
|
|
401
|
+
"""Map external API errors to appropriate HTTP responses."""
|
|
402
|
+
logger.error("Error %s: %s", context, error)
|
|
403
|
+
|
|
404
|
+
if isinstance(error, httpx.HTTPStatusError):
|
|
405
|
+
status = error.response.status_code
|
|
406
|
+
if status in (401, 403):
|
|
407
|
+
raise HTTPException(status_code=status, detail=f"Authorization failed while {context}.")
|
|
408
|
+
if status == 404:
|
|
409
|
+
raise HTTPException(status_code=404, detail=f"Resource not found while {context}.")
|
|
410
|
+
|
|
411
|
+
if isinstance(error, HTTPException):
|
|
412
|
+
raise error
|
|
413
|
+
|
|
414
|
+
raise HTTPException(status_code=500, detail=f"Failed {context}.")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
class ResolveRequest(BaseModel):
|
|
418
|
+
"""A request to resolve one provider-backed FetchField's options."""
|
|
419
|
+
|
|
420
|
+
component_key: str
|
|
421
|
+
field: str
|
|
422
|
+
# Credentials per resource slot, e.g. {"connection": {"access_token": ...}}.
|
|
423
|
+
deps: dict[str, dict[str, Any]] = {}
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@router.post("/resolve")
|
|
427
|
+
async def resolve_fetch_field(
|
|
428
|
+
body: ResolveRequest,
|
|
429
|
+
catalog: Catalog = Depends(get_catalog),
|
|
430
|
+
_user: Profile = Depends(require_viewer),
|
|
431
|
+
) -> list[dict[str, Any]]:
|
|
432
|
+
"""Resolve the options for a ``FetchField(provider=...)`` field.
|
|
433
|
+
|
|
434
|
+
One endpoint resolves any field declared with
|
|
435
|
+
``FetchField(provider="<slot>.<method>")`` — there are no hand-written
|
|
436
|
+
per-provider routes. The component definition comes from the catalog
|
|
437
|
+
(authoritative — the provider reference comes from the server's schema,
|
|
438
|
+
never the client), the resource in ``<slot>`` is instantiated from the
|
|
439
|
+
credentials the form already holds, and the ``@fetch_field_provider``
|
|
440
|
+
method ``<method>`` is called on it. That marker is the allowlist: only
|
|
441
|
+
methods opted in that way may be invoked, so the browser cannot call
|
|
442
|
+
arbitrary attributes.
|
|
443
|
+
"""
|
|
444
|
+
defn = catalog.get(body.component_key)
|
|
445
|
+
if defn is None:
|
|
446
|
+
raise HTTPException(status_code=404, detail=f"Unknown component '{body.component_key}'")
|
|
447
|
+
|
|
448
|
+
# Read the provider from the server's own schema, so the client cannot
|
|
449
|
+
# redirect the call to an arbitrary method.
|
|
450
|
+
prop = getattr(defn, "config_schema", {}).get("properties", {}).get(body.field, {})
|
|
451
|
+
provider = prop.get("x-fetch", {}).get("provider")
|
|
452
|
+
if not provider:
|
|
453
|
+
raise HTTPException(
|
|
454
|
+
status_code=400,
|
|
455
|
+
detail=f"Field '{body.field}' on '{body.component_key}' is not a provider-backed FetchField",
|
|
456
|
+
)
|
|
457
|
+
slot, _, method = str(provider).partition(".")
|
|
458
|
+
|
|
459
|
+
component_cls = import_from_path(defn.path)
|
|
460
|
+
resource_cls = getattr(component_cls, "resource_types", {}).get(slot)
|
|
461
|
+
if resource_cls is None:
|
|
462
|
+
raise HTTPException(status_code=400, detail=f"Resource slot '{slot}' not found on '{body.component_key}'")
|
|
463
|
+
|
|
464
|
+
# Only pass through fields the resource actually declares — the form may
|
|
465
|
+
# carry extra markers (e.g. an internal id) that the model would reject.
|
|
466
|
+
raw = body.deps.get(slot, {})
|
|
467
|
+
creds = {k: v for k, v in raw.items() if k in resource_cls.model_fields}
|
|
468
|
+
resource = resource_cls(**creds)
|
|
469
|
+
|
|
470
|
+
fn = getattr(resource, method, None)
|
|
471
|
+
if not is_fetch_field_provider(fn):
|
|
472
|
+
# Should never happen — validated at catalog build — but guard anyway.
|
|
473
|
+
raise HTTPException(status_code=403, detail=f"'{provider}' is not a fetch provider")
|
|
474
|
+
assert fn is not None # narrowed by the is_fetch_field_provider guard above
|
|
475
|
+
|
|
476
|
+
try:
|
|
477
|
+
result = await invoke(fn)
|
|
478
|
+
except Exception as exc:
|
|
479
|
+
handle_error(exc, f"resolving {body.component_key}.{body.field}")
|
|
480
|
+
return []
|
|
481
|
+
return list(result or [])
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
# -- Connection check ----------------------------------------------------------
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
#: Upper bound on a live check — the wizard must never hang on a dead host.
|
|
488
|
+
CHECK_TIMEOUT = 15.0
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
class FieldError(BaseModel):
|
|
492
|
+
"""One static-validation error, addressed to a config field."""
|
|
493
|
+
|
|
494
|
+
field: str
|
|
495
|
+
message: str
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class CheckRequest(BaseModel):
|
|
499
|
+
"""A request to check one connection's candidate config."""
|
|
500
|
+
|
|
501
|
+
component_key: str
|
|
502
|
+
config: dict[str, Any] = {}
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class CheckResponse(BaseModel):
|
|
506
|
+
"""The outcome of a connection check.
|
|
507
|
+
|
|
508
|
+
``live`` distinguishes a full check from a static-only one (the class
|
|
509
|
+
implements no ``check()`` hook). ``category`` classifies failures so the
|
|
510
|
+
UI can hint at a fix: bad ``config`` values, rejected ``auth``,
|
|
511
|
+
unreachable ``network``, or an uncategorised ``error``.
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
ok: bool
|
|
515
|
+
live: bool
|
|
516
|
+
message: str | None = None
|
|
517
|
+
category: Literal["config", "auth", "network", "error"] | None = None
|
|
518
|
+
errors: list[FieldError] = Field(default_factory=list)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _check_failure(exc: Exception, key: str) -> CheckResponse:
|
|
522
|
+
"""Map a live-check exception to its response.
|
|
523
|
+
|
|
524
|
+
Full details are logged server-side only — provider errors may carry
|
|
525
|
+
URLs with tokens.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
The categorised failure response.
|
|
529
|
+
"""
|
|
530
|
+
logger.error("Connection check failed for '%s': %s", key, exc)
|
|
531
|
+
|
|
532
|
+
if isinstance(exc, ConnectionCheckError):
|
|
533
|
+
return CheckResponse(ok=False, live=True, category="error", message=str(exc))
|
|
534
|
+
if isinstance(exc, httpx.HTTPStatusError):
|
|
535
|
+
status = exc.response.status_code
|
|
536
|
+
if status in (401, 403):
|
|
537
|
+
return CheckResponse(ok=False, live=True, category="auth", message="The provider rejected the credentials.")
|
|
538
|
+
return CheckResponse(
|
|
539
|
+
ok=False, live=True, category="error", message=f"The provider responded with HTTP {status}."
|
|
540
|
+
)
|
|
541
|
+
if isinstance(exc, (TimeoutError, httpx.TimeoutException)):
|
|
542
|
+
return CheckResponse(ok=False, live=True, category="network", message="The provider did not respond in time.")
|
|
543
|
+
if isinstance(exc, httpx.TransportError):
|
|
544
|
+
return CheckResponse(ok=False, live=True, category="network", message="The provider could not be reached.")
|
|
545
|
+
return CheckResponse(ok=False, live=True, category="error", message="The connection check failed unexpectedly.")
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
@router.post("/check")
|
|
549
|
+
async def check_connection(
|
|
550
|
+
body: CheckRequest,
|
|
551
|
+
catalog: Catalog = Depends(get_catalog),
|
|
552
|
+
_user: Profile = Depends(require_viewer),
|
|
553
|
+
) -> CheckResponse:
|
|
554
|
+
"""Check a connection's candidate config, statically and (when supported) live.
|
|
555
|
+
|
|
556
|
+
The static tier instantiates the connection class from the config the
|
|
557
|
+
form holds — pydantic validation surfaces per-field errors. The live
|
|
558
|
+
tier calls the class's ``check()`` hook (when implemented), a
|
|
559
|
+
lightweight authenticated call against the provider. A failed check is
|
|
560
|
+
this endpoint's *expected* output, so failures are reported as
|
|
561
|
+
``ok: false`` in a 200 response, never as HTTP errors; only an unknown
|
|
562
|
+
component key is a 404.
|
|
563
|
+
"""
|
|
564
|
+
defn = catalog.get(body.component_key)
|
|
565
|
+
if defn is None or defn.kind != "connection":
|
|
566
|
+
raise HTTPException(status_code=404, detail=f"Unknown connection '{body.component_key}'")
|
|
567
|
+
|
|
568
|
+
connection_cls = import_from_path(defn.path)
|
|
569
|
+
assert issubclass(connection_cls, Connection) # guaranteed by the kind check above
|
|
570
|
+
|
|
571
|
+
# Only pass through fields the connection actually declares — the form may
|
|
572
|
+
# carry extra markers (e.g. an internal id) that the model would reject.
|
|
573
|
+
config = {k: v for k, v in body.config.items() if k in connection_cls.model_fields}
|
|
574
|
+
try:
|
|
575
|
+
conn = connection_cls(**config)
|
|
576
|
+
except ValidationError as exc:
|
|
577
|
+
errors = [FieldError(field=".".join(str(loc) for loc in e["loc"]), message=e["msg"]) for e in exc.errors()]
|
|
578
|
+
return CheckResponse(
|
|
579
|
+
ok=False, live=False, category="config", message="The configuration is invalid.", errors=errors
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
if not connection_cls.checkable():
|
|
583
|
+
return CheckResponse(ok=True, live=False)
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
ok = bool(await asyncio.wait_for(invoke(conn.check), timeout=CHECK_TIMEOUT))
|
|
587
|
+
except Exception as exc:
|
|
588
|
+
return _check_failure(exc, body.component_key)
|
|
589
|
+
if not ok:
|
|
590
|
+
return CheckResponse(ok=False, live=True, category="error", message="The connection check failed.")
|
|
591
|
+
return CheckResponse(ok=True, live=True)
|
|
@@ -26,7 +26,7 @@ from typing import Any
|
|
|
26
26
|
|
|
27
27
|
import httpx
|
|
28
28
|
from fastapi import APIRouter, Depends, HTTPException
|
|
29
|
-
from interloper.oauth import PROVIDERS, OAuthProvider
|
|
29
|
+
from interloper.oauth import PROVIDERS, OAuthAppCredentials, OAuthProvider, provider_env_names
|
|
30
30
|
from interloper_db import Profile
|
|
31
31
|
from pydantic import BaseModel
|
|
32
32
|
|
|
@@ -37,26 +37,22 @@ logger = logging.getLogger(__name__)
|
|
|
37
37
|
router = APIRouter(prefix="/oauth", tags=["oauth"])
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
def log_provider_status() -> None:
|
|
41
|
+
"""Log which OAuth providers are usable, warning on partial credential trios.
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return bool(self.client_id and self.client_secret and self.redirect_uri)
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
def _load_providers() -> dict[str, _ProviderConfig]:
|
|
59
|
-
return {key: _ProviderConfig(key) for key in PROVIDERS.keys()}
|
|
43
|
+
Called at app startup: a provider with only some of its three env vars set
|
|
44
|
+
is invisible everywhere else (it simply isn't offered), so this warning is
|
|
45
|
+
the one place a typo'd or forgotten variable surfaces.
|
|
46
|
+
"""
|
|
47
|
+
active = []
|
|
48
|
+
for key in PROVIDERS.keys():
|
|
49
|
+
names = provider_env_names(key)
|
|
50
|
+
missing = [n for n in names.values() if not os.environ.get(n)]
|
|
51
|
+
if not missing:
|
|
52
|
+
active.append(key)
|
|
53
|
+
elif len(missing) < len(names):
|
|
54
|
+
logger.warning("OAuth provider '%s' is partially configured — missing %s", key, ", ".join(sorted(missing)))
|
|
55
|
+
logger.info("OAuth sign-in providers: %s", ", ".join(sorted(active)) if active else "none configured")
|
|
60
56
|
|
|
61
57
|
|
|
62
58
|
# -- Generic token exchange ----------------------------------------------------
|
|
@@ -65,7 +61,7 @@ def _load_providers() -> dict[str, _ProviderConfig]:
|
|
|
65
61
|
async def _exchange(
|
|
66
62
|
client: httpx.AsyncClient,
|
|
67
63
|
spec: OAuthProvider,
|
|
68
|
-
cfg:
|
|
64
|
+
cfg: OAuthAppCredentials,
|
|
69
65
|
code: str,
|
|
70
66
|
) -> dict[str, Any]:
|
|
71
67
|
"""Exchange an authorization code for tokens, driven by the provider spec.
|
|
@@ -136,18 +132,17 @@ def list_providers() -> list[ProviderInfo]:
|
|
|
136
132
|
``REDIRECT_URI`` environment variables set are included. Metadata
|
|
137
133
|
(auth_url, label, icon) comes from the provider registry.
|
|
138
134
|
"""
|
|
139
|
-
specs = dict(PROVIDERS.items())
|
|
140
135
|
return [
|
|
141
136
|
ProviderInfo(
|
|
142
|
-
key=
|
|
143
|
-
client_id=
|
|
144
|
-
redirect_uri=
|
|
145
|
-
auth_url=
|
|
146
|
-
label=
|
|
147
|
-
icon=
|
|
137
|
+
key=key,
|
|
138
|
+
client_id=creds.client_id,
|
|
139
|
+
redirect_uri=creds.redirect_uri,
|
|
140
|
+
auth_url=spec.auth_url,
|
|
141
|
+
label=spec.label,
|
|
142
|
+
icon=spec.icon,
|
|
148
143
|
)
|
|
149
|
-
for
|
|
150
|
-
if
|
|
144
|
+
for key, spec in PROVIDERS.items()
|
|
145
|
+
if (creds := OAuthAppCredentials.from_env(key)) is not None
|
|
151
146
|
]
|
|
152
147
|
|
|
153
148
|
|
|
@@ -167,8 +162,8 @@ async def exchange_token(
|
|
|
167
162
|
if spec is None:
|
|
168
163
|
raise HTTPException(status_code=400, detail=f"Unknown OAuth provider: {provider}")
|
|
169
164
|
|
|
170
|
-
cfg =
|
|
171
|
-
if
|
|
165
|
+
cfg = OAuthAppCredentials.from_env(provider)
|
|
166
|
+
if cfg is None:
|
|
172
167
|
raise HTTPException(status_code=400, detail=f"OAuth provider {provider} is not configured")
|
|
173
168
|
|
|
174
169
|
try:
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
"""External provider API routes.
|
|
2
|
-
|
|
3
|
-
FetchField options are resolved by a single generic endpoint (``resolve``);
|
|
4
|
-
there are no per-provider routes. Each connector exposes its lookups as
|
|
5
|
-
``@fetch_field_provider`` methods on its connection class, which the resolver calls.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
|
|
12
|
-
import httpx
|
|
13
|
-
from fastapi import APIRouter, HTTPException
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
router = APIRouter()
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def handle_error(error: Exception, context: str) -> None:
|
|
21
|
-
"""Map external API errors to appropriate HTTP responses."""
|
|
22
|
-
logger.error("Error %s: %s", context, error)
|
|
23
|
-
|
|
24
|
-
if isinstance(error, httpx.HTTPStatusError):
|
|
25
|
-
status = error.response.status_code
|
|
26
|
-
if status in (401, 403):
|
|
27
|
-
raise HTTPException(status_code=status, detail=f"Authorization failed while {context}.")
|
|
28
|
-
if status == 404:
|
|
29
|
-
raise HTTPException(status_code=404, detail=f"Resource not found while {context}.")
|
|
30
|
-
|
|
31
|
-
if isinstance(error, HTTPException):
|
|
32
|
-
raise error
|
|
33
|
-
|
|
34
|
-
raise HTTPException(status_code=500, detail=f"Failed {context}.")
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
# Import and register the resolver after helpers are defined.
|
|
38
|
-
from interloper_api.routes.external.resolve import sub_router as resolve_router # noqa: E402
|
|
39
|
-
|
|
40
|
-
router.include_router(resolve_router)
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
"""Generic FetchField resolver.
|
|
2
|
-
|
|
3
|
-
Replaces the hand-written per-provider ``/external/<provider>/<x>`` routes
|
|
4
|
-
for fields declared with ``FetchField(provider="<slot>.<method>")``. One
|
|
5
|
-
endpoint resolves any such field by:
|
|
6
|
-
|
|
7
|
-
1. Looking up the component definition in the catalog (authoritative — the
|
|
8
|
-
provider reference comes from the server's schema, never the client).
|
|
9
|
-
2. Importing the component class and reading the resource class in ``<slot>``.
|
|
10
|
-
3. Instantiating that resource from the credentials the form already holds.
|
|
11
|
-
4. Calling the ``@fetch_field_provider`` method ``<method>`` on it.
|
|
12
|
-
|
|
13
|
-
The ``@fetch_field_provider`` marker is the allowlist: only methods opted in that
|
|
14
|
-
way may be invoked, so the browser cannot call arbitrary attributes.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
from typing import Any
|
|
20
|
-
|
|
21
|
-
from fastapi import APIRouter, Depends, HTTPException
|
|
22
|
-
from interloper.catalog.base import Catalog
|
|
23
|
-
from interloper.resource.fields import is_fetch_field_provider
|
|
24
|
-
from interloper.utils.concurrency import invoke
|
|
25
|
-
from interloper.utils.imports import import_from_path
|
|
26
|
-
from interloper_db import Profile
|
|
27
|
-
from pydantic import BaseModel
|
|
28
|
-
|
|
29
|
-
from interloper_api.dependencies import get_catalog, require_viewer
|
|
30
|
-
from interloper_api.routes.external import handle_error
|
|
31
|
-
|
|
32
|
-
sub_router = APIRouter()
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
class ResolveRequest(BaseModel):
|
|
36
|
-
"""A request to resolve one provider-backed FetchField's options."""
|
|
37
|
-
|
|
38
|
-
component_key: str
|
|
39
|
-
field: str
|
|
40
|
-
# Credentials per resource slot, e.g. {"connection": {"access_token": ...}}.
|
|
41
|
-
deps: dict[str, dict[str, Any]] = {}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
@sub_router.post("/resolve")
|
|
45
|
-
async def resolve_fetch_field(
|
|
46
|
-
body: ResolveRequest,
|
|
47
|
-
catalog: Catalog = Depends(get_catalog),
|
|
48
|
-
_user: Profile = Depends(require_viewer),
|
|
49
|
-
) -> list[dict[str, Any]]:
|
|
50
|
-
"""Resolve the options for a ``FetchField(provider=...)`` field."""
|
|
51
|
-
defn = catalog.get(body.component_key)
|
|
52
|
-
if defn is None:
|
|
53
|
-
raise HTTPException(status_code=404, detail=f"Unknown component '{body.component_key}'")
|
|
54
|
-
|
|
55
|
-
# Read the provider from the server's own schema, so the client cannot
|
|
56
|
-
# redirect the call to an arbitrary method.
|
|
57
|
-
prop = getattr(defn, "config_schema", {}).get("properties", {}).get(body.field, {})
|
|
58
|
-
provider = prop.get("x-fetch", {}).get("provider")
|
|
59
|
-
if not provider:
|
|
60
|
-
raise HTTPException(
|
|
61
|
-
status_code=400,
|
|
62
|
-
detail=f"Field '{body.field}' on '{body.component_key}' is not a provider-backed FetchField",
|
|
63
|
-
)
|
|
64
|
-
slot, _, method = str(provider).partition(".")
|
|
65
|
-
|
|
66
|
-
component_cls = import_from_path(defn.path)
|
|
67
|
-
resource_cls = getattr(component_cls, "resource_types", {}).get(slot)
|
|
68
|
-
if resource_cls is None:
|
|
69
|
-
raise HTTPException(status_code=400, detail=f"Resource slot '{slot}' not found on '{body.component_key}'")
|
|
70
|
-
|
|
71
|
-
# Only pass through fields the resource actually declares — the form may
|
|
72
|
-
# carry extra markers (e.g. an internal id) that the model would reject.
|
|
73
|
-
raw = body.deps.get(slot, {})
|
|
74
|
-
creds = {k: v for k, v in raw.items() if k in resource_cls.model_fields}
|
|
75
|
-
resource = resource_cls(**creds)
|
|
76
|
-
|
|
77
|
-
fn = getattr(resource, method, None)
|
|
78
|
-
if not is_fetch_field_provider(fn):
|
|
79
|
-
# Should never happen — validated at catalog build — but guard anyway.
|
|
80
|
-
raise HTTPException(status_code=403, detail=f"'{provider}' is not a fetch provider")
|
|
81
|
-
assert fn is not None # narrowed by the is_fetch_field_provider guard above
|
|
82
|
-
|
|
83
|
-
try:
|
|
84
|
-
result = await invoke(fn)
|
|
85
|
-
except Exception as exc:
|
|
86
|
-
handle_error(exc, f"resolving {body.component_key}.{body.field}")
|
|
87
|
-
return []
|
|
88
|
-
return list(result or [])
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|