@qelos/api-kit 3.11.11 → 4.0.0
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/dist/api-version.d.ts +37 -0
- package/dist/api-version.js +89 -0
- package/dist/api-version.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/src/api-version.test.ts +67 -0
- package/src/api-version.ts +124 -0
- package/src/index.ts +2 -1
- package/tsconfig.json +2 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
/** Latest API version when no `Accept-Version` header and no `/api/vN/` URL prefix. */
|
|
3
|
+
export declare const LATEST_API_VERSION = "v1";
|
|
4
|
+
export declare const SUPPORTED_API_VERSIONS: Set<string>;
|
|
5
|
+
/**
|
|
6
|
+
* Strips `/api/vN` from the URL path so existing `/api/...` mounts keep matching.
|
|
7
|
+
* Only applies to paths under `/api/` (not `/internal-api/`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function stripApiVersionFromUrl(fullUrl: string): {
|
|
10
|
+
rewritten: string;
|
|
11
|
+
pathVersion?: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function normalizeAcceptVersionHeader(raw: string | undefined): string | undefined;
|
|
14
|
+
export declare function resolveApiVersion(pathVersion: string | undefined, acceptHeaderRaw: string | undefined, latest: string, supported: Set<string>): {
|
|
15
|
+
version: string;
|
|
16
|
+
} | {
|
|
17
|
+
error: string;
|
|
18
|
+
status: number;
|
|
19
|
+
};
|
|
20
|
+
export type ApiVersionMiddlewareOptions = {
|
|
21
|
+
latest?: string;
|
|
22
|
+
supported?: Set<string>;
|
|
23
|
+
};
|
|
24
|
+
export declare function apiVersionMiddleware(options?: ApiVersionMiddlewareOptions): (req: Request, res: Response, next: NextFunction) => void;
|
|
25
|
+
export declare function setApiVersionResponseHeader(req: Request, res: Response): void;
|
|
26
|
+
/**
|
|
27
|
+
* Use with `http-proxy-middleware` `onProxyRes` so proxied API responses include `X-API-Version`.
|
|
28
|
+
*/
|
|
29
|
+
export declare function onProxyResSetApiVersion(_proxyRes: NodeJS.ReadableStream, req: Request, res: Response): void;
|
|
30
|
+
declare global {
|
|
31
|
+
namespace Express {
|
|
32
|
+
interface Request {
|
|
33
|
+
/** Resolved API version (e.g. `v1`) after gateway middleware runs. */
|
|
34
|
+
apiVersion?: string;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SUPPORTED_API_VERSIONS = exports.LATEST_API_VERSION = void 0;
|
|
4
|
+
exports.stripApiVersionFromUrl = stripApiVersionFromUrl;
|
|
5
|
+
exports.normalizeAcceptVersionHeader = normalizeAcceptVersionHeader;
|
|
6
|
+
exports.resolveApiVersion = resolveApiVersion;
|
|
7
|
+
exports.apiVersionMiddleware = apiVersionMiddleware;
|
|
8
|
+
exports.setApiVersionResponseHeader = setApiVersionResponseHeader;
|
|
9
|
+
exports.onProxyResSetApiVersion = onProxyResSetApiVersion;
|
|
10
|
+
/** Latest API version when no `Accept-Version` header and no `/api/vN/` URL prefix. */
|
|
11
|
+
exports.LATEST_API_VERSION = 'v1';
|
|
12
|
+
exports.SUPPORTED_API_VERSIONS = new Set([exports.LATEST_API_VERSION]);
|
|
13
|
+
/**
|
|
14
|
+
* Strips `/api/vN` from the URL path so existing `/api/...` mounts keep matching.
|
|
15
|
+
* Only applies to paths under `/api/` (not `/internal-api/`).
|
|
16
|
+
*/
|
|
17
|
+
function stripApiVersionFromUrl(fullUrl) {
|
|
18
|
+
const qIndex = fullUrl.indexOf('?');
|
|
19
|
+
const path = qIndex >= 0 ? fullUrl.slice(0, qIndex) : fullUrl;
|
|
20
|
+
const query = qIndex >= 0 ? fullUrl.slice(qIndex) : '';
|
|
21
|
+
const m = path.match(/^\/api\/(v\d+)(\/.*)?$/);
|
|
22
|
+
if (!m) {
|
|
23
|
+
return { rewritten: fullUrl };
|
|
24
|
+
}
|
|
25
|
+
const rest = m[2] ?? '/';
|
|
26
|
+
const rewrittenPath = '/api' + rest;
|
|
27
|
+
return { rewritten: rewrittenPath + query, pathVersion: m[1] };
|
|
28
|
+
}
|
|
29
|
+
function normalizeAcceptVersionHeader(raw) {
|
|
30
|
+
if (raw === undefined || raw === null) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const t = String(raw).trim().toLowerCase();
|
|
34
|
+
if (!t) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (!/^v\d+$/.test(t)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return t;
|
|
41
|
+
}
|
|
42
|
+
function resolveApiVersion(pathVersion, acceptHeaderRaw, latest, supported) {
|
|
43
|
+
const headerVersion = normalizeAcceptVersionHeader(acceptHeaderRaw);
|
|
44
|
+
const headerPresent = typeof acceptHeaderRaw === 'string' && acceptHeaderRaw.trim().length > 0;
|
|
45
|
+
if (headerPresent && !headerVersion) {
|
|
46
|
+
return { error: 'Invalid Accept-Version header', status: 400 };
|
|
47
|
+
}
|
|
48
|
+
if (pathVersion && headerVersion && pathVersion !== headerVersion) {
|
|
49
|
+
return {
|
|
50
|
+
error: 'Accept-Version header does not match URL version prefix',
|
|
51
|
+
status: 400,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const resolved = pathVersion ?? headerVersion ?? latest;
|
|
55
|
+
if (!supported.has(resolved)) {
|
|
56
|
+
return { error: `Unsupported API version: ${resolved}`, status: 400 };
|
|
57
|
+
}
|
|
58
|
+
return { version: resolved };
|
|
59
|
+
}
|
|
60
|
+
function apiVersionMiddleware(options = {}) {
|
|
61
|
+
const latest = options.latest ?? exports.LATEST_API_VERSION;
|
|
62
|
+
const supported = options.supported ?? exports.SUPPORTED_API_VERSIONS;
|
|
63
|
+
return function handleApiVersion(req, res, next) {
|
|
64
|
+
const { rewritten, pathVersion } = stripApiVersionFromUrl(req.url);
|
|
65
|
+
if (rewritten !== req.url) {
|
|
66
|
+
req.url = rewritten;
|
|
67
|
+
}
|
|
68
|
+
const rawHeader = req.headers['accept-version'];
|
|
69
|
+
const headerStr = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
70
|
+
const resolved = resolveApiVersion(pathVersion, headerStr, latest, supported);
|
|
71
|
+
if ('error' in resolved) {
|
|
72
|
+
res.status(resolved.status).json({ message: resolved.error }).end();
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
req.apiVersion = resolved.version;
|
|
76
|
+
next();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function setApiVersionResponseHeader(req, res) {
|
|
80
|
+
const version = req.apiVersion ?? exports.LATEST_API_VERSION;
|
|
81
|
+
res.setHeader('X-API-Version', version);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Use with `http-proxy-middleware` `onProxyRes` so proxied API responses include `X-API-Version`.
|
|
85
|
+
*/
|
|
86
|
+
function onProxyResSetApiVersion(_proxyRes, req, res) {
|
|
87
|
+
setApiVersionResponseHeader(req, res);
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=api-version.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api-version.js","sourceRoot":"","sources":["../src/api-version.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -22,4 +22,5 @@ __exportStar(require("./user-middlewares"), exports);
|
|
|
22
22
|
__exportStar(require("./emit-internal-event"), exports);
|
|
23
23
|
__exportStar(require("./response-error"), exports);
|
|
24
24
|
__exportStar(require("./request-utils"), exports);
|
|
25
|
+
__exportStar(require("./api-version"), exports);
|
|
25
26
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,6CAA0B;AAC1B,wCAAqB;AACrB,2CAAwB;AACxB,qDAAkC;AAClC,qDAAkC;AAClC,wDAAqC;AACrC,mDAAiC;AACjC,kDAAgC"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,6CAA0B;AAC1B,wCAAqB;AACrB,2CAAwB;AACxB,qDAAkC;AAClC,qDAAkC;AAClC,wDAAqC;AACrC,mDAAiC;AACjC,kDAAgC;AAChC,gDAA8B"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qelos/api-kit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "API-Kit package to help with qelos infrastructure and reuse capabilities across services",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
"gitHead": "13468bc51557291935b96b1aeaa837b8b52861e8",
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@types/node": "^22.5.4",
|
|
21
|
+
"tsx": "^4.20.4",
|
|
21
22
|
"typescript": "^5.4.5"
|
|
22
23
|
},
|
|
23
24
|
"scripts": {
|
|
24
|
-
"build": "tsc"
|
|
25
|
+
"build": "tsc",
|
|
26
|
+
"test": "node --import tsx --test \"src/**/*.test.ts\""
|
|
25
27
|
}
|
|
26
28
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
LATEST_API_VERSION,
|
|
6
|
+
normalizeAcceptVersionHeader,
|
|
7
|
+
resolveApiVersion,
|
|
8
|
+
SUPPORTED_API_VERSIONS,
|
|
9
|
+
stripApiVersionFromUrl,
|
|
10
|
+
} from './api-version';
|
|
11
|
+
|
|
12
|
+
test('stripApiVersionFromUrl leaves non-versioned URLs unchanged', () => {
|
|
13
|
+
assert.equal(stripApiVersionFromUrl('/api/foo').rewritten, '/api/foo');
|
|
14
|
+
assert.equal(stripApiVersionFromUrl('/internal-api/health').rewritten, '/internal-api/health');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test('stripApiVersionFromUrl rewrites /api/v1 prefix and preserves query', () => {
|
|
18
|
+
const r = stripApiVersionFromUrl('/api/v1/blueprints/x?a=1');
|
|
19
|
+
assert.equal(r.rewritten, '/api/blueprints/x?a=1');
|
|
20
|
+
assert.equal(r.pathVersion, 'v1');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('stripApiVersionFromUrl maps bare /api/v1 to /api/', () => {
|
|
24
|
+
const r = stripApiVersionFromUrl('/api/v1');
|
|
25
|
+
assert.equal(r.rewritten, '/api/');
|
|
26
|
+
assert.equal(r.pathVersion, 'v1');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('normalizeAcceptVersionHeader', () => {
|
|
30
|
+
assert.equal(normalizeAcceptVersionHeader('v1'), 'v1');
|
|
31
|
+
assert.equal(normalizeAcceptVersionHeader('V1'), 'v1');
|
|
32
|
+
assert.equal(normalizeAcceptVersionHeader(' v2 '), 'v2');
|
|
33
|
+
assert.equal(normalizeAcceptVersionHeader('bad'), undefined);
|
|
34
|
+
assert.equal(normalizeAcceptVersionHeader(''), undefined);
|
|
35
|
+
assert.equal(normalizeAcceptVersionHeader(undefined), undefined);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('resolveApiVersion defaults to latest', () => {
|
|
39
|
+
const r = resolveApiVersion(undefined, undefined, LATEST_API_VERSION, SUPPORTED_API_VERSIONS);
|
|
40
|
+
assert.ok(!('error' in r));
|
|
41
|
+
if ('version' in r) {
|
|
42
|
+
assert.equal(r.version, LATEST_API_VERSION);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('resolveApiVersion uses path over header when equal', () => {
|
|
47
|
+
const r = resolveApiVersion('v1', 'v1', LATEST_API_VERSION, SUPPORTED_API_VERSIONS);
|
|
48
|
+
assert.ok(!('error' in r));
|
|
49
|
+
if ('version' in r) {
|
|
50
|
+
assert.equal(r.version, 'v1');
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('resolveApiVersion rejects path/header mismatch', () => {
|
|
55
|
+
const r = resolveApiVersion('v1', 'v2', LATEST_API_VERSION, SUPPORTED_API_VERSIONS);
|
|
56
|
+
assert.ok('error' in r);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('resolveApiVersion rejects unsupported version', () => {
|
|
60
|
+
const r = resolveApiVersion(undefined, 'v99', LATEST_API_VERSION, SUPPORTED_API_VERSIONS);
|
|
61
|
+
assert.ok('error' in r);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('resolveApiVersion rejects invalid header when header present', () => {
|
|
65
|
+
const r = resolveApiVersion(undefined, 'nope', LATEST_API_VERSION, SUPPORTED_API_VERSIONS);
|
|
66
|
+
assert.ok('error' in r);
|
|
67
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
+
|
|
3
|
+
/** Latest API version when no `Accept-Version` header and no `/api/vN/` URL prefix. */
|
|
4
|
+
export const LATEST_API_VERSION = 'v1';
|
|
5
|
+
|
|
6
|
+
export const SUPPORTED_API_VERSIONS = new Set<string>([LATEST_API_VERSION]);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Strips `/api/vN` from the URL path so existing `/api/...` mounts keep matching.
|
|
10
|
+
* Only applies to paths under `/api/` (not `/internal-api/`).
|
|
11
|
+
*/
|
|
12
|
+
export function stripApiVersionFromUrl(fullUrl: string): {
|
|
13
|
+
rewritten: string;
|
|
14
|
+
pathVersion?: string;
|
|
15
|
+
} {
|
|
16
|
+
const qIndex = fullUrl.indexOf('?');
|
|
17
|
+
const path = qIndex >= 0 ? fullUrl.slice(0, qIndex) : fullUrl;
|
|
18
|
+
const query = qIndex >= 0 ? fullUrl.slice(qIndex) : '';
|
|
19
|
+
|
|
20
|
+
const m = path.match(/^\/api\/(v\d+)(\/.*)?$/);
|
|
21
|
+
if (!m) {
|
|
22
|
+
return { rewritten: fullUrl };
|
|
23
|
+
}
|
|
24
|
+
const rest = m[2] ?? '/';
|
|
25
|
+
const rewrittenPath = '/api' + rest;
|
|
26
|
+
return { rewritten: rewrittenPath + query, pathVersion: m[1] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeAcceptVersionHeader(raw: string | undefined): string | undefined {
|
|
30
|
+
if (raw === undefined || raw === null) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const t = String(raw).trim().toLowerCase();
|
|
34
|
+
if (!t) {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
if (!/^v\d+$/.test(t)) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return t;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolveApiVersion(
|
|
44
|
+
pathVersion: string | undefined,
|
|
45
|
+
acceptHeaderRaw: string | undefined,
|
|
46
|
+
latest: string,
|
|
47
|
+
supported: Set<string>
|
|
48
|
+
): { version: string } | { error: string; status: number } {
|
|
49
|
+
const headerVersion = normalizeAcceptVersionHeader(acceptHeaderRaw);
|
|
50
|
+
const headerPresent = typeof acceptHeaderRaw === 'string' && acceptHeaderRaw.trim().length > 0;
|
|
51
|
+
|
|
52
|
+
if (headerPresent && !headerVersion) {
|
|
53
|
+
return { error: 'Invalid Accept-Version header', status: 400 };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (pathVersion && headerVersion && pathVersion !== headerVersion) {
|
|
57
|
+
return {
|
|
58
|
+
error: 'Accept-Version header does not match URL version prefix',
|
|
59
|
+
status: 400,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const resolved = pathVersion ?? headerVersion ?? latest;
|
|
64
|
+
|
|
65
|
+
if (!supported.has(resolved)) {
|
|
66
|
+
return { error: `Unsupported API version: ${resolved}`, status: 400 };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { version: resolved };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type ApiVersionMiddlewareOptions = {
|
|
73
|
+
latest?: string;
|
|
74
|
+
supported?: Set<string>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export function apiVersionMiddleware(options: ApiVersionMiddlewareOptions = {}) {
|
|
78
|
+
const latest = options.latest ?? LATEST_API_VERSION;
|
|
79
|
+
const supported = options.supported ?? SUPPORTED_API_VERSIONS;
|
|
80
|
+
|
|
81
|
+
return function handleApiVersion(req: Request, res: Response, next: NextFunction) {
|
|
82
|
+
const { rewritten, pathVersion } = stripApiVersionFromUrl(req.url);
|
|
83
|
+
if (rewritten !== req.url) {
|
|
84
|
+
req.url = rewritten;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const rawHeader = req.headers['accept-version'];
|
|
88
|
+
const headerStr = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
|
|
89
|
+
|
|
90
|
+
const resolved = resolveApiVersion(pathVersion, headerStr, latest, supported);
|
|
91
|
+
if ('error' in resolved) {
|
|
92
|
+
res.status(resolved.status).json({ message: resolved.error }).end();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
req.apiVersion = resolved.version;
|
|
97
|
+
next();
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function setApiVersionResponseHeader(req: Request, res: Response) {
|
|
102
|
+
const version = req.apiVersion ?? LATEST_API_VERSION;
|
|
103
|
+
res.setHeader('X-API-Version', version);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Use with `http-proxy-middleware` `onProxyRes` so proxied API responses include `X-API-Version`.
|
|
108
|
+
*/
|
|
109
|
+
export function onProxyResSetApiVersion(
|
|
110
|
+
_proxyRes: NodeJS.ReadableStream,
|
|
111
|
+
req: Request,
|
|
112
|
+
res: Response
|
|
113
|
+
) {
|
|
114
|
+
setApiVersionResponseHeader(req, res);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
declare global {
|
|
118
|
+
namespace Express {
|
|
119
|
+
interface Request {
|
|
120
|
+
/** Resolved API version (e.g. `v1`) after gateway middleware runs. */
|
|
121
|
+
apiVersion?: string;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/index.ts
CHANGED