@furystack/rest-service 12.3.5 → 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.
Files changed (252) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +47 -28
  3. package/esm/actions/get-current-user.spec.js +10 -6
  4. package/esm/actions/get-current-user.spec.js.map +1 -1
  5. package/esm/actions/index.d.ts +0 -1
  6. package/esm/actions/index.d.ts.map +1 -1
  7. package/esm/actions/index.js +0 -1
  8. package/esm/actions/index.js.map +1 -1
  9. package/esm/actions/is-authenticated.spec.js +10 -6
  10. package/esm/actions/is-authenticated.spec.js.map +1 -1
  11. package/esm/actions/logout-action.spec.js +11 -9
  12. package/esm/actions/logout-action.spec.js.map +1 -1
  13. package/esm/actions/logout.js +1 -1
  14. package/esm/actions/logout.js.map +1 -1
  15. package/esm/actions/password-login-action.js +1 -1
  16. package/esm/actions/password-login-action.js.map +1 -1
  17. package/esm/actions/password-login-action.spec.js +23 -28
  18. package/esm/actions/password-login-action.spec.js.map +1 -1
  19. package/esm/authenticate.js +1 -1
  20. package/esm/authenticate.js.map +1 -1
  21. package/esm/authenticate.spec.js +41 -33
  22. package/esm/authenticate.spec.js.map +1 -1
  23. package/esm/authorize.spec.js +34 -29
  24. package/esm/authorize.spec.js.map +1 -1
  25. package/esm/endpoint-generators/create-delete-endpoint.d.ts +6 -11
  26. package/esm/endpoint-generators/create-delete-endpoint.d.ts.map +1 -1
  27. package/esm/endpoint-generators/create-delete-endpoint.js +7 -11
  28. package/esm/endpoint-generators/create-delete-endpoint.js.map +1 -1
  29. package/esm/endpoint-generators/create-delete-endpoint.spec.js +14 -21
  30. package/esm/endpoint-generators/create-delete-endpoint.spec.js.map +1 -1
  31. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts +11 -11
  32. package/esm/endpoint-generators/create-get-collection-endpoint.d.ts.map +1 -1
  33. package/esm/endpoint-generators/create-get-collection-endpoint.js +13 -12
  34. package/esm/endpoint-generators/create-get-collection-endpoint.js.map +1 -1
  35. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js +43 -70
  36. package/esm/endpoint-generators/create-get-collection-endpoint.spec.js.map +1 -1
  37. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts +6 -11
  38. package/esm/endpoint-generators/create-get-entity-endpoint.d.ts.map +1 -1
  39. package/esm/endpoint-generators/create-get-entity-endpoint.js +7 -11
  40. package/esm/endpoint-generators/create-get-entity-endpoint.js.map +1 -1
  41. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js +16 -33
  42. package/esm/endpoint-generators/create-get-entity-endpoint.spec.js.map +1 -1
  43. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts +1 -15
  44. package/esm/endpoint-generators/create-get-openapi-document-action.d.ts.map +1 -1
  45. package/esm/endpoint-generators/create-get-openapi-document-action.js +1 -24
  46. package/esm/endpoint-generators/create-get-openapi-document-action.js.map +1 -1
  47. package/esm/endpoint-generators/create-get-schema-action.d.ts +1 -1
  48. package/esm/endpoint-generators/create-get-schema-action.d.ts.map +1 -1
  49. package/esm/endpoint-generators/create-get-schema-action.js.map +1 -1
  50. package/esm/endpoint-generators/create-patch-endpoint.d.ts +7 -13
  51. package/esm/endpoint-generators/create-patch-endpoint.d.ts.map +1 -1
  52. package/esm/endpoint-generators/create-patch-endpoint.js +8 -13
  53. package/esm/endpoint-generators/create-patch-endpoint.js.map +1 -1
  54. package/esm/endpoint-generators/create-patch-endpoint.spec.js +10 -17
  55. package/esm/endpoint-generators/create-patch-endpoint.spec.js.map +1 -1
  56. package/esm/endpoint-generators/create-post-endpoint.d.ts +7 -13
  57. package/esm/endpoint-generators/create-post-endpoint.d.ts.map +1 -1
  58. package/esm/endpoint-generators/create-post-endpoint.js +8 -13
  59. package/esm/endpoint-generators/create-post-endpoint.js.map +1 -1
  60. package/esm/endpoint-generators/create-post-endpoint.spec.js +41 -14
  61. package/esm/endpoint-generators/create-post-endpoint.spec.js.map +1 -1
  62. package/esm/endpoint-generators/utils.d.ts +17 -2
  63. package/esm/endpoint-generators/utils.d.ts.map +1 -1
  64. package/esm/endpoint-generators/utils.js +33 -20
  65. package/esm/endpoint-generators/utils.js.map +1 -1
  66. package/esm/endpoint-generators/with-schema-and-openapi-action.d.ts +0 -2
  67. package/esm/endpoint-generators/with-schema-and-openapi-action.d.ts.map +1 -1
  68. package/esm/get-schema-from-api.d.ts +1 -1
  69. package/esm/get-schema-from-api.d.ts.map +1 -1
  70. package/esm/get-schema-from-api.spec.js.map +1 -1
  71. package/esm/helpers.d.ts +53 -87
  72. package/esm/helpers.d.ts.map +1 -1
  73. package/esm/helpers.js +91 -92
  74. package/esm/helpers.js.map +1 -1
  75. package/esm/helpers.spec.d.ts +2 -1
  76. package/esm/helpers.spec.d.ts.map +1 -1
  77. package/esm/helpers.spec.js +41 -38
  78. package/esm/helpers.spec.js.map +1 -1
  79. package/esm/http-authentication-settings.d.ts +35 -12
  80. package/esm/http-authentication-settings.d.ts.map +1 -1
  81. package/esm/http-authentication-settings.js +21 -29
  82. package/esm/http-authentication-settings.js.map +1 -1
  83. package/esm/http-server-pool.d.ts +64 -0
  84. package/esm/http-server-pool.d.ts.map +1 -0
  85. package/esm/http-server-pool.js +95 -0
  86. package/esm/http-server-pool.js.map +1 -0
  87. package/esm/http-server-pool.spec.d.ts +2 -0
  88. package/esm/http-server-pool.spec.d.ts.map +1 -0
  89. package/esm/http-server-pool.spec.js +193 -0
  90. package/esm/http-server-pool.spec.js.map +1 -0
  91. package/esm/http-user-context.d.ts +31 -44
  92. package/esm/http-user-context.d.ts.map +1 -1
  93. package/esm/http-user-context.js +76 -95
  94. package/esm/http-user-context.js.map +1 -1
  95. package/esm/http-user-context.spec.d.ts +8 -2
  96. package/esm/http-user-context.spec.d.ts.map +1 -1
  97. package/esm/http-user-context.spec.js +189 -337
  98. package/esm/http-user-context.spec.js.map +1 -1
  99. package/esm/index.d.ts +6 -4
  100. package/esm/index.d.ts.map +1 -1
  101. package/esm/index.js +6 -4
  102. package/esm/index.js.map +1 -1
  103. package/esm/login-response-strategy.d.ts +11 -10
  104. package/esm/login-response-strategy.d.ts.map +1 -1
  105. package/esm/login-response-strategy.js +7 -6
  106. package/esm/login-response-strategy.js.map +1 -1
  107. package/esm/login-response-strategy.spec.js +25 -29
  108. package/esm/login-response-strategy.spec.js.map +1 -1
  109. package/esm/openapi/generate-openapi-document.d.ts +0 -8
  110. package/esm/openapi/generate-openapi-document.d.ts.map +1 -1
  111. package/esm/openapi/generate-openapi-document.js +0 -2
  112. package/esm/openapi/generate-openapi-document.js.map +1 -1
  113. package/esm/proxy-manager.spec.js +152 -176
  114. package/esm/proxy-manager.spec.js.map +1 -1
  115. package/esm/proxy-runtime.d.ts +27 -0
  116. package/esm/proxy-runtime.d.ts.map +1 -0
  117. package/esm/proxy-runtime.js +45 -0
  118. package/esm/proxy-runtime.js.map +1 -0
  119. package/esm/proxy-runtime.spec.d.ts +2 -0
  120. package/esm/proxy-runtime.spec.d.ts.map +1 -0
  121. package/esm/proxy-runtime.spec.js +39 -0
  122. package/esm/proxy-runtime.spec.js.map +1 -0
  123. package/esm/rest-api-runtime.d.ts +76 -0
  124. package/esm/rest-api-runtime.d.ts.map +1 -0
  125. package/esm/rest-api-runtime.js +131 -0
  126. package/esm/rest-api-runtime.js.map +1 -0
  127. package/esm/rest-api-runtime.spec.d.ts +2 -0
  128. package/esm/rest-api-runtime.spec.d.ts.map +1 -0
  129. package/esm/rest-api-runtime.spec.js +58 -0
  130. package/esm/rest-api-runtime.spec.js.map +1 -0
  131. package/esm/rest-service.integration.spec.d.ts +1 -1
  132. package/esm/rest-service.integration.spec.d.ts.map +1 -1
  133. package/esm/rest-service.integration.spec.js +60 -82
  134. package/esm/rest-service.integration.spec.js.map +1 -1
  135. package/esm/rest.integration.test.js +26 -84
  136. package/esm/rest.integration.test.js.map +1 -1
  137. package/esm/server-telemetry.d.ts +57 -0
  138. package/esm/server-telemetry.d.ts.map +1 -0
  139. package/esm/server-telemetry.js +26 -0
  140. package/esm/server-telemetry.js.map +1 -0
  141. package/esm/server-telemetry.spec.d.ts +2 -0
  142. package/esm/server-telemetry.spec.d.ts.map +1 -0
  143. package/esm/server-telemetry.spec.js +52 -0
  144. package/esm/server-telemetry.spec.js.map +1 -0
  145. package/esm/static-server-manager.spec.js +60 -125
  146. package/esm/static-server-manager.spec.js.map +1 -1
  147. package/esm/static-site-runtime.d.ts +21 -0
  148. package/esm/static-site-runtime.d.ts.map +1 -0
  149. package/esm/static-site-runtime.js +48 -0
  150. package/esm/static-site-runtime.js.map +1 -0
  151. package/esm/static-site-runtime.spec.d.ts +2 -0
  152. package/esm/static-site-runtime.spec.d.ts.map +1 -0
  153. package/esm/static-site-runtime.spec.js +30 -0
  154. package/esm/static-site-runtime.spec.js.map +1 -0
  155. package/esm/user-store.d.ts +39 -0
  156. package/esm/user-store.d.ts.map +1 -0
  157. package/esm/user-store.js +62 -0
  158. package/esm/user-store.js.map +1 -0
  159. package/esm/user-store.spec.d.ts +2 -0
  160. package/esm/user-store.spec.d.ts.map +1 -0
  161. package/esm/user-store.spec.js +72 -0
  162. package/esm/user-store.spec.js.map +1 -0
  163. package/esm/validate.integration.spec.js +26 -83
  164. package/esm/validate.integration.spec.js.map +1 -1
  165. package/package.json +8 -8
  166. package/src/actions/get-current-user.spec.ts +11 -10
  167. package/src/actions/index.ts +0 -1
  168. package/src/actions/is-authenticated.spec.ts +12 -7
  169. package/src/actions/logout-action.spec.ts +12 -12
  170. package/src/actions/logout.ts +1 -1
  171. package/src/actions/password-login-action.spec.ts +30 -30
  172. package/src/actions/password-login-action.ts +1 -1
  173. package/src/authenticate.spec.ts +45 -51
  174. package/src/authenticate.ts +1 -1
  175. package/src/authorize.spec.ts +37 -39
  176. package/src/endpoint-generators/create-delete-endpoint.spec.ts +16 -24
  177. package/src/endpoint-generators/create-delete-endpoint.ts +11 -17
  178. package/src/endpoint-generators/create-get-collection-endpoint.spec.ts +45 -78
  179. package/src/endpoint-generators/create-get-collection-endpoint.ts +17 -19
  180. package/src/endpoint-generators/create-get-entity-endpoint.spec.ts +17 -33
  181. package/src/endpoint-generators/create-get-entity-endpoint.ts +11 -17
  182. package/src/endpoint-generators/create-get-openapi-document-action.ts +2 -38
  183. package/src/endpoint-generators/create-get-schema-action.ts +1 -1
  184. package/src/endpoint-generators/create-patch-endpoint.spec.ts +11 -18
  185. package/src/endpoint-generators/create-patch-endpoint.ts +12 -23
  186. package/src/endpoint-generators/create-post-endpoint.spec.ts +45 -14
  187. package/src/endpoint-generators/create-post-endpoint.ts +13 -23
  188. package/src/endpoint-generators/utils.ts +36 -28
  189. package/src/endpoint-generators/with-schema-and-openapi-action.ts +0 -3
  190. package/src/get-schema-from-api.spec.ts +1 -1
  191. package/src/get-schema-from-api.ts +1 -1
  192. package/src/helpers.spec.ts +51 -41
  193. package/src/helpers.ts +140 -109
  194. package/src/http-authentication-settings.ts +51 -25
  195. package/src/http-server-pool.spec.ts +220 -0
  196. package/src/http-server-pool.ts +169 -0
  197. package/src/http-user-context.spec.ts +203 -383
  198. package/src/http-user-context.ts +114 -91
  199. package/src/index.ts +6 -4
  200. package/src/login-response-strategy.spec.ts +32 -31
  201. package/src/login-response-strategy.ts +13 -12
  202. package/src/openapi/generate-openapi-document.ts +0 -3
  203. package/src/proxy-manager.spec.ts +152 -182
  204. package/src/proxy-runtime.spec.ts +50 -0
  205. package/src/proxy-runtime.ts +75 -0
  206. package/src/rest-api-runtime.spec.ts +77 -0
  207. package/src/rest-api-runtime.ts +204 -0
  208. package/src/rest-service.integration.spec.ts +68 -87
  209. package/src/rest.integration.test.ts +27 -87
  210. package/src/server-telemetry.spec.ts +56 -0
  211. package/src/server-telemetry.ts +51 -0
  212. package/src/static-server-manager.spec.ts +63 -151
  213. package/src/static-site-runtime.spec.ts +39 -0
  214. package/src/static-site-runtime.ts +75 -0
  215. package/src/user-store.spec.ts +86 -0
  216. package/src/user-store.ts +68 -0
  217. package/src/validate.integration.spec.ts +39 -97
  218. package/esm/actions/login-action.spec.d.ts +0 -2
  219. package/esm/actions/login-action.spec.d.ts.map +0 -1
  220. package/esm/actions/login-action.spec.js +0 -34
  221. package/esm/actions/login-action.spec.js.map +0 -1
  222. package/esm/actions/login.d.ts +0 -19
  223. package/esm/actions/login.d.ts.map +0 -1
  224. package/esm/actions/login.js +0 -27
  225. package/esm/actions/login.js.map +0 -1
  226. package/esm/api-manager.d.ts +0 -104
  227. package/esm/api-manager.d.ts.map +0 -1
  228. package/esm/api-manager.js +0 -180
  229. package/esm/api-manager.js.map +0 -1
  230. package/esm/proxy-manager.d.ts +0 -53
  231. package/esm/proxy-manager.d.ts.map +0 -1
  232. package/esm/proxy-manager.js +0 -84
  233. package/esm/proxy-manager.js.map +0 -1
  234. package/esm/server-manager.d.ts +0 -48
  235. package/esm/server-manager.d.ts.map +0 -1
  236. package/esm/server-manager.js +0 -100
  237. package/esm/server-manager.js.map +0 -1
  238. package/esm/static-server-manager.d.ts +0 -19
  239. package/esm/static-server-manager.d.ts.map +0 -1
  240. package/esm/static-server-manager.js +0 -65
  241. package/esm/static-server-manager.js.map +0 -1
  242. package/esm/swagger/generate-swagger-json.spec.d.ts +0 -2
  243. package/esm/swagger/generate-swagger-json.spec.d.ts.map +0 -1
  244. package/esm/swagger/generate-swagger-json.spec.js +0 -215
  245. package/esm/swagger/generate-swagger-json.spec.js.map +0 -1
  246. package/src/actions/login-action.spec.ts +0 -42
  247. package/src/actions/login.ts +0 -32
  248. package/src/api-manager.ts +0 -308
  249. package/src/proxy-manager.ts +0 -102
  250. package/src/server-manager.ts +0 -135
  251. package/src/static-server-manager.ts +0 -90
  252. package/src/swagger/generate-swagger-json.spec.ts +0 -250
@@ -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
+ }