interloper-api 0.32.0__tar.gz → 0.34.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.
Files changed (21) hide show
  1. {interloper_api-0.32.0 → interloper_api-0.34.0}/PKG-INFO +1 -1
  2. {interloper_api-0.32.0 → interloper_api-0.34.0}/pyproject.toml +1 -1
  3. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/app.py +2 -2
  4. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/components.py +228 -5
  5. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/oauth.py +27 -32
  6. interloper_api-0.32.0/src/interloper_api/routes/external/__init__.py +0 -40
  7. interloper_api-0.32.0/src/interloper_api/routes/external/resolve.py +0 -88
  8. {interloper_api-0.32.0 → interloper_api-0.34.0}/README.md +0 -0
  9. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/__init__.py +0 -0
  10. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/components.py +0 -0
  11. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/dependencies.py +0 -0
  12. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/email.py +0 -0
  13. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/__init__.py +0 -0
  14. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/admin.py +0 -0
  15. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/agent.py +0 -0
  16. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/auth.py +0 -0
  17. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/backfills.py +0 -0
  18. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/catalog.py +0 -0
  19. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/organisations.py +0 -0
  20. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/runs.py +0 -0
  21. {interloper_api-0.32.0 → interloper_api-0.34.0}/src/interloper_api/routes/ws.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: interloper-api
3
- Version: 0.32.0
3
+ Version: 0.34.0
4
4
  Summary: Interloper FastAPI routes
5
5
  Author: Guillaume Onfroy
6
6
  Author-email: Guillaume Onfroy <guillaume@digitlcloud.com>
@@ -3,7 +3,7 @@
3
3
  # ###############
4
4
  [project]
5
5
  name = "interloper-api"
6
- version = "0.32.0"
6
+ version = "0.34.0"
7
7
  description = "Interloper FastAPI routes"
8
8
  readme = "README.md"
9
9
  authors = [{ name = "Guillaume Onfroy", email = "guillaume@digitlcloud.com" }]
@@ -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 generic CRUD surface for every component kind.
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
- from typing import Annotated, Any
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.errors import CatalogKeyError, ComponentDriftError, ConfigError, DataNotFoundError, NotFoundError
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. Children and relations cascade."""
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
- # -- App credentials (environment) ---------------------------------------------
40
+ def log_provider_status() -> None:
41
+ """Log which OAuth providers are usable, warning on partial credential trios.
41
42
 
42
-
43
- class _ProviderConfig:
44
- """In-house OAuth credentials resolved from environment variables."""
45
-
46
- def __init__(self, key: str, *, env_prefix: str | None = None) -> None:
47
- prefix = (env_prefix or key).upper()
48
- self.key = key
49
- self.client_id = os.environ.get(f"INTERLOPER_{prefix}_CLIENT_ID", "")
50
- self.client_secret = os.environ.get(f"INTERLOPER_{prefix}_CLIENT_SECRET", "")
51
- self.redirect_uri = os.environ.get(f"INTERLOPER_{prefix}_REDIRECT_URI", "")
52
-
53
- @property
54
- def configured(self) -> bool:
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: _ProviderConfig,
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=cfg.key,
143
- client_id=cfg.client_id,
144
- redirect_uri=cfg.redirect_uri,
145
- auth_url=specs[cfg.key].auth_url,
146
- label=specs[cfg.key].label,
147
- icon=specs[cfg.key].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 cfg in _load_providers().values()
150
- if cfg.configured
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 = _ProviderConfig(provider)
171
- if not cfg.configured:
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 [])