diff --git a/swh/graphql/app.py b/swh/graphql/app.py --- a/swh/graphql/app.py +++ b/swh/graphql/app.py @@ -36,6 +36,7 @@ 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 --- a/swh/graphql/resolvers/resolvers.py +++ b/swh/graphql/resolvers/resolvers.py @@ -16,11 +16,15 @@ # 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 get_connection_resolver, get_node_resolver @@ -36,6 +40,7 @@ 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") @@ -297,3 +302,33 @@ @binary_string.field("base64") def binary_string_base64_resolver(obj, *args, **kw): return utils.get_b64_string(obj) + + +@date.field("date") +def date_info_date_resolver( + obj: Optional[Union[datetime.datetime, TimestampWithTimezone]], + *args: GraphQLResolveInfo, + **kw, +) -> Optional[Union[datetime.datetime, TimestampWithTimezone]]: + if obj is not None: + # This will be serialised as a DateTime Scalar + return obj + return None + + +@date.field("timestamp") +def date_info_micro_seconds_resolver( + obj: Optional[TimestampWithTimezone], *args: GraphQLResolveInfo, **kw +) -> Optional[float]: + if obj is not None: + return obj.timestamp.seconds + (obj.timestamp.microseconds / (10**6)) + return None + + +@date.field("offset") +def date_info_offset_resolver( + obj: Optional[TimestampWithTimezone], *args: GraphQLResolveInfo, **kw +) -> Optional[bytes]: + if obj is not None: + return obj.offset_bytes + return None diff --git a/swh/graphql/resolvers/scalars.py b/swh/graphql/resolvers/scalars.py --- a/swh/graphql/resolvers/scalars.py +++ b/swh/graphql/resolvers/scalars.py @@ -3,7 +3,8 @@ # 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, Union from ariadne import ScalarType @@ -28,13 +29,16 @@ @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[Union[datetime.datetime, TimestampWithTimezone]] +) -> Optional[str]: + if isinstance(value, TimestampWithTimezone): + try: + value = value.to_datetime() + except ValueError: + # Return None in case of an error + pass + return utils.get_formatted_date(value) if type(value) == datetime.datetime else None @swhid_scalar.value_parser diff --git a/swh/graphql/schema/schema.graphql b/swh/graphql/schema/schema.graphql --- a/swh/graphql/schema/schema.graphql +++ b/swh/graphql/schema/schema.graphql @@ -522,6 +522,28 @@ node: Revision } +""" +Object with Date information +""" +type Date { + """ + ISO-8601 encoded date. This can be null in case of dates with extreme values, + but timestamp fields (timestamp and offset) could still contain the real values. + So, they are not guaranteed to agree with each other always. + """ + date: DateTime + + """ + Seconds in the timestamp + """ + timestamp: Float + + """ + UTC offset + """ + offset: Int +} + """ A revision object """ @@ -550,9 +572,9 @@ committer: Person """ - Revision date ISO-8601 encoded + Revision date """ - date: DateTime + date: Date """ Type of the revision, eg: git/hg @@ -640,9 +662,9 @@ author: Person """ - Release date ISO-8601 encoded + Release date """ - date: DateTime + date: Date """ Type of release target diff --git a/swh/graphql/tests/functional/test_release_node.py b/swh/graphql/tests/functional/test_release_node.py --- a/swh/graphql/tests/functional/test_release_node.py +++ b/swh/graphql/tests/functional/test_release_node.py @@ -44,7 +44,9 @@ text } } - date + date { + date + } targetType } } @@ -67,7 +69,9 @@ } if release.author else None, - "date": release.date.to_datetime().isoformat() if release.date else None, + "date": {"date": release.date.to_datetime().isoformat()} + if release.date + else None, "targetType": release.target_type.value, } diff --git a/swh/graphql/tests/functional/test_revision.py b/swh/graphql/tests/functional/test_revision.py --- a/swh/graphql/tests/functional/test_revision.py +++ b/swh/graphql/tests/functional/test_revision.py @@ -42,7 +42,11 @@ text } } - date + date { + date + timestamp + offset + } type directory { swhid @@ -51,6 +55,7 @@ } """ data, _ = utils.get_query_response(client, query_str % revision.swhid()) + assert data["revision"] == { "swhid": str(revision.swhid()), "message": {"text": revision.message.decode()}, @@ -64,7 +69,12 @@ "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(), + "timestamp": revision.date.timestamp.seconds + + revision.date.timestamp.microseconds / (10**6), + "offset": int(revision.date.offset_bytes), + }, "type": revision.type.value, "directory": { "swhid": str(CoreSWHID(object_id=revision.directory, object_type="dir")) diff --git a/swh/graphql/tests/unit/resolvers/test_scalars.py b/swh/graphql/tests/unit/resolvers/test_scalars.py new file mode 100644 --- /dev/null +++ b/swh/graphql/tests/unit/resolvers/test_scalars.py @@ -0,0 +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 + +from swh.graphql.resolvers import scalars +from swh.model.model import Timestamp, TimestampWithTimezone + + +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_from_timestamp(): + timestamp = Timestamp(seconds=-25444, microseconds=322322) + dt = TimestampWithTimezone.from_numeric_offset( + timestamp, offset=-264, negative_utc=True + ) + assert scalars.serialize_datetime(dt) == "1969-12-31T12:31:56.322322-04:24" + + +def test_serialize_datetime_from_too_big_timestamp(): + # date is too large for the python datetime + timestamp = Timestamp(seconds=254443232223, microseconds=3) + dt = TimestampWithTimezone.from_numeric_offset( + timestamp, offset=-264, negative_utc=True + ) + assert scalars.serialize_datetime(dt) is None + + +def test_serialize_datetime_invalid_input(): + assert scalars.serialize_datetime("test") is None