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}
49
50pub 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
60pub 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
73pub 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
83pub 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 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 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 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
160type 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
178fn 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 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 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 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 Rejected {
294 status: u16,
295 code: String,
296 message: String,
297 },
298 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#[derive(Clone, Debug, Default, PartialEq)]
334#[non_exhaustive]
335pub struct Lookup {
336 pub postcode: String,
337 pub valid: bool,
339 pub administrative_address: Option<AdministrativeAddress>,
341 pub recent_house_address: Option<RecentHouseAddress>,
343 pub building_use_status: Option<String>,
345 pub other_building_info: Option<serde_json::Value>,
347 pub point_geometry: Option<serde_json::Value>,
349 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 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 pub code: String,
382 pub label: Option<String>,
384}
385
386#[derive(Clone, Debug, Default, PartialEq)]
387#[non_exhaustive]
388pub struct Reverse {
389 pub found: bool,
391 pub coordinate: Option<Coordinate>,
393 pub unit: Option<NearestUnit>,
395 pub area: Option<String>,
396 pub district: Option<String>,
397 pub state: Option<String>,
398 pub message: Option<String>,
400 pub radius_m: Option<f64>,
402 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 pub confidence: Option<String>,
422 pub state_name: Option<String>,
424 pub lga_name: Option<String>,
426 pub locality_name: Option<String>,
428 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 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}