diff --git a/swh/graphql/backends/archive.py b/swh/graphql/backends/archive.py index 1d4dbda..20c75c3 100644 --- a/swh/graphql/backends/archive.py +++ b/swh/graphql/backends/archive.py @@ -1,47 +1,50 @@ from swh.storage import get_storage class Archive: def __init__(self): # FIXME, setup config self.storage = get_storage( cls="remote", url="http://moma.internal.softwareheritage.org:5002" ) 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_snapshot_branches(self, snapshot, after, first, target_types): return self.storage.snapshot_get_branches( snapshot, branches_from=after, branches_count=first, target_types=target_types, ) def get_revisions(self, revision_ids): return self.storage.revision_get(revision_ids=revision_ids) def get_releases(self, release_ids): return self.storage.release_get(releases=release_ids) def get_directory_entries(self, directory_id): return self.storage.directory_ls(directory_id) def get_content(self, content_id): # FIXME, only for tests return self.storage.content_find({"sha1_git": content_id}) diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py index 18ef831..96183a9 100644 --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -1,51 +1,52 @@ from .content import ContentNode, TargetContentNode from .directory import DirectoryNode, RevisionDirectoryNode, TargetDirectoryNode from .directory_entry import DirectoryEntryConnection from .origin import OriginConnection, OriginNode from .release import ReleaseNode, TargetReleaseNode from .revision import ParentRevisionConnection, RevisionNode, TargetRevisionNode from .snapshot import SnapshotNode, VisitSnapshotNode from .snapshot_branch import SnapshotBranchConnection -from .visit import OriginVisitConnection, OriginVisitNode +from .visit import LatestVisitNode, OriginVisitConnection, OriginVisitNode from .visit_status import VisitStatusConnection def get_node_resolver(resolver_type): # FIXME, replace with a proper factory method mapping = { "origin": OriginNode, "visit": OriginVisitNode, + "latest-visit": LatestVisitNode, "visit-snapshot": VisitSnapshotNode, "snapshot": SnapshotNode, "branch-revision": TargetRevisionNode, "branch-release": TargetReleaseNode, "revision": RevisionNode, "revision-directory": RevisionDirectoryNode, "release": ReleaseNode, "release-revision": TargetRevisionNode, "release-release": TargetReleaseNode, "release-directory": TargetDirectoryNode, "release-content": TargetContentNode, "directory": DirectoryNode, "content": ContentNode, "dir-entry-dir": TargetDirectoryNode, "dir-entry-file": 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, "visit-status": VisitStatusConnection, "snapshot-branches": SnapshotBranchConnection, "revision-parents": ParentRevisionConnection, "directory-entries": DirectoryEntryConnection, } 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 e3ea1b6..c0d632e 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,172 +1,179 @@ """ 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 an annotation (eg: @visitstatus.field("snapshot")) - As a property in the Node object (eg: resolvers.visit.OriginVisitNode.id) - As an attribute/item in the object/dict returned by the backend (eg: Origin.url) """ from ariadne import ObjectType, UnionType from .resolver_factory import get_connection_resolver, get_node_resolver query = ObjectType("Query") origin = ObjectType("Origin") visit = ObjectType("Visit") visit_status = ObjectType("VisitStatus") snapshot = ObjectType("Snapshot") snapshot_branch = ObjectType("Branch") revision = ObjectType("Revision") release = ObjectType("Release") directory = ObjectType("Directory") directory_entry = ObjectType("DirectoryEntry") branch_target = UnionType("BranchTarget") release_target = UnionType("ReleaseTarget") directory_entry_target = UnionType("DirectoryEntryTarget") # Node resolvers # A node resolver should return an instance of BaseNode @query.field("origin") def origin_resolver(obj, info, **kw): """ """ resolver = get_node_resolver("origin") return resolver(obj, info, **kw)() +@origin.field("latestVisit") +def latest_visit_resolver(obj, info, **kw): + """ """ + resolver = get_node_resolver("latest-visit") + return resolver(obj, info, **kw)() + + @query.field("visit") def visit_resolver(obj, info, **kw): """ """ resolver = get_node_resolver("visit") return resolver(obj, info, **kw)() @query.field("snapshot") def snapshot_resolver(obj, info, **kw): """ """ resolver = get_node_resolver("snapshot") return resolver(obj, info, **kw)() @visit_status.field("snapshot") def visit_snapshot_resolver(obj, info, **kw): resolver = get_node_resolver("visit-snapshot") return resolver(obj, info, **kw)() @snapshot_branch.field("target") def snapshot_branch_target_resolver(obj, info, **kw): """ Snapshot branch target can be a revision or a release """ resolver_type = f"branch-{obj.type}" resolver = get_node_resolver(resolver_type) return resolver(obj, info, **kw)() @query.field("revision") def revision_resolver(obj, info, **kw): resolver = get_node_resolver("revision") return resolver(obj, info, **kw)() @revision.field("directory") def revision_directory_resolver(obj, info, **kw): resolver = get_node_resolver("revision-directory") return resolver(obj, info, **kw)() @query.field("release") def release_resolver(obj, info, **kw): resolver = get_node_resolver("release") return resolver(obj, info, **kw)() @release.field("target") def release_target_resolver(obj, info, **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, info, **kw): resolver = get_node_resolver("directory") return resolver(obj, info, **kw)() @directory_entry.field("target") def directory_entry_target_resolver(obj, info, **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, info, **kw): resolver = get_node_resolver("content") return resolver(obj, info, **kw)() # Connection resolvers # A connection resolver should return an instance of BaseConnection @query.field("origins") def origins_resolver(obj, info, **kw): resolver = get_connection_resolver("origins") return resolver(obj, info, **kw)() @origin.field("visits") def visits_resolver(obj, info, **kw): resolver = get_connection_resolver("origin-visits") return resolver(obj, info, **kw)() @visit.field("status") def visitstatus_resolver(obj, info, **kw): resolver = get_connection_resolver("visit-status") return resolver(obj, info, **kw)() @snapshot.field("branches") def snapshot_branches_resolver(obj, info, **kw): resolver = get_connection_resolver("snapshot-branches") return resolver(obj, info, **kw)() @revision.field("parents") def revision_parent_resolver(obj, info, **kw): resolver = get_connection_resolver("revision-parents") return resolver(obj, info, **kw)() @directory.field("entries") def directory_entry_resolver(obj, info, **kw): resolver = get_connection_resolver("directory-entries") return resolver(obj, info, **kw)() # Any other type of resolver @release_target.type_resolver @directory_entry_target.type_resolver @branch_target.type_resolver def union_resolver(obj, *_): """ Generic resolver for all the union types """ return obj.is_type_of() diff --git a/swh/graphql/resolvers/visit.py b/swh/graphql/resolvers/visit.py index f6c1dc9..5ce59d3 100644 --- a/swh/graphql/resolvers/visit.py +++ b/swh/graphql/resolvers/visit.py @@ -1,30 +1,47 @@ from swh.graphql.backends import archive from swh.graphql.utils import utils from .base_connection import BaseConnection from .base_node import BaseNode -class OriginVisitNode(BaseNode): - def _get_node_data(self): - return archive.Archive().get_origin_visit( - self.kwargs.get("originUrl"), int(self.kwargs.get("id")) - ) - +class BaseVisitNode(BaseNode): @property def id(self): # FIXME, use a better id return utils.b64encode(f"{self.origin}-{str(self.visit)}") +class OriginVisitNode(BaseVisitNode): + """ + Get the visit directly with an origin URL and a visit ID + """ + + def _get_node_data(self): + return archive.Archive().get_origin_visit( + self.kwargs.get("originUrl"), int(self.kwargs.get("visitId")) + ) + + +class LatestVisitNode(BaseVisitNode): + """ + Get the latest visit for an origin + self.obj is the origin object here + self.obj.url is the origin URL + """ + + def _get_node_data(self): + return archive.Archive().get_origin_latest_visit(self.obj.url) + + class OriginVisitConnection(BaseConnection): - _node_class = OriginVisitNode + _node_class = BaseVisitNode def _get_paged_result(self): """ Get the visits for the given origin parent obj (self.obj) is origin here """ return archive.Archive().get_origin_visits( self.obj.url, after=self._get_after_arg(), first=self._get_first_arg() ) diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index c29616c..4367a3d 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,343 +1,345 @@ scalar SWHID scalar Sha1 scalar DateTime scalar DateTimeZone scalar BinaryText interface Node { id: ID! } # interface SWHIDNode { # id: SWHID! # } interface SWHNode { id: Sha1! } type PageInfo { endCursor: String hasNextPage: Boolean! } type OriginConnection { edges: [OriginEdge] nodes: [Origin] pageInfo: PageInfo! totalCount: Int } type OriginEdge { cursor: String! node: Origin } type Origin implements SWHNode { id: Sha1! url: String! visits( first: Int after: String ): VisitConnection! + latestVisit: Visit } type VisitConnection { edges: [VisitEdge] nodes: [Visit] pageInfo: PageInfo! totalCount: Int } type VisitEdge { cursor: String! node: Visit } type Visit implements Node { id: ID! + visit: Int date: DateTime! type: String status( first: Int after: String ): VisitStatusConnection # origin: Origin # FIXME, this can be added later } type VisitStatusConnection { edges: [VisitStatusEdge] nodes: [VisitStatus] pageInfo: PageInfo! totalCount: Int } type VisitStatusEdge { cursor: String! node: VisitStatus } type VisitStatus { status: String! date: DateTime! snapshot: Snapshot type: String } # FIXME, add OriginSnapshotConnection type Snapshot implements SWHNode { id: Sha1! branches( first: Int after: String types: [BranchTypes] ): BranchConnection # releases( # first: Int # after: String # ): ReleaseConnection # FIXME, add alias type as well } type BranchConnection { edges: [BranchConnectionEdge] nodes: [Branch] pageInfo: PageInfo! totalCount: Int } type BranchConnectionEdge { cursor: String! node: Branch } type Person { email: BinaryText name: BinaryText fullname: BinaryText } # FIXME, this can be Content, Directory, Snapshot, or Alias as well union BranchTarget = Revision | Release enum BranchTypes { revision release } type Branch { name: BinaryText type: BranchTypes targetId: Sha1 target: BranchTarget } type RevisionConnection { edges: [RevisionEdge] nodes: [Revision] pageInfo: PageInfo! totalCount: Int } type RevisionEdge { cursor: String! node: Revision } type Revision implements SWHNode { id: Sha1! message: BinaryText author: Person committer: Person date: DateTimeZone type: String # Revision type: FIXME, change to an enum directoryId: Sha1 directory: Directory parentIds: [Sha1] parents( first: Int after: String ): RevisionConnection # log } # type ReleaseConnection { # } # type ReleasEdge { # } union ReleaseTarget = Release | Revision | Directory | Content enum ReleaseTargetType { release revision content directory } type Release implements SWHNode { id: Sha1! name: BinaryText message: BinaryText author: Person date: DateTimeZone targetId: Sha1 targetType: ReleaseTargetType target: ReleaseTarget } type DirectoryEntryConnection { edges: [DirectoryEntryEdge] nodes: [DirectoryEntry] pageInfo: PageInfo! totalCount: Int } type DirectoryEntryEdge { cursor: String! node: DirectoryEntry } union DirectoryEntryTarget = Directory | Content enum DirectoryEntryType { dir file rev } type DirectoryEntry { name: BinaryText type: DirectoryEntryType targetId: Sha1 target: DirectoryEntryTarget } type Directory implements SWHNode { id: Sha1! entries( first: Int after: String ): DirectoryEntryConnection } type ContentChecksum { # FIXME, temp types blake2s256: Sha1 sha1: Sha1 sha1_git: Sha1 sha256: Sha1 } # type ContentType { # test: String # } # type ContentLanguage { # test: String # } # type ContentLicense { # test: String # } type Content implements SWHNode { id: Sha1! swhid: String checksum: ContentChecksum # data: # filetype: ContentType # language: ContentLanguage # license: ContentLicense length: Int status: String data: BinaryText } type Query { """ Get an origin with its url """ # FIXME, find some unique id to help cache # maybe base64 encode the URL origin( url: String! ): Origin """ Get a list of origins matching the given filters Can also be used to search for an origin """ # FIMXE, use Input types to make this cleaner origins( first: Int after: String ): OriginConnection """ Get a visit object with its id and/or origin and visit id """ # FIXME, find some unique id to help cache visit( originUrl: String! - id: String! + visitId: Int! ): Visit """ Get a snapshot with SWHID """ snapshot( Sha1: String! ): Snapshot # """ # Get all the snapshot for the given origin # """ # originSnapshot( # originUrl: String! # first: Int # after: String # ): SnapshotConnection """ Get the revision with the given swhid """ revision( Sha1: String! ): Revision """ Get the release with the given swhid """ release( Sha1: String! ): Release """ Get the directory with the given swhid """ directory( Sha1: String! ): Directory """ Get the content with the given swhid """ content( SWHID: String! ): Content # """ # Search with the given swhid # """ # searchWithSwhid } diff --git a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py index 1db95f9..a30743a 100644 --- a/swh/graphql/tests/unit/resolvers/test_resolver_factory.py +++ b/swh/graphql/tests/unit/resolvers/test_resolver_factory.py @@ -1,54 +1,55 @@ import pytest from swh.graphql.resolvers import resolver_factory class TestFactory: @pytest.mark.parametrize( "input_type, expexted", [ ("origin", "OriginNode"), ("visit", "OriginVisitNode"), + ("latest-visit", "LatestVisitNode"), ("visit-snapshot", "VisitSnapshotNode"), ("snapshot", "SnapshotNode"), ("branch-revision", "TargetRevisionNode"), ("branch-release", "TargetReleaseNode"), ("revision", "RevisionNode"), ("revision-directory", "RevisionDirectoryNode"), ("release", "ReleaseNode"), ("release-revision", "TargetRevisionNode"), ("release-release", "TargetReleaseNode"), ("release-directory", "TargetDirectoryNode"), ("release-content", "TargetContentNode"), ("directory", "DirectoryNode"), ("content", "ContentNode"), ("dir-entry-dir", "TargetDirectoryNode"), ("dir-entry-file", "TargetContentNode"), ], ) def test_get_node_resolver(self, input_type, expexted): response = resolver_factory.get_node_resolver(input_type) assert response.__name__ == expexted def test_get_node_resolver_invalid_type(self): with pytest.raises(AttributeError): resolver_factory.get_node_resolver("invalid") @pytest.mark.parametrize( "input_type, expexted", [ ("origins", "OriginConnection"), ("origin-visits", "OriginVisitConnection"), ("visit-status", "VisitStatusConnection"), ("snapshot-branches", "SnapshotBranchConnection"), ("revision-parents", "ParentRevisionConnection"), ("directory-entries", "DirectoryEntryConnection"), ], ) def test_get_connection_resolver(self, input_type, expexted): response = resolver_factory.get_connection_resolver(input_type) assert response.__name__ == expexted def test_get_connection_resolver_invalid_type(self): with pytest.raises(AttributeError): resolver_factory.get_connection_resolver("invalid") diff --git a/swh/graphql/tests/unit/resolvers/test_resolvers.py b/swh/graphql/tests/unit/resolvers/test_resolvers.py index 48d474b..9ed2f27 100644 --- a/swh/graphql/tests/unit/resolvers/test_resolvers.py +++ b/swh/graphql/tests/unit/resolvers/test_resolvers.py @@ -1,108 +1,109 @@ 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.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.visitstatus_resolver, resolvers.visit_status.VisitStatusConnection), ( rs.snapshot_branches_resolver, resolvers.snapshot_branch.SnapshotBranchConnection, ), (rs.revision_parent_resolver, resolvers.revision.ParentRevisionConnection), ( rs.directory_entry_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), ], ) 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_unit_resolver(self, mocker): obj = mocker.Mock() obj.is_type_of.return_value = "test" assert rs.union_resolver(obj) == "test"