Skip to content
Clone

feat(export): make the documented PATCH and timestamp contract real

Two JSON-layer gaps that made writes behave silently wrong, both fixed as
shared helpers so every service gets them the same way.

serde_patch::deserialize_opt_opt: the PATCH contract in
lib/webapi/src/openapi.rs is absent = unchanged, null = cleared, value =
set, but a derived Option<Option<T>> never unwraps the outer Option, so a
JSON null deserialises to None and every "clear this field" request
quietly no-ops. The visitor maps null/unit to Some(None).

serde_datetime: with serde-human-readable enabled workspace-wide, `time`
parses only "YYYY-MM-DD HH:MM:SS.ffffff +00:00:00", while browsers send
RFC 3339 and the generated schema advertises format: date-time - so
writes against the documented shape failed. parse_offset_datetime tries
RFC 3339 first, then the legacy space-separated form, and both the
required and optional variants are exposed.

Applied to the command structs across auth, admin, chat, issues, pulls
and space: message reminders and room-admin fields, issue/milestone/label
writers, space bans/invites/members/dir/groups, access keys and the pull
update command, so "null means clear" now holds everywhere it already
claimed to (mute_until being the visible one).
ZhenYi committed
parent a7c3e3d commit cf002db Browse files

20 files changed

+215 −38

