From d6ea80237ffcec942453198f9fe242784efb2c63 Mon Sep 17 00:00:00 2001 From: Alex Chesters <10558792+AlexChesters@users.noreply.github.com> Date: Tue, 15 Sep 2026 08:48:31 +0100 Subject: [PATCH 1/2] fix #29 - allow retrieving crimes in a custom area --- CHANGELOG.md | 4 ++++ README.md | 11 ++++++++++ examples/js/custom-area.js | 13 ++++++++++++ examples/ts/custom-area.ts | 13 ++++++++++++ jest.config.js | 1 + src/index.ts | 2 ++ src/methods/custom-area.test.ts | 20 ++++++++++++++++++ src/methods/custom-area.ts | 9 ++++++++ src/utils/fetch-data.test.ts | 37 +++++++++++++++++++++++++++++++++ src/utils/fetch-data.ts | 36 ++++++++++++++++++++++++++++++-- 10 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 examples/js/custom-area.js create mode 100644 examples/ts/custom-area.ts create mode 100644 src/methods/custom-area.test.ts create mode 100644 src/methods/custom-area.ts create mode 100644 src/utils/fetch-data.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c38043..aafc5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] +* Adds support for retrieving crimes within a custom area +* Retries rate-limited API requests using the response's `retry_after` value + ## [8.0.0] - 2026-09-15 * Removes support for Node.js 20.x * Adds support for Node.js 24.x diff --git a/README.md b/README.md index 18e3fd9..e78070e 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,17 @@ try { } ``` +To retrieve crimes within a custom area, pass a polygon as colon-separated +latitude/longitude pairs to `customArea`: + +```javascript +const poly = '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' +const data = await UKPD.customArea(poly) +``` + +Rate-limited requests are retried up to three times after waiting for the +number of seconds returned in the API response's `retry_after` field. + You can find more information by looking at any of the available [methods](./src/methods), or the [examples](./examples). diff --git a/examples/js/custom-area.js b/examples/js/custom-area.js new file mode 100644 index 0000000..d57b155 --- /dev/null +++ b/examples/js/custom-area.js @@ -0,0 +1,13 @@ +// obtain a reference to the module +// when using the module in your project, this line would be +// const UKPD = require('ukpd') +const UKPD = require('../../build') + +async function main () { + const poly = '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' + const results = await UKPD.customArea(poly) + + console.log('there were', results.length, 'crimes') +} + +main() diff --git a/examples/ts/custom-area.ts b/examples/ts/custom-area.ts new file mode 100644 index 0000000..a0fdf3c --- /dev/null +++ b/examples/ts/custom-area.ts @@ -0,0 +1,13 @@ +// obtain a reference to the module +// when using the module in your project, this line would be +// import * as UKPD from 'ukpd' +import * as UKPD from '../../build' + +async function main () { + const poly = '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' + const results = await UKPD.customArea(poly) + + console.log('there were', results.length, 'crimes') +} + +main() diff --git a/jest.config.js b/jest.config.js index bcfd15e..b4ddf0c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,4 +10,5 @@ const tsJestTransformCfg = createDefaultEsmPreset({ module.exports = { ...tsJestTransformCfg, testPathIgnorePatterns: ['build/'], + testTimeout: 120000, } diff --git a/src/index.ts b/src/index.ts index 5ae1d0d..6d4ddaf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import availability from './methods/availability' import categories from './methods/categories' +import customArea from './methods/custom-area' import forces from './methods/forces' import lastUpdated from './methods/last-updated' import stopAndSearch from './methods/stop-and-search' @@ -78,6 +79,7 @@ interface StopAndSearch { export { availability, categories, + customArea, forces, lastUpdated, stopAndSearch, diff --git a/src/methods/custom-area.test.ts b/src/methods/custom-area.test.ts new file mode 100644 index 0000000..da4b209 --- /dev/null +++ b/src/methods/custom-area.test.ts @@ -0,0 +1,20 @@ +import * as chai from 'chai' +import * as R from 'ramda' + +import * as UKPD from '../' + +import { Crime } from '../' + +const expect = chai.expect + +describe('Custom area', function () { + test('should return the expected data structure', function () { + return UKPD.customArea( + '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' + ) + .then((data: Crime[]) => { + expect(data).to.be.an('array') + R.forEach((item) => expect(item).to.be.an('object'), data) + }) + }) +}) diff --git a/src/methods/custom-area.ts b/src/methods/custom-area.ts new file mode 100644 index 0000000..7c244de --- /dev/null +++ b/src/methods/custom-area.ts @@ -0,0 +1,9 @@ +import fetchData from '../utils/fetch-data' +import { Endpoint } from '../utils/endpoints' +import { Crime } from '../' + +export default async (poly: string, date?: string): Promise => { + const dateParam = date ? `&date=${date}` : '' + + return fetchData(`${Endpoint.STREET_LEVEL}?poly=${poly}${dateParam}`) +} diff --git a/src/utils/fetch-data.test.ts b/src/utils/fetch-data.test.ts new file mode 100644 index 0000000..b15749f --- /dev/null +++ b/src/utils/fetch-data.test.ts @@ -0,0 +1,37 @@ +import fetchData from './fetch-data' + +const response = (data: unknown): Response => new Response(JSON.stringify(data)) + +describe('fetch data', function () { + const originalFetch = globalThis.fetch + const originalSetTimeout = globalThis.setTimeout + + afterEach(function () { + globalThis.fetch = originalFetch + globalThis.setTimeout = originalSetTimeout + }) + + test('retries after waiting for a rate limit response', async function () { + let requests = 0 + let retryDelay = 0 + + globalThis.fetch = async () => { + requests += 1 + + return response( + requests === 1 + ? { error: 'too_many_requests', retry_after: 30 } + : [{ id: 1 }] + ) + } + globalThis.setTimeout = ((callback: () => void, milliseconds: number): number => { + retryDelay = milliseconds + callback() + return 0 + }) as typeof setTimeout + + await expect(fetchData('/test')).resolves.toEqual([{ id: 1 }]) + expect(requests).toBe(2) + expect(retryDelay).toBe(30_000) + }) +}) diff --git a/src/utils/fetch-data.ts b/src/utils/fetch-data.ts index 78349c8..cdeb604 100644 --- a/src/utils/fetch-data.ts +++ b/src/utils/fetch-data.ts @@ -1,5 +1,37 @@ +const MAX_RETRIES = 3 + +interface RateLimitResponse { + error: string + retry_after: number +} + +const isRateLimitResponse = (data: unknown): data is RateLimitResponse => { + if (!data || typeof data !== 'object') return false + + const response = data as Partial + + return response.error === 'too_many_requests' && + typeof response.retry_after === 'number' && + Number.isFinite(response.retry_after) && + response.retry_after >= 0 +} + +const wait = (milliseconds: number): Promise => { + return new Promise((resolve) => setTimeout(resolve, milliseconds)) +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any -export default async (endpoint: string): Promise => { +const request = async (endpoint: string, retriesRemaining: number): Promise => { const response = await fetch(`https://data.police.uk/api${endpoint}`) - return response.json() + const data = await response.json() + + if (retriesRemaining > 0 && isRateLimitResponse(data)) { + await wait(data.retry_after * 1000) + return request(endpoint, retriesRemaining - 1) + } + + return data } + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export default async (endpoint: string): Promise => request(endpoint, MAX_RETRIES) From 7073767c859abd9bf3e1ab123c884e17cee2bc04 Mon Sep 17 00:00:00 2001 From: Alex Chesters <10558792+AlexChesters@users.noreply.github.com> Date: Tue, 15 Sep 2026 09:17:59 +0100 Subject: [PATCH 2/2] rename --- CHANGELOG.md | 2 +- README.md | 6 +++--- examples/js/{custom-area.js => street-level-custom-area.js} | 2 +- examples/ts/{custom-area.ts => street-level-custom-area.ts} | 2 +- src/index.ts | 4 ++-- ...custom-area.test.ts => street-level-custom-area.test.ts} | 4 ++-- src/methods/{custom-area.ts => street-level-custom-area.ts} | 0 7 files changed, 10 insertions(+), 10 deletions(-) rename examples/js/{custom-area.js => street-level-custom-area.js} (86%) rename examples/ts/{custom-area.ts => street-level-custom-area.ts} (86%) rename src/methods/{custom-area.test.ts => street-level-custom-area.test.ts} (83%) rename src/methods/{custom-area.ts => street-level-custom-area.ts} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index aafc5ca..9ef9118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -* Adds support for retrieving crimes within a custom area +* Adds support for retrieving crimes within a street-level custom area * Retries rate-limited API requests using the response's `retry_after` value ## [8.0.0] - 2026-09-15 diff --git a/README.md b/README.md index e78070e..ca824a3 100644 --- a/README.md +++ b/README.md @@ -18,12 +18,12 @@ try { } ``` -To retrieve crimes within a custom area, pass a polygon as colon-separated -latitude/longitude pairs to `customArea`: +To retrieve crimes within a street-level custom area, pass a polygon as +colon-separated latitude/longitude pairs to `streetLevelCustomArea`: ```javascript const poly = '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' -const data = await UKPD.customArea(poly) +const data = await UKPD.streetLevelCustomArea(poly) ``` Rate-limited requests are retried up to three times after waiting for the diff --git a/examples/js/custom-area.js b/examples/js/street-level-custom-area.js similarity index 86% rename from examples/js/custom-area.js rename to examples/js/street-level-custom-area.js index d57b155..e992bb4 100644 --- a/examples/js/custom-area.js +++ b/examples/js/street-level-custom-area.js @@ -5,7 +5,7 @@ const UKPD = require('../../build') async function main () { const poly = '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' - const results = await UKPD.customArea(poly) + const results = await UKPD.streetLevelCustomArea(poly) console.log('there were', results.length, 'crimes') } diff --git a/examples/ts/custom-area.ts b/examples/ts/street-level-custom-area.ts similarity index 86% rename from examples/ts/custom-area.ts rename to examples/ts/street-level-custom-area.ts index a0fdf3c..c112d73 100644 --- a/examples/ts/custom-area.ts +++ b/examples/ts/street-level-custom-area.ts @@ -5,7 +5,7 @@ import * as UKPD from '../../build' async function main () { const poly = '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' - const results = await UKPD.customArea(poly) + const results = await UKPD.streetLevelCustomArea(poly) console.log('there were', results.length, 'crimes') } diff --git a/src/index.ts b/src/index.ts index 6d4ddaf..5ceaa46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ import availability from './methods/availability' import categories from './methods/categories' -import customArea from './methods/custom-area' +import streetLevelCustomArea from './methods/street-level-custom-area' import forces from './methods/forces' import lastUpdated from './methods/last-updated' import stopAndSearch from './methods/stop-and-search' @@ -79,7 +79,7 @@ interface StopAndSearch { export { availability, categories, - customArea, + streetLevelCustomArea, forces, lastUpdated, stopAndSearch, diff --git a/src/methods/custom-area.test.ts b/src/methods/street-level-custom-area.test.ts similarity index 83% rename from src/methods/custom-area.test.ts rename to src/methods/street-level-custom-area.test.ts index da4b209..642932d 100644 --- a/src/methods/custom-area.test.ts +++ b/src/methods/street-level-custom-area.test.ts @@ -7,9 +7,9 @@ import { Crime } from '../' const expect = chai.expect -describe('Custom area', function () { +describe('Street-level custom area', function () { test('should return the expected data structure', function () { - return UKPD.customArea( + return UKPD.streetLevelCustomArea( '52.629729,-1.131592:52.630729,-1.131592:52.630729,-1.130592:52.629729,-1.130592' ) .then((data: Crime[]) => { diff --git a/src/methods/custom-area.ts b/src/methods/street-level-custom-area.ts similarity index 100% rename from src/methods/custom-area.ts rename to src/methods/street-level-custom-area.ts