diff --git a/src/apigateway/helpers/datetime_helper.py b/src/apigateway/helpers/datetime_helper.py new file mode 100644 index 0000000..c90827d --- /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: + 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..0f300b7 100644 --- a/src/apigateway/resolvers/query/comment.py +++ b/src/apigateway/resolvers/query/comment.py @@ -1,12 +1,16 @@ +from datetime import datetime 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 +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 +25,20 @@ class GetCommentsResult(BaseModel): id: int text: str author_id: int - created_at: int - edited_at: int | None = None + created_at: datetime + edited_at: datetime | None = None - @field_validator("edited_at", mode="before") - def _edited_at(cls, v: Any) -> Any | None: - return None if v == 0 else v + @field_validator("created_at", mode="before") + 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 -comment_query = ObjectType("CommentQuery") + @field_validator("edited_at", mode="before") + def _edited_at(cls, value: Timestamp) -> 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..087b35d 100644 --- a/src/apigateway/resolvers/query/mod.py +++ b/src/apigateway/resolvers/query/mod.py @@ -1,13 +1,17 @@ +from datetime import datetime 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 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 +22,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 +33,38 @@ 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: 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") @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, diff --git a/tests/helpers/test_datetime_helper.py b/tests/helpers/test_datetime_helper.py new file mode 100644 index 0000000..da820ff --- /dev/null +++ b/tests/helpers/test_datetime_helper.py @@ -0,0 +1,50 @@ +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_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: + 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) + ts = Timestamp() + ts.FromDatetime(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 result == aware_dt + assert result.tzinfo is UTC + + +def test_raises_for_unsupported_type() -> None: + with pytest.raises(AttributeError): + timestamp_to_datetime("2024-05-06T07:08:09Z") # type: ignore[arg-type]