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