diff --git a/swh/graphql/app.py b/swh/graphql/app.py index 21e7865..7d0b11d 100644 --- a/swh/graphql/app.py +++ b/swh/graphql/app.py @@ -1,40 +1,42 @@ # 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 pkg_resources import os from pathlib import Path from ariadne import gql, load_schema_from_path, make_executable_schema from .resolvers import resolvers, scalars type_defs = gql( # pkg_resources.resource_string("swh.graphql", "schem/schema.graphql").decode() load_schema_from_path( os.path.join(Path(__file__).parent.resolve(), "schema", "schema.graphql") ) ) schema = make_executable_schema( type_defs, resolvers.query, resolvers.origin, resolvers.visit, resolvers.visit_status, resolvers.snapshot, resolvers.snapshot_branch, resolvers.revision, resolvers.release, resolvers.directory, resolvers.directory_entry, + resolvers.search_result, resolvers.branch_target, resolvers.release_target, resolvers.directory_entry_target, + resolvers.search_result_target, resolvers.binary_string, scalars.id_scalar, scalars.datetime_scalar, scalars.swhid_scalar, ) diff --git a/swh/graphql/backends/archive.py b/swh/graphql/backends/archive.py index cee5091..7b62510 100644 --- a/swh/graphql/backends/archive.py +++ b/swh/graphql/backends/archive.py @@ -1,79 +1,84 @@ # 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.graphql import server +from swh.model.swhids import ObjectType class Archive: def __init__(self): self.storage = server.get_storage() def get_origin(self, url): return self.storage.origin_get([url])[0] def get_origins(self, after=None, first=50, url_pattern=None): # STORAGE-TODO # Make them a single function in the backend if url_pattern is None: return self.storage.origin_list(page_token=after, limit=first) return self.storage.origin_search( url_pattern=url_pattern, page_token=after, limit=first ) def get_origin_visits(self, origin_url, after=None, first=50): return self.storage.origin_visit_get(origin_url, page_token=after, limit=first) def get_origin_visit(self, origin_url, visit_id): return self.storage.origin_visit_get_by(origin_url, visit_id) def get_origin_latest_visit(self, origin_url): return self.storage.origin_visit_get_latest(origin_url) def get_visit_status(self, origin_url, visit_id, after=None, first=50): return self.storage.origin_visit_status_get( origin_url, visit_id, page_token=after, limit=first ) def get_latest_visit_status(self, origin_url, visit_id): return self.storage.origin_visit_status_get_latest(origin_url, visit_id) def get_origin_snapshots(self, origin_url): return self.storage.origin_snapshot_get_all(origin_url) - def is_snapshot_available(self, snapshot_ids): - return not self.storage.snapshot_missing(snapshot_ids) - def get_snapshot_branches( self, snapshot, after=b"", first=50, target_types=[], name_include=None ): return self.storage.snapshot_get_branches( snapshot, branches_from=after, branches_count=first, target_types=target_types, branch_name_include_substring=name_include, ) def get_revisions(self, revision_ids): return self.storage.revision_get(revision_ids=revision_ids) def get_revision_log(self, revision_ids, after=None, first=50): return self.storage.revision_log(revisions=revision_ids, limit=first) def get_releases(self, release_ids): return self.storage.release_get(releases=release_ids) - def is_directory_available(self, directory_ids): - return not self.storage.directory_missing(directory_ids) - def get_directory_entries(self, directory_id, after=None, first=50): return self.storage.directory_get_entries( directory_id, limit=first, page_token=after ) def get_content(self, content_id): # FIXME, only for tests return self.storage.content_find({"sha1_git": content_id}) + + def is_object_available(self, object_id: str, object_type: ObjectType) -> bool: + mapping = { + ObjectType.CONTENT: self.storage.content_missing_per_sha1_git, + ObjectType.DIRECTORY: self.storage.directory_missing, + ObjectType.RELEASE: self.storage.release_missing, + ObjectType.REVISION: self.storage.revision_missing, + ObjectType.SNAPSHOT: self.storage.snapshot_missing, + } + return not mapping[object_type]([object_id]) diff --git a/swh/graphql/resolvers/directory.py b/swh/graphql/resolvers/directory.py index 5cbf024..7c3e126 100644 --- a/swh/graphql/resolvers/directory.py +++ b/swh/graphql/resolvers/directory.py @@ -1,70 +1,76 @@ # 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 Union from swh.graphql.backends import archive from swh.model.model import Directory +from swh.model.swhids import ObjectType from .base_node import BaseSWHNode from .release import BaseReleaseNode from .revision import BaseRevisionNode from .snapshot_branch import SnapshotBranchNode class BaseDirectoryNode(BaseSWHNode): """ Base resolver for all the directory nodes """ def _get_directory_by_id(self, directory_id): # Return a Directory model object # entries is initialized as empty # Same pattern is used in snapshot return Directory(id=directory_id, entries=()) def is_type_of(self): return "Directory" class DirectoryNode(BaseDirectoryNode): """ Node resolver for a directory requested directly with its SWHID """ def _get_node_data(self): - directory_id = self.kwargs.get("swhid").object_id + swhid = self.kwargs.get("swhid") # path = "" - if archive.Archive().is_directory_available([directory_id]): + if ( + swhid.object_type == ObjectType.DIRECTORY + and archive.Archive().is_object_available( + swhid.object_id, swhid.object_type + ) + ): # _get_directory_by_id is not making any backend call # hence the is_directory_available validation - return self._get_directory_by_id(directory_id) + return self._get_directory_by_id(swhid.object_id) return None class RevisionDirectoryNode(BaseDirectoryNode): """ Node resolver for a directory requested from a revision """ obj: BaseRevisionNode def _get_node_data(self): # self.obj.directory_swhid is the requested directory SWHID directory_id = self.obj.directory_swhid.object_id return self._get_directory_by_id(directory_id) class TargetDirectoryNode(BaseDirectoryNode): """ Node resolver for a directory requested as a target """ from .directory_entry import DirectoryEntryNode obj: Union[SnapshotBranchNode, BaseReleaseNode, DirectoryEntryNode] def _get_node_data(self): return self._get_directory_by_id(self.obj.target_hash) diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py index 388128f..8f42e9e 100644 --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -1,73 +1,80 @@ # 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 .content import ContentNode, TargetContentNode from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode from .directory_entry import DirectoryEntryConnection from .origin import OriginConnection, OriginNode from .release import ReleaseNode, TargetReleaseNode from .revision import ( LogRevisionConnection, ParentRevisionConnection, RevisionNode, TargetRevisionNode, ) +from .search import ResolveSwhidConnection from .snapshot import ( OriginSnapshotConnection, SnapshotNode, TargetSnapshotNode, VisitSnapshotNode, ) from .snapshot_branch import SnapshotBranchConnection from .visit import LatestVisitNode, OriginVisitConnection, OriginVisitNode from .visit_status import LatestVisitStatusNode, VisitStatusConnection def get_node_resolver(resolver_type): # FIXME, replace with a proper factory method mapping = { "origin": OriginNode, "visit": OriginVisitNode, "latest-visit": LatestVisitNode, "latest-status": LatestVisitStatusNode, "visit-snapshot": VisitSnapshotNode, "snapshot": SnapshotNode, "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, "content": ContentNode, "dir-entry-dir": TargetDirectoryNode, "dir-entry-file": TargetContentNode, + "search-result-snapshot": TargetSnapshotNode, + "search-result-revision": TargetRevisionNode, + "search-result-release": TargetReleaseNode, + "search-result-directory": TargetDirectoryNode, + "search-result-content": TargetContentNode, } if resolver_type not in mapping: raise AttributeError(f"Invalid node type: {resolver_type}") return mapping[resolver_type] def get_connection_resolver(resolver_type): # FIXME, replace with a proper factory method mapping = { "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, } if resolver_type not in mapping: raise AttributeError(f"Invalid connection type: {resolver_type}") return mapping[resolver_type] diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py index 4bd38f4..ac0e315 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,262 +1,282 @@ # 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: @visitstatus.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) from ariadne import ObjectType, UnionType from graphql.type import GraphQLResolveInfo from swh.graphql import resolvers as rs from swh.graphql.utils import utils from .resolver_factory import get_connection_resolver, get_node_resolver 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") 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 should return an instance of BaseNode @query.field("origin") def origin_resolver(obj: None, info: GraphQLResolveInfo, **kw) -> rs.origin.OriginNode: """ """ resolver = get_node_resolver("origin") return resolver(obj, info, **kw) @origin.field("latestVisit") def latest_visit_resolver( obj: rs.origin.OriginNode, info: GraphQLResolveInfo, **kw ) -> rs.visit.LatestVisitNode: """ """ resolver = get_node_resolver("latest-visit") return resolver(obj, info, **kw) @query.field("visit") def visit_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitNode: """ """ resolver = get_node_resolver("visit") return resolver(obj, info, **kw) @visit.field("latestStatus") def latest_visit_status_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.visit_status.LatestVisitStatusNode: """ """ resolver = get_node_resolver("latest-status") return resolver(obj, info, **kw) @query.field("snapshot") def snapshot_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.SnapshotNode: """ """ resolver = get_node_resolver("snapshot") return resolver(obj, info, **kw) @visit_status.field("snapshot") def visit_snapshot_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.VisitSnapshotNode: resolver = get_node_resolver("visit-snapshot") return resolver(obj, info, **kw) @snapshot_branch.field("target") def snapshot_branch_target_resolver( obj: rs.snapshot_branch.SnapshotBranchNode, info: GraphQLResolveInfo, **kw ): """ Snapshot branch target can be a revision or a release """ resolver_type = f"branch-{obj.type}" resolver = get_node_resolver(resolver_type) return resolver(obj, info, **kw) @query.field("revision") def revision_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.revision.RevisionNode: resolver = get_node_resolver("revision") return resolver(obj, info, **kw) @revision.field("directory") def revision_directory_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.directory.RevisionDirectoryNode: resolver = get_node_resolver("revision-directory") return resolver(obj, info, **kw) @query.field("release") def release_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.release.ReleaseNode: resolver = get_node_resolver("release") return resolver(obj, info, **kw) @release.field("target") def release_target_resolver(obj, info: GraphQLResolveInfo, **kw): """ release target can be a release, revision, directory or content obj is release here, target type is obj.target_type """ resolver_type = f"release-{obj.target_type.value}" resolver = get_node_resolver(resolver_type) return resolver(obj, info, **kw) @query.field("directory") def directory_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.directory.DirectoryNode: resolver = get_node_resolver("directory") return resolver(obj, info, **kw) @directory_entry.field("target") def directory_entry_target_resolver( obj: rs.directory_entry.DirectoryEntryNode, info: GraphQLResolveInfo, **kw ): """ directory entry target can be a directory or a content """ resolver_type = f"dir-entry-{obj.type}" resolver = get_node_resolver(resolver_type) return resolver(obj, info, **kw) @query.field("content") def content_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.content.ContentNode: resolver = get_node_resolver("content") return resolver(obj, info, **kw) +@search_result.field("target") +def search_result_target_resolver( + obj: rs.search.SearchResultNode, info: GraphQLResolveInfo, **kw +): + resolver_type = f"search-result-{obj.type}" + resolver = get_node_resolver(resolver_type) + return resolver(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: resolver = get_connection_resolver("origins") return resolver(obj, info, **kw) @origin.field("visits") def visits_resolver( obj: rs.origin.OriginNode, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitConnection: resolver = get_connection_resolver("origin-visits") return resolver(obj, info, **kw) @origin.field("snapshots") def origin_snapshots_resolver( obj: rs.origin.OriginNode, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.OriginSnapshotConnection: """ """ resolver = get_connection_resolver("origin-snapshots") return resolver(obj, info, **kw) @visit.field("status") def visitstatus_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.visit_status.VisitStatusConnection: resolver = get_connection_resolver("visit-status") return resolver(obj, info, **kw) @snapshot.field("branches") def snapshot_branches_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.snapshot_branch.SnapshotBranchConnection: resolver = get_connection_resolver("snapshot-branches") return resolver(obj, info, **kw) @revision.field("parents") def revision_parents_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.revision.ParentRevisionConnection: resolver = get_connection_resolver("revision-parents") return resolver(obj, info, **kw) # @revision.field("revisionLog") # def revision_log_resolver(obj, info, **kw): # resolver = get_connection_resolver("revision-log") # return resolver(obj, info, **kw) @directory.field("entries") def directory_entry_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.directory_entry.DirectoryEntryConnection: resolver = get_connection_resolver("directory-entries") return resolver(obj, info, **kw) +@query.field("resolveSwhid") +def search_swhid_resolver( + obj, info: GraphQLResolveInfo, **kw +) -> rs.search.ResolveSwhidConnection: + resolver = get_connection_resolver("resolve-swhid") + return resolver(obj, info, **kw) + + # Any other type of resolver @release_target.type_resolver @directory_entry_target.type_resolver @branch_target.type_resolver +@search_result_target.type_resolver def union_resolver(obj, *_) -> str: """ Generic resolver for all the union types """ return obj.is_type_of() @binary_string.field("text") def binary_string_text_resolver(obj, *args, **kw): return obj.decode(utils.ENCODING, "replace") @binary_string.field("base64") def binary_string_base64_resolver(obj, *args, **kw): return utils.get_b64_string(obj) diff --git a/swh/graphql/resolvers/search.py b/swh/graphql/resolvers/search.py new file mode 100644 index 0000000..ed7092b --- /dev/null +++ b/swh/graphql/resolvers/search.py @@ -0,0 +1,31 @@ +# 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.graphql.backends import archive +from swh.storage.interface import PagedResult + +from .base_connection import BaseConnection +from .base_node import BaseNode + + +class SearchResultNode(BaseNode): + """ """ + + +class ResolveSwhidConnection(BaseConnection): + + _node_class = SearchResultNode + + def _get_paged_result(self) -> PagedResult: + swhid = self.kwargs.get("swhid") + results = [] + if archive.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) diff --git a/swh/graphql/resolvers/snapshot.py b/swh/graphql/resolvers/snapshot.py index 89f75e0..3e73723 100644 --- a/swh/graphql/resolvers/snapshot.py +++ b/swh/graphql/resolvers/snapshot.py @@ -1,91 +1,97 @@ # 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 Union from swh.graphql.backends import archive from swh.graphql.utils import utils from swh.model.model import Snapshot +from swh.model.swhids import ObjectType from swh.storage.interface import PagedResult from .base_connection import BaseConnection from .base_node import BaseSWHNode from .origin import OriginNode from .visit_status import BaseVisitStatusNode class BaseSnapshotNode(BaseSWHNode): """ Base resolver for all the snapshot nodes """ def _get_snapshot_by_id(self, snapshot_id): # Return a Snapshot model object # branches is initialized as empty # Same pattern is used in directory return Snapshot(id=snapshot_id, branches={}) def is_type_of(self): # is_type_of is required only when resolving a UNION type # This is for ariadne to return the right type return "Snapshot" class SnapshotNode(BaseSnapshotNode): """ Node resolver for a snapshot requested directly with its SWHID """ def _get_node_data(self): """ """ - snapshot_id = self.kwargs.get("swhid").object_id - if archive.Archive().is_snapshot_available([snapshot_id]): - return self._get_snapshot_by_id(snapshot_id) + swhid = self.kwargs.get("swhid") + if ( + swhid.object_type == ObjectType.SNAPSHOT + and archive.Archive().is_object_available( + swhid.object_id, swhid.object_type + ) + ): + return self._get_snapshot_by_id(swhid.object_id) return None class VisitSnapshotNode(BaseSnapshotNode): """ Node resolver for a snapshot requested from a visit-status """ obj: BaseVisitStatusNode def _get_node_data(self): # self.obj.snapshotSWHID is the requested snapshot SWHID snapshot_id = self.obj.snapshotSWHID.object_id return self._get_snapshot_by_id(snapshot_id) class TargetSnapshotNode(BaseSnapshotNode): """ Node resolver for a snapshot requested as a target """ from .snapshot_branch import SnapshotBranchNode obj: Union[BaseVisitStatusNode, SnapshotBranchNode] def _get_node_data(self): snapshot_id = self.obj.target_hash return self._get_snapshot_by_id(snapshot_id) class OriginSnapshotConnection(BaseConnection): """ Connection resolver for the snapshots in an origin """ obj: OriginNode _node_class = BaseSnapshotNode def _get_paged_result(self) -> PagedResult: results = archive.Archive().get_origin_snapshots(self.obj.url) snapshots = [Snapshot(id=snapshot, branches={}) for snapshot in results] # FIXME, using dummy(local) pagination, move pagination to backend # To remove localpagination, just drop the paginated call # STORAGE-TODO return utils.paginated(snapshots, self._get_first_arg(), self._get_after_arg()) diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index e41bb6d..df5383a 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,906 +1,982 @@ """ 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 MerkleNode { """ 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 strings; different 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 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: 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 to request the next page after the item """ cursor: String! """ Visit object """ node: Visit } """ 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 """ status( """ 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: 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 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: String! """ 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 MerkleNode & 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] """ Filter by branch name """ nameInclude: 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 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 """ type: 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 } """ A revision object """ type Revision implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the revision object """ swhid: SWHID! """ Message associated to the revision """ message: BinaryString """ """ author: Person """ """ committer: Person """ Revision date ISO-8601 encoded """ date: DateTime """ 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 MerkleNode & 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 """ """ author: Person """ Release date ISO-8601 encoded """ date: DateTime """ 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 """ Possible directory entry types """ enum DirectoryEntryType { dir file rev } """ A directory entry object """ type DirectoryEntry { """ The directory entry name """ name: BinaryString """ Directory entry object type; can be file, dir or rev """ type: DirectoryEntryType """ Directory entry target object """ target: DirectoryEntryTarget } """ A directory object """ type Directory implements MerkleNode & 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 ): DirectoryEntryConnection } """ An object with different checksums """ type ContentChecksum { """ """ blake2s256: String """ """ sha1: String """ """ sha1_git: String """ """ sha256: String } """ A content object """ type Content implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the content object """ swhid: SWHID! """ Checksums for the content """ checksum: ContentChecksum """ Length of the content in bytes """ length: Int """ Content status, visible or hidden """ status: String } +""" +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 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 + """ + type: 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 the content with a SWHID """ content( """ SWHID of the content object """ swhid: SWHID! ): Content + + """ + Resolve the given SWHID to an object + """ + resolveSwhid( + """ + SWHID to look for + """ + swhid: SWHID! + ): SearchResultConnection! } diff --git a/swh/graphql/tests/data.py b/swh/graphql/tests/data.py index 0ccfa1c..8b32edc 100644 --- a/swh/graphql/tests/data.py +++ b/swh/graphql/tests/data.py @@ -1,24 +1,36 @@ # 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.model.tests import swh_model_data def populate_dummy_data(storage): for object_type, objects in swh_model_data.TEST_OBJECTS.items(): method = getattr(storage, object_type + "_add") method(objects) def get_origins(): return swh_model_data.ORIGINS def get_snapshots(): return swh_model_data.SNAPSHOTS def get_releases(): return swh_model_data.RELEASES + + +def get_revisions(): + return swh_model_data.REVISIONS + + +def get_contents(): + return swh_model_data.CONTENTS + + +def get_directories(): + return swh_model_data.DIRECTORIES diff --git a/swh/graphql/tests/functional/test_swhid_resolve.py b/swh/graphql/tests/functional/test_swhid_resolve.py new file mode 100644 index 0000000..3947ec8 --- /dev/null +++ b/swh/graphql/tests/functional/test_swhid_resolve.py @@ -0,0 +1,182 @@ +# 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_directories, get_releases, get_revisions, get_snapshots + + +def test_invalid_swhid(client): + query_str = """ + { + resolveSwhid(swhid: "swh:1:dir:dae0d245988b472abd30a4f968b919d0019b6c7") { + nodes { + type + } + } + } + """ + errors = utils.get_error_response(client, query_str) + # API will throw an error in case of an invalid SWHID + assert len(errors) == 1 + assert "Invalid SWHID: invalid syntax" 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 = """ + { + resolveSwhid(swhid: "%s") { + nodes { + type + } + } + } + """ + data, _ = utils.get_query_response(client, query_str % swhid) + # API will return an empty list in case of a valid, non existing SWHID + assert data == {"resolveSwhid": {"nodes": []}} + + +@pytest.mark.parametrize("snapshot", get_snapshots()) +def test_snapshot_swhid_resolve(client, snapshot): + query_str = """ + { + resolveSwhid(swhid: "%s") { + nodes { + type + target { + __typename + ... on Snapshot { + swhid + } + } + } + } + } + """ + data, _ = utils.get_query_response(client, query_str % snapshot.swhid()) + assert data == { + "resolveSwhid": { + "nodes": [ + { + "target": { + "__typename": "Snapshot", + "swhid": str(snapshot.swhid()), + }, + "type": "snapshot", + } + ] + } + } + + +@pytest.mark.parametrize("revision", get_revisions()) +def test_revision_swhid_resolve(client, revision): + query_str = """ + { + resolveSwhid(swhid: "%s") { + nodes { + type + target { + __typename + ... on Revision { + swhid + } + } + } + } + } + """ + data, _ = utils.get_query_response(client, query_str % revision.swhid()) + assert data == { + "resolveSwhid": { + "nodes": [ + { + "target": { + "__typename": "Revision", + "swhid": str(revision.swhid()), + }, + "type": "revision", + } + ] + } + } + + +@pytest.mark.parametrize("release", get_releases()) +def test_release_swhid_resolve(client, release): + query_str = """ + { + resolveSwhid(swhid: "%s") { + nodes { + type + target { + __typename + ... on Release { + swhid + } + } + } + } + } + """ + data, _ = utils.get_query_response(client, query_str % release.swhid()) + assert data == { + "resolveSwhid": { + "nodes": [ + { + "target": { + "__typename": "Release", + "swhid": str(release.swhid()), + }, + "type": "release", + } + ] + } + } + + +@pytest.mark.parametrize("directory", get_directories()) +def test_directory_swhid_resolve(client, directory): + query_str = """ + { + resolveSwhid(swhid: "%s") { + nodes { + type + target { + __typename + ... on Directory { + swhid + } + } + } + } + } + """ + data, _ = utils.get_query_response(client, query_str % directory.swhid()) + assert data == { + "resolveSwhid": { + "nodes": [ + { + "target": { + "__typename": "Directory", + "swhid": str(directory.swhid()), + }, + "type": "directory", + } + ] + } + } diff --git a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py index 3a3bd2c..a0dad8b 100644 --- a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py +++ b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py @@ -1,66 +1,72 @@ # 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 swh.graphql.resolvers import resolver_factory class TestFactory: @pytest.mark.parametrize( "input_type, expected", [ ("origin", "OriginNode"), ("visit", "OriginVisitNode"), ("latest-visit", "LatestVisitNode"), ("latest-status", "LatestVisitStatusNode"), ("visit-snapshot", "VisitSnapshotNode"), ("snapshot", "SnapshotNode"), ("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"), ("content", "ContentNode"), ("dir-entry-dir", "TargetDirectoryNode"), ("dir-entry-file", "TargetContentNode"), + ("search-result-snapshot", "TargetSnapshotNode"), + ("search-result-revision", "TargetRevisionNode"), + ("search-result-release", "TargetReleaseNode"), + ("search-result-directory", "TargetDirectoryNode"), + ("search-result-content", "TargetContentNode"), ], ) def test_get_node_resolver(self, input_type, expected): response = resolver_factory.get_node_resolver(input_type) assert response.__name__ == expected def test_get_node_resolver_invalid_type(self): with pytest.raises(AttributeError): resolver_factory.get_node_resolver("invalid") @pytest.mark.parametrize( "input_type, expected", [ ("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"), ], ) def test_get_connection_resolver(self, input_type, expected): response = resolver_factory.get_connection_resolver(input_type) assert response.__name__ == expected def test_get_connection_resolver_invalid_type(self): with pytest.raises(AttributeError): resolver_factory.get_connection_resolver("invalid")