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
15#[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
23impl<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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38#[non_exhaustive]
39pub enum InvalidRequest {
40 Level(u8),
42 EmptyQuery,
44 NotFinite(&'static str),
46 OutOfRange(&'static str),
48 Negative(&'static str),
50}
51
52pub 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
62pub 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
75pub 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
85pub 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 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 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 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
169type Object = Map<String, Value>;
174
175fn 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
198fn 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 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 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 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 Rejected {
314 status: u16,
315 code: String,
316 message: String,
317 },
318 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#[derive(Clone, Debug, Default, PartialEq)]
355#[non_exhaustive]
356pub struct Lookup {
357 pub postcode: String,
358 pub valid: bool,
360 pub administrative_address: Option<AdministrativeAddress>,
362 pub recent_house_address: Option<RecentHouseAddress>,
364 pub building_use_status: Option<String>,
366 pub other_building_info: Option<serde_json::Value>,
368 pub point_geometry: Option<serde_json::Value>,
370 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 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 pub code: String,
403 pub label: Option<String>,
405}
406
407#[derive(Clone, Debug, Default, PartialEq)]
408#[non_exhaustive]
409pub struct Reverse {
410 pub found: bool,
412 pub coordinate: Option<Coordinate>,
414 pub unit: Option<NearestUnit>,
416 pub area: Option<String>,
417 pub district: Option<String>,
418 pub state: Option<String>,
419 pub message: Option<String>,
421 pub radius_m: Option<f64>,
423 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 pub confidence: Option<String>,
443 pub state_name: Option<String>,
445 pub lga_name: Option<String>,
447 pub locality_name: Option<String>,
449 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 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}