@decohere/http 0.0.1-dev.126 → 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 (293) hide show
  1. package/README.md +2 -2
  2. package/package.json +4 -13
  3. package/src/app/app.ts +147 -0
  4. package/src/app/module.ts +19 -0
  5. package/src/auth/ability.ts +56 -0
  6. package/src/auth/auth.config.ts +34 -0
  7. package/src/auth/auth.extensions.ts +115 -0
  8. package/src/auth/auth.types.ts +124 -0
  9. package/src/auth/casl-authorization.service.ts +62 -0
  10. package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
  11. package/src/auth/safe-outbound-url.ts +117 -0
  12. package/src/auth/token-service.ts +192 -0
  13. package/src/builder/builder.ts +118 -0
  14. package/{es6/src/const.js → src/const.ts} +25 -17
  15. package/src/health/health.controller.ts +34 -0
  16. package/src/health/health.extensions.ts +53 -0
  17. package/src/health/readiness.service.ts +33 -0
  18. package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
  19. package/src/hosting/hosted-service-host.ts +102 -0
  20. package/src/hosting/hosted-service.ts +31 -0
  21. package/src/http/http.compression.ts +38 -0
  22. package/src/http/http.extensions.ts +244 -0
  23. package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
  24. package/src/http/http.options.ts +41 -0
  25. package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
  26. package/src/http/http.static.ts +359 -0
  27. package/{es6/src/index.js → src/index.ts} +1 -0
  28. package/src/openapi/index.ts +22 -0
  29. package/src/openapi/openapi.generator.ts +78 -0
  30. package/src/openapi/openapi.types.ts +60 -0
  31. package/src/openapi/request-schema.ts +155 -0
  32. package/src/openapi/request-validator.ts +95 -0
  33. package/src/providers/fastify.provider.ts +454 -0
  34. package/src/routing/anvil.types.ts +150 -0
  35. package/src/routing/decorators/argument.decorator.ts +33 -0
  36. package/src/routing/decorators/auth.decorator.ts +79 -0
  37. package/src/routing/decorators/controller.decorator.ts +9 -0
  38. package/src/routing/decorators/field.decorator.ts +15 -0
  39. package/src/routing/decorators/request.decorator.ts +34 -0
  40. package/src/routing/decorators/response.decorator.ts +18 -0
  41. package/src/routing/decorators/route.decorator.ts +52 -0
  42. package/src/routing/filters/exception-filter-host.ts +90 -0
  43. package/src/routing/filters/exception-filter.ts +29 -0
  44. package/src/routing/middleware/http-access-log.middleware.ts +36 -0
  45. package/src/routing/middleware/middleware.ts +26 -0
  46. package/src/routing/middleware/request-id.ts +18 -0
  47. package/src/routing/path.util.ts +38 -0
  48. package/src/routing/pipes/pipes.ts +132 -0
  49. package/src/routing/services/route-argument-resolver.ts +92 -0
  50. package/src/routing/services/route-registry.ts +229 -0
  51. package/src/routing/services/router.ts +166 -0
  52. package/src/sse/sse.decorator.ts +41 -0
  53. package/src/sse/sse.encoder.ts +86 -0
  54. package/src/sse/sse.types.ts +47 -0
  55. package/src/sse/sse.wire.ts +34 -0
  56. package/src/testing/boot-module.ts +63 -0
  57. package/src/testing/sse-reader.ts +120 -0
  58. package/tests/app.hosted-services.spec.ts +292 -0
  59. package/tests/app.shutdown.integration.spec.ts +160 -0
  60. package/tests/app.spec.ts +132 -0
  61. package/tests/auth.ability.spec.ts +48 -0
  62. package/tests/auth.decorators.spec.ts +20 -0
  63. package/tests/auth.fail-closed.pentest.spec.ts +77 -0
  64. package/tests/auth.integration.spec.ts +234 -0
  65. package/tests/auth.jwks-cache.spec.ts +52 -0
  66. package/tests/auth.missing-auth.pentest.spec.ts +56 -0
  67. package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
  68. package/tests/auth.oidc.integration.spec.ts +161 -0
  69. package/tests/auth.token-service.spec.ts +251 -0
  70. package/tests/builder.spec.ts +12 -0
  71. package/tests/health.integration.spec.ts +111 -0
  72. package/tests/helpers/listen-ephemeral.ts +13 -0
  73. package/tests/helpers/local-oidc-server.ts +100 -0
  74. package/tests/http.access-log.integration.spec.ts +111 -0
  75. package/tests/http.boolean-query.integration.spec.ts +59 -0
  76. package/tests/http.compression.integration.spec.ts +604 -0
  77. package/tests/http.date-query.integration.spec.ts +71 -0
  78. package/tests/http.global-prefix.integration.spec.ts +284 -0
  79. package/tests/http.number-query.integration.spec.ts +65 -0
  80. package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
  81. package/tests/http.pipeline-order.integration.spec.ts +95 -0
  82. package/tests/http.request-cleanup.integration.spec.ts +57 -0
  83. package/tests/http.request-id-scope.pentest.spec.ts +50 -0
  84. package/tests/http.security.integration.spec.ts +106 -0
  85. package/tests/http.static.integration.spec.ts +847 -0
  86. package/tests/http.static.spec.ts +111 -0
  87. package/tests/http.surface.integration.spec.ts +167 -0
  88. package/tests/openapi.integration.spec.ts +193 -0
  89. package/tests/openapi.validator-cache.integration.spec.ts +55 -0
  90. package/tests/routing.decorators.spec.ts +15 -0
  91. package/tests/routing.registry.spec.ts +44 -0
  92. package/tests/routing.resolver.spec.ts +92 -0
  93. package/tests/sse.integration.spec.ts +421 -0
  94. package/tests/sse.wire.spec.ts +33 -0
  95. package/LICENSE +0 -15
  96. package/commonjs/src/app/app.js +0 -166
  97. package/commonjs/src/app/index.js +0 -19
  98. package/commonjs/src/app/module.js +0 -17
  99. package/commonjs/src/auth/ability.js +0 -54
  100. package/commonjs/src/auth/auth.config.js +0 -23
  101. package/commonjs/src/auth/auth.extensions.js +0 -106
  102. package/commonjs/src/auth/auth.types.js +0 -65
  103. package/commonjs/src/auth/casl-authorization.service.js +0 -36
  104. package/commonjs/src/auth/index.js +0 -23
  105. package/commonjs/src/auth/principal-provider.js +0 -19
  106. package/commonjs/src/auth/safe-outbound-url.js +0 -102
  107. package/commonjs/src/auth/token-service.js +0 -151
  108. package/commonjs/src/builder/builder.js +0 -95
  109. package/commonjs/src/builder/index.js +0 -18
  110. package/commonjs/src/const.js +0 -87
  111. package/commonjs/src/health/health.controller.js +0 -75
  112. package/commonjs/src/health/health.extensions.js +0 -50
  113. package/commonjs/src/health/index.js +0 -21
  114. package/commonjs/src/health/readiness.service.js +0 -67
  115. package/commonjs/src/health/readiness.types.js +0 -7
  116. package/commonjs/src/hosting/hosted-service-host.js +0 -88
  117. package/commonjs/src/hosting/hosted-service.js +0 -11
  118. package/commonjs/src/hosting/index.js +0 -19
  119. package/commonjs/src/http/http.compression.js +0 -27
  120. package/commonjs/src/http/http.extensions.js +0 -269
  121. package/commonjs/src/http/http.options.js +0 -26
  122. package/commonjs/src/http/http.options.store.js +0 -20
  123. package/commonjs/src/http/http.security.js +0 -27
  124. package/commonjs/src/http/http.static.js +0 -248
  125. package/commonjs/src/http/index.js +0 -22
  126. package/commonjs/src/index.js +0 -27
  127. package/commonjs/src/openapi/index.js +0 -45
  128. package/commonjs/src/openapi/openapi.generator.js +0 -103
  129. package/commonjs/src/openapi/openapi.types.js +0 -4
  130. package/commonjs/src/openapi/request-schema.js +0 -187
  131. package/commonjs/src/openapi/request-validator.js +0 -85
  132. package/commonjs/src/providers/fastify.provider.js +0 -391
  133. package/commonjs/src/providers/index.js +0 -18
  134. package/commonjs/src/routing/anvil.types.js +0 -99
  135. package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
  136. package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
  137. package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
  138. package/commonjs/src/routing/decorators/field.decorator.js +0 -25
  139. package/commonjs/src/routing/decorators/index.js +0 -24
  140. package/commonjs/src/routing/decorators/request.decorator.js +0 -30
  141. package/commonjs/src/routing/decorators/response.decorator.js +0 -34
  142. package/commonjs/src/routing/decorators/route.decorator.js +0 -67
  143. package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
  144. package/commonjs/src/routing/filters/exception-filter.js +0 -28
  145. package/commonjs/src/routing/index.js +0 -25
  146. package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
  147. package/commonjs/src/routing/middleware/middleware.js +0 -20
  148. package/commonjs/src/routing/middleware/request-id.js +0 -30
  149. package/commonjs/src/routing/path.util.js +0 -51
  150. package/commonjs/src/routing/pipes/index.js +0 -18
  151. package/commonjs/src/routing/pipes/pipes.js +0 -145
  152. package/commonjs/src/routing/services/index.js +0 -18
  153. package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
  154. package/commonjs/src/routing/services/route-registry.js +0 -194
  155. package/commonjs/src/routing/services/router.js +0 -158
  156. package/commonjs/src/sse/index.js +0 -26
  157. package/commonjs/src/sse/sse.decorator.js +0 -42
  158. package/commonjs/src/sse/sse.encoder.js +0 -73
  159. package/commonjs/src/sse/sse.types.js +0 -36
  160. package/commonjs/src/sse/sse.wire.js +0 -40
  161. package/commonjs/src/testing/boot-module.js +0 -38
  162. package/commonjs/src/testing/index.js +0 -20
  163. package/commonjs/src/testing/sse-reader.js +0 -98
  164. package/es6/src/app/app.js +0 -148
  165. package/es6/src/app/index.js +0 -2
  166. package/es6/src/app/module.js +0 -7
  167. package/es6/src/auth/ability.js +0 -45
  168. package/es6/src/auth/auth.config.js +0 -19
  169. package/es6/src/auth/auth.extensions.js +0 -83
  170. package/es6/src/auth/auth.types.js +0 -35
  171. package/es6/src/auth/casl-authorization.service.js +0 -40
  172. package/es6/src/auth/index.js +0 -6
  173. package/es6/src/auth/principal-provider.js +0 -14
  174. package/es6/src/auth/safe-outbound-url.js +0 -99
  175. package/es6/src/auth/token-service.js +0 -141
  176. package/es6/src/builder/builder.js +0 -85
  177. package/es6/src/builder/index.js +0 -1
  178. package/es6/src/health/health.controller.js +0 -65
  179. package/es6/src/health/health.extensions.js +0 -40
  180. package/es6/src/health/index.js +0 -4
  181. package/es6/src/health/readiness.service.js +0 -57
  182. package/es6/src/health/readiness.types.js +0 -4
  183. package/es6/src/hosting/hosted-service-host.js +0 -81
  184. package/es6/src/hosting/hosted-service.js +0 -8
  185. package/es6/src/hosting/index.js +0 -2
  186. package/es6/src/http/http.compression.js +0 -9
  187. package/es6/src/http/http.extensions.js +0 -259
  188. package/es6/src/http/http.options.js +0 -16
  189. package/es6/src/http/http.options.store.js +0 -13
  190. package/es6/src/http/http.security.js +0 -9
  191. package/es6/src/http/http.static.js +0 -224
  192. package/es6/src/http/index.js +0 -5
  193. package/es6/src/openapi/index.js +0 -3
  194. package/es6/src/openapi/openapi.generator.js +0 -82
  195. package/es6/src/openapi/openapi.types.js +0 -1
  196. package/es6/src/openapi/request-schema.js +0 -160
  197. package/es6/src/openapi/request-validator.js +0 -71
  198. package/es6/src/providers/fastify.provider.js +0 -376
  199. package/es6/src/providers/index.js +0 -1
  200. package/es6/src/routing/anvil.types.js +0 -57
  201. package/es6/src/routing/decorators/argument.decorator.js +0 -28
  202. package/es6/src/routing/decorators/auth.decorator.js +0 -58
  203. package/es6/src/routing/decorators/controller.decorator.js +0 -6
  204. package/es6/src/routing/decorators/field.decorator.js +0 -15
  205. package/es6/src/routing/decorators/index.js +0 -7
  206. package/es6/src/routing/decorators/request.decorator.js +0 -24
  207. package/es6/src/routing/decorators/response.decorator.js +0 -16
  208. package/es6/src/routing/decorators/route.decorator.js +0 -37
  209. package/es6/src/routing/filters/exception-filter-host.js +0 -127
  210. package/es6/src/routing/filters/exception-filter.js +0 -10
  211. package/es6/src/routing/index.js +0 -8
  212. package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
  213. package/es6/src/routing/middleware/middleware.js +0 -10
  214. package/es6/src/routing/middleware/request-id.js +0 -14
  215. package/es6/src/routing/path.util.js +0 -33
  216. package/es6/src/routing/pipes/index.js +0 -1
  217. package/es6/src/routing/pipes/pipes.js +0 -118
  218. package/es6/src/routing/services/index.js +0 -1
  219. package/es6/src/routing/services/route-argument-resolver.js +0 -67
  220. package/es6/src/routing/services/route-registry.js +0 -184
  221. package/es6/src/routing/services/router.js +0 -148
  222. package/es6/src/sse/index.js +0 -2
  223. package/es6/src/sse/sse.decorator.js +0 -31
  224. package/es6/src/sse/sse.encoder.js +0 -67
  225. package/es6/src/sse/sse.types.js +0 -22
  226. package/es6/src/sse/sse.wire.js +0 -30
  227. package/es6/src/testing/boot-module.js +0 -35
  228. package/es6/src/testing/index.js +0 -2
  229. package/es6/src/testing/sse-reader.js +0 -91
  230. package/types/src/app/app.d.ts +0 -41
  231. package/types/src/app/module.d.ts +0 -15
  232. package/types/src/auth/ability.d.ts +0 -19
  233. package/types/src/auth/auth.config.d.ts +0 -28
  234. package/types/src/auth/auth.extensions.d.ts +0 -53
  235. package/types/src/auth/auth.types.d.ts +0 -73
  236. package/types/src/auth/casl-authorization.service.d.ts +0 -28
  237. package/types/src/auth/safe-outbound-url.d.ts +0 -9
  238. package/types/src/auth/token-service.d.ts +0 -31
  239. package/types/src/builder/builder.d.ts +0 -36
  240. package/types/src/const.d.ts +0 -32
  241. package/types/src/health/health.controller.d.ts +0 -16
  242. package/types/src/health/health.extensions.d.ts +0 -31
  243. package/types/src/health/readiness.service.d.ts +0 -9
  244. package/types/src/hosting/hosted-service-host.d.ts +0 -37
  245. package/types/src/hosting/hosted-service.d.ts +0 -31
  246. package/types/src/http/http.compression.d.ts +0 -32
  247. package/types/src/http/http.extensions.d.ts +0 -79
  248. package/types/src/http/http.options.d.ts +0 -39
  249. package/types/src/http/http.static.d.ts +0 -160
  250. package/types/src/index.d.ts +0 -10
  251. package/types/src/openapi/index.d.ts +0 -6
  252. package/types/src/openapi/openapi.generator.d.ts +0 -6
  253. package/types/src/openapi/openapi.types.d.ts +0 -58
  254. package/types/src/openapi/request-schema.d.ts +0 -21
  255. package/types/src/openapi/request-validator.d.ts +0 -9
  256. package/types/src/providers/fastify.provider.d.ts +0 -56
  257. package/types/src/routing/anvil.types.d.ts +0 -119
  258. package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
  259. package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
  260. package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
  261. package/types/src/routing/decorators/field.decorator.d.ts +0 -2
  262. package/types/src/routing/decorators/request.decorator.d.ts +0 -8
  263. package/types/src/routing/decorators/response.decorator.d.ts +0 -4
  264. package/types/src/routing/decorators/route.decorator.d.ts +0 -7
  265. package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
  266. package/types/src/routing/filters/exception-filter.d.ts +0 -16
  267. package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
  268. package/types/src/routing/middleware/middleware.d.ts +0 -10
  269. package/types/src/routing/middleware/request-id.d.ts +0 -6
  270. package/types/src/routing/path.util.d.ts +0 -9
  271. package/types/src/routing/pipes/pipes.d.ts +0 -21
  272. package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
  273. package/types/src/routing/services/route-registry.d.ts +0 -20
  274. package/types/src/routing/services/router.d.ts +0 -22
  275. package/types/src/sse/sse.decorator.d.ts +0 -17
  276. package/types/src/sse/sse.encoder.d.ts +0 -16
  277. package/types/src/sse/sse.types.d.ts +0 -37
  278. package/types/src/sse/sse.wire.d.ts +0 -9
  279. package/types/src/testing/boot-module.d.ts +0 -34
  280. package/types/src/testing/sse-reader.d.ts +0 -29
  281. /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
  282. /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
  283. /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
  284. /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
  285. /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
  286. /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
  287. /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
  288. /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
  289. /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
  290. /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
  291. /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
  292. /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
  293. /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
+ });