Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/apigateway/helpers/datetime_helper.py
Original file line number Diff line number Diff line change
@@ -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)
23 changes: 16 additions & 7 deletions src/apigateway/resolvers/query/comment.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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")
Expand Down
45 changes: 32 additions & 13 deletions src/apigateway/resolvers/query/mod.py
Original file line number Diff line number Diff line change
@@ -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):
Expand All @@ -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(
Expand All @@ -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
]
28 changes: 28 additions & 0 deletions src/apigateway/resolvers/scalars.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions src/apigateway/schema/query/comment.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/apigateway/schema/query/mod.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ type Mod {
description: String!
version: Int!
status: ModStatus!
created_at: Int!
created_at: DateTime!
}

input getModDownloadLinkInput {
Expand Down
2 changes: 2 additions & 0 deletions src/apigateway/schema/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
scalar DateTime

type Mutation {
comment: CommentMutation
mod: ModMutation
Expand Down
2 changes: 2 additions & 0 deletions src/apigateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,6 +29,7 @@

schema = make_executable_schema(
type_defs,
datetime_scalar,
query,
comment_query,
mod_query,
Expand Down
50 changes: 50 additions & 0 deletions tests/helpers/test_datetime_helper.py
Original file line number Diff line number Diff line change
@@ -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]