Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
13 changes: 13 additions & 0 deletions examples/js/street-level-custom-area.js
Original file line number Diff line number Diff line change
@@ -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()
13 changes: 13 additions & 0 deletions examples/ts/street-level-custom-area.ts
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ const tsJestTransformCfg = createDefaultEsmPreset({
module.exports = {
...tsJestTransformCfg,
testPathIgnorePatterns: ['build/'],
testTimeout: 120000,
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -78,6 +79,7 @@ interface StopAndSearch {
export {
availability,
categories,
streetLevelCustomArea,
forces,
lastUpdated,
stopAndSearch,
Expand Down
20 changes: 20 additions & 0 deletions src/methods/street-level-custom-area.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
})
9 changes: 9 additions & 0 deletions src/methods/street-level-custom-area.ts
Original file line number Diff line number Diff line change
@@ -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<Crime[]> => {
const dateParam = date ? `&date=${date}` : ''

return fetchData(`${Endpoint.STREET_LEVEL}?poly=${poly}${dateParam}`)
}
37 changes: 37 additions & 0 deletions src/utils/fetch-data.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
36 changes: 34 additions & 2 deletions src/utils/fetch-data.ts
Original file line number Diff line number Diff line change
@@ -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<RateLimitResponse>

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<void> => {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default async (endpoint: string): Promise<any> => {
const request = async (endpoint: string, retriesRemaining: number): Promise<any> => {
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<any> => request(endpoint, MAX_RETRIES)
Loading