File tree 20
Modified lib/admin/src/services/spaces.rs View lib/admin/src/services/spaces.rs
@@ -69,7 +69,7 @@
6969pub struct UpdateSpaceCmd {
7070 #[serde(default)]
7171 pub name: Option<String>,
72 #[serde(default)]
72 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
7373 pub description: Option<Option<String>>,
7474 #[serde(default)]
7575 pub is_private: Option<bool>,
Modified lib/admin/src/services/users.rs View lib/admin/src/services/users.rs
@@ -117,7 +117,7 @@
117117 pub suspension_type: SuspensionType,
118118 #[serde(default)]
119119 pub reason: Option<String>,
120 #[serde(default)]
120 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
121121 pub ends_at: Option<OffsetDateTime>,
122122}
123123
@@ -745,7 +745,7 @@
745745 pub reason: Option<String>,
746746 #[serde(default)]
747747 pub suspension_type: Option<SuspensionType>,
748 #[serde(default)]
748 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
749749 pub ends_at: Option<OffsetDateTime>,
750750}
751751
Modified lib/auth/src/services/access_keys.rs View lib/auth/src/services/access_keys.rs
@@ -19,7 +19,7 @@
1919pub struct CreateAccessKeyCmd {
2020 pub name: String,
2121 pub scope: AccessKeyScope,
22 #[serde(default)]
22 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
2323 pub expires_at: Option<OffsetDateTime>,
2424}
2525
@@ -45,7 +45,10 @@
4545 pub name: Option<String>,
4646 #[serde(default)]
4747 pub scope: Option<AccessKeyScope>,
48 #[serde(default)]
48 #[serde(
49 default,
50 deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
51 )]
4952 pub expires_at: Option<Option<OffsetDateTime>>,
5053}
5154
Modified lib/chat/src/services/reminders.rs View lib/chat/src/services/reminders.rs
@@ -30,6 +30,7 @@
3030
3131#[derive(Debug, Clone, Deserialize, ToSchema)]
3232pub struct CreateReminderCmd {
33 #[serde(deserialize_with = "export::serde_datetime::deserialize_datetime")]
3334 pub remind_at: OffsetDateTime,
3435 #[serde(default)]
3536 pub note: Option<String>,
@@ -37,9 +38,12 @@
3738
3839#[derive(Debug, Clone, Deserialize, ToSchema)]
3940pub struct UpdateReminderCmd {
40 #[serde(default)]
41 #[serde(
42 default,
43 deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
44 )]
4145 pub remind_at: Option<OffsetDateTime>,
42 #[serde(default)]
46 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
4347 pub note: Option<Option<String>>,
4448}
4549
@@ -230,7 +234,10 @@
230234 &pending,
231235 reminder.user_id,
232236 NotificationType::System,
233 i18n::catalog::notification_reminder_title(self.recipient_locale(reminder.user_id).await).to_string(),
237 i18n::catalog::notification_reminder_title(
238 self.recipient_locale(reminder.user_id).await,
239 )
240 .to_string(),
234241 None,
235242 Some(data),
236243 None,
Modified lib/chat/src/services/room_admin.rs View lib/chat/src/services/room_admin.rs
@@ -29,13 +29,16 @@
2929 pub user_id: Uuid,
3030 #[serde(default)]
3131 pub reason: Option<String>,
32 #[serde(default)]
32 #[serde(
33 default,
34 deserialize_with = "export::serde_datetime::deserialize_opt_datetime"
35 )]
3336 pub expires_at: Option<OffsetDateTime>,
3437}
3538
3639#[derive(Debug, Clone, Deserialize, ToSchema)]
3740pub struct UpdateRoomMemberCmd {
38 #[serde(default)]
41 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
3942 pub nick: Option<Option<String>>,
4043 #[serde(default)]
4144 pub role: Option<RoomMemberRole>,
Modified lib/export/src/lib.rs View lib/export/src/lib.rs
@@ -8,6 +8,8 @@
88pub mod pagination;
99pub mod redis;
1010pub mod rest;
11pub mod serde_patch;
12pub mod serde_datetime;
1113pub mod storage;
1214pub mod telemetry;
1315pub mod urls;
Added lib/export/src/serde_datetime.rs View lib/export/src/serde_datetime.rs
@@ -0,0 +1,102 @@
1use serde::Deserialize;
2use serde::de::value::StrDeserializer;
3use serde::de::{self, Deserializer, Visitor};
4use std::fmt;
5use time::OffsetDateTime;
6use time::format_description::well_known::Rfc3339;
7
8/// Parse a timestamp in either shape a client may send.
9///
10/// `time`'s `serde-human-readable` feature — enabled workspace-wide — parses and
11/// emits `YYYY-MM-DD HH:MM:SS.ffffff +00:00:00` and nothing else. Browser
12/// clients send RFC 3339 (`new Date(...).toISOString()`), and the OpenAPI schema
13/// advertises `format: date-time` (RFC 3339) for these fields, so the documented
14/// contract used to fail with a 422 on every write.
15///
16/// RFC 3339 is tried first, then `time`'s own default parser (the legacy
17/// space-separated shape), so payloads written against either convention work.
18pub fn parse_offset_datetime(value: &str) -> Result<OffsetDateTime, String> {
19 if let Ok(parsed) = OffsetDateTime::parse(value, &Rfc3339) {
20 return Ok(parsed);
21 }
22 OffsetDateTime::deserialize(StrDeserializer::<de::value::Error>::new(value))
23 .map_err(|error| error.to_string())
24}
25
26/// Deserialize a required timestamp, tolerating both shapes.
27pub fn deserialize_datetime<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
28where
29 D: Deserializer<'de>,
30{
31 deserializer.deserialize_str(DateTimeVisitor)
32}
33
34/// Deserialize `Option<OffsetDateTime>`, tolerating both shapes plus `null`.
35pub fn deserialize_opt_datetime<'de, D>(
36 deserializer: D,
37) -> Result<Option<OffsetDateTime>, D::Error>
38where
39 D: Deserializer<'de>,
40{
41 deserializer.deserialize_option(OptDateTimeVisitor)
42}
43
44/// Deserialize a three-state `Option<Option<OffsetDateTime>>` update field:
45/// absent leaves the column alone, `null` clears it, a value sets it.
46///
47/// Composes the shared three-state reader with this module's parser, so a
48/// clearable timestamp accepts both `null` and RFC 3339.
49pub fn deserialize_opt_opt_datetime<'de, D>(
50 deserializer: D,
51) -> Result<Option<Option<OffsetDateTime>>, D::Error>
52where
53 D: Deserializer<'de>,
54{
55 crate::serde_patch::deserialize_opt_opt::<D, DateTimeWire>(deserializer)
56 .map(|outer| outer.map(|inner| inner.map(|wrapped| wrapped.0)))
57}
58
59/// Newtype so the shared three-state reader can drive this module's parser.
60struct DateTimeWire(OffsetDateTime);
61
62impl<'de> Deserialize<'de> for DateTimeWire {
63 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
64 deserialize_datetime(deserializer).map(DateTimeWire)
65 }
66}
67
68struct DateTimeVisitor;
69
70impl<'de> Visitor<'de> for DateTimeVisitor {
71 type Value = OffsetDateTime;
72
73 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
74 formatter.write_str("an RFC 3339 timestamp")
75 }
76
77 fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
78 parse_offset_datetime(value).map_err(E::custom)
79 }
80}
81
82struct OptDateTimeVisitor;
83
84impl<'de> Visitor<'de> for OptDateTimeVisitor {
85 type Value = Option<OffsetDateTime>;
86
87 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
88 formatter.write_str("an RFC 3339 timestamp or null")
89 }
90
91 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
92 Ok(None)
93 }
94
95 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
96 Ok(None)
97 }
98
99 fn visit_some<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
100 deserialize_datetime(deserializer).map(Some)
101 }
102}
Added lib/export/src/serde_patch.rs View lib/export/src/serde_patch.rs
@@ -0,0 +1,47 @@
1use serde::Deserialize;
2use serde::de::{self, Deserializer, Visitor};
3use std::fmt;
4use std::marker::PhantomData;
5
6/// Deserialize the three-state update field `Option<Option<T>>`.
7///
8/// The documented PATCH contract (`lib/webapi/src/openapi.rs`) is:
9/// field absent → unchanged, field `null` → cleared, field value → set.
10///
11/// serde does not unwrap the outer `Option` by itself, so a derived
12/// `Option<Option<T>>` collapses "absent" and "null" into `None` and every
13/// clear request silently no-ops. This maps `null` to `Some(None)` and a value
14/// to `Some(Some(value))`, which is what the service layer branches on.
15pub fn deserialize_opt_opt<'de, D, T>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
16where
17 D: Deserializer<'de>,
18 T: Deserialize<'de>,
19{
20 deserializer.deserialize_option(OptOptVisitor(PhantomData))
21}
22
23struct OptOptVisitor<T>(PhantomData<T>);
24
25impl<'de, T> Visitor<'de> for OptOptVisitor<T>
26where
27 T: Deserialize<'de>,
28{
29 type Value = Option<Option<T>>;
30
31 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
32 formatter.write_str("a value, null, or nothing")
33 }
34
35 /// JSON `null`: the caller asked for the field to be cleared.
36 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
37 Ok(Some(None))
38 }
39
40 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
41 Ok(Some(None))
42 }
43
44 fn visit_some<D: Deserializer<'de>>(self, deserializer: D) -> Result<Self::Value, D::Error> {
45 T::deserialize(deserializer).map(|value| Some(Some(value)))
46 }
47}
No newline at end of file
Modified lib/export/Cargo.toml View lib/export/Cargo.toml
@@ -22,4 +22,5 @@
2222bytes = { workspace = true }
2323http-body-util = { workspace = true }
2424jsonwebtoken = { workspace = true }
25thiserror = { workspace = true }
No newline at end of file
25thiserror = { workspace = true }
26time = { workspace = true }
Modified lib/issues/src/services/boards.rs View lib/issues/src/services/boards.rs
@@ -50,9 +50,9 @@
5050pub struct UpdateBoardCmd {
5151 #[serde(default)]
5252 pub name: Option<String>,
53 #[serde(default)]
53 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
5454 pub icon: Option<Option<String>>,
55 #[serde(default)]
55 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
5656 pub description: Option<Option<String>>,
5757}
5858
Modified lib/issues/src/services/issues.rs View lib/issues/src/services/issues.rs
@@ -92,7 +92,7 @@
9292 pub milestone_id: Option<Uuid>,
9393 #[serde(default)]
9494 pub priority: IssuePriority,
95 #[serde(default)]
95 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
9696 pub due_at: Option<OffsetDateTime>,
9797 #[serde(default)]
9898 pub assignee_ids: Vec<Uuid>,
@@ -104,15 +104,18 @@
104104pub struct UpdateIssueCmd {
105105 #[serde(default)]
106106 pub title: Option<String>,
107 #[serde(default)]
107 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
108108 pub description: Option<Option<String>>,
109109 #[serde(default)]
110110 pub content: Option<String>,
111 #[serde(default)]
111 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
112112 pub milestone_id: Option<Option<Uuid>>,
113113 #[serde(default)]
114114 pub priority: Option<IssuePriority>,
115 #[serde(default)]
115 #[serde(
116 default,
117 deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
118 )]
116119 pub due_at: Option<Option<OffsetDateTime>>,
117120 #[serde(default)]
118121 pub assignee_ids: Option<Vec<Uuid>>,
Modified lib/issues/src/services/labels.rs View lib/issues/src/services/labels.rs
@@ -72,7 +72,7 @@
7272 pub exclusive: Option<bool>,
7373 #[serde(default)]
7474 pub exclusive_order: Option<i32>,
75 #[serde(default)]
75 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
7676 pub description: Option<Option<String>>,
7777 #[serde(default)]
7878 pub is_archived: Option<bool>,
Modified lib/issues/src/services/milestones.rs View lib/issues/src/services/milestones.rs
@@ -38,7 +38,7 @@
3838 pub name: String,
3939 #[serde(default)]
4040 pub description: Option<String>,
41 #[serde(default)]
41 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
4242 pub due_at: Option<OffsetDateTime>,
4343}
4444
@@ -46,9 +46,12 @@
4646pub struct UpdateMilestoneCmd {
4747 #[serde(default)]
4848 pub name: Option<String>,
49 #[serde(default)]
49 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
5050 pub description: Option<Option<String>>,
51 #[serde(default)]
51 #[serde(
52 default,
53 deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
54 )]
5255 pub due_at: Option<Option<OffsetDateTime>>,
5356 #[serde(default)]
5457 pub is_closed: Option<bool>,
Modified lib/pulls/src/services/pulls.rs View lib/pulls/src/services/pulls.rs
@@ -128,15 +128,15 @@
128128pub struct UpdatePullCmd {
129129 #[serde(default)]
130130 pub title: Option<String>,
131 #[serde(default)]
131 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
132132 pub body: Option<Option<String>>,
133133 #[serde(default)]
134134 pub base_branch: Option<String>,
135135 #[serde(default)]
136136 pub draft: Option<bool>,
137 #[serde(default)]
137 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
138138 pub auto_merge: Option<Option<bool>>,
139 #[serde(default)]
139 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
140140 pub issue_id: Option<Option<Uuid>>,
141141}
142142
Modified lib/space/src/services/bans.rs View lib/space/src/services/bans.rs
@@ -38,7 +38,7 @@
3838 pub user_id: Uuid,
3939 #[serde(default)]
4040 pub reason: Option<String>,
41 #[serde(default)]
41 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
4242 pub expires_at: Option<OffsetDateTime>,
4343 #[serde(default)]
4444 pub allow_agent_join: Option<bool>,
@@ -52,9 +52,12 @@
5252
5353#[derive(Debug, Clone, Deserialize, ToSchema)]
5454pub struct UpdateBanCmd {
55 #[serde(default)]
55 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
5656 pub reason: Option<Option<String>>,
57 #[serde(default)]
57 #[serde(
58 default,
59 deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
60 )]
5861 pub expires_at: Option<Option<OffsetDateTime>>,
5962 #[serde(default)]
6063 pub allow_agent_join: Option<bool>,
Modified lib/space/src/services/dir.rs View lib/space/src/services/dir.rs
@@ -84,7 +84,7 @@
8484
8585#[derive(Debug, Clone, Deserialize, ToSchema)]
8686pub struct MoveDirCmd {
87 #[serde(default)]
87 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
8888 pub parent_dir_id: Option<Option<Uuid>>,
8989 #[serde(default)]
9090 pub position: Option<i64>,
Modified lib/space/src/services/groups.rs View lib/space/src/services/groups.rs
@@ -58,9 +58,9 @@
5858 pub name: Option<String>,
5959 #[serde(default)]
6060 pub color: Option<String>,
61 #[serde(default)]
61 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
6262 pub unicode_emoji: Option<Option<String>>,
63 #[serde(default)]
63 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
6464 pub icon_hash: Option<Option<String>>,
6565 #[serde(default)]
6666 pub position: Option<i64>,
Modified lib/space/src/services/invites.rs View lib/space/src/services/invites.rs
@@ -48,7 +48,7 @@
4848 pub email: Option<String>,
4949 #[serde(default)]
5050 pub group_ids: Vec<Uuid>,
51 #[serde(default)]
51 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
5252 pub expires_at: Option<OffsetDateTime>,
5353}
5454
Modified lib/space/src/services/members.rs View lib/space/src/services/members.rs
@@ -52,7 +52,7 @@
5252 pub message_notifications: Option<MessageNotifications>,
5353 #[serde(default)]
5454 pub muted: Option<bool>,
55 #[serde(default)]
55 #[serde(default, deserialize_with = "export::serde_datetime::deserialize_opt_datetime")]
5656 pub mute_until: Option<OffsetDateTime>,
5757 #[serde(default)]
5858 pub mobile_push: Option<bool>,
@@ -66,15 +66,18 @@
6666
6767#[derive(Debug, Clone, Deserialize, ToSchema)]
6868pub struct UpdateMemberCmd {
69 #[serde(default)]
69 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
7070 pub alias_name: Option<Option<String>>,
71 #[serde(default)]
71 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
7272 pub alias_avatar_url: Option<Option<String>>,
7373 #[serde(default)]
7474 pub message_notifications: Option<MessageNotifications>,
7575 #[serde(default)]
7676 pub muted: Option<bool>,
77 #[serde(default)]
77 #[serde(
78 default,
79 deserialize_with = "export::serde_datetime::deserialize_opt_opt_datetime"
80 )]
7881 pub mute_until: Option<Option<OffsetDateTime>>,
7982 #[serde(default)]
8083 pub mobile_push: Option<bool>,
Modified lib/space/src/services/spaces.rs View lib/space/src/services/spaces.rs
@@ -105,7 +105,7 @@
105105pub struct UpdateSpaceCmd {
106106 #[serde(default)]
107107 pub name: Option<String>,
108 #[serde(default)]
108 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
109109 pub description: Option<Option<String>>,
110110 #[serde(default)]
111111 pub is_private: Option<bool>,
@@ -113,13 +113,13 @@
113113 pub allow_join: Option<bool>,
114114 #[serde(default)]
115115 pub allow_invite: Option<bool>,
116 #[serde(default)]
116 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
117117 pub welcome_message: Option<Option<String>>,
118118 #[serde(default)]
119119 pub allow_join_level: Option<AllowJoinLevel>,
120120 #[serde(default)]
121121 pub member_must_enable_2fa: Option<bool>,
122 #[serde(default)]
122 #[serde(default, deserialize_with = "export::serde_patch::deserialize_opt_opt")]
123123 pub welcome_object: Option<Option<Uuid>>,
124124}
125125