@decohere/http 0.0.1-dev.125 → 0.0.1-dev.5

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 (292) hide show
  1. package/package.json +4 -13
  2. package/src/app/app.ts +147 -0
  3. package/src/app/module.ts +19 -0
  4. package/src/auth/ability.ts +56 -0
  5. package/src/auth/auth.config.ts +34 -0
  6. package/src/auth/auth.extensions.ts +115 -0
  7. package/src/auth/auth.types.ts +124 -0
  8. package/src/auth/casl-authorization.service.ts +62 -0
  9. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  10. package/src/auth/safe-outbound-url.ts +117 -0
  11. package/src/auth/token-service.ts +192 -0
  12. package/src/builder/builder.ts +118 -0
  13. package/{es6/src/const.js → src/const.ts} +25 -17
  14. package/src/health/health.controller.ts +34 -0
  15. package/src/health/health.extensions.ts +53 -0
  16. package/src/health/readiness.service.ts +33 -0
  17. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  18. package/src/hosting/hosted-service-host.ts +102 -0
  19. package/src/hosting/hosted-service.ts +31 -0
  20. package/src/http/http.compression.ts +38 -0
  21. package/src/http/http.extensions.ts +244 -0
  22. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  23. package/src/http/http.options.ts +41 -0
  24. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  25. package/src/http/http.static.ts +359 -0
  26. package/{es6/src/index.js → src/index.ts} +1 -0
  27. package/src/openapi/index.ts +22 -0
  28. package/src/openapi/openapi.generator.ts +78 -0
  29. package/src/openapi/openapi.types.ts +60 -0
  30. package/src/openapi/request-schema.ts +155 -0
  31. package/src/openapi/request-validator.ts +95 -0
  32. package/src/providers/fastify.provider.ts +454 -0
  33. package/src/routing/anvil.types.ts +150 -0
  34. package/src/routing/decorators/argument.decorator.ts +33 -0
  35. package/src/routing/decorators/auth.decorator.ts +79 -0
  36. package/src/routing/decorators/controller.decorator.ts +9 -0
  37. package/src/routing/decorators/field.decorator.ts +15 -0
  38. package/src/routing/decorators/request.decorator.ts +34 -0
  39. package/src/routing/decorators/response.decorator.ts +18 -0
  40. package/src/routing/decorators/route.decorator.ts +52 -0
  41. package/src/routing/filters/exception-filter-host.ts +90 -0
  42. package/src/routing/filters/exception-filter.ts +29 -0
  43. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  44. package/src/routing/middleware/middleware.ts +26 -0
  45. package/src/routing/middleware/request-id.ts +18 -0
  46. package/src/routing/path.util.ts +38 -0
  47. package/src/routing/pipes/pipes.ts +132 -0
  48. package/src/routing/services/route-argument-resolver.ts +92 -0
  49. package/src/routing/services/route-registry.ts +229 -0
  50. package/src/routing/services/router.ts +166 -0
  51. package/src/sse/sse.decorator.ts +41 -0
  52. package/src/sse/sse.encoder.ts +86 -0
  53. package/src/sse/sse.types.ts +47 -0
  54. package/src/sse/sse.wire.ts +34 -0
  55. package/src/testing/boot-module.ts +63 -0
  56. package/src/testing/sse-reader.ts +120 -0
  57. package/tests/app.hosted-services.spec.ts +292 -0
  58. package/tests/app.shutdown.integration.spec.ts +160 -0
  59. package/tests/app.spec.ts +132 -0
  60. package/tests/auth.ability.spec.ts +48 -0
  61. package/tests/auth.decorators.spec.ts +20 -0
  62. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  63. package/tests/auth.integration.spec.ts +234 -0
  64. package/tests/auth.jwks-cache.spec.ts +52 -0
  65. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  66. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  67. package/tests/auth.oidc.integration.spec.ts +161 -0
  68. package/tests/auth.token-service.spec.ts +251 -0
  69. package/tests/builder.spec.ts +12 -0
  70. package/tests/health.integration.spec.ts +111 -0
  71. package/tests/helpers/listen-ephemeral.ts +13 -0
  72. package/tests/helpers/local-oidc-server.ts +100 -0
  73. package/tests/http.access-log.integration.spec.ts +111 -0
  74. package/tests/http.boolean-query.integration.spec.ts +59 -0
  75. package/tests/http.compression.integration.spec.ts +604 -0
  76. package/tests/http.date-query.integration.spec.ts +71 -0
  77. package/tests/http.global-prefix.integration.spec.ts +284 -0
  78. package/tests/http.number-query.integration.spec.ts +65 -0
  79. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  80. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  81. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  82. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  83. package/tests/http.security.integration.spec.ts +106 -0
  84. package/tests/http.static.integration.spec.ts +847 -0
  85. package/tests/http.static.spec.ts +111 -0
  86. package/tests/http.surface.integration.spec.ts +167 -0
  87. package/tests/openapi.integration.spec.ts +193 -0
  88. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  89. package/tests/routing.decorators.spec.ts +15 -0
  90. package/tests/routing.registry.spec.ts +44 -0
  91. package/tests/routing.resolver.spec.ts +92 -0
  92. package/tests/sse.integration.spec.ts +421 -0
  93. package/tests/sse.wire.spec.ts +33 -0
  94. package/LICENSE +0 -15
  95. package/commonjs/src/app/app.js +0 -166
  96. package/commonjs/src/app/index.js +0 -19
  97. package/commonjs/src/app/module.js +0 -17
  98. package/commonjs/src/auth/ability.js +0 -54
  99. package/commonjs/src/auth/auth.config.js +0 -23
  100. package/commonjs/src/auth/auth.extensions.js +0 -106
  101. package/commonjs/src/auth/auth.types.js +0 -65
  102. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  103. package/commonjs/src/auth/index.js +0 -23
  104. package/commonjs/src/auth/principal-provider.js +0 -19
  105. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  106. package/commonjs/src/auth/token-service.js +0 -151
  107. package/commonjs/src/builder/builder.js +0 -95
  108. package/commonjs/src/builder/index.js +0 -18
  109. package/commonjs/src/const.js +0 -87
  110. package/commonjs/src/health/health.controller.js +0 -75
  111. package/commonjs/src/health/health.extensions.js +0 -50
  112. package/commonjs/src/health/index.js +0 -21
  113. package/commonjs/src/health/readiness.service.js +0 -67
  114. package/commonjs/src/health/readiness.types.js +0 -7
  115. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  116. package/commonjs/src/hosting/hosted-service.js +0 -11
  117. package/commonjs/src/hosting/index.js +0 -19
  118. package/commonjs/src/http/http.compression.js +0 -27
  119. package/commonjs/src/http/http.extensions.js +0 -269
  120. package/commonjs/src/http/http.options.js +0 -26
  121. package/commonjs/src/http/http.options.store.js +0 -20
  122. package/commonjs/src/http/http.security.js +0 -27
  123. package/commonjs/src/http/http.static.js +0 -248
  124. package/commonjs/src/http/index.js +0 -22
  125. package/commonjs/src/index.js +0 -27
  126. package/commonjs/src/openapi/index.js +0 -45
  127. package/commonjs/src/openapi/openapi.generator.js +0 -103
  128. package/commonjs/src/openapi/openapi.types.js +0 -4
  129. package/commonjs/src/openapi/request-schema.js +0 -187
  130. package/commonjs/src/openapi/request-validator.js +0 -85
  131. package/commonjs/src/providers/fastify.provider.js +0 -391
  132. package/commonjs/src/providers/index.js +0 -18
  133. package/commonjs/src/routing/anvil.types.js +0 -99
  134. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  135. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  136. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  137. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  138. package/commonjs/src/routing/decorators/index.js +0 -24
  139. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  140. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  141. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  142. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  143. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  144. package/commonjs/src/routing/index.js +0 -25
  145. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  146. package/commonjs/src/routing/middleware/middleware.js +0 -20
  147. package/commonjs/src/routing/middleware/request-id.js +0 -30
  148. package/commonjs/src/routing/path.util.js +0 -51
  149. package/commonjs/src/routing/pipes/index.js +0 -18
  150. package/commonjs/src/routing/pipes/pipes.js +0 -145
  151. package/commonjs/src/routing/services/index.js +0 -18
  152. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  153. package/commonjs/src/routing/services/route-registry.js +0 -194
  154. package/commonjs/src/routing/services/router.js +0 -158
  155. package/commonjs/src/sse/index.js +0 -26
  156. package/commonjs/src/sse/sse.decorator.js +0 -42
  157. package/commonjs/src/sse/sse.encoder.js +0 -73
  158. package/commonjs/src/sse/sse.types.js +0 -36
  159. package/commonjs/src/sse/sse.wire.js +0 -40
  160. package/commonjs/src/testing/boot-module.js +0 -38
  161. package/commonjs/src/testing/index.js +0 -20
  162. package/commonjs/src/testing/sse-reader.js +0 -98
  163. package/es6/src/app/app.js +0 -148
  164. package/es6/src/app/index.js +0 -2
  165. package/es6/src/app/module.js +0 -7
  166. package/es6/src/auth/ability.js +0 -45
  167. package/es6/src/auth/auth.config.js +0 -19
  168. package/es6/src/auth/auth.extensions.js +0 -83
  169. package/es6/src/auth/auth.types.js +0 -35
  170. package/es6/src/auth/casl-authorization.service.js +0 -40
  171. package/es6/src/auth/index.js +0 -6
  172. package/es6/src/auth/principal-provider.js +0 -14
  173. package/es6/src/auth/safe-outbound-url.js +0 -99
  174. package/es6/src/auth/token-service.js +0 -141
  175. package/es6/src/builder/builder.js +0 -85
  176. package/es6/src/builder/index.js +0 -1
  177. package/es6/src/health/health.controller.js +0 -65
  178. package/es6/src/health/health.extensions.js +0 -40
  179. package/es6/src/health/index.js +0 -4
  180. package/es6/src/health/readiness.service.js +0 -57
  181. package/es6/src/health/readiness.types.js +0 -4
  182. package/es6/src/hosting/hosted-service-host.js +0 -81
  183. package/es6/src/hosting/hosted-service.js +0 -8
  184. package/es6/src/hosting/index.js +0 -2
  185. package/es6/src/http/http.compression.js +0 -9
  186. package/es6/src/http/http.extensions.js +0 -259
  187. package/es6/src/http/http.options.js +0 -16
  188. package/es6/src/http/http.options.store.js +0 -13
  189. package/es6/src/http/http.security.js +0 -9
  190. package/es6/src/http/http.static.js +0 -224
  191. package/es6/src/http/index.js +0 -5
  192. package/es6/src/openapi/index.js +0 -3
  193. package/es6/src/openapi/openapi.generator.js +0 -82
  194. package/es6/src/openapi/openapi.types.js +0 -1
  195. package/es6/src/openapi/request-schema.js +0 -160
  196. package/es6/src/openapi/request-validator.js +0 -71
  197. package/es6/src/providers/fastify.provider.js +0 -376
  198. package/es6/src/providers/index.js +0 -1
  199. package/es6/src/routing/anvil.types.js +0 -57
  200. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  201. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  202. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  203. package/es6/src/routing/decorators/field.decorator.js +0 -15
  204. package/es6/src/routing/decorators/index.js +0 -7
  205. package/es6/src/routing/decorators/request.decorator.js +0 -24
  206. package/es6/src/routing/decorators/response.decorator.js +0 -16
  207. package/es6/src/routing/decorators/route.decorator.js +0 -37
  208. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  209. package/es6/src/routing/filters/exception-filter.js +0 -10
  210. package/es6/src/routing/index.js +0 -8
  211. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  212. package/es6/src/routing/middleware/middleware.js +0 -10
  213. package/es6/src/routing/middleware/request-id.js +0 -14
  214. package/es6/src/routing/path.util.js +0 -33
  215. package/es6/src/routing/pipes/index.js +0 -1
  216. package/es6/src/routing/pipes/pipes.js +0 -118
  217. package/es6/src/routing/services/index.js +0 -1
  218. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  219. package/es6/src/routing/services/route-registry.js +0 -184
  220. package/es6/src/routing/services/router.js +0 -148
  221. package/es6/src/sse/index.js +0 -2
  222. package/es6/src/sse/sse.decorator.js +0 -31
  223. package/es6/src/sse/sse.encoder.js +0 -67
  224. package/es6/src/sse/sse.types.js +0 -22
  225. package/es6/src/sse/sse.wire.js +0 -30
  226. package/es6/src/testing/boot-module.js +0 -35
  227. package/es6/src/testing/index.js +0 -2
  228. package/es6/src/testing/sse-reader.js +0 -91
  229. package/types/src/app/app.d.ts +0 -41
  230. package/types/src/app/module.d.ts +0 -15
  231. package/types/src/auth/ability.d.ts +0 -19
  232. package/types/src/auth/auth.config.d.ts +0 -28
  233. package/types/src/auth/auth.extensions.d.ts +0 -53
  234. package/types/src/auth/auth.types.d.ts +0 -73
  235. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  236. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  237. package/types/src/auth/token-service.d.ts +0 -31
  238. package/types/src/builder/builder.d.ts +0 -36
  239. package/types/src/const.d.ts +0 -32
  240. package/types/src/health/health.controller.d.ts +0 -16
  241. package/types/src/health/health.extensions.d.ts +0 -31
  242. package/types/src/health/readiness.service.d.ts +0 -9
  243. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  244. package/types/src/hosting/hosted-service.d.ts +0 -31
  245. package/types/src/http/http.compression.d.ts +0 -32
  246. package/types/src/http/http.extensions.d.ts +0 -79
  247. package/types/src/http/http.options.d.ts +0 -39
  248. package/types/src/http/http.static.d.ts +0 -160
  249. package/types/src/index.d.ts +0 -10
  250. package/types/src/openapi/index.d.ts +0 -6
  251. package/types/src/openapi/openapi.generator.d.ts +0 -6
  252. package/types/src/openapi/openapi.types.d.ts +0 -58
  253. package/types/src/openapi/request-schema.d.ts +0 -21
  254. package/types/src/openapi/request-validator.d.ts +0 -9
  255. package/types/src/providers/fastify.provider.d.ts +0 -56
  256. package/types/src/routing/anvil.types.d.ts +0 -119
  257. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  258. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  259. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  260. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  261. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  262. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  263. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  264. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  265. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  266. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  267. package/types/src/routing/middleware/middleware.d.ts +0 -10
  268. package/types/src/routing/middleware/request-id.d.ts +0 -6
  269. package/types/src/routing/path.util.d.ts +0 -9
  270. package/types/src/routing/pipes/pipes.d.ts +0 -21
  271. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  272. package/types/src/routing/services/route-registry.d.ts +0 -20
  273. package/types/src/routing/services/router.d.ts +0 -22
  274. package/types/src/sse/sse.decorator.d.ts +0 -17
  275. package/types/src/sse/sse.encoder.d.ts +0 -16
  276. package/types/src/sse/sse.types.d.ts +0 -37
  277. package/types/src/sse/sse.wire.d.ts +0 -9
  278. package/types/src/testing/boot-module.d.ts +0 -34
  279. package/types/src/testing/sse-reader.d.ts +0 -29
  280. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  281. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  282. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  283. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  284. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  285. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  286. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  287. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  288. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  289. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  290. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  291. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  292. /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { STATIC_DEFAULT_IMMUTABLE, staticFiles } from "../src/http/http.static.js";
