From 60299af9235dc5815567087bb6be523bdcc591d7 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Mon, 19 Oct 2026 18:17:53 -0400 Subject: [PATCH] fix: Serialize datetime values as UTC with a Z suffix The API only accepts the `Z` form for date-time parameters, so `range_start=datetime.now(UTC).isoformat()` was rejected with a 400 (#743). Generated resources type these params as `str` and forward them untouched, which leaves callers to discover the exact format by trial and error. Accepting `datetime` objects in query strings and JSON bodies gives callers a path that cannot produce a rejected timestamp. Aware values are converted to UTC first and naive values are assumed to be UTC, matching the API contract and the legacy `utcnow()` habit. Strings still pass through untouched. Refs #743 --- src/workos/_base_client.py | 18 ++++++- src/workos/_types.py | 14 ++++-- tests/test_datetime_serialization.py | 71 ++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 tests/test_datetime_serialization.py diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index b12230821..ec98fc43d 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -14,6 +14,7 @@ from typing import Any, Dict, List, Optional, Sequence, Type, Union, cast, overload from urllib.parse import quote, urlencode +from ._types import _format_datetime from ._errors import ( APIError, WorkOSError, @@ -244,6 +245,8 @@ def _query_value(value: Any) -> str: return "false" if value is None: return "" + if isinstance(value, datetime): + return _format_datetime(value) return str(value) @staticmethod @@ -273,9 +276,22 @@ def _encode_body(body: Optional[Dict[str, Any]]) -> Optional[bytes]: if body is None: return None return json.dumps( - body, ensure_ascii=False, separators=(",", ":"), allow_nan=False + body, + ensure_ascii=False, + separators=(",", ":"), + allow_nan=False, + default=_BaseWorkOSClient._json_default, ).encode("utf-8") + @staticmethod + def _json_default(value: Any) -> str: + """Serialize values ``json.dumps`` does not handle natively.""" + if isinstance(value, datetime): + return _format_datetime(value) + raise TypeError( + f"Object of type {type(value).__name__} is not JSON serializable" + ) + def _build_request_url( self, path: Sequence[str], diff --git a/src/workos/_types.py b/src/workos/_types.py index ece7b179e..50cd28070 100644 --- a/src/workos/_types.py +++ b/src/workos/_types.py @@ -3,7 +3,7 @@ from __future__ import annotations import sys -from datetime import datetime +from datetime import datetime, timezone from enum import Enum from typing import Any, Dict, Literal, NoReturn, Protocol, TypedDict, TypeVar @@ -81,9 +81,15 @@ def _raise_deserialize_error(cls_name: str, error: Exception) -> NoReturn: def _format_datetime(dt: datetime) -> str: - """Format a datetime as an ISO 8601 string with 'Z' suffix for UTC. + """Format a datetime as an ISO 8601 UTC string with a 'Z' suffix. - Inverse of _parse_datetime: produces millisecond-precision ISO strings - with '+00:00' replaced by 'Z' for consistency with the API. + Inverse of _parse_datetime: produces millisecond-precision ISO strings. + The API only accepts the 'Z' form, so this is the single serialization + path for every outgoing datetime. Aware datetimes are converted to UTC + first. Naive datetimes are assumed to already be UTC. """ + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) return dt.isoformat(timespec="milliseconds").replace("+00:00", "Z") diff --git a/tests/test_datetime_serialization.py b/tests/test_datetime_serialization.py new file mode 100644 index 000000000..c002ad644 --- /dev/null +++ b/tests/test_datetime_serialization.py @@ -0,0 +1,71 @@ +"""Outgoing datetimes serialize as millisecond-precision UTC with a Z suffix. + +The API accepts only the Z form for date-time parameters, so every path that +sends a datetime (query strings, JSON bodies, model ``to_dict``) must agree. +""" + +from __future__ import annotations + +import json +from datetime import datetime, timedelta, timezone + +import pytest + +from workos._base_client import _BaseWorkOSClient +from workos._types import _format_datetime + +UTC_AWARE = datetime(2026, 10, 1, 18, 1, 14, 870587, tzinfo=timezone.utc) +EASTERN = datetime(2026, 10, 1, 14, 1, 14, 870587, tzinfo=timezone(timedelta(hours=-4))) +NAIVE = datetime(2026, 10, 1, 18, 1, 14, 870587) +EXPECTED = "2026-10-01T18:01:14.870Z" + + +class TestFormatDatetime: + @pytest.mark.parametrize("value", [UTC_AWARE, EASTERN, NAIVE]) + def test_normalizes_to_utc_z(self, value: datetime) -> None: + assert _format_datetime(value) == EXPECTED + + def test_never_emits_an_offset(self) -> None: + # datetime.isoformat() would produce "+00:00" here; the API rejects it. + assert "+00:00" not in _format_datetime(UTC_AWARE) + assert "-04:00" not in _format_datetime(EASTERN) + + +class TestQueryEncoding: + def test_datetime_values(self) -> None: + encoded = _BaseWorkOSClient._encode_query( + {"range_start": UTC_AWARE, "range_end": EASTERN, "naive": NAIVE} + ) + expected_value = EXPECTED.replace(":", "%3A") + assert encoded == ( + f"range_start={expected_value}" + f"&range_end={expected_value}" + f"&naive={expected_value}" + ) + + def test_datetime_lists_repeat_the_key(self) -> None: + encoded = _BaseWorkOSClient._encode_query({"at": [UTC_AWARE, EASTERN]}) + expected_value = EXPECTED.replace(":", "%3A") + assert encoded == f"at={expected_value}&at={expected_value}" + + def test_strings_pass_through_untouched(self) -> None: + raw = "2026-10-01T18:01:14.870587+00:00" + encoded = _BaseWorkOSClient._encode_query({"range_start": raw}) + assert encoded == "range_start=2026-10-01T18%3A01%3A14.870587%2B00%3A00" + + +class TestBodyEncoding: + def test_datetime_values_anywhere_in_the_body(self) -> None: + body = _BaseWorkOSClient._encode_body( + {"range_start": EASTERN, "nested": {"at": UTC_AWARE}, "many": [NAIVE]} + ) + assert body is not None + assert json.loads(body) == { + "range_start": EXPECTED, + "nested": {"at": EXPECTED}, + "many": [EXPECTED], + } + + def test_unknown_types_still_raise(self) -> None: + with pytest.raises(TypeError, match="not JSON serializable"): + _BaseWorkOSClient._encode_body({"x": object()})