diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py index fb85b22..b4fa41d 100644 --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -1,85 +1,87 @@ # 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, 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, + "dir-entry-dir": TargetDirectoryNode, + "dir-entry-rev": TargetRevisionNode, "search-result-origin": TargetOriginNode, "search-result-snapshot": TargetSnapshotNode, "search-result-revision": TargetRevisionNode, "search-result-release": TargetReleaseNode, "search-result-directory": TargetDirectoryNode, "search-result-content": TargetContentNode, } if resolver_type not in mapping: raise AttributeError(f"Invalid node type: {resolver_type}") return mapping[resolver_type] 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/revision.py b/swh/graphql/resolvers/revision.py index 4165df3..7870011 100644 --- a/swh/graphql/resolvers/revision.py +++ b/swh/graphql/resolvers/revision.py @@ -1,109 +1,110 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information from typing import Union from swh.graphql.utils import utils from swh.model.model import Revision from swh.model.swhids import CoreSWHID, ObjectType from swh.storage.interface import PagedResult from .base_connection import BaseConnection from .base_node import BaseSWHNode +from .directory_entry import BaseDirectoryEntryNode from .release import BaseReleaseNode from .snapshot_branch import BaseSnapshotBranchNode class BaseRevisionNode(BaseSWHNode): """ Base resolver for all the revision nodes """ def _get_revision_by_id(self, revision_id): return self.archive.get_revisions([revision_id])[0] @property def parent_swhids(self): # for ParentRevisionConnection resolver return [ CoreSWHID(object_type=ObjectType.REVISION, object_id=parent_id) for parent_id in self._node.parents ] @property def directory_hash(self): # for RevisionDirectoryNode resolver return self._node.directory @property def type(self): return self._node.type.value def is_type_of(self): # is_type_of is required only when resolving a UNION type # This is for ariadne to return the right type return "Revision" class RevisionNode(BaseRevisionNode): """ Node resolver for a revision requested directly with its SWHID """ def _get_node_data(self): return self._get_revision_by_id(self.kwargs.get("swhid").object_id) class TargetRevisionNode(BaseRevisionNode): """ Node resolver for a revision requested as a target """ - obj: Union[BaseSnapshotBranchNode, BaseReleaseNode] + obj: Union[BaseSnapshotBranchNode, BaseReleaseNode, BaseDirectoryEntryNode] def _get_node_data(self): # self.obj.target_hash is the requested revision id return self._get_revision_by_id(self.obj.target_hash) class ParentRevisionConnection(BaseConnection): """ Connection resolver for parent revisions in a revision """ obj: BaseRevisionNode _node_class = BaseRevisionNode def _get_paged_result(self) -> PagedResult: # self.obj is the current(child) revision # self.obj.parent_swhids is the list of parent SWHIDs # FIXME, using dummy(local) pagination, move pagination to backend # To remove localpagination, just drop the paginated call # STORAGE-TODO (pagination) parents = self.archive.get_revisions( [x.object_id for x in self.obj.parent_swhids] ) return utils.paginated(parents, self._get_first_arg(), self._get_after_arg()) class LogRevisionConnection(BaseConnection): """ Connection resolver for the log (list of revisions) in a revision """ obj: BaseRevisionNode _node_class = BaseRevisionNode def _get_paged_result(self) -> PagedResult: log = self.archive.get_revision_log([self.obj.swhid.object_id]) # Storage is returning a list of dicts instead of model objects # Following loop is to reverse that operation # STORAGE-TODO; remove to_dict from storage.revision_log log = [Revision.from_dict(rev) for rev in log] # FIXME, using dummy(local) pagination, move pagination to backend # To remove localpagination, just drop the paginated call # STORAGE-TODO (pagination) return utils.paginated(log, self._get_first_arg(), self._get_after_arg()) diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index 8ae62f1..4ede953 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1078 +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 +union DirectoryEntryTarget = Directory | Content | Revision """ 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/functional/test_directory_entry.py b/swh/graphql/tests/functional/test_directory_entry.py index 05c286d..09868d5 100644 --- a/swh/graphql/tests/functional/test_directory_entry.py +++ b/swh/graphql/tests/functional/test_directory_entry.py @@ -1,115 +1,119 @@ # 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, 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 } + ...on Revision { + 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"] ) + elif entry["type"] == "rev" and entry["target"] is not None: + swhid = CoreSWHID( + object_type=ObjectType.REVISION, 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