use serde::{de::DeserializeOwned, Deserialize, Serialize};
use serde_json::Value as JsonValue;
use crate::client::Client;
use crate::error::Result;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncEnvelope {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extras: Option<JsonValue>,
}
impl SyncEnvelope {
pub fn for_endpoint<E: SyncEndpoint + ?Sized>() -> Self {
Self {
schema_version: Some(E::CURRENT_SCHEMA_VERSION),
extras: None,
}
}
}
pub trait HasSyncEnvelope {
fn envelope_mut(&mut self) -> &mut SyncEnvelope;
}
fn stamp_schema_version<E: SyncEndpoint>(items: &[E::Record]) -> Vec<E::Record>
where
E::Record: Clone + HasSyncEnvelope,
{
let mut out = items.to_vec();
for item in &mut out {
let env = item.envelope_mut();
if env.schema_version.is_none() {
env.schema_version = Some(E::CURRENT_SCHEMA_VERSION);
}
}
out
}
pub trait SyncEndpoint {
const RESOURCE: &'static str;
const CURRENT_SCHEMA_VERSION: u32 = 1;
type Record: Serialize + DeserializeOwned + Send + Sync + 'static;
type Query: Serialize + Send + Sync;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SyncRequest<R> {
pub items: Vec<R>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SyncResponse {
pub accepted: u32,
pub skipped: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Page<R> {
pub items: Vec<R>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_before: Option<String>,
}
impl Client {
pub async fn sync<E: SyncEndpoint>(&self, items: &[E::Record]) -> Result<SyncResponse>
where
E::Record: Clone + HasSyncEnvelope,
{
let path = format!("/api/voice/{}/sync", E::RESOURCE);
let body = SyncRequest {
items: stamp_schema_version::<E>(items),
};
self.post_json::<SyncResponse, _>(&path, &body).await
}
pub async fn list<E: SyncEndpoint>(&self, query: &E::Query) -> Result<Page<E::Record>> {
let path = format!("/api/voice/{}", E::RESOURCE);
self.get_json_query::<Page<E::Record>, _>(&path, query)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
struct DummyResource;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct DummyRecord {
source_id: String,
payload: String,
}
#[derive(Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
struct DummyQuery {
before: Option<String>,
limit: Option<u32>,
}
impl SyncEndpoint for DummyResource {
const RESOURCE: &'static str = "dummy";
type Record = DummyRecord;
type Query = DummyQuery;
}
#[test]
fn sync_request_serializes_with_items_field() {
let body = SyncRequest::<DummyRecord> {
items: vec![
DummyRecord {
source_id: "a".into(),
payload: "x".into(),
},
DummyRecord {
source_id: "b".into(),
payload: "y".into(),
},
],
};
let s = serde_json::to_string(&body).unwrap();
assert!(s.contains("\"items\":["), "missing items envelope: {s}");
assert!(
s.contains("\"sourceId\":\"a\""),
"wire should be camelCase: {s}"
);
}
#[test]
fn sync_response_parses_platform_shape() {
let raw = r#"{"accepted": 3, "skipped": 0}"#;
let parsed: SyncResponse = serde_json::from_str(raw).unwrap();
assert_eq!(parsed.accepted, 3);
assert_eq!(parsed.skipped, 0);
}
#[test]
fn page_round_trip_without_cursor() {
let with_null = r#"{"items": [], "nextBefore": null}"#;
let omitted = r#"{"items": []}"#;
let p1: Page<DummyRecord> = serde_json::from_str(with_null).unwrap();
let p2: Page<DummyRecord> = serde_json::from_str(omitted).unwrap();
assert!(p1.next_before.is_none());
assert!(p2.next_before.is_none());
}
#[test]
fn resource_const_drives_path() {
assert_eq!(<DummyResource as SyncEndpoint>::RESOURCE, "dummy");
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct DummyEnvelopedRecord {
source_id: String,
#[serde(flatten, default)]
envelope: SyncEnvelope,
}
impl HasSyncEnvelope for DummyEnvelopedRecord {
fn envelope_mut(&mut self) -> &mut SyncEnvelope {
&mut self.envelope
}
}
struct EnvelopedResource;
impl SyncEndpoint for EnvelopedResource {
const RESOURCE: &'static str = "enveloped";
const CURRENT_SCHEMA_VERSION: u32 = 7;
type Record = DummyEnvelopedRecord;
type Query = DummyQuery;
}
#[test]
fn stamp_schema_version_fills_in_when_missing() {
let items = vec![DummyEnvelopedRecord {
source_id: "a".into(),
envelope: SyncEnvelope::default(),
}];
let stamped = stamp_schema_version::<EnvelopedResource>(&items);
assert_eq!(stamped[0].envelope.schema_version, Some(7));
}
#[test]
fn stamp_schema_version_preserves_explicit_value() {
let items = vec![DummyEnvelopedRecord {
source_id: "a".into(),
envelope: SyncEnvelope {
schema_version: Some(2),
extras: None,
},
}];
let stamped = stamp_schema_version::<EnvelopedResource>(&items);
assert_eq!(stamped[0].envelope.schema_version, Some(2));
}
#[test]
fn for_endpoint_returns_envelope_with_current_version() {
let env = SyncEnvelope::for_endpoint::<EnvelopedResource>();
assert_eq!(env.schema_version, Some(7));
assert!(env.extras.is_none());
}
}