Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
f3bc5cc
Widen `WpDateString` to every date that isn't an instant
oguzkocer Aug 9, 2026
dd888ca
Type the dates in five wp.com stats modules
oguzkocer Aug 9, 2026
ffe026e
Document what a time-series `period` label holds
oguzkocer Aug 9, 2026
8d97724
Add a conventions file, starting with the date rule
oguzkocer Aug 9, 2026
fff00e6
Type the `/wp/v2` dates
oguzkocer Aug 9, 2026
17182c3
Type the dates in the remaining wp.com stats modules
oguzkocer Aug 11, 2026
0c11df0
Correct the `pending_registration_time` doc, and log the date change
oguzkocer Aug 11, 2026
5e58ad6
Name the time-series period label
oguzkocer Aug 11, 2026
9d641d4
Query date windows against the columns WordPress compares them to
oguzkocer Aug 11, 2026
4043bcb
Fold the date-window entry into the existing `Fixed` section
oguzkocer Aug 11, 2026
f45cb26
Read an application password's creation date as a date
oguzkocer Aug 11, 2026
d1e5828
Revert "Query date windows against the columns WordPress compares the…
oguzkocer Aug 11, 2026
b6f9646
Accept every datetime form `WpGmtDateTime` does when it is optional
oguzkocer Aug 11, 2026
fdca3b4
Sharpen the date rule where the code doesn't follow it
oguzkocer Aug 11, 2026
0f67fc8
Write test date literals with RFC 3339 offsets
oguzkocer Aug 11, 2026
f2d3dd1
Read a `WpGmtDateTime` the same way through `FromStr` and serde
oguzkocer Aug 11, 2026
f971bbd
Recognise WordPress's never-set date instead of reading it as 1 BCE
oguzkocer Aug 11, 2026
ae791d9
Narrow the date docs to what the endpoints actually send
oguzkocer Aug 12, 2026
7a57b2a
Read a subscriber's blog registration date as a date
oguzkocer Aug 12, 2026
05d63d7
Type blocks' publish date and navigations' date windows to match thei…
oguzkocer Aug 12, 2026
5e65b17
Tidy the date docs and drop an unused derive
oguzkocer Aug 12, 2026
bcfeadb
Refetch a cached item whose modified date can't be read
oguzkocer Aug 12, 2026
d02477d
Hold the cached list metadata's modified date as a date
oguzkocer Aug 12, 2026
fc8f770
Read a subscription whose date was never set as absent
oguzkocer Aug 12, 2026
933f5d3
Wrap the mobile integration tests' post dates in `WpDateString`
oguzkocer Aug 13, 2026
e41b0c6
Read an unguarded never-set date as absent instead of failing the res…
oguzkocer Aug 13, 2026
8042f2a
Correct what the changelog claims about core's date formatting
oguzkocer Aug 13, 2026
712b54d
Accept a numeric timestamp on the optional date path too
oguzkocer Aug 13, 2026
150fe1d
Hold the bindings' timestamp lift to the same rules as every other read
oguzkocer Aug 13, 2026
28165e3
Say where the two date helpers stop being the right tool
oguzkocer Aug 13, 2026
7759050
Pin what the per-post stats endpoint sends when it has no history
oguzkocer Aug 13, 2026
d6af815
Make `WpDateString` a record so the bindings see a real type
oguzkocer Aug 13, 2026
a8e49fd
Give each stats endpoint's period its own type
oguzkocer Aug 13, 2026
35e336f
Keep the staleness conditions as a list
oguzkocer Aug 13, 2026
678fa5b
Untangle a sentence in the type rule
oguzkocer Aug 13, 2026
d3849af
Edit the conventions and changelog entries down
oguzkocer Aug 13, 2026
5b49113
Read `WpDateString` through `value` in the Kotlin example app
oguzkocer Aug 13, 2026
54cbcc0
Call the zero date what WordPress calls it, and define it where it's …
oguzkocer Aug 24, 2026
e776349
Match the zero date on a window rather than the exact instant
oguzkocer Aug 25, 2026
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **BREAKING:** The cache now enables SQLite foreign key enforcement on every connection it prepares, and fails with `SqliteDbError::ForeignKeysUnavailable` if the setting doesn't take effect. Removing a site relies on `ON DELETE CASCADE` to clear its cached rows, so on builds where enforcement defaulted to off those rows were silently left behind.
- **BREAKING:** `ShoppingCart.coupon` changed from `String` to `CouponCode`, and `ShoppingCartCostOverride.override_code` from `String` to `CostOverrideCode`, so the shopping cart and site plans describe these values with the same types. Callers will need to wrap/unwrap with `CouponCode(...)` / `CostOverrideCode(...)`.
- **BREAKING:** Added a `MediaFileUnreadable { file_path }` variant to `RequestExecutionError` and `WpApiError` for a media file that exists but fails *mid-read* while its multipart body is streamed (e.g. deleted after the upload started, or a storage read error). Previously surfaced as a path-less `.genericError`; it now carries the file path and stays distinct from `MediaFileNotFound` (the file couldn't be opened at all). Both executors produce it — Swift maps a mid-read serialization failure, and Kotlin tags a file-read failure during the OkHttp upload (distinct from a socket write error) — surfacing as `WpRequestResult.MediaFileUnreadable` / `WpApiException.MediaFileUnreadable` on Kotlin. Adds a case that may affect exhaustive `match`/`when`/`switch` expressions. ([#1541](https://github.com/Automattic/wordpress-rs/issues/1541))
- **BREAKING:** The `period` on a WordPress.com stats time series was a `String` and now has a type per endpoint — `StatsVisitsPeriod`, `StatsSubscribersPeriod` and `StatsPostViewPeriod`. None of them is a date: the shape follows the unit the caller asked for and the endpoints don't agree on it, so display them or group by them rather than reading a date out of one. Each is a record with a single `value` field.
- **BREAKING:** Date fields that were `String` are now typed: `WpDateString` for a value that can't be resolved to an instant — a bare calendar date, or a datetime in the site's timezone — and `WpGmtDateTime` for one that can. This covers `date` and `modified` across posts, pages, media, blocks, navigation, templates, template parts and every revision and autosave resource, `date` on comments, the date parameters and response dates on the WordPress.com stats endpoints, and, as GMT, `users.registered_date`, application passwords' `created` and `last_used`, and `blog_registration_date`. `WpDateString` is a record with a single `value` field rather than an alias for a string, so Swift and Kotlin see a distinct type; Rust callers construct it with `WpDateString::new(...)` and read `.value`. `CONVENTIONS.md` records how to choose between the two.
- **BREAKING:** Every way of reading a `WpGmtDateTime` now goes through one parser and accepts the same set: an RFC 3339 offset, the offsetless WordPress form (`2026-08-06T09:15:49`, read as UTC), MySQL's (`2026-08-06 09:15:49`), sub-second precision, and a unix timestamp. In Rust, `FromStr` gains the offsetless forms and loses an offset written without a colon (`+0000`), which RFC 3339 forbids and neither server was found to emit; `WpGmtDateTime::from_timestamp` is no longer public. On the bindings, lifting a `WpGmtDateTime` from a timestamp now rejects WordPress's zero date and any instant before year 1, and an out-of-range value is an error rather than silently becoming the unix epoch.
- **BREAKING:** `users.registered_date`, comments' `date_gmt`, `Subscriber.date_subscribed` and `blog_registration_date` became optional, so Swift and Kotlin see `Date?`. These endpoints format their date column without guarding WordPress's zero date, so a record that has none sends that value rather than `null`, and one such row previously failed the entire response.
- **BREAKING:** `date` on `BlockCreateParams` and `BlockUpdateParams` changed from `WpGmtDateTime` to `WpDateString`. The field is the publish date in the site's timezone, but a `WpGmtDateTime` serializes without an offset and WordPress reads an offsetless value as site-local, so passing a UTC instant published at that wall clock in the site's timezone instead. Posts, pages, media and comments already used `WpDateString` for this parameter.
- **BREAKING:** `after`, `modified_after`, `before` and `modified_before` on `NavigationListParams` changed from `String` to `WpGmtDateTime`, matching the same parameters on posts, media, comments, blocks and navigation menu items. They are now sent with an offset rather than verbatim.
- Documented `GET /all-domains/` subtypes and parameters. `DomainSubtypeId::DefaultAddress` covers staging and garden subdomains as well as the free WordPress.com address, and is the set v1.1's `no_wpcom=true` excluded; v1.2 has no equivalent parameter, so clients filter this subtype out instead.
- **Internal:** Corrected `GET /all-domains/` fixtures that claimed subtypes the endpoint never returns (`site_redirect`, `domain_mapping`).
- Kotlin: The request executor now classifies cancelling an in-flight request via `CancellableCall.cancel()` as `CancellationError` instead of `GenericError`, matching Swift's handling of `URLError.cancelled`. Whole-call `callTimeout` expiry is classified as `HttpTimeoutError` rather than being mistaken for a cancellation, and a `CancellationException` surfacing synchronously inside the executor (e.g. from an upload callback) is classified as `CancellationError` rather than flattened into a `GenericError` ([#1492](https://github.com/Automattic/wordpress-rs/issues/1492)).
Expand All @@ -46,6 +52,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Swift: `WpRequestExecutor` classifies three more `URLError` codes that mean the device can't use the network right now — cellular data disallowed for the app or by carrier policy (`dataNotAllowed`, the common Wi-Fi-off case), international roaming turned off while abroad (`internationalRoamingOff`), and a voice call holding the radio on a single-radio device (`callIsActive`) — as `DeviceIsOfflineError` instead of the catch-all `GenericError`. `errorIsDeviceIsOffline` previously matched only `notConnectedToInternet` and `networkConnectionLost`, so these fell through and a caller wanting "we're offline" had to match `GenericError`, which also covers unrelated failures. This matches what Kotlin callers effectively get through the `NetworkAvailabilityProvider` gate. ([#1501](https://github.com/Automattic/wordpress-rs/issues/1501))
- Swift: `WpRequestExecutor` classifies a URLSession timeout (`URLError.timedOut`) as `HttpTimeoutError` instead of the catch-all `GenericError`. The timeout had no branch in the executor's error dispatch, so `HttpTimeoutError` was unreachable on Apple platforms and a caller wanting "retry on timeout" had to match `GenericError`, which also covers unrelated failures. This brings Swift to parity with reqwest (`is_timeout()`) and Kotlin (`SocketTimeoutException`). ([#1491](https://github.com/Automattic/wordpress-rs/issues/1491))
- Swift: Large (>10 MB) multipart uploads leaked their temporary file. A form estimated over 10 MB (or built with `forceWriteToFile: true`) is serialized to a UUID-named temp file under `FileManager.default.temporaryDirectory` and handed to `URLSession.uploadTask(fromFile:)`, which treats the file as caller-owned — it reads it during the transfer but never deletes it — so every large media upload (4K video, ProRAW, multi-file posts) left a temp file behind for the OS to reclaim later. `upload(...)` now removes the file once the transfer completes, on success, failure, or cancellation. ([#1540](https://github.com/Automattic/wordpress-rs/issues/1540))
- A datetime WordPress never set (`0000-00-00 00:00:00`, and PHP's renderings of it) is no longer read as an instant in 1 BCE. Fields that can be unset now report it as absent; everywhere else it is a parse error rather than a plausible-looking date.
- A cached post or media item whose stored `modified_gmt` could not be read was treated as up to date and never refetched, so the stale copy stayed in the cache indefinitely. The staleness check dropped such rows silently, and a row missing from that lookup means "not stale"; an unreadable timestamp now marks the item stale instead.
- Swift: `WpRequestExecutor.sleep(millis:)` converted milliseconds to nanoseconds with the wrong factor (`* 1_000` instead of `* 1_000_000`), so it slept 1000× too short — a `Retry-After: 30` waited 30 ms instead of 30 s. `RetryAfterMiddleware` then re-sent immediately, the server kept returning 429, and after `max_retries` the caller observed `MisconfiguredRateLimitError` where honoring the backoff would usually have succeeded. The executor now waits the full interval, and no longer risks a `fatalError` if the sleep's task is cancelled.
- Swift: Classify invalid-SSL failures from the failed handshake's `SecTrust` (`URLError.failureURLPeerTrust`), via `SecTrustCopyCertificateChain`, instead of reading the undocumented `NSErrorPeerCertificateChainKey` `userInfo` string that has no public constant. Behavior is unchanged on every platform: iOS/macOS/tvOS still surface the presented certificate as `certificateNotValidForName`, and watchOS — which exposes no peer trust — still degrades to `genericSslError`. ([#1510](https://github.com/Automattic/wordpress-rs/issues/1510))
- `isSiteUnreachable` now returns the same answer for a refused connection — the host resolves, but nothing is listening (server down, wrong port) — on every executor. Previously it was `NonExistentSiteError` on Swift (so `isSiteUnreachable` was `true`) but the generic `HttpError` on Kotlin and reqwest (so it was `false`); a refused connection is now a `ConnectionError` everywhere, which `isSiteUnreachable` covers. `NonExistentSiteError` is reserved for a DNS-resolution failure.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Test credentials are configured in:

## Important Files

- `CONVENTIONS.md` - Rules for modelling API responses; read before adding types
- `Makefile` - Build automation and platform-specific targets
- `wp_api/src/lib.rs` - Main library entry point
- `wp_api/src/request.rs` - Core request/response handling
Expand Down
33 changes: 33 additions & 0 deletions CONVENTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Conventions

Rules for modelling the WordPress and WordPress.com REST APIs in this crate, so the same decisions don't get re-argued on every endpoint.

## Types

A type is a promise. Two values share one only when they are the same thing — not nearly, not for now. If you can't say yes without a qualifier, the answer is no. "Same shape today", "same apart from one endpoint", "same unless the server does X" are all no.

Share helpers where they genuinely overlap; that's what the overlap is good for.

## Dates

When you model a point in time, reach for one of these two types rather than a bare `String`.

**`WpGmtDateTime`** — the value resolves to an absolute instant, because it is UTC or carries an offset. The bindings lower it to a unix timestamp, so a value that isn't genuinely an instant becomes a wrong one.

**`WpDateString`** — it denotes a date but can't be resolved to an instant: a bare calendar date (`2026-08-06`), or a datetime in the *site's* timezone (`2026-08-06 09:15:49`), which needs the site's offset to place.

Where an endpoint sends both forms of the same timestamp, model both — the GMT one as `WpGmtDateTime`, its local twin as `WpDateString`.

Every way of reading a `WpGmtDateTime` accepts the same set: an offset-bearing value, the offsetless WordPress form, MySQL's, and a unix timestamp. The offsetless forms are read as UTC, so only reach for this type once you know the value is GMT.

Decide from what the endpoint implementation produces, not from the field's name or the schema's wording. Both directions bite: a "most active day" is an instant, because it comes off a comment's GMT timestamp, while a "last updated" can be prose for display.

Query parameters are no different. `/wp/v2`'s `after` is documented as ISO-8601 and matched against the site-local `post_date` column, but `WP_Date_Query` converts an offset-bearing value into the site's timezone first, so `WpGmtDateTime` is right for it.

### Absent and unparseable values

`wp_com`'s domain fields send boolean `false` rather than `null` when a date doesn't apply; `deserialize_optional_date_string` covers that for `WpDateString`. `deserialize_optional_wp_gmt_date_time` treats `null` and `""` as absent.

WordPress's zero date — `0000-00-00 00:00:00`, and what PHP's formatters make of it — is not a datetime. `deserialize_optional_wp_gmt_date_time` reads it as `None`; everywhere else it is an error, because the alternative is an instant in 1 BCE that looks like data.

So a field on an endpoint that doesn't guard the column has to be an `Option` — otherwise one such record fails the entire response. `/wp/v2` guards posts, but not users or comments.
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ fun ApplicationPasswordListScreen(
items(applicationPasswords) { appPassword ->
ListItem(
headlineContent = { Text(appPassword.name) },
supportingContent = { Text(appPassword.created) }
supportingContent = { Text(appPassword.created.toString()) }
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ class PostCollectionViewModel(
title = fullEntity.data.title?.rendered ?: "<no-title>",
contentPreview = fullEntity.data.content.rendered.take(100),
status = fullEntity.data.status.toString(),
date = fullEntity.data.date,
modified = fullEntity.data.modified,
date = fullEntity.data.date.value,
modified = fullEntity.data.modified.value,
author = fullEntity.data.author?.toString()
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ fun PostListByTypeScreen(
ListItem(
headlineContent = { Text(post.title?.rendered ?: "(untitled)") },
supportingContent = { Text(post.status.toString()) },
overlineContent = { Text(post.date) }
overlineContent = { Text(post.date.value) }
)
}
if (error != null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ class StressTestViewModel(
contentPreview = fullEntity.data.content.rendered.take(100),
status = fullEntity.data.status.toString(),
author = fullEntity.data.author?.toString(),
date = fullEntity.data.date,
modified = fullEntity.data.modified
date = fullEntity.data.date.value,
modified = fullEntity.data.modified.value
)
}

Expand Down
6 changes: 1 addition & 5 deletions native/swift/Example/Example/ListViewData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,7 @@ extension PluginWithEditContext: ListViewDataConvertable {

extension ApplicationPasswordWithEditContext: ListViewDataConvertable {
var creationDateString: String {
guard let date = Date.fromWordPressDate(self.created) else {
return self.created
}

return RelativeDateTimeFormatter().string(for: date) ?? self.created
RelativeDateTimeFormatter().string(for: self.created) ?? self.created.formatted()
}

var asListViewData: ListViewData {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,13 @@ public extension Date {
return dateFormatter
}()

/// Parses a date string provided by WordPress APIs (which are assumed to be in GMT)
/// Parses an offsetless WordPress datetime string as GMT — the shape of
/// WordPress's `_gmt` fields.
///
/// Those fields cross the bindings as `Date`, so this is for a string
/// obtained some other way. It is the wrong tool for `date` and
/// `modified`, which are in the site's timezone rather than GMT: reading
/// them with this shifts the instant by the site's UTC offset.
static func fromWordPressDate(_ string: String) -> Date? {
wordpressDateFormatter.date(from: string)
}
Expand Down
5 changes: 3 additions & 2 deletions wp_api/src/application_passwords.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::fmt::Display;

use crate::date::WpGmtDateTime;
use serde::{Deserialize, Serialize};
use wp_contextual::WpContextual;

Expand All @@ -12,10 +13,10 @@ pub struct SparseApplicationPassword {
#[WpContext(edit, embed, view)]
pub name: Option<String>,
#[WpContext(edit, view)]
pub created: Option<String>,
pub created: Option<WpGmtDateTime>,
#[WpContextualOption]
#[WpContext(edit, view)]
pub last_used: Option<String>,
pub last_used: Option<WpGmtDateTime>,
#[WpContextualOption]
#[WpContext(edit, view)]
pub last_ip: Option<IpAddress>,
Expand Down
6 changes: 3 additions & 3 deletions wp_api/src/block_revisions.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::{
UserId, WpApiParamOrder,
blocks::BlockId,
date::WpGmtDateTime,
date::{WpDateString, WpGmtDateTime},
impl_as_query_value_from_to_string,
url_query::{
AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap,
Expand Down Expand Up @@ -76,11 +76,11 @@ pub struct SparseBlockRevision {
#[WpContext(edit, embed, view)]
pub author: Option<UserId>,
#[WpContext(edit, embed, view)]
pub date: Option<String>,
pub date: Option<WpDateString>,
#[WpContext(edit, view)]
pub date_gmt: Option<WpGmtDateTime>,
#[WpContext(edit, view)]
pub modified: Option<String>,
pub modified: Option<WpDateString>,
#[WpContext(edit, view)]
pub modified_gmt: Option<WpGmtDateTime>,
#[WpContext(edit, embed, view)]
Expand Down
10 changes: 5 additions & 5 deletions wp_api/src/blocks.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::{
WpApiParamOrder,
date::WpGmtDateTime,
date::{WpDateString, WpGmtDateTime},
impl_as_query_value_from_to_string,
url_query::{
AppendUrlQueryPairs, FromUrlQueryPairs, QueryPairs, QueryPairsExtension, UrlQueryPairsMap,
Expand Down Expand Up @@ -47,7 +47,7 @@ impl_as_query_value_from_to_string!(BlockStatus);
#[derive(Debug, Serialize, Deserialize, WpContextual)]
pub struct SparseBlock {
#[WpContext(edit, embed, view)]
pub date: Option<String>,
pub date: Option<WpDateString>,
#[WpContext(edit, view)]
pub date_gmt: Option<WpGmtDateTime>,
#[WpContext(edit, view)]
Expand All @@ -58,7 +58,7 @@ pub struct SparseBlock {
#[WpContext(edit, embed, view)]
pub link: Option<String>,
#[WpContext(edit, view)]
pub modified: Option<String>,
pub modified: Option<WpDateString>,
#[WpContext(edit, view)]
pub modified_gmt: Option<WpGmtDateTime>,
#[WpContext(edit, embed, view)]
Expand Down Expand Up @@ -201,7 +201,7 @@ pub struct BlockCreateParams {
/// The date the post was published, in the site's timezone.
#[uniffi(default = None)]
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<WpGmtDateTime>,
pub date: Option<WpDateString>,
/// The date the post was published, as GMT.
#[uniffi(default = None)]
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -237,7 +237,7 @@ pub struct BlockUpdateParams {
/// The date the post was published, in the site's timezone.
#[uniffi(default = None)]
#[serde(skip_serializing_if = "Option::is_none")]
pub date: Option<WpGmtDateTime>,
pub date: Option<WpDateString>,
/// The date the post was published, as GMT.
#[uniffi(default = None)]
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down
Loading