Skip to main content

alarm_dot_com/models/
jsonapi.rs

1//! JSON:API response types for Alarm.com's API.
2//!
3//! Alarm.com uses a variant of the JSON:API specification. Responses contain
4//! `data` (single or array), `included` (related resources), and `relationships`.
5
6use serde::de::Error as _;
7use serde::{Deserialize, Deserializer, Serialize};
8use std::collections::HashMap;
9
10/// Deserialize an `id` field that may be either a JSON string or number.
11/// Alarm.com sometimes returns numeric IDs (e.g. `"id": 19991856`) instead
12/// of the JSON:API-standard string form (`"id": "19991856"`).
13fn deserialize_id<'de, D>(deserializer: D) -> std::result::Result<String, D::Error>
14where
15    D: Deserializer<'de>,
16{
17    let value = serde_json::Value::deserialize(deserializer)?;
18    match value {
19        serde_json::Value::String(s) => Ok(s),
20        serde_json::Value::Number(n) => Ok(n.to_string()),
21        other => Ok(other.to_string()),
22    }
23}
24
25/// A JSON:API document with a single resource in `data`.
26#[derive(Debug, Clone, Deserialize, Serialize)]
27pub struct SingleDocument {
28    pub data: Resource,
29    #[serde(default)]
30    pub included: Vec<Resource>,
31}
32
33/// A JSON:API document with multiple resources in `data`.
34#[derive(Debug, Clone, Deserialize, Serialize)]
35pub struct MultiDocument {
36    pub data: Vec<Resource>,
37    #[serde(default)]
38    pub included: Vec<Resource>,
39}
40
41/// A JSON:API error document.
42#[derive(Debug, Clone, Deserialize, Serialize)]
43pub struct ErrorDocument {
44    pub errors: Vec<ApiError>,
45}
46
47/// A single error entry in a JSON:API error document.
48#[derive(Debug, Clone, Deserialize, Serialize)]
49pub struct ApiError {
50    #[serde(default)]
51    pub status: Option<String>,
52    #[serde(default)]
53    pub code: Option<String>,
54    #[serde(default)]
55    pub title: Option<String>,
56    #[serde(default)]
57    pub detail: Option<String>,
58}
59
60/// A JSON:API resource object.
61#[derive(Debug, Clone, Deserialize, Serialize)]
62pub struct Resource {
63    #[serde(deserialize_with = "deserialize_id")]
64    pub id: String,
65    #[serde(rename = "type")]
66    pub resource_type: String,
67    #[serde(default)]
68    pub attributes: serde_json::Value,
69    #[serde(default)]
70    pub relationships: HashMap<String, Relationship>,
71}
72
73/// A JSON:API relationship object.
74#[derive(Debug, Clone, Deserialize, Serialize)]
75pub struct Relationship {
76    pub data: RelationshipData,
77}
78
79/// Relationship data can be a single identifier or a list of identifiers.
80#[derive(Debug, Clone, Deserialize, Serialize)]
81#[serde(untagged)]
82pub enum RelationshipData {
83    Single(ResourceIdentifier),
84    Many(Vec<ResourceIdentifier>),
85    Null,
86}
87
88/// A JSON:API resource identifier (type + id).
89#[derive(Debug, Clone, Deserialize, Serialize)]
90pub struct ResourceIdentifier {
91    #[serde(deserialize_with = "deserialize_id")]
92    pub id: String,
93    #[serde(rename = "type")]
94    pub resource_type: String,
95}
96
97impl Resource {
98    /// Extract all related resource IDs for a given relationship key.
99    pub fn related_ids(&self, key: &str) -> Vec<String> {
100        match self.relationships.get(key) {
101            Some(rel) => match &rel.data {
102                RelationshipData::Single(ident) => vec![ident.id.clone()],
103                RelationshipData::Many(idents) => idents.iter().map(|i| i.id.clone()).collect(),
104                RelationshipData::Null => vec![],
105            },
106            None => vec![],
107        }
108    }
109}
110
111/// Try to parse a response body as either a single or multi document.
112/// Falls back to error document detection.
113#[derive(Debug, Clone)]
114pub enum ApiResponse {
115    Single(SingleDocument),
116    Multi(MultiDocument),
117    Error(ErrorDocument),
118}
119
120impl ApiResponse {
121    /// Parse a JSON string into an `ApiResponse`.
122    pub fn parse(body: &str) -> std::result::Result<Self, serde_json::Error> {
123        // Try single document first
124        if let Ok(doc) = serde_json::from_str::<SingleDocument>(body) {
125            return Ok(ApiResponse::Single(doc));
126        }
127        // Try multi document
128        if let Ok(doc) = serde_json::from_str::<MultiDocument>(body) {
129            return Ok(ApiResponse::Multi(doc));
130        }
131        // Try error document
132        if let Ok(doc) = serde_json::from_str::<ErrorDocument>(body) {
133            return Ok(ApiResponse::Error(doc));
134        }
135        // Fall back: try to deserialize as generic Value and return unexpected
136        Err(serde_json::Error::custom(
137            "response did not match any known JSON:API format",
138        ))
139    }
140
141    /// Get the data resources from the response.
142    pub fn resources(&self) -> Vec<&Resource> {
143        match self {
144            ApiResponse::Single(doc) => vec![&doc.data],
145            ApiResponse::Multi(doc) => doc.data.iter().collect(),
146            ApiResponse::Error(_) => vec![],
147        }
148    }
149
150    /// Get included resources from the response.
151    pub fn included(&self) -> &[Resource] {
152        match self {
153            ApiResponse::Single(doc) => &doc.included,
154            ApiResponse::Multi(doc) => &doc.included,
155            ApiResponse::Error(_) => &[],
156        }
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn parse_single_document() {
166        let json = r#"{
167            "data": {
168                "id": "123",
169                "type": "devices/partition",
170                "attributes": { "state": 1, "desiredState": 1 },
171                "relationships": {}
172            },
173            "included": []
174        }"#;
175        let resp = ApiResponse::parse(json).unwrap();
176        assert!(matches!(resp, ApiResponse::Single(_)));
177        let resources = resp.resources();
178        assert_eq!(resources.len(), 1);
179        assert_eq!(resources[0].id, "123");
180    }
181
182    #[test]
183    fn parse_multi_document() {
184        let json = r#"{
185            "data": [
186                { "id": "1", "type": "devices/sensor", "attributes": {}, "relationships": {} },
187                { "id": "2", "type": "devices/sensor", "attributes": {}, "relationships": {} }
188            ],
189            "included": []
190        }"#;
191        let resp = ApiResponse::parse(json).unwrap();
192        assert!(matches!(resp, ApiResponse::Multi(_)));
193        assert_eq!(resp.resources().len(), 2);
194    }
195
196    #[test]
197    fn parse_error_document() {
198        let json = r#"{
199            "errors": [
200                { "status": "401", "code": "401", "title": "Unauthorized" }
201            ]
202        }"#;
203        let resp = ApiResponse::parse(json).unwrap();
204        assert!(matches!(resp, ApiResponse::Error(_)));
205    }
206
207    #[test]
208    fn resource_related_ids() {
209        let json = r#"{
210            "id": "sys-1",
211            "type": "systems/system",
212            "attributes": {},
213            "relationships": {
214                "partitions": {
215                    "data": [
216                        { "id": "p-1", "type": "devices/partition" },
217                        { "id": "p-2", "type": "devices/partition" }
218                    ]
219                },
220                "primaryLock": {
221                    "data": { "id": "l-1", "type": "devices/lock" }
222                }
223            }
224        }"#;
225        let res: Resource = serde_json::from_str(json).unwrap();
226        assert_eq!(res.related_ids("partitions"), vec!["p-1", "p-2"]);
227        assert_eq!(res.related_ids("primaryLock"), vec!["l-1"]);
228        assert!(res.related_ids("nonexistent").is_empty());
229    }
230}