Skip to main content

uk_police_api/
client.rs

1use crate::error::Error;
2use crate::models::{
3    Area, Crime, CrimeCategory, CrimeLastUpdated, CrimeOutcomes, Force, ForceDetail, LatLng,
4    LocateNeighbourhoodResult, Neighbourhood, NeighbourhoodDetail, NeighbourhoodEvent,
5    NeighbourhoodPriority, Outcome, SeniorOfficer, StopAndSearch,
6};
7
8const BASE_URL: &str = "https://data.police.uk/api";
9
10/// An async client for the UK Police API.
11///
12/// # Example
13///
14/// ```no_run
15/// # #[tokio::main]
16/// # async fn main() -> Result<(), uk_police_api::Error> {
17/// let client = uk_police_api::Client::new();
18/// let forces = client.forces().await?;
19/// # Ok(())
20/// # }
21/// ```
22#[derive(Clone)]
23pub struct Client {
24    http: reqwest::Client,
25    base_url: String,
26}
27
28impl Client {
29    async fn handle_response<T: serde::de::DeserializeOwned>(
30        response: reqwest::Response,
31    ) -> Result<T, Error> {
32        if !response.status().is_success() {
33            let status = response.status().as_u16();
34            let body = response.text().await.unwrap_or_default();
35            return Err(Error::Api { status, body });
36        }
37        Ok(response.json().await?)
38    }
39
40    fn area_query(area: &Area) -> String {
41        match area {
42            Area::Point(coord) => format!("lat={}&lng={}", coord.lat, coord.lng),
43            Area::Custom(coords) => {
44                let poly = coords
45                    .iter()
46                    .map(|c| format!("{},{}", c.lat, c.lng))
47                    .collect::<Vec<_>>()
48                    .join(":");
49                format!("poly={poly}")
50            }
51            Area::LocationId(id) => format!("location_id={id}"),
52        }
53    }
54
55    pub fn new() -> Self {
56        Self {
57            http: reqwest::Client::new(),
58            base_url: BASE_URL.to_string(),
59        }
60    }
61
62    /// Creates a client with a pre-configured [`reqwest::Client`].
63    ///
64    /// Use this to customize timeouts, headers, proxies, or any other HTTP
65    /// behaviour.
66    ///
67    /// # Example
68    ///
69    /// ```no_run
70    /// let http = reqwest::Client::builder()
71    ///     .timeout(std::time::Duration::from_secs(10))
72    ///     .build()
73    ///     .unwrap();
74    /// let client = uk_police_api::Client::from_http_client(http);
75    /// ```
76    pub fn from_http_client(http: reqwest::Client) -> Self {
77        Self {
78            http,
79            base_url: BASE_URL.to_string(),
80        }
81    }
82
83    /// Returns a list of all police forces.
84    pub async fn forces(&self) -> Result<Vec<Force>, Error> {
85        let url = format!("{}/forces", self.base_url);
86        let response = self.http.get(&url).send().await?;
87        Self::handle_response(response).await
88    }
89
90    /// Returns details for a specific police force.
91    pub async fn force(&self, id: &str) -> Result<ForceDetail, Error> {
92        let url = format!("{}/forces/{}", self.base_url, id);
93        let response = self.http.get(&url).send().await?;
94        Self::handle_response(response).await
95    }
96
97    /// Returns a list of crime categories. Optionally filtered by date (format: `YYYY-MM`).
98    pub async fn crime_categories(&self, date: Option<&str>) -> Result<Vec<CrimeCategory>, Error> {
99        let mut url = format!("{}/crime-categories", self.base_url);
100        if let Some(date) = date {
101            url.push_str(&format!("?date={date}"));
102        }
103        let response = self.http.get(&url).send().await?;
104        Self::handle_response(response).await
105    }
106
107    /// Returns street-level crimes within a given area.
108    ///
109    /// # Arguments
110    ///
111    /// * `category` - Crime category slug (e.g. "all-crime", "burglary"). See [`Client::crime_categories`].
112    /// * `area` - Either a point (1 mile radius) or a custom polygon.
113    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
114    pub async fn street_level_crimes(
115        &self,
116        category: &str,
117        area: &Area,
118        date: Option<&str>,
119    ) -> Result<Vec<Crime>, Error> {
120        let mut url = format!(
121            "{}/crimes-street/{}?{}",
122            self.base_url,
123            category,
124            Self::area_query(area)
125        );
126        if let Some(date) = date {
127            url.push_str(&format!("&date={date}"));
128        }
129        let response = self.http.get(&url).send().await?;
130        Self::handle_response(response).await
131    }
132
133    /// Returns street-level outcomes at a given location.
134    ///
135    /// # Arguments
136    ///
137    /// * `area` - A point (1 mile radius), custom polygon, or specific location ID.
138    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
139    pub async fn street_level_outcomes(
140        &self,
141        area: &Area,
142        date: Option<&str>,
143    ) -> Result<Vec<Outcome>, Error> {
144        let mut url = format!(
145            "{}/outcomes-at-location?{}",
146            self.base_url,
147            Self::area_query(area)
148        );
149        if let Some(date) = date {
150            url.push_str(&format!("&date={date}"));
151        }
152        let response = self.http.get(&url).send().await?;
153        Self::handle_response(response).await
154    }
155
156    /// Returns the date when crime data was last updated.
157    pub async fn crime_last_updated(&self) -> Result<CrimeLastUpdated, Error> {
158        let url = format!("{}/crime-last-updated", self.base_url);
159        let response = self.http.get(&url).send().await?;
160        Self::handle_response(response).await
161    }
162
163    /// Returns a list of senior officers for a given force.
164    pub async fn senior_officers(&self, force_id: &str) -> Result<Vec<SeniorOfficer>, Error> {
165        let url = format!("{}/forces/{}/people", self.base_url, force_id);
166        let response = self.http.get(&url).send().await?;
167        Self::handle_response(response).await
168    }
169
170    /// Returns crimes at a specific location.
171    ///
172    /// # Arguments
173    ///
174    /// * `location_id` - A location ID (from a street's `id` field).
175    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
176    pub async fn crimes_at_location(
177        &self,
178        location_id: u64,
179        date: Option<&str>,
180    ) -> Result<Vec<Crime>, Error> {
181        let mut url = format!(
182            "{}/crimes-at-location?location_id={}",
183            self.base_url, location_id
184        );
185        if let Some(date) = date {
186            url.push_str(&format!("&date={date}"));
187        }
188        let response = self.http.get(&url).send().await?;
189        Self::handle_response(response).await
190    }
191
192    /// Returns crimes that could not be mapped to a location.
193    ///
194    /// # Arguments
195    ///
196    /// * `category` - Crime category slug (e.g. "all-crime"). See [`Client::crime_categories`].
197    /// * `force` - Force identifier (e.g. "metropolitan").
198    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
199    pub async fn crimes_no_location(
200        &self,
201        category: &str,
202        force: &str,
203        date: Option<&str>,
204    ) -> Result<Vec<Crime>, Error> {
205        let mut url = format!(
206            "{}/crimes-no-location?category={}&force={}",
207            self.base_url, category, force
208        );
209        if let Some(date) = date {
210            url.push_str(&format!("&date={date}"));
211        }
212        let response = self.http.get(&url).send().await?;
213        Self::handle_response(response).await
214    }
215
216    /// Returns all outcomes for a specific crime.
217    ///
218    /// # Arguments
219    ///
220    /// * `persistent_id` - The 64-character crime identifier.
221    pub async fn outcomes_for_crime(&self, persistent_id: &str) -> Result<CrimeOutcomes, Error> {
222        let url = format!("{}/outcomes-for-crime/{}", self.base_url, persistent_id);
223        let response = self.http.get(&url).send().await?;
224        Self::handle_response(response).await
225    }
226
227    /// Returns a list of neighbourhoods for a force.
228    pub async fn neighbourhoods(&self, force_id: &str) -> Result<Vec<Neighbourhood>, Error> {
229        let url = format!("{}/{}/neighbourhoods", self.base_url, force_id);
230        let response = self.http.get(&url).send().await?;
231        Self::handle_response(response).await
232    }
233
234    /// Returns details for a specific neighbourhood.
235    pub async fn neighbourhood(
236        &self,
237        force_id: &str,
238        neighbourhood_id: &str,
239    ) -> Result<NeighbourhoodDetail, Error> {
240        let url = format!("{}/{}/{}", self.base_url, force_id, neighbourhood_id);
241        let response = self.http.get(&url).send().await?;
242        Self::handle_response(response).await
243    }
244
245    /// Returns the boundary of a neighbourhood as a list of lat/lng pairs.
246    pub async fn neighbourhood_boundary(
247        &self,
248        force_id: &str,
249        neighbourhood_id: &str,
250    ) -> Result<Vec<LatLng>, Error> {
251        let url = format!(
252            "{}/{}/{}/boundary",
253            self.base_url, force_id, neighbourhood_id
254        );
255        let response = self.http.get(&url).send().await?;
256        Self::handle_response(response).await
257    }
258
259    /// Returns the policing team for a neighbourhood.
260    pub async fn neighbourhood_team(
261        &self,
262        force_id: &str,
263        neighbourhood_id: &str,
264    ) -> Result<Vec<SeniorOfficer>, Error> {
265        let url = format!("{}/{}/{}/people", self.base_url, force_id, neighbourhood_id);
266        let response = self.http.get(&url).send().await?;
267        Self::handle_response(response).await
268    }
269
270    /// Returns events for a neighbourhood.
271    pub async fn neighbourhood_events(
272        &self,
273        force_id: &str,
274        neighbourhood_id: &str,
275    ) -> Result<Vec<NeighbourhoodEvent>, Error> {
276        let url = format!("{}/{}/{}/events", self.base_url, force_id, neighbourhood_id);
277        let response = self.http.get(&url).send().await?;
278        Self::handle_response(response).await
279    }
280
281    /// Returns policing priorities for a neighbourhood.
282    pub async fn neighbourhood_priorities(
283        &self,
284        force_id: &str,
285        neighbourhood_id: &str,
286    ) -> Result<Vec<NeighbourhoodPriority>, Error> {
287        let url = format!(
288            "{}/{}/{}/priorities",
289            self.base_url, force_id, neighbourhood_id
290        );
291        let response = self.http.get(&url).send().await?;
292        Self::handle_response(response).await
293    }
294
295    /// Locates the neighbourhood policing team responsible for a given point.
296    pub async fn locate_neighbourhood(
297        &self,
298        lat: f64,
299        lng: f64,
300    ) -> Result<LocateNeighbourhoodResult, Error> {
301        let url = format!("{}/locate-neighbourhood?q={},{}", self.base_url, lat, lng);
302        let response = self.http.get(&url).send().await?;
303        Self::handle_response(response).await
304    }
305
306    /// Returns stop and searches within a given area.
307    ///
308    /// # Arguments
309    ///
310    /// * `area` - A point (1 mile radius) or custom polygon.
311    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
312    pub async fn stops_street(
313        &self,
314        area: &Area,
315        date: Option<&str>,
316    ) -> Result<Vec<StopAndSearch>, Error> {
317        let mut url = format!("{}/stops-street?{}", self.base_url, Self::area_query(area));
318        if let Some(date) = date {
319            url.push_str(&format!("&date={date}"));
320        }
321        let response = self.http.get(&url).send().await?;
322        Self::handle_response(response).await
323    }
324
325    /// Returns stop and searches at a specific location.
326    ///
327    /// # Arguments
328    ///
329    /// * `location_id` - A location ID (from a street's `id` field).
330    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
331    pub async fn stops_at_location(
332        &self,
333        location_id: u64,
334        date: Option<&str>,
335    ) -> Result<Vec<StopAndSearch>, Error> {
336        let mut url = format!(
337            "{}/stops-at-location?location_id={}",
338            self.base_url, location_id
339        );
340        if let Some(date) = date {
341            url.push_str(&format!("&date={date}"));
342        }
343        let response = self.http.get(&url).send().await?;
344        Self::handle_response(response).await
345    }
346
347    /// Returns stop and searches that could not be mapped to a location.
348    ///
349    /// # Arguments
350    ///
351    /// * `force` - Force identifier (e.g. "metropolitan").
352    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
353    pub async fn stops_no_location(
354        &self,
355        force: &str,
356        date: Option<&str>,
357    ) -> Result<Vec<StopAndSearch>, Error> {
358        let mut url = format!("{}/stops-no-location?force={}", self.base_url, force);
359        if let Some(date) = date {
360            url.push_str(&format!("&date={date}"));
361        }
362        let response = self.http.get(&url).send().await?;
363        Self::handle_response(response).await
364    }
365
366    /// Returns stop and searches reported by a force.
367    ///
368    /// # Arguments
369    ///
370    /// * `force` - Force identifier (e.g. "metropolitan").
371    /// * `date` - Optional month filter (format: `YYYY-MM`). Defaults to the latest available.
372    pub async fn stops_force(
373        &self,
374        force: &str,
375        date: Option<&str>,
376    ) -> Result<Vec<StopAndSearch>, Error> {
377        let mut url = format!("{}/stops-force?force={}", self.base_url, force);
378        if let Some(date) = date {
379            url.push_str(&format!("&date={date}"));
380        }
381        let response = self.http.get(&url).send().await?;
382        Self::handle_response(response).await
383    }
384}
385
386impl Default for Client {
387    fn default() -> Self {
388        Self::new()
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::models::Coordinate;
396    use wiremock::matchers::{method, path};
397    use wiremock::{Mock, MockServer, ResponseTemplate};
398
399    fn test_client(uri: &str) -> Client {
400        Client {
401            http: reqwest::Client::new(),
402            base_url: uri.to_string(),
403        }
404    }
405
406    #[tokio::test]
407    async fn test_forces() {
408        let server = MockServer::start().await;
409
410        Mock::given(method("GET"))
411            .and(path("/forces"))
412            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
413                { "id": "met", "name": "Metropolitan Police" },
414                { "id": "kent", "name": "Kent Police" }
415            ])))
416            .mount(&server)
417            .await;
418
419        let client = test_client(&server.uri());
420        let forces = client.forces().await.unwrap();
421
422        assert_eq!(forces.len(), 2);
423        assert_eq!(forces[0].id, "met");
424        assert_eq!(forces[1].name, "Kent Police");
425    }
426
427    #[tokio::test]
428    async fn test_force() {
429        let server = MockServer::start().await;
430
431        Mock::given(method("GET"))
432            .and(path("/forces/metropolitan"))
433            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
434                "id": "metropolitan",
435                "name": "Metropolitan Police Service",
436                "description": "The Met",
437                "url": "https://www.met.police.uk/",
438                "telephone": "101",
439                "engagement_methods": [
440                    {
441                        "type": "twitter",
442                        "title": "twitter",
443                        "description": null,
444                        "url": "https://x.com/Metpoliceuk"
445                    }
446                ]
447            })))
448            .mount(&server)
449            .await;
450
451        let client = test_client(&server.uri());
452        let force = client.force("metropolitan").await.unwrap();
453
454        assert_eq!(force.id, "metropolitan");
455        assert_eq!(force.telephone, Some("101".to_string()));
456        assert_eq!(force.engagement_methods.len(), 1);
457        assert_eq!(force.engagement_methods[0].kind, "twitter");
458    }
459
460    #[tokio::test]
461    async fn test_crime_categories() {
462        let server = MockServer::start().await;
463
464        Mock::given(method("GET"))
465            .and(path("/crime-categories"))
466            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
467                { "url": "burglary", "name": "Burglary" },
468                { "url": "drugs", "name": "Drugs" }
469            ])))
470            .mount(&server)
471            .await;
472
473        let client = test_client(&server.uri());
474        let categories = client.crime_categories(None).await.unwrap();
475
476        assert_eq!(categories.len(), 2);
477        assert_eq!(categories[0].url, "burglary");
478    }
479
480    fn mock_crime_json() -> serde_json::Value {
481        serde_json::json!([{
482            "category": "anti-social-behaviour",
483            "persistent_id": "",
484            "location_subtype": "",
485            "id": 116208998,
486            "location": {
487                "latitude": "52.632805",
488                "street": { "id": 1738842, "name": "On or near Campbell Street" },
489                "longitude": "-1.124819"
490            },
491            "context": "",
492            "month": "2024-01",
493            "location_type": "Force",
494            "outcome_status": {
495                "category": "Investigation complete; no suspect identified",
496                "date": "2024-01"
497            }
498        }])
499    }
500
501    #[tokio::test]
502    async fn test_street_level_crimes_by_point() {
503        let server = MockServer::start().await;
504
505        Mock::given(method("GET"))
506            .and(path("/crimes-street/all-crime"))
507            .respond_with(ResponseTemplate::new(200).set_body_json(mock_crime_json()))
508            .mount(&server)
509            .await;
510
511        let client = test_client(&server.uri());
512        let area = Area::Point(Coordinate {
513            lat: 52.629729,
514            lng: -1.131592,
515        });
516        let crimes = client
517            .street_level_crimes("all-crime", &area, Some("2024-01"))
518            .await
519            .unwrap();
520
521        assert_eq!(crimes.len(), 1);
522        assert_eq!(crimes[0].category, "anti-social-behaviour");
523        assert_eq!(
524            crimes[0].location.as_ref().unwrap().street.name,
525            "On or near Campbell Street"
526        );
527        assert_eq!(
528            crimes[0].outcome_status.as_ref().unwrap().category,
529            crate::models::OutcomeCategory::NoFurtherAction
530        );
531    }
532
533    #[tokio::test]
534    async fn test_street_level_crimes_by_area() {
535        let server = MockServer::start().await;
536
537        Mock::given(method("GET"))
538            .and(path("/crimes-street/all-crime"))
539            .respond_with(ResponseTemplate::new(200).set_body_json(mock_crime_json()))
540            .mount(&server)
541            .await;
542
543        let client = test_client(&server.uri());
544        let area = Area::Custom(vec![
545            Coordinate {
546                lat: 52.268,
547                lng: 0.543,
548            },
549            Coordinate {
550                lat: 52.794,
551                lng: 0.238,
552            },
553            Coordinate {
554                lat: 52.130,
555                lng: 0.478,
556            },
557        ]);
558        let crimes = client
559            .street_level_crimes("all-crime", &area, None)
560            .await
561            .unwrap();
562
563        assert_eq!(crimes.len(), 1);
564        assert_eq!(crimes[0].id, 116208998);
565    }
566
567    #[tokio::test]
568    async fn test_street_level_outcomes_by_location_id() {
569        let server = MockServer::start().await;
570
571        Mock::given(method("GET"))
572            .and(path("/outcomes-at-location"))
573            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
574                {
575                    "category": {
576                        "code": "local-resolution",
577                        "name": "Local resolution"
578                    },
579                    "date": "2024-01",
580                    "person_id": null,
581                    "crime": {
582                        "category": "public-order",
583                        "persistent_id": "dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1",
584                        "location_subtype": "ROAD",
585                        "location_type": "Force",
586                        "location": {
587                            "latitude": "52.637146",
588                            "street": { "id": 1737432, "name": "On or near Vaughan Street" },
589                            "longitude": "-1.149381"
590                        },
591                        "context": "",
592                        "month": "2024-01",
593                        "id": 116202605
594                    }
595                }
596            ])))
597            .mount(&server)
598            .await;
599
600        let client = test_client(&server.uri());
601        let outcomes = client
602            .street_level_outcomes(&Area::LocationId(1737432), Some("2024-01"))
603            .await
604            .unwrap();
605
606        assert_eq!(outcomes.len(), 1);
607        assert_eq!(
608            outcomes[0].category.code,
609            crate::models::OutcomeCategory::LocalResolution
610        );
611        assert_eq!(outcomes[0].crime.category, "public-order");
612        assert!(outcomes[0].person_id.is_none());
613    }
614
615    #[tokio::test]
616    async fn test_street_level_outcomes_by_point() {
617        let server = MockServer::start().await;
618
619        Mock::given(method("GET"))
620            .and(path("/outcomes-at-location"))
621            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
622            .mount(&server)
623            .await;
624
625        let client = test_client(&server.uri());
626        let area = Area::Point(Coordinate {
627            lat: 52.629729,
628            lng: -1.131592,
629        });
630        let outcomes = client.street_level_outcomes(&area, None).await.unwrap();
631
632        assert!(outcomes.is_empty());
633    }
634
635    #[tokio::test]
636    async fn test_crime_last_updated() {
637        let server = MockServer::start().await;
638
639        Mock::given(method("GET"))
640            .and(path("/crime-last-updated"))
641            .respond_with(
642                ResponseTemplate::new(200)
643                    .set_body_json(serde_json::json!({ "date": "2025-12-01" })),
644            )
645            .mount(&server)
646            .await;
647
648        let client = test_client(&server.uri());
649        let updated = client.crime_last_updated().await.unwrap();
650
651        assert_eq!(updated.date, "2025-12-01");
652    }
653
654    #[tokio::test]
655    async fn test_senior_officers() {
656        let server = MockServer::start().await;
657
658        Mock::given(method("GET"))
659            .and(path("/forces/metropolitan/people"))
660            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
661                {
662                    "name": "Mark Rowley",
663                    "rank": "Commissioner",
664                    "bio": null,
665                    "contact_details": {
666                        "twitter": "https://x.com/metpoliceuk"
667                    }
668                }
669            ])))
670            .mount(&server)
671            .await;
672
673        let client = test_client(&server.uri());
674        let officers = client.senior_officers("metropolitan").await.unwrap();
675
676        assert_eq!(officers.len(), 1);
677        assert_eq!(officers[0].name, "Mark Rowley");
678        assert_eq!(officers[0].rank, "Commissioner");
679        assert!(officers[0].bio.is_none());
680        assert_eq!(
681            officers[0].contact_details.twitter,
682            Some("https://x.com/metpoliceuk".to_string())
683        );
684    }
685
686    #[tokio::test]
687    async fn test_crimes_at_location() {
688        let server = MockServer::start().await;
689
690        Mock::given(method("GET"))
691            .and(path("/crimes-at-location"))
692            .respond_with(ResponseTemplate::new(200).set_body_json(mock_crime_json()))
693            .mount(&server)
694            .await;
695
696        let client = test_client(&server.uri());
697        let crimes = client
698            .crimes_at_location(1738842, Some("2024-01"))
699            .await
700            .unwrap();
701
702        assert_eq!(crimes.len(), 1);
703        assert_eq!(crimes[0].category, "anti-social-behaviour");
704    }
705
706    #[tokio::test]
707    async fn test_crimes_no_location() {
708        let server = MockServer::start().await;
709
710        Mock::given(method("GET"))
711            .and(path("/crimes-no-location"))
712            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
713                {
714                    "category": "burglary",
715                    "persistent_id": "abc123",
716                    "location_subtype": "",
717                    "id": 999,
718                    "location": null,
719                    "context": "",
720                    "month": "2024-01",
721                    "location_type": null,
722                    "outcome_status": null
723                }
724            ])))
725            .mount(&server)
726            .await;
727
728        let client = test_client(&server.uri());
729        let crimes = client
730            .crimes_no_location("burglary", "metropolitan", Some("2024-01"))
731            .await
732            .unwrap();
733
734        assert_eq!(crimes.len(), 1);
735        assert_eq!(crimes[0].category, "burglary");
736        assert!(crimes[0].location.is_none());
737        assert!(crimes[0].location_type.is_none());
738    }
739
740    #[tokio::test]
741    async fn test_outcomes_for_crime() {
742        let server = MockServer::start().await;
743
744        Mock::given(method("GET"))
745            .and(path(
746                "/outcomes-for-crime/dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1",
747            ))
748            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
749                "crime": {
750                    "category": "violent-crime",
751                    "persistent_id": "dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1",
752                    "location_subtype": "",
753                    "id": 116202605,
754                    "location": {
755                        "latitude": "52.637146",
756                        "street": { "id": 1737432, "name": "On or near Vaughan Street" },
757                        "longitude": "-1.149381"
758                    },
759                    "context": "",
760                    "month": "2024-01",
761                    "location_type": "Force",
762                    "outcome_status": null
763                },
764                "outcomes": [
765                    {
766                        "category": {
767                            "code": "no-further-action",
768                            "name": "Investigation complete; no suspect identified"
769                        },
770                        "date": "2024-01",
771                        "person_id": null
772                    }
773                ]
774            })))
775            .mount(&server)
776            .await;
777
778        let client = test_client(&server.uri());
779        let result = client
780            .outcomes_for_crime("dd6e56f90d1bdd7bc7482af17852369f263203d9a688fac42ec53bf48485d8f1")
781            .await
782            .unwrap();
783
784        assert_eq!(result.crime.category, "violent-crime");
785        assert_eq!(result.outcomes.len(), 1);
786        assert_eq!(
787            result.outcomes[0].category.code,
788            crate::models::OutcomeCategory::NoFurtherAction
789        );
790        assert!(result.outcomes[0].person_id.is_none());
791    }
792
793    #[tokio::test]
794    async fn test_neighbourhoods() {
795        let server = MockServer::start().await;
796
797        Mock::given(method("GET"))
798            .and(path("/leicestershire/neighbourhoods"))
799            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
800                { "id": "NC04", "name": "City Centre" },
801                { "id": "NC66", "name": "Cultural Quarter" }
802            ])))
803            .mount(&server)
804            .await;
805
806        let client = test_client(&server.uri());
807        let neighbourhoods = client.neighbourhoods("leicestershire").await.unwrap();
808
809        assert_eq!(neighbourhoods.len(), 2);
810        assert_eq!(neighbourhoods[0].id, "NC04");
811        assert_eq!(neighbourhoods[1].name, "Cultural Quarter");
812    }
813
814    #[tokio::test]
815    async fn test_neighbourhood() {
816        let server = MockServer::start().await;
817
818        Mock::given(method("GET"))
819            .and(path("/leicestershire/NC04"))
820            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
821                "id": "NC04",
822                "name": "City Centre",
823                "description": "The city centre neighbourhood",
824                "population": "7985",
825                "url_force": "https://www.leics.police.uk/local-policing/city-centre",
826                "contact_details": {
827                    "email": "citycentre@example.com"
828                },
829                "centre": {
830                    "latitude": "52.6389",
831                    "longitude": "-1.1350"
832                },
833                "links": [
834                    { "url": "https://example.com", "title": "Example", "description": null }
835                ],
836                "locations": [
837                    {
838                        "name": "Mansfield House",
839                        "latitude": "52.6352",
840                        "longitude": "-1.1332",
841                        "postcode": "LE1 3GG",
842                        "address": "74 Belgrave Gate",
843                        "telephone": "101",
844                        "type": "station",
845                        "description": null
846                    }
847                ]
848            })))
849            .mount(&server)
850            .await;
851
852        let client = test_client(&server.uri());
853        let detail = client
854            .neighbourhood("leicestershire", "NC04")
855            .await
856            .unwrap();
857
858        assert_eq!(detail.id, "NC04");
859        assert_eq!(detail.population, Some("7985".to_string()));
860        assert_eq!(detail.centre.latitude, "52.6389");
861        assert_eq!(detail.links.len(), 1);
862        assert_eq!(detail.locations.len(), 1);
863        assert_eq!(detail.locations[0].kind, Some("station".to_string()));
864    }
865
866    #[tokio::test]
867    async fn test_neighbourhood_boundary() {
868        let server = MockServer::start().await;
869
870        Mock::given(method("GET"))
871            .and(path("/leicestershire/NC04/boundary"))
872            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
873                { "latitude": "52.6394", "longitude": "-1.1459" },
874                { "latitude": "52.6389", "longitude": "-1.1457" },
875                { "latitude": "52.6381", "longitude": "-1.1447" }
876            ])))
877            .mount(&server)
878            .await;
879
880        let client = test_client(&server.uri());
881        let boundary = client
882            .neighbourhood_boundary("leicestershire", "NC04")
883            .await
884            .unwrap();
885
886        assert_eq!(boundary.len(), 3);
887        assert_eq!(boundary[0].latitude, "52.6394");
888        assert_eq!(boundary[2].longitude, "-1.1447");
889    }
890
891    #[tokio::test]
892    async fn test_neighbourhood_team() {
893        let server = MockServer::start().await;
894
895        Mock::given(method("GET"))
896            .and(path("/leicestershire/NC04/people"))
897            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
898                {
899                    "name": "Andy Cooper",
900                    "rank": "Sgt",
901                    "bio": "Andy has been with the force since 2003.",
902                    "contact_details": {}
903                }
904            ])))
905            .mount(&server)
906            .await;
907
908        let client = test_client(&server.uri());
909        let team = client
910            .neighbourhood_team("leicestershire", "NC04")
911            .await
912            .unwrap();
913
914        assert_eq!(team.len(), 1);
915        assert_eq!(team[0].name, "Andy Cooper");
916        assert_eq!(team[0].rank, "Sgt");
917        assert_eq!(
918            team[0].bio,
919            Some("Andy has been with the force since 2003.".to_string())
920        );
921    }
922
923    #[tokio::test]
924    async fn test_neighbourhood_events() {
925        let server = MockServer::start().await;
926
927        Mock::given(method("GET"))
928            .and(path("/leicestershire/NC04/events"))
929            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
930                {
931                    "title": "Bike Registration",
932                    "description": "Free bike registration event",
933                    "address": "Mansfield House",
934                    "type": "meeting",
935                    "start_date": "2024-09-17T17:00:00",
936                    "end_date": "2024-09-17T19:00:00",
937                    "contact_details": {}
938                }
939            ])))
940            .mount(&server)
941            .await;
942
943        let client = test_client(&server.uri());
944        let events = client
945            .neighbourhood_events("leicestershire", "NC04")
946            .await
947            .unwrap();
948
949        assert_eq!(events.len(), 1);
950        assert_eq!(events[0].title, Some("Bike Registration".to_string()));
951        assert_eq!(events[0].kind, Some("meeting".to_string()));
952    }
953
954    #[tokio::test]
955    async fn test_neighbourhood_priorities() {
956        let server = MockServer::start().await;
957
958        Mock::given(method("GET"))
959            .and(path("/leicestershire/NC04/priorities"))
960            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([
961                {
962                    "action": "Increased patrols in the area.",
963                    "issue-date": "2024-07-01T00:00:00",
964                    "action-date": "2024-09-01T00:00:00",
965                    "issue": "Anti-social behaviour on Granby Street"
966                }
967            ])))
968            .mount(&server)
969            .await;
970
971        let client = test_client(&server.uri());
972        let priorities = client
973            .neighbourhood_priorities("leicestershire", "NC04")
974            .await
975            .unwrap();
976
977        assert_eq!(priorities.len(), 1);
978        assert_eq!(
979            priorities[0].issue,
980            Some("Anti-social behaviour on Granby Street".to_string())
981        );
982        assert_eq!(
983            priorities[0].action,
984            Some("Increased patrols in the area.".to_string())
985        );
986    }
987
988    #[tokio::test]
989    async fn test_locate_neighbourhood() {
990        let server = MockServer::start().await;
991
992        Mock::given(method("GET"))
993            .and(path("/locate-neighbourhood"))
994            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
995                "force": "metropolitan",
996                "neighbourhood": "E05013806N"
997            })))
998            .mount(&server)
999            .await;
1000
1001        let client = test_client(&server.uri());
1002        let result = client
1003            .locate_neighbourhood(51.500617, -0.124629)
1004            .await
1005            .unwrap();
1006
1007        assert_eq!(result.force, "metropolitan");
1008        assert_eq!(result.neighbourhood, "E05013806N");
1009    }
1010
1011    fn mock_stop_json() -> serde_json::Value {
1012        serde_json::json!([{
1013            "type": "Person search",
1014            "involved_person": true,
1015            "datetime": "2024-01-15T12:30:00+00:00",
1016            "operation": false,
1017            "operation_name": null,
1018            "location": {
1019                "latitude": "52.634407",
1020                "street": { "id": 1737432, "name": "On or near Vaughan Street" },
1021                "longitude": "-1.149381"
1022            },
1023            "gender": "Male",
1024            "age_range": "18-24",
1025            "self_defined_ethnicity": "White - English/Welsh/Scottish/Northern Irish/British",
1026            "officer_defined_ethnicity": "White",
1027            "legislation": "Misuse of Drugs Act 1971 (section 23)",
1028            "object_of_search": "Controlled drugs",
1029            "outcome": "A no further action disposal",
1030            "outcome_linked_to_object_of_search": null,
1031            "removal_of_more_than_outer_clothing": false
1032        }])
1033    }
1034
1035    #[tokio::test]
1036    async fn test_stops_street() {
1037        let server = MockServer::start().await;
1038
1039        Mock::given(method("GET"))
1040            .and(path("/stops-street"))
1041            .respond_with(ResponseTemplate::new(200).set_body_json(mock_stop_json()))
1042            .mount(&server)
1043            .await;
1044
1045        let client = test_client(&server.uri());
1046        let area = Area::Point(Coordinate {
1047            lat: 52.629729,
1048            lng: -1.131592,
1049        });
1050        let stops = client.stops_street(&area, Some("2024-01")).await.unwrap();
1051
1052        assert_eq!(stops.len(), 1);
1053        assert_eq!(
1054            stops[0].kind,
1055            Some(crate::models::StopAndSearchType::Person)
1056        );
1057        assert_eq!(stops[0].involved_person, Some(true));
1058        assert_eq!(stops[0].gender, Some("Male".to_string()));
1059        assert_eq!(
1060            stops[0].outcome,
1061            Some("A no further action disposal".to_string())
1062        );
1063    }
1064
1065    #[tokio::test]
1066    async fn test_stops_at_location() {
1067        let server = MockServer::start().await;
1068
1069        Mock::given(method("GET"))
1070            .and(path("/stops-at-location"))
1071            .respond_with(ResponseTemplate::new(200).set_body_json(mock_stop_json()))
1072            .mount(&server)
1073            .await;
1074
1075        let client = test_client(&server.uri());
1076        let stops = client
1077            .stops_at_location(1737432, Some("2024-01"))
1078            .await
1079            .unwrap();
1080
1081        assert_eq!(stops.len(), 1);
1082        assert_eq!(
1083            stops[0].object_of_search,
1084            Some("Controlled drugs".to_string())
1085        );
1086    }
1087
1088    #[tokio::test]
1089    async fn test_stops_no_location() {
1090        let server = MockServer::start().await;
1091
1092        Mock::given(method("GET"))
1093            .and(path("/stops-no-location"))
1094            .respond_with(
1095                ResponseTemplate::new(200).set_body_json(serde_json::json!([{
1096                    "type": "Vehicle search",
1097                    "involved_person": false,
1098                    "datetime": "2024-01-10T08:00:00+00:00",
1099                    "operation": null,
1100                    "operation_name": null,
1101                    "location": null,
1102                    "gender": null,
1103                    "age_range": null,
1104                    "self_defined_ethnicity": null,
1105                    "officer_defined_ethnicity": null,
1106                    "legislation": "Misuse of Drugs Act 1971 (section 23)",
1107                    "object_of_search": "Controlled drugs",
1108                    "outcome": false,
1109                    "outcome_linked_to_object_of_search": null,
1110                    "removal_of_more_than_outer_clothing": null
1111                }])),
1112            )
1113            .mount(&server)
1114            .await;
1115
1116        let client = test_client(&server.uri());
1117        let stops = client
1118            .stops_no_location("leicestershire", Some("2024-01"))
1119            .await
1120            .unwrap();
1121
1122        assert_eq!(stops.len(), 1);
1123        assert_eq!(
1124            stops[0].kind,
1125            Some(crate::models::StopAndSearchType::Vehicle)
1126        );
1127        assert!(stops[0].location.is_none());
1128        assert!(stops[0].outcome.is_none());
1129    }
1130
1131    #[tokio::test]
1132    async fn test_stops_force() {
1133        let server = MockServer::start().await;
1134
1135        Mock::given(method("GET"))
1136            .and(path("/stops-force"))
1137            .respond_with(
1138                ResponseTemplate::new(200).set_body_json(serde_json::json!([{
1139                    "type": "Person and Vehicle search",
1140                    "involved_person": true,
1141                    "datetime": "2024-01-20T14:00:00+00:00",
1142                    "operation": true,
1143                    "operation_name": "Operation Blitz",
1144                    "location": {
1145                        "latitude": "52.634407",
1146                        "street": { "id": 1737432, "name": "On or near Vaughan Street" },
1147                        "longitude": "-1.149381"
1148                    },
1149                    "gender": "Female",
1150                    "age_range": "25-34",
1151                    "self_defined_ethnicity": null,
1152                    "officer_defined_ethnicity": "Black",
1153                    "legislation": "Police and Criminal Evidence Act 1984 (section 1)",
1154                    "object_of_search": "Stolen goods",
1155                    "outcome": "Arrest",
1156                    "outcome_object": {
1157                        "id": "bu-arrest",
1158                        "name": "Arrest"
1159                    },
1160                    "outcome_linked_to_object_of_search": true,
1161                    "removal_of_more_than_outer_clothing": false
1162                }])),
1163            )
1164            .mount(&server)
1165            .await;
1166
1167        let client = test_client(&server.uri());
1168        let stops = client
1169            .stops_force("leicestershire", Some("2024-01"))
1170            .await
1171            .unwrap();
1172
1173        assert_eq!(stops.len(), 1);
1174        assert_eq!(
1175            stops[0].kind,
1176            Some(crate::models::StopAndSearchType::PersonAndVehicle)
1177        );
1178        assert_eq!(stops[0].operation, Some(true));
1179        assert_eq!(stops[0].operation_name, Some("Operation Blitz".to_string()));
1180        assert_eq!(stops[0].outcome, Some("Arrest".to_string()));
1181        assert_eq!(
1182            stops[0].outcome_object.as_ref().unwrap().name,
1183            Some("Arrest".to_string())
1184        );
1185    }
1186
1187    #[tokio::test]
1188    async fn test_not_found() {
1189        let server = MockServer::start().await;
1190
1191        Mock::given(method("GET"))
1192            .and(path("/forces/nonexistent"))
1193            .respond_with(ResponseTemplate::new(404).set_body_string("Not Found"))
1194            .mount(&server)
1195            .await;
1196
1197        let client = test_client(&server.uri());
1198        let err = client.force("nonexistent").await.unwrap_err();
1199
1200        match err {
1201            Error::Api { status, body } => {
1202                assert_eq!(status, 404);
1203                assert_eq!(body, "Not Found");
1204            }
1205            other => panic!("expected Error::Api, got: {other}"),
1206        }
1207    }
1208
1209    #[tokio::test]
1210    async fn test_rate_limited() {
1211        let server = MockServer::start().await;
1212
1213        Mock::given(method("GET"))
1214            .and(path("/forces"))
1215            .respond_with(ResponseTemplate::new(429).set_body_string("Rate limit exceeded"))
1216            .mount(&server)
1217            .await;
1218
1219        let client = test_client(&server.uri());
1220        let err = client.forces().await.unwrap_err();
1221
1222        match err {
1223            Error::Api { status, body } => {
1224                assert_eq!(status, 429);
1225                assert_eq!(body, "Rate limit exceeded");
1226            }
1227            other => panic!("expected Error::Api, got: {other}"),
1228        }
1229    }
1230
1231    #[tokio::test]
1232    async fn test_bad_request() {
1233        let server = MockServer::start().await;
1234
1235        Mock::given(method("GET"))
1236            .and(path("/crime-categories"))
1237            .respond_with(ResponseTemplate::new(400).set_body_string("Bad Request"))
1238            .mount(&server)
1239            .await;
1240
1241        let client = test_client(&server.uri());
1242        let err = client.crime_categories(None).await.unwrap_err();
1243
1244        match err {
1245            Error::Api { status, body } => {
1246                assert_eq!(status, 400);
1247                assert_eq!(body, "Bad Request");
1248            }
1249            other => panic!("expected Error::Api, got: {other}"),
1250        }
1251    }
1252}