diff --git a/swh/graphql/resolvers/person.py b/swh/graphql/resolvers/person.py index 0707230..11265f0 100644 --- a/swh/graphql/resolvers/person.py +++ b/swh/graphql/resolvers/person.py @@ -1,4 +1,74 @@ # 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 List + +from .base_connection import BaseList +from .base_node import BaseNode +from .release import BaseReleaseNode +from .revision import BaseRevisionNode + + +class Person(BaseNode): + pass + + +def get_person_list(person) -> List[Person]: + return [person] if person else [] + + +class RevisionAuthorList(BaseList): + """ + List of revision authors + """ + + obj: BaseRevisionNode + + _node_class = Person + + def _get_results(self) -> List[Person]: + """ + Author is a single object in the current data model, + return it as a list to support future evolutions + + No backend fetching required as the data exists in + the parent object (revision) + """ + + return get_person_list(self.obj.author) + + +class RevisionCommitterList(BaseList): + obj: BaseRevisionNode + + _node_class = Person + + def _get_results(self) -> List[Person]: + """ + Committer is a single object in the current data model, + return it as a list to support future evolutions + + No backend fetching required as the data exists in + the parent object (revision) + """ + + return get_person_list(self.obj.committer) + + +class ReleaseAuthorList(BaseList): + obj: BaseReleaseNode + + _node_class = Person + + def _get_results(self) -> List[Person]: + """ + Author is a single object in the current data model, + return it as a list to support future evolutions + + No backend fetching required as the data exists in + the parent object (release) + """ + + return get_person_list(self.obj.author) diff --git a/swh/graphql/resolvers/resolver_factory.py b/swh/graphql/resolvers/resolver_factory.py index abb2595..1b2efb6 100644 --- a/swh/graphql/resolvers/resolver_factory.py +++ b/swh/graphql/resolvers/resolver_factory.py @@ -1,117 +1,121 @@ # 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 .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, "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, } @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 638bbdc..12b3c4a 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,343 +1,364 @@ # 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( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.content.ContentNode: return NodeObjectFactory.create("content-by-hash", 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("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 87233ed..892a2d4 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1164 +1,1164 @@ """ 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 author + Revision authors """ - author: Person + author: [Person] """ - Revision committer + Revision committers """ - committer: Person + 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 + 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 """ content( """ SWHID of the content object """ swhid: SWHID! ): 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. """ 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_release_node.py b/swh/graphql/tests/functional/test_release_node.py index 4a8da76..ad8817e 100644 --- a/swh/graphql/tests/functional/test_release_node.py +++ b/swh/graphql/tests/functional/test_release_node.py @@ -1,196 +1,198 @@ # 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 base64 import pytest from swh.model.model import ObjectType from . import utils from ..data import ( get_contents, get_directories, get_releases, get_releases_with_target, get_revisions, ) @pytest.mark.parametrize("release", get_releases()) def test_get_release(client, release): query_str = """ query getRelease($swhid: SWHID!) { release(swhid: $swhid) { swhid name { text base64 } message { text } author { email { text } name { text } fullname { text } } date { date offset { text base64 } } targetType } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(release.swhid())) assert data["release"] == { "swhid": str(release.swhid()), "name": { "text": release.name.decode(), "base64": base64.b64encode(release.name).decode("ascii"), }, "message": {"text": release.message.decode()}, - "author": { - "email": {"text": release.author.email.decode()}, - "name": {"text": release.author.name.decode()}, - "fullname": {"text": release.author.fullname.decode()}, - } + "author": [ + { + "email": {"text": release.author.email.decode()}, + "name": {"text": release.author.name.decode()}, + "fullname": {"text": release.author.fullname.decode()}, + } + ] if release.author - else None, + else [], "date": { "date": release.date.to_datetime().isoformat(), "offset": { "text": release.date.offset_bytes.decode(), "base64": base64.b64encode(release.date.offset_bytes).decode("ascii"), }, } if release.date else None, "targetType": release.target_type.value, } def test_get_release_with_invalid_swhid(client): query_str = """ query getRelease($swhid: SWHID!) { release(swhid: $swhid) { swhid } } """ errors = utils.get_error_response(client, query_str, swhid="swh:1:rel:invalid") # API will throw an error in case of an invalid SWHID assert len(errors) == 1 assert "Expected type 'SWHID'. Input error: Invalid SWHID" in errors[0]["message"] @pytest.mark.parametrize("release_with_target", get_releases_with_target()) def test_get_release_targets(client, release_with_target): query_str = """ query getRelease($swhid: SWHID!) { release(swhid: $swhid) { targetType target { ...on Revision { swhid } ...on Release { swhid } ...on Directory { swhid } ...on Content { swhid } } } } """ data, _ = utils.get_query_response( client, query_str, swhid=str(release_with_target.swhid()) ) if release_with_target.target_type == ObjectType.REVISION: target_swhid = get_revisions()[0].swhid() elif release_with_target.target_type == ObjectType.RELEASE: target_swhid = get_releases()[0].swhid() elif release_with_target.target_type == ObjectType.DIRECTORY: target_swhid = get_directories()[0].swhid() elif release_with_target.target_type == ObjectType.CONTENT: target_swhid = get_contents()[0].swhid() assert data["release"] == { "targetType": release_with_target.target_type.value, "target": {"swhid": str(target_swhid)}, } def test_get_release_target_unknown(client): # Clinet can request all the possible options if the target type # is unknown. The data under the right type will be returned # The target is of type Revision in this case # ie: both swhid and message will be available in the response swhid = get_releases_with_target()[0].swhid() query_str = """ query getRelease($swhid: SWHID!) { release(swhid: $swhid) { targetType target { ...on Revision { swhid message { text } } ...on Release { swhid } ...on Directory { swhid } ...on Content { swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(swhid)) assert data["release"] == { "target": { "message": {"text": "hello"}, "swhid": str(get_revisions()[0].swhid()), }, "targetType": "revision", } def test_get_release_with_unknown_swhid(client): unknown_sha1 = "1" * 40 query_str = """ query getRelease($swhid: SWHID!) { release(swhid: $swhid) { swhid } } """ utils.assert_missing_object( client, query_str, obj_type="release", swhid=f"swh:1:rel:{unknown_sha1}", ) diff --git a/swh/graphql/tests/functional/test_revision.py b/swh/graphql/tests/functional/test_revision.py index 7d526f6..112993d 100644 --- a/swh/graphql/tests/functional/test_revision.py +++ b/swh/graphql/tests/functional/test_revision.py @@ -1,236 +1,240 @@ # 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 base64 import pytest from swh.model.swhids import CoreSWHID from . import utils from ..data import ( get_revisions, get_revisions_with_none_date, get_revisions_with_parents, ) @pytest.mark.parametrize("revision", get_revisions()) def test_get_revision(client, revision): query_str = """ query getRevision($swhid: SWHID!) { revision(swhid: $swhid) { swhid message { text } author { fullname { text } name { text } email { text } } committer { fullname { text } name { text } email { text } } date { date offset { text base64 } } committerDate { date offset { text base64 } } type directory { swhid } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(revision.swhid())) assert data["revision"] == { "swhid": str(revision.swhid()), "message": {"text": revision.message.decode()}, - "author": { - "fullname": {"text": revision.author.fullname.decode()}, - "name": {"text": revision.author.name.decode()}, - "email": {"text": revision.author.email.decode()}, - }, - "committer": { - "fullname": {"text": revision.committer.fullname.decode()}, - "name": {"text": revision.committer.name.decode()}, - "email": {"text": revision.committer.email.decode()}, - }, + "author": [ + { + "fullname": {"text": revision.author.fullname.decode()}, + "name": {"text": revision.author.name.decode()}, + "email": {"text": revision.author.email.decode()}, + } + ], + "committer": [ + { + "fullname": {"text": revision.committer.fullname.decode()}, + "name": {"text": revision.committer.name.decode()}, + "email": {"text": revision.committer.email.decode()}, + } + ], "date": { "date": revision.date.to_datetime().isoformat(), "offset": { "text": revision.date.offset_bytes.decode(), "base64": base64.b64encode(revision.date.offset_bytes).decode("ascii"), }, } if revision.date else None, "committerDate": { "date": revision.committer_date.to_datetime().isoformat(), "offset": { "text": revision.committer_date.offset_bytes.decode(), "base64": base64.b64encode(revision.committer_date.offset_bytes).decode( "ascii" ), }, } if revision.committer_date else None, "type": revision.type.value, "directory": { "swhid": str(CoreSWHID(object_id=revision.directory, object_type="dir")) }, } def test_get_revision_with_invalid_swhid(client): query_str = """ query getRevision($swhid: SWHID!) { revision(swhid: $swhid) { swhid } } """ errors = utils.get_error_response(client, query_str, swhid="swh:1:cnt: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_revision_as_target(client): # SWHID of a snapshot with revision as target snapshot_swhid = "swh:1:snp:9e78d7105c5e0f886487511e2a92377b4ee4c32a" query_str = """ query getSnapshot($swhid: SWHID!) { snapshot(swhid: $swhid) { branches(first: 1, types: [revision]) { nodes { targetType target { ...on Revision { swhid } } } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=snapshot_swhid) revision_obj = data["snapshot"]["branches"]["nodes"][0]["target"] assert revision_obj == { "swhid": "swh:1:rev:66c7c1cd9673275037140f2abff7b7b11fc9439c" } def test_get_revision_log(client): revision_swhid = get_revisions_with_parents()[0].swhid() query_str = """ query getRevision($swhid: SWHID!) { revision(swhid: $swhid) { swhid revisionLog(first: 3) { nodes { swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(revision_swhid)) assert data["revision"]["revisionLog"] == { "nodes": [ {"swhid": str(revision_swhid)}, {"swhid": str(get_revisions()[0].swhid())}, {"swhid": str(get_revisions()[1].swhid())}, ] } def test_get_revision_parents(client): revision_swhid = get_revisions_with_parents()[0].swhid() query_str = """ query getRevision($swhid: SWHID!) { revision(swhid: $swhid) { swhid parents { nodes { swhid } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(revision_swhid)) assert data["revision"]["parents"] == { "nodes": [ {"swhid": str(get_revisions()[0].swhid())}, {"swhid": str(get_revisions()[1].swhid())}, ] } def test_get_revision_with_unknown_swhid(client): unknown_sha1 = "1" * 40 query_str = """ query getRevision($swhid: SWHID!) { revision(swhid: $swhid) { swhid } } """ utils.assert_missing_object( client, query_str, obj_type="revision", swhid=f"swh:1:rev:{unknown_sha1}", ) def test_get_revisions_with_none_date(client): revision_swhid = get_revisions_with_none_date()[0].swhid() query_str = """ query getRevision($swhid: SWHID!) { revision(swhid: $swhid) { swhid date { date offset { text base64 } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(revision_swhid)) assert data == {"revision": {"swhid": str(revision_swhid), "date": None}}