1use 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
15const MAX_PARTIAL: usize = 64;
17
18#[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
26impl<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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum InvalidRequest {
43 Level(u8),
45 EmptyQuery,
47 QueryTooLong,
49 NotFinite(&'static str),
51 OutOfRange(&'static str),
53 Negative(&'static str),
55}
56
57pub 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
67pub 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
83pub 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
93pub 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 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 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 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
177type Object = Map<String, Value>;
182
183fn 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
206fn 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 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 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 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 Rejected {
322 status: u16,
323 code: String,
324 message: String,
325 },
326 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#[derive(Clone, Debug, Default, PartialEq)]
366#[non_exhaustive]
367pub struct Lookup {
368 pub postcode: String,
369 pub valid: bool,
371 pub administrative_address: Option<AdministrativeAddress>,
373 pub recent_house_address: Option<RecentHouseAddress>,
375 pub building_use_status: Option<String>,
377 pub other_building_info: Option<serde_json::Value>,
379 pub point_geometry: Option<serde_json::Value>,
381 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 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 pub code: String,
414 pub label: Option<String>,
416}
417
418#[derive(Clone, Debug, Default, PartialEq)]
419#[non_exhaustive]
420pub struct Reverse {
421 pub found: bool,
423 pub coordinate: Option<Coordinate>,
425 pub unit: Option<NearestUnit>,
427 pub area: Option<String>,
428 pub district: Option<String>,
429 pub state: Option<String>,
430 pub message: Option<String>,
432 pub radius_m: Option<f64>,
434 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 pub confidence: Option<String>,
454 pub state_name: Option<String>,
456 pub lga_name: Option<String>,
458 pub locality_name: Option<String>,
460 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 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}