diff --git a/swh/graphql/app.py b/swh/graphql/app.py index 18be340..37e91b4 100644 --- a/swh/graphql/app.py +++ b/swh/graphql/app.py @@ -1,42 +1,43 @@ # 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 pkg_resources import os from pathlib import Path from ariadne import gql, load_schema_from_path, make_executable_schema from .resolvers import resolvers, scalars type_defs = gql( # pkg_resources.resource_string("swh.graphql", "schema/schema.graphql").decode() load_schema_from_path( os.path.join(Path(__file__).parent.resolve(), "schema", "schema.graphql") ) ) schema = make_executable_schema( type_defs, resolvers.query, resolvers.origin, resolvers.visit, resolvers.visit_status, resolvers.snapshot, resolvers.snapshot_branch, resolvers.revision, resolvers.release, resolvers.directory, resolvers.directory_entry, resolvers.search_result, resolvers.branch_target, resolvers.release_target, resolvers.directory_entry_target, resolvers.search_result_target, resolvers.binary_string, + resolvers.date, scalars.id_scalar, scalars.datetime_scalar, scalars.swhid_scalar, ) diff --git a/swh/graphql/resolvers/resolvers.py b/swh/graphql/resolvers/resolvers.py index 968ecbc..b3cd69c 100644 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -1,314 +1,336 @@ # 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 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("resolveSWHID") def search_swhid_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.search.ResolveSwhidConnection: return ConnectionObjectFactory.create("resolve-swhid", obj, info, **kw) @query.field("search") def search_resolver( obj: None, info: GraphQLResolveInfo, **kw ) -> rs.search.SearchConnection: return ConnectionObjectFactory.create("search", 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/resolvers/revision.py b/swh/graphql/resolvers/revision.py index fa5dad4..3799228 100644 --- a/swh/graphql/resolvers/revision.py +++ b/swh/graphql/resolvers/revision.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 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 .search import SearchResultNode 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 committerDate(self): # To support the schema naming convention + return self._node.committer_date + @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 """ _can_be_null = True obj: Union[ BaseSnapshotBranchNode, BaseReleaseNode, BaseDirectoryEntryNode, SearchResultNode, ] 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/resolvers/scalars.py b/swh/graphql/resolvers/scalars.py index 56c5009..e6a3baa 100644 --- a/swh/graphql/resolvers/scalars.py +++ b/swh/graphql/resolvers/scalars.py @@ -1,49 +1,44 @@ # 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 datetime import datetime +import datetime +from typing import Optional from ariadne import ScalarType from swh.graphql.errors import InvalidInputError from swh.graphql.utils import utils from swh.model.exceptions import ValidationError -from swh.model.model import TimestampWithTimezone from swh.model.swhids import CoreSWHID datetime_scalar = ScalarType("DateTime") swhid_scalar = ScalarType("SWHID") id_scalar = ScalarType("ID") @id_scalar.serializer def serialize_id(value) -> str: if type(value) is str: value = value.encode() return value.hex() @datetime_scalar.serializer -def serialize_datetime(value): - # FIXME, handle error and return None - if type(value) == TimestampWithTimezone: - value = value.to_datetime() - if type(value) == datetime: - return utils.get_formatted_date(value) - return None +def serialize_datetime(value: Optional[datetime.datetime]) -> Optional[str]: + return utils.get_formatted_date(value) if type(value) == datetime.datetime else None @swhid_scalar.value_parser def validate_swhid(value): try: swhid = CoreSWHID.from_string(value) except ValidationError as e: raise InvalidInputError("Invalid SWHID", e) return swhid @swhid_scalar.serializer def serialize_swhid(value): return str(value) diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index 1ad0a67..369bace 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1144 +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 """ author: Person """ Revision committer """ committer: Person """ - Revision date ISO-8601 encoded + Commit date """ - date: DateTime + committerDate: Date + + """ + Revision date + """ + date: Date """ Type of the revision, eg: git/hg """ type: String """ The unique directory object that revision points to """ directory: Directory """ Connection to all the parents of the revision """ parents( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): RevisionConnection """ Connection to all the revisions heading to this one aka the commit log """ revisionLog( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): RevisionConnection } """ Possible release target objects """ union ReleaseTarget = Release | Revision | Directory | Content """ Possible release target types """ enum ReleaseTargetType { release revision content directory } """ A release object """ type Release implements SWHNode & Node { """ Unique identifier """ id: ID! """ SWHID of the release object """ swhid: SWHID! """ The name of the release """ name: BinaryString """ The message associated to the release """ message: BinaryString """ Release author """ author: Person """ - Release date ISO-8601 encoded + Release date """ - date: DateTime + 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! ): 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 71367aa..dde4c6e 100644 --- a/swh/graphql/tests/data.py +++ b/swh/graphql/tests/data.py @@ -1,174 +1,190 @@ # 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 datetime from swh.model.model import ( Directory, DirectoryEntry, ObjectType, OriginVisitStatus, Release, Revision, RevisionType, ) from swh.model.tests import swh_model_data UTC = datetime.timezone.utc 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_visits(): return swh_model_data.ORIGIN_VISITS def get_visit_status(): return swh_model_data.ORIGIN_VISIT_STATUSES 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_revisions_with_none_date(): + return [ + Revision( + message=b"hello", + date=None, + 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, ), ) ) ] def get_directories_with_special_name_entries(): return [ Directory( entries=( DirectoryEntry( name="ßßétEÉt".encode(), perms=0o644, type="file", target=get_contents()[0].sha1_git, ), ) ) ] def get_visit_with_multiple_status(): return [ OriginVisitStatus( origin=get_origins()[0].url, date=datetime.datetime(2014, 5, 7, 4, 20, 39, 432222, tzinfo=UTC), visit=1, type="git", status="ongoing", snapshot=None, metadata=None, ) ] GRAPHQL_EXTRA_TEST_OBJECTS = { "release": get_releases_with_target(), - "revision": get_revisions_with_parents(), + "revision": get_revisions_with_parents() + get_revisions_with_none_date(), "directory": get_directories_with_nested_path() + get_directories_with_special_name_entries(), "origin_visit_status": get_visit_with_multiple_status(), } 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_release_node.py b/swh/graphql/tests/functional/test_release_node.py index 36532ee..4a8da76 100644 --- a/swh/graphql/tests/functional/test_release_node.py +++ b/swh/graphql/tests/functional/test_release_node.py @@ -1,182 +1,196 @@ # 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 { + 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()}, } if release.author else None, - "date": release.date.to_datetime().isoformat() if release.date else None, + "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 b8ce5c5..7d526f6 100644 --- a/swh/graphql/tests/functional/test_revision.py +++ b/swh/graphql/tests/functional/test_revision.py @@ -1,177 +1,236 @@ # 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_parents +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 { + 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()}, }, - "date": revision.date.to_datetime().isoformat(), + "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}} diff --git a/swh/graphql/tests/unit/resolvers/test_scalars.py b/swh/graphql/tests/unit/resolvers/test_scalars.py index b5a4e74..64491cc 100644 --- a/swh/graphql/tests/unit/resolvers/test_scalars.py +++ b/swh/graphql/tests/unit/resolvers/test_scalars.py @@ -1,34 +1,35 @@ # 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 datetime import pytest from swh.graphql.errors import InvalidInputError from swh.graphql.resolvers import scalars def test_serialize_id(): assert scalars.serialize_id("test") == "74657374" assert scalars.serialize_id(b"test") == "74657374" -def test_serialize_datetime(): - assert scalars.serialize_datetime("invalid") is None - # python datetime - date = datetime.datetime(2020, 5, 17) - assert scalars.serialize_datetime(date) == date.isoformat() - # FIXME, Timestamp with timezone - - def test_validate_swhid_invalid(): with pytest.raises(InvalidInputError): scalars.validate_swhid("invalid") def test_validate_swhid(): swhid = scalars.validate_swhid(f"swh:1:rev:{'1' * 40}") assert str(swhid) == "swh:1:rev:1111111111111111111111111111111111111111" + + +def test_serialize_datetime_from_datetime(): + dt = datetime.datetime(2010, 1, 15, 2, 12, 10, 2, datetime.timezone.utc) + assert scalars.serialize_datetime(dt) == "2010-01-15T02:12:10.000002+00:00" + + +def test_serialize_datetime_invalid_input(): + assert scalars.serialize_datetime("test") is None