@centralping/ergo 0.1.0-beta.2 → 0.1.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/README.md +1 -1
- package/http/accepts.js +5 -5
- package/http/body.js +4 -1
- package/http/idempotency.js +110 -0
- package/http/main.js +3 -0
- package/http/validate.js +9 -5
- package/lib/idempotency.js +139 -0
- package/lib/link.js +57 -4
- package/lib/validate.js +14 -0
- package/package.json +2 -1
- package/types/http/idempotency.d.ts +20 -0
- package/types/http/main.d.ts +6 -2
- package/types/http/validate.d.ts +3 -1
- package/types/lib/idempotency.d.ts +64 -0
- package/types/lib/link.d.ts +28 -0
- package/types/lib/validate.d.ts +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0-beta.4] - 2026-05-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- JSON Schema `format` keyword support via `ajv-formats`. Standard formats (`email`, `uri`,
|
|
12
|
+
`date-time`, `uuid`, etc.) are validated by default. Opt out with `formats: false` or select
|
|
13
|
+
specific formats with an array (e.g. `formats: ['email', 'uri']`). (#58)
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **BREAKING**: `accepts()` now defaults `throwIfFail` to `true`, enforcing 406 responses for unsatisfied content negotiation per the Fast Fail pipeline contract. Set `throwIfFail: false` to restore the previous informational-only behavior. (#55)
|
|
18
|
+
- `body()` default types now include `application/merge-patch+json` (RFC 7386) and `application/json-patch+json` (RFC 6902), resolving 415 rejections for valid PATCH content types that ergo-router's `strictPatch` allows. (#56)
|
|
19
|
+
- `validate()` params validation now checks `acc.route.params` (ergo-router convention) with fallback to `acc.params` (standalone), fixing silent no-op validation when used with ergo-router. (#57)
|
|
20
|
+
|
|
5
21
|
## [0.1.0-beta.1] - 2026-05-20
|
|
6
22
|
|
|
7
23
|
### Changed
|
package/README.md
CHANGED
package/http/accepts.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* `Accept-Charset`, and `Accept-Encoding` request headers and determine the best
|
|
6
6
|
* matching content type, language, charset, and encoding for the response.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* By default, returns `{response: {statusCode: 406, detail}}` for any header
|
|
9
|
+
* that cannot be satisfied, enforcing strict content negotiation in the Fast
|
|
10
|
+
* Fail pipeline. Set `throwIfFail: false` for informational-only negotiation.
|
|
11
11
|
*
|
|
12
12
|
* @module http/accepts
|
|
13
13
|
* @since 0.1.0
|
|
@@ -37,14 +37,14 @@ const headerMap = {
|
|
|
37
37
|
* Creates a content negotiation middleware.
|
|
38
38
|
*
|
|
39
39
|
* @param {object} [options] - Negotiation configuration
|
|
40
|
-
* @param {boolean} [options.throwIfFail=
|
|
40
|
+
* @param {boolean} [options.throwIfFail=true] - Return `{response: {statusCode: 406, detail}}` if any negotiation key is undefined
|
|
41
41
|
* @param {string[]} [options.types] - Acceptable media types
|
|
42
42
|
* @param {string[]} [options.languages] - Acceptable languages
|
|
43
43
|
* @param {string[]} [options.charsets] - Acceptable character sets
|
|
44
44
|
* @param {string[]} [options.encodings] - Acceptable content encodings
|
|
45
45
|
* @returns {function} - Middleware `(req) => {type, language, charset, encoding}` on success, or `{response: {statusCode: 406, detail: string}}` when `throwIfFail` is true and any negotiation value is undefined
|
|
46
46
|
*/
|
|
47
|
-
export default ({throwIfFail =
|
|
47
|
+
export default ({throwIfFail = true, ...options} = {}) => {
|
|
48
48
|
const acceptor = accepts(options);
|
|
49
49
|
|
|
50
50
|
return ({headers = {}} = {}) => {
|
package/http/body.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* @fileoverview HTTP middleware factory for request body parsing.
|
|
3
3
|
*
|
|
4
4
|
* Reads and parses the request body according to the declared `Content-Type`. Supports:
|
|
5
|
-
* - `application/json
|
|
5
|
+
* - `application/json`, `application/vnd.api+json`, `application/merge-patch+json`
|
|
6
|
+
* (RFC 7386), and `application/json-patch+json` (RFC 6902) — parsed via `JSON.parse`
|
|
6
7
|
* - `application/x-www-form-urlencoded` — parsed via the query string parser
|
|
7
8
|
* - `multipart/form-data` — parsed via the RFC 7578 streaming multipart parser
|
|
8
9
|
*
|
|
@@ -128,6 +129,8 @@ export default ({
|
|
|
128
129
|
types = [
|
|
129
130
|
'application/vnd.api+json',
|
|
130
131
|
'application/json',
|
|
132
|
+
'application/merge-patch+json',
|
|
133
|
+
'application/json-patch+json',
|
|
131
134
|
'application/x-www-form-urlencoded',
|
|
132
135
|
'multipart/form-data'
|
|
133
136
|
],
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Idempotency-Key pipeline middleware.
|
|
3
|
+
*
|
|
4
|
+
* Detects duplicate requests by `Idempotency-Key` header value per
|
|
5
|
+
* draft-ietf-httpapi-idempotency-key-header-07. When a matching key with
|
|
6
|
+
* the same request fingerprint is found, the stored response is replayed.
|
|
7
|
+
* When a key matches but the fingerprint differs, a 409 Conflict is returned.
|
|
8
|
+
*
|
|
9
|
+
* Placed in Stage 3 (Validation) after body parsing so the request body
|
|
10
|
+
* fingerprint can be computed from the parsed body.
|
|
11
|
+
*
|
|
12
|
+
* @module http/idempotency
|
|
13
|
+
* @since 0.1.0-beta.2
|
|
14
|
+
* @requires ../lib/idempotency.js
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* import {compose, body, idempotency} from '@centralping/ergo';
|
|
18
|
+
*
|
|
19
|
+
* const pipeline = compose(
|
|
20
|
+
* [body(), 'body'],
|
|
21
|
+
* [idempotency({required: true}), 'idempotency'],
|
|
22
|
+
* (req, res, acc) => ({response: {statusCode: 201, body: {created: true}}})
|
|
23
|
+
* );
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ Idempotency-Key Header (IETF Draft)}
|
|
26
|
+
*/
|
|
27
|
+
import {IdempotencyStore, parseIdempotencyKey, generateFingerprint} from '../lib/idempotency.js';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_METHODS = new Set(['POST', 'PATCH']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Create an idempotency middleware.
|
|
33
|
+
*
|
|
34
|
+
* @param {object} [options]
|
|
35
|
+
* @param {object} [options.store] - Pluggable store (must implement get/set/complete/delete).
|
|
36
|
+
* Defaults to an in-memory store with 24h TTL.
|
|
37
|
+
* @param {number} [options.ttlMs=86400000] - TTL for stored entries in milliseconds (default: 24h).
|
|
38
|
+
* Only used when creating the default in-memory store.
|
|
39
|
+
* @param {boolean} [options.required=false] - When true, returns 400 if the header is missing
|
|
40
|
+
* on applicable methods
|
|
41
|
+
* @param {Set<string>|string[]} [options.methods] - HTTP methods to apply idempotency to
|
|
42
|
+
* (default: POST, PATCH)
|
|
43
|
+
* @returns {function} - Middleware `(req, res, domainAcc) => {value?, response?}`
|
|
44
|
+
*/
|
|
45
|
+
export default function idempotency({store, ttlMs, required = false, methods} = {}) {
|
|
46
|
+
const _store = store ?? new IdempotencyStore(ttlMs ? {ttlMs} : undefined);
|
|
47
|
+
const _methods = methods instanceof Set ? methods : new Set(methods ?? DEFAULT_METHODS);
|
|
48
|
+
|
|
49
|
+
return (req, _res, domainAcc) => {
|
|
50
|
+
if (!_methods.has(req.method)) return {};
|
|
51
|
+
|
|
52
|
+
const key = parseIdempotencyKey(req.headers?.['idempotency-key']);
|
|
53
|
+
|
|
54
|
+
if (!key) {
|
|
55
|
+
if (required) {
|
|
56
|
+
return {
|
|
57
|
+
response: {
|
|
58
|
+
statusCode: 400,
|
|
59
|
+
detail: 'Idempotency-Key header is required'
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const rawBody = domainAcc?.body?.raw ?? domainAcc?.body?.parsed ?? '';
|
|
67
|
+
const fingerprint = generateFingerprint(
|
|
68
|
+
typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const existing = _store.get(key);
|
|
72
|
+
|
|
73
|
+
if (existing) {
|
|
74
|
+
if (existing.fingerprint !== fingerprint) {
|
|
75
|
+
return {
|
|
76
|
+
response: {
|
|
77
|
+
statusCode: 409,
|
|
78
|
+
detail: 'Idempotency key already used with a different request'
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (existing.status === 'complete' && existing.response) {
|
|
84
|
+
return {
|
|
85
|
+
value: {replayed: true},
|
|
86
|
+
response: existing.response
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Still processing — concurrent duplicate
|
|
91
|
+
return {
|
|
92
|
+
response: {
|
|
93
|
+
statusCode: 409,
|
|
94
|
+
detail: 'A request with this idempotency key is already being processed'
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_store.set(key, fingerprint);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
value: {
|
|
103
|
+
key,
|
|
104
|
+
fingerprint,
|
|
105
|
+
complete: response => _store.complete(key, response),
|
|
106
|
+
discard: () => _store.delete(key)
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
};
|
|
110
|
+
}
|
package/http/main.js
CHANGED
|
@@ -80,6 +80,7 @@ import rateLimit from './rate-limit.js';
|
|
|
80
80
|
import securityHeaders from './security-headers.js';
|
|
81
81
|
import send from './send.js';
|
|
82
82
|
import timeout from './timeout.js';
|
|
83
|
+
import idempotency from './idempotency.js';
|
|
83
84
|
import validate from './validate.js';
|
|
84
85
|
import httpErrors from '../utils/http-errors.js';
|
|
85
86
|
import fromConnect from '../lib/from-connect.js';
|
|
@@ -99,6 +100,7 @@ export {
|
|
|
99
100
|
csrf,
|
|
100
101
|
fromConnect,
|
|
101
102
|
httpErrors,
|
|
103
|
+
idempotency,
|
|
102
104
|
jsonApiQuery,
|
|
103
105
|
logger,
|
|
104
106
|
prefer,
|
|
@@ -126,6 +128,7 @@ export default {
|
|
|
126
128
|
csrf,
|
|
127
129
|
fromConnect,
|
|
128
130
|
httpErrors,
|
|
131
|
+
idempotency,
|
|
129
132
|
jsonApiQuery,
|
|
130
133
|
logger,
|
|
131
134
|
prefer,
|
package/http/validate.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview HTTP middleware factory for JSON Schema validation.
|
|
3
3
|
*
|
|
4
|
-
* Validates properties from the accumulator (body, url, params) against provided
|
|
5
|
-
* Schemas using AJV. Schemas are compiled once at middleware creation time for
|
|
4
|
+
* Validates properties from the accumulator (body, url, route params) against provided
|
|
5
|
+
* JSON Schemas using AJV. Schemas are compiled once at middleware creation time for
|
|
6
|
+
* performance. Route params are resolved from `acc.route.params` (ergo-router) with
|
|
7
|
+
* fallback to `acc.params` (standalone).
|
|
6
8
|
*
|
|
7
9
|
* Returns `{response: {statusCode: 422, detail: ...}}` with structured error details on validation failure.
|
|
8
10
|
* Must be placed after `body()` and/or `url()` in the pipeline so accumulator values
|
|
@@ -39,8 +41,10 @@ import createValidator from '../lib/validate.js';
|
|
|
39
41
|
* @param {object} [schemas] - Schema map; each key corresponds to an accumulator property
|
|
40
42
|
* @param {object} [schemas.body] - JSON Schema for the parsed request body
|
|
41
43
|
* @param {object} [schemas.query] - JSON Schema for parsed query parameters
|
|
42
|
-
* @param {object} [schemas.params] - JSON Schema for route path parameters
|
|
44
|
+
* @param {object} [schemas.params] - JSON Schema for route path parameters (reads `acc.route.params` or `acc.params`)
|
|
43
45
|
* @param {object} [options] - AJV options forwarded to each compiled validator
|
|
46
|
+
* @param {boolean|Array<string>|object} [options.formats] - Format keyword support via
|
|
47
|
+
* `ajv-formats`; forwarded to `createValidator`. Defaults to all standard formats enabled
|
|
44
48
|
* @returns {function} - Ergo middleware `(req, res, acc) => void` that returns `{response: {statusCode: 422}}`
|
|
45
49
|
* (with `detail` and `details` from AJV) on validation failure
|
|
46
50
|
*/
|
|
@@ -67,8 +71,8 @@ export default (schemas = {}, options = {}) => {
|
|
|
67
71
|
validators.query(acc.url.query);
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
if (validators.params && acc.params) {
|
|
71
|
-
validators.params(acc.params);
|
|
74
|
+
if (validators.params && (acc.route?.params ?? acc.params)) {
|
|
75
|
+
validators.params(acc.route?.params ?? acc.params);
|
|
72
76
|
}
|
|
73
77
|
} catch (err) {
|
|
74
78
|
return {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Idempotency-Key shared primitives.
|
|
3
|
+
*
|
|
4
|
+
* Provides the core building blocks for idempotent request handling per
|
|
5
|
+
* draft-ietf-httpapi-idempotency-key-header-07. The `IdempotencyStore`
|
|
6
|
+
* manages key lifecycle (storage, expiry, replay). `parseIdempotencyKey`
|
|
7
|
+
* extracts the key value from the RFC 8941 structured field header.
|
|
8
|
+
* `generateFingerprint` creates a SHA-256 hash of the request body for
|
|
9
|
+
* detecting key reuse with different payloads.
|
|
10
|
+
*
|
|
11
|
+
* Used by:
|
|
12
|
+
* - `http/idempotency.js` (ergo pipeline middleware)
|
|
13
|
+
*
|
|
14
|
+
* @module lib/idempotency
|
|
15
|
+
* @since 0.1.0-beta.2
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* import {IdempotencyStore, parseIdempotencyKey, generateFingerprint}
|
|
19
|
+
* from '@centralping/ergo/lib/idempotency';
|
|
20
|
+
*
|
|
21
|
+
* const store = new IdempotencyStore();
|
|
22
|
+
* const key = parseIdempotencyKey(req.headers['idempotency-key']);
|
|
23
|
+
* const fingerprint = generateFingerprint(body);
|
|
24
|
+
*
|
|
25
|
+
* @see {@link https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/ Idempotency-Key Header (IETF Draft)}
|
|
26
|
+
* @see {@link https://www.rfc-editor.org/rfc/rfc8941 RFC 8941 - Structured Field Values}
|
|
27
|
+
*/
|
|
28
|
+
import {createHash} from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
const SF_STRING_RE = /^"([^"\\]*(?:\\.[^"\\]*)*)"$/;
|
|
31
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* In-memory idempotency key store with TTL-based expiry.
|
|
35
|
+
*
|
|
36
|
+
* Entries transition through states: `processing` -> `complete`.
|
|
37
|
+
* Expired entries are pruned lazily on `get()` calls.
|
|
38
|
+
*/
|
|
39
|
+
export class IdempotencyStore {
|
|
40
|
+
constructor({maxKeys = 10_000, ttlMs = DEFAULT_TTL_MS} = {}) {
|
|
41
|
+
this._entries = new Map();
|
|
42
|
+
this._maxKeys = maxKeys;
|
|
43
|
+
this._ttlMs = ttlMs;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retrieve a stored entry by key. Returns `undefined` if not found or expired.
|
|
48
|
+
* @param {string} key - Idempotency key
|
|
49
|
+
* @returns {{fingerprint: string, response: object, status: string, expiresAt: number} | undefined}
|
|
50
|
+
*/
|
|
51
|
+
get(key) {
|
|
52
|
+
const entry = this._entries.get(key);
|
|
53
|
+
if (!entry) return undefined;
|
|
54
|
+
|
|
55
|
+
if (Date.now() > entry.expiresAt) {
|
|
56
|
+
this._entries.delete(key);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return entry;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Store a new idempotency entry in `processing` state.
|
|
65
|
+
* @param {string} key - Idempotency key
|
|
66
|
+
* @param {string} fingerprint - Request body fingerprint
|
|
67
|
+
*/
|
|
68
|
+
set(key, fingerprint) {
|
|
69
|
+
if (this._entries.size >= this._maxKeys) {
|
|
70
|
+
const oldest = this._entries.keys().next().value;
|
|
71
|
+
this._entries.delete(oldest);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entry = Object.create(null);
|
|
75
|
+
entry.fingerprint = fingerprint;
|
|
76
|
+
entry.response = undefined;
|
|
77
|
+
entry.status = 'processing';
|
|
78
|
+
entry.expiresAt = Date.now() + this._ttlMs;
|
|
79
|
+
this._entries.set(key, entry);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mark an entry as complete with the response to replay.
|
|
84
|
+
* @param {string} key - Idempotency key
|
|
85
|
+
* @param {object} response - Response accumulator snapshot to replay
|
|
86
|
+
*/
|
|
87
|
+
complete(key, response) {
|
|
88
|
+
const entry = this._entries.get(key);
|
|
89
|
+
if (entry) {
|
|
90
|
+
entry.status = 'complete';
|
|
91
|
+
entry.response = response;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Remove an entry (e.g. on request failure where replay is not desired).
|
|
97
|
+
* @param {string} key - Idempotency key
|
|
98
|
+
*/
|
|
99
|
+
delete(key) {
|
|
100
|
+
this._entries.delete(key);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Parse an `Idempotency-Key` header value as an RFC 8941 sf-string.
|
|
106
|
+
*
|
|
107
|
+
* The draft specifies the header is an Item Structured Header whose value
|
|
108
|
+
* is a String. This means the wire format is `"value"` (double-quoted).
|
|
109
|
+
* Returns `undefined` for missing, empty, or malformed values.
|
|
110
|
+
*
|
|
111
|
+
* @param {string | undefined} header - Raw header value
|
|
112
|
+
* @returns {string | undefined} - Parsed key value, or undefined
|
|
113
|
+
*/
|
|
114
|
+
export function parseIdempotencyKey(header) {
|
|
115
|
+
if (!header) return undefined;
|
|
116
|
+
|
|
117
|
+
const trimmed = header.trim();
|
|
118
|
+
const match = SF_STRING_RE.exec(trimmed);
|
|
119
|
+
if (!match) return undefined;
|
|
120
|
+
|
|
121
|
+
const inner = match[1];
|
|
122
|
+
if (inner.replace(/\\["\\]/g, '').includes('\\')) return undefined;
|
|
123
|
+
return inner.replace(/\\(["\\])/g, '$1');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Generate a SHA-256 fingerprint of the request body.
|
|
128
|
+
*
|
|
129
|
+
* Used to detect key reuse with different payloads (which should return 409).
|
|
130
|
+
* Accepts strings or Buffers. Returns the hex digest.
|
|
131
|
+
*
|
|
132
|
+
* @param {string | Buffer} body - Serialized request body
|
|
133
|
+
* @returns {string} - Hex-encoded SHA-256 hash
|
|
134
|
+
*/
|
|
135
|
+
export function generateFingerprint(body) {
|
|
136
|
+
return createHash('sha256')
|
|
137
|
+
.update(body ?? '')
|
|
138
|
+
.digest('hex');
|
|
139
|
+
}
|
package/lib/link.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* @fileoverview RFC 8288 Web Linking utilities.
|
|
3
3
|
*
|
|
4
4
|
* Provides functions for formatting `Link` response headers per RFC 8288.
|
|
5
|
-
* `formatLinkHeader` is the low-level formatter; `paginationLinks`
|
|
6
|
-
* convenience
|
|
7
|
-
*
|
|
5
|
+
* `formatLinkHeader` is the low-level formatter; `paginationLinks` and
|
|
6
|
+
* `cursorPaginationLinks` are convenience helpers that generate link objects
|
|
7
|
+
* for offset-based and cursor-based pagination respectively.
|
|
8
8
|
*
|
|
9
9
|
* These are pure utility functions (not middleware). Consumers wire the
|
|
10
10
|
* formatted header value into the accumulator's `headers` array for `send()`.
|
|
@@ -15,8 +15,10 @@
|
|
|
15
15
|
* @see {@link https://www.rfc-editor.org/rfc/rfc8288 RFC 8288 - Web Linking}
|
|
16
16
|
*
|
|
17
17
|
* @example
|
|
18
|
-
* import {formatLinkHeader, paginationLinks}
|
|
18
|
+
* import {formatLinkHeader, paginationLinks, cursorPaginationLinks}
|
|
19
|
+
* from '@centralping/ergo/lib/link';
|
|
19
20
|
*
|
|
21
|
+
* // Offset-based pagination
|
|
20
22
|
* const links = paginationLinks({
|
|
21
23
|
* baseUrl: '/articles',
|
|
22
24
|
* searchParams: 'sort=date',
|
|
@@ -26,6 +28,15 @@
|
|
|
26
28
|
* });
|
|
27
29
|
* const header = formatLinkHeader(links);
|
|
28
30
|
* // '</articles?sort=date&page=1&per_page=25>; rel="first", ...'
|
|
31
|
+
*
|
|
32
|
+
* // Cursor-based pagination
|
|
33
|
+
* const cursorLinks = cursorPaginationLinks({
|
|
34
|
+
* baseUrl: '/articles',
|
|
35
|
+
* searchParams: 'sort=date',
|
|
36
|
+
* nextCursor: 'eyJpZCI6NDJ9.abc123'
|
|
37
|
+
* });
|
|
38
|
+
* const cursorHeader = formatLinkHeader(cursorLinks);
|
|
39
|
+
* // '</articles?sort=date>; rel="first", </articles?sort=date&cursor=eyJpZCI6NDJ9.abc123>; rel="next"'
|
|
29
40
|
*/
|
|
30
41
|
import sanitizeQuotedString from './sanitize-quoted-string.js';
|
|
31
42
|
|
|
@@ -93,3 +104,45 @@ export function paginationLinks({baseUrl, page, perPage, total, searchParams = '
|
|
|
93
104
|
|
|
94
105
|
return links;
|
|
95
106
|
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Generates cursor-based pagination link objects for first, prev, and next pages.
|
|
110
|
+
*
|
|
111
|
+
* Unlike offset pagination, cursor-based pagination uses opaque continuation
|
|
112
|
+
* tokens instead of page numbers. There is no `last` link because the total
|
|
113
|
+
* number of pages is unknown in cursor-based schemes.
|
|
114
|
+
*
|
|
115
|
+
* The `first` link is always present and points to the base URL without any
|
|
116
|
+
* cursor parameter (requesting the first page). `prev` and `next` are
|
|
117
|
+
* included only when the corresponding cursor token is provided.
|
|
118
|
+
*
|
|
119
|
+
* @param {object} options - Cursor pagination parameters
|
|
120
|
+
* @param {string} options.baseUrl - Base URL path (e.g. '/articles')
|
|
121
|
+
* @param {string} [options.searchParams=''] - Additional query parameters to preserve
|
|
122
|
+
* (e.g. 'sort=date&filter=active'). Appended before the cursor parameter.
|
|
123
|
+
* @param {string} [options.nextCursor] - Opaque cursor token for the next page
|
|
124
|
+
* @param {string} [options.prevCursor] - Opaque cursor token for the previous page
|
|
125
|
+
* @returns {Array<{href: string, rel: string}>} - Array of link objects
|
|
126
|
+
*/
|
|
127
|
+
export function cursorPaginationLinks({baseUrl, searchParams = '', nextCursor, prevCursor}) {
|
|
128
|
+
const base = searchParams ? `${baseUrl}?${searchParams}` : baseUrl;
|
|
129
|
+
const sep = searchParams ? '&' : '?';
|
|
130
|
+
|
|
131
|
+
const links = [{href: base, rel: 'first'}];
|
|
132
|
+
|
|
133
|
+
if (prevCursor !== undefined) {
|
|
134
|
+
links.push({
|
|
135
|
+
href: `${base}${sep}cursor=${encodeURIComponent(prevCursor)}`,
|
|
136
|
+
rel: 'prev'
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (nextCursor !== undefined) {
|
|
141
|
+
links.push({
|
|
142
|
+
href: `${base}${sep}cursor=${encodeURIComponent(nextCursor)}`,
|
|
143
|
+
rel: 'next'
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return links;
|
|
148
|
+
}
|
package/lib/validate.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* @module lib/validate
|
|
11
11
|
* @since 0.1.0
|
|
12
12
|
* @requires ajv
|
|
13
|
+
* @requires ajv-formats
|
|
13
14
|
* @requires ../utils/http-errors.js
|
|
14
15
|
*
|
|
15
16
|
* @example
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
* validate({}); // throws 422 with details
|
|
26
27
|
*/
|
|
27
28
|
import Ajv from 'ajv';
|
|
29
|
+
import addFormats from 'ajv-formats';
|
|
28
30
|
import httpErrors from '../utils/http-errors.js';
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -34,6 +36,11 @@ import httpErrors from '../utils/http-errors.js';
|
|
|
34
36
|
* @param {object} [options] - Validator options
|
|
35
37
|
* @param {boolean} [options.allErrors=true] - Report all errors instead of stopping at the first
|
|
36
38
|
* @param {boolean} [options.coerceTypes=false] - Coerce input values to match schema types
|
|
39
|
+
* @param {boolean|Array<string>|object} [options.formats] - Format keyword support via
|
|
40
|
+
* `ajv-formats`. `undefined` or `true` enables all standard formats; `false` disables (AJV
|
|
41
|
+
* strict mode rejects unknown formats); an array enables selective formats
|
|
42
|
+
* (e.g. `['email', 'uri']`); an object is passed as the full plugin config
|
|
43
|
+
* (e.g. `{mode: 'fast'}`)
|
|
37
44
|
* @param {object} [options.ajv] - Additional AJV constructor options
|
|
38
45
|
* @returns {function} - `validateData(data)` — returns `data` on success, throws 422 on failure
|
|
39
46
|
* @throws {Error} 422 with `details` array if schema validation fails
|
|
@@ -45,6 +52,13 @@ export default function createValidator(schema, options = {}) {
|
|
|
45
52
|
...options.ajv
|
|
46
53
|
});
|
|
47
54
|
|
|
55
|
+
if (options.formats !== false) {
|
|
56
|
+
addFormats(
|
|
57
|
+
ajv,
|
|
58
|
+
options.formats === true || options.formats === undefined ? undefined : options.formats
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
48
62
|
const validate = ajv.compile(schema);
|
|
49
63
|
|
|
50
64
|
return function validateData(data) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@centralping/ergo",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.4",
|
|
4
4
|
"description": "A Fast Fail REST API toolkit for Node.js -- composable middleware with structured Negotiation, Authorization, Validation, and Execution stages.",
|
|
5
5
|
"main": "http/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -116,6 +116,7 @@
|
|
|
116
116
|
},
|
|
117
117
|
"dependencies": {
|
|
118
118
|
"ajv": "^8.20.0",
|
|
119
|
+
"ajv-formats": "^3.0.1",
|
|
119
120
|
"content-type": "^2.0.0",
|
|
120
121
|
"etag": "^1.8.1",
|
|
121
122
|
"negotiator": "^1.0.0"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Create an idempotency middleware.
|
|
3
|
+
*
|
|
4
|
+
* @param {object} [options]
|
|
5
|
+
* @param {object} [options.store] - Pluggable store (must implement get/set/complete/delete).
|
|
6
|
+
* Defaults to an in-memory store with 24h TTL.
|
|
7
|
+
* @param {number} [options.ttlMs=86400000] - TTL for stored entries in milliseconds (default: 24h).
|
|
8
|
+
* Only used when creating the default in-memory store.
|
|
9
|
+
* @param {boolean} [options.required=false] - When true, returns 400 if the header is missing
|
|
10
|
+
* on applicable methods
|
|
11
|
+
* @param {Set<string>|string[]} [options.methods] - HTTP methods to apply idempotency to
|
|
12
|
+
* (default: POST, PATCH)
|
|
13
|
+
* @returns {function} - Middleware `(req, res, domainAcc) => {value?, response?}`
|
|
14
|
+
*/
|
|
15
|
+
export default function idempotency({ store, ttlMs, required, methods }?: {
|
|
16
|
+
store?: object | undefined;
|
|
17
|
+
ttlMs?: number | undefined;
|
|
18
|
+
required?: boolean | undefined;
|
|
19
|
+
methods?: string[] | Set<string> | undefined;
|
|
20
|
+
}): Function;
|
package/types/http/main.d.ts
CHANGED
|
@@ -75,6 +75,7 @@ declare const _default: {
|
|
|
75
75
|
}) => object;
|
|
76
76
|
fromConnect: typeof fromConnect;
|
|
77
77
|
httpErrors: typeof httpErrors;
|
|
78
|
+
idempotency: typeof idempotency;
|
|
78
79
|
jsonApiQuery: (...options: any[]) => Function;
|
|
79
80
|
logger: ({ log, error: logError, uuid, headerRequestIdName, headerRequestIpName, redactHeaders }?: {
|
|
80
81
|
log?: Function | undefined;
|
|
@@ -112,7 +113,9 @@ declare const _default: {
|
|
|
112
113
|
body?: object | undefined;
|
|
113
114
|
query?: object | undefined;
|
|
114
115
|
params?: object | undefined;
|
|
115
|
-
}, options?:
|
|
116
|
+
}, options?: {
|
|
117
|
+
formats?: boolean | object | string[] | undefined;
|
|
118
|
+
}) => Function;
|
|
116
119
|
};
|
|
117
120
|
export default _default;
|
|
118
121
|
import compose from '../utils/compose-with.js';
|
|
@@ -129,6 +132,7 @@ import cors from './cors.js';
|
|
|
129
132
|
import csrf from './csrf.js';
|
|
130
133
|
import fromConnect from '../lib/from-connect.js';
|
|
131
134
|
import httpErrors from '../utils/http-errors.js';
|
|
135
|
+
import idempotency from './idempotency.js';
|
|
132
136
|
import jsonApiQuery from './json-api-query.js';
|
|
133
137
|
import logger from './logger.js';
|
|
134
138
|
import prefer from './prefer.js';
|
|
@@ -139,4 +143,4 @@ import url from './url.js';
|
|
|
139
143
|
import send from './send.js';
|
|
140
144
|
import timeout from './timeout.js';
|
|
141
145
|
import validate from './validate.js';
|
|
142
|
-
export { compose, createResponseAcc, mergeResponse, handler, accepts, authorization, body, cacheControl, compress, cookie, cors, csrf, fromConnect, httpErrors, jsonApiQuery, logger, prefer, precondition, rateLimit, securityHeaders, url, send, timeout, validate };
|
|
146
|
+
export { compose, createResponseAcc, mergeResponse, handler, accepts, authorization, body, cacheControl, compress, cookie, cors, csrf, fromConnect, httpErrors, idempotency, jsonApiQuery, logger, prefer, precondition, rateLimit, securityHeaders, url, send, timeout, validate };
|
package/types/http/validate.d.ts
CHANGED
|
@@ -2,5 +2,7 @@ declare function _default(schemas?: {
|
|
|
2
2
|
body?: object | undefined;
|
|
3
3
|
query?: object | undefined;
|
|
4
4
|
params?: object | undefined;
|
|
5
|
-
}, options?:
|
|
5
|
+
}, options?: {
|
|
6
|
+
formats?: boolean | object | string[] | undefined;
|
|
7
|
+
}): Function;
|
|
6
8
|
export default _default;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse an `Idempotency-Key` header value as an RFC 8941 sf-string.
|
|
3
|
+
*
|
|
4
|
+
* The draft specifies the header is an Item Structured Header whose value
|
|
5
|
+
* is a String. This means the wire format is `"value"` (double-quoted).
|
|
6
|
+
* Returns `undefined` for missing, empty, or malformed values.
|
|
7
|
+
*
|
|
8
|
+
* @param {string | undefined} header - Raw header value
|
|
9
|
+
* @returns {string | undefined} - Parsed key value, or undefined
|
|
10
|
+
*/
|
|
11
|
+
export function parseIdempotencyKey(header: string | undefined): string | undefined;
|
|
12
|
+
/**
|
|
13
|
+
* Generate a SHA-256 fingerprint of the request body.
|
|
14
|
+
*
|
|
15
|
+
* Used to detect key reuse with different payloads (which should return 409).
|
|
16
|
+
* Accepts strings or Buffers. Returns the hex digest.
|
|
17
|
+
*
|
|
18
|
+
* @param {string | Buffer} body - Serialized request body
|
|
19
|
+
* @returns {string} - Hex-encoded SHA-256 hash
|
|
20
|
+
*/
|
|
21
|
+
export function generateFingerprint(body: string | Buffer): string;
|
|
22
|
+
/**
|
|
23
|
+
* In-memory idempotency key store with TTL-based expiry.
|
|
24
|
+
*
|
|
25
|
+
* Entries transition through states: `processing` -> `complete`.
|
|
26
|
+
* Expired entries are pruned lazily on `get()` calls.
|
|
27
|
+
*/
|
|
28
|
+
export class IdempotencyStore {
|
|
29
|
+
constructor({ maxKeys, ttlMs }?: {
|
|
30
|
+
maxKeys?: number | undefined;
|
|
31
|
+
ttlMs?: number | undefined;
|
|
32
|
+
});
|
|
33
|
+
_entries: Map<any, any>;
|
|
34
|
+
_maxKeys: number;
|
|
35
|
+
_ttlMs: number;
|
|
36
|
+
/**
|
|
37
|
+
* Retrieve a stored entry by key. Returns `undefined` if not found or expired.
|
|
38
|
+
* @param {string} key - Idempotency key
|
|
39
|
+
* @returns {{fingerprint: string, response: object, status: string, expiresAt: number} | undefined}
|
|
40
|
+
*/
|
|
41
|
+
get(key: string): {
|
|
42
|
+
fingerprint: string;
|
|
43
|
+
response: object;
|
|
44
|
+
status: string;
|
|
45
|
+
expiresAt: number;
|
|
46
|
+
} | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Store a new idempotency entry in `processing` state.
|
|
49
|
+
* @param {string} key - Idempotency key
|
|
50
|
+
* @param {string} fingerprint - Request body fingerprint
|
|
51
|
+
*/
|
|
52
|
+
set(key: string, fingerprint: string): void;
|
|
53
|
+
/**
|
|
54
|
+
* Mark an entry as complete with the response to replay.
|
|
55
|
+
* @param {string} key - Idempotency key
|
|
56
|
+
* @param {object} response - Response accumulator snapshot to replay
|
|
57
|
+
*/
|
|
58
|
+
complete(key: string, response: object): void;
|
|
59
|
+
/**
|
|
60
|
+
* Remove an entry (e.g. on request failure where replay is not desired).
|
|
61
|
+
* @param {string} key - Idempotency key
|
|
62
|
+
*/
|
|
63
|
+
delete(key: string): void;
|
|
64
|
+
}
|
package/types/lib/link.d.ts
CHANGED
|
@@ -35,3 +35,31 @@ export function paginationLinks({ baseUrl, page, perPage, total, searchParams }:
|
|
|
35
35
|
href: string;
|
|
36
36
|
rel: string;
|
|
37
37
|
}>;
|
|
38
|
+
/**
|
|
39
|
+
* Generates cursor-based pagination link objects for first, prev, and next pages.
|
|
40
|
+
*
|
|
41
|
+
* Unlike offset pagination, cursor-based pagination uses opaque continuation
|
|
42
|
+
* tokens instead of page numbers. There is no `last` link because the total
|
|
43
|
+
* number of pages is unknown in cursor-based schemes.
|
|
44
|
+
*
|
|
45
|
+
* The `first` link is always present and points to the base URL without any
|
|
46
|
+
* cursor parameter (requesting the first page). `prev` and `next` are
|
|
47
|
+
* included only when the corresponding cursor token is provided.
|
|
48
|
+
*
|
|
49
|
+
* @param {object} options - Cursor pagination parameters
|
|
50
|
+
* @param {string} options.baseUrl - Base URL path (e.g. '/articles')
|
|
51
|
+
* @param {string} [options.searchParams=''] - Additional query parameters to preserve
|
|
52
|
+
* (e.g. 'sort=date&filter=active'). Appended before the cursor parameter.
|
|
53
|
+
* @param {string} [options.nextCursor] - Opaque cursor token for the next page
|
|
54
|
+
* @param {string} [options.prevCursor] - Opaque cursor token for the previous page
|
|
55
|
+
* @returns {Array<{href: string, rel: string}>} - Array of link objects
|
|
56
|
+
*/
|
|
57
|
+
export function cursorPaginationLinks({ baseUrl, searchParams, nextCursor, prevCursor }: {
|
|
58
|
+
baseUrl: string;
|
|
59
|
+
searchParams?: string | undefined;
|
|
60
|
+
nextCursor?: string | undefined;
|
|
61
|
+
prevCursor?: string | undefined;
|
|
62
|
+
}): Array<{
|
|
63
|
+
href: string;
|
|
64
|
+
rel: string;
|
|
65
|
+
}>;
|
package/types/lib/validate.d.ts
CHANGED
|
@@ -5,6 +5,11 @@
|
|
|
5
5
|
* @param {object} [options] - Validator options
|
|
6
6
|
* @param {boolean} [options.allErrors=true] - Report all errors instead of stopping at the first
|
|
7
7
|
* @param {boolean} [options.coerceTypes=false] - Coerce input values to match schema types
|
|
8
|
+
* @param {boolean|Array<string>|object} [options.formats] - Format keyword support via
|
|
9
|
+
* `ajv-formats`. `undefined` or `true` enables all standard formats; `false` disables (AJV
|
|
10
|
+
* strict mode rejects unknown formats); an array enables selective formats
|
|
11
|
+
* (e.g. `['email', 'uri']`); an object is passed as the full plugin config
|
|
12
|
+
* (e.g. `{mode: 'fast'}`)
|
|
8
13
|
* @param {object} [options.ajv] - Additional AJV constructor options
|
|
9
14
|
* @returns {function} - `validateData(data)` — returns `data` on success, throws 422 on failure
|
|
10
15
|
* @throws {Error} 422 with `details` array if schema validation fails
|
|
@@ -12,5 +17,6 @@
|
|
|
12
17
|
export default function createValidator(schema: object, options?: {
|
|
13
18
|
allErrors?: boolean | undefined;
|
|
14
19
|
coerceTypes?: boolean | undefined;
|
|
20
|
+
formats?: boolean | object | string[] | undefined;
|
|
15
21
|
ajv?: object | undefined;
|
|
16
22
|
}): Function;
|