Add doctate-common crate for shared types

This commit introduces a new `doctate-common` crate to the workspace.
This crate will house shared data structures and constants used by both
the `doctate-server` and any future clients.

This refactoring helps to:
- Reduce code duplication.
- Improve maintainability by centralizing common logic.
- Define a clear API contract for server-client communication.

The following modules have been added:
- `ack`: Contains `AckResponse` and `AckStatus` for server responses.
- `constants`: Defines shared constants like API endpoints and multipart
  field names.
- `timestamp`: Provides utilities for handling RFC3339 timestamps,
  including conversions to and from filesystem-safe strings.
This commit is contained in:
2026-04-17 15:17:37 +02:00
parent 0a8e66eecd
commit ed5047a3ff
7 changed files with 174 additions and 1 deletions
Generated
+10
View File
@@ -330,6 +330,16 @@ dependencies = [
"syn",
]
[[package]]
name = "doctate-common"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"time",
"uuid",
]
[[package]]
name = "doctate-server"
version = "0.1.0"
+1 -1
View File
@@ -1,5 +1,5 @@
[workspace]
members = ["server"]
members = ["server", "doctate-common"]
resolver = "3"
[workspace.package]
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "doctate-common"
version.workspace = true
edition.workspace = true
description = "Shared types and helpers between doctate server and clients"
[dependencies]
serde = { workspace = true }
uuid = { workspace = true }
time = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+54
View File
@@ -0,0 +1,54 @@
use serde::{Deserialize, Serialize};
/// Server response to `POST /api/upload`. Shape is stable across all clients.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AckResponse {
pub case_id: String,
pub recorded_at: String,
pub status: AckStatus,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AckStatus {
/// Recording accepted and persisted.
Received,
/// Case no longer exists server-side; client should delete its local copy.
Gone,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn status_serializes_lowercase() {
assert_eq!(
serde_json::to_string(&AckStatus::Received).unwrap(),
"\"received\""
);
assert_eq!(serde_json::to_string(&AckStatus::Gone).unwrap(), "\"gone\"");
}
#[test]
fn status_deserializes_lowercase() {
let got: AckStatus = serde_json::from_str("\"received\"").unwrap();
assert_eq!(got, AckStatus::Received);
let got: AckStatus = serde_json::from_str("\"gone\"").unwrap();
assert_eq!(got, AckStatus::Gone);
}
#[test]
fn response_roundtrip() {
let original = AckResponse {
case_id: "550e8400-e29b-41d4-a716-446655440000".into(),
recorded_at: "2026-04-15T10:32:00Z".into(),
status: AckStatus::Received,
};
let json = serde_json::to_string(&original).unwrap();
let decoded: AckResponse = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.case_id, original.case_id);
assert_eq!(decoded.recorded_at, original.recorded_at);
assert_eq!(decoded.status, original.status);
}
}
+14
View File
@@ -0,0 +1,14 @@
/// HTTP header that carries the per-user API key on `/api/...` requests.
pub const API_KEY_HEADER: &str = "X-API-Key";
/// Server endpoint path for recording upload (relative to the server base URL).
pub const UPLOAD_PATH: &str = "/api/upload";
// -- Multipart field names for POST /api/upload --
pub const FIELD_CASE_ID: &str = "case_id";
pub const FIELD_RECORDED_AT: &str = "recorded_at";
pub const FIELD_AUDIO: &str = "audio";
/// Content-Type of the audio part in the upload multipart body (m4a/AAC).
pub const CONTENT_TYPE_AUDIO_MP4: &str = "audio/mp4";
+10
View File
@@ -0,0 +1,10 @@
pub mod ack;
pub mod constants;
pub mod timestamp;
pub use ack::{AckResponse, AckStatus};
pub use constants::{
API_KEY_HEADER, CONTENT_TYPE_AUDIO_MP4, FIELD_AUDIO, FIELD_CASE_ID, FIELD_RECORDED_AT,
UPLOAD_PATH,
};
pub use timestamp::{filename_stem_to_recorded_at, now_rfc3339, recorded_at_to_filename_stem};
+72
View File
@@ -0,0 +1,72 @@
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
/// Current UTC time as an RFC3339 string (e.g. `2026-04-15T10:32:00.123Z`).
///
/// Formatting an `OffsetDateTime` as RFC3339 is infallible — every required
/// component is always present — so we panic on error rather than propagate.
pub fn now_rfc3339() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.expect("RFC3339 formatting of OffsetDateTime is infallible")
}
/// Map an RFC3339 timestamp to a filesystem-safe stem by replacing every
/// `:` with `-`. Intentionally naive — RFC3339 has no colons outside the
/// time/offset portion, so the result is bijective against
/// `filename_stem_to_recorded_at`.
///
/// Example: `"2026-04-15T10:32:00Z"` → `"2026-04-15T10-32-00Z"`.
pub fn recorded_at_to_filename_stem(recorded_at: &str) -> String {
recorded_at.replace(':', "-")
}
/// Reverse of `recorded_at_to_filename_stem`. Only undoes the mapping in
/// the time portion (after `T`) so date-part hyphens stay intact.
///
/// Example: `"2026-04-15T10-32-00Z"` → `"2026-04-15T10:32:00Z"`.
pub fn filename_stem_to_recorded_at(stem: &str) -> String {
match stem.split_once('T') {
Some((date, time)) => format!("{}T{}", date, time.replace('-', ":")),
None => stem.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filename_stem_roundtrip() {
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00Z"),
"2026-04-15T10:32:00Z"
);
assert_eq!(
filename_stem_to_recorded_at("2026-04-15T10-32-00.123Z"),
"2026-04-15T10:32:00.123Z"
);
}
#[test]
fn recorded_at_stem_roundtrip_utc() {
let original = "2026-04-15T10:32:00Z";
let stem = recorded_at_to_filename_stem(original);
assert_eq!(stem, "2026-04-15T10-32-00Z");
assert_eq!(filename_stem_to_recorded_at(&stem), original);
}
#[test]
fn recorded_at_stem_roundtrip_with_milliseconds() {
let original = "2026-04-15T10:32:00.123Z";
let stem = recorded_at_to_filename_stem(original);
assert_eq!(filename_stem_to_recorded_at(&stem), original);
}
#[test]
fn now_rfc3339_has_expected_shape() {
let now = now_rfc3339();
assert!(now.starts_with("20"), "unexpected year prefix: {now}");
assert!(now.contains('T'), "missing T separator: {now}");
}
}