Skip to content

ftmq.api

Settings

Bases: BaseSettings

anystore settings management using pydantic-settings

Note

All settings can be set via environment variables in uppercase, prepending FTMQ_API_ (except for those with a given prefix)

Source code in ftmq/api/settings.py
class Settings(BaseSettings):
    """
    `anystore` settings management using
    [pydantic-settings](https://docs.pydantic.dev/latest/concepts/pydantic_settings/)

    Note:
        All settings can be set via environment variables in uppercase,
        prepending `FTMQ_API_` (except for those with a given prefix)
    """

    model_config = SettingsConfigDict(
        env_prefix="ftmq_api_",
        env_nested_delimiter="_",
        env_nested_max_split=1,
        nested_model_default_partial_update=True,
    )

    catalog: str | None = None
    """Catalog uri"""

    store_uri: str = DB_URL
    """ftmq store uri"""

    resolver_uri: str | None = None
    """nomenklatura resolver uri: a sql database holding a `resolver` table, or
    a json lines edge dump. Defaults to the store uri."""

    build_api_key: str | None = None
    """Backend api key to use for build process (higher limit)"""

    min_search_length: int = 3
    """Minimum search query length"""

    use_cache: bool = False
    """Activate caching"""

    cache: StoreModel = StoreModel(
        uri=".cache", backend_config={"redis_prefix": f"ftmq-api/{__version__}"}
    )
    """Api cache (via anystore)"""

    allowed_origin: list[str] = ["http://localhost:3000"]
    """Allowed origins"""

    default_limit: int = 100
    """Default public pagination limit"""

    max_facet_size: int = 50
    """Public cap on the buckets per facet"""

    info: ApiInfo = ApiInfo()
    """Rendered information on redoc page"""

allowed_origin = ['http://localhost:3000'] class-attribute instance-attribute

Allowed origins

build_api_key = None class-attribute instance-attribute

Backend api key to use for build process (higher limit)

cache = StoreModel(uri='.cache', backend_config={'redis_prefix': f'ftmq-api/{__version__}'}) class-attribute instance-attribute

Api cache (via anystore)

catalog = None class-attribute instance-attribute

Catalog uri

default_limit = 100 class-attribute instance-attribute

Default public pagination limit

info = ApiInfo() class-attribute instance-attribute

Rendered information on redoc page

max_facet_size = 50 class-attribute instance-attribute

Public cap on the buckets per facet

min_search_length = 3 class-attribute instance-attribute

Minimum search query length

resolver_uri = None class-attribute instance-attribute

nomenklatura resolver uri: a sql database holding a resolver table, or a json lines edge dump. Defaults to the store uri.

store_uri = DB_URL class-attribute instance-attribute

ftmq store uri

use_cache = False class-attribute instance-attribute

Activate caching

get_catalog() cached

The catalog of queryable datasets, reconciled with the store.

settings.catalog describes datasets, but the store decides which exist. A name present in the store and missing from the catalog is added as a bare dataset, so a catalog naming something else - or no catalog at all - can never leave the store's own datasets un-queryable (the dataset filter validates against this catalog).

Source code in ftmq/api/store.py
@cache
def get_catalog() -> Catalog:
    """The catalog of queryable datasets, reconciled with the store.

    `settings.catalog` *describes* datasets, but the store *decides* which
    exist. A name present in the store and missing from the catalog is added
    as a bare dataset, so a catalog naming something else - or no catalog at
    all - can never leave the store's own datasets un-queryable (the dataset
    filter validates against this catalog).
    """
    catalog = Catalog()
    if settings.catalog is not None:
        catalog = Catalog._from_uri(settings.catalog)
    undeclared = get_store_datasets() - catalog.names
    if undeclared:
        if catalog.names:
            log.warning(
                f"Datasets in store but not in catalog: `{', '.join(sorted(undeclared))}`",
                catalog=settings.catalog,
            )
        catalog.datasets = catalog.datasets + [
            Dataset(name=name, title=name) for name in sorted(undeclared)
        ]
    return catalog

get_entity(entity_id, params)

The entity by id, or by a referent id the store's linker resolves.

