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