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:
Generated
+10
@@ -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
@@ -1,5 +1,5 @@
|
||||
[workspace]
|
||||
members = ["server"]
|
||||
members = ["server", "doctate-common"]
|
||||
resolver = "3"
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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};
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user