diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c38043..9ef9118 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 street-level 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..ca824a3 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,17 @@ try { } ``` +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.streetLevelCustomArea(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/street-level-custom-area.js b/examples/js/street-level-custom-area.js new file mode 100644 index 0000000..e992bb4 --- /dev/null +++ b/examples/js/street-level-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.streetLevelCustomArea(poly) + + console.log('there were', results.length, 'crimes') +} + +main() diff --git a/examples/ts/street-level-custom-area.ts b/examples/ts/street-level-custom-area.ts new file mode 100644 index 0000000..c112d73 --- /dev/null +++ b/examples/ts/street-level-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.streetLevelCustomArea(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..5ceaa46 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import availability from './methods/availability' import categories from './methods/categories' +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' @@ -78,6 +79,7 @@ interface StopAndSearch { export { availability, categories, + streetLevelCustomArea, forces, lastUpdated, stopAndSearch, diff --git a/src/methods/street-level-custom-area.test.ts b/src/methods/street-level-custom-area.test.ts new file mode 100644 index 0000000..642932d --- /dev/null +++ b/src/methods/street-level-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('Street-level custom area', function () { + test('should return the expected data structure', function () { + return UKPD.streetLevelCustomArea( + '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/street-level-custom-area.ts b/src/methods/street-level-custom-area.ts new file mode 100644 index 0000000..7c244de --- /dev/null +++ b/src/methods/street-level-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)