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