Source code in ftmq/api/store.py
def get_entity(entity_id: str, params: "RetrieveParams") -> Entity:
    """The entity by id, or by a referent id the store's linker resolves."""
    # the statements of a resolved store carry the canonical id
    view = get_view()
    proxy = view.get_entity(get_store().linker.get_canonical(entity_id))
    proxy = proxy or view.get_entity(entity_id)
    if proxy is None:
        raise HTTPException(404, detail=[f"Entity `{entity_id}` not found."])
    return shape(proxy, params)

get_store_datasets()

The dataset names the configured store actually holds.

Source code in ftmq/api/store.py
def get_store_datasets() -> set[str]:
    """The dataset names the configured store actually holds."""
    try:
        return set(get_store().scope.leaf_names)
    except Exception as e:
        # never make the catalog (and with it the app's import) depend on the
        # store being reachable - an unconfigured catalog degrades to empty
        log.error(f"Cannot read datasets from store: `{e}`", store=settings.store_uri)
        return set()

shape(proxy, params)

Dehydrate an entity, or reduce it to its featured properties, as asked.

Source code in ftmq/api/store.py
def shape(proxy: Entity, params: "RetrieveParams") -> Entity:
    """Dehydrate an entity, or reduce it to its featured properties, as asked."""
    if params.dehydrate:
        return get_dehydrated_entity(proxy)
    if params.featured:
        return get_featured_entity(proxy)
    return proxy

RetrieveParams dataclass

The response-shaping query params (a Depends() dependency).

Source code in ftmq/api/query.py
@dataclass(frozen=True)
class RetrieveParams:
    """The response-shaping query params (a `Depends()` dependency)."""

    nested: Annotated[
        bool, QueryField(description="Inline adjacent entities instead of their ids")
    ] = False
    featured: Annotated[
        bool, QueryField(description="Only include featured properties and caption")
    ] = False
    dehydrate: Annotated[
        bool, QueryField(description="Only include id, schema and caption")
    ] = False
    dehydrate_nested: Annotated[
        bool, QueryField(description="Dehydrate nested entities")
    ] = True
    stats: Annotated[bool, QueryField(description="Include statistics in response")] = (
        False
    )

build_query(request, authenticated=False)

Build a Query from a request's query params.

The flat filter grammar is the Aleph one (filter: / exclude: / empty:, sort, limit / offset, metric:<func> / facet) parsed via Query.from_params. An optional rql= param carries a full nested RQL filter tree (and, if present, its aggregations and its select projection); it overrides the flat filter grammar while sort / limit / offset keep coming from the plain params.

Non-query params (q, api_key, retrieve flags) are ignored by the parser. The limit is capped to settings.default_limit and each facet size to settings.max_facet_size, unless the request is authenticated; datasets are validated against the catalog.

Raises:

Type Description
HTTPException

422 for a dataset not in the catalog.

QueryError

For invalid filter fields, values or RQL (handled as 400 upstream).

Source code in ftmq/api/query.py
def build_query(request: Request, authenticated: bool | None = False) -> Query:
    """Build a [`Query`][ftmq.Query] from a request's query params.

    The flat filter grammar is the Aleph one (`filter:` / `exclude:` /
    `empty:`, `sort`, `limit` / `offset`, `metric:<func>` / `facet`) parsed via
    [`Query.from_params`][ftmq.Query.from_params]. An optional `rql=` param
    carries a full nested [RQL][ftmq.Query.from_rql] filter tree (and, if
    present, its aggregations and its `select` projection); it overrides the
    flat filter grammar while `sort` / `limit` / `offset` keep coming from the
    plain params.

    Non-query params (`q`, `api_key`, retrieve flags) are ignored by the
    parser. The limit is capped to `settings.default_limit` and each facet
    size to `settings.max_facet_size`, unless the request is authenticated;
    datasets are validated against the catalog.

    Raises:
        HTTPException: 422 for a dataset not in the catalog.
        QueryError: For invalid filter fields, values or RQL (handled as 400
            upstream).
    """
    params = params_from_request(request)
    q = Query.from_params(params)
    rql = params.get("rql")
    if rql:
        rql_q = Query.from_rql(rql[0])
        q = q._chain(
            q=rql_q.q,
            aggregations=rql_q.aggregations or q.aggregations,
            selection=rql_q.selection or q.selection,
        )
    limit = q.limit if q.limit is not None else settings.default_limit
    if not authenticated:
        limit = min(limit, settings.default_limit)
        sizes = {r: q.get_facet_size(r) for r in q.facet_sizes}
        q = q._chain(
            facet_sizes={r: min(n, settings.max_facet_size) for r, n in sizes.items()}
        )
    offset = q.offset or 0
    q = q[offset : offset + limit]
    names = get_catalog().names
    invalid = q.dataset_names - names
    if invalid:
        raise HTTPException(
            422,
            detail=[
                f"Invalid dataset: `{', '.join(sorted(invalid))}` - "
                f"available: `{', '.join(sorted(names)) or 'none'}`"
            ],
        )
    return q

