diff --git a/swh/graphql/errors/__init__.py b/swh/graphql/errors/__init__.py index 4f8f674..b468498 100644 --- a/swh/graphql/errors/__init__.py +++ b/swh/graphql/errors/__init__.py @@ -1,20 +1,22 @@ # 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 .errors import ( + DataError, InvalidInputError, NullableObjectError, ObjectNotFoundError, PaginationError, ) from .handlers import format_error __all__ = [ "ObjectNotFoundError", "PaginationError", "InvalidInputError", "NullableObjectError", + "DataError", "format_error", ] diff --git a/swh/graphql/errors/errors.py b/swh/graphql/errors/errors.py index 51289b8..6d34242 100644 --- a/swh/graphql/errors/errors.py +++ b/swh/graphql/errors/errors.py @@ -1,35 +1,39 @@ # 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 class ObjectNotFoundError(Exception): """ """ msg: str = "Object error" def __init__(self, message, errors=None): super().__init__(f"{self.msg}: {message}") class PaginationError(Exception): """ """ msg: str = "Pagination error" def __init__(self, message, errors=None): super().__init__(f"{self.msg}: {message}") class InvalidInputError(Exception): """ """ msg: str = "Input error" def __init__(self, message, errors=None): super().__init__(f"{self.msg}: {message}") class NullableObjectError(Exception): pass + + +class DataError(Exception): + pass diff --git a/swh/graphql/resolvers/content.py b/swh/graphql/resolvers/content.py index ff1cacc..296ca20 100644 --- a/swh/graphql/resolvers/content.py +++ b/swh/graphql/resolvers/content.py @@ -1,111 +1,137 @@ # 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 typing import Dict, List, Optional, Union -from swh.graphql.errors import InvalidInputError +from swh.graphql.errors import DataError, InvalidInputError from swh.model import hashutil +from swh.model.model import Content +from .base_connection import BaseList from .base_node import BaseSWHNode from .directory_entry import BaseDirectoryEntryNode from .release import BaseReleaseNode from .search import SearchResultNode from .snapshot_branch import BaseSnapshotBranchNode +def read_and_validate_content_hashes(hashes) -> Dict[str, bytes]: + try: + return { + hash_type: hashutil.hash_to_bytes(hash_value) + for (hash_type, hash_value) in hashes + } + except ValueError as e: + # raise an input error in case of an invalid hash + raise InvalidInputError("Invalid content hash", e) + + class BaseContentNode(BaseSWHNode): """ Base resolver for all the content nodes """ - def _get_content_by_hashes(self, hashes: dict): - content = self.archive.get_contents(hashes) - # in case of a conflict, return the first element - return content[0] if content else None - @property def hashes(self): # FIXME, use a Node instead return {k: v.hex() for (k, v) in self._node.hashes().items()} @property def id(self): return self._node.sha1_git @property def data(self): # FIXME, return a Node object # FIXME, add more ways to retrieve data like binary string archive_url = "https://archive.softwareheritage.org/api/1/" content_sha1 = self._node.hashes()["sha1"] return { "url": f"{archive_url}content/sha1:{content_sha1.hex()}/raw/", } @property def mimeType(self): # FIXME, fetch data from the indexers return None @property def language(self): # FIXME, fetch data from the indexers return None @property def license(self): # FIXME, fetch data from the indexers return None 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 "Content" -class ContentNode(BaseContentNode): - """ - Node resolver for a content requested directly with its SWHID - """ - - def _get_node_data(self): - hashes = {"sha1_git": self.kwargs.get("swhid").object_id} - return self._get_content_by_hashes(hashes) - - -class HashContentNode(BaseContentNode): +class ContentbyHashesNode(BaseContentNode): """ - Node resolver for a content requested with one or more hashes + Node resolver for a content requested with all of its hashes + A single content object will be returned """ - def _get_node_data(self): - try: - hashes = { - hash_type: hashutil.hash_to_bytes(hash_value) - for (hash_type, hash_value) in self.kwargs.items() - } - except ValueError as e: - # raise an input error in case of an invalid hash - raise InvalidInputError("Invalid content hash", e) - if not hashes: - raise InvalidInputError("At least one of the four hashes must be provided") - return self._get_content_by_hashes(hashes) + def _get_node_data(self) -> Optional[Content]: + hashes = read_and_validate_content_hashes(self.kwargs.items()) + contents = self.archive.get_contents(hashes=hashes) + if len(contents) > 1: + # This situation is not expected to happen IRL + raise DataError("Content hash conflict for the set ", hashes) + return contents[0] if contents else None class TargetContentNode(BaseContentNode): """ Node resolver for a content requested as a target """ _can_be_null = True obj: Union[ SearchResultNode, BaseDirectoryEntryNode, BaseReleaseNode, BaseSnapshotBranchNode, ] - def _get_node_data(self): - return self._get_content_by_hashes(hashes={"sha1_git": self.obj.target_hash}) + def _get_node_data(self) -> Optional[Content]: + # FIXME, this is not considering hash collisions + # and could return a wrong object in very rare situations + contents = self.archive.get_contents(hashes={"sha1_git": self.obj.target_hash}) + # always returning the first content from the storage + return contents[0] if contents else None + + +class ContentSwhidList(BaseList): + """ + Return a non paginated list of contents for the given SWHID + This will return a single item in most of the cases + """ + + _node_class = BaseContentNode + + def _get_results(self) -> List[Content]: + hashes = {"sha1_git": self.kwargs.get("swhid").object_id} + return self.archive.get_contents(hashes=hashes) + + +class ContentHashList(BaseList): + """ + Return a non paginated list of contents for the given hashes + This will return a single item in most of the cases + """ + + _node_class = BaseContentNode + + def _get_results(self) -> List[Content]: + hashes = read_and_validate_content_hashes(self.kwargs.items()) + if not hashes: + raise InvalidInputError("At least one of the four hashes must be provided") + return self.archive.get_contents(hashes=hashes) diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py index 1b2efb6..e2a0390 100644 --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -1,121 +1,127 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from typing import ClassVar, Dict, Type from swh.graphql.errors import NullableObjectError from .base_connection import BaseConnection, BaseList from .base_node import BaseNode -from .content import ContentNode, HashContentNode, TargetContentNode +from .content import ( + ContentbyHashesNode, + ContentHashList, + ContentSwhidList, + TargetContentNode, +) from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode from .directory_entry import DirectoryEntryConnection, DirectoryEntryNode from .origin import OriginConnection, OriginNode, TargetOriginNode from .person import ReleaseAuthorList, RevisionAuthorList, RevisionCommitterList from .release import ReleaseNode, TargetReleaseNode from .revision import ( LogRevisionConnection, ParentRevisionConnection, RevisionNode, TargetRevisionNode, ) from .search import ResolveSwhidList, SearchConnection from .snapshot import ( OriginSnapshotConnection, SnapshotNode, TargetSnapshotNode, VisitSnapshotNode, ) from .snapshot_branch import AliasSnapshotBranchNode, SnapshotBranchConnection from .visit import LatestVisitNode, OriginVisitConnection, OriginVisitNode from .visit_status import LatestVisitStatusNode, VisitStatusConnection class NodeObjectFactory: mapping: ClassVar[Dict[str, Type[BaseNode]]] = { "origin": OriginNode, "visit": OriginVisitNode, "latest-visit": LatestVisitNode, "latest-status": LatestVisitStatusNode, "visit-snapshot": VisitSnapshotNode, "snapshot": SnapshotNode, "branch-alias": AliasSnapshotBranchNode, "branch-revision": TargetRevisionNode, "branch-release": TargetReleaseNode, "branch-directory": TargetDirectoryNode, "branch-content": TargetContentNode, "branch-snapshot": TargetSnapshotNode, "revision": RevisionNode, "revision-directory": RevisionDirectoryNode, "release": ReleaseNode, "release-revision": TargetRevisionNode, "release-release": TargetReleaseNode, "release-directory": TargetDirectoryNode, "release-content": TargetContentNode, "directory": DirectoryNode, "directory-entry": DirectoryEntryNode, - "content": ContentNode, - "content-by-hash": HashContentNode, + "content-by-hashes": ContentbyHashesNode, "dir-entry-content": TargetContentNode, "dir-entry-directory": TargetDirectoryNode, "dir-entry-revision": TargetRevisionNode, "search-result-origin": TargetOriginNode, "search-result-snapshot": TargetSnapshotNode, "search-result-revision": TargetRevisionNode, "search-result-release": TargetReleaseNode, "search-result-directory": TargetDirectoryNode, "search-result-content": TargetContentNode, } @classmethod def create(cls, node_type: str, obj, info, *args, **kw): resolver = cls.mapping.get(node_type) if not resolver: raise AttributeError(f"Invalid node type: {node_type}") try: node_obj = resolver(obj, info, *args, **kw) except NullableObjectError: # Return None instead of the object node_obj = None return node_obj class ConnectionObjectFactory: mapping: ClassVar[Dict[str, Type[BaseConnection]]] = { "origins": OriginConnection, "origin-visits": OriginVisitConnection, "origin-snapshots": OriginSnapshotConnection, "visit-status": VisitStatusConnection, "snapshot-branches": SnapshotBranchConnection, "revision-parents": ParentRevisionConnection, "revision-log": LogRevisionConnection, "directory-entries": DirectoryEntryConnection, "search": SearchConnection, } @classmethod def create(cls, connection_type: str, obj, info, *args, **kw): resolver = cls.mapping.get(connection_type) if not resolver: raise AttributeError(f"Invalid connection type: {connection_type}") return resolver(obj, info, *args, **kw) class SimpleListFactory: mapping: ClassVar[Dict[str, Type[BaseList]]] = { "resolve-swhid": ResolveSwhidList, "revision-author": RevisionAuthorList, "revision-committer": RevisionCommitterList, "release-author": ReleaseAuthorList, + "contents-swhid": ContentSwhidList, + "contents-hashes": ContentHashList, } @classmethod def create(cls, list_type: str, obj, info, *args, **kw): resolver = cls.mapping.get(list_type) if not resolver: raise AttributeError(f"Invalid list type: {list_type}") # invoke the get_results method to return the list return resolver(obj, info, *args, **kw).get_results() diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py index 12b3c4a..068fef7 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,364 +1,371 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information """ High level resolvers """ # Any schema attribute can be resolved by any of the following ways # and in the following priority order # - In this module using a decorator (eg: @visit_status.field("snapshot")) # Every object (type) is expected to resolve this way as they can accept arguments # eg: origin.visits takes arguments to paginate # - As a property in the Node object (eg: resolvers.visit.BaseVisitNode.id) # Every scalar is expected to resolve this way # - As an attribute/item in the object/dict returned by a backend (eg: Origin.url) import datetime from typing import Optional, Union from ariadne import ObjectType, UnionType from graphql.type import GraphQLResolveInfo from swh.graphql import resolvers as rs from swh.graphql.utils import utils from swh.model.model import TimestampWithTimezone from .resolver_factory import ( ConnectionObjectFactory, NodeObjectFactory, SimpleListFactory, ) query: ObjectType = ObjectType("Query") origin: ObjectType = ObjectType("Origin") visit: ObjectType = ObjectType("Visit") visit_status: ObjectType = ObjectType("VisitStatus") snapshot: ObjectType = ObjectType("Snapshot") snapshot_branch: ObjectType = ObjectType("Branch") revision: ObjectType = ObjectType("Revision") release: ObjectType = ObjectType("Release") directory: ObjectType = ObjectType("Directory") directory_entry: ObjectType = ObjectType("DirectoryEntry") search_result: ObjectType = ObjectType("SearchResult") binary_string: ObjectType = ObjectType("BinaryString") date: ObjectType = ObjectType("Date") branch_target: UnionType = UnionType("BranchTarget") release_target: UnionType = UnionType("ReleaseTarget") directory_entry_target: UnionType = UnionType("DirectoryEntryTarget") search_result_target: UnionType = UnionType("SearchResultTarget") # Node resolvers # A node resolver will return either an instance of a BaseNode subclass or None @query.field("origin") def origin_resolver(obj: None, info: GraphQLResolveInfo, **kw) -> rs.origin.OriginNode: return NodeObjectFactory.create("origin", obj, info, **kw) @origin.field("latestVisit") def latest_visit_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.visit.LatestVisitNode]: return NodeObjectFactory.create("latest-visit", obj, info, **kw) @query.field("visit") def visit_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitNode: return NodeObjectFactory.create("visit", obj, info, **kw) @visit.field("latestStatus") def latest_visit_status_resolver( obj: rs.visit.BaseVisitNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.visit_status.LatestVisitStatusNode]: return NodeObjectFactory.create("latest-status", obj, info, **kw) @query.field("snapshot") def snapshot_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.SnapshotNode: return NodeObjectFactory.create("snapshot", obj, info, **kw) @visit_status.field("snapshot") def visit_snapshot_resolver( obj: rs.visit_status.BaseVisitStatusNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.snapshot.VisitSnapshotNode]: return NodeObjectFactory.create("visit-snapshot", obj, info, **kw) @snapshot_branch.field("target") def snapshot_branch_target_resolver( obj: rs.snapshot_branch.BaseSnapshotBranchNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, rs.snapshot.BaseSnapshotNode, rs.snapshot_branch.BaseSnapshotBranchNode, ]: """ Snapshot branch target can be a revision, release, directory, content, snapshot or a branch itself (alias type) """ return NodeObjectFactory.create(f"branch-{obj.targetType}", obj, info, **kw) @query.field("revision") def revision_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.revision.RevisionNode: return NodeObjectFactory.create("revision", obj, info, **kw) @revision.field("directory") def revision_directory_resolver( obj: rs.revision.BaseRevisionNode, info: GraphQLResolveInfo, **kw ) -> Optional[rs.directory.RevisionDirectoryNode]: return NodeObjectFactory.create("revision-directory", obj, info, **kw) @query.field("release") def release_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.release.ReleaseNode: return NodeObjectFactory.create("release", obj, info, **kw) @release.field("target") def release_target_resolver( obj: rs.release.BaseReleaseNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: """ Release target can be a release, revision, directory or a content """ return NodeObjectFactory.create(f"release-{obj.targetType}", obj, info, **kw) @query.field("directory") def directory_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.directory.DirectoryNode: return NodeObjectFactory.create("directory", obj, info, **kw) @query.field("directoryEntry") def directory_entry_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.directory_entry.DirectoryEntryNode: return NodeObjectFactory.create("directory-entry", obj, info, **kw) @directory_entry.field("target") def directory_entry_target_resolver( obj: rs.directory_entry.BaseDirectoryEntryNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.revision.BaseRevisionNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: """ DirectoryEntry target can be a directory, content or a revision """ return NodeObjectFactory.create(f"dir-entry-{obj.targetType}", obj, info, **kw) -@query.field("content") -def content_resolver( - obj: None, info: GraphQLResolveInfo, **kw -) -> rs.content.ContentNode: - return NodeObjectFactory.create("content", obj, info, **kw) - - @search_result.field("target") def search_result_target_resolver( obj: rs.search.SearchResultNode, info: GraphQLResolveInfo, **kw ) -> Union[ rs.origin.BaseOriginNode, rs.snapshot.BaseSnapshotNode, rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, ]: """ SearchResult target can be an origin, snapshot, revision, release directory or a content """ return NodeObjectFactory.create(f"search-result-{obj.targetType}", obj, info, **kw) @query.field("contentByHashes") -def content_by_hash_resolver( +def content_by_hashes_resolver( obj: None, info: GraphQLResolveInfo, **kw -) -> rs.content.ContentNode: - return NodeObjectFactory.create("content-by-hash", obj, info, **kw) +) -> rs.content.ContentbyHashesNode: + return NodeObjectFactory.create("content-by-hashes", obj, info, **kw) # Connection resolvers # A connection resolver should return an instance of BaseConnection @query.field("origins") def origins_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.origin.OriginConnection: return ConnectionObjectFactory.create("origins", obj, info, **kw) @origin.field("visits") def visits_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw ) -> rs.visit.OriginVisitConnection: return ConnectionObjectFactory.create("origin-visits", obj, info, **kw) @origin.field("snapshots") def origin_snapshots_resolver( obj: rs.origin.BaseOriginNode, info: GraphQLResolveInfo, **kw ) -> rs.snapshot.OriginSnapshotConnection: return ConnectionObjectFactory.create("origin-snapshots", obj, info, **kw) @visit.field("statuses") def visitstatus_resolver( obj: rs.visit.BaseVisitNode, info: GraphQLResolveInfo, **kw ) -> rs.visit_status.VisitStatusConnection: return ConnectionObjectFactory.create("visit-status", obj, info, **kw) @snapshot.field("branches") def snapshot_branches_resolver( obj: rs.snapshot.BaseSnapshotNode, info: GraphQLResolveInfo, **kw ) -> rs.snapshot_branch.SnapshotBranchConnection: return ConnectionObjectFactory.create("snapshot-branches", obj, info, **kw) @revision.field("parents") def revision_parents_resolver( obj: rs.revision.BaseRevisionNode, info: GraphQLResolveInfo, **kw ) -> rs.revision.ParentRevisionConnection: return ConnectionObjectFactory.create("revision-parents", obj, info, **kw) @revision.field("revisionLog") def revision_log_resolver( obj: rs.revision.BaseRevisionNode, info: GraphQLResolveInfo, **kw ) -> rs.revision.LogRevisionConnection: return ConnectionObjectFactory.create("revision-log", obj, info, **kw) @directory.field("entries") def directory_entries_resolver( obj: rs.directory.BaseDirectoryNode, info: GraphQLResolveInfo, **kw ) -> rs.directory_entry.DirectoryEntryConnection: return ConnectionObjectFactory.create("directory-entries", obj, info, **kw) @query.field("search") def search_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.search.SearchConnection: return ConnectionObjectFactory.create("search", obj, info, **kw) # Simple list resolvers (lists without paginations) +@query.field("contentsBySWHID") +def contnets_by_swhid_resolver( + obj: None, info: GraphQLResolveInfo, **kw +) -> rs.content.ContentSwhidList: + return SimpleListFactory.create("contents-swhid", obj, info, **kw) + + +@query.field("contentsByHashes") +def contnets_by_hashes_resolver( + obj: None, info: GraphQLResolveInfo, **kw +) -> rs.content.ContentHashList: + return SimpleListFactory.create("contents-hashes", obj, info, **kw) + + @query.field("resolveSWHID") def search_swhid_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.search.ResolveSwhidList: return SimpleListFactory.create("resolve-swhid", obj, info, **kw) @revision.field("author") def revision_author_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.revision.RevisionNode: return SimpleListFactory.create("revision-author", obj, info, **kw) @revision.field("committer") def revision_committer_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.revision.RevisionNode: return SimpleListFactory.create("revision-committer", obj, info, **kw) @release.field("author") def release_author_resolver( obj: rs.release.BaseReleaseNode, info: GraphQLResolveInfo, **kw ) -> rs.revision.RevisionNode: return SimpleListFactory.create("release-author", obj, info, **kw) # Other resolvers @release_target.type_resolver @directory_entry_target.type_resolver @branch_target.type_resolver @search_result_target.type_resolver def union_resolver( obj: Union[ rs.origin.BaseOriginNode, rs.revision.BaseRevisionNode, rs.release.BaseReleaseNode, rs.directory.BaseDirectoryNode, rs.content.BaseContentNode, rs.snapshot.BaseSnapshotNode, rs.snapshot_branch.BaseSnapshotBranchNode, ], *_, ) -> str: """ Generic resolver for all the union types """ return obj.is_type_of() # BinaryString resolvers @binary_string.field("text") def binary_string_text_resolver(obj: bytes, *args, **kw) -> str: return obj.decode(utils.ENCODING, "replace") @binary_string.field("base64") def binary_string_base64_resolver(obj: bytes, *args, **kw) -> str: return utils.get_b64_string(obj) # Date object resolver @date.field("date") def date_date_resolver( obj: TimestampWithTimezone, *args: GraphQLResolveInfo, **kw ) -> datetime.datetime: # This will be serialised as a DateTime Scalar return obj.to_datetime() @date.field("offset") def date_offset_resolver( obj: TimestampWithTimezone, *args: GraphQLResolveInfo, **kw ) -> bytes: # This will be serialised as a Binary string return obj.offset_bytes diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index 892a2d4..fae3c40 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1164 +1,1176 @@ """ SoftWare Heritage persistent Identifier """ scalar SWHID """ ISO-8601 encoded date string """ scalar DateTime """ Object with an id """ interface Node { """ Id of the object. This is for caching purpose and should not be used outside the GraphQL API """ id: ID! } """ SWH merkle node object with a SWHID """ interface SWHNode { """ SWHID of the object """ swhid: SWHID! } """ Information about pagination """ type PageInfo { """ Cursor to request the next page in the connection """ endCursor: String """ Are there more pages in the connection? """ hasNextPage: Boolean! } """ Binary string with multiple encodings """ type BinaryString { """ Utf-8 encoded value, any non Unicode character will be replaced """ text: String! """ base64 encoded value """ base64: String! } """ Connection to origins """ type OriginConnection { """ List of origin edges """ edges: [OriginEdge] """ List of origin objects """ nodes: [Origin] """ Information for pagination """ pageInfo: PageInfo! """ Total number of origin objects in the connection """ totalCount: Int } """ Edge in origin connection """ type OriginEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Origin object """ node: Origin } """ A software origin object """ type Origin implements Node { """ Unique identifier """ id: ID! """ Origin URL """ url: String! """ Connection to all the visit objects for the origin """ visits( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): VisitConnection """ Latest visit object for the origin """ latestVisit( """ Return the latest visit with the given visit type """ visitType: String """ Return the latest visit with any of the given statuses """ allowedStatuses: [VisitStatusState] """ If True, the latest visit with a snapshot will be returned """ requireSnapshot: Boolean ): Visit """ Connection to all the snapshots for the origin """ snapshots( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): SnapshotConnection } """ Connection to origin visits """ type VisitConnection { """ List of visit edges """ edges: [VisitEdge] """ List of visit objects """ nodes: [Visit] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit objects in the connection """ totalCount: Int } """ Edge in origin visit connection """ type VisitEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Visit object """ node: Visit } """ Possible visit status states """ enum VisitStatusState { created ongoing partial full not_found failed } """ An origin visit object """ type Visit implements Node { """ Unique identifier """ id: ID! """ Visit number for the origin """ visitId: Int """ Visit date ISO-8601 encoded """ date: DateTime! """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String """ Connection to all the status objects for the visit """ statuses( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): VisitStatusConnection """ Latest status object for the Visit """ latestStatus( """ Filter by status state """ allowedStatuses: [VisitStatusState] """ Filter by the availability of a snapshot in the status """ requireSnapshot: Boolean ): VisitStatus } """ Connection to visit status """ type VisitStatusConnection { """ List of visit status edges """ edges: [VisitStatusEdge] """ List of visit status objects """ nodes: [VisitStatus] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit status objects in the connection """ totalCount: Int } """ Edge in visit status connection """ type VisitStatusEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Visit status object """ node: VisitStatus } """ A visit status object """ type VisitStatus { """ Status string of the visit (either full, partial or ongoing) """ status: VisitStatusState! """ ISO-8601 encoded date string """ date: DateTime! """ Snapshot object """ snapshot: Snapshot """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String } """ Connection to snapshots """ type SnapshotConnection { """ List of snapshot edges """ edges: [SnapshotEdge] """ List of snapshot objects """ nodes: [Snapshot] """ Information for pagination """ pageInfo: PageInfo! """ Total number of snapshot objects in the connection """ totalCount: Int } """ Edge in snapshot connection """ type SnapshotEdge { """ Cursor to request the next page after the item """ cursor: String! """ Snapshot object """ node: Snapshot } """ A snapshot object """ type Snapshot implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the snapshot object """ swhid: SWHID! """ Connection to all the snapshot branches """ branches( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String """ Filter by branch target types """ types: [BranchTargetType] """ Return branches whose name contains the given substring """ nameInclude: String """ Do not return branches whose name contains the given prefix """ nameExcludePrefix: String ): BranchConnection } """ Connection to snapshot branches """ type BranchConnection { """ List of branch edges """ edges: [BranchConnectionEdge] """ List of branch objects """ nodes: [Branch] """ Information for pagination """ pageInfo: PageInfo! """ Total number of branch objects in the connection """ totalCount: Int } """ Edge in snapshot branch connection """ type BranchConnectionEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ Branch object """ node: Branch } """ A user object """ type Person { """ User's email address """ email: BinaryString """ User's name """ name: BinaryString """ User's full name """ fullname: BinaryString } """ Possible branch target objects """ union BranchTarget = Revision | Release | Branch | Content | Directory | Snapshot """ Possible Branch target types """ enum BranchTargetType { revision release alias content directory snapshot } """ A snapshot branch object """ type Branch { """ Branch name """ name: BinaryString """ Type of Branch target """ targetType: BranchTargetType """ Branch target object """ target: BranchTarget } """ Connection to revisions """ type RevisionConnection { """ List of revision edges """ edges: [RevisionEdge] """ List of revision objects """ nodes: [Revision] """ Information for pagination """ pageInfo: PageInfo! """ Total number of revision objects in the connection """ totalCount: Int } """ Edge in revision connection """ type RevisionEdge { """ Cursor to request the next page after the item """ cursor: String! """ Revision object """ node: Revision } """ Object with Date values """ type Date { """ ISO-8601 encoded date string. """ date: DateTime """ UTC offset """ offset: BinaryString } """ A revision object """ type Revision implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the revision object """ swhid: SWHID! """ Message associated to the revision """ message: BinaryString """ Revision authors """ author: [Person] """ Revision committers """ committer: [Person] """ Commit date """ committerDate: Date """ Revision date """ date: Date """ Type of the revision, eg: git/hg """ type: String """ The unique directory object that revision points to """ directory: Directory """ Connection to all the parents of the revision """ parents( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): RevisionConnection """ Connection to all the revisions heading to this one aka the commit log """ revisionLog( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): RevisionConnection } """ Possible release target objects """ union ReleaseTarget = Release | Revision | Directory | Content """ Possible release target types """ enum ReleaseTargetType { release revision content directory } """ A release object """ type Release implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the release object """ swhid: SWHID! """ The name of the release """ name: BinaryString """ The message associated to the release """ message: BinaryString """ Release author """ author: [Person] """ Release date """ date: Date """ Type of release target """ targetType: ReleaseTargetType """ Release target object """ target: ReleaseTarget } """ Connection to directory entries """ type DirectoryEntryConnection { """ List of directory entry edges """ edges: [DirectoryEntryEdge] """ List of directory entry objects """ nodes: [DirectoryEntry] """ Information for pagination """ pageInfo: PageInfo! """ Total number of directory entry objects in the connection """ totalCount: Int } """ Edge in directory entry connection """ type DirectoryEntryEdge { """ Cursor to request the next page after the item """ cursor: String! """ Directory entry object """ node: DirectoryEntry } """ Possible directory entry target objects """ union DirectoryEntryTarget = Directory | Content | Revision """ Possible directory entry types """ enum DirectoryEntryTargetType { directory content revision } """ A directory entry object """ type DirectoryEntry { """ The directory entry name """ name: BinaryString """ Directory entry object type; can be file, dir or rev """ targetType: DirectoryEntryTargetType """ Directory entry target object """ target: DirectoryEntryTarget } """ A directory object """ type Directory implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the directory object """ swhid: SWHID! """ Connection to the directory entries """ entries( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String """ Filter by entry name """ nameInclude: String ): DirectoryEntryConnection } """ An object with different content hashes """ type ContentHashes { blake2s256: String sha1: String sha1_git: String sha256: String } """ Object with different content data representations """ type ContentData { """ URL to download the file data """ url: String } type ContentMimeType { """ Detected content encoding """ encoding: String """ Detected MIME type of the content """ mimetype: String } type ContentLanguage { """ Detected programming language if any """ lang: String } type ContentLicense { """ Array of strings containing the detected license names """ licenses: [String] } """ A content object """ type Content implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the content object """ swhid: SWHID! """ Hashes for the content """ hashes: ContentHashes """ Length of the content in bytes """ length: Int """ Content status, visible or hidden """ status: String """ File content """ data: ContentData """ Information about the content MIME type """ mimeType: ContentMimeType """ Information about the programming language used in the content """ language: ContentLanguage """ Information about the license of the content """ license: ContentLicense } """ Connection to SearchResults """ type SearchResultConnection { """ List of SearchResult edges """ edges: [SearchResultEdge] """ List of SearchResult objects """ nodes: [SearchResult] """ Information for pagination """ pageInfo: PageInfo! """ Total number of result objects in the connection """ totalCount: Int } """ Edge in SearchResult connection """ type SearchResultEdge { # Cursor in this edge is removed for the time being # see https://forge.softwareheritage.org/D8911 for details # """ # Cursor to request the next page after the item # """ # cursor: String! """ SearchResult object """ node: SearchResult } union SearchResultTarget = Origin | Revision | Release | Content | Directory | Snapshot enum SearchResultTargetType { origin revision release content directory snapshot } """ A SearchResult object """ type SearchResult { """ Result target type """ targetType: SearchResultTargetType """ Result target object """ target: SearchResultTarget } """ The query root of the GraphQL interface. """ type Query { """ Get an origin with its url """ origin( """ URL of the Origin """ url: String! ): Origin """ Get a Connection to all the origins """ origins( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String """ Filter origins with a URL pattern """ urlPattern: String ): OriginConnection """ Get the visit object with an origin URL and a visit id """ visit( """ URL of the origin """ originUrl: String! """ Visit id to get """ visitId: Int! ): Visit """ Get the snapshot with a SWHID """ snapshot( """ SWHID of the snapshot object """ swhid: SWHID! ): Snapshot """ Get the revision with a SWHID """ revision( """ SWHID of the revision object """ swhid: SWHID! ): Revision """ Get the release with a SWHID """ release( """ SWHID of the release object """ swhid: SWHID! ): Release """ Get the directory with a SWHID """ directory( """ SWHID of the directory object """ swhid: SWHID! ): Directory """ Get a directory entry with directory SWHID and a path """ directoryEntry( """ SWHID of the directory object """ directorySWHID: SWHID! """ Relative path to the requested object """ path: String! ): DirectoryEntry """ - Get the content with a SWHID + Get a list of contents for the given SWHID """ - content( + contentsBySWHID ( """ - SWHID of the content object + SWHID to look for """ swhid: SWHID! - ): Content + ): [Content] """ - Get a content that match all the given hashes. - This entrypoint can be used to uniquely identify a content - in the event of hash conflicts. Use multiple hashes to - get an accurate result. - - At least one of the four hashes must be provided. + Get contents with hashes + At least one of the four hashes must be provided """ - contentByHashes( + contentsByHashes( sha1: String sha256: String sha1_git: String blake2s256: String + ): [Content] + + """ + Get a content that match all the given hashes. + All the four hashes must be provided + This entrypoint can be used to uniquely identify + a content in the event of hash collisions + """ + contentByHashes( + sha1: String! + + sha256: String! + + sha1_git: String! + + blake2s256: String! ): Content """ Resolve the given SWHID to an object """ resolveSWHID( """ SWHID to look for """ swhid: SWHID! ): [SearchResult] """ Search in SWH """ search( """ String to search for """ query: String! """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): SearchResultConnection } diff --git a/swh/graphql/tests/functional/test_content.py b/swh/graphql/tests/functional/test_content.py index 71c384b..46bc995 100644 --- a/swh/graphql/tests/functional/test_content.py +++ b/swh/graphql/tests/functional/test_content.py @@ -1,222 +1,253 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import pytest from . import utils from ..data import get_contents @pytest.mark.parametrize("content", get_contents()) -def test_get_content_with_swhid(client, content): +def test_get_content_with_hashes(client, content): query_str = """ - query getContent($swhid: SWHID!) { - content(swhid: $swhid) { + query getContentByHashes($sha1: String!, $sha256: String!, + $sha1_git: String!, $blake2s256: String!) { + contentByHashes(sha1: $sha1, sha256: $sha256, sha1_git: $sha1_git, + blake2s256: $blake2s256) { swhid id hashes { blake2s256 sha1 sha1_git sha256 } length status data { url } mimeType { encoding } language { lang } license { licenses } } } """ - data, _ = utils.get_query_response(client, query_str, swhid=str(content.swhid())) + data, _ = utils.get_query_response( + client, + query_str, + blake2s256=content.blake2s256.hex(), + sha1=content.sha1.hex(), + sha1_git=content.sha1_git.hex(), + sha256=content.sha256.hex(), + ) archive_url = "https://archive.softwareheritage.org/api/1/" response = { "swhid": str(content.swhid()), "id": content.sha1_git.hex(), "hashes": { "blake2s256": content.blake2s256.hex(), "sha1": content.sha1.hex(), "sha1_git": content.sha1_git.hex(), "sha256": content.sha256.hex(), }, "length": content.length, "status": content.status, "data": { "url": f"{archive_url}content/sha1:{content.sha1.hex()}/raw/", }, "mimeType": None, "language": None, "license": None, } - assert data["content"] == response + assert data["contentByHashes"] == response -def test_get_content_with_invalid_swhid(client): +@pytest.mark.parametrize("content", get_contents()) +def test_get_contents_with_swhid(client, content): query_str = """ - query getContent($swhid: SWHID!) { - content(swhid: $swhid) { + query getContents($swhid: SWHID!) { + contentsBySWHID(swhid: $swhid) { + swhid + } + } + """ + data, _ = utils.get_query_response(client, query_str, swhid=str(content.swhid())) + assert data["contentsBySWHID"] == [{"swhid": str(content.swhid())}] + + +def test_get_contents_with_invalid_swhid(client): + query_str = """ + query getContents($swhid: SWHID!) { + contentsBySWHID(swhid: $swhid) { swhid } } """ errors = utils.get_error_response(client, query_str, swhid="invalid") # API will throw an error in case of an invalid SWHID assert len(errors) == 1 assert "Input error: Invalid SWHID" in errors[0]["message"] +def test_get_contents_with_missing_swhid(client): + missing_sha1 = "1" * 40 + query_str = """ + query getContents($swhid: SWHID!) { + contentsBySWHID(swhid: $swhid) { + swhid + } + } + """ + data, _ = utils.get_query_response( + client, query_str, swhid=f"swh:1:cnt:{missing_sha1}" + ) + assert data["contentsBySWHID"] == [] + + @pytest.mark.parametrize("content", get_contents()) -def test_get_content_with_hash(client, content): +def test_get_contents_with_hashes(client, content): query_str = """ - query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { - contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + query getContents($sha1: String, $sha1_git: String, $sha256: String, + $blake2s256: String) { + contentsByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, blake2s256: $blake2s256) { swhid } } """ data, _ = utils.get_query_response( client, query_str, sha1=content.sha1.hex(), sha1_git=content.sha1_git.hex(), sha256=content.sha256.hex(), blake2s256=content.blake2s256.hex(), ) - assert data["contentByHashes"] == {"swhid": str(content.swhid())} + assert data["contentsByHashes"] == [{"swhid": str(content.swhid())}] @pytest.mark.parametrize("content", get_contents()) def test_get_content_with_single_hash(client, content): query_str = """ - query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { - contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + query getContents($sha1: String, $sha1_git: String, $sha256: String, + $blake2s256: String) { + contentsByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, blake2s256: $blake2s256) { swhid } } """ data, _ = utils.get_query_response( client, query_str, sha1=content.sha1.hex(), ) - assert data["contentByHashes"] == {"swhid": str(content.swhid())} + assert data["contentsByHashes"] == [{"swhid": str(content.swhid())}] + + data, _ = utils.get_query_response( + client, + query_str, + blake2s256=content.blake2s256.hex(), + ) + assert data["contentsByHashes"] == [{"swhid": str(content.swhid())}] @pytest.mark.parametrize("content", get_contents()) -def test_get_content_with_one_non_matching_hash(client, content): +def test_get_contents_with_one_non_matching_hash(client, content): query_str = """ - query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { - contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + query getContents($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { + contentsByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, blake2s256: $blake2s256) { swhid } } """ - utils.assert_missing_object( + data, _ = utils.get_query_response( client, query_str, - obj_type="contentByHashes", + obj_type="contentsByHashes", sha1=content.sha1.hex(), sha1_git="a" * 20, # hash is valid, but not matching the object ) + assert data["contentsByHashes"] == [] def test_get_content_with_invalid_hashes(client): content = get_contents()[0] query_str = """ - query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { - contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, - blake2s256: $blake2s256) { + query getContents($sha1: String, $sha1_git: String, $sha256: String, + $blake2s256: String) { + contentsByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + blake2s256: $blake2s256) { swhid } } """ errors = utils.get_error_response( client, query_str, sha1="invalid", # Only one hash is invalid sha1_git=content.sha1_git.hex(), sha256=content.sha256.hex(), ) # API will throw an error in case of an invalid content hash assert len(errors) == 1 assert "Input error: Invalid content hash" in errors[0]["message"] def test_get_content_with_no_hashes(client): query_str = """ - query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { - contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + query getContents($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { + contentsByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, blake2s256: $blake2s256) { swhid } } """ errors = utils.get_error_response( client, query_str, ) assert len(errors) == 1 assert ( "Input error: At least one of the four hashes must be provided" in errors[0]["message"] ) def test_get_content_as_target(client): # SWHID of a test dir with a file entry directory_swhid = "swh:1:dir:87b339104f7dc2a8163dec988445e3987995545f" query_str = """ query getDirectory($swhid: SWHID!) { directory(swhid: $swhid) { swhid entries(first: 2) { nodes { targetType target { ...on Content { swhid length } } } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=directory_swhid) content_obj = data["directory"]["entries"]["nodes"][1]["target"] assert content_obj == { "length": 4, "swhid": "swh:1:cnt:86bc6b377e9d25f9d26777a4a28d08e63e7c5779", } - - -def test_get_content_with_unknown_swhid(client): - unknown_sha1 = "1" * 40 - query_str = """ - query getDirectory($swhid: SWHID!) { - content(swhid: $swhid) { - swhid - } - } - """ - utils.assert_missing_object( - client, - query_str, - obj_type="content", - swhid=f"swh:1:cnt:{unknown_sha1}", - ) diff --git a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py index 88d2a32..a0b1d4c 100644 --- a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py +++ b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py @@ -1,22 +1,22 @@ # 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: def test_get_node_resolver_invalid_type(self): with pytest.raises(AttributeError): resolver_factory.NodeObjectFactory().create("invalid", None, None) def test_get_connection_resolver_invalid_type(self): with pytest.raises(AttributeError): resolver_factory.ConnectionObjectFactory().create("invalid", None, None) - def test_get_list_resolver_invalid_type(self): + def test_get_base_list_resolver_invalid_type(self): with pytest.raises(AttributeError): resolver_factory.SimpleListFactory().create("invalid", None, None) diff --git a/swh/graphql/tests/unit/resolvers/test_resolvers.py b/swh/graphql/tests/unit/resolvers/test_resolvers.py index 00ba594..0afd398 100644 --- a/swh/graphql/tests/unit/resolvers/test_resolvers.py +++ b/swh/graphql/tests/unit/resolvers/test_resolvers.py @@ -1,131 +1,131 @@ # 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 import resolvers from swh.graphql.resolvers import resolvers as rs class TestResolvers: """ """ @pytest.fixture def dummy_node(self): return {"test": "test"} @pytest.mark.parametrize( "resolver_func, node_cls", [ (rs.origin_resolver, resolvers.origin.OriginNode), (rs.visit_resolver, resolvers.visit.OriginVisitNode), (rs.latest_visit_resolver, resolvers.visit.LatestVisitNode), ( rs.latest_visit_status_resolver, resolvers.visit_status.LatestVisitStatusNode, ), (rs.snapshot_resolver, resolvers.snapshot.SnapshotNode), (rs.revision_resolver, resolvers.revision.RevisionNode), (rs.revision_directory_resolver, resolvers.directory.RevisionDirectoryNode), (rs.release_resolver, resolvers.release.ReleaseNode), (rs.directory_resolver, resolvers.directory.DirectoryNode), - (rs.content_resolver, resolvers.content.ContentNode), + (rs.content_by_hashes_resolver, resolvers.content.ContentbyHashesNode), ], ) def test_node_resolver(self, mocker, dummy_node, resolver_func, node_cls): mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node) node_obj = resolver_func(None, None) # assert the _get_node method is called on the right object assert isinstance(node_obj, node_cls) assert mock_get.assert_called @pytest.mark.parametrize( "resolver_func, connection_cls", [ (rs.origins_resolver, resolvers.origin.OriginConnection), (rs.visits_resolver, resolvers.visit.OriginVisitConnection), (rs.origin_snapshots_resolver, resolvers.snapshot.OriginSnapshotConnection), (rs.visitstatus_resolver, resolvers.visit_status.VisitStatusConnection), ( rs.snapshot_branches_resolver, resolvers.snapshot_branch.SnapshotBranchConnection, ), (rs.revision_parents_resolver, resolvers.revision.ParentRevisionConnection), (rs.revision_log_resolver, resolvers.revision.LogRevisionConnection), ( rs.directory_entries_resolver, resolvers.directory_entry.DirectoryEntryConnection, ), ], ) def test_connection_resolver(self, resolver_func, connection_cls): connection_obj = resolver_func(None, None) # assert the right object is returned assert isinstance(connection_obj, connection_cls) @pytest.mark.parametrize( "branch_type, node_cls", [ ("revision", resolvers.revision.TargetRevisionNode), ("release", resolvers.release.TargetReleaseNode), ("directory", resolvers.directory.TargetDirectoryNode), ("content", resolvers.content.TargetContentNode), ("snapshot", resolvers.snapshot.TargetSnapshotNode), ], ) def test_snapshot_branch_target_resolver( self, mocker, dummy_node, branch_type, node_cls ): 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) assert mock_get.assert_called @pytest.mark.parametrize( "target_type, node_cls", [ ("revision", resolvers.revision.TargetRevisionNode), ("release", resolvers.release.TargetReleaseNode), ("directory", resolvers.directory.TargetDirectoryNode), ("content", resolvers.content.TargetContentNode), ], ) def test_release_target_resolver(self, mocker, dummy_node, target_type, node_cls): 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) assert mock_get.assert_called @pytest.mark.parametrize( "target_type, node_cls", [ ("directory", resolvers.directory.TargetDirectoryNode), ("content", resolvers.content.TargetContentNode), ("revision", resolvers.revision.TargetRevisionNode), ], ) def test_directory_entry_target_resolver( self, mocker, dummy_node, target_type, node_cls ): obj = mocker.Mock(targetType=target_type) mock_get = mocker.patch.object(node_cls, "_get_node", return_value=dummy_node) node_obj = rs.directory_entry_target_resolver(obj, None) assert isinstance(node_obj, node_cls) assert mock_get.assert_called def test_union_resolver(self, mocker): obj = mocker.Mock() obj.is_type_of.return_value = "test" assert rs.union_resolver(obj) == "test" def test_binary_string_text_resolver(self): text = rs.binary_string_text_resolver(b"test", None) assert text == "test" def test_binary_string_base64_resolver(self): b64string = rs.binary_string_base64_resolver(b"test", None) assert b64string == "dGVzdA=="