Skip to content

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="_",
        nested_model_default_partial_update=True,
    )

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

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

    build_api_key: str = "secret-key-for-build"
    """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"""

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

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

Allowed origins

build_api_key = 'secret-key-for-build' 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

min_search_length = 3 class-attribute instance-attribute

Minimum search query length

store_uri = DB_URL class-attribute instance-attribute

ftmq store uri

use_cache = False class-attribute instance-attribute

Activate caching

View

A wrapper around a store's default View scoped to the api's use cases.

Source code in ftmq/api/store.py
class View:
    """A wrapper around a store's default [`View`][ftmq.store.base.View] scoped
    to the api's use cases."""

    def __init__(self, dataset: str | None = None) -> None:
        self.store = get_store(dataset)
        self.dataset = dataset
        self.view = self.store.default_view()

    def get_entity(self, entity_id: str, params: "RetrieveParams") -> Entity:
        canonical = self.store.linker.get_canonical(entity_id)
        proxy = self.view.get_entity(canonical)
        if proxy is None:
            # fall back to the original id
            proxy = self.view.get_entity(entity_id)
            if proxy is None:
                raise HTTPException(404, detail=[f"Entity `{entity_id}` not found."])
        if params.dehydrate:
            return get_dehydrated_entity(proxy)
        if params.featured:
            return get_featured_entity(proxy)
        return proxy

    def get_entities(self, query: Query, params: "RetrieveParams") -> Entities:
        yield from retrieve_entities(self.view.query(query), params)

    def get_adjacents(self, proxies: Entities) -> set[StatementEntity]:
        return self.view.get_adjacents(proxies)

    def get_adjacent(self, proxy: StatementEntity):
        return self.view.get_adjacent(proxy)

    def stats(self, query: Query | None = None) -> DatasetStats:
        return self.view.stats(query)

    def count(self, query: Query | None = None) -> int:
        return self.view.count(query)

    def aggregations(self, query: Query) -> AggregatorResult | None:
        return self.view.aggregations(query)

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); 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 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); 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` 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)
    limit = q.limit if q.limit is not None else settings.default_limit
    if not authenticated:
        limit = min(limit, settings.default_limit)
    offset = q.offset or 0
    q = q[offset : offset + limit]
    invalid = q.dataset_names - get_catalog().names
    if invalid:
        raise HTTPException(422, detail=[f"Invalid dataset: `{', '.join(invalid)}`"])
    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,
        count: int = 0,
        aggregations: AggregatorResult | None = None,
        query_q: str | None = None,
    ) -> Self:
        url = furl(str(request.url))
        results = [EntityResponse.from_entity(e, adjacents) for e in entities]
        total = stats.entity_count if stats else count
        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) if aggregations else {},
            metrics=build_metrics(aggregations) if aggregations else {},
        )
        if limit:
            if offset > 0:
                url.args["offset"] = max(0, offset - limit)
                url.args["limit"] = limit
                response.previous = str(url)
            if offset + limit < total:
                url.args["offset"] = offset + limit
                url.args["limit"] = limit
                response.next = str(url)
        return response

build_facets(aggregations)

Grouped aggregations as Aleph-style facets: {field: {"values": [{"value", "label", <func>: value}], "total": n}}.

A count grouping yields the idiomatic Aleph {value, label, count}; other functions ride under their function name in each value bucket.

Source code in ftmq/api/serialize.py
def build_facets(aggregations: AggregatorResult) -> dict[str, Any]:
    """Grouped aggregations as Aleph-style `facets`:
    `{field: {"values": [{"value", "label", <func>: value}], "total": n}}`.

    A `count` grouping yields the idiomatic Aleph `{value, label, count}`; other
    functions ride under their function name in each value bucket.
    """
    facets: dict[str, Any] = {}
    for field, funcs in aggregations.get("groups", {}).items():
        buckets: dict[str, dict[str, Any]] = defaultdict(dict)
        for func, props in funcs.items():
            for _prop, gvals in props.items():
                for gval, value in gvals.items():
                    buckets[gval][func] = value
        values = [{"value": g, "label": g, **m} for g, m in buckets.items()]
        values.sort(key=lambda v: (-(v.get("count") or 0), v["value"]))
        facets[field] = {"values": values, "total": len(values)}
    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)

Ungrouped aggregations as Aleph-style metrics: {prop: {func: value}}.

Source code in ftmq/api/serialize.py
def build_metrics(aggregations: AggregatorResult) -> dict[str, Any]:
    """Ungrouped aggregations as Aleph-style `metrics`: `{prop: {func: value}}`."""
    metrics: dict[str, Any] = defaultdict(dict)
    for func, props in aggregations.items():
        if func == "groups":
            continue
        for prop, value in props.items():
            metrics[prop][func] = value
    return dict(metrics)