Skip to main content

rdap_core/
normalizer.rs

1//! Response normaliser — converts raw RDAP JSON into typed response structs.
2
3use chrono::Utc;
4use serde_json::Value;
5
6use rdap_types::{
7    asn::AsnResponse,
8    common::{RdapEntity, RdapEvent, RdapLink, RdapRemark, RdapRole, RdapStatus, ResponseMeta},
9    domain::{DomainResponse, RegistrarSummary},
10    entity::EntityResponse,
11    error::{RdapError, Result},
12    ip::{IpResponse, IpVersion},
13    nameserver::{NameserverIpAddresses, NameserverResponse},
14};
15
16/// Normalises raw RDAP responses into typed structs.
17#[derive(Debug, Clone, Default)]
18pub struct Normalizer;
19
20impl Normalizer {
21    pub fn new() -> Self {
22        Self
23    }
24
25    pub fn domain(
26        &self,
27        query: &str,
28        raw: Value,
29        source: &str,
30        cached: bool,
31    ) -> Result<DomainResponse> {
32        let meta = make_meta(source, cached);
33        let obj = require_object(&raw)?;
34
35        let entities = parse_entities(obj.get("entities"));
36        let events = parse_events(obj.get("events"));
37
38        let nameservers = obj
39            .get("nameservers")
40            .and_then(|v| v.as_array())
41            .map(|arr| {
42                arr.iter()
43                    .filter_map(|ns| {
44                        ns.get("ldhName")
45                            .or_else(|| ns.get("unicodeName"))
46                            .and_then(|v| v.as_str())
47                            .map(str::to_lowercase)
48                    })
49                    .collect::<Vec<_>>()
50            })
51            .unwrap_or_default();
52
53        let registrar = extract_registrar(&entities);
54
55        Ok(DomainResponse {
56            query: query.to_string(),
57            ldh_name: string_field(obj, "ldhName"),
58            unicode_name: string_field(obj, "unicodeName"),
59            handle: string_field(obj, "handle"),
60            status: parse_status(obj.get("status")),
61            nameservers,
62            registrar,
63            entities,
64            events,
65            links: parse_links(obj.get("links")),
66            remarks: parse_remarks(obj.get("remarks")),
67            meta,
68        })
69    }
70
71    pub fn ip(&self, query: &str, raw: Value, source: &str, cached: bool) -> Result<IpResponse> {
72        let meta = make_meta(source, cached);
73        let obj = require_object(&raw)?;
74
75        let ip_version = obj
76            .get("ipVersion")
77            .and_then(|v| v.as_str())
78            .map(|s| match s {
79                "v4" => IpVersion::V4,
80                _ => IpVersion::V6,
81            });
82
83        Ok(IpResponse {
84            query: query.to_string(),
85            handle: string_field(obj, "handle"),
86            start_address: string_field(obj, "startAddress"),
87            end_address: string_field(obj, "endAddress"),
88            ip_version,
89            name: string_field(obj, "name"),
90            allocation_type: string_field(obj, "type"),
91            country: string_field(obj, "country"),
92            parent_handle: string_field(obj, "parentHandle"),
93            status: parse_status(obj.get("status")),
94            entities: parse_entities(obj.get("entities")),
95            events: parse_events(obj.get("events")),
96            links: parse_links(obj.get("links")),
97            remarks: parse_remarks(obj.get("remarks")),
98            meta,
99        })
100    }
101
102    pub fn asn(&self, query: u32, raw: Value, source: &str, cached: bool) -> Result<AsnResponse> {
103        let meta = make_meta(source, cached);
104        let obj = require_object(&raw)?;
105
106        Ok(AsnResponse {
107            query,
108            handle: string_field(obj, "handle"),
109            start_autnum: obj
110                .get("startAutnum")
111                .and_then(|v| v.as_u64())
112                .map(|n| n as u32),
113            end_autnum: obj
114                .get("endAutnum")
115                .and_then(|v| v.as_u64())
116                .map(|n| n as u32),
117            name: string_field(obj, "name"),
118            autnum_type: string_field(obj, "type"),
119            country: string_field(obj, "country"),
120            status: parse_status(obj.get("status")),
121            entities: parse_entities(obj.get("entities")),
122            events: parse_events(obj.get("events")),
123            links: parse_links(obj.get("links")),
124            remarks: parse_remarks(obj.get("remarks")),
125            meta,
126        })
127    }
128
129    pub fn nameserver(
130        &self,
131        query: &str,
132        raw: Value,
133        source: &str,
134        cached: bool,
135    ) -> Result<NameserverResponse> {
136        let meta = make_meta(source, cached);
137        let obj = require_object(&raw)?;
138
139        let ip_addresses = {
140            let ip_obj = obj.get("ipAddresses").and_then(|v| v.as_object());
141            NameserverIpAddresses {
142                v4: ip_obj
143                    .and_then(|o| o.get("v4"))
144                    .and_then(|v| v.as_array())
145                    .map(|arr| {
146                        arr.iter()
147                            .filter_map(|v| v.as_str().map(str::to_string))
148                            .collect()
149                    })
150                    .unwrap_or_default(),
151                v6: ip_obj
152                    .and_then(|o| o.get("v6"))
153                    .and_then(|v| v.as_array())
154                    .map(|arr| {
155                        arr.iter()
156                            .filter_map(|v| v.as_str().map(str::to_string))
157                            .collect()
158                    })
159                    .unwrap_or_default(),
160            }
161        };
162
163        Ok(NameserverResponse {
164            query: query.to_string(),
165            handle: string_field(obj, "handle"),
166            ldh_name: string_field(obj, "ldhName"),
167            unicode_name: string_field(obj, "unicodeName"),
168            ip_addresses,
169            status: parse_status(obj.get("status")),
170            entities: parse_entities(obj.get("entities")),
171            events: parse_events(obj.get("events")),
172            links: parse_links(obj.get("links")),
173            remarks: parse_remarks(obj.get("remarks")),
174            meta,
175        })
176    }
177
178    pub fn entity(
179        &self,
180        query: &str,
181        raw: Value,
182        source: &str,
183        cached: bool,
184    ) -> Result<EntityResponse> {
185        let meta = make_meta(source, cached);
186        let obj = require_object(&raw)?;
187
188        let roles = obj
189            .get("roles")
190            .and_then(|v| v.as_array())
191            .map(|arr| {
192                arr.iter()
193                    .filter_map(|v| serde_json::from_value::<RdapRole>(v.clone()).ok())
194                    .collect()
195            })
196            .unwrap_or_default();
197
198        Ok(EntityResponse {
199            query: query.to_string(),
200            handle: string_field(obj, "handle"),
201            vcard_array: obj.get("vcardArray").cloned(),
202            roles,
203            status: parse_status(obj.get("status")),
204            entities: parse_entities(obj.get("entities")),
205            events: parse_events(obj.get("events")),
206            links: parse_links(obj.get("links")),
207            remarks: parse_remarks(obj.get("remarks")),
208            meta,
209        })
210    }
211}
212
213// ── Private helpers ───────────────────────────────────────────────────────────
214
215fn make_meta(source: &str, cached: bool) -> ResponseMeta {
216    ResponseMeta {
217        source: source.to_string(),
218        queried_at: Utc::now().to_rfc3339(),
219        cached,
220    }
221}
222
223fn require_object(value: &Value) -> Result<&serde_json::Map<String, Value>> {
224    value.as_object().ok_or_else(|| RdapError::ParseError {
225        reason: "Expected a JSON object at the response root".to_string(),
226    })
227}
228
229fn string_field(obj: &serde_json::Map<String, Value>, key: &str) -> Option<String> {
230    obj.get(key).and_then(|v| v.as_str()).map(str::to_string)
231}
232
233fn parse_status(value: Option<&Value>) -> Vec<RdapStatus> {
234    value
235        .and_then(|v| v.as_array())
236        .map(|arr| {
237            arr.iter()
238                .filter_map(|v| serde_json::from_value::<RdapStatus>(v.clone()).ok())
239                .collect()
240        })
241        .unwrap_or_default()
242}
243
244fn parse_events(value: Option<&Value>) -> Vec<RdapEvent> {
245    value
246        .and_then(|v| v.as_array())
247        .map(|arr| {
248            arr.iter()
249                .filter_map(|v| serde_json::from_value::<RdapEvent>(v.clone()).ok())
250                .collect()
251        })
252        .unwrap_or_default()
253}
254
255fn parse_links(value: Option<&Value>) -> Vec<RdapLink> {
256    value
257        .and_then(|v| v.as_array())
258        .map(|arr| {
259            arr.iter()
260                .filter_map(|v| serde_json::from_value::<RdapLink>(v.clone()).ok())
261                .collect()
262        })
263        .unwrap_or_default()
264}
265
266fn parse_remarks(value: Option<&Value>) -> Vec<RdapRemark> {
267    value
268        .and_then(|v| v.as_array())
269        .map(|arr| {
270            arr.iter()
271                .filter_map(|v| serde_json::from_value::<RdapRemark>(v.clone()).ok())
272                .collect()
273        })
274        .unwrap_or_default()
275}
276
277fn parse_entities(value: Option<&Value>) -> Vec<RdapEntity> {
278    value
279        .and_then(|v| v.as_array())
280        .map(|arr| {
281            arr.iter()
282                .filter_map(|v| serde_json::from_value::<RdapEntity>(v.clone()).ok())
283                .collect()
284        })
285        .unwrap_or_default()
286}
287
288fn extract_registrar(entities: &[RdapEntity]) -> Option<RegistrarSummary> {
289    let registrar_entity = entities
290        .iter()
291        .find(|e| e.roles.iter().any(|r| matches!(r, RdapRole::Registrar)))?;
292
293    let name = registrar_entity
294        .vcard_array
295        .as_ref()
296        .and_then(extract_vcard_name);
297
298    let url = registrar_entity
299        .links
300        .iter()
301        .find(|l| l.rel.as_deref() == Some("self"))
302        .map(|l| l.href.clone());
303
304    Some(RegistrarSummary {
305        name,
306        handle: registrar_entity.handle.clone(),
307        url,
308        abuse_email: None,
309        abuse_phone: None,
310    })
311}
312
313fn extract_vcard_name(vcard: &Value) -> Option<String> {
314    let outer = vcard.as_array()?;
315    let props = outer.get(1)?.as_array()?;
316
317    for prop in props {
318        let arr = prop.as_array()?;
319        if arr.first()?.as_str()? == "fn" {
320            return arr.get(3).and_then(|v| v.as_str()).map(str::to_string);
321        }
322    }
323    None
324}
325
326// ── Tests ─────────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use serde_json::json;
332
333    fn norm() -> Normalizer {
334        Normalizer::new()
335    }
336
337    #[test]
338    fn domain_basic_fields() {
339        let raw = json!({
340            "ldhName": "EXAMPLE.COM",
341            "unicodeName": "example.com",
342            "handle": "DOMAIN-HANDLE-1",
343            "status": ["active"],
344            "nameservers": [
345                { "ldhName": "NS1.EXAMPLE.COM" },
346                { "ldhName": "NS2.EXAMPLE.COM" }
347            ]
348        });
349        let res = norm()
350            .domain("example.com", raw, "https://rdap.example/", false)
351            .unwrap();
352
353        assert_eq!(res.query, "example.com");
354        assert_eq!(res.ldh_name.as_deref(), Some("EXAMPLE.COM"));
355        assert!(res.is_active());
356        assert_eq!(res.nameservers, vec!["ns1.example.com", "ns2.example.com"]);
357    }
358
359    #[test]
360    fn ip_basic_v4_fields() {
361        let raw = json!({
362            "handle": "NET-192-0-2-0-1",
363            "startAddress": "192.0.2.0",
364            "ipVersion": "v4",
365            "country": "US"
366        });
367        let res = norm()
368            .ip("192.0.2.0/24", raw, "https://rdap.arin.net/", false)
369            .unwrap();
370
371        assert_eq!(res.country.as_deref(), Some("US"));
372        assert_eq!(res.ip_version, Some(IpVersion::V4));
373    }
374
375    #[test]
376    fn asn_basic_fields() {
377        let raw = json!({
378            "handle": "AS15169",
379            "startAutnum": 15169,
380            "name": "GOOGLE",
381            "country": "US"
382        });
383        let res = norm()
384            .asn(15169, raw, "https://rdap.arin.net/", false)
385            .unwrap();
386
387        assert_eq!(res.query, 15169);
388        assert_eq!(res.name.as_deref(), Some("GOOGLE"));
389    }
390
391    #[test]
392    fn nameserver_basic_fields() {
393        let raw = json!({
394            "ldhName": "NS1.EXAMPLE.COM",
395            "ipAddresses": {
396                "v4": ["192.0.2.1"],
397                "v6": ["2001:db8::1"]
398            }
399        });
400        let res = norm()
401            .nameserver("ns1.example.com", raw, "s", false)
402            .unwrap();
403
404        assert_eq!(res.ip_addresses.v4, vec!["192.0.2.1"]);
405        assert_eq!(res.ip_addresses.v6, vec!["2001:db8::1"]);
406    }
407
408    #[test]
409    fn domain_non_object_json_returns_error() {
410        let res = norm().domain("example.com", json!([1, 2, 3]), "s", false);
411        assert!(res.is_err());
412    }
413}