Skip to main content

ng_postcode/
api.rs

1//! The NIPOST Postcode API as plain data: requests to send and responses to
2//! decode. Nothing here performs I/O, so it works with any HTTP client.
3//!
4//! Assembly and disassembly are not modelled because [`Postcode`] does both
5//! offline.
6
7use std::fmt;
8
9use serde_json::{Map, Value};
10
11use crate::{Postcode, Segment};
12
13pub const BASE_URL: &str = "https://api.postcode.gov.ng";
14
15/// A GET request whose successful response decodes to `T`.
16#[derive(Clone, Debug)]
17pub struct Request<T> {
18    pub path: &'static str,
19    pub query: Vec<(&'static str, String)>,
20    read: fn(&Value) -> Option<T>,
21}
22
23/// Two requests are equal when they send the same thing.
24impl<T> PartialEq for Request<T> {
25    fn eq(&self, other: &Self) -> bool {
26        self.path == other.path && self.query == other.query
27    }
28}
29
30#[derive(Clone, Copy, Debug, PartialEq)]
31pub struct Coordinate {
32    pub lat: f64,
33    pub lng: f64,
34}
35
36/// Why a request was not built. Nothing is sent.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum InvalidRequest {
39    /// The lookup level is outside 1 to 5.
40    Level(u8),
41    /// The autocomplete text is empty, which the live API never answers.
42    EmptyQuery,
43    /// The named coordinate or distance is not a finite number.
44    NotFinite(&'static str),
45}
46
47/// Resolves a postcode. Levels are cumulative from 1 (validity only) to 5,
48/// and the API caps the answer at the level granted to the key.
49pub fn lookup(code: Postcode, level: u8) -> Result<Request<Lookup>, InvalidRequest> {
50    if !(1..=5).contains(&level) {
51        return Err(InvalidRequest::Level(level));
52    }
53    let query = [("code", code.to_string()), ("level", level.to_string())];
54    Ok(Request::get("/v1/lookup", query, read_lookup))
55}
56
57/// Suggests completions for a partial postcode such as `EK 01 A`.
58pub fn autocomplete(partial: &str) -> Result<Request<Autocomplete>, InvalidRequest> {
59    if partial.trim().is_empty() {
60        return Err(InvalidRequest::EmptyQuery);
61    }
62    let query = [("q", partial.to_owned())];
63    Ok(Request::get(
64        "/v1/search/autocomplete",
65        query,
66        read_autocomplete,
67    ))
68}
69
70/// Finds the postcode of the nearest building, within 25 m unless
71/// `max_distance_m` says otherwise. The API clamps it to 250 m.
72pub fn reverse(
73    at: Coordinate,
74    max_distance_m: Option<f64>,
75) -> Result<Request<Reverse>, InvalidRequest> {
76    let query = around(at, "max_distance_m", max_distance_m)?;
77    Ok(Request::get("/v1/search/reverse", query, read_reverse))
78}
79
80/// Lists buildings around a point, nearest first, within 300 m unless
81/// `radius_m` says otherwise. Empty when nothing is in range.
82pub fn nearby(
83    at: Coordinate,
84    radius_m: Option<f64>,
85) -> Result<Request<Vec<NearbyUnit>>, InvalidRequest> {
86    let query = around(at, "radius", radius_m)?;
87    Ok(Request::get("/v1/search/nearby", query, read_nearby))
88}
89
90fn around(
91    at: Coordinate,
92    key: &'static str,
93    metres: Option<f64>,
94) -> Result<Vec<(&'static str, String)>, InvalidRequest> {
95    [("lat", Some(at.lat)), ("lng", Some(at.lng)), (key, metres)]
96        .into_iter()
97        .filter_map(|(key, value)| Some((key, value?)))
98        .map(|(key, value)| match value.is_finite() {
99            // Adding zero writes -0.0 as "0", as the other implementations do.
100            true => Ok((key, (value + 0.0).to_string())),
101            false => Err(InvalidRequest::NotFinite(key)),
102        })
103        .collect()
104}
105
106impl<T> Request<T> {
107    fn get(
108        path: &'static str,
109        query: impl IntoIterator<Item = (&'static str, String)>,
110        read: fn(&Value) -> Option<T>,
111    ) -> Self {
112        Self {
113            path,
114            query: query.into_iter().collect(),
115            read,
116        }
117    }
118
119    /// Decodes the response to this request from its status and body.
120    pub fn decode(&self, status: u16, body: &str) -> Result<T, ApiError> {
121        let malformed = |reason: &str| ApiError::Malformed {
122            status,
123            reason: reason.to_owned(),
124        };
125        let rejected = |code: Option<String>, message: Option<String>| ApiError::Rejected {
126            status,
127            code: code.unwrap_or_else(|| "unknown_error".to_owned()),
128            message: message.unwrap_or_default(),
129        };
130        // Read as a tree and checked by hand: `data` this version cannot read must not
131        // hide the `error` beside it, and keys beside the two are ignored.
132        let envelope: Value =
133            serde_json::from_str(body).map_err(|error| malformed(&error.to_string()))?;
134        let envelope = envelope
135            .as_object()
136            .ok_or_else(|| malformed("expected a JSON object"))?;
137        match envelope.get("error") {
138            Some(Value::Object(failure)) => {
139                return Err(rejected(text(failure, "code"), text(failure, "message")))
140            }
141            Some(Value::String(message)) => return Err(rejected(None, Some(message.clone()))),
142            _ => {}
143        }
144        if !(200..300).contains(&status) {
145            return Err(malformed("an error status without an error"));
146        }
147        (self.read)(envelope.get("data").unwrap_or(&Value::Null))
148            .ok_or_else(|| malformed("unexpected data"))
149    }
150}
151
152// The readers below are strict about one thing each: the field that carries the
153// answer. Any other field of the wrong type is read as absent, so a change NIPOST
154// makes to one field cannot fail the whole response.
155
156type Object = Map<String, Value>;
157
158fn text(data: &Object, key: &str) -> Option<String> {
159    data.get(key)?.as_str().map(str::to_owned)
160}
161
162fn number(data: &Object, key: &str) -> Option<f64> {
163    data.get(key)?.as_f64()
164}
165
166fn object<'a>(data: &'a Object, key: &str) -> Option<&'a Object> {
167    data.get(key)?.as_object()
168}
169
170fn raw(data: &Object, key: &str) -> Option<Value> {
171    data.get(key).filter(|value| !value.is_null()).cloned()
172}
173
174fn read_lookup(data: &Value) -> Option<Lookup> {
175    let data = data.as_object()?;
176    Some(Lookup {
177        postcode: text(data, "postcode").unwrap_or_default(),
178        // A body without it is malformed, not an unassigned code.
179        valid: data.get("valid")?.as_bool()?,
180        administrative_address: object(data, "administrative_address").map(|admin| {
181            AdministrativeAddress {
182                state_name: text(admin, "state_name"),
183                lga_name: text(admin, "lga_name"),
184                locality_name: text(admin, "locality_name"),
185                zone: text(admin, "zone"),
186            }
187        }),
188        recent_house_address: object(data, "recent_house_address").map(|recent| {
189            RecentHouseAddress {
190                recent: text(recent, "recent"),
191            }
192        }),
193        building_use_status: text(data, "building_use_status"),
194        other_building_info: raw(data, "other_building_info"),
195        point_geometry: raw(data, "point_geometry"),
196        status: text(data, "status"),
197        verified: data.get("verified").and_then(Value::as_bool),
198    })
199}
200
201fn read_autocomplete(data: &Value) -> Option<Autocomplete> {
202    let data = data.as_object()?;
203    let suggestions = data.get("suggestions").and_then(Value::as_array);
204    Some(Autocomplete {
205        // A segment name this version does not know is `None`, not a failed response.
206        segment: match data.get("segment").and_then(Value::as_str) {
207            Some("state") => Some(Segment::State),
208            Some("lga") => Some(Segment::Lga),
209            Some("district") => Some(Segment::District),
210            Some("area") => Some(Segment::Area),
211            Some("unit") => Some(Segment::Unit),
212            _ => None,
213        },
214        suggestions: suggestions
215            .into_iter()
216            .flatten()
217            .filter_map(Value::as_object)
218            .map(|item| Suggestion {
219                code: text(item, "code").unwrap_or_default(),
220                label: text(item, "label"),
221            })
222            .collect(),
223    })
224}
225
226fn read_reverse(data: &Value) -> Option<Reverse> {
227    let data = data.as_object()?;
228    let point = data.get("coordinate").and_then(Value::as_array);
229    Some(Reverse {
230        // A body without it is malformed, not an empty search.
231        found: data.get("found")?.as_bool()?,
232        coordinate: point.and_then(|point| match point.as_slice() {
233            [lng, lat] => Some([lng.as_f64()?, lat.as_f64()?]),
234            _ => None,
235        }),
236        unit: object(data, "unit").map(|unit| NearestUnit {
237            postcode: text(unit, "postcode").unwrap_or_default(),
238            display: text(unit, "display").unwrap_or_default(),
239            distance_m: number(unit, "distance_m"),
240            confidence: text(unit, "confidence"),
241            state_name: text(unit, "state_name"),
242            lga_name: text(unit, "lga_name"),
243            locality_name: text(unit, "locality_name"),
244            address: text(unit, "address"),
245        }),
246        area: text(data, "area"),
247        district: text(data, "district"),
248        state: text(data, "state"),
249        message: text(data, "message"),
250        radius_m: number(data, "radius_m"),
251        depth: text(data, "depth"),
252    })
253}
254
255fn read_nearby(data: &Value) -> Option<Vec<NearbyUnit>> {
256    let units = data.as_array()?.iter().filter_map(Value::as_object);
257    Some(
258        units
259            .map(|unit| NearbyUnit {
260                postcode: text(unit, "postcode").unwrap_or_default(),
261                display: text(unit, "display").unwrap_or_default(),
262                distance_m: number(unit, "distance_m"),
263            })
264            .collect(),
265    )
266}
267
268#[derive(Clone, Debug, PartialEq, Eq)]
269pub enum ApiError {
270    /// The API refused the request, for example `auth_required` (401),
271    /// `insufficient_credits` (402) or a rate limit (429).
272    Rejected {
273        status: u16,
274        code: String,
275        message: String,
276    },
277    /// The body was not the documented JSON envelope.
278    Malformed { status: u16, reason: String },
279}
280
281impl fmt::Display for ApiError {
282    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283        match self {
284            Self::Rejected {
285                status,
286                code,
287                message,
288            } => write!(f, "{code} ({status}): {message}"),
289            Self::Malformed { status, reason } => {
290                write!(f, "unreadable response ({status}): {reason}")
291            }
292        }
293    }
294}
295
296impl std::error::Error for ApiError {}
297
298impl fmt::Display for InvalidRequest {
299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300        match self {
301            Self::Level(level) => write!(f, "level must be 1 to 5, got {level}"),
302            Self::EmptyQuery => f.write_str("autocomplete text must not be empty"),
303            Self::NotFinite(name) => write!(f, "{name} must be a finite number"),
304        }
305    }
306}
307
308impl std::error::Error for InvalidRequest {}
309
310/// Fields above the level granted to the key are `None`.
311#[derive(Clone, Debug, Default, PartialEq)]
312pub struct Lookup {
313    pub postcode: String,
314    /// Required: a body without it is malformed, not an unassigned code.
315    pub valid: bool,
316    /// Level 2.
317    pub administrative_address: Option<AdministrativeAddress>,
318    /// Level 2.
319    pub recent_house_address: Option<RecentHouseAddress>,
320    /// Level 3.
321    pub building_use_status: Option<String>,
322    /// Level 4. Undocumented, so left untyped.
323    pub other_building_info: Option<serde_json::Value>,
324    /// Level 5. Undocumented, so left untyped.
325    pub point_geometry: Option<serde_json::Value>,
326    /// `valid`, `not_found`, or `invalid` for a malformed code. Sent at every level.
327    pub status: Option<String>,
328    pub verified: Option<bool>,
329}
330
331#[derive(Clone, Debug, Default, PartialEq, Eq)]
332pub struct AdministrativeAddress {
333    pub state_name: Option<String>,
334    pub lga_name: Option<String>,
335    pub locality_name: Option<String>,
336    pub zone: Option<String>,
337}
338
339#[derive(Clone, Debug, Default, PartialEq, Eq)]
340pub struct RecentHouseAddress {
341    pub recent: Option<String>,
342}
343
344#[derive(Clone, Debug, Default, PartialEq, Eq)]
345pub struct Autocomplete {
346    /// The segment the suggestions complete.
347    pub segment: Option<Segment>,
348    pub suggestions: Vec<Suggestion>,
349}
350
351#[derive(Clone, Debug, Default, PartialEq, Eq)]
352pub struct Suggestion {
353    /// The value of the segment being completed, such as `A03`, not a full prefix.
354    pub code: String,
355    /// Documented by NIPOST but not sent by the live API as of October 2026.
356    pub label: Option<String>,
357}
358
359#[derive(Clone, Debug, Default, PartialEq)]
360pub struct Reverse {
361    /// Required: a body without it is malformed, not an empty search.
362    pub found: bool,
363    /// The queried point, echoed back as `[lng, lat]`.
364    pub coordinate: Option<[f64; 2]>,
365    /// The nearest building, absent when nothing is in range.
366    pub unit: Option<NearestUnit>,
367    pub area: Option<String>,
368    pub district: Option<String>,
369    pub state: Option<String>,
370    /// Set when nothing is in range.
371    pub message: Option<String>,
372    /// The radius the API actually applied.
373    pub radius_m: Option<f64>,
374    /// How deep the match goes, such as `unit`.
375    pub depth: Option<String>,
376}
377
378#[derive(Clone, Debug, Default, PartialEq)]
379pub struct NearbyUnit {
380    pub postcode: String,
381    pub display: String,
382    pub distance_m: Option<f64>,
383}
384
385#[derive(Clone, Debug, Default, PartialEq)]
386pub struct NearestUnit {
387    pub postcode: String,
388    pub display: String,
389    pub distance_m: Option<f64>,
390    /// `high`, `medium` or `low`, graded by distance.
391    pub confidence: Option<String>,
392    /// Level 2.
393    pub state_name: Option<String>,
394    /// Level 2.
395    pub lga_name: Option<String>,
396    /// Level 2.
397    pub locality_name: Option<String>,
398    /// Level 2.
399    pub address: Option<String>,
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    const HERE: Coordinate = Coordinate {
407        lat: 7.62,
408        lng: 5.22,
409    };
410
411    fn pairs<const N: usize>(query: [(&'static str, &str); N]) -> Vec<(&'static str, String)> {
412        query.map(|(key, value)| (key, value.to_owned())).to_vec()
413    }
414
415    #[test]
416    fn builds_requests() {
417        let code: Postcode = "ek01a03fk01".parse().unwrap();
418        let request = lookup(code, 3).unwrap();
419        assert_eq!(request.path, "/v1/lookup");
420        assert_eq!(
421            request.query,
422            pairs([("code", "EK-01-A03-FK-01"), ("level", "3")])
423        );
424
425        assert_eq!(lookup(code, 0), Err(InvalidRequest::Level(0)));
426        assert_eq!(autocomplete(" "), Err(InvalidRequest::EmptyQuery));
427        let nowhere = Coordinate {
428            lat: f64::NAN,
429            ..HERE
430        };
431        assert_eq!(nearby(nowhere, None), Err(InvalidRequest::NotFinite("lat")));
432
433        let request = autocomplete("EK 01 A").unwrap();
434        assert_eq!(request.path, "/v1/search/autocomplete");
435        assert_eq!(request.query, pairs([("q", "EK 01 A")]));
436
437        let request = reverse(HERE, Some(100.0)).unwrap();
438        assert_eq!(request.path, "/v1/search/reverse");
439        assert_eq!(
440            request.query,
441            pairs([("lat", "7.62"), ("lng", "5.22"), ("max_distance_m", "100")])
442        );
443
444        let request = nearby(HERE, None).unwrap();
445        assert_eq!(request.path, "/v1/search/nearby");
446        assert_eq!(request.query, pairs([("lat", "7.62"), ("lng", "5.22")]));
447    }
448
449    #[test]
450    fn decodes_the_documented_lookup() {
451        let body = r#"{ "data": {
452            "postcode": "EK-01-A03-FK-01",
453            "valid": true,
454            "administrative_address": { "state_name": "EKITI", "lga_name": "ADO EKITI", "locality_name": "ADO EKITI", "zone": "SOUTH WEST" },
455            "recent_house_address": { "recent": "NTA ROAD, BACK OF FABIAN HOTEL, ADO EKITI" },
456            "building_use_status": "residential"
457        } }"#;
458        let found = lookup("EK-01-A03-FK-01".parse().unwrap(), 3)
459            .unwrap()
460            .decode(200, body)
461            .unwrap();
462        assert!(found.valid);
463        assert_eq!(
464            found.administrative_address.unwrap().zone.as_deref(),
465            Some("SOUTH WEST")
466        );
467        assert_eq!(found.building_use_status.as_deref(), Some("residential"));
468        assert_eq!(found.point_geometry, None);
469    }
470
471    #[test]
472    fn decodes_fields_missing_below_the_granted_level() {
473        let body = r#"{ "data": { "postcode": "EK-01-A03-FK-01", "valid": true } }"#;
474        let found = lookup("EK-01-A03-FK-01".parse().unwrap(), 1)
475            .unwrap()
476            .decode(200, body)
477            .unwrap();
478        assert_eq!(found.administrative_address, None);
479    }
480
481    #[test]
482    fn decodes_autocomplete_and_reverse() {
483        let body = r#"{ "data": { "segment": "lga", "suggestions": [{ "code": "EK-01", "label": "ADO EKITI" }] } }"#;
484        let found = autocomplete("EK").unwrap().decode(200, body).unwrap();
485        assert_eq!(found.segment, Some(Segment::Lga));
486        assert_eq!(found.suggestions[0].code, "EK-01");
487
488        let body = r#"{ "data": { "found": false, "coordinate": [5.22, 7.62], "message": "no unit in range", "radius_m": 25 } }"#;
489        let found = reverse(HERE, None).unwrap().decode(200, body).unwrap();
490        assert_eq!((found.found, found.unit), (false, None));
491        assert_eq!(found.radius_m, Some(25.0));
492    }
493
494    #[test]
495    fn turns_error_envelopes_and_bad_bodies_into_values() {
496        let request = lookup("EK-01-A03-FK-01".parse().unwrap(), 1).unwrap();
497
498        // What the live API answered on 2 October 2026 when called without a key.
499        let body = r#"{"error":{"code":"auth_required","message":"an API key is required; pass it in the X-API-Key header"}}"#;
500        let expected = ApiError::Rejected {
501            status: 401,
502            code: "auth_required".to_owned(),
503            message: "an API key is required; pass it in the X-API-Key header".to_owned(),
504        };
505        assert_eq!(request.decode(401, body), Err(expected));
506
507        let malformed = request.decode(502, "<html>Bad Gateway</html>");
508        assert!(matches!(
509            malformed,
510            Err(ApiError::Malformed { status: 502, .. })
511        ));
512        let empty = request.decode(200, r#"{"data":null}"#);
513        assert!(matches!(empty, Err(ApiError::Malformed { .. })));
514    }
515
516    #[test]
517    fn tolerates_what_the_api_may_add_or_leave_out() {
518        let request = lookup("EK-01-A03-FK-01".parse().unwrap(), 1).unwrap();
519        let body =
520            r#"{"data":{"postcode":"EK-01-A03-FK-01","valid":true},"meta":{"request_id":"r1"}}"#;
521        assert!(request.decode(200, body).unwrap().valid);
522
523        let body = r#"{"error":{"code":"rate_limited"},"request_id":"r1"}"#;
524        let expected = ApiError::Rejected {
525            status: 429,
526            code: "rate_limited".to_owned(),
527            message: String::new(),
528        };
529        assert_eq!(request.decode(429, body), Err(expected));
530
531        let body = r#"{"data":{"segment":"street","suggestions":null}}"#;
532        let found = autocomplete("EK").unwrap().decode(200, body).unwrap();
533        assert_eq!((found.segment, found.suggestions.len()), (None, 0));
534
535        let body = r#"{"data":[{"postcode":null,"distance_m":3}]}"#;
536        let units = nearby(HERE, None).unwrap().decode(200, body).unwrap();
537        assert_eq!(
538            (units[0].postcode.as_str(), units[0].distance_m),
539            ("", Some(3.0))
540        );
541
542        let body = r#"{"data":{"found":true,"unit":{"postcode":"EK-01-A03-FK-01"}}}"#;
543        let found = reverse(HERE, None).unwrap().decode(200, body).unwrap();
544        assert_eq!(found.unit.unwrap().distance_m, None);
545    }
546}