Skip to content
Open
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
18 changes: 17 additions & 1 deletion src/workos/_base_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
14 changes: 10 additions & 4 deletions src/workos/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
71 changes: 71 additions & 0 deletions tests/test_datetime_serialization.py
Original file line number Diff line number Diff line change
@@ -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()})
Loading