@furystack/rest-service 13.0.0 → 14.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/CHANGELOG.md +17 -0
- package/README.md +47 -28
- package/esm/actions/get-current-user.spec.js +10 -6
- package/esm/actions/get-current-user.spec.js.map +1 -1
- package/esm/actions/is-authenticated.spec.js +10 -6
- package/esm/actions/is-authenticated.spec.js.map +1 -1
- package/esm/actions/logout-action.spec.js +11 -9
- package/esm/actions/logout-action.spec.js.map +1 -1
- package/esm/actions/logout.js +1 -1
- package/esm/actions/logout.js.map +1 -1
- package/esm/actions/password-login-action.js +1 -1
- package/esm/actions/password-login-action.js.map +1 -1
- package/esm/actions/password-login-action.spec.js +23 -28
- package/esm/actions/password-login-action.spec.js.map +1 -1
- package/esm/authenticate.js +1 -1
- package/esm/authenticate.js.map +1 -1
- package/esm/authenticate.spec.js +41 -33
- package/esm/authenticate.spec.js.map +1 -1
- package/esm/authorize.spec.js +34 -29
- package/esm/authorize.spec.js.map +1 -1
- package/esm/endpoint-generators/create-delete-endpoint.d.ts +6 -11
- package/esm/endpoint-generators/create-delete-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-delete-endpoint.js +7 -11
- package/esm/endpoint-generators/create-delete-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-delete-endpoint.spec.js +14 -21
- package/esm/endpoint-generators/create-delete-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-get-collection-endpoint.d.ts +11 -11
- package/esm/endpoint-generators/create-get-collection-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-collection-endpoint.js +13 -12
- package/esm/endpoint-generators/create-get-collection-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-get-collection-endpoint.spec.js +43 -70
- package/esm/endpoint-generators/create-get-collection-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-get-entity-endpoint.d.ts +6 -11
- package/esm/endpoint-generators/create-get-entity-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-entity-endpoint.js +7 -11
- package/esm/endpoint-generators/create-get-entity-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-get-entity-endpoint.spec.js +16 -33
- package/esm/endpoint-generators/create-get-entity-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.js +1 -1
- package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -1
- package/esm/endpoint-generators/create-get-schema-action.d.ts +1 -1
- package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -1
- package/esm/endpoint-generators/create-get-schema-action.js.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.d.ts +7 -13
- package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.js +8 -13
- package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-patch-endpoint.spec.js +10 -17
- package/esm/endpoint-generators/create-patch-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.d.ts +7 -13
- package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.js +8 -13
- package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
- package/esm/endpoint-generators/create-post-endpoint.spec.js +41 -14
- package/esm/endpoint-generators/create-post-endpoint.spec.js.map +1 -1
- package/esm/endpoint-generators/utils.d.ts +17 -2
- package/esm/endpoint-generators/utils.d.ts.map +1 -1
- package/esm/endpoint-generators/utils.js +33 -20
- package/esm/endpoint-generators/utils.js.map +1 -1
- package/esm/get-schema-from-api.d.ts +1 -1
- package/esm/get-schema-from-api.d.ts.map +1 -1
- package/esm/get-schema-from-api.spec.js.map +1 -1
- package/esm/helpers.d.ts +53 -87
- package/esm/helpers.d.ts.map +1 -1
- package/esm/helpers.js +91 -92
- package/esm/helpers.js.map +1 -1
- package/esm/helpers.spec.d.ts +2 -1
- package/esm/helpers.spec.d.ts.map +1 -1
- package/esm/helpers.spec.js +41 -38
- package/esm/helpers.spec.js.map +1 -1
- package/esm/http-authentication-settings.d.ts +35 -12
- package/esm/http-authentication-settings.d.ts.map +1 -1
- package/esm/http-authentication-settings.js +21 -29
- package/esm/http-authentication-settings.js.map +1 -1
- package/esm/http-server-pool.d.ts +64 -0
- package/esm/http-server-pool.d.ts.map +1 -0
- package/esm/http-server-pool.js +95 -0
- package/esm/http-server-pool.js.map +1 -0
- package/esm/http-server-pool.spec.d.ts +2 -0
- package/esm/http-server-pool.spec.d.ts.map +1 -0
- package/esm/http-server-pool.spec.js +193 -0
- package/esm/http-server-pool.spec.js.map +1 -0
- package/esm/http-user-context.d.ts +31 -44
- package/esm/http-user-context.d.ts.map +1 -1
- package/esm/http-user-context.js +76 -95
- package/esm/http-user-context.js.map +1 -1
- package/esm/http-user-context.spec.d.ts +8 -2
- package/esm/http-user-context.spec.d.ts.map +1 -1
- package/esm/http-user-context.spec.js +189 -337
- package/esm/http-user-context.spec.js.map +1 -1
- package/esm/index.d.ts +6 -4
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +6 -4
- package/esm/index.js.map +1 -1
- package/esm/login-response-strategy.d.ts +11 -10
- package/esm/login-response-strategy.d.ts.map +1 -1
- package/esm/login-response-strategy.js +7 -6
- package/esm/login-response-strategy.js.map +1 -1
- package/esm/login-response-strategy.spec.js +25 -29
- package/esm/login-response-strategy.spec.js.map +1 -1
- package/esm/proxy-manager.spec.js +152 -176
- package/esm/proxy-manager.spec.js.map +1 -1
- package/esm/proxy-runtime.d.ts +27 -0
- package/esm/proxy-runtime.d.ts.map +1 -0
- package/esm/proxy-runtime.js +45 -0
- package/esm/proxy-runtime.js.map +1 -0
- package/esm/proxy-runtime.spec.d.ts +2 -0
- package/esm/proxy-runtime.spec.d.ts.map +1 -0
- package/esm/proxy-runtime.spec.js +39 -0
- package/esm/proxy-runtime.spec.js.map +1 -0
- package/esm/rest-api-runtime.d.ts +76 -0
- package/esm/rest-api-runtime.d.ts.map +1 -0
- package/esm/rest-api-runtime.js +131 -0
- package/esm/rest-api-runtime.js.map +1 -0
- package/esm/rest-api-runtime.spec.d.ts +2 -0
- package/esm/rest-api-runtime.spec.d.ts.map +1 -0
- package/esm/rest-api-runtime.spec.js +58 -0
- package/esm/rest-api-runtime.spec.js.map +1 -0
- package/esm/rest-service.integration.spec.d.ts +1 -1
- package/esm/rest-service.integration.spec.d.ts.map +1 -1
- package/esm/rest-service.integration.spec.js +57 -80
- package/esm/rest-service.integration.spec.js.map +1 -1
- package/esm/rest.integration.test.js +26 -84
- package/esm/rest.integration.test.js.map +1 -1
- package/esm/server-telemetry.d.ts +57 -0
- package/esm/server-telemetry.d.ts.map +1 -0
- package/esm/server-telemetry.js +26 -0
- package/esm/server-telemetry.js.map +1 -0
- package/esm/server-telemetry.spec.d.ts +2 -0
- package/esm/server-telemetry.spec.d.ts.map +1 -0
- package/esm/server-telemetry.spec.js +52 -0
- package/esm/server-telemetry.spec.js.map +1 -0
- package/esm/static-server-manager.spec.js +60 -125
- package/esm/static-server-manager.spec.js.map +1 -1
- package/esm/static-site-runtime.d.ts +21 -0
- package/esm/static-site-runtime.d.ts.map +1 -0
- package/esm/static-site-runtime.js +48 -0
- package/esm/static-site-runtime.js.map +1 -0
- package/esm/static-site-runtime.spec.d.ts +2 -0
- package/esm/static-site-runtime.spec.d.ts.map +1 -0
- package/esm/static-site-runtime.spec.js +30 -0
- package/esm/static-site-runtime.spec.js.map +1 -0
- package/esm/user-store.d.ts +39 -0
- package/esm/user-store.d.ts.map +1 -0
- package/esm/user-store.js +62 -0
- package/esm/user-store.js.map +1 -0
- package/esm/user-store.spec.d.ts +2 -0
- package/esm/user-store.spec.d.ts.map +1 -0
- package/esm/user-store.spec.js +72 -0
- package/esm/user-store.spec.js.map +1 -0
- package/esm/validate.integration.spec.js +26 -28
- package/esm/validate.integration.spec.js.map +1 -1
- package/package.json +8 -8
- package/src/actions/get-current-user.spec.ts +11 -10
- package/src/actions/is-authenticated.spec.ts +12 -7
- package/src/actions/logout-action.spec.ts +12 -12
- package/src/actions/logout.ts +1 -1
- package/src/actions/password-login-action.spec.ts +30 -30
- package/src/actions/password-login-action.ts +1 -1
- package/src/authenticate.spec.ts +45 -51
- package/src/authenticate.ts +1 -1
- package/src/authorize.spec.ts +37 -39
- package/src/endpoint-generators/create-delete-endpoint.spec.ts +16 -24
- package/src/endpoint-generators/create-delete-endpoint.ts +11 -17
- package/src/endpoint-generators/create-get-collection-endpoint.spec.ts +45 -78
- package/src/endpoint-generators/create-get-collection-endpoint.ts +17 -19
- package/src/endpoint-generators/create-get-entity-endpoint.spec.ts +17 -33
- package/src/endpoint-generators/create-get-entity-endpoint.ts +11 -17
- package/src/endpoint-generators/create-get-openapi-document-action.ts +2 -2
- package/src/endpoint-generators/create-get-schema-action.ts +1 -1
- package/src/endpoint-generators/create-patch-endpoint.spec.ts +11 -18
- package/src/endpoint-generators/create-patch-endpoint.ts +12 -23
- package/src/endpoint-generators/create-post-endpoint.spec.ts +45 -14
- package/src/endpoint-generators/create-post-endpoint.ts +13 -23
- package/src/endpoint-generators/utils.ts +36 -28
- package/src/get-schema-from-api.spec.ts +1 -1
- package/src/get-schema-from-api.ts +1 -1
- package/src/helpers.spec.ts +51 -41
- package/src/helpers.ts +140 -109
- package/src/http-authentication-settings.ts +51 -25
- package/src/http-server-pool.spec.ts +220 -0
- package/src/http-server-pool.ts +169 -0
- package/src/http-user-context.spec.ts +203 -383
- package/src/http-user-context.ts +114 -91
- package/src/index.ts +6 -4
- package/src/login-response-strategy.spec.ts +32 -31
- package/src/login-response-strategy.ts +13 -12
- package/src/proxy-manager.spec.ts +152 -182
- package/src/proxy-runtime.spec.ts +50 -0
- package/src/proxy-runtime.ts +75 -0
- package/src/rest-api-runtime.spec.ts +77 -0
- package/src/rest-api-runtime.ts +204 -0
- package/src/rest-service.integration.spec.ts +65 -85
- package/src/rest.integration.test.ts +27 -87
- package/src/server-telemetry.spec.ts +56 -0
- package/src/server-telemetry.ts +51 -0
- package/src/static-server-manager.spec.ts +63 -151
- package/src/static-site-runtime.spec.ts +39 -0
- package/src/static-site-runtime.ts +75 -0
- package/src/user-store.spec.ts +86 -0
- package/src/user-store.ts +68 -0
- package/src/validate.integration.spec.ts +39 -35
- package/esm/api-manager.d.ts +0 -104
- package/esm/api-manager.d.ts.map +0 -1
- package/esm/api-manager.js +0 -179
- package/esm/api-manager.js.map +0 -1
- package/esm/proxy-manager.d.ts +0 -53
- package/esm/proxy-manager.d.ts.map +0 -1
- package/esm/proxy-manager.js +0 -84
- package/esm/proxy-manager.js.map +0 -1
- package/esm/server-manager.d.ts +0 -48
- package/esm/server-manager.d.ts.map +0 -1
- package/esm/server-manager.js +0 -100
- package/esm/server-manager.js.map +0 -1
- package/esm/static-server-manager.d.ts +0 -19
- package/esm/static-server-manager.d.ts.map +0 -1
- package/esm/static-server-manager.js +0 -65
- package/esm/static-server-manager.js.map +0 -1
- package/src/api-manager.ts +0 -304
- package/src/proxy-manager.ts +0 -102
- package/src/server-manager.ts +0 -135
- package/src/static-server-manager.ts +0 -90
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildProxyServerApi, type ProxyOptions } from './proxy-runtime.js'
|
|
3
|
+
import { ServerTelemetry } from './server-telemetry.js'
|
|
4
|
+
|
|
5
|
+
const baseOptions: ProxyOptions = {
|
|
6
|
+
sourceBaseUrl: '/api',
|
|
7
|
+
sourcePort: 0,
|
|
8
|
+
targetBaseUrl: 'http://upstream.invalid:8080',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('buildProxyServerApi', () => {
|
|
12
|
+
let telemetry: ServerTelemetry
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
telemetry = new ServerTelemetry()
|
|
16
|
+
})
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
telemetry[Symbol.dispose]()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns a ServerApi whose shouldExec matches URLs under the sourceBaseUrl', () => {
|
|
22
|
+
const api = buildProxyServerApi(baseOptions, telemetry)
|
|
23
|
+
expect(api.shouldExec({ req: { url: '/api/items', method: 'GET' } as never, res: {} as never })).toBe(true)
|
|
24
|
+
expect(api.shouldExec({ req: { url: '/other', method: 'GET' } as never, res: {} as never })).toBe(false)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('shouldExec rejects requests missing a URL', () => {
|
|
28
|
+
const api = buildProxyServerApi(baseOptions, telemetry)
|
|
29
|
+
expect(api.shouldExec({ req: { method: 'GET' } as never, res: {} as never })).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('does not attach onUpgrade unless websockets are explicitly enabled', () => {
|
|
33
|
+
const apiWithoutWs = buildProxyServerApi(baseOptions, telemetry)
|
|
34
|
+
const apiWithWs = buildProxyServerApi({ ...baseOptions, enableWebsockets: true }, telemetry)
|
|
35
|
+
expect(apiWithoutWs.onUpgrade).toBeUndefined()
|
|
36
|
+
expect(typeof apiWithWs.onUpgrade).toBe('function')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('throws on an invalid targetBaseUrl', () => {
|
|
40
|
+
expect(() => buildProxyServerApi({ ...baseOptions, targetBaseUrl: 'not-a-url' }, telemetry)).toThrow(
|
|
41
|
+
/Invalid targetBaseUrl/,
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('throws on non-HTTP(S) targets', () => {
|
|
46
|
+
expect(() =>
|
|
47
|
+
buildProxyServerApi({ ...baseOptions, targetBaseUrl: 'ftp://upstream.invalid:21' }, telemetry),
|
|
48
|
+
).toThrow(/protocol/i)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { PathHelper } from '@furystack/utils'
|
|
2
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'http'
|
|
3
|
+
import type { Duplex } from 'stream'
|
|
4
|
+
import { HttpProxyHandler } from './http-proxy-handler.js'
|
|
5
|
+
import type { ServerApi } from './http-server-pool.js'
|
|
6
|
+
import { PathProcessor } from './path-processor.js'
|
|
7
|
+
import type { ServerTelemetry } from './server-telemetry.js'
|
|
8
|
+
import { WebSocketProxyHandler } from './websocket-proxy-handler.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Options accepted by {@link buildProxyServerApi} and {@link useProxy}.
|
|
12
|
+
* Mirrors the original `ProxyOptions` shape from the deleted
|
|
13
|
+
* `ProxyManager`; see that history for field-by-field documentation.
|
|
14
|
+
*/
|
|
15
|
+
export interface ProxyOptions {
|
|
16
|
+
sourceBaseUrl: string
|
|
17
|
+
targetBaseUrl: string
|
|
18
|
+
pathRewrite?: (sourcePath: string) => string
|
|
19
|
+
sourceHostName?: string
|
|
20
|
+
sourcePort: number
|
|
21
|
+
headers?: (originalHeaders: OutgoingHttpHeaders) => OutgoingHttpHeaders
|
|
22
|
+
cookies?: (originalCookies: string[]) => string[]
|
|
23
|
+
responseCookies?: (responseCookies: string[]) => string[]
|
|
24
|
+
timeout?: number
|
|
25
|
+
enableWebsockets?: boolean
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const createRequestHandler = (options: ProxyOptions, telemetry: ServerTelemetry) => {
|
|
29
|
+
const handler = new HttpProxyHandler({
|
|
30
|
+
sourceBaseUrl: options.sourceBaseUrl,
|
|
31
|
+
targetBaseUrl: options.targetBaseUrl,
|
|
32
|
+
pathRewrite: options.pathRewrite,
|
|
33
|
+
headers: options.headers,
|
|
34
|
+
cookies: options.cookies,
|
|
35
|
+
responseCookies: options.responseCookies,
|
|
36
|
+
timeout: options.timeout,
|
|
37
|
+
onError: (error) => telemetry.emit('onProxyFailed', error),
|
|
38
|
+
})
|
|
39
|
+
return async ({ req, res }: { req: IncomingMessage; res: ServerResponse }) => handler.handle(req, res)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const createUpgradeHandler = (options: ProxyOptions, telemetry: ServerTelemetry) => {
|
|
43
|
+
const handler = new WebSocketProxyHandler({
|
|
44
|
+
sourceBaseUrl: options.sourceBaseUrl,
|
|
45
|
+
targetBaseUrl: options.targetBaseUrl,
|
|
46
|
+
pathRewrite: options.pathRewrite,
|
|
47
|
+
headers: options.headers,
|
|
48
|
+
timeout: options.timeout,
|
|
49
|
+
onError: (error) => telemetry.emit('onWebSocketProxyFailed', error),
|
|
50
|
+
})
|
|
51
|
+
return async ({ req, socket, head }: { req: IncomingMessage; socket: Duplex; head: Buffer }) =>
|
|
52
|
+
handler.handle(req, socket, head)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const shouldExecProxy =
|
|
56
|
+
(sourceBaseUrl: string) =>
|
|
57
|
+
({ req }: { req: Pick<IncomingMessage, 'url' | 'method'> }): boolean =>
|
|
58
|
+
req.url ? PathHelper.matchesBaseUrl(req.url, sourceBaseUrl) : false
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Validates the proxy configuration and returns a {@link ServerApi} that
|
|
62
|
+
* forwards HTTP (and optionally WebSocket) traffic from
|
|
63
|
+
* `options.sourceBaseUrl` to `options.targetBaseUrl`.
|
|
64
|
+
*/
|
|
65
|
+
export const buildProxyServerApi = (options: ProxyOptions, telemetry: ServerTelemetry): ServerApi => {
|
|
66
|
+
const pathProcessor = new PathProcessor()
|
|
67
|
+
const targetUrl = pathProcessor.validateUrl(options.targetBaseUrl, 'targetBaseUrl')
|
|
68
|
+
pathProcessor.validateHttpProtocol(targetUrl)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
shouldExec: shouldExecProxy(options.sourceBaseUrl),
|
|
72
|
+
onRequest: createRequestHandler(options, telemetry),
|
|
73
|
+
...(options.enableWebsockets ? { onUpgrade: createUpgradeHandler(options, telemetry) } : {}),
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { JsonResult } from './request-action-implementation.js'
|
|
3
|
+
import { compileApi, shouldExecRequest, type RestApiImplementation } from './rest-api-runtime.js'
|
|
4
|
+
|
|
5
|
+
type SampleApi = {
|
|
6
|
+
GET: {
|
|
7
|
+
'/items': { result: { items: number[] } }
|
|
8
|
+
'/items/:id': { url: { id: string }; result: { id: string } }
|
|
9
|
+
}
|
|
10
|
+
POST: {
|
|
11
|
+
'/items': { body: { name: string }; result: { name: string } }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const buildImplementation = (): RestApiImplementation<SampleApi> => ({
|
|
16
|
+
GET: {
|
|
17
|
+
'/items': async () => JsonResult({ items: [1, 2, 3] }),
|
|
18
|
+
'/items/:id': async ({ getUrlParams }) => JsonResult({ id: (getUrlParams() as { id: string }).id }),
|
|
19
|
+
},
|
|
20
|
+
POST: {
|
|
21
|
+
'/items': async ({ getBody }) => JsonResult(await getBody()),
|
|
22
|
+
},
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('rest-api-runtime', () => {
|
|
26
|
+
describe('compileApi', () => {
|
|
27
|
+
it('normalises paths, prefixes the root, and preserves method→route ordering', () => {
|
|
28
|
+
const compiled = compileApi<SampleApi>(buildImplementation(), '/api')
|
|
29
|
+
expect(Object.keys(compiled)).toEqual(['GET', 'POST'])
|
|
30
|
+
const getRoutes = compiled.GET!
|
|
31
|
+
expect(getRoutes).toHaveLength(2)
|
|
32
|
+
expect(getRoutes[0].method).toBe('GET')
|
|
33
|
+
expect(getRoutes[0].fullPath).toBe('/api/items')
|
|
34
|
+
expect(getRoutes[1].fullPath).toBe('/api/items/:id')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('produces matchers that extract url params', () => {
|
|
38
|
+
const compiled = compileApi<SampleApi>(buildImplementation(), '/api')
|
|
39
|
+
const itemById = compiled.GET!.find((r) => r.fullPath === '/api/items/:id')!
|
|
40
|
+
const result = itemById.matcher('/api/items/42')
|
|
41
|
+
expect(result && result.params).toEqual({ id: '42' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('returns false from matchers when the path does not match', () => {
|
|
45
|
+
const compiled = compileApi<SampleApi>(buildImplementation(), '/api')
|
|
46
|
+
const itemByIdMatcher = compiled.GET!.find((r) => r.fullPath === '/api/items/:id')!.matcher
|
|
47
|
+
expect(itemByIdMatcher('/api/items')).toBe(false)
|
|
48
|
+
expect(itemByIdMatcher('/other')).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('shouldExecRequest', () => {
|
|
53
|
+
const supportedMethods = ['GET', 'POST'] as string[]
|
|
54
|
+
const rootApiPath = '/api'
|
|
55
|
+
|
|
56
|
+
it('accepts supported methods whose URL starts with the root api path', () => {
|
|
57
|
+
expect(shouldExecRequest({ method: 'GET', url: '/api/items', rootApiPath, supportedMethods })).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('accepts OPTIONS (preflight) even when it is not explicitly in supportedMethods', () => {
|
|
61
|
+
expect(shouldExecRequest({ method: 'OPTIONS', url: '/api/items', rootApiPath, supportedMethods })).toBe(true)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('rejects unsupported methods', () => {
|
|
65
|
+
expect(shouldExecRequest({ method: 'DELETE', url: '/api/items', rootApiPath, supportedMethods })).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('rejects URLs that do not match the root api path', () => {
|
|
69
|
+
expect(shouldExecRequest({ method: 'GET', url: '/other/items', rootApiPath, supportedMethods })).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('rejects requests missing method or url', () => {
|
|
73
|
+
expect(shouldExecRequest({ method: undefined, url: '/api/items', rootApiPath, supportedMethods })).toBe(false)
|
|
74
|
+
expect(shouldExecRequest({ method: 'GET', url: undefined, rootApiPath, supportedMethods })).toBe(false)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { IdentityContext, type User } from '@furystack/core'
|
|
2
|
+
import type { Injector } from '@furystack/inject'
|
|
3
|
+
import type { Method, RestApi } from '@furystack/rest'
|
|
4
|
+
import { RequestError, deserializeQueryString } from '@furystack/rest'
|
|
5
|
+
import { PathHelper, usingAsync } from '@furystack/utils'
|
|
6
|
+
import type { MatchFunction } from 'path-to-regexp'
|
|
7
|
+
import { match } from 'path-to-regexp'
|
|
8
|
+
import { ErrorAction } from './actions/error-action.js'
|
|
9
|
+
import { NotFoundAction } from './actions/not-found-action.js'
|
|
10
|
+
import { addCorsHeaders } from './add-cors-header.js'
|
|
11
|
+
import { HttpUserContext } from './http-user-context.js'
|
|
12
|
+
import type { CorsOptions } from './models/cors-options.js'
|
|
13
|
+
import { readPostBody } from './read-post-body.js'
|
|
14
|
+
import type { RequestAction } from './request-action-implementation.js'
|
|
15
|
+
import './server-response-extensions.js'
|
|
16
|
+
import type { OnRequest } from './http-server-pool.js'
|
|
17
|
+
import { DEFAULT_HOST } from './http-server-pool.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Concrete runtime representation of an API implementation — the object the
|
|
21
|
+
* application supplies to `useRestService` gets wrapped in this structure at
|
|
22
|
+
* registration time. Each method maps to an ordered array of compiled routes.
|
|
23
|
+
*/
|
|
24
|
+
export type RestApiImplementation<T extends RestApi> = {
|
|
25
|
+
[TMethod in keyof T]: {
|
|
26
|
+
[TUrl in keyof T[TMethod]]: T[TMethod][TUrl] extends { result: unknown } ? RequestAction<T[TMethod][TUrl]> : never
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Compiled entry representing a single route: the HTTP method, the fully
|
|
32
|
+
* qualified path, a `path-to-regexp` matcher and the user-supplied
|
|
33
|
+
* action.
|
|
34
|
+
*/
|
|
35
|
+
export type CompiledApiEntry = {
|
|
36
|
+
method: Method
|
|
37
|
+
fullPath: string
|
|
38
|
+
matcher: MatchFunction<Partial<Record<string, string | string[]>>>
|
|
39
|
+
action: RequestAction<any>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Grouping of {@link CompiledApiEntry}s keyed by HTTP method. Built once at
|
|
44
|
+
* setup time and reused for every incoming request.
|
|
45
|
+
*/
|
|
46
|
+
export type CompiledApi = {
|
|
47
|
+
[K in Method]?: CompiledApiEntry[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Options accepted by {@link onRestApiMessage}. */
|
|
51
|
+
export type OnRestApiMessageOptions = OnRequest & {
|
|
52
|
+
compiledApi: CompiledApi
|
|
53
|
+
hostName?: string
|
|
54
|
+
port: number
|
|
55
|
+
rootApiPath: string
|
|
56
|
+
injector: Injector
|
|
57
|
+
cors?: CorsOptions
|
|
58
|
+
supportedMethods: string[]
|
|
59
|
+
deserializeQueryParams?: (param: string) => Record<string, unknown>
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Compiles a user-supplied REST API definition into the runtime shape used by
|
|
64
|
+
* {@link onRestApiMessage}. Each method's route map is transformed into an
|
|
65
|
+
* array of `{method, fullPath, matcher, action}` entries. The `root` prefix
|
|
66
|
+
* is prepended to every path.
|
|
67
|
+
*/
|
|
68
|
+
export const compileApi = <T extends RestApi>(api: RestApiImplementation<T>, root: string): CompiledApi => {
|
|
69
|
+
const supportedMethods = Object.keys(api) as Method[]
|
|
70
|
+
const compiled: CompiledApi = {}
|
|
71
|
+
supportedMethods.forEach((method) => {
|
|
72
|
+
compiled[method] = Object.entries(api[method as keyof typeof api]).map(([path, action]) => {
|
|
73
|
+
const fullPath = `/${PathHelper.normalize(PathHelper.joinPaths(root, path))}`
|
|
74
|
+
const matcher = match(fullPath, { decode: decodeURIComponent })
|
|
75
|
+
return { method, fullPath, matcher, action: action as RequestAction<any> }
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
return compiled
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Quick eligibility check executed by the server pool before invoking a
|
|
83
|
+
* {@link ServerApi.onRequest}. Avoids the cost of URL parsing and CORS header
|
|
84
|
+
* handling for requests this API clearly does not own.
|
|
85
|
+
*/
|
|
86
|
+
export const shouldExecRequest = (options: {
|
|
87
|
+
method?: Method
|
|
88
|
+
url?: string
|
|
89
|
+
rootApiPath: string
|
|
90
|
+
supportedMethods: string[]
|
|
91
|
+
}): boolean => {
|
|
92
|
+
if (!options.method || !options.url) {
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
if (!options.supportedMethods.includes(options.method) && options.method !== 'OPTIONS') {
|
|
96
|
+
return false
|
|
97
|
+
}
|
|
98
|
+
return PathHelper.matchesBaseUrl(options.url, options.rootApiPath)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const getActionFromEndpoint = (compiledApi: CompiledApi, fullUrl: URL, method: Method) => {
|
|
102
|
+
let resolvedParams: unknown
|
|
103
|
+
const action = compiledApi[method]?.find((route) => {
|
|
104
|
+
const result = route.matcher(fullUrl.pathname)
|
|
105
|
+
if (result) {
|
|
106
|
+
resolvedParams = result.params
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
})
|
|
110
|
+
return (
|
|
111
|
+
action && {
|
|
112
|
+
...action,
|
|
113
|
+
params: resolvedParams,
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const executeAction = async (
|
|
119
|
+
options: OnRestApiMessageOptions & {
|
|
120
|
+
fullUrl: URL
|
|
121
|
+
action: RequestAction<{ body: object; result: object; query: object; url: object; headers: object }>
|
|
122
|
+
params: unknown
|
|
123
|
+
},
|
|
124
|
+
): Promise<void> => {
|
|
125
|
+
const { injector, req, res, fullUrl, action, deserializeQueryParams, params } = options
|
|
126
|
+
await usingAsync(injector.createScope({ owner: action }), async (scope) => {
|
|
127
|
+
const httpUserContext = scope.get(HttpUserContext)
|
|
128
|
+
scope.bind(IdentityContext, () => ({
|
|
129
|
+
getCurrentUser: <TUser extends User>() => httpUserContext.getCurrentUser(req) as Promise<TUser>,
|
|
130
|
+
isAuthorized: (...roles) => httpUserContext.isAuthorized(req, ...roles),
|
|
131
|
+
isAuthenticated: () => httpUserContext.isAuthenticated(req),
|
|
132
|
+
}))
|
|
133
|
+
try {
|
|
134
|
+
const actionResult = await action({
|
|
135
|
+
request: req,
|
|
136
|
+
response: res,
|
|
137
|
+
injector: scope,
|
|
138
|
+
getBody: () => readPostBody<never>(req),
|
|
139
|
+
headers: req.headers,
|
|
140
|
+
getQuery: () =>
|
|
141
|
+
deserializeQueryParams ? deserializeQueryParams(fullUrl.search) : deserializeQueryString(fullUrl.search),
|
|
142
|
+
getUrlParams: () => params as object,
|
|
143
|
+
})
|
|
144
|
+
res.sendActionResult(actionResult)
|
|
145
|
+
} catch (error) {
|
|
146
|
+
const errorActionResult = await ErrorAction({
|
|
147
|
+
request: req,
|
|
148
|
+
response: res,
|
|
149
|
+
injector: scope,
|
|
150
|
+
getBody: async () => error,
|
|
151
|
+
})
|
|
152
|
+
res.sendActionResult(errorActionResult)
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Handles a single incoming HTTP request matched by a REST API's
|
|
159
|
+
* {@link ServerApi.shouldExec}. Normalises the URL, applies CORS headers,
|
|
160
|
+
* short-circuits `OPTIONS` preflights and dispatches to the compiled action.
|
|
161
|
+
*
|
|
162
|
+
* Moved out of the deleted `ApiManager.onMessage` verbatim; behaviour is
|
|
163
|
+
* unchanged apart from injector resolution (`get` instead of `getInstance`).
|
|
164
|
+
*/
|
|
165
|
+
export const onRestApiMessage = async (options: OnRestApiMessageOptions): Promise<void> => {
|
|
166
|
+
const protocol = 'http://'
|
|
167
|
+
const host = `${options.hostName ?? DEFAULT_HOST}:${options.port}`
|
|
168
|
+
const fullUrl = new URL(PathHelper.joinUrl(`${protocol}${host}`, options.req.url as string))
|
|
169
|
+
|
|
170
|
+
if (options.cors) {
|
|
171
|
+
addCorsHeaders(options.cors, options.req, options.res)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (options.req.method === 'OPTIONS') {
|
|
175
|
+
options.res.writeHead(200)
|
|
176
|
+
options.res.end()
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let action: ReturnType<typeof getActionFromEndpoint>
|
|
181
|
+
try {
|
|
182
|
+
action = getActionFromEndpoint(options.compiledApi, fullUrl, options.req.method?.toUpperCase() as Method)
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const responseError =
|
|
185
|
+
error instanceof URIError ? new RequestError('Failed to decode URL path parameter', 400) : (error as Error)
|
|
186
|
+
const errorActionResult = await ErrorAction({
|
|
187
|
+
request: options.req,
|
|
188
|
+
response: options.res,
|
|
189
|
+
injector: options.injector,
|
|
190
|
+
getBody: async () => responseError,
|
|
191
|
+
})
|
|
192
|
+
options.res.sendActionResult(errorActionResult)
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (action) {
|
|
197
|
+
await executeAction({ ...options, ...action, fullUrl })
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
options.res.sendActionResult(
|
|
202
|
+
await NotFoundAction({ injector: options.injector, request: options.req, response: options.res }),
|
|
203
|
+
)
|
|
204
|
+
}
|