diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py index 946b423..2be89f2 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,299 +1,299 @@ # 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.BaseSnapshotBranchNode, info: GraphQLResolveInfo, **kw ): """ Snapshot branch target can be a revision, release, directory, content, snapshot or a branch itself (alias type) """ 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) @query.field("contentByHash") def content_by_hash_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.content.ContentNode: resolver = get_node_resolver("content-by-hash") 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) +@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) @query.field("search") def search_resolver( obj, info: GraphQLResolveInfo, **kw ) -> rs.search.ResolveSwhidConnection: resolver = get_connection_resolver("search") 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/revision.py b/swh/graphql/resolvers/revision.py index e5cd18b..ab25cb3 100644 --- a/swh/graphql/resolvers/revision.py +++ b/swh/graphql/resolvers/revision.py @@ -1,106 +1,110 @@ # 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 Revision from swh.model.swhids import CoreSWHID, ObjectType from swh.storage.interface import PagedResult from .base_connection import BaseConnection from .base_node import BaseSWHNode from .release import BaseReleaseNode from .snapshot_branch import BaseSnapshotBranchNode class BaseRevisionNode(BaseSWHNode): """ Base resolver for all the revision nodes """ def _get_revision_by_id(self, revision_id): return (archive.Archive().get_revisions([revision_id]) or None)[0] @property def parent_swhids(self): # for ParentRevisionConnection resolver return [ CoreSWHID(object_type=ObjectType.REVISION, object_id=parent_id) for parent_id in self._node.parents ] @property def directory_hash(self): # for RevisionDirectoryNode resolver return self._node.directory @property def type(self): return self._node.type.value 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 "Revision" class RevisionNode(BaseRevisionNode): """ Node resolver for a revision requested directly with its SWHID """ def _get_node_data(self): return self._get_revision_by_id(self.kwargs.get("swhid").object_id) class TargetRevisionNode(BaseRevisionNode): """ Node resolver for a revision requested as a target """ obj: Union[BaseSnapshotBranchNode, BaseReleaseNode] def _get_node_data(self): # self.obj.target_hash is the requested revision id return self._get_revision_by_id(self.obj.target_hash) class ParentRevisionConnection(BaseConnection): """ Connection resolver for parent revisions in a revision """ obj: BaseRevisionNode _node_class = BaseRevisionNode def _get_paged_result(self) -> PagedResult: # self.obj is the current(child) revision # self.obj.parent_swhids is the list of parent SWHIDs # FIXME, using dummy(local) pagination, move pagination to backend # To remove localpagination, just drop the paginated call # STORAGE-TODO (pagination) parents = archive.Archive().get_revisions( [x.object_id for x in self.obj.parent_swhids] ) return utils.paginated(parents, self._get_first_arg(), self._get_after_arg()) class LogRevisionConnection(BaseConnection): """ Connection resolver for the log (list of revisions) in a revision """ obj: BaseRevisionNode _node_class = BaseRevisionNode def _get_paged_result(self) -> PagedResult: - # STORAGE-TODO (date in revisionlog is a dict) log = archive.Archive().get_revision_log([self.obj.swhid.object_id]) + # Storage is returning a list of dicts instead of model objects + # Following loop is to reverse that operation + # STORAGE-TODO; remove to_dict from storage.revision_log + log = [Revision.from_dict(rev) for rev in log] # FIXME, using dummy(local) pagination, move pagination to backend # To remove localpagination, just drop the paginated call # STORAGE-TODO (pagination) return utils.paginated(log, self._get_first_arg(), self._get_after_arg()) diff --git a/swh/graphql/tests/data.py b/swh/graphql/tests/data.py index aebdcc9..1216816 100644 --- a/swh/graphql/tests/data.py +++ b/swh/graphql/tests/data.py @@ -1,90 +1,113 @@ # 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.hashutil import hash_to_bytes -from swh.model.model import ObjectType, Release +from swh.model.model import ObjectType, Release, Revision, RevisionType from swh.model.tests import swh_model_data def populate_search_data(search): search.origin_update({"url": origin.url} for origin in get_origins()) 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 def get_releases_with_target(): """ GraphQL will not return a target object unless the target id is present in the DB. Return release objects with real targets instead of dummy targets in swh.model.tests.swh_model_data """ with_revision = Release( id=hash_to_bytes("9129dc4e14acd0e51ca3bcd6b80f4577d281fd25"), name=b"v0.0.1", target_type=ObjectType.REVISION, target=get_revisions()[0].id, message=b"foo", synthetic=False, ) with_release = Release( id=hash_to_bytes("6429dc4e14acd0e51ca3bcd6b80f4577d281fd32"), name=b"v0.0.1", target_type=ObjectType.RELEASE, target=get_releases()[0].id, message=b"foo", synthetic=False, ) with_directory = Release( id=hash_to_bytes("3129dc4e14acd0e51ca3bcd6b80f4577d281fd42"), name=b"v0.0.1", target_type=ObjectType.DIRECTORY, target=get_directories()[0].id, message=b"foo", synthetic=False, ) with_content = Release( id=hash_to_bytes("7589dc4e14acd0e51ca3bcd6b80f4577d281fd34"), name=b"v0.0.1", target_type=ObjectType.CONTENT, target=get_contents()[0].sha1_git, message=b"foo", synthetic=False, ) return [with_revision, with_release, with_directory, with_content] -GRAPHQL_EXTRA_TEST_OBJECTS = {"release": get_releases_with_target()} +def get_revisions_with_parents(): + """ + Revisions with real revisions as parents + """ + return [ + Revision( + id=hash_to_bytes("37580d63b8dcc0ec73e74994e66896858542844c"), + message=b"hello", + date=swh_model_data.DATES[0], + committer=swh_model_data.COMMITTERS[0], + author=swh_model_data.COMMITTERS[0], + committer_date=swh_model_data.DATES[0], + type=RevisionType.GIT, + directory=b"\x01" * 20, + synthetic=False, + parents=(get_revisions()[0].id, get_revisions()[1].id), + ) + ] + + +GRAPHQL_EXTRA_TEST_OBJECTS = { + "release": get_releases_with_target(), + "revision": get_revisions_with_parents(), +} def populate_dummy_data(storage): for object_type, objects in swh_model_data.TEST_OBJECTS.items(): method = getattr(storage, object_type + "_add") method(objects) for object_type, objects in GRAPHQL_EXTRA_TEST_OBJECTS.items(): method = getattr(storage, object_type + "_add") method(objects) diff --git a/swh/graphql/tests/functional/test_revision.py b/swh/graphql/tests/functional/test_revision.py index a0e7042..6c20c0b 100644 --- a/swh/graphql/tests/functional/test_revision.py +++ b/swh/graphql/tests/functional/test_revision.py @@ -1,112 +1,159 @@ # 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.model.swhids import CoreSWHID from . import utils from ..data import get_revisions @pytest.mark.parametrize("revision", get_revisions()) def test_get_revision(client, revision): query_str = """ { revision(swhid: "%s") { swhid message { text } author { fullname { text } name { text } email { text } } committer { fullname { text } name { text } email { text } } date type directory { swhid } } } """ data, _ = utils.get_query_response(client, query_str % revision.swhid()) assert data["revision"] == { "swhid": str(revision.swhid()), "message": {"text": revision.message.decode()}, "author": { "fullname": {"text": revision.author.fullname.decode()}, "name": {"text": revision.author.name.decode()}, "email": {"text": revision.author.email.decode()}, }, "committer": { "fullname": {"text": revision.committer.fullname.decode()}, "name": {"text": revision.committer.name.decode()}, "email": {"text": revision.committer.email.decode()}, }, "date": revision.date.to_datetime().isoformat(), "type": revision.type.value, "directory": { "swhid": str(CoreSWHID(object_id=revision.directory, object_type="dir")) }, } def test_get_revision_with_invalid_swhid(client): query_str = """ { revision(swhid: "swh:1:cnt:invalid") { swhid } } """ 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"] def test_get_revision_as_target(client): # SWHID of a snapshot with revision as target snapshot_swhid = "swh:1:snp:9e78d7105c5e0f886487511e2a92377b4ee4c32a" query_str = """ { snapshot(swhid: "%s") { branches(first: 1, types: [revision]) { nodes { type target { ...on Revision { swhid } } } } } } """ data, _ = utils.get_query_response(client, query_str % snapshot_swhid) revision_obj = data["snapshot"]["branches"]["nodes"][0]["target"] assert revision_obj == { "swhid": "swh:1:rev:66c7c1cd9673275037140f2abff7b7b11fc9439c" } + + +def test_get_revision_log(client): + revision_swhid = "swh:1:rev:37580d63b8dcc0ec73e74994e66896858542844c" + query_str = """ + { + revision(swhid: "%s") { + swhid + revisionLog(first: 3) { + nodes { + swhid + } + } + } + } + """ + data, _ = utils.get_query_response(client, query_str % revision_swhid) + assert data["revision"]["revisionLog"] == { + "nodes": [ + {"swhid": "swh:1:rev:37580d63b8dcc0ec73e74994e66896858542844c"}, + {"swhid": "swh:1:rev:66c7c1cd9673275037140f2abff7b7b11fc9439c"}, + {"swhid": "swh:1:rev:c7f96242d73c267adc77c2908e64e0c1cb6a4431"}, + ] + } + + +def test_get_revision_parents(client): + revision_swhid = "swh:1:rev:37580d63b8dcc0ec73e74994e66896858542844c" + query_str = """ + { + revision(swhid: "%s") { + swhid + parents { + nodes { + swhid + } + } + } + } + """ + data, _ = utils.get_query_response(client, query_str % revision_swhid) + assert data["revision"]["parents"] == { + "nodes": [ + {"swhid": "swh:1:rev:66c7c1cd9673275037140f2abff7b7b11fc9439c"}, + {"swhid": "swh:1:rev:c7f96242d73c267adc77c2908e64e0c1cb6a4431"}, + ] + }