Source code for aiida_restapi.jsonapi.adapters

"""JSON:API adapters module."""

from __future__ import annotations

import typing as t

from aiida import orm
from fastapi.datastructures import URL
from starlette.requests import Request

from aiida_restapi.common.pagination import PaginatedResults
from aiida_restapi.common.query import CollectionQueryParams
from aiida_restapi.config import API_CONFIG

from . import hooks
from .utils import IncludedItemParamsCache


[docs] class JsonApiAdapter: """JSON:API adapter to convert AiiDA models to JSON:API compliant documents.""" RESOURCE_TYPES: dict[type[orm.Entity], str] = { orm.User: 'users', orm.Computer: 'computers', orm.Group: 'groups', orm.Node: 'nodes', } HOOKS: dict[str, type[hooks.BaseHook]] = { 'users': hooks.ResourceHook, 'computers': hooks.ComputerHook, 'groups': hooks.GroupHook, 'nodes': hooks.NodeHook, 'links': hooks.LinkHook, } BASE_API_URL = ''
[docs] @classmethod def register_hooks(cls, new_hooks: dict[str, type[hooks.BaseHook]]) -> None: """Update the hook mapping with new hooks. :param new_hooks: A dictionary of resource type to Hook class mappings. :type new_hooks: dict[str, type[Hook]] """ cls.HOOKS.update(new_hooks)
[docs] @classmethod def resource( cls, request: Request, result: dict[str, t.Any], *, resource_identity: str, resource_type: str, include: list[str] | None = None, meta: dict[str, t.Any] | None = None, ) -> dict[str, t.Any]: """Return a JSON:API document for a single resource derived from an AiiDA object. :param request: The incoming request. :type request: Request :param result: The result model to convert. :type result: dict[str, t.Any] :param resource_identity: The identity field to use for the resource. :type resource_identity: str :param resource_type: The resource type to use for the resource. :type resource_type: str :param include: A list of related resource types to include. :type include: list[str] | None :param meta: Optional meta information to include in the document. :type meta: dict[str, t.Any] | None :return: The JSON:API document. :rtype: JsonApiResponse """ base_api = cls._base_api_url(request) resource, included = cls._build_resource( result, resource_identity, resource_type, base_api, include=include, ) return { 'links': { 'self': str(request.url), }, 'data': resource, 'included': included or None, 'meta': meta, }
[docs] @classmethod def child_resource( cls, request: Request, result: dict[str, t.Any], *, pid: str | int, parent_type: str, child_type: str, include: list[str] | None = None, meta: dict[str, t.Any] | None = None, ) -> dict[str, t.Any]: """Return a JSON:API document for a child resource derived from an AiiDA object's child quantity. :param request: The incoming request. :type request: Request :param result: The result dictionary to convert. :type result: dict[str, t.Any] :param pid: The parent resource identifier. :type pid: str | int :param child_type: The child resource type. :type child_type: str :param parent_type: The parent resource type. :type parent_type: str | None :param include: A list of related resource types to include. :type include: list[str] | None :param meta: Optional meta information to include in the document. :type meta: dict[str, t.Any] | None :return: The JSON:API document. :rtype: JsonApiResponse """ base_api = cls._base_api_url(request) hook = cls._hook_for(parent_type) included = ( [ cls._to_resource( included_identifier, included_attributes, included_foreign_fields, included_type, base_api, ) for ( included_identifier, included_type, included_attributes, included_foreign_fields, ) in hook.include( foreign_fields={hook.TYPE_MAP[parent_type]: pid}, include=include, ) ] if include else [] ) child_resource = cls._to_child_resource( result, pid=pid, parent_type=parent_type, child_type=child_type, base_api=base_api, ) return { 'links': { 'self': str(request.url), }, 'data': child_resource, 'included': included or None, 'meta': meta, }
[docs] @classmethod def collection( cls, request: Request, results: PaginatedResults, *, resource_identity: str, resource_type: str, query_params: CollectionQueryParams = CollectionQueryParams(), meta: dict[str, t.Any] | None = None, ) -> dict[str, t.Any]: """Return a JSON:API document with a collection of resources derived from AiiDA objects. :param request: The incoming request. :type request: Request :param results: The paginated results to convert. :type results: PaginatedResults :param resource_identity: The identity field to use for the resources. :type resource_identity: str :param resource_type: The resource type to use for the resources. :type resource_type: str :param query_params: The collection query parameters. :type query_params: CollectionQueryParams :param meta: Optional meta information to include in the document. :type meta: dict[str, t.Any] | None :return: The JSON:API document. :rtype: JsonApiResponse """ base_api = cls._base_api_url(request) resources: list[dict[str, t.Any]] = [] included: list[dict[str, t.Any]] = [] included_cache = IncludedItemParamsCache() for result in results.data: resource, included_items = cls._build_resource( result, resource_identity, resource_type, base_api, include=query_params.include, cache=included_cache, ) resources.append(resource) included.extend(included_items) meta = { 'total': results.total, 'page': results.page, 'page_size': results.page_size, } | (meta or {}) toplevel_links = cls._build_toplevel_links( request, total=results.total, page=query_params.page, page_size=query_params.page_size, ) return { 'links': toplevel_links, 'meta': meta, 'included': included or None, 'data': resources, }
[docs] @classmethod def _base_api_url(cls, request: Request) -> str: """Return the base API URL from the request. :param request: The incoming request. :type request: Request :return: The base API URL. :rtype: str """ if not cls.BASE_API_URL: base = str(request.base_url).rstrip('/') cls.BASE_API_URL = f'{base}/{API_CONFIG["PREFIX"].lstrip("/")}' return cls.BASE_API_URL
[docs] @classmethod def _build_resource( cls, result: dict[str, t.Any], resource_identity: str, resource_type: str, base_api: str, include: list[str] | None = None, cache: IncludedItemParamsCache | None = None, ) -> tuple[dict[str, t.Any], list[dict[str, t.Any]]]: """Build a JSON:API resource and its included related resources. :param result: The result to convert. :type result: dict[str, t.Any] :param resource_identity: The identity field to use for the resource. :type resource_identity: str :param resource_type: The resource type to use for the resource. :type resource_type: str :param base_api: The base API URL. :type base_api: str :param include: A list of related resource types to include. :type include: list[str] | None :param cache: An optional cache for included resources. :type cache: IncludedItemParamsCache | None :return: The JSON:API resource and included resources. :rtype: tuple[dict[str, t.Any], list[dict[str, t.Any]]] :raises JsonApiException: If the resource identity or type are missing. """ hook = cls._hook_for(resource_type) identifier, attributes, foreign_fields = hook.split_resource( result, resource_identity, resource_type, ) included = ( [ cls._to_resource( included_identifier, included_attributes, included_foreign_fields, included_type, base_api, ) for ( included_identifier, included_type, included_attributes, included_foreign_fields, ) in hook.include( foreign_fields=foreign_fields, include=include, cache=cache, ) ] if include else [] ) resource = cls._to_resource( identifier, attributes, foreign_fields, resource_type, base_api, ) return resource, included
[docs] @classmethod def _hook_for(cls, resource_type: str) -> type[hooks.BaseHook]: """Return the Hook class for the given resource type. :param resource_type: The resource type. :type resource_type: str :return: The hook class. :rtype: type[Hook] """ return cls.HOOKS.get(resource_type, hooks.BaseHook)
[docs] @classmethod def _to_resource( cls, identifier: str | int, attributes: dict[str, t.Any], foreign_fields: dict[str, t.Any], resource_type: str, base_api: str, ) -> dict[str, t.Any]: """Convert an AiiDA quantity to a JSON:API resource. :param result: The result dictionary to convert. :type result: dict[str, t.Any] :param base_api: The base API URL. :type base_api: str :param resource_identity: The identity field to use for the resource. :type resource_identity: str :param resource_type: The resource type to use for the resource. :type resource_type: str :return: The JSON:API resource object. :rtype: dict[str, t.Any] """ hook = cls._hook_for(resource_type) links = hook.links( resource_type=resource_type, base_api_url=base_api, url_id=str(identifier), ) relationships = hook.relationships( foreign_fields=foreign_fields, resource_type=resource_type, base_api_url=base_api, url_id=str(identifier), ) return { 'id': identifier, 'type': resource_type, 'links': links or None, 'attributes': attributes or None, 'relationships': relationships or None, }
[docs] @classmethod def _to_child_resource( cls, result: dict[str, t.Any], *, pid: str | int, parent_type: str, child_type: str, base_api: str, ) -> dict[str, t.Any]: """Convert an dependent quantity to a single resource JSON:API document. :param result: The result dictionary to convert. :type result: dict[str, t.Any] :param pid: The parent resource identifier. :type pid: str | int :param parent_type: The parent resource type. :type parent_type: str :param child_type: The child resource type. :type child_type: str :param base_api: The base API URL. :type base_api: str :return: The JSON:API resource object. :rtype: dict[str, t.Any] """ root = f'{base_api}/{parent_type}' return { 'id': pid, 'type': child_type, 'links': { 'self': f'{root}/{pid}/{child_type.replace("-", "/")}', }, 'attributes': result, 'relationships': { 'parent': { 'links': { 'related': f'{root}/{pid}', }, 'data': { 'id': str(pid), 'type': parent_type, }, } }, }