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 @@ -10,7 +10,7 @@ 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,6 +18,8 @@ Base resolver for all the nodes """ + _can_be_null: bool = False + def __init__(self, obj, info, node_data: Optional[Any] = None, **kwargs): self.obj: Optional[Union[BaseNode, rs.base_connection.BaseConnection]] = obj self.info: GraphQLResolveInfo = info @@ -55,7 +57,9 @@ 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: + raise NullableObjectError() + elif self._node is None: 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,11 @@ # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information +from typing import TypeVar + +from swh.graphql.errors import NullableObjectError + +from .base_node import BaseNode from .content import ContentNode, HashContentNode, TargetContentNode from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode from .directory_entry import DirectoryEntryConnection, DirectoryEntryNode @@ -25,46 +30,57 @@ from .visit import LatestVisitNode, OriginVisitConnection, OriginVisitNode from .visit_status import LatestVisitStatusNode, VisitStatusConnection +BaseNodeType = TypeVar("BaseNodeType", bound=BaseNode) -def get_node_resolver(resolver_type: str): - # FIXME, replace with a proper factory method - mapping = { - "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, - } - if resolver_type not in mapping: - raise AttributeError(f"Invalid node type: {resolver_type}") - return mapping[resolver_type] + +class NodeObjectFactory: + def __init__(self): + self.mapping = { + "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, + } + + def create(self, node_type: str, *args, **kw) -> BaseNodeType: + resolver = self.mapping.get(node_type) + if not resolver: + raise AttributeError(f"Invalid node type: {node_type}") + try: + node_obj = resolver(*args, **kw) + except NullableObjectError: + # This exception will not create 'missing field' + # errors for object attributes + node_obj = None + return node_obj def get_connection_resolver(resolver_type: str): 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 NodeObjectFactory, get_connection_resolver query: ObjectType = ObjectType("Query") origin: ObjectType = ObjectType("Origin") @@ -48,21 +47,21 @@ # Node resolvers # A node resolver should return an instance of BaseNode +node_object_factory = NodeObjectFactory() + @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 node_object_factory.create("origin", obj, info, **kw) @origin.field("latestVisit") def latest_visit_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw -) -> rs.visit.LatestVisitNode: +) -> Optional[rs.visit.LatestVisitNode]: """ """ - resolver = get_node_resolver("latest-visit") - return resolver(obj, info, **kw) + return node_object_factory.create("latest-visit", obj, info, **kw) @query.field("visit") @@ -70,8 +69,7 @@ obj: None, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitNode: """ """ - resolver = get_node_resolver("visit") - return resolver(obj, info, **kw) + return node_object_factory.create("visit", obj, info, **kw) @visit.field("latestStatus") @@ -79,12 +77,7 @@ 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 node_object_factory.create("latest-status", obj, info, **kw) @query.field("snapshot") @@ -92,18 +85,14 @@ obj: None, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.SnapshotNode: """ """ - resolver = get_node_resolver("snapshot") - return resolver(obj, info, **kw) + return node_object_factory.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 node_object_factory.create("visit-snapshot", obj, info, **kw) @snapshot_branch.field("target") @@ -121,33 +110,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 node_object_factory.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 node_object_factory.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 node_object_factory.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 node_object_factory.create("release", obj, info, **kw) @release.field("target") @@ -165,25 +149,21 @@ 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) + return node_object_factory.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 node_object_factory.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 node_object_factory.create("directory-entry", obj, info, **kw) @directory_entry.field("target") @@ -197,17 +177,14 @@ """ directory entry 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 node_object_factory.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 node_object_factory.create("content", obj, info, **kw) @search_result.field("target") @@ -221,17 +198,16 @@ rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: - resolver_type = f"search-result-{obj.type}" - resolver = get_node_resolver(resolver_type) - return resolver(obj, info, **kw) + return node_object_factory.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 node_object_factory.create("content-by-hash", obj, info, **kw) # Connection resolvers 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,46 +9,9 @@ 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") + resolver_factory.NodeObjectFactory().create("invalid", None, None) @pytest.mark.parametrize( "input_type, expected", 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 @@ -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)