diff --git a/swh/graphql/app.py b/swh/graphql/app.py index 622ce55..18be340 100644 --- a/swh/graphql/app.py +++ b/swh/graphql/app.py @@ -1,43 +1,42 @@ # 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, scalars.id_scalar, scalars.datetime_scalar, scalars.swhid_scalar, - scalars.content_hash_scalar, ) diff --git a/swh/graphql/resolvers/content.py b/swh/graphql/resolvers/content.py index 0292e12..ff1cacc 100644 --- a/swh/graphql/resolvers/content.py +++ b/swh/graphql/resolvers/content.py @@ -1,99 +1,111 @@ # 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.errors import InvalidInputError +from swh.model import hashutil + from .base_node import BaseSWHNode from .directory_entry import BaseDirectoryEntryNode from .release import BaseReleaseNode from .search import SearchResultNode from .snapshot_branch import BaseSnapshotBranchNode class BaseContentNode(BaseSWHNode): """ Base resolver for all the content nodes """ def _get_content_by_hashes(self, hashes: dict): content = self.archive.get_contents(hashes) # in case of a conflict, return the first element return content[0] if content else None @property def hashes(self): # FIXME, use a Node instead return {k: v.hex() for (k, v) in self._node.hashes().items()} @property def id(self): return self._node.sha1_git @property def data(self): # FIXME, return a Node object # FIXME, add more ways to retrieve data like binary string archive_url = "https://archive.softwareheritage.org/api/1/" content_sha1 = self._node.hashes()["sha1"] return { "url": f"{archive_url}content/sha1:{content_sha1.hex()}/raw/", } @property def mimeType(self): # FIXME, fetch data from the indexers return None @property def language(self): # FIXME, fetch data from the indexers return None @property def license(self): # FIXME, fetch data from the indexers return None def is_type_of(self): # is_type_of is required only when resolving a UNION type # This is for ariadne to return the right type return "Content" class ContentNode(BaseContentNode): """ Node resolver for a content requested directly with its SWHID """ def _get_node_data(self): hashes = {"sha1_git": self.kwargs.get("swhid").object_id} return self._get_content_by_hashes(hashes) class HashContentNode(BaseContentNode): """ Node resolver for a content requested with one or more hashes """ def _get_node_data(self): - hashes = dict(self.kwargs.get("hashes")) + try: + hashes = { + hash_type: hashutil.hash_to_bytes(hash_value) + for (hash_type, hash_value) in self.kwargs.items() + } + except ValueError as e: + # raise an input error in case of an invalid hash + raise InvalidInputError("Invalid content hash", e) + if not hashes: + raise InvalidInputError("At least one of the four hashes must be provided") return self._get_content_by_hashes(hashes) class TargetContentNode(BaseContentNode): """ Node resolver for a content requested as a target """ _can_be_null = True obj: Union[ SearchResultNode, BaseDirectoryEntryNode, BaseReleaseNode, BaseSnapshotBranchNode, ] def _get_node_data(self): return self._get_content_by_hashes(hashes={"sha1_git": self.obj.target_hash}) diff --git a/swh/graphql/resolvers/scalars.py b/swh/graphql/resolvers/scalars.py index 085cfa9..56c5009 100644 --- a/swh/graphql/resolvers/scalars.py +++ b/swh/graphql/resolvers/scalars.py @@ -1,63 +1,49 @@ # 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 from ariadne import ScalarType from swh.graphql.errors import InvalidInputError from swh.graphql.utils import utils -from swh.model import hashutil 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") -content_hash_scalar = ScalarType("ContentHash") @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 @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) - - -@content_hash_scalar.value_parser -def validate_content_hash(value): - try: - hash_type, hash_string = value.split(":") - hash_value = hashutil.hash_to_bytes(hash_string) - except ValueError as e: - raise InvalidInputError("Invalid content hash", e) - if hash_type not in hashutil.ALGORITHMS: - raise InvalidInputError("Invalid hash algorithm") - return hash_type, hash_value diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql index 78bf237..7fe9b66 100644 --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -1,1127 +1,1129 @@ """ SoftWare Heritage persistent Identifier """ scalar SWHID """ ISO-8601 encoded date string """ scalar DateTime -""" -Content identifier in the form hash-type:hash-value -""" -scalar ContentHash - """ Object with an id """ interface Node { """ Id of the object. This is for caching purpose and should not be used outside the GraphQL API """ id: ID! } """ SWH merkle node object with a SWHID """ interface 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 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 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 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 to request the next page after the item """ cursor: String! """ Branch object """ node: Branch } """ A user object """ type Person { """ User's email address """ email: BinaryString """ User's name """ name: BinaryString """ User's full name """ fullname: BinaryString } """ Possible branch target objects """ union BranchTarget = Revision | Release | Branch | Content | Directory | Snapshot """ Possible Branch target types """ enum BranchTargetType { revision release alias content directory snapshot } """ A snapshot branch object """ type Branch { """ Branch name """ name: BinaryString """ Type of Branch target """ targetType: BranchTargetType """ Branch target object """ target: BranchTarget } """ Connection to revisions """ type RevisionConnection { """ List of revision edges """ edges: [RevisionEdge] """ List of revision objects """ nodes: [Revision] """ Information for pagination """ pageInfo: PageInfo! """ Total number of revision objects in the connection """ totalCount: Int } """ Edge in revision connection """ type RevisionEdge { """ Cursor to request the next page after the item """ cursor: String! """ Revision object """ node: Revision } """ A revision object """ type Revision implements 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 """ date: DateTime """ Type of the revision, eg: git/hg """ type: String """ The unique directory object that revision points to """ directory: Directory """ Connection to all the parents of the revision """ parents( """ Returns the first _n_ elements from the list """ first: Int """ Returns the page after this cursor """ after: String ): RevisionConnection """ Connection to all the revisions heading to this one aka the commit log """ revisionLog( """ Returns the first _n_ elements from the list """ first: Int! """ Returns the page after the cursor """ after: String ): RevisionConnection } """ Possible release target objects """ union ReleaseTarget = Release | Revision | Directory | Content """ Possible release target types """ enum ReleaseTargetType { release revision content directory } """ A release object """ type Release implements 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 """ date: DateTime """ Type of release target """ targetType: ReleaseTargetType """ Release target object """ target: ReleaseTarget } """ Connection to directory entries """ type DirectoryEntryConnection { """ List of directory entry edges """ edges: [DirectoryEntryEdge] """ List of directory entry objects """ nodes: [DirectoryEntry] """ Information for pagination """ pageInfo: PageInfo! """ Total number of directory entry objects in the connection """ totalCount: Int } """ Edge in directory entry connection """ type DirectoryEntryEdge { """ Cursor to request the next page after the item """ cursor: String! """ Directory entry object """ node: DirectoryEntry } """ Possible directory entry target objects """ union DirectoryEntryTarget = Directory | Content | 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 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 the content by one or more hashes - Use multiple hashes for an accurate result + 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( - """ - List of hashType:hashValue strings - """ - hashes: [ContentHash]! + 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/functional/test_content.py b/swh/graphql/tests/functional/test_content.py index fcd66d0..71c384b 100644 --- a/swh/graphql/tests/functional/test_content.py +++ b/swh/graphql/tests/functional/test_content.py @@ -1,185 +1,222 @@ # Copyright (C) 2022 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import pytest from . import utils from ..data import get_contents @pytest.mark.parametrize("content", get_contents()) def test_get_content_with_swhid(client, content): query_str = """ query getContent($swhid: SWHID!) { content(swhid: $swhid) { swhid id hashes { blake2s256 sha1 sha1_git sha256 } length status data { url } mimeType { encoding } language { lang } license { licenses } } } """ data, _ = utils.get_query_response(client, query_str, swhid=str(content.swhid())) archive_url = "https://archive.softwareheritage.org/api/1/" response = { "swhid": str(content.swhid()), "id": content.sha1_git.hex(), "hashes": { "blake2s256": content.blake2s256.hex(), "sha1": content.sha1.hex(), "sha1_git": content.sha1_git.hex(), "sha256": content.sha256.hex(), }, "length": content.length, "status": content.status, "data": { "url": f"{archive_url}content/sha1:{content.sha1.hex()}/raw/", }, "mimeType": None, "language": None, "license": None, } assert data["content"] == response +def test_get_content_with_invalid_swhid(client): + query_str = """ + query getContent($swhid: SWHID!) { + content(swhid: $swhid) { + swhid + } + } + """ + errors = utils.get_error_response(client, query_str, swhid="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"] + + @pytest.mark.parametrize("content", get_contents()) def test_get_content_with_hash(client, content): query_str = """ - query getContent($hashes: [ContentHash]!) { - contentByHashes(hashes: $hashes) { + query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { + contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + blake2s256: $blake2s256) { swhid } } """ data, _ = utils.get_query_response( client, query_str, - hashes=[ - f"blake2s256:{content.blake2s256.hex()}", - f"sha1:{content.sha1.hex()}", - f"sha1_git:{content.sha1_git.hex()}", - f"sha256:{content.sha256.hex()}", - ], + sha1=content.sha1.hex(), + sha1_git=content.sha1_git.hex(), + sha256=content.sha256.hex(), + blake2s256=content.blake2s256.hex(), ) assert data["contentByHashes"] == {"swhid": str(content.swhid())} -def test_get_content_with_invalid_swhid(client): +@pytest.mark.parametrize("content", get_contents()) +def test_get_content_with_single_hash(client, content): query_str = """ - query getContent($swhid: SWHID!) { - content(swhid: $swhid) { + query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { + contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + blake2s256: $blake2s256) { swhid } } """ - errors = utils.get_error_response(client, query_str, swhid="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"] + data, _ = utils.get_query_response( + client, + query_str, + sha1=content.sha1.hex(), + ) + assert data["contentByHashes"] == {"swhid": str(content.swhid())} + + +@pytest.mark.parametrize("content", get_contents()) +def test_get_content_with_one_non_matching_hash(client, content): + query_str = """ + query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { + contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + blake2s256: $blake2s256) { + swhid + } + } + """ + utils.assert_missing_object( + client, + query_str, + obj_type="contentByHashes", + sha1=content.sha1.hex(), + sha1_git="a" * 20, # hash is valid, but not matching the object + ) def test_get_content_with_invalid_hashes(client): content = get_contents()[0] query_str = """ - query getContent($hashes: [ContentHash]!) { - contentByHashes(hashes: $hashes) { + query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { + contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + blake2s256: $blake2s256) { swhid } } """ errors = utils.get_error_response( client, query_str, - hashes=[ - "invalid", # Only one hash is invalid - f"sha1:{content.sha1.hex()}", - f"sha1_git:{content.sha1_git.hex()}", - f"sha256:{content.sha256.hex()}", - ], + sha1="invalid", # Only one hash is invalid + sha1_git=content.sha1_git.hex(), + sha256=content.sha256.hex(), ) # API will throw an error in case of an invalid content hash assert len(errors) == 1 assert "Input error: Invalid content hash" in errors[0]["message"] -def test_get_content_with_invalid_hash_algorithm(client): - content = get_contents()[0] +def test_get_content_with_no_hashes(client): query_str = """ - query getContent($hashes: [ContentHash]!) { - contentByHashes(hashes: $hashes) { + query getContent($sha1: String, $sha1_git: String, $sha256: String, $blake2s256: String) { + contentByHashes(sha1: $sha1, sha1_git: $sha1_git, sha256: $sha256, + blake2s256: $blake2s256) { swhid } } """ - data, errors = utils.get_query_response( - client, query_str, hashes=[f"test:{content.sha1.hex()}"] + errors = utils.get_error_response( + client, + query_str, ) - assert data is None assert len(errors) == 1 - assert "Input error: Invalid hash algorithm" in errors[0]["message"] + assert ( + "Input error: At least one of the four hashes must be provided" + in errors[0]["message"] + ) def test_get_content_as_target(client): # SWHID of a test dir with a file entry directory_swhid = "swh:1:dir:87b339104f7dc2a8163dec988445e3987995545f" query_str = """ query getDirectory($swhid: SWHID!) { directory(swhid: $swhid) { swhid entries(first: 2) { nodes { targetType target { ...on Content { swhid length } } } } } } """ data, _ = utils.get_query_response(client, query_str, swhid=directory_swhid) content_obj = data["directory"]["entries"]["nodes"][1]["target"] assert content_obj == { "length": 4, "swhid": "swh:1:cnt:86bc6b377e9d25f9d26777a4a28d08e63e7c5779", } def test_get_content_with_unknown_swhid(client): unknown_sha1 = "1" * 40 query_str = """ query getDirectory($swhid: SWHID!) { content(swhid: $swhid) { swhid } } """ utils.assert_missing_object( client, query_str, obj_type="content", swhid=f"swh:1:cnt:{unknown_sha1}", ) diff --git a/swh/graphql/tests/functional/utils.py b/swh/graphql/tests/functional/utils.py index 35d2ee3..bdb0d19 100644 --- a/swh/graphql/tests/functional/utils.py +++ b/swh/graphql/tests/functional/utils.py @@ -1,32 +1,31 @@ # 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 json from typing import Dict, Tuple from ariadne import gql def get_query_response(client, query_str: str, **kwargs) -> Tuple[Dict, Dict]: query = gql(query_str) response = client.post("/", json={"query": query, "variables": kwargs}) assert response.status_code == 200, response.data result = json.loads(response.data) return result.get("data"), result.get("errors") def assert_missing_object(client, query_str: str, obj_type: str, **kwargs) -> None: data, errors = get_query_response(client, query_str, **kwargs) assert data[obj_type] is None assert len(errors) == 1 assert errors[0]["message"] == "Object error: Requested object is not available" assert errors[0]["path"] == [obj_type] def get_error_response(client, query_str: str, **kwargs) -> Dict: data, errors = get_query_response(client, query_str, **kwargs) - assert data is None assert len(errors) > 0 return errors diff --git a/swh/graphql/tests/unit/resolvers/test_scalars.py b/swh/graphql/tests/unit/resolvers/test_scalars.py index 009bec4..b5a4e74 100644 --- a/swh/graphql/tests/unit/resolvers/test_scalars.py +++ b/swh/graphql/tests/unit/resolvers/test_scalars.py @@ -1,54 +1,34 @@ # 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" - - -@pytest.mark.parametrize("content_hash", ["invalid", "test:invalid"]) -def test_validate_content_hash_invalid_value(content_hash): - with pytest.raises(InvalidInputError) as e: - scalars.validate_content_hash(content_hash) - assert "Invalid content hash" in str(e.value) - - -def test_validate_content_hash_invalid_hash_algo(): - with pytest.raises(InvalidInputError) as e: - scalars.validate_content_hash(f"invalid:{'1' * 40}") - assert "Invalid hash algorithm" in str(e.value) - - -def test_validate_content_hash(): - assert ( - "sha1", - b"\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11", - ) == scalars.validate_content_hash(f"sha1:{'1' * 40}")