diff --git a/swh/graphql/resolvers/base_connection.py b/swh/graphql/resolvers/base_connection.py index a970bd4..b8df117 100644 --- a/swh/graphql/resolvers/base_connection.py +++ b/swh/graphql/resolvers/base_connection.py @@ -1,146 +1,185 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from abc import ABC, abstractmethod import binascii from dataclasses import dataclass from typing import Any, List, Optional, Type, Union from graphql.type import GraphQLResolveInfo from swh.graphql.backends.archive import Archive from swh.graphql.backends.search import Search from swh.graphql.errors import PaginationError from swh.graphql.utils import utils from swh.storage.interface import PagedResult from .base_node import BaseNode @dataclass class PageInfo: hasNextPage: bool endCursor: Optional[str] @dataclass class ConnectionEdge: node: Any cursor: Optional[str] class BaseConnection(ABC): """ Base resolver for all the connections """ _node_class: Optional[Type[BaseNode]] = None _page_size: int = 50 # default page size (default value for the first arg) _max_page_size: int = 1000 # maximum page size(max value for the first arg) def __init__(self, obj, info, paged_data=None, **kwargs): self.obj: Optional[BaseNode] = obj self.info: GraphQLResolveInfo = info self.kwargs = kwargs # initialize commonly used vars self.archive = Archive() self.search = Search() self._paged_data: PagedResult = paged_data @property def edges(self) -> List[ConnectionEdge]: """ Return the list of connection edges, each with a cursor """ return [ ConnectionEdge(node=node, cursor=self._get_index_cursor(index, node)) for (index, node) in enumerate(self.nodes) ] @property def nodes(self) -> List[Union[BaseNode, object]]: """ Override if needed; return a list of objects If a node class is set, return a list of its (Node) instances else a list of raw results """ if self._node_class is not None: return [ self._node_class( obj=self, info=self.info, node_data=result, **self.kwargs ) for result in self.get_paged_data().results ] return self.get_paged_data().results @property def pageInfo(self) -> PageInfo: # To support the schema naming convention # FIXME, add more details like startCursor return PageInfo( hasNextPage=bool(self.get_paged_data().next_page_token), endCursor=utils.get_encoded_cursor(self.get_paged_data().next_page_token), ) @property def totalCount(self) -> Optional[int]: # To support the schema naming convention """ Will be None for most of the connections override if needed/possible """ return None def get_paged_data(self) -> PagedResult: """ Cache to avoid multiple calls to the backend :meth:`_get_paged_result` return a PagedResult object """ if self._paged_data is None: # FIXME, make this call async (not for v1) self._paged_data = self._get_paged_result() return self._paged_data @abstractmethod def _get_paged_result(self): """ Override for desired behaviour return a PagedResult object """ # FIXME, make this call async (not for v1) return None def _get_after_arg(self): """ Return the decoded next page token. Override to support a different cursor type """ # different implementation is used in SnapshotBranchConnection try: cursor = utils.get_decoded_cursor(self.kwargs.get("after")) except (UnicodeDecodeError, binascii.Error) as e: raise PaginationError("Invalid value for argument 'after'", errors=e) return cursor def _get_first_arg(self) -> int: """ """ # page_size is set to 50 by default # Input type check is not required; It is defined in schema as an int first = self.kwargs.get("first", self._page_size) if first < 0 or first > self._max_page_size: raise PaginationError( f"Value for argument 'first' is invalid; it must be between 0 and {self._max_page_size}" # noqa: B950 ) return first def _get_index_cursor(self, index: int, node: Any) -> Optional[str]: """ Get the cursor to the given item index """ # default implementation which works with swh-storage pagaination # override this function to support other types (eg: SnapshotBranchConnection) offset_index = self._get_after_arg() or "0" index_cursor = int(offset_index) + index return utils.get_encoded_cursor(str(index_cursor)) + + +class BaseList(ABC): + """ + Base class to be used for simple lists that do not require + pagination; eg resolveSWHID entrypoint + """ + + _node_class: Optional[Type[BaseNode]] = None + + def __init__(self, obj, info, results=None, **kwargs): + self.obj: Optional[BaseNode] = obj + self.info: GraphQLResolveInfo = info + self.kwargs = kwargs + self._results: List = results + + self.archive = Archive() + + def get_results(self) -> List: + if self._results is None: + # To avoid multiple calls to the backend + self._results = self._get_results() + + if self._node_class is not None: + # convert list items to node objects + return [ + self._node_class( + obj=self.obj, info=self.info, node_data=result, **self.kwargs + ) + for result in self._results + ] + return self._results + + @abstractmethod + def _get_results(self) -> List: + """ + Override for desired behaviour + return a list of objects + """ diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py index 1bca961..abb2595 100644 --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -1,103 +1,117 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from typing import ClassVar, Dict, Type from swh.graphql.errors import NullableObjectError -from .base_connection import BaseConnection +from .base_connection import BaseConnection, BaseList from .base_node import BaseNode from .content import ContentNode, HashContentNode, TargetContentNode from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode from .directory_entry import DirectoryEntryConnection, DirectoryEntryNode from .origin import OriginConnection, OriginNode, TargetOriginNode from .release import ReleaseNode, TargetReleaseNode from .revision import ( LogRevisionConnection, ParentRevisionConnection, RevisionNode, TargetRevisionNode, ) -from .search import ResolveSwhidConnection, SearchConnection +from .search import ResolveSwhidList, SearchConnection from .snapshot import ( OriginSnapshotConnection, SnapshotNode, TargetSnapshotNode, VisitSnapshotNode, ) from .snapshot_branch import AliasSnapshotBranchNode, SnapshotBranchConnection from .visit import LatestVisitNode, OriginVisitConnection, OriginVisitNode from .visit_status import LatestVisitStatusNode, VisitStatusConnection class NodeObjectFactory: mapping: ClassVar[Dict[str, Type[BaseNode]]] = { "origin": OriginNode, "visit": OriginVisitNode, "latest-visit": LatestVisitNode, "latest-status": LatestVisitStatusNode, "visit-snapshot": VisitSnapshotNode, "snapshot": SnapshotNode, "branch-alias": AliasSnapshotBranchNode, "branch-revision": TargetRevisionNode, "branch-release": TargetReleaseNode, "branch-directory": TargetDirectoryNode, "branch-content": TargetContentNode, "branch-snapshot": TargetSnapshotNode, "revision": RevisionNode, "revision-directory": RevisionDirectoryNode, "release": ReleaseNode, "release-revision": TargetRevisionNode, "release-release": TargetReleaseNode, "release-directory": TargetDirectoryNode, "release-content": TargetContentNode, "directory": DirectoryNode, "directory-entry": DirectoryEntryNode, "content": ContentNode, "content-by-hash": HashContentNode, "dir-entry-content": TargetContentNode, "dir-entry-directory": TargetDirectoryNode, "dir-entry-revision": TargetRevisionNode, "search-result-origin": TargetOriginNode, "search-result-snapshot": TargetSnapshotNode, "search-result-revision": TargetRevisionNode, "search-result-release": TargetReleaseNode, "search-result-directory": TargetDirectoryNode, "search-result-content": TargetContentNode, } @classmethod def create(cls, node_type: str, obj, info, *args, **kw): resolver = cls.mapping.get(node_type) if not resolver: raise AttributeError(f"Invalid node type: {node_type}") try: node_obj = resolver(obj, info, *args, **kw) except NullableObjectError: # Return None instead of the object node_obj = None return node_obj class ConnectionObjectFactory: mapping: ClassVar[Dict[str, Type[BaseConnection]]] = { "origins": OriginConnection, "origin-visits": OriginVisitConnection, "origin-snapshots": OriginSnapshotConnection, "visit-status": VisitStatusConnection, "snapshot-branches": SnapshotBranchConnection, "revision-parents": ParentRevisionConnection, "revision-log": LogRevisionConnection, "directory-entries": DirectoryEntryConnection, - "resolve-swhid": ResolveSwhidConnection, "search": SearchConnection, } @classmethod def create(cls, connection_type: str, obj, info, *args, **kw): resolver = cls.mapping.get(connection_type) if not resolver: raise AttributeError(f"Invalid connection type: {connection_type}") return resolver(obj, info, *args, **kw) + + +class SimpleListFactory: + mapping: ClassVar[Dict[str, Type[BaseList]]] = { + "resolve-swhid": ResolveSwhidList, + } + + @classmethod + def create(cls, list_type: str, obj, info, *args, **kw): + resolver = cls.mapping.get(list_type) + + if not resolver: + raise AttributeError(f"Invalid list type: {list_type}") + # invoke the get_results method to return the list + return resolver(obj, info, *args, **kw).get_results() diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py index b3cd69c..638bbdc 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,336 +1,343 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information """ High level resolvers """ # Any schema attribute can be resolved by any of the following ways # and in the following priority order # - In this module using a decorator (eg: @visit_status.field("snapshot")) # Every object (type) is expected to resolve this way as they can accept arguments # eg: origin.visits takes arguments to paginate # - As a property in the Node object (eg: resolvers.visit.BaseVisitNode.id) # Every scalar is expected to resolve this way # - As an attribute/item in the object/dict returned by a backend (eg: Origin.url) import datetime from typing import Optional, Union from ariadne import ObjectType, UnionType from graphql.type import GraphQLResolveInfo from swh.graphql import resolvers as rs from swh.graphql.utils import utils from swh.model.model import TimestampWithTimezone -from .resolver_factory import ConnectionObjectFactory, NodeObjectFactory +from .resolver_factory import ( + ConnectionObjectFactory, + NodeObjectFactory, + SimpleListFactory, +) query: ObjectType = ObjectType("Query") origin: ObjectType = ObjectType("Origin") visit: ObjectType = ObjectType("Visit") visit_status: ObjectType = ObjectType("VisitStatus") snapshot: ObjectType = ObjectType("Snapshot") snapshot_branch: ObjectType = ObjectType("Branch") revision: ObjectType = ObjectType("Revision") release: ObjectType = ObjectType("Release") directory: ObjectType = ObjectType("Directory") directory_entry: ObjectType = ObjectType("DirectoryEntry") search_result: ObjectType = ObjectType("SearchResult") binary_string: ObjectType = ObjectType("BinaryString") date: ObjectType = ObjectType("Date") branch_target: UnionType = UnionType("BranchTarget") release_target: UnionType = UnionType("ReleaseTarget") directory_entry_target: UnionType = UnionType("DirectoryEntryTarget") search_result_target: UnionType = UnionType("SearchResultTarget") # Node resolvers # A node resolver will return either an instance of a BaseNode subclass or None @query.field("origin") def origin_resolver(obj: None, info: GraphQLResolveInfo, **kw) -> rs.origin.OriginNode: return NodeObjectFactory.create("origin", obj, info, **kw) @origin.field("latestVisit") def latest_visit_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.visit.LatestVisitNode]: return NodeObjectFactory.create("latest-visit", obj, info, **kw) @query.field("visit") def visit_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitNode: return NodeObjectFactory.create("visit", obj, info, **kw) @visit.field("latestStatus") def latest_visit_status_resolver( obj: rs.visit.BaseVisitNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.visit_status.LatestVisitStatusNode]: return NodeObjectFactory.create("latest-status", obj, info, **kw) @query.field("snapshot") def snapshot_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.SnapshotNode: return NodeObjectFactory.create("snapshot", obj, info, **kw) @visit_status.field("snapshot") def visit_snapshot_resolver( obj: rs.visit_status.BaseVisitStatusNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.snapshot.VisitSnapshotNode]: return NodeObjectFactory.create("visit-snapshot", obj, info, **kw) @snapshot_branch.field("target") def snapshot_branch_target_resolver( obj: rs.snapshot_branch.BaseSnapshotBranchNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, rs.snapshot.BaseSnapshotNode, rs.snapshot_branch.BaseSnapshotBranchNode, ]: """ Snapshot branch target can be a revision, release, directory, content, snapshot or a branch itself (alias type) """ return NodeObjectFactory.create(f"branch-{obj.targetType}", obj, info, **kw) @query.field("revision") def revision_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.revision.RevisionNode: return NodeObjectFactory.create("revision", obj, info, **kw) @revision.field("directory") def revision_directory_resolver( obj: rs.revision.BaseRevisionNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.directory.RevisionDirectoryNode]: return NodeObjectFactory.create("revision-directory", obj, info, **kw) @query.field("release") def release_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.release.ReleaseNode: return NodeObjectFactory.create("release", obj, info, **kw) @release.field("target") def release_target_resolver( obj: rs.release.BaseReleaseNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: """ Release target can be a release, revision, directory or a content """ return NodeObjectFactory.create(f"release-{obj.targetType}", obj, info, **kw) @query.field("directory") def directory_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.directory.DirectoryNode: return NodeObjectFactory.create("directory", obj, info, **kw) @query.field("directoryEntry") def directory_entry_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.directory_entry.DirectoryEntryNode: return NodeObjectFactory.create("directory-entry", obj, info, **kw) @directory_entry.field("target") def directory_entry_target_resolver( obj: rs.directory_entry.BaseDirectoryEntryNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.revision.BaseRevisionNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: """ DirectoryEntry target can be a directory, content or a revision """ return NodeObjectFactory.create(f"dir-entry-{obj.targetType}", obj, info, **kw) @query.field("content") def content_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.content.ContentNode: return NodeObjectFactory.create("content", obj, info, **kw) @search_result.field("target") def search_result_target_resolver( obj: rs.search.SearchResultNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.origin.BaseOriginNode, rs.snapshot.BaseSnapshotNode, rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: """ SearchResult target can be an origin, snapshot, revision, release directory or a content """ return NodeObjectFactory.create(f"search-result-{obj.targetType}", obj, info, **kw) @query.field("contentByHashes") def content_by_hash_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.content.ContentNode: return NodeObjectFactory.create("content-by-hash", obj, info, **kw) # Connection resolvers # A connection resolver should return an instance of BaseConnection @query.field("origins") def origins_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.origin.OriginConnection: return ConnectionObjectFactory.create("origins", obj, info, **kw) @origin.field("visits") def visits_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitConnection: return ConnectionObjectFactory.create("origin-visits", obj, info, **kw) @origin.field("snapshots") def origin_snapshots_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.OriginSnapshotConnection: return ConnectionObjectFactory.create("origin-snapshots", obj, info, **kw) @visit.field("statuses") def visitstatus_resolver( obj: rs.visit.BaseVisitNode, info: GraphQLResolveInfo, **kw ) -> rs.visit_status.VisitStatusConnection: return ConnectionObjectFactory.create("visit-status", obj, info, **kw) @snapshot.field("branches") def snapshot_branches_resolver( obj: rs.snapshot.BaseSnapshotNode, info: GraphQLResolveInfo, **kw ) -> rs.snapshot_branch.SnapshotBranchConnection: return ConnectionObjectFactory.create("snapshot-branches", obj, info, **kw) @revision.field("parents") def revision_parents_resolver( obj: rs.revision.BaseRevisionNode, info: GraphQLResolveInfo, **kw ) -> rs.revision.ParentRevisionConnection: return ConnectionObjectFactory.create("revision-parents", obj, info, **kw) @revision.field("revisionLog") def revision_log_resolver( obj: rs.revision.BaseRevisionNode, info: GraphQLResolveInfo, **kw ) -> rs.revision.LogRevisionConnection: return ConnectionObjectFactory.create("revision-log", obj, info, **kw) @directory.field("entries") def directory_entries_resolver( obj: rs.directory.BaseDirectoryNode, info: GraphQLResolveInfo, **kw ) -> rs.directory_entry.DirectoryEntryConnection: return ConnectionObjectFactory.create("directory-entries", obj, info, **kw) -@query.field("resolveSWHID") -def search_swhid_resolver( - obj: None, info: GraphQLResolveInfo, **kw -) -> rs.search.ResolveSwhidConnection: - return ConnectionObjectFactory.create("resolve-swhid", obj, info, **kw) - - @query.field("search") def search_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.search.SearchConnection: return ConnectionObjectFactory.create("search", obj, info, **kw) +# Simple list resolvers (lists without paginations) + + +@query.field("resolveSWHID") +def search_swhid_resolver( + obj: None, info: GraphQLResolveInfo, **kw +) -> rs.search.ResolveSwhidList: + return SimpleListFactory.create("resolve-swhid", obj, info, **kw) + + # Other resolvers @release_target.type_resolver @directory_entry_target.type_resolver @branch_target.type_resolver @search_result_target.type_resolver def union_resolver( obj: Union[ rs.origin.BaseOriginNode, rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, rs.snapshot.BaseSnapshotNode, rs.snapshot_branch.BaseSnapshotBranchNode, ], *_, ) -> str: """ Generic resolver for all the union types """ return obj.is_type_of() # BinaryString resolvers @binary_string.field("text") def binary_string_text_resolver(obj: bytes, *args, **kw) -> str: return obj.decode(utils.ENCODING, "replace") @binary_string.field("base64") def binary_string_base64_resolver(obj: bytes, *args, **kw) -> str: return utils.get_b64_string(obj) # Date object resolver @date.field("date") def date_date_resolver( obj: TimestampWithTimezone, *args: GraphQLResolveInfo, **kw ) -> datetime.datetime: # This will be serialised as a DateTime Scalar return obj.to_datetime() @date.field("offset") def date_offset_resolver( obj: TimestampWithTimezone, *args: GraphQLResolveInfo, **kw ) -> bytes: # This will be serialised as a Binary string return obj.offset_bytes diff --git a/swh/graphql/resolvers/search.py b/swh/graphql/resolvers/search.py index 29145ca..ef13a66 100644 --- a/swh/graphql/resolvers/search.py +++ b/swh/graphql/resolvers/search.py @@ -1,52 +1,53 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from swh.storage.interface import PagedResult -from .base_connection import BaseConnection +from .base_connection import BaseConnection, BaseList from .base_node import BaseNode class SearchResultNode(BaseNode): """ """ @property def targetType(self): # To support the schema naming convention return self._node.type -class ResolveSwhidConnection(BaseConnection): +class ResolveSwhidList(BaseList): _node_class = SearchResultNode - def _get_paged_result(self) -> PagedResult: + def _get_results(self) -> list: swhid = self.kwargs.get("swhid") results = [] + if self.archive.is_object_available(swhid.object_id, swhid.object_type): results = [ { "target_hash": swhid.object_id, "type": swhid.object_type.name.lower(), } ] - return PagedResult(results=results) + return results class SearchConnection(BaseConnection): _node_class = SearchResultNode def _get_paged_result(self) -> PagedResult: origins = self.search.get_origins( query=self.kwargs.get("query"), after=self._get_after_arg(), first=self._get_first_arg(), ) # FIXME hard coding type to origin for now, as it is the only searchable object results = [ {"target_url": ori["url"], "type": "origin"} for ori in origins.results ] return PagedResult(results=results, next_page_token=origins.next_page_token) diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index 369bace..87233ed 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1164 +1,1164 @@ """ SoftWare Heritage persistent Identifier """ scalar SWHID """ ISO-8601 encoded date string """ scalar DateTime """ Object with an id """ interface Node { """ Id of the object. This is for caching purpose and should not be used outside the GraphQL API """ id: ID! } """ SWH merkle node object with a SWHID """ interface SWHNode { """ SWHID of the object """ swhid: SWHID! } """ Information about pagination """ type PageInfo { """ Cursor to request the next page in the connection """ endCursor: String """ Are there more pages in the connection? """ hasNextPage: Boolean! } """ Binary string with multiple encodings """ type BinaryString { """ Utf-8 encoded value, any non Unicode character will be replaced """ text: String! """ base64 encoded value """ base64: String! } """ Connection to origins """ type OriginConnection { """ List of origin edges """ edges: [OriginEdge] """ List of origin objects """ nodes: [Origin] """ Information for pagination """ pageInfo: PageInfo! """ Total number of origin objects in the connection """ totalCount: Int } """ Edge in origin connection """ type OriginEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Origin object """ node: Origin } """ A software origin object """ type Origin implements Node { """ Unique identifier """ id: ID! """ Origin URL """ url: String! """ Connection to all the visit objects for the origin """ visits( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): VisitConnection """ Latest visit object for the origin """ latestVisit( """ Return the latest visit with the given visit type """ visitType: String """ Return the latest visit with any of the given statuses """ allowedStatuses: [VisitStatusState] """ If True, the latest visit with a snapshot will be returned """ requireSnapshot: Boolean ): Visit """ Connection to all the snapshots for the origin """ snapshots( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): SnapshotConnection } """ Connection to origin visits """ type VisitConnection { """ List of visit edges """ edges: [VisitEdge] """ List of visit objects """ nodes: [Visit] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit objects in the connection """ totalCount: Int } """ Edge in origin visit connection """ type VisitEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Visit object """ node: Visit } """ Possible visit status states """ enum VisitStatusState { created ongoing partial full not_found failed } """ An origin visit object """ type Visit implements Node { """ Unique identifier """ id: ID! """ Visit number for the origin """ visitId: Int """ Visit date ISO-8601 encoded """ date: DateTime! """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String """ Connection to all the status objects for the visit """ statuses( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): VisitStatusConnection """ Latest status object for the Visit """ latestStatus( """ Filter by status state """ allowedStatuses: [VisitStatusState] """ Filter by the availability of a snapshot in the status """ requireSnapshot: Boolean ): VisitStatus } """ Connection to visit status """ type VisitStatusConnection { """ List of visit status edges """ edges: [VisitStatusEdge] """ List of visit status objects """ nodes: [VisitStatus] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit status objects in the connection """ totalCount: Int } """ Edge in visit status connection """ type VisitStatusEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Visit status object """ node: VisitStatus } """ A visit status object """ type VisitStatus { """ Status string of the visit (either full, partial or ongoing) """ status: VisitStatusState! """ ISO-8601 encoded date string """ date: DateTime! """ Snapshot object """ snapshot: Snapshot """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String } """ Connection to snapshots """ type SnapshotConnection { """ List of snapshot edges """ edges: [SnapshotEdge] """ List of snapshot objects """ nodes: [Snapshot] """ Information for pagination """ pageInfo: PageInfo! """ Total number of snapshot objects in the connection """ totalCount: Int } """ Edge in snapshot connection """ type SnapshotEdge { """ Cursor to request the next page after the item """ cursor: String! """ Snapshot object """ node: Snapshot } """ A snapshot object """ type Snapshot implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the snapshot object """ swhid: SWHID! """ Connection to all the snapshot branches """ branches( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String """ Filter by branch target types """ types: [BranchTargetType] """ Return branches whose name contains the given substring """ nameInclude: String """ Do not return branches whose name contains the given prefix """ nameExcludePrefix: String ): BranchConnection } """ Connection to snapshot branches """ type BranchConnection { """ List of branch edges """ edges: [BranchConnectionEdge] """ List of branch objects """ nodes: [Branch] """ Information for pagination """ pageInfo: PageInfo! """ Total number of branch objects in the connection """ totalCount: Int } """ Edge in snapshot branch connection """ type BranchConnectionEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Branch object """ node: Branch } """ A user object """ type Person { """ User's email address """ email: BinaryString """ User's name """ name: BinaryString """ User's full name """ fullname: BinaryString } """ Possible branch target objects """ union BranchTarget = Revision | Release | Branch | Content | Directory | Snapshot """ Possible Branch target types """ enum BranchTargetType { revision release alias content directory snapshot } """ A snapshot branch object """ type Branch { """ Branch name """ name: BinaryString """ Type of Branch target """ targetType: BranchTargetType """ Branch target object """ target: BranchTarget } """ Connection to revisions """ type RevisionConnection { """ List of revision edges """ edges: [RevisionEdge] """ List of revision objects """ nodes: [Revision] """ Information for pagination """ pageInfo: PageInfo! """ Total number of revision objects in the connection """ totalCount: Int } """ Edge in revision connection """ type RevisionEdge { """ Cursor to request the next page after the item """ cursor: String! """ Revision object """ node: Revision } """ Object with Date values """ type Date { """ ISO-8601 encoded date string. """ date: DateTime """ UTC offset """ offset: BinaryString } """ A revision object """ type Revision implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the revision object """ swhid: SWHID! """ Message associated to the revision """ message: BinaryString """ Revision author """ author: Person """ Revision committer """ committer: Person """ Commit date """ committerDate: Date """ Revision date """ date: Date """ Type of the revision, eg: git/hg """ type: String """ The unique directory object that revision points to """ directory: Directory """ Connection to all the parents of the revision """ parents( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): RevisionConnection """ Connection to all the revisions heading to this one aka the commit log """ revisionLog( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): RevisionConnection } """ Possible release target objects """ union ReleaseTarget = Release | Revision | Directory | Content """ Possible release target types """ enum ReleaseTargetType { release revision content directory } """ A release object """ type Release implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the release object """ swhid: SWHID! """ The name of the release """ name: BinaryString """ The message associated to the release """ message: BinaryString """ Release author """ author: Person """ Release date """ date: Date """ Type of release target """ targetType: ReleaseTargetType """ Release target object """ target: ReleaseTarget } """ Connection to directory entries """ type DirectoryEntryConnection { """ List of directory entry edges """ edges: [DirectoryEntryEdge] """ List of directory entry objects """ nodes: [DirectoryEntry] """ Information for pagination """ pageInfo: PageInfo! """ Total number of directory entry objects in the connection """ totalCount: Int } """ Edge in directory entry connection """ type DirectoryEntryEdge { """ Cursor to request the next page after the item """ cursor: String! """ Directory entry object """ node: DirectoryEntry } """ Possible directory entry target objects """ union DirectoryEntryTarget = Directory | Content | Revision """ Possible directory entry types """ enum DirectoryEntryTargetType { directory content revision } """ A directory entry object """ type DirectoryEntry { """ The directory entry name """ name: BinaryString """ Directory entry object type; can be file, dir or rev """ targetType: DirectoryEntryTargetType """ Directory entry target object """ target: DirectoryEntryTarget } """ A directory object """ type Directory implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the directory object """ swhid: SWHID! """ Connection to the directory entries """ entries( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String """ Filter by entry name """ nameInclude: String ): DirectoryEntryConnection } """ An object with different content hashes """ type ContentHashes { blake2s256: String sha1: String sha1_git: String sha256: String } """ Object with different content data representations """ type ContentData { """ URL to download the file data """ url: String } type ContentMimeType { """ Detected content encoding """ encoding: String """ Detected MIME type of the content """ mimetype: String } type ContentLanguage { """ Detected programming language if any """ lang: String } type ContentLicense { """ Array of strings containing the detected license names """ licenses: [String] } """ A content object """ type Content implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the content object """ swhid: SWHID! """ Hashes for the content """ hashes: ContentHashes """ Length of the content in bytes """ length: Int """ Content status, visible or hidden """ status: String """ File content """ data: ContentData """ Information about the content MIME type """ mimeType: ContentMimeType """ Information about the programming language used in the content """ language: ContentLanguage """ Information about the license of the content """ license: ContentLicense } """ Connection to SearchResults """ type SearchResultConnection { """ List of SearchResult edges """ edges: [SearchResultEdge] """ List of SearchResult objects """ nodes: [SearchResult] """ Information for pagination """ pageInfo: PageInfo! """ Total number of result objects in the connection """ totalCount: Int } """ Edge in SearchResult connection """ type SearchResultEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ SearchResult object """ node: SearchResult } union SearchResultTarget = Origin | Revision | Release | Content | Directory | Snapshot enum SearchResultTargetType { origin revision release content directory snapshot } """ A SearchResult object """ type SearchResult { """ Result target type """ targetType: SearchResultTargetType """ Result target object """ target: SearchResultTarget } """ The query root of the GraphQL interface. """ type Query { """ Get an origin with its url """ origin( """ URL of the Origin """ url: String! ): Origin """ Get a Connection to all the origins """ origins( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String """ Filter origins with a URL pattern """ urlPattern: String ): OriginConnection """ Get the visit object with an origin URL and a visit id """ visit( """ URL of the origin """ originUrl: String! """ Visit id to get """ visitId: Int! ): Visit """ Get the snapshot with a SWHID """ snapshot( """ SWHID of the snapshot object """ swhid: SWHID! ): Snapshot """ Get the revision with a SWHID """ revision( """ SWHID of the revision object """ swhid: SWHID! ): Revision """ Get the release with a SWHID """ release( """ SWHID of the release object """ swhid: SWHID! ): Release """ Get the directory with a SWHID """ directory( """ SWHID of the directory object """ swhid: SWHID! ): Directory """ Get a directory entry with directory SWHID and a path """ directoryEntry( """ SWHID of the directory object """ directorySWHID: SWHID! """ Relative path to the requested object """ path: String! ): DirectoryEntry """ Get the content with a SWHID """ content( """ SWHID of the content object """ swhid: SWHID! ): Content """ Get a content that match all the given hashes. This entrypoint can be used to uniquely identify a content in the event of hash conflicts. Use multiple hashes to get an accurate result. At least one of the four hashes must be provided. """ contentByHashes( sha1: String sha256: String sha1_git: String blake2s256: String ): Content """ Resolve the given SWHID to an object """ resolveSWHID( """ SWHID to look for """ swhid: SWHID! - ): SearchResultConnection + ): [SearchResult] """ Search in SWH """ search( """ String to search for """ query: String! """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): SearchResultConnection } diff --git a/swh/graphql/tests/functional/test_swhid_resolve.py b/swh/graphql/tests/functional/test_swhid_resolve.py index 5ca39aa..b9405d6 100644 --- a/swh/graphql/tests/functional/test_swhid_resolve.py +++ b/swh/graphql/tests/functional/test_swhid_resolve.py @@ -1,221 +1,197 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import pytest from . import utils from ..data import ( get_contents, get_directories, get_releases, get_revisions, get_snapshots, ) def test_invalid_swhid(client): query_str = """ query resolve($swhid: SWHID!) { resolveSWHID(swhid: $swhid) { - nodes { - targetType - } + targetType } } """ errors = utils.get_error_response(client, query_str, swhid="swh:1:dir:invalid") # API will throw an error in case of an invalid SWHID assert len(errors) == 1 assert "Input error: Invalid SWHID" in errors[0]["message"] @pytest.mark.parametrize( "swhid", [ "swh:1:rel:0949d7a8c96347dba09be8d79085b8207f345412", "swh:1:rev:0949d7a8c96347dba09be8d79085b8207f345412", "swh:1:dir:0949d7a8c96347dba09be8d79085b8207f345412", "swh:1:cnt:0949d7a8c96347dba09be8d79085b8207f345412", "swh:1:snp:0949d7a8c96347dba09be8d79085b8207f345412", ], ) def test_missing_swhid(client, swhid): query_str = """ query resolve($swhid: SWHID!) { resolveSWHID(swhid: $swhid) { - nodes { - targetType - } + targetType } } """ data, _ = utils.get_query_response(client, query_str, swhid=swhid) # API will return an empty list in case of a valid, non existing SWHID - assert data == {"resolveSWHID": {"nodes": []}} + assert data == {"resolveSWHID": []} @pytest.mark.parametrize("snapshot", get_snapshots()) def test_snapshot_swhid_resolve(client, snapshot): query_str = """ query resolve($swhid: SWHID!) { resolveSWHID(swhid: $swhid) { - nodes { - targetType - target { - __typename - ... on Snapshot { - swhid - } + targetType + target { + __typename + ... on Snapshot { + swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(snapshot.swhid())) assert data == { - "resolveSWHID": { - "nodes": [ - { - "target": { - "__typename": "Snapshot", - "swhid": str(snapshot.swhid()), - }, - "targetType": "snapshot", - } - ] - } + "resolveSWHID": [ + { + "target": { + "__typename": "Snapshot", + "swhid": str(snapshot.swhid()), + }, + "targetType": "snapshot", + } + ] } @pytest.mark.parametrize("revision", get_revisions()) def test_revision_swhid_resolve(client, revision): query_str = """ query resolve($swhid: SWHID!) { resolveSWHID(swhid: $swhid) { - nodes { - targetType - target { - __typename - ... on Revision { - swhid - } + targetType + target { + __typename + ... on Revision { + swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(revision.swhid())) assert data == { - "resolveSWHID": { - "nodes": [ - { - "target": { - "__typename": "Revision", - "swhid": str(revision.swhid()), - }, - "targetType": "revision", - } - ] - } + "resolveSWHID": [ + { + "target": { + "__typename": "Revision", + "swhid": str(revision.swhid()), + }, + "targetType": "revision", + } + ] } @pytest.mark.parametrize("release", get_releases()) def test_release_swhid_resolve(client, release): query_str = """ query resolve($swhid: SWHID!) { resolveSWHID(swhid: $swhid) { - nodes { - targetType - target { - __typename - ... on Release { - swhid - } + targetType + target { + __typename + ... on Release { + swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(release.swhid())) assert data == { - "resolveSWHID": { - "nodes": [ - { - "target": { - "__typename": "Release", - "swhid": str(release.swhid()), - }, - "targetType": "release", - } - ] - } + "resolveSWHID": [ + { + "target": { + "__typename": "Release", + "swhid": str(release.swhid()), + }, + "targetType": "release", + } + ] } @pytest.mark.parametrize("directory", get_directories()) def test_directory_swhid_resolve(client, directory): query_str = """ query resolve($swhid: SWHID!) { resolveSWHID(swhid: $swhid) { - nodes { - targetType - target { - __typename - ... on Directory { - swhid - } + targetType + target { + __typename + ... on Directory { + swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(directory.swhid())) assert data == { - "resolveSWHID": { - "nodes": [ - { - "target": { - "__typename": "Directory", - "swhid": str(directory.swhid()), - }, - "targetType": "directory", - } - ] - } + "resolveSWHID": [ + { + "target": { + "__typename": "Directory", + "swhid": str(directory.swhid()), + }, + "targetType": "directory", + } + ] } @pytest.mark.parametrize("content", get_contents()) def test_content_swhid_resolve(client, content): query_str = """ query resolve($swhid: SWHID!) { resolveSWHID(swhid: $swhid) { - nodes { - targetType - target { - __typename - ... on Content { - swhid - } + targetType + target { + __typename + ... on Content { + swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(content.swhid())) assert data == { - "resolveSWHID": { - "nodes": [ - { - "target": { - "__typename": "Content", - "swhid": str(content.swhid()), - }, - "targetType": "content", - } - ] - } + "resolveSWHID": [ + { + "target": { + "__typename": "Content", + "swhid": str(content.swhid()), + }, + "targetType": "content", + } + ] }