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)]
38pub enum InvalidRequest {
39 Level(u8),
41 EmptyQuery,
43 NotFinite(&'static str),
45}
46
47pub fn lookup(code: Postcode, level: u8) -> Result<Request<Lookup>, InvalidRequest> {
50 if !(1..=5).contains(&level) {
51 return Err(InvalidRequest::Level(level));
52 }
53 let query = [("code", code.to_string()), ("level", level.to_string())];
54 Ok(Request::get("/v1/lookup", query, read_lookup))
55}
56
57pub fn autocomplete(partial: &str) -> Result<Request<Autocomplete>, InvalidRequest> {
59 if partial.trim().is_empty() {
60 return Err(InvalidRequest::EmptyQuery);
61 }
62 let query = [("q", partial.to_owned())];
63 Ok(Request::get(
64 "/v1/search/autocomplete",
65 query,
66 read_autocomplete,
67 ))
68}
69
70pub fn reverse(
73 at: Coordinate,
74 max_distance_m: Option<f64>,
75) -> Result<Request<Reverse>, InvalidRequest> {
76 let query = around(at, "max_distance_m", max_distance_m)?;
77 Ok(Request::get("/v1/search/reverse", query, read_reverse))
78}
79
80pub fn nearby(
83 at: Coordinate,
84 radius_m: Option<f64>,
85) -> Result<Request<Vec<NearbyUnit>>, InvalidRequest> {
86 let query = around(at, "radius", radius_m)?;
87 Ok(Request::get("/v1/search/nearby", query, read_nearby))
88}
89
90fn around(
91 at: Coordinate,
92 key: &'static str,
93 metres: Option<f64>,
94) -> Result<Vec<(&'static str, String)>, InvalidRequest> {
95 [("lat", Some(at.lat)), ("lng", Some(at.lng)), (key, metres)]
96 .into_iter()
97 .filter_map(|(key, value)| Some((key, value?)))
98 .map(|(key, value)| match value.is_finite() {
99 true => Ok((key, (value + 0.0).to_string())),
101 false => Err(InvalidRequest::NotFinite(key)),
102 })
103 .collect()
104}
105
106impl<T> Request<T> {
107 fn get(
108 path: &'static str,
109 query: impl IntoIterator<Item = (&'static str, String)>,
110 read: fn(&Value) -> Option<T>,
111 ) -> Self {
112 Self {
113 path,
114 query: query.into_iter().collect(),
115 read,
116 }
117 }
118
119 pub fn decode(&self, status: u16, body: &str) -> Result<T, ApiError> {
121 let malformed = |reason: &str| ApiError::Malformed {
122 status,
123 reason: reason.to_owned(),
124 };
125 let rejected = |code: Option<String>, message: Option<String>| ApiError::Rejected {
126 status,
127 code: code.unwrap_or_else(|| "unknown_error".to_owned()),
128 message: message.unwrap_or_default(),
129 };
130 let envelope: Value =
133 serde_json::from_str(body).map_err(|error| malformed(&error.to_string()))?;
134 let envelope = envelope
135 .as_object()
136 .ok_or_else(|| malformed("expected a JSON object"))?;
137 match envelope.get("error") {
138 Some(Value::Object(failure)) => {
139 return Err(rejected(text(failure, "code"), text(failure, "message")))
140 }
141 Some(Value::String(message)) => return Err(rejected(None, Some(message.clone()))),
142 _ => {}
143 }
144 if !(200..300).contains(&status) {
145 return Err(malformed("an error status without an error"));
146 }
147 (self.read)(envelope.get("data").unwrap_or(&Value::Null))
148 .ok_or_else(|| malformed("unexpected data"))
149 }
150}
151
152type Object = Map<String, Value>;
157
158fn text(data: &Object, key: &str) -> Option<String> {
159 data.get(key)?.as_str().map(str::to_owned)
160}
161
162fn number(data: &Object, key: &str) -> Option<f64> {
163 data.get(key)?.as_f64()
164}
165
166fn object<'a>(data: &'a Object, key: &str) -> Option<&'a Object> {
167 data.get(key)?.as_object()
168}
169
170fn raw(data: &Object, key: &str) -> Option<Value> {
171 data.get(key).filter(|value| !value.is_null()).cloned()
172}
173
174fn read_lookup(data: &Value) -> Option<Lookup> {
175 let data = data.as_object()?;
176 Some(Lookup {
177 postcode: text(data, "postcode").unwrap_or_default(),
178 valid: data.get("valid")?.as_bool()?,
180 administrative_address: object(data, "administrative_address").map(|admin| {
181 AdministrativeAddress {
182 state_name: text(admin, "state_name"),
183 lga_name: text(admin, "lga_name"),
184 locality_name: text(admin, "locality_name"),
185 zone: text(admin, "zone"),
186 }
187 }),
188 recent_house_address: object(data, "recent_house_address").map(|recent| {
189 RecentHouseAddress {
190 recent: text(recent, "recent"),
191 }
192 }),
193 building_use_status: text(data, "building_use_status"),
194 other_building_info: raw(data, "other_building_info"),
195 point_geometry: raw(data, "point_geometry"),
196 status: text(data, "status"),
197 verified: data.get("verified").and_then(Value::as_bool),
198 })
199}
200
201fn read_autocomplete(data: &Value) -> Option<Autocomplete> {
202 let data = data.as_object()?;
203 let suggestions = data.get("suggestions").and_then(Value::as_array);
204 Some(Autocomplete {
205 segment: match data.get("segment").and_then(Value::as_str) {
207 Some("state") => Some(Segment::State),
208 Some("lga") => Some(Segment::Lga),
209 Some("district") => Some(Segment::District),
210 Some("area") => Some(Segment::Area),
211 Some("unit") => Some(Segment::Unit),
212 _ => None,
213 },
214 suggestions: suggestions
215 .into_iter()
216 .flatten()
217 .filter_map(Value::as_object)
218 .map(|item| Suggestion {
219 code: text(item, "code").unwrap_or_default(),
220 label: text(item, "label"),
221 })
222 .collect(),
223 })
224}
225
226fn read_reverse(data: &Value) -> Option<Reverse> {
227 let data = data.as_object()?;
228 let point = data.get("coordinate").and_then(Value::as_array);
229 Some(Reverse {
230 found: data.get("found")?.as_bool()?,
232 coordinate: point.and_then(|point| match point.as_slice() {
233 [lng, lat] => Some([lng.as_f64()?, lat.as_f64()?]),
234 _ => None,
235 }),
236 unit: object(data, "unit").map(|unit| NearestUnit {
237 postcode: text(unit, "postcode").unwrap_or_default(),
238 display: text(unit, "display").unwrap_or_default(),
239 distance_m: number(unit, "distance_m"),
240 confidence: text(unit, "confidence"),
241 state_name: text(unit, "state_name"),
242 lga_name: text(unit, "lga_name"),
243 locality_name: text(unit, "locality_name"),
244 address: text(unit, "address"),
245 }),
246 area: text(data, "area"),
247 district: text(data, "district"),
248 state: text(data, "state"),
249 message: text(data, "message"),
250 radius_m: number(data, "radius_m"),
251 depth: text(data, "depth"),
252 })
253}
254
255fn read_nearby(data: &Value) -> Option<Vec<NearbyUnit>> {
256 let units = data.as_array()?.iter().filter_map(Value::as_object);
257 Some(
258 units
259 .map(|unit| NearbyUnit {
260 postcode: text(unit, "postcode").unwrap_or_default(),
261 display: text(unit, "display").unwrap_or_default(),
262 distance_m: number(unit, "distance_m"),
263 })
264 .collect(),
265 )
266}
267
268#[derive(Clone, Debug, PartialEq, Eq)]
269pub enum ApiError {
270 Rejected {
273 status: u16,
274 code: String,
275 message: String,
276 },
277 Malformed { status: u16, reason: String },
279}
280
281impl fmt::Display for ApiError {
282 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
283 match self {
284 Self::Rejected {
285 status,
286 code,
287 message,
288 } => write!(f, "{code} ({status}): {message}"),
289 Self::Malformed { status, reason } => {
290 write!(f, "unreadable response ({status}): {reason}")
291 }
292 }
293 }
294}
295
296impl std::error::Error for ApiError {}
297
298impl fmt::Display for InvalidRequest {
299 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300 match self {
301 Self::Level(level) => write!(f, "level must be 1 to 5, got {level}"),
302 Self::EmptyQuery => f.write_str("autocomplete text must not be empty"),
303 Self::NotFinite(name) => write!(f, "{name} must be a finite number"),
304 }
305 }
306}
307
308impl std::error::Error for InvalidRequest {}
309
310#[derive(Clone, Debug, Default, PartialEq)]
312pub struct Lookup {
313 pub postcode: String,
314 pub valid: bool,
316 pub administrative_address: Option<AdministrativeAddress>,
318 pub recent_house_address: Option<RecentHouseAddress>,
320 pub building_use_status: Option<String>,
322 pub other_building_info: Option<serde_json::Value>,
324 pub point_geometry: Option<serde_json::Value>,
326 pub status: Option<String>,
328 pub verified: Option<bool>,
329}
330
331#[derive(Clone, Debug, Default, PartialEq, Eq)]
332pub struct AdministrativeAddress {
333 pub state_name: Option<String>,
334 pub lga_name: Option<String>,
335 pub locality_name: Option<String>,
336 pub zone: Option<String>,
337}
338
339#[derive(Clone, Debug, Default, PartialEq, Eq)]
340pub struct RecentHouseAddress {
341 pub recent: Option<String>,
342}
343
344#[derive(Clone, Debug, Default, PartialEq, Eq)]
345pub struct Autocomplete {
346 pub segment: Option<Segment>,
348 pub suggestions: Vec<Suggestion>,
349}
350
351#[derive(Clone, Debug, Default, PartialEq, Eq)]
352pub struct Suggestion {
353 pub code: String,
355 pub label: Option<String>,
357}
358
359#[derive(Clone, Debug, Default, PartialEq)]
360pub struct Reverse {
361 pub found: bool,
363 pub coordinate: Option<[f64; 2]>,
365 pub unit: Option<NearestUnit>,
367 pub area: Option<String>,
368 pub district: Option<String>,
369 pub state: Option<String>,
370 pub message: Option<String>,
372 pub radius_m: Option<f64>,
374 pub depth: Option<String>,
376}
377
378#[derive(Clone, Debug, Default, PartialEq)]
379pub struct NearbyUnit {
380 pub postcode: String,
381 pub display: String,
382 pub distance_m: Option<f64>,
383}
384
385#[derive(Clone, Debug, Default, PartialEq)]
386pub struct NearestUnit {
387 pub postcode: String,
388 pub display: String,
389 pub distance_m: Option<f64>,
390 pub confidence: Option<String>,
392 pub state_name: Option<String>,
394 pub lga_name: Option<String>,
396 pub locality_name: Option<String>,
398 pub address: Option<String>,
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 const HERE: Coordinate = Coordinate {
407 lat: 7.62,
408 lng: 5.22,
409 };
410
411 fn pairs<const N: usize>(query: [(&'static str, &str); N]) -> Vec<(&'static str, String)> {
412 query.map(|(key, value)| (key, value.to_owned())).to_vec()
413 }
414
415 #[test]
416 fn builds_requests() {
417 let code: Postcode = "ek01a03fk01".parse().unwrap();
418 let request = lookup(code, 3).unwrap();
419 assert_eq!(request.path, "/v1/lookup");
420 assert_eq!(
421 request.query,
422 pairs([("code", "EK-01-A03-FK-01"), ("level", "3")])
423 );
424
425 assert_eq!(lookup(code, 0), Err(InvalidRequest::Level(0)));
426 assert_eq!(autocomplete(" "), Err(InvalidRequest::EmptyQuery));
427 let nowhere = Coordinate {
428 lat: f64::NAN,
429 ..HERE
430 };
431 assert_eq!(nearby(nowhere, None), Err(InvalidRequest::NotFinite("lat")));
432
433 let request = autocomplete("EK 01 A").unwrap();
434 assert_eq!(request.path, "/v1/search/autocomplete");
435 assert_eq!(request.query, pairs([("q", "EK 01 A")]));
436
437 let request = reverse(HERE, Some(100.0)).unwrap();
438 assert_eq!(request.path, "/v1/search/reverse");
439 assert_eq!(
440 request.query,
441 pairs([("lat", "7.62"), ("lng", "5.22"), ("max_distance_m", "100")])
442 );
443
444 let request = nearby(HERE, None).unwrap();
445 assert_eq!(request.path, "/v1/search/nearby");
446 assert_eq!(request.query, pairs([("lat", "7.62"), ("lng", "5.22")]));
447 }
448
449 #[test]
450 fn decodes_the_documented_lookup() {
451 let body = r#"{ "data": {
452 "postcode": "EK-01-A03-FK-01",
453 "valid": true,
454 "administrative_address": { "state_name": "EKITI", "lga_name": "ADO EKITI", "locality_name": "ADO EKITI", "zone": "SOUTH WEST" },
455 "recent_house_address": { "recent": "NTA ROAD, BACK OF FABIAN HOTEL, ADO EKITI" },
456 "building_use_status": "residential"
457 } }"#;
458 let found = lookup("EK-01-A03-FK-01".parse().unwrap(), 3)
459 .unwrap()
460 .decode(200, body)
461 .unwrap();
462 assert!(found.valid);
463 assert_eq!(
464 found.administrative_address.unwrap().zone.as_deref(),
465 Some("SOUTH WEST")
466 );
467 assert_eq!(found.building_use_status.as_deref(), Some("residential"));
468 assert_eq!(found.point_geometry, None);
469 }
470
471 #[test]
472 fn decodes_fields_missing_below_the_granted_level() {
473 let body = r#"{ "data": { "postcode": "EK-01-A03-FK-01", "valid": true } }"#;
474 let found = lookup("EK-01-A03-FK-01".parse().unwrap(), 1)
475 .unwrap()
476 .decode(200, body)
477 .unwrap();
478 assert_eq!(found.administrative_address, None);
479 }
480
481 #[test]
482 fn decodes_autocomplete_and_reverse() {
483 let body = r#"{ "data": { "segment": "lga", "suggestions": [{ "code": "EK-01", "label": "ADO EKITI" }] } }"#;
484 let found = autocomplete("EK").unwrap().decode(200, body).unwrap();
485 assert_eq!(found.segment, Some(Segment::Lga));
486 assert_eq!(found.suggestions[0].code, "EK-01");
487
488 let body = r#"{ "data": { "found": false, "coordinate": [5.22, 7.62], "message": "no unit in range", "radius_m": 25 } }"#;
489 let found = reverse(HERE, None).unwrap().decode(200, body).unwrap();
490 assert_eq!((found.found, found.unit), (false, None));
491 assert_eq!(found.radius_m, Some(25.0));
492 }
493
494 #[test]
495 fn turns_error_envelopes_and_bad_bodies_into_values() {
496 let request = lookup("EK-01-A03-FK-01".parse().unwrap(), 1).unwrap();
497
498 let body = r#"{"error":{"code":"auth_required","message":"an API key is required; pass it in the X-API-Key header"}}"#;
500 let expected = ApiError::Rejected {
501 status: 401,
502 code: "auth_required".to_owned(),
503 message: "an API key is required; pass it in the X-API-Key header".to_owned(),
504 };
505 assert_eq!(request.decode(401, body), Err(expected));
506
507 let malformed = request.decode(502, "<html>Bad Gateway</html>");
508 assert!(matches!(
509 malformed,
510 Err(ApiError::Malformed { status: 502, .. })
511 ));
512 let empty = request.decode(200, r#"{"data":null}"#);
513 assert!(matches!(empty, Err(ApiError::Malformed { .. })));
514 }
515
516 #[test]
517 fn tolerates_what_the_api_may_add_or_leave_out() {
518 let request = lookup("EK-01-A03-FK-01".parse().unwrap(), 1).unwrap();
519 let body =
520 r#"{"data":{"postcode":"EK-01-A03-FK-01","valid":true},"meta":{"request_id":"r1"}}"#;
521 assert!(request.decode(200, body).unwrap().valid);
522
523 let body = r#"{"error":{"code":"rate_limited"},"request_id":"r1"}"#;
524 let expected = ApiError::Rejected {
525 status: 429,
526 code: "rate_limited".to_owned(),
527 message: String::new(),
528 };
529 assert_eq!(request.decode(429, body), Err(expected));
530
531 let body = r#"{"data":{"segment":"street","suggestions":null}}"#;
532 let found = autocomplete("EK").unwrap().decode(200, body).unwrap();
533 assert_eq!((found.segment, found.suggestions.len()), (None, 0));
534
535 let body = r#"{"data":[{"postcode":null,"distance_m":3}]}"#;
536 let units = nearby(HERE, None).unwrap().decode(200, body).unwrap();
537 assert_eq!(
538 (units[0].postcode.as_str(), units[0].distance_m),
539 ("", Some(3.0))
540 );
541
542 let body = r#"{"data":{"found":true,"unit":{"postcode":"EK-01-A03-FK-01"}}}"#;
543 let found = reverse(HERE, None).unwrap().decode(200, body).unwrap();
544 assert_eq!(found.unit.unwrap().distance_m, None);
545 }
546}