params_from_request(request)

Collect the request query params as a dict of lists (starlette's QueryParams.items() drops repeated keys).

Source code in ftmq/api/query.py
def params_from_request(request: Request) -> dict[str, list[str]]:
    """Collect the request query params as a dict of lists (starlette's
    `QueryParams.items()` drops repeated keys)."""
    params: dict[str, list[str]] = defaultdict(list)
    for key, value in request.query_params.multi_items():
        params[key].append(value)
    return dict(params)

serialization data models as seen in https://github.com/opensanctions/yente/

EntitiesResponse

Bases: BaseModel

The list / search response, matching the OpenAleph api v2 envelope.

Source code in ftmq/api/serialize.py
class EntitiesResponse(BaseModel):
    """The list / search response, matching the OpenAleph api v2 envelope."""

    status: str = "ok"
    results: list[EntityResponse] = []
    total: int = 0
    total_type: str = "eq"
    page: int = 1
    pages: int = 0
    limit: int = 0
    offset: int = 0
    next: str | None = None
    previous: str | None = None
    facets: dict[str, Any] = {}
    metrics: dict[str, Any] = {}
    filters: dict[str, list[str]] = {}
    query_q: str | None = None
    # ftmq extensions (additive; Aleph clients ignore extra keys)
    query: dict[str, Any] = {}
    stats: DatasetStats | None = None
    links: dict[str, str] = {}

    @classmethod
    def from_view(
        cls,
        request: Request,
        entities: Entities,
        query: Query,
        stats: DatasetStats | None = None,
        adjacents: Iterable[Entity] | None = None,
        total: int = 0,
        aggregations: AggregatorResult | None = None,
        query_q: str | None = None,
    ) -> Self:
        nested = {must_str(e.id): EntityResponse.from_proxy(e) for e in adjacents or []}
        results = [EntityResponse.from_proxy(e, nested) for e in entities]
        limit, offset = query.limit or 0, query.offset or 0
        response = cls(
            results=results,
            total=total,
            limit=limit,
            offset=offset,
            page=(offset // limit + 1) if limit else 1,
            pages=math.ceil(total / limit) if limit else 0,
            query=query.to_dict(),
            query_q=query_q,
            stats=stats,
            filters=build_filters(query),
            facets=build_facets(aggregations, query) if aggregations else {},
            metrics=build_metrics(aggregations, query) if aggregations else {},
        )
        if limit:
            page = request.url.include_query_params
            if offset > 0:
                response.previous = str(
                    page(offset=max(0, offset - limit), limit=limit)
                )
            if offset + limit < total:
                response.next = str(page(offset=offset + limit, limit=limit))
        return response

EntityResponse

Bases: EntityModel

An entity on the wire, with its adjacents inlined when nested.

Source code in ftmq/api/serialize.py
class EntityResponse(EntityModel):
    """An entity on the wire, with its adjacents inlined when nested."""

build_facets(aggregations, query)

Grouped aggregations as Aleph facets with buckets of {value, label, count, metrics: {field: {func: value}}}, ranked by the query's facet_sort metric, else by entity count.

Parameters:

Name Type Description Default
aggregations AggregatorResult

The computed aggregations, with bucket counts.

required
query Query

The request query.

required

Returns:

Type Description
dict[str, Any]

The Aleph-style facets.

Source code in ftmq/api/serialize.py
def build_facets(aggregations: AggregatorResult, query: Query) -> dict[str, Any]:
    """Grouped aggregations as Aleph `facets` with buckets of
    `{value, label, count, metrics: {field: {func: value}}}`, ranked by the
    query's `facet_sort` metric, else by entity count.

    Args:
        aggregations: The computed aggregations, with bucket counts.
        query: The request query.

    Returns:
        The Aleph-style `facets`.
    """
    facets: dict[str, Any] = {}
    order = query.facet_sort
    for field, results in aggregations.get("groups", {}).items():
        counts = results.get("count", {}).get("id", {})
        buckets = {gval: _bucket(gval, count) for gval, count in counts.items()}
        for agg in query.aggregations:
            if field not in {g.wire for g in agg.groups}:
                continue
            for gval, value in results.get(agg.func, {}).get(agg.key, {}).items():
                bucket = buckets.setdefault(gval, _bucket(gval, 0))
                bucket["metrics"].setdefault(agg.key, {})[agg.func] = value
        values = sorted(buckets.values(), key=lambda v: (-v["count"], v["value"]))
        if order is not None:
            keyed = [
                (v["metrics"].get(order.ref.wire, {}).get(order.func), v)
                for v in values
            ]
            # stable: ties keep the count order
            present = [(m, v) for m, v in keyed if m is not None]
            present.sort(key=lambda x: x[0], reverse=not order.ascending)
            values = [v for _, v in present] + [v for m, v in keyed if m is None]
        total = aggregations.get("count", {}).get(field, len(values))
        facets[field] = {"values": values, "total": total}
    return facets

build_filters(query)

The applied positive filters as {field: [values]} (empty for nested queries).

Source code in ftmq/api/serialize.py
def build_filters(query: Query) -> dict[str, list[str]]:
    """The applied positive filters as `{field: [values]}` (empty for nested queries)."""
    try:
        params = query.to_params()
    except QueryError:
        return {}
    return {
        key[len("filter:") :]: list(values)
        for key, values in params.items()
        if key.startswith("filter:")
    }

build_metrics(aggregations, query)

The query's ungrouped aggregations as {field: {func: value}}.

Parameters:

Name Type Description Default
aggregations AggregatorResult

The computed aggregations.

required
query Query

The request query; only its aggregations are reported.

required

Returns:

Type Description
dict[str, Any]

The Aleph-style metrics.

Source code in ftmq/api/serialize.py
def build_metrics(aggregations: AggregatorResult, query: Query) -> dict[str, Any]:
    """The query's ungrouped aggregations as `{field: {func: value}}`.

    Args:
        aggregations: The computed aggregations.
        query: The request query; only its aggregations are reported.

    Returns:
        The Aleph-style `metrics`.
    """
    metrics: dict[str, Any] = defaultdict(dict)
    for agg in query.aggregations:
        value = aggregations.get(agg.func, {}).get(agg.key)
        if value is not None:
            metrics[agg.key][agg.func] = value
    return dict(metrics)

with_bucket_counts(query)

Add an entity count per facet, so every bucket has its count, and the facet's distinct values, its total.

Parameters:

Name Type Description Default
query Query

The request query.

required

Returns:

Type Description
Query

The query to compute the aggregations with.

Source code in ftmq/api/serialize.py
def with_bucket_counts(query: Query) -> Query:
    """Add an entity count per facet, so every bucket has its `count`, and the
    facet's distinct values, its `total`.

    Args:
        query: The request query.

    Returns:
        The query to compute the aggregations with.
    """
    groups = groupers(query.aggregations)
    if not groups:
        return query
    totals = {make_agg("count", g) for g in groups}
    return query._chain(
        aggregations={*query.aggregations, *totals, make_agg("count", IdRef(), groups)}
    )

get_nested(view, entities, params)

The entities the given ones reference, to inline with nested=true.

Source code in ftmq/api/views.py
def get_nested(view: View, entities: list[Entity], params: RetrieveParams) -> list:
    """The entities the given ones reference, to inline with `nested=true`."""
    if not params.nested:
        return []
    adjacents = view.get_adjacents(entities)
    if params.dehydrate_nested:
        return [get_dehydrated_entity(e) for e in adjacents]
    return list(adjacents)