Source code for fsh_lib.actions
"""Action availability for kiln-generated FastAPI projects.
An *action* is anything you can do to (or with) a resource: the
built-in CRUD ops (``get``, ``list``, ``create``, ``update``,
``delete``) plus any custom action endpoints declared in the
spec. Every action carries a single *guard* callable --
async def can_<name>(resource, session) -> bool
-- that decides two things at once: whether the current session
may execute the action, and whether the action should appear in
serialized responses so the frontend can show the corresponding
button. Object-scope guards see the resource instance; collection-
scope guards see ``None`` (there is no per-row resource yet).
Generated code emits one ``actions.py`` per app holding tuples of
:class:`ActionSpec` per resource (object and collection scopes
kept separate). The route-handler templates call
:func:`available_actions` against the right tuple to populate the
``actions`` field on response payloads, and call the matching
``can`` callable directly before executing each handler so the
visibility predicate and the authorization gate can never drift.
"""
from collections.abc import Awaitable, Callable, Iterable
from dataclasses import dataclass
from typing import Any, Literal
from pydantic import BaseModel
Scope = Literal["object", "collection"]
"""Whether an action targets a single resource or a collection.
Object-scope actions take ``(resource, session)``; collection-
scope actions take ``(None, session)``. The frontend uses this
to decide where to render the button -- per row or once on the
list page.
"""
# Signature every guard conforms to: ``(resource, session) -> bool``.
# ``resource`` is the SQLAlchemy instance for object-scope actions
# or ``None`` for collection-scope actions; ``session`` is whatever
# the consumer's auth dep resolves -- the type is left open since
# consumers pick the session model.
CanCallable = Callable[[Any, Any], Awaitable[bool]]
[docs]
class ActionRef[NameT: str = str, ScopeT: str = Scope](BaseModel):
"""One action exposed in a serialized response.
Carries the bare minimum the frontend needs to render a
button: the action name (matches the operation name on the
backend) and its scope. Kept Pydantic so the OpenAPI schema
surfaces a stable shape; consumers downstream get a typed TS
interface for free.
Generic over the ``name`` and ``scope`` types so generated
code can subclass with a ``Literal`` union for ``name`` and a
single-member ``scope`` -- ``ActionRef[FooAction, Literal["object"]]``
-- and have the OpenAPI schema (and the TypeScript types
openapi-ts derives from it) carry the enum / const rather than
a bare ``str``. Narrowing through the type parameters keeps
the override compatible, so no field has to be redeclared.
Attributes:
name: Operation name (e.g. ``"publish"``, ``"update"``).
scope: ``"object"`` for per-row actions, ``"collection"``
for actions that target the resource as a whole.
"""
name: NameT
scope: ScopeT
[docs]
@dataclass(frozen=True)
class ActionSpec:
"""Generator-emitted descriptor for one action.
Lives in the per-app ``actions.py`` registry. The route
handlers and serializers consume :class:`ActionSpec` tuples
via :func:`available_actions`; nothing outside generated code
should construct these by hand.
Attributes:
name: Operation name.
can: Async guard returning ``True`` when the action is
available. Bound to :func:`always_true` when the
spec did not declare a ``can`` dotted path.
is_object_action: ``True`` for object-scope actions,
``False`` for collection-scope actions. Drives the
:class:`ActionRef` ``scope`` field and disambiguates
which tuple a spec belongs in.
"""
name: str
can: CanCallable
is_object_action: bool
@property
def scope(self) -> Scope:
"""Match :class:`ActionRef.scope` for this spec."""
return "object" if self.is_object_action else "collection"
[docs]
async def always_true(_resource: Any, _session: Any) -> bool:
"""Default guard used when an action declares no ``can`` path.
Returning ``True`` unconditionally matches the historical
behavior of generated handlers (auth handled at the route
level, no per-action gating); opting in to action gating is
additive.
"""
return True
[docs]
async def available_actions[T: BaseModel = ActionRef](
resource: Any,
session: Any,
specs: Iterable[ActionSpec],
ref_cls: type[T] = ActionRef, # type: ignore[assignment]
) -> list[T]:
"""Return the subset of *specs* whose guards pass for *session*.
The guard for each spec is awaited in declaration order; specs
whose guard returns ``False`` are dropped. Order is preserved
so the frontend can rely on a stable button layout driven by
the spec.
*ref_cls* lets generated code substitute a per-resource typed
``ActionRef`` subclass -- e.g. ``AssetObjectPermission`` whose
``name`` is a ``Literal["get", "update", "publish"]`` -- so
the OpenAPI schema (and the TypeScript types openapi-ts
derives from it) surface the enum rather than a bare ``str``.
The default keeps the historical untyped shape for callers
that don't go through codegen.
Args:
resource: The SQLAlchemy instance for object-scope dumps,
or ``None`` for collection-scope dumps.
session: Whatever the auth dep resolved -- passed through
to each guard untouched.
specs: Iterable of :class:`ActionSpec`; typically a tuple
literal from the generated per-app ``actions.py``.
ref_cls: Pydantic model used to construct each ref.
Defaults to :class:`ActionRef`.
Returns:
List of *ref_cls* instances, one per spec whose guard
returned ``True``, in spec order.
"""
return [
ref_cls(name=spec.name, scope=spec.scope)
for spec in specs
if await spec.can(resource, session)
]
[docs]
def find_can(
specs: Iterable[ActionSpec],
name: str,
) -> CanCallable:
"""Return the guard for the action *name* in *specs*.
Generated handlers resolve their own row-level guard at
module-import time -- e.g. ``_CAN_LIST = find_can(...)`` --
so the per-request path is a single attribute lookup rather
than a tuple scan.
Args:
specs: An :class:`ActionSpec` tuple from the per-app
registry, typically the collection-scoped tuple for
row-level filters or the object-scoped tuple for
execution-time gates.
name: Operation name to look up (e.g. ``"list"``,
``"publish"``).
Returns:
The bound guard callable (a ``CanCallable``).
Raises:
KeyError: If *name* is not present in *specs* -- the
generated code should never reach this branch since
the registry is built from the same operation list.
"""
for spec in specs:
if spec.name == name:
return spec.can
msg = f"No action {name!r} in registry"
raise KeyError(msg)