diff --git a/swh/graphql/resolvers/base_node.py b/swh/graphql/resolvers/base_node.py --- a/swh/graphql/resolvers/base_node.py +++ b/swh/graphql/resolvers/base_node.py @@ -4,13 +4,13 @@ # See top-level LICENSE file for more information from collections import namedtuple -from typing import Any, Optional, Union +from typing import Any, ClassVar, Optional, Union from graphql.type import GraphQLResolveInfo from swh.graphql import resolvers as rs from swh.graphql.backends.archive import Archive -from swh.graphql.errors import ObjectNotFoundError +from swh.graphql.errors import NullableObjectError, ObjectNotFoundError class BaseNode: @@ -18,7 +18,9 @@ Base resolver for all the nodes """ - def __init__(self, obj, info, node_data: Optional[Any] = None, **kwargs): + _can_be_null: ClassVar[bool] = False + + def __init__(self, obj, info, node_data: Optional[Any] = None, **kwargs) -> None: self.obj: Optional[Union[BaseNode, rs.base_connection.BaseConnection]] = obj self.info: GraphQLResolveInfo = info self.kwargs = kwargs @@ -55,7 +57,11 @@ raise an error in case the object returned is None override for specific behaviour """ - if self._node is None: + if self._node is None and self._can_be_null: + # fail silently + raise NullableObjectError() + elif self._node is None: + # This will send this error to the client raise ObjectNotFoundError("Requested object is not available") def _get_node_data(self) -> Optional[Any]: diff --git a/swh/graphql/resolvers/content.py b/swh/graphql/resolvers/content.py --- a/swh/graphql/resolvers/content.py +++ b/swh/graphql/resolvers/content.py @@ -87,6 +87,7 @@ Node resolver for a content requested as a target """ + _can_be_null = True obj: Union[ SearchResultNode, BaseDirectoryEntryNode, diff --git a/swh/graphql/resolvers/directory.py b/swh/graphql/resolvers/directory.py --- a/swh/graphql/resolvers/directory.py +++ b/swh/graphql/resolvers/directory.py @@ -52,6 +52,7 @@ Node resolver for a directory requested from a revision """ + _can_be_null = True obj: BaseRevisionNode def _get_node_data(self): @@ -66,6 +67,7 @@ from .directory_entry import BaseDirectoryEntryNode + _can_be_null = True obj: Union[ BaseSnapshotBranchNode, BaseReleaseNode, diff --git a/swh/graphql/resolvers/release.py b/swh/graphql/resolvers/release.py --- a/swh/graphql/resolvers/release.py +++ b/swh/graphql/resolvers/release.py @@ -46,6 +46,7 @@ Node resolver for a release requested as a target """ + _can_be_null = True obj: Union[BaseSnapshotBranchNode, BaseReleaseNode, SearchResultNode] def _get_node_data(self): diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -3,6 +3,12 @@ # 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_node import BaseNode from .content import ContentNode, HashContentNode, TargetContentNode from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode from .directory_entry import DirectoryEntryConnection, DirectoryEntryNode @@ -26,9 +32,8 @@ from .visit_status import LatestVisitStatusNode, VisitStatusConnection -def get_node_resolver(resolver_type: str): - # FIXME, replace with a proper factory method - mapping = { +class NodeObjectFactory: + mapping: ClassVar[Dict[str, Type[BaseNode]]] = { "origin": OriginNode, "visit": OriginVisitNode, "latest-visit": LatestVisitNode, @@ -62,14 +67,22 @@ "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] + @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 -def get_connection_resolver(resolver_type: str): - # FIXME, replace with a proper factory method - mapping = { + +class ConnectionObjectFactory: + mapping: ClassVar[Dict[str, Type[BaseConnection]]] = { "origins": OriginConnection, "origin-visits": OriginVisitConnection, "origin-snapshots": OriginSnapshotConnection, @@ -81,6 +94,10 @@ "resolve-swhid": ResolveSwhidConnection, "search": SearchConnection, } - if resolver_type not in mapping: - raise AttributeError(f"Invalid connection type: {resolver_type}") - return mapping[resolver_type] + + @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) diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -22,10 +22,9 @@ from graphql.type import GraphQLResolveInfo from swh.graphql import resolvers as rs -from swh.graphql.errors import NullableObjectError from swh.graphql.utils import utils -from .resolver_factory import get_connection_resolver, get_node_resolver +from .resolver_factory import ConnectionObjectFactory, NodeObjectFactory query: ObjectType = ObjectType("Query") origin: ObjectType = ObjectType("Origin") @@ -46,64 +45,47 @@ search_result_target: UnionType = UnionType("SearchResultTarget") # Node resolvers -# A node resolver should return an instance of BaseNode +# 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: - """ """ - resolver = get_node_resolver("origin") - return resolver(obj, info, **kw) + return NodeObjectFactory.create("origin", obj, info, **kw) @origin.field("latestVisit") def latest_visit_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw -) -> rs.visit.LatestVisitNode: - """ """ - resolver = get_node_resolver("latest-visit") - return resolver(obj, info, **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: - """ """ - resolver = get_node_resolver("visit") - return resolver(obj, info, **kw) + 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]: - """ """ - resolver = get_node_resolver("latest-status") - try: - return resolver(obj, info, **kw) - except NullableObjectError: - # FIXME, make this pattern generic for all the resolvers - return None + return NodeObjectFactory.create("latest-status", 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) + 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]: - if obj.snapshotSWHID is None: - return None - resolver = get_node_resolver("visit-snapshot") - return resolver(obj, info, **kw) + return NodeObjectFactory.create("visit-snapshot", obj, info, **kw) @snapshot_branch.field("target") @@ -121,33 +103,28 @@ 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) + return NodeObjectFactory.create(f"branch-{obj.targetType}", 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) + return NodeObjectFactory.create("revision", obj, info, **kw) @revision.field("directory") def revision_directory_resolver( obj: rs.revision.BaseRevisionNode, info: GraphQLResolveInfo, **kw -) -> rs.directory.RevisionDirectoryNode: - resolver = get_node_resolver("revision-directory") - return resolver(obj, info, **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: - resolver = get_node_resolver("release") - return resolver(obj, info, **kw) + return NodeObjectFactory.create("release", obj, info, **kw) @release.field("target") @@ -160,30 +137,23 @@ rs.content.BaseContentNode, ]: """ - release target can be a release, revision, - directory or content - obj is release here, target type is - obj.target_type + Release target can be a release, revision, directory or a content """ - resolver_type = f"release-{obj.target_type.value}" - resolver = get_node_resolver(resolver_type) - return resolver(obj, info, **kw) + return NodeObjectFactory.create(f"release-{obj.targetType}", 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) + return NodeObjectFactory.create("directory", obj, info, **kw) @query.field("directoryEntry") def directory_entry_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.directory_entry.DirectoryEntryNode: - resolver = get_node_resolver("directory-entry") - return resolver(obj, info, **kw) + return NodeObjectFactory.create("directory-entry", obj, info, **kw) @directory_entry.field("target") @@ -195,19 +165,16 @@ rs.content.BaseContentNode, ]: """ - directory entry target can be a directory, content or a revision + DirectoryEntry target can be a directory, content or a revision """ - resolver_type = f"dir-entry-{obj.targetType}" - resolver = get_node_resolver(resolver_type) - return resolver(obj, info, **kw) + 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: - resolver = get_node_resolver("content") - return resolver(obj, info, **kw) + return NodeObjectFactory.create("content", obj, info, **kw) @search_result.field("target") @@ -221,17 +188,18 @@ rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: - resolver_type = f"search-result-{obj.type}" - resolver = get_node_resolver(resolver_type) - return resolver(obj, info, **kw) + """ + 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("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) + return NodeObjectFactory.create("content-by-hash", obj, info, **kw) # Connection resolvers @@ -242,84 +210,73 @@ def origins_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.origin.OriginConnection: - resolver = get_connection_resolver("origins") - return resolver(obj, info, **kw) + return ConnectionObjectFactory.create("origins", obj, info, **kw) @origin.field("visits") def visits_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitConnection: - resolver = get_connection_resolver("origin-visits") - return resolver(obj, info, **kw) + 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: - """ """ - resolver = get_connection_resolver("origin-snapshots") - return resolver(obj, info, **kw) + 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: - resolver = get_connection_resolver("visit-status") - return resolver(obj, info, **kw) + 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: - resolver = get_connection_resolver("snapshot-branches") - return resolver(obj, info, **kw) + 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: - resolver = get_connection_resolver("revision-parents") - return resolver(obj, info, **kw) + 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: - resolver = get_connection_resolver("revision-log") - return resolver(obj, info, **kw) + 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: - resolver = get_connection_resolver("directory-entries") - return resolver(obj, info, **kw) + return ConnectionObjectFactory.create("directory-entries", obj, info, **kw) @query.field("resolveSwhid") def search_swhid_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.search.ResolveSwhidConnection: - resolver = get_connection_resolver("resolve-swhid") - return resolver(obj, info, **kw) + return ConnectionObjectFactory.create("resolve-swhid", obj, info, **kw) @query.field("search") def search_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.search.SearchConnection: - resolver = get_connection_resolver("search") - return resolver(obj, info, **kw) + return ConnectionObjectFactory.create("search", obj, info, **kw) -# Any other type of resolver +# Other resolvers @release_target.type_resolver @@ -344,6 +301,9 @@ 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") diff --git a/swh/graphql/resolvers/revision.py b/swh/graphql/resolvers/revision.py --- a/swh/graphql/resolvers/revision.py +++ b/swh/graphql/resolvers/revision.py @@ -61,6 +61,7 @@ Node resolver for a revision requested as a target """ + _can_be_null = True obj: Union[ BaseSnapshotBranchNode, BaseReleaseNode, diff --git a/swh/graphql/resolvers/snapshot.py b/swh/graphql/resolvers/snapshot.py --- a/swh/graphql/resolvers/snapshot.py +++ b/swh/graphql/resolvers/snapshot.py @@ -5,6 +5,7 @@ from typing import Union +from swh.graphql.errors import NullableObjectError from swh.graphql.utils import utils from swh.model.model import Snapshot from swh.model.swhids import ObjectType @@ -55,9 +56,12 @@ Node resolver for a snapshot requested from a visit-status """ + _can_be_null = True obj: BaseVisitStatusNode def _get_node_data(self): + if self.obj.snapshotSWHID is None: + raise NullableObjectError() snapshot_id = self.obj.snapshotSWHID.object_id return self._get_snapshot_by_id(snapshot_id) @@ -69,6 +73,7 @@ from .snapshot_branch import BaseSnapshotBranchNode + _can_be_null = True obj: Union[SearchResultNode, BaseSnapshotBranchNode] def _get_node_data(self): diff --git a/swh/graphql/resolvers/visit.py b/swh/graphql/resolvers/visit.py --- a/swh/graphql/resolvers/visit.py +++ b/swh/graphql/resolvers/visit.py @@ -43,6 +43,7 @@ Node resolver for the latest visit in an origin """ + _can_be_null = True obj: OriginNode def _get_node_data(self): diff --git a/swh/graphql/resolvers/visit_status.py b/swh/graphql/resolvers/visit_status.py --- a/swh/graphql/resolvers/visit_status.py +++ b/swh/graphql/resolvers/visit_status.py @@ -3,7 +3,6 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information -from swh.graphql.errors import NullableObjectError from swh.model.swhids import CoreSWHID, ObjectType from swh.storage.interface import PagedResult @@ -29,6 +28,7 @@ Node resolver for a visit-status requested from a visit """ + _can_be_null = True obj: BaseVisitNode def _get_node_data(self): @@ -40,11 +40,6 @@ require_snapshot=self.kwargs.get("requireSnapshot"), ) - def _handle_node_errors(self) -> None: - # This object can be null - if self._node is None: - raise NullableObjectError("") - class VisitStatusConnection(BaseConnection): """ diff --git a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py --- a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py +++ b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py @@ -9,65 +9,10 @@ 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-directory", "TargetDirectoryNode"), - ("dir-entry-content", "TargetContentNode"), - ("dir-entry-revision", "TargetRevisionNode"), - ("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 + resolver_factory.NodeObjectFactory().create("invalid", None, None) def test_get_connection_resolver_invalid_type(self): with pytest.raises(AttributeError): - resolver_factory.get_connection_resolver("invalid") + resolver_factory.get_connection_resolver("invalid", None, None) diff --git a/swh/graphql/tests/unit/resolvers/test_resolvers.py b/swh/graphql/tests/unit/resolvers/test_resolvers.py --- a/swh/graphql/tests/unit/resolvers/test_resolvers.py +++ b/swh/graphql/tests/unit/resolvers/test_resolvers.py @@ -53,7 +53,7 @@ resolvers.snapshot_branch.SnapshotBranchConnection, ), (rs.revision_parents_resolver, resolvers.revision.ParentRevisionConnection), - # (rs.revision_log_resolver, resolvers.revision.LogRevisionConnection), + (rs.revision_log_resolver, resolvers.revision.LogRevisionConnection), ( rs.directory_entries_resolver, resolvers.directory_entry.DirectoryEntryConnection, @@ -78,7 +78,7 @@ def test_snapshot_branch_target_resolver( self, mocker, dummy_node, branch_type, node_cls ): - obj = mocker.Mock(type=branch_type) + obj = mocker.Mock(targetType=branch_type) mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node) node_obj = rs.snapshot_branch_target_resolver(obj, None) assert isinstance(node_obj, node_cls) @@ -94,7 +94,7 @@ ], ) def test_release_target_resolver(self, mocker, dummy_node, target_type, node_cls): - obj = mocker.Mock(target_type=(mocker.Mock(value=target_type))) + obj = mocker.Mock(targetType=target_type) mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node) node_obj = rs.release_target_resolver(obj, None) assert isinstance(node_obj, node_cls)