3
+
4
+ describe("static file rules", () => {
5
+ it.each([
6
+ "assets/index-DiwrgTda.js",
7
+ "assets/index-3f9a8c1b.js",
8
+ "assets/style.3f9a8c1b.css",
9
+ "assets/vendor-x9Kd02_a.js.map",
10
+ "chunk-1a2b3c4d5e6f.js",
11
+ ])("treats %s as hashed", (file) => {
12
+ expect(STATIC_DEFAULT_IMMUTABLE.test(file)).toBe(true);
13
+ });
14
+
15
+ it.each([
16
+ "index.html",
17
+ "remoteEntry.js",
18
+ "favicon.ico",
19
+ "assets/logo-background.png",
20
+ "assets/short-abc123.js",
21
+ "fonts/inter.woff2",
22
+ "main.min.js",
23
+ ])("does not treat %s as hashed", (file) => {
24
+ expect(STATIC_DEFAULT_IMMUTABLE.test(file)).toBe(false);
25
+ });
26
+
27
+ it.each([
28
+ ["/ui/", "/ui/"],
29
+ ["/ui", "/ui/"],
30
+ ["ui", "/ui/"],
31
+ ["//apps//crypto/", "/apps/crypto/"],
32
+ ["/", "/"],
33
+ ["//", "/"],
34
+ ])("mounts %s at %s", (given, expected) => {
35
+ expect(staticFiles.toMount(given, "ui").prefix).toBe(expected);
36
+ });
37
+
38
+ it.each(["", "/ui/../api", "/ui/*"])("refuses the prefix %j", (given) => {
39
+ expect(() => staticFiles.toMount(given, "ui")).toThrow(/withStatic/);
40
+ });
41
+
42
+ it.each([
43
+ ["/portfolio", false],
44
+ ["/portfolio/42/edit", false],
45
+ ["/portfolio/", false],
46
+ ["/", false],
47
+ ["/app.js", true],
48
+ ["/assets/x.map", true],
49
+ ["/.env", true],
50
+ ["/.well-known/x", true],
51
+ ["/v1.2/overview/", false],
52
+ ])("treats %s as a file: %s", (pathname, expected) => {
53
+ expect(staticFiles.looksLikeFile(pathname)).toBe(expected);
54
+ });
55
+
56
+ it.each([
57
+ ["text/html", true],
58
+ ["text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", true],
59
+ ["application/json, TEXT/HTML;q=0.5", true],
60
+ ["text/html;q=0", false],
61
+ ["application/json", false],
62
+ ["*/*", false],
63
+ ["text/*", false],
64
+ ["", false],
65
+ [undefined, false],
66
+ ])("reads the Accept header %j as html: %s", (accept, expected) => {
67
+ expect(staticFiles.acceptsHtml(accept)).toBe(expected);
68
+ });
69
+
70
+ it("takes the first segment of every route that is not a parameter", () => {
71
+ const segments = staticFiles.routeSegments([
72
+ "/lp/portfolio",
73
+ "/health",
74
+ "/:id",
75
+ "/*",
76
+ "/",
77
+ "/lp/x",
78
+ ]);
79
+
80
+ expect([...segments].sort()).toEqual(["health", "lp"]);
81
+ });
82
+
83
+ it.each([
84
+ ["/lp/portfolo", true],
85
+ ["/lp", true],
86
+ ["/lp/a/b/c", true],
87
+ ["/lpx", false],
88
+ ["/portfolio", false],
89
+ ["/", false],
90
+ ])("decides whether %s is under a route: %s", (pathname, expected) => {
91
+ expect(staticFiles.isUnderRoute(pathname, new Set(["lp", "health"]))).toBe(expected);
92
+ });
93
+
94
+ it.each([
95
+ [["/api/**"], "/api/x", true],
96
+ [["/api/**"], "/api", true],
97
+ [["/api/**"], "/apix", false],
98
+ [["/docs/*"], "/docs/a/b", false],
99
+ [["/docs/*"], "/docs/a", true],
100
+ [["/api/**", "/docs/**"], "/docs/a/b", true],
101
+ [["/api/**"], "/api/.hidden", true],
102
+ [[], "/api/x", false],
103
+ ])("matches exclude %j against %s: %s", (patterns, pathname, expected) => {
104
+ expect(staticFiles.excluder(patterns)(pathname)).toBe(expected);
105
+ });
106
+
107
+ it("normalises the path: no query, decoded, one slash", () => {
108
+ expect(staticFiles.normalizePathname("//api%2Fx//y?z=1")).toBe("/api/x/y");
109
+ expect(staticFiles.normalizePathname("/bad%zz")).toBe("/bad%zz");
110
+ });
111
+ });
@@ -0,0 +1,167 @@
1
+ import { injectable } from "@decohere/di/decorators";
2
+ import axios from "axios";
3
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import type { App } from "../src/app/app.js";
5
+ import { Builder } from "../src/builder/builder.js";
6
+ import "../src/http/http.extensions.js";
7
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
8
+ import {
9
+ Catch,
10
+ Controller,
11
+ type ExceptionContext,
12
+ type ExceptionFilter,
13
+ type ExceptionResult,
14
+ Get,
15
+ Header,
16
+ HttpCode,
17
+ HttpError,
18
+ type Middleware,
19
+ type MiddlewareContext,
20
+ Post,
21
+ } from "../src/routing/index.js";
22
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
23
+
24
+ class NotFoundError extends HttpError {
25
+ public constructor(message = "Not Found") {
26
+ super(message, 404);
27
+ }
28
+ }
29
+
30
+ @Catch(NotFoundError)
31
+ @injectable()
32
+ class NotFoundFilter implements ExceptionFilter {
33
+ public catch(error: NotFoundError, _ctx: ExceptionContext): ExceptionResult {
34
+ return { status: error.status, body: { code: "NOT_FOUND", message: error.message } };
35
+ }
36
+ }
37
+
38
+ @injectable()
39
+ class TraceMiddleware implements Middleware {
40
+ public static calls = 0;
41
+ public async use(ctx: MiddlewareContext, next: () => Promise<unknown>) {
42
+ TraceMiddleware.calls += 1;
43
+ ctx.request.headers["x-mw"] = "1";
44
+ return next();
45
+ }
46
+ }
47
+
48
+ @Controller({ prefix: "/scope" })
49
+ class ScopedController {
50
+ private static nextId = 0;
51
+ private readonly id = ++ScopedController.nextId;
52
+
53
+ @Get()
54
+ public who() {
55
+ return { id: this.id };
56
+ }
57
+ }
58
+
59
+ @Controller({ prefix: "/widgets" })
60
+ class WidgetController {
61
+ @HttpCode(201)
62
+ @Header("X-Widget", "yes")
63
+ @Post()
64
+ public create() {
65
+ return { id: 1 };
66
+ }
67
+
68
+ @Get("/missing")
69
+ public missing() {
70
+ throw new NotFoundError("widget gone");
71
+ }
72
+
73
+ @Get("/boom")
74
+ public boom() {
75
+ throw new Error("kaboom");
76
+ }
77
+
78
+ @Get()
79
+ public list() {
80
+ return { ok: true };
81
+ }
82
+ }
83
+
84
+ describe("HTTP surface (filters, HttpCode/Header, middleware, CORS, global prefix)", () => {
85
+ let app: App;
86
+
87
+ beforeEach(() => {
88
+ TraceMiddleware.calls = 0;
89
+ });
90
+
91
+ afterEach(async () => {
92
+ await app?.dispose();
93
+ });
94
+
95
+ it("applies global prefix, HttpCode, Header, middleware, CORS, and exception filters", async () => {
96
+ app = await Builder.create()
97
+ .withGlobalPrefix("/api")
98
+ .withCors({ origin: "https://example.com", credentials: true })
99
+ .withMiddleware(TraceMiddleware)
100
+ .withExceptionFilters(NotFoundFilter)
101
+ .withHttpProvider(FastifyProvider)
102
+ .withControllers(WidgetController)
103
+ .build();
104
+ axios.defaults.baseURL = await listenEphemeral(app);
105
+
106
+ const created = await axios.post(
107
+ "/api/widgets",
108
+ {},
109
+ {
110
+ headers: { "Content-Type": "application/json" },
111
+ },
112
+ );
113
+ expect(created.status).toBe(201);
114
+ expect(created.headers["x-widget"]).toBe("yes");
115
+ expect(created.data).toEqual({ id: 1 });
116
+ expect(TraceMiddleware.calls).toBeGreaterThanOrEqual(1);
117
+
118
+ const missing = await axios.get("/api/widgets/missing", { validateStatus: () => true });
119
+ expect(missing.status).toBe(404);
120
+ expect(missing.data).toEqual({ code: "NOT_FOUND", message: "widget gone" });
121
+
122
+ const boom = await axios.get("/api/widgets/boom", { validateStatus: () => true });
123
+ expect(boom.status).toBe(500);
124
+ expect(boom.data).toEqual({ message: "Internal Server Error" });
125
+ expect(JSON.stringify(boom.data)).not.toMatch(/kaboom/i);
126
+
127
+ const preflight = await axios.options("/api/widgets", {
128
+ headers: {
129
+ Origin: "https://example.com",
130
+ "Access-Control-Request-Method": "POST",
131
+ },
132
+ validateStatus: () => true,
133
+ });
134
+ expect(preflight.status).toBe(204);
135
+ expect(preflight.headers["access-control-allow-origin"]).toBe("https://example.com");
136
+ expect(preflight.headers["access-control-allow-credentials"]).toBe("true");
137
+
138
+ const listed = await axios.get("/api/widgets", {
139
+ headers: { Origin: "https://example.com" },
140
+ });
141
+ expect(listed.status).toBe(200);
142
+ expect(listed.headers["access-control-allow-origin"]).toBe("https://example.com");
143
+ });
144
+
145
+ it("returns 404 for an unregistered path", async () => {
146
+ app = await Builder.create()
147
+ .withHttpProvider(FastifyProvider)
148
+ .withControllers(WidgetController)
149
+ .build();
150
+ axios.defaults.baseURL = await listenEphemeral(app);
151
+
152
+ const missing = await axios.get("/nope", { validateStatus: () => true });
153
+ expect(missing.status).toBe(404);
154
+ });
155
+
156
+ it("creates a new controller instance per request", async () => {
157
+ app = await Builder.create()
158
+ .withHttpProvider(FastifyProvider)
159
+ .withControllers(ScopedController)
160
+ .build();
161
+ axios.defaults.baseURL = await listenEphemeral(app);
162
+
163
+ const first = await axios.get("/scope");
164
+ const second = await axios.get("/scope");
165
+ expect(first.data.id).not.toBe(second.data.id);
166
+ });
167
+ });
@@ -0,0 +1,193 @@
1
+ import axios from "axios";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import type { App } from "../src/app/app.js";
4
+ import { Builder } from "../src/builder/builder.js";
5
+ import "../src/http/http.extensions.js";
6
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
7
+ import {
8
+ Controller,
9
+ Field,
10
+ Get,
11
+ Param,
12
+ Post,
13
+ Put,
14
+ Query,
15
+ RequestParts,
16
+ } from "../src/routing/index.js";
17
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
18
+
19
+ class CreateBody {
20
+ @Field(RequestParts.body, "message")
21
+ public message!: string;
22
+ }
23
+
24
+ @Controller({ prefix: "/widgets" })
25
+ class WidgetController {
26
+ @Get("/:id")
27
+ public getOne(@Param("id") id: string) {
28
+ return { id };
29
+ }
30
+
31
+ @Post()
32
+ public create(body: CreateBody) {
33
+ return { message: body.message };
34
+ }
35
+ }
36
+
37
+ /** Mirrors scaffolded HelloController + HelloBody for dogfood validation. */
38
+ class HelloBody {
39
+ @Field(RequestParts.body, "message")
40
+ public message!: string;
41
+ }
42
+
43
+ @Controller({ prefix: "/hello" })
44
+ class HelloController {
45
+ @Get()
46
+ public getAll(@Query("filter") filter: string) {
47
+ return { filter, message: "hello" };
48
+ }
49
+
50
+ @Get("/:id")
51
+ public getOne(@Param("id") id: string) {
52
+ return { id, message: `Hello ${id}!` };
53
+ }
54
+
55
+ @Post()
56
+ public create(body: HelloBody) {
57
+ return { message: body.message };
58
+ }
59
+
60
+ @Put("/:id")
61
+ public update(@Param("id") id: string, body: HelloBody) {
62
+ return { id, message: body.message };
63
+ }
64
+ }
65
+
66
+ describe("OpenAPI integration", () => {
67
+ let app: App;
68
+
69
+ afterEach(async () => {
70
+ await app?.dispose();
71
+ });
72
+
73
+ it("serves the OpenAPI document and rejects invalid bodies with typed 400", async () => {
74
+ app = await Builder.create()
75
+ .withOpenApi({
76
+ info: { title: "Widgets API", version: "1.0.0" },
77
+ path: "/openapi.json",
78
+ })
79
+ .withHttpProvider(FastifyProvider)
80
+ .withControllers(WidgetController)
81
+ .build();
82
+ axios.defaults.baseURL = await listenEphemeral(app);
83
+
84
+ const spec = await axios.get("/openapi.json");
85
+ expect(spec.status).toBe(200);
86
+ expect(spec.data.openapi).toBe("3.0.3");
87
+ expect(spec.data.paths["/widgets/{id}"].get).toBeDefined();
88
+ expect(spec.data.paths["/widgets"].post.requestBody).toBeDefined();
89
+
90
+ const bad = await axios.post(
91
+ "/widgets",
92
+ {},
93
+ { headers: { "Content-Type": "application/json" }, validateStatus: () => true },
94
+ );
95
+ expect(bad.status).toBe(400);
96
+ expect(bad.data).toMatchObject({
97
+ statusCode: 400,
98
+ error: "Bad Request",
99
+ message: "Validation failed",
100
+ });
101
+ expect(bad.data.errors).toEqual(
102
+ expect.arrayContaining([
103
+ expect.objectContaining({
104
+ keyword: "required",
105
+ message: expect.stringContaining("message"),
106
+ }),
107
+ ]),
108
+ );
109
+
110
+ const wrongType = await axios.post(
111
+ "/widgets",
112
+ { message: 42 },
113
+ { headers: { "Content-Type": "application/json" }, validateStatus: () => true },
114
+ );
115
+ expect(wrongType.status).toBe(400);
116
+ expect(wrongType.data.statusCode).toBe(400);
117
+ expect(wrongType.data.errors).toEqual(
118
+ expect.arrayContaining([
119
+ expect.objectContaining({
120
+ path: "/body/message",
121
+ keyword: "type",
122
+ }),
123
+ ]),
124
+ );
125
+
126
+ const extra = await axios.post(
127
+ "/widgets",
128
+ { message: "hi", surprise: true },
129
+ { headers: { "Content-Type": "application/json" }, validateStatus: () => true },
130
+ );
131
+ expect(extra.status).toBe(400);
132
+ expect(extra.data.errors).toEqual(
133
+ expect.arrayContaining([
134
+ expect.objectContaining({
135
+ keyword: "additionalProperties",
136
+ }),
137
+ ]),
138
+ );
139
+
140
+ const ok = await axios.post(
141
+ "/widgets",
142
+ { message: "hi" },
143
+ { headers: { "Content-Type": "application/json" } },
144
+ );
145
+ expect(ok.status).toBe(200);
146
+ expect(ok.data).toEqual({ message: "hi" });
147
+
148
+ const one = await axios.get("/widgets/abc");
149
+ expect(one.status).toBe(200);
150
+ expect(one.data).toEqual({ id: "abc" });
151
+ });
152
+
153
+ it("dogfoods Hello routes: fail-closed body validation with typed 400", async () => {
154
+ app = await Builder.create()
155
+ .withOpenApi({
156
+ info: { title: "hello.api", version: "1.0.0" },
157
+ })
158
+ .withHttpProvider(FastifyProvider)
159
+ .withControllers(HelloController)
160
+ .build();
161
+ axios.defaults.baseURL = await listenEphemeral(app);
162
+
163
+ const missing = await axios.post(
164
+ "/hello",
165
+ {},
166
+ { headers: { "Content-Type": "application/json" }, validateStatus: () => true },
167
+ );
168
+ expect(missing.status).toBe(400);
169
+ expect(missing.data).toMatchObject({
170
+ statusCode: 400,
171
+ error: "Bad Request",
172
+ message: "Validation failed",
173
+ });
174
+ expect(Array.isArray(missing.data.errors)).toBe(true);
175
+ expect(missing.data.errors.length).toBeGreaterThan(0);
176
+
177
+ const created = await axios.post(
178
+ "/hello",
179
+ { message: "hello" },
180
+ { headers: { "Content-Type": "application/json" } },
181
+ );
182
+ expect(created.status).toBe(200);
183
+ expect(created.data).toEqual({ message: "hello" });
184
+
185
+ const updated = await axios.put(
186
+ "/hello/1",
187
+ { message: "updated" },
188
+ { headers: { "Content-Type": "application/json" } },
189
+ );
190
+ expect(updated.status).toBe(200);
191
+ expect(updated.data).toEqual({ id: "1", message: "updated" });
192
+ });
193
+ });
@@ -0,0 +1,55 @@
1
+ import { Ajv } from "ajv";
2
+ import axios from "axios";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+ import type { App } from "../src/app/app.js";
5
+ import { Builder } from "../src/builder/builder.js";
6
+ import "../src/http/http.extensions.js";
7
+ import { FastifyProvider } from "../src/providers/fastify.provider.js";
8
+ import { Controller, Field, Param, Post, RequestParts } from "../src/routing/index.js";
9
+ import { listenEphemeral } from "./helpers/listen-ephemeral.js";
10
+
11
+ class NoteBody {
12
+ @Field(RequestParts.body, "text")
13
+ public text!: string;
14
+ }
15
+
16
+ @Controller({ prefix: "/notes" })
17
+ class NoteController {
18
+ @Post("/:id")
19
+ public save(@Param("id") id: string, body: NoteBody) {
20
+ return { id, text: body.text };
21
+ }
22
+ }
23
+
24
+ // #498: validators were compiled on every request, and AJV keeps every compiled schema,
25
+ // so memory grew with traffic. Each route's validators must be compiled once and reused.
26
+ describe("request validation compiles each route's schemas once", () => {
27
+ let app: App;
28
+
29
+ afterEach(async () => {
30
+ await app?.dispose();
31
+ vi.restoreAllMocks();
32
+ });
33
+
34
+ it("does not compile again for later requests to the same route", async () => {
35
+ app = await Builder.create()
36
+ .withOpenApi({ info: { title: "Notes", version: "1.0.0" }, path: "/openapi.json" })
37
+ .withHttpProvider(FastifyProvider)
38
+ .withControllers(NoteController)
39
+ .build();
40
+ const baseURL = await listenEphemeral(app);
41
+ const compile = vi.spyOn(Ajv.prototype, "compile");
42
+ const post = (body: object) =>
43
+ axios.post("/notes/1", body, { baseURL, validateStatus: () => true });
44
+
45
+ expect((await post({ text: "first" })).status).toBe(200);
46
+ const afterFirst = compile.mock.calls.length;
47
+
48
+ for (let i = 0; i < 20; i++) {
49
+ expect((await post({ text: `note ${i}` })).status).toBe(200);
50
+ }
51
+ expect((await post({})).status).toBe(400);
52
+
53
+ expect(compile.mock.calls.length).toBe(afterFirst);
54
+ });
55
+ });
@@ -0,0 +1,15 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RouteConfigurationError } from "../src/routing/anvil.types.js";
3
+ import { Get } from "../src/routing/decorators/index.js";
4
+
5
+ describe("routing decorators", () => {
6
+ it("throws RouteConfigurationError when a path does not start with /", () => {
7
+ expect(() => {
8
+ class BadController {
9
+ @Get("noslash")
10
+ public bad() {}
11
+ }
12
+ new BadController();
13
+ }).toThrow(RouteConfigurationError);
14
+ });
15
+ });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RouteConfigurationError } from "../src/routing/anvil.types.js";
3
+ import { Argument, Controller, Field, Get } from "../src/routing/index.js";
4
+ import { RouteRegistry } from "../src/routing/services/route-registry.js";
5
+
6
+ describe("RouteRegistry", () => {
7
+ it("throws when a field has no location and no arg-level location", () => {
8
+ class BadParams {
9
+ @Field()
10
+ public q: string;
11
+ }
12
+
13
+ @Controller()
14
+ class BadController {
15
+ @Get("/bad")
16
+ public bad(@Argument({}) _params: BadParams) {}
17
+ }
18
+
19
+ const registry = new RouteRegistry();
20
+ expect(() => registry.addController(BadController)).toThrow(RouteConfigurationError);
21
+ });
22
+
23
+ // #503: two handlers for the same method and path used to overwrite each other silently,
24
+ // so one of them was never reachable.
25
+ it("throws when two handlers register the same method and path", () => {
26
+ @Controller({ prefix: "/items" })
27
+ class FirstController {
28
+ @Get()
29
+ public list() {}
30
+ }
31
+
32
+ @Controller({ prefix: "/items" })
33
+ class SecondController {
34
+ @Get()
35
+ public listAgain() {}
36
+ }
37
+
38
+ const registry = new RouteRegistry();
39
+ registry.addController(FirstController);
40
+ expect(() => registry.addController(SecondController)).toThrow(
41
+ /GET \/items.*FirstController\.list.*SecondController\.listAgain/,
42
+ );
43
+ });
44
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { HttpRequest, TransformError, ValidationError } from "../src/routing/anvil.types.js";
3
+ import { BooleanPipe, DatePipe, JsonPipe, NumberPipe } from "../src/routing/pipes/pipes.js";
4
+ import { RouteArgumentResolve } from "../src/routing/services/route-argument-resolver.js";
5
+
6
+ describe("BooleanPipe", () => {
7
+ const pipe = new BooleanPipe();
8
+
9
+ it("converts query-shaped strings including 1 / 0", () => {
10
+ expect(pipe.transform("true")).toBe(true);
11
+ expect(pipe.transform("FALSE")).toBe(false);
12
+ expect(pipe.transform("1")).toBe(true);
13
+ expect(pipe.transform("0")).toBe(false);
14
+ expect(pipe.transform(1)).toBe(true);
15
+ expect(pipe.transform(0)).toBe(false);
16
+ });
17
+
18
+ it("throws ValidationError for unrecognised values", () => {
19
+ expect(() => pipe.transform("yes")).toThrow(ValidationError);
20
+ });
21
+ });
22
+
23
+ describe("NumberPipe", () => {
24
+ const pipe = new NumberPipe();
25
+
26
+ it("throws ValidationError for non-numeric strings (#503)", () => {
27
+ expect(() => pipe.transform("abc")).toThrow(ValidationError);
28
+ });
29
+ });
30
+
31
+ describe("DatePipe", () => {
32
+ const pipe = new DatePipe();
33
+
34
+ it("converts an ISO date string to Date", () => {
35
+ const result = pipe.transform("2024-01-15T00:00:00.000Z");
36
+ expect(result).toBeInstanceOf(Date);
37
+ expect(result.getFullYear()).toBe(2024);
38
+ });
39
+
40
+ it("throws ValidationError for an invalid or empty date string", () => {
41
+ expect(() => pipe.transform("not-a-date")).toThrow(ValidationError);
42
+ expect(() => pipe.transform("")).toThrow(ValidationError);
43
+ expect(() => pipe.transform(" ")).toThrow(ValidationError);
44
+ });
45
+ });
46
+
47
+ describe("JsonPipe", () => {
48
+ const pipe = new JsonPipe();
49
+
50
+ it("throws TransformError for invalid JSON", () => {
51
+ expect(() => pipe.transform("not json")).toThrow(TransformError);
52
+ });
53
+ });
54
+
55
+ describe("RouteArgumentResolve", () => {
56
+ const resolver = new RouteArgumentResolve();
57
+
58
+ it("passes fieldMap types into JsonPipe so Date fields revive", () => {
59
+ const iso = "2024-03-01T00:00:00.000Z";
60
+ const request = new HttpRequest({
61
+ requestId: "r1",
62
+ method: "GET",
63
+ url: "/",
64
+ params: {},
65
+ query: { payload: JSON.stringify({ birthday: iso }) },
66
+ body: {},
67
+ });
68
+ const args = resolver.resolveArguments(request, [
69
+ {
70
+ argumentIndex: 0,
71
+ argumentType: String,
72
+ argumentLocation: "query",
73
+ mappingKey: "payload",
74
+ pipes: [JsonPipe],
75
+ fieldMap: new Map([
76
+ [
77
+ "birthday",
78
+ {
79
+ fieldKey: "birthday",
80
+ fieldType: Date,
81
+ mappingKey: "birthday",
82
+ fieldLocation: "query" as const,
83
+ pipes: [],
84
+ },
85
+ ],
86
+ ]),
87
+ },
88
+ ]);
89
+ expect((args[0] as { birthday: Date }).birthday).toBeInstanceOf(Date);
90
+ expect((args[0] as { birthday: Date }).birthday.toISOString()).toBe(iso);
91
+ });
92
+ });