diff --git a/swh/graphql/backends/archive.py b/swh/graphql/backends/archive.py index c253232..738b340 100644 --- a/swh/graphql/backends/archive.py +++ b/swh/graphql/backends/archive.py @@ -1,79 +1,91 @@ # 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 os +from typing import Any, Dict, Optional + from swh.graphql import server +from swh.model.model import Sha1Git from swh.model.swhids import ObjectType class Archive: def __init__(self): self.storage = server.get_storage() def get_origin(self, url): return self.storage.origin_get([url])[0] def get_origins(self, after=None, first=50): return self.storage.origin_list(page_token=after, limit=first) def get_origin_visits(self, origin_url, after=None, first=50): return self.storage.origin_visit_get(origin_url, page_token=after, limit=first) def get_origin_visit(self, origin_url, visit_id): return self.storage.origin_visit_get_by(origin_url, visit_id) def get_origin_latest_visit(self, origin_url): return self.storage.origin_visit_get_latest(origin_url) def get_visit_status(self, origin_url, visit_id, after=None, first=50): return self.storage.origin_visit_status_get( origin_url, visit_id, page_token=after, limit=first ) def get_latest_visit_status(self, origin_url, visit_id): return self.storage.origin_visit_status_get_latest(origin_url, visit_id) def get_origin_snapshots(self, origin_url): return self.storage.origin_snapshot_get_all(origin_url) def get_snapshot_branches( self, snapshot, after=b"", first=50, target_types=None, name_include=None ): return self.storage.snapshot_get_branches( snapshot, branches_from=after, branches_count=first, target_types=target_types, branch_name_include_substring=name_include, ) def get_revisions(self, revision_ids): return self.storage.revision_get(revision_ids=revision_ids) def get_revision_log(self, revision_ids, after=None, first=50): return self.storage.revision_log(revisions=revision_ids, limit=first) def get_releases(self, release_ids): return self.storage.release_get(releases=release_ids) + def get_directory_entry_by_path( + self, directory_id: Sha1Git, path: str + ) -> Optional[Dict[str, Any]]: + paths = [x.encode() for x in path.strip(os.path.sep).split(os.path.sep)] + return self.storage.directory_entry_get_by_path( + directory=directory_id, paths=paths + ) + def get_directory_entries(self, directory_id, after=None, first=50): return self.storage.directory_get_entries( directory_id, limit=first, page_token=after ) def is_object_available(self, object_id: str, object_type: ObjectType) -> bool: mapping = { ObjectType.CONTENT: self.storage.content_missing_per_sha1_git, ObjectType.DIRECTORY: self.storage.directory_missing, ObjectType.RELEASE: self.storage.release_missing, ObjectType.REVISION: self.storage.revision_missing, ObjectType.SNAPSHOT: self.storage.snapshot_missing, } return not list(mapping[object_type]([object_id])) def get_contents(self, checksums: dict): return self.storage.content_find(checksums) def get_content_data(self, content_sha1): return self.storage.content_get_data(content_sha1) diff --git a/swh/graphql/resolvers/content.py b/swh/graphql/resolvers/content.py index e5335b2..71e91ca 100644 --- a/swh/graphql/resolvers/content.py +++ b/swh/graphql/resolvers/content.py @@ -1,93 +1,93 @@ # 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 .base_node import BaseSWHNode -from .directory_entry import DirectoryEntryNode +from .directory_entry import BaseDirectoryEntryNode from .release import BaseReleaseNode from .snapshot_branch import BaseSnapshotBranchNode class BaseContentNode(BaseSWHNode): """ Base resolver for all the content nodes """ def _get_content_by_hash(self, checksums: dict): content = self.archive.get_contents(checksums) # in case of a conflict, return the first element return content[0] if content else None @property def checksum(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 fileType(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): checksums = {"sha1_git": self.kwargs.get("swhid").object_id} return self._get_content_by_hash(checksums) class HashContentNode(BaseContentNode): """ Node resolver for a content requested with one or more checksums """ def _get_node_data(self): checksums = dict(self.kwargs.get("checksums")) return self._get_content_by_hash(checksums) class TargetContentNode(BaseContentNode): """ Node resolver for a content requested as a target This request could be from directory entry, release or a branch """ - obj: Union[DirectoryEntryNode, BaseReleaseNode, BaseSnapshotBranchNode] + obj: Union[BaseDirectoryEntryNode, BaseReleaseNode, BaseSnapshotBranchNode] def _get_node_data(self): return self._get_content_by_hash(checksums={"sha1_git": self.obj.target_hash}) diff --git a/swh/graphql/resolvers/directory.py b/swh/graphql/resolvers/directory.py index fc07520..1776ae6 100644 --- a/swh/graphql/resolvers/directory.py +++ b/swh/graphql/resolvers/directory.py @@ -1,72 +1,71 @@ # 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.model.model import Directory from swh.model.swhids import ObjectType from .base_node import BaseSWHNode from .release import BaseReleaseNode from .revision import BaseRevisionNode from .snapshot_branch import BaseSnapshotBranchNode class BaseDirectoryNode(BaseSWHNode): """ Base resolver for all the directory nodes """ def _get_directory_by_id(self, directory_id): # Return a Directory model object # entries is initialized as empty # Same pattern is used in snapshot return Directory(id=directory_id, entries=()) def is_type_of(self): return "Directory" class DirectoryNode(BaseDirectoryNode): """ Node resolver for a directory requested directly with its SWHID """ def _get_node_data(self): swhid = self.kwargs.get("swhid") - # path = "" if ( swhid.object_type == ObjectType.DIRECTORY and self.archive.is_object_available(swhid.object_id, swhid.object_type) ): # _get_directory_by_id is not making any backend call # hence the is_directory_available validation return self._get_directory_by_id(swhid.object_id) return None class RevisionDirectoryNode(BaseDirectoryNode): """ Node resolver for a directory requested from a revision """ obj: BaseRevisionNode def _get_node_data(self): # self.obj.directory_hash is the requested directory Id return self._get_directory_by_id(self.obj.directory_hash) class TargetDirectoryNode(BaseDirectoryNode): """ Node resolver for a directory requested as a target """ - from .directory_entry import DirectoryEntryNode + from .directory_entry import BaseDirectoryEntryNode - obj: Union[BaseSnapshotBranchNode, BaseReleaseNode, DirectoryEntryNode] + obj: Union[BaseSnapshotBranchNode, BaseReleaseNode, BaseDirectoryEntryNode] def _get_node_data(self): return self._get_directory_by_id(self.obj.target_hash) diff --git a/swh/graphql/resolvers/directory_entry.py b/swh/graphql/resolvers/directory_entry.py index e1aea34..b19ddf2 100644 --- a/swh/graphql/resolvers/directory_entry.py +++ b/swh/graphql/resolvers/directory_entry.py @@ -1,43 +1,54 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from swh.graphql.utils import utils from swh.storage.interface import PagedResult from .base_connection import BaseConnection from .base_node import BaseNode -class DirectoryEntryNode(BaseNode): - """ - Node resolver for a directory entry - """ - +class BaseDirectoryEntryNode(BaseNode): @property def target_hash(self): # for DirectoryNode return self._node.target @property def targetType(self): # To support the schema naming convention return self._node.type +class DirectoryEntryNode(BaseDirectoryEntryNode): + """ + Node resolver for a directory entry requested with a + directory SWHID and a relative path + """ + + def _get_node_data(self): + # STORAGE-TODO, archive is returning a dict + # return DirectoryEntry object instead + return self.archive.get_directory_entry_by_path( + directory_id=self.kwargs.get("swhid").object_id, + path=self.kwargs.get("path"), + ) + + class DirectoryEntryConnection(BaseConnection): """ Connection resolver for entries in a directory """ from .directory import BaseDirectoryNode obj: BaseDirectoryNode - _node_class = DirectoryEntryNode + _node_class = BaseDirectoryEntryNode def _get_paged_result(self) -> PagedResult: # FIXME, using dummy(local) pagination, move pagination to backend # To remove localpagination, just drop the paginated call # STORAGE-TODO entries = self.archive.get_directory_entries(self.obj.swhid.object_id).results return utils.paginated(entries, self._get_first_arg(), self._get_after_arg()) diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py index 438bc5b..fb85b22 100644 --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -1,84 +1,85 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from .content import ContentNode, HashContentNode, TargetContentNode from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode -from .directory_entry import DirectoryEntryConnection +from .directory_entry import DirectoryEntryConnection, DirectoryEntryNode from .origin import OriginConnection, OriginNode, TargetOriginNode from .release import ReleaseNode, TargetReleaseNode from .revision import ( LogRevisionConnection, ParentRevisionConnection, RevisionNode, TargetRevisionNode, ) from .search import ResolveSwhidConnection, 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 def get_node_resolver(resolver_type): # FIXME, replace with a proper factory method mapping = { "origin": OriginNode, "visit": OriginVisitNode, "latest-visit": LatestVisitNode, "latest-status": LatestVisitStatusNode, "visit-snapshot": VisitSnapshotNode, "snapshot": SnapshotNode, "branch-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-dir": TargetDirectoryNode, "dir-entry-file": TargetContentNode, "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] def get_connection_resolver(resolver_type): # FIXME, replace with a proper factory method mapping = { "origins": OriginConnection, "origin-visits": OriginVisitConnection, "origin-snapshots": OriginSnapshotConnection, "visit-status": VisitStatusConnection, "snapshot-branches": SnapshotBranchConnection, "revision-parents": ParentRevisionConnection, "revision-log": LogRevisionConnection, "directory-entries": DirectoryEntryConnection, "resolve-swhid": ResolveSwhidConnection, "search": SearchConnection, } if resolver_type not in mapping: raise AttributeError(f"Invalid connection type: {resolver_type}") return mapping[resolver_type] diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py index 2be89f2..60992bb 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,299 +1,307 @@ # 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) +@query.field("directoryEntry") +def directory_entry_resolver( + obj: None, info: GraphQLResolveInfo, **kw +) -> rs.directory.DirectoryNode: + resolver = get_node_resolver("directory-entry") + return resolver(obj, info, **kw) + + @directory_entry.field("target") def directory_entry_target_resolver( - obj: rs.directory_entry.DirectoryEntryNode, info: GraphQLResolveInfo, **kw + obj: rs.directory_entry.BaseDirectoryEntryNode, 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) @directory.field("entries") -def directory_entry_resolver( +def directory_entries_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/schema/schema.graphql b/swh/graphql/schema/schema.graphql index 0d61a85..8ae62f1 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1063 +1,1078 @@ """ SoftWare Heritage persistent Identifier """ scalar SWHID """ ISO-8601 encoded date string """ scalar DateTime """ Content identifier in the form hash-type:hash-value """ scalar ContentHash """ Object with an id """ interface Node { """ Id of the object. This is for caching purpose and should not be used outside the GraphQL API """ id: ID! } """ SWH merkle node object with a SWHID """ interface MerkleNode { """ SWHID of the object """ swhid: SWHID! } """ Information about pagination """ type PageInfo { """ Cursor to request the next page in the connection """ endCursor: String """ Are there more pages in the connection? """ hasNextPage: Boolean! } """ Binary strings; different encodings """ type BinaryString { """ Utf-8 encoded value, any non Unicode character will be replaced """ text: String """ base64 encoded value """ base64: String } """ Connection to origins """ type OriginConnection { """ List of origin edges """ edges: [OriginEdge] """ List of origin objects """ nodes: [Origin] """ Information for pagination """ pageInfo: PageInfo! """ Total number of origin objects in the connection """ totalCount: Int } """ Edge in origin connection """ type OriginEdge { """ Cursor to request the next page after the item """ cursor: String! """ Origin object """ node: Origin } """ A software origin object """ type Origin implements Node { """ Unique identifier """ id: ID! """ Origin URL """ url: String! """ Connection to all the visit objects for the origin """ visits( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): VisitConnection! """ Latest visit object for the origin """ latestVisit: Visit """ Connection to all the snapshots for the origin """ snapshots( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String ): SnapshotConnection } """ Connection to origin visits """ type VisitConnection { """ List of visit edges """ edges: [VisitEdge] """ List of visit objects """ nodes: [Visit] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit objects in the connection """ totalCount: Int } """ Edge in origin visit connection """ type VisitEdge { """ Cursor to request the next page after the item """ cursor: String! """ Visit object """ node: Visit } """ An origin visit object """ type Visit implements Node { """ Unique identifier """ id: ID! """ Visit number for the origin """ visitId: Int """ Visit date ISO-8601 encoded """ date: DateTime! """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String """ Connection to all the status objects for the visit """ status( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): VisitStatusConnection """ Latest status object for the Visit """ latestStatus: VisitStatus } """ Connection to visit status """ type VisitStatusConnection { """ List of visit status edges """ edges: [VisitStatusEdge] """ List of visit status objects """ nodes: [VisitStatus] """ Information for pagination """ pageInfo: PageInfo! """ Total number of visit status objects in the connection """ totalCount: Int } """ Edge in visit status connection """ type VisitStatusEdge { """ Cursor to request the next page after the item """ cursor: String! """ Visit status object """ node: VisitStatus } """ A visit status object """ type VisitStatus { """ Status string of the visit (either full, partial or ongoing) """ status: String! """ ISO-8601 encoded date string """ date: DateTime! """ Snapshot object """ snapshot: Snapshot """ Type of the origin visited. Eg: git/hg/svn/tar/deb """ type: String } """ Connection to snapshots """ type SnapshotConnection { """ List of snapshot edges """ edges: [SnapshotEdge] """ List of snapshot objects """ nodes: [Snapshot] """ Information for pagination """ pageInfo: PageInfo! """ Total number of snapshot objects in the connection """ totalCount: Int } """ Edge in snapshot connection """ type SnapshotEdge { """ Cursor to request the next page after the item """ cursor: String! """ Snapshot object """ node: Snapshot } """ A snapshot object """ type Snapshot implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the snapshot object """ swhid: SWHID! """ Connection to all the snapshot branches """ branches( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after this cursor """ after: String """ Filter by branch target types """ types: [BranchTargetType] """ Filter by branch name """ nameInclude: String ): BranchConnection } """ Connection to snapshot branches """ type BranchConnection { """ List of branch edges """ edges: [BranchConnectionEdge] """ List of branch objects """ nodes: [Branch] """ Information for pagination """ pageInfo: PageInfo! """ Total number of branch objects in the connection """ totalCount: Int } """ Edge in snapshot branch connection """ type BranchConnectionEdge { """ Cursor to request the next page after the item """ cursor: String! """ Branch object """ node: Branch } """ A user object """ type Person { """ User's email address """ email: BinaryString """ User's name """ name: BinaryString """ User's full name """ fullname: BinaryString } """ Possible branch target objects """ union BranchTarget = Revision | Release | Branch | Content | Directory | Snapshot """ Possible Branch target types """ enum BranchTargetType { revision release alias content directory snapshot } """ A snapshot branch object """ type Branch { """ Branch name """ name: BinaryString """ Type of Branch target """ 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 } """ A revision object """ type Revision implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the revision object """ swhid: SWHID! """ Message associated to the revision """ message: BinaryString """ """ author: Person """ """ committer: Person """ Revision date ISO-8601 encoded """ date: DateTime """ Type of the revision, eg: git/hg """ type: String """ The unique directory object that revision points to """ directory: Directory """ Connection to all the parents of the revision """ parents( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): RevisionConnection """ Connection to all the revisions heading to this one aka the commit log """ revisionLog( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): RevisionConnection } """ Possible release target objects """ union ReleaseTarget = Release | Revision | Directory | Content """ Possible release target types """ enum ReleaseTargetType { release revision content directory } """ A release object """ type Release implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the release object """ swhid: SWHID! """ The name of the release """ name: BinaryString """ The message associated to the release """ message: BinaryString """ """ author: Person """ Release date ISO-8601 encoded """ date: DateTime """ Type of release target """ targetType: ReleaseTargetType """ Release target object """ target: ReleaseTarget } """ Connection to directory entries """ type DirectoryEntryConnection { """ List of directory entry edges """ edges: [DirectoryEntryEdge] """ List of directory entry objects """ nodes: [DirectoryEntry] """ Information for pagination """ pageInfo: PageInfo! """ Total number of directory entry objects in the connection """ totalCount: Int } """ Edge in directory entry connection """ type DirectoryEntryEdge { """ Cursor to request the next page after the item """ cursor: String! """ Directory entry object """ node: DirectoryEntry } """ Possible directory entry target objects """ union DirectoryEntryTarget = Directory | Content """ Possible directory entry types """ enum DirectoryEntryTargetType { dir file rev } """ A directory entry object """ type DirectoryEntry { """ The directory entry name """ name: BinaryString """ Directory entry object type; can be file, dir or rev """ targetType: DirectoryEntryTargetType """ Directory entry target object """ target: DirectoryEntryTarget } """ A directory object """ type Directory implements MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the directory object """ swhid: SWHID! """ Connection to the directory entries """ entries( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): DirectoryEntryConnection } """ An object with different content checksums """ type ContentChecksum { 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 ContentFileType { """ 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 MerkleNode & Node { """ Unique identifier """ id: ID! """ SWHID of the content object """ swhid: SWHID! """ Checksums for the content """ checksum: ContentChecksum """ Length of the content in bytes """ length: Int """ Content status, visible or hidden """ status: String """ File content """ data: ContentData """ Information about the content MIME type """ fileType: ContentFileType """ 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 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 + """ + swhid: SWHID! + + """ + Relative path to the requested object + """ + path: String! + ): DirectoryEntry + """ Get the content with a SWHID """ content( """ SWHID of the content object """ swhid: SWHID! ): Content """ Get the content by one or more hashes Use multiple hashes for an accurate result """ contentByHash( """ List of hashType:hashValue strings """ checksums: [ContentHash]! ): Content """ Resolve the given SWHID to an object """ resolveSwhid( """ SWHID to look for """ swhid: SWHID! ): SearchResultConnection! """ 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/data.py b/swh/graphql/tests/data.py index beed8b6..1c9f983 100644 --- a/swh/graphql/tests/data.py +++ b/swh/graphql/tests/data.py @@ -1,107 +1,130 @@ # 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.model import ObjectType, Release, Revision, RevisionType +from swh.model.model import ( + Directory, + DirectoryEntry, + 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( name=b"v0.0.1", target_type=ObjectType.REVISION, target=get_revisions()[0].id, message=b"foo", synthetic=False, ) with_release = Release( name=b"v0.0.1", target_type=ObjectType.RELEASE, target=get_releases()[0].id, message=b"foo", synthetic=False, ) with_directory = Release( name=b"v0.0.1", target_type=ObjectType.DIRECTORY, target=get_directories()[0].id, message=b"foo", synthetic=False, ) with_content = Release( 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] def get_revisions_with_parents(): """ Revisions with real revisions as parents """ return [ Revision( 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), ) ] +def get_directories_with_nested_path(): + return [ + Directory( + entries=( + DirectoryEntry( + name=b"sub-dir", + perms=0o644, + type="dir", + target=get_directories()[1].id, + ), + ) + ) + ] + + GRAPHQL_EXTRA_TEST_OBJECTS = { "release": get_releases_with_target(), "revision": get_revisions_with_parents(), + "directory": get_directories_with_nested_path(), } 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_directory_entry.py b/swh/graphql/tests/functional/test_directory_entry.py index 411435a..05c286d 100644 --- a/swh/graphql/tests/functional/test_directory_entry.py +++ b/swh/graphql/tests/functional/test_directory_entry.py @@ -1,37 +1,115 @@ # 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 server +from swh.model.swhids import CoreSWHID, ObjectType + from . import utils -from ..data import get_directories +from ..data import get_directories, get_directories_with_nested_path + + +def test_get_directory_entry_missing_path(client): + directory = get_directories()[0] + path = "missing" + query_str = """ + { + directoryEntry(swhid: "%s", path: "%s") { + name { + text + } + targetType + target { + ...on Content { + swhid + } + } + } + } + """ % ( + directory.swhid(), + path, + ) + utils.assert_missing_object(client, query_str, "directoryEntry") + + +@pytest.mark.parametrize( + "directory", get_directories() + get_directories_with_nested_path() +) +def test_get_directory_entry(client, directory): + storage = server.get_storage() + query_str = """ + { + directoryEntry(swhid: "%s", path: "%s") { + name { + text + } + targetType + target { + ...on Content { + swhid + } + ...on Directory { + swhid + } + } + } + } + """ + for entry in storage.directory_ls(directory.id, recursive=True): + if entry["type"] == "rev": + # FIXME, Revision is not supported as a directory entry target yet + continue + query = query_str % ( + directory.swhid(), + entry["name"].decode(), + ) + data, _ = utils.get_query_response( + client, + query, + ) + swhid = None + if entry["type"] == "file" and entry["sha1_git"] is not None: + swhid = CoreSWHID( + object_type=ObjectType.CONTENT, object_id=entry["sha1_git"] + ) + elif entry["type"] == "dir" and entry["target"] is not None: + swhid = CoreSWHID( + object_type=ObjectType.DIRECTORY, object_id=entry["target"] + ) + assert data["directoryEntry"] == { + "name": {"text": entry["name"].decode()}, + "target": {"swhid": str(swhid)} if swhid else None, + "targetType": entry["type"], + } @pytest.mark.parametrize("directory", get_directories()) def test_get_directory_entry_connection(client, directory): query_str = """ { directory(swhid: "%s") { swhid entries { nodes { targetType name { text } } } } } """ data, _ = utils.get_query_response(client, query_str % directory.swhid()) directory_entries = data["directory"]["entries"]["nodes"] assert len(directory_entries) == len(directory.entries) output = [ {"name": {"text": de.name.decode()}, "targetType": de.type} for de in directory.entries ] for each_entry in output: assert each_entry in directory_entries diff --git a/swh/graphql/tests/unit/resolvers/test_resolvers.py b/swh/graphql/tests/unit/resolvers/test_resolvers.py index 1093cef..99fc127 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.visit_snapshot_resolver, resolvers.snapshot.VisitSnapshotNode), (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), ], ) 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_entry_resolver, + 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(type=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(target_type=(mocker.Mock(value=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", [ ("dir", resolvers.directory.TargetDirectoryNode), ("file", resolvers.content.TargetContentNode), ], ) def test_directory_entry_target_resolver( self, mocker, dummy_node, target_type, node_cls ): obj = mocker.Mock(type=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=="