From ae941f32e7fdd78dbd20f77ac5c82ef197318e55 Mon Sep 17 00:00:00 2001 From: Andrey Kataev Date: Mon, 24 Nov 2025 17:06:14 +0300 Subject: [PATCH 1/3] =?UTF-8?q?Datetime=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE=20int?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apigateway/helpers/datetime_helper.py | 10 ++++++ src/apigateway/resolvers/query/comment.py | 20 ++++++----- src/apigateway/resolvers/query/mod.py | 40 ++++++++++++++------- src/apigateway/resolvers/scalars.py | 28 +++++++++++++++ src/apigateway/schema/query/comment.graphql | 4 +-- src/apigateway/schema/query/mod.graphql | 2 +- src/apigateway/schema/schema.graphql | 2 ++ src/apigateway/server.py | 2 ++ 8 files changed, 84 insertions(+), 24 deletions(-) create mode 100644 src/apigateway/helpers/datetime_helper.py create mode 100644 src/apigateway/resolvers/scalars.py diff --git a/src/apigateway/helpers/datetime_helper.py b/src/apigateway/helpers/datetime_helper.py new file mode 100644 index 0000000..13e8809 --- /dev/null +++ b/src/apigateway/helpers/datetime_helper.py @@ -0,0 +1,10 @@ +from datetime import UTC, datetime + +from google.protobuf.timestamp_pb2 import Timestamp + + +def timestamp_to_datetime(timestamp: Timestamp | datetime | None) -> datetime | None: + if timestamp.seconds == 0 and timestamp.nanos == 0: + return None + + return timestamp.ToDatetime(tzinfo=UTC) diff --git a/src/apigateway/resolvers/query/comment.py b/src/apigateway/resolvers/query/comment.py index adcdd9f..75ff564 100644 --- a/src/apigateway/resolvers/query/comment.py +++ b/src/apigateway/resolvers/query/comment.py @@ -1,12 +1,15 @@ +from datetime import datetime from typing import Any from ariadne import ObjectType from graphql import GraphQLResolveInfo from pydantic import BaseModel, field_validator +from apigateway.helpers.datetime_helper import timestamp_to_datetime from apigateway.helpers.id_helper import validate_and_convert_id +from apigateway.resolvers.grpc_error_wrapper import handle_grpc_errors -from ..grpc_error_wrapper import handle_grpc_errors +comment_query = ObjectType("CommentQuery") class GetCommentsInput(BaseModel): @@ -21,15 +24,16 @@ class GetCommentsResult(BaseModel): id: int text: str author_id: int - created_at: int - edited_at: int | None = None - - @field_validator("edited_at", mode="before") - def _edited_at(cls, v: Any) -> Any | None: - return None if v == 0 else v + created_at: datetime + edited_at: datetime | None = None + @field_validator("created_at", mode="before") + def _created_at(cls, value: Any) -> datetime: + return timestamp_to_datetime(value) -comment_query = ObjectType("CommentQuery") + @field_validator("edited_at", mode="before") + def _edited_at(cls, value: Any) -> datetime | None: + return timestamp_to_datetime(value) @comment_query.field("getComments") diff --git a/src/apigateway/resolvers/query/mod.py b/src/apigateway/resolvers/query/mod.py index 2fe6012..5f2c404 100644 --- a/src/apigateway/resolvers/query/mod.py +++ b/src/apigateway/resolvers/query/mod.py @@ -1,3 +1,4 @@ +from datetime import datetime from typing import Any from ariadne import ObjectType @@ -5,9 +6,11 @@ from pydantic import BaseModel, field_validator from apigateway.converters.mod_status_converter import proto_to_graphql_mod_status +from apigateway.helpers.datetime_helper import timestamp_to_datetime from apigateway.helpers.id_helper import validate_and_convert_id +from apigateway.resolvers.grpc_error_wrapper import handle_grpc_errors -from ..grpc_error_wrapper import handle_grpc_errors +mod_query = ObjectType("ModQuery") class GetModDownloadLinkInput(BaseModel): @@ -18,9 +21,6 @@ def _mod_id(cls, v: Any) -> int: return validate_and_convert_id(v, "mod_id") -mod_query = ObjectType("ModQuery") - - @mod_query.field("getModDownloadLink") @handle_grpc_errors async def resolve_get_mod_download_link( @@ -32,20 +32,34 @@ async def resolve_get_mod_download_link( return resp.link_url # type: ignore +class GetModsResult(BaseModel): + id: int + author_id: int + title: str + description: str + version: str + status: str + created_at: datetime + + @field_validator("created_at", mode="before") + def _created_at(cls, value: Any) -> datetime: + return timestamp_to_datetime(value) + + @mod_query.field("getMods") @handle_grpc_errors async def resolve_get_mods(parent: object, info: GraphQLResolveInfo) -> list[dict[str, Any]]: client = info.context["clients"]["mod_service"] resp = await client.get_mods() return [ - { - "id": item.id, - "author_id": item.author_id, - "title": item.title, - "description": item.description, - "version": item.version, - "status": proto_to_graphql_mod_status(item.status), - "created_at": int(item.created_at.seconds), - } + GetModsResult( + id=item.id, + author_id=item.author_id, + title=item.title, + description=item.description, + version=item.version, + status=proto_to_graphql_mod_status(item.status), + created_at=item.created_at, + ).model_dump() for item in resp.mods ] diff --git a/src/apigateway/resolvers/scalars.py b/src/apigateway/resolvers/scalars.py new file mode 100644 index 0000000..62c0e5c --- /dev/null +++ b/src/apigateway/resolvers/scalars.py @@ -0,0 +1,28 @@ +from datetime import UTC, datetime + +from ariadne import ScalarType + +datetime_scalar = ScalarType("DateTime") + + +@datetime_scalar.serializer +def serialize_datetime(value: datetime | str) -> str: + if isinstance(value, str): + return value + + if not isinstance(value, datetime): + raise TypeError("DateTime value must be datetime or ISO string") + + if value.tzinfo is None: + value = value.replace(tzinfo=UTC) + + return value.isoformat() + + +@datetime_scalar.value_parser +def parse_datetime_value(value: str) -> datetime: + if not isinstance(value, str): + raise TypeError("DateTime value must be a string") + + normalized = value if not value.endswith("Z") else value[:-1] + "+00:00" + return datetime.fromisoformat(normalized) diff --git a/src/apigateway/schema/query/comment.graphql b/src/apigateway/schema/query/comment.graphql index 5f92ba7..2d1419c 100644 --- a/src/apigateway/schema/query/comment.graphql +++ b/src/apigateway/schema/query/comment.graphql @@ -6,8 +6,8 @@ type Comment { id: ID! author_id: ID! text: String! - created_at: Int! - edited_at: Int + created_at: DateTime! + edited_at: DateTime } input GetCommentsInput { diff --git a/src/apigateway/schema/query/mod.graphql b/src/apigateway/schema/query/mod.graphql index b3f161d..ead3b37 100644 --- a/src/apigateway/schema/query/mod.graphql +++ b/src/apigateway/schema/query/mod.graphql @@ -17,7 +17,7 @@ type Mod { description: String! version: Int! status: ModStatus! - created_at: Int! + created_at: DateTime! } input getModDownloadLinkInput { diff --git a/src/apigateway/schema/schema.graphql b/src/apigateway/schema/schema.graphql index 662541b..62f2847 100644 --- a/src/apigateway/schema/schema.graphql +++ b/src/apigateway/schema/schema.graphql @@ -1,3 +1,5 @@ +scalar DateTime + type Mutation { comment: CommentMutation mod: ModMutation diff --git a/src/apigateway/server.py b/src/apigateway/server.py index 00c4c19..95d8747 100644 --- a/src/apigateway/server.py +++ b/src/apigateway/server.py @@ -15,6 +15,7 @@ from apigateway.resolvers.query.mod import mod_query from apigateway.resolvers.query.rating import rating_query from apigateway.resolvers.query.root import query +from apigateway.resolvers.scalars import datetime_scalar from apigateway.settings import Settings from .esclient_graphql import GQLContextViewer @@ -28,6 +29,7 @@ schema = make_executable_schema( type_defs, + datetime_scalar, query, comment_query, mod_query, From ca1afa0bafc46f5b0c3c56515fa2d6e13c72a940 Mon Sep 17 00:00:00 2001 From: Andrey Kataev Date: Mon, 24 Nov 2025 17:27:11 +0300 Subject: [PATCH 2/3] =?UTF-8?q?=D0=A2=D0=B5=D1=81=D1=82=20=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apigateway/helpers/datetime_helper.py | 2 +- src/apigateway/resolvers/query/comment.py | 11 ++++-- src/apigateway/resolvers/query/mod.py | 9 +++-- tests/helpers/test_datetime_helper.py | 41 +++++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 tests/helpers/test_datetime_helper.py diff --git a/src/apigateway/helpers/datetime_helper.py b/src/apigateway/helpers/datetime_helper.py index 13e8809..c90827d 100644 --- a/src/apigateway/helpers/datetime_helper.py +++ b/src/apigateway/helpers/datetime_helper.py @@ -3,7 +3,7 @@ from google.protobuf.timestamp_pb2 import Timestamp -def timestamp_to_datetime(timestamp: Timestamp | datetime | None) -> datetime | None: +def timestamp_to_datetime(timestamp: Timestamp) -> datetime | None: if timestamp.seconds == 0 and timestamp.nanos == 0: return None diff --git a/src/apigateway/resolvers/query/comment.py b/src/apigateway/resolvers/query/comment.py index 75ff564..0f300b7 100644 --- a/src/apigateway/resolvers/query/comment.py +++ b/src/apigateway/resolvers/query/comment.py @@ -2,6 +2,7 @@ from typing import Any from ariadne import ObjectType +from google.protobuf.timestamp_pb2 import Timestamp from graphql import GraphQLResolveInfo from pydantic import BaseModel, field_validator @@ -28,11 +29,15 @@ class GetCommentsResult(BaseModel): edited_at: datetime | None = None @field_validator("created_at", mode="before") - def _created_at(cls, value: Any) -> datetime: - return timestamp_to_datetime(value) + def _created_at(cls, value: Timestamp) -> datetime: + created_at = timestamp_to_datetime(value) + if created_at is None: + raise ValueError("created_at is missing") + + return created_at @field_validator("edited_at", mode="before") - def _edited_at(cls, value: Any) -> datetime | None: + def _edited_at(cls, value: Timestamp) -> datetime | None: return timestamp_to_datetime(value) diff --git a/src/apigateway/resolvers/query/mod.py b/src/apigateway/resolvers/query/mod.py index 5f2c404..087b35d 100644 --- a/src/apigateway/resolvers/query/mod.py +++ b/src/apigateway/resolvers/query/mod.py @@ -2,6 +2,7 @@ from typing import Any from ariadne import ObjectType +from google.protobuf.timestamp_pb2 import Timestamp from graphql import GraphQLResolveInfo from pydantic import BaseModel, field_validator @@ -42,8 +43,12 @@ class GetModsResult(BaseModel): created_at: datetime @field_validator("created_at", mode="before") - def _created_at(cls, value: Any) -> datetime: - return timestamp_to_datetime(value) + def _created_at(cls, value: Timestamp) -> datetime: + created_at = timestamp_to_datetime(value) + if created_at is None: + raise ValueError("created_at is missing") + + return created_at @mod_query.field("getMods") diff --git a/tests/helpers/test_datetime_helper.py b/tests/helpers/test_datetime_helper.py new file mode 100644 index 0000000..70a8c99 --- /dev/null +++ b/tests/helpers/test_datetime_helper.py @@ -0,0 +1,41 @@ +from datetime import UTC, datetime + +import pytest +from google.protobuf.timestamp_pb2 import Timestamp + +from apigateway.helpers.datetime_helper import timestamp_to_datetime + + +def test_returns_none_for_none() -> None: + assert timestamp_to_datetime(None) is None + + +def test_returns_none_for_empty_timestamp() -> None: + assert timestamp_to_datetime(Timestamp()) is None + + +def test_converts_timestamp_to_datetime_with_utc() -> None: + dt = datetime(2024, 5, 6, 7, 8, 9, tzinfo=UTC) + ts = Timestamp() + ts.FromDatetime(dt) + + assert timestamp_to_datetime(ts) == dt + + +def test_naive_datetime_gets_utc_attached() -> None: + naive_dt = datetime(2024, 5, 6, 7, 8, 9) + + result = timestamp_to_datetime(naive_dt) + + assert result == naive_dt.replace(tzinfo=UTC) + + +def test_timezone_aware_datetime_passthrough() -> None: + aware_dt = datetime(2024, 5, 6, 7, 8, 9, tzinfo=UTC) + + assert timestamp_to_datetime(aware_dt) is aware_dt + + +def test_raises_for_unsupported_type() -> None: + with pytest.raises(TypeError): + timestamp_to_datetime("2024-05-06T07:08:09Z") # type: ignore[arg-type] From 8421b855860fd6d7550d4ba0a18454a6f744d409 Mon Sep 17 00:00:00 2001 From: Andrey Kataev Date: Mon, 24 Nov 2025 17:34:51 +0300 Subject: [PATCH 3/3] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/helpers/test_datetime_helper.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/helpers/test_datetime_helper.py b/tests/helpers/test_datetime_helper.py index 70a8c99..da820ff 100644 --- a/tests/helpers/test_datetime_helper.py +++ b/tests/helpers/test_datetime_helper.py @@ -6,8 +6,9 @@ from apigateway.helpers.datetime_helper import timestamp_to_datetime -def test_returns_none_for_none() -> None: - assert timestamp_to_datetime(None) is None +def test_raises_for_none_input() -> None: + with pytest.raises(AttributeError): + timestamp_to_datetime(None) # type: ignore[arg-type] def test_returns_none_for_empty_timestamp() -> None: @@ -24,18 +25,26 @@ def test_converts_timestamp_to_datetime_with_utc() -> None: def test_naive_datetime_gets_utc_attached() -> None: naive_dt = datetime(2024, 5, 6, 7, 8, 9) + ts = Timestamp() + ts.FromDatetime(naive_dt) - result = timestamp_to_datetime(naive_dt) + result = timestamp_to_datetime(ts) assert result == naive_dt.replace(tzinfo=UTC) + assert result.tzinfo is UTC def test_timezone_aware_datetime_passthrough() -> None: aware_dt = datetime(2024, 5, 6, 7, 8, 9, tzinfo=UTC) + ts = Timestamp() + ts.FromDatetime(aware_dt) + + result = timestamp_to_datetime(ts) - assert timestamp_to_datetime(aware_dt) is aware_dt + assert result == aware_dt + assert result.tzinfo is UTC def test_raises_for_unsupported_type() -> None: - with pytest.raises(TypeError): + with pytest.raises(AttributeError): timestamp_to_datetime("2024-05-06T07:08:09Z") # type: ignore[arg-type]