@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.
- package/README.md +2 -2
- package/package.json +4 -13
- package/src/app/app.ts +147 -0
- package/src/app/module.ts +19 -0
- package/src/auth/ability.ts +56 -0
- package/src/auth/auth.config.ts +34 -0
- package/src/auth/auth.extensions.ts +115 -0
- package/src/auth/auth.types.ts +124 -0
- package/src/auth/casl-authorization.service.ts +62 -0
- package/{types/src/auth/principal-provider.d.ts → src/auth/principal-provider.ts} +13 -5
- package/src/auth/safe-outbound-url.ts +117 -0
- package/src/auth/token-service.ts +192 -0
- package/src/builder/builder.ts +118 -0
- package/{es6/src/const.js → src/const.ts} +25 -17
- package/src/health/health.controller.ts +34 -0
- package/src/health/health.extensions.ts +53 -0
- package/src/health/readiness.service.ts +33 -0
- package/{types/src/health/readiness.types.d.ts → src/health/readiness.types.ts} +4 -8
- package/src/hosting/hosted-service-host.ts +102 -0
- package/src/hosting/hosted-service.ts +31 -0
- package/src/http/http.compression.ts +38 -0
- package/src/http/http.extensions.ts +244 -0
- package/{types/src/http/http.options.store.d.ts → src/http/http.options.store.ts} +11 -1
- package/src/http/http.options.ts +41 -0
- package/{types/src/http/http.security.d.ts → src/http/http.security.ts} +16 -10
- package/src/http/http.static.ts +359 -0
- package/{es6/src/index.js → src/index.ts} +1 -0
- package/src/openapi/index.ts +22 -0
- package/src/openapi/openapi.generator.ts +78 -0
- package/src/openapi/openapi.types.ts +60 -0
- package/src/openapi/request-schema.ts +155 -0
- package/src/openapi/request-validator.ts +95 -0
- package/src/providers/fastify.provider.ts +454 -0
- package/src/routing/anvil.types.ts +150 -0
- package/src/routing/decorators/argument.decorator.ts +33 -0
- package/src/routing/decorators/auth.decorator.ts +79 -0
- package/src/routing/decorators/controller.decorator.ts +9 -0
- package/src/routing/decorators/field.decorator.ts +15 -0
- package/src/routing/decorators/request.decorator.ts +34 -0
- package/src/routing/decorators/response.decorator.ts +18 -0
- package/src/routing/decorators/route.decorator.ts +52 -0
- package/src/routing/filters/exception-filter-host.ts +90 -0
- package/src/routing/filters/exception-filter.ts +29 -0
- package/src/routing/middleware/http-access-log.middleware.ts +36 -0
- package/src/routing/middleware/middleware.ts +26 -0
- package/src/routing/middleware/request-id.ts +18 -0
- package/src/routing/path.util.ts +38 -0
- package/src/routing/pipes/pipes.ts +132 -0
- package/src/routing/services/route-argument-resolver.ts +92 -0
- package/src/routing/services/route-registry.ts +229 -0
- package/src/routing/services/router.ts +166 -0
- package/src/sse/sse.decorator.ts +41 -0
- package/src/sse/sse.encoder.ts +86 -0
- package/src/sse/sse.types.ts +47 -0
- package/src/sse/sse.wire.ts +34 -0
- package/src/testing/boot-module.ts +63 -0
- package/src/testing/sse-reader.ts +120 -0
- package/tests/app.hosted-services.spec.ts +292 -0
- package/tests/app.shutdown.integration.spec.ts +160 -0
- package/tests/app.spec.ts +132 -0
- package/tests/auth.ability.spec.ts +48 -0
- package/tests/auth.decorators.spec.ts +20 -0
- package/tests/auth.fail-closed.pentest.spec.ts +77 -0
- package/tests/auth.integration.spec.ts +234 -0
- package/tests/auth.jwks-cache.spec.ts +52 -0
- package/tests/auth.missing-auth.pentest.spec.ts +56 -0
- package/tests/auth.oidc-ssrf.pentest.spec.ts +72 -0
- package/tests/auth.oidc.integration.spec.ts +161 -0
- package/tests/auth.token-service.spec.ts +251 -0
- package/tests/builder.spec.ts +12 -0
- package/tests/health.integration.spec.ts +111 -0
- package/tests/helpers/listen-ephemeral.ts +13 -0
- package/tests/helpers/local-oidc-server.ts +100 -0
- package/tests/http.access-log.integration.spec.ts +111 -0
- package/tests/http.boolean-query.integration.spec.ts +59 -0
- package/tests/http.compression.integration.spec.ts +604 -0
- package/tests/http.date-query.integration.spec.ts +71 -0
- package/tests/http.global-prefix.integration.spec.ts +284 -0
- package/tests/http.number-query.integration.spec.ts +65 -0
- package/tests/http.pino-roll-hmr.integration.spec.ts +79 -0
- package/tests/http.pipeline-order.integration.spec.ts +95 -0
- package/tests/http.request-cleanup.integration.spec.ts +57 -0
- package/tests/http.request-id-scope.pentest.spec.ts +50 -0
- package/tests/http.security.integration.spec.ts +106 -0
- package/tests/http.static.integration.spec.ts +847 -0
- package/tests/http.static.spec.ts +111 -0
- package/tests/http.surface.integration.spec.ts +167 -0
- package/tests/openapi.integration.spec.ts +193 -0
- package/tests/openapi.validator-cache.integration.spec.ts +55 -0
- package/tests/routing.decorators.spec.ts +15 -0
- package/tests/routing.registry.spec.ts +44 -0
- package/tests/routing.resolver.spec.ts +92 -0
- package/tests/sse.integration.spec.ts +421 -0
- package/tests/sse.wire.spec.ts +33 -0
- package/LICENSE +0 -15
- package/commonjs/src/app/app.js +0 -166
- package/commonjs/src/app/index.js +0 -19
- package/commonjs/src/app/module.js +0 -17
- package/commonjs/src/auth/ability.js +0 -54
- package/commonjs/src/auth/auth.config.js +0 -23
- package/commonjs/src/auth/auth.extensions.js +0 -106
- package/commonjs/src/auth/auth.types.js +0 -65
- package/commonjs/src/auth/casl-authorization.service.js +0 -36
- package/commonjs/src/auth/index.js +0 -23
- package/commonjs/src/auth/principal-provider.js +0 -19
- package/commonjs/src/auth/safe-outbound-url.js +0 -102
- package/commonjs/src/auth/token-service.js +0 -151
- package/commonjs/src/builder/builder.js +0 -95
- package/commonjs/src/builder/index.js +0 -18
- package/commonjs/src/const.js +0 -87
- package/commonjs/src/health/health.controller.js +0 -75
- package/commonjs/src/health/health.extensions.js +0 -50
- package/commonjs/src/health/index.js +0 -21
- package/commonjs/src/health/readiness.service.js +0 -67
- package/commonjs/src/health/readiness.types.js +0 -7
- package/commonjs/src/hosting/hosted-service-host.js +0 -88
- package/commonjs/src/hosting/hosted-service.js +0 -11
- package/commonjs/src/hosting/index.js +0 -19
- package/commonjs/src/http/http.compression.js +0 -27
- package/commonjs/src/http/http.extensions.js +0 -269
- package/commonjs/src/http/http.options.js +0 -26
- package/commonjs/src/http/http.options.store.js +0 -20
- package/commonjs/src/http/http.security.js +0 -27
- package/commonjs/src/http/http.static.js +0 -248
- package/commonjs/src/http/index.js +0 -22
- package/commonjs/src/index.js +0 -27
- package/commonjs/src/openapi/index.js +0 -45
- package/commonjs/src/openapi/openapi.generator.js +0 -103
- package/commonjs/src/openapi/openapi.types.js +0 -4
- package/commonjs/src/openapi/request-schema.js +0 -187
- package/commonjs/src/openapi/request-validator.js +0 -85
- package/commonjs/src/providers/fastify.provider.js +0 -391
- package/commonjs/src/providers/index.js +0 -18
- package/commonjs/src/routing/anvil.types.js +0 -99
- package/commonjs/src/routing/decorators/argument.decorator.js +0 -49
- package/commonjs/src/routing/decorators/auth.decorator.js +0 -70
- package/commonjs/src/routing/decorators/controller.decorator.js +0 -16
- package/commonjs/src/routing/decorators/field.decorator.js +0 -25
- package/commonjs/src/routing/decorators/index.js +0 -24
- package/commonjs/src/routing/decorators/request.decorator.js +0 -30
- package/commonjs/src/routing/decorators/response.decorator.js +0 -34
- package/commonjs/src/routing/decorators/route.decorator.js +0 -67
- package/commonjs/src/routing/filters/exception-filter-host.js +0 -145
- package/commonjs/src/routing/filters/exception-filter.js +0 -28
- package/commonjs/src/routing/index.js +0 -25
- package/commonjs/src/routing/middleware/http-access-log.middleware.js +0 -47
- package/commonjs/src/routing/middleware/middleware.js +0 -20
- package/commonjs/src/routing/middleware/request-id.js +0 -30
- package/commonjs/src/routing/path.util.js +0 -51
- package/commonjs/src/routing/pipes/index.js +0 -18
- package/commonjs/src/routing/pipes/pipes.js +0 -145
- package/commonjs/src/routing/services/index.js +0 -18
- package/commonjs/src/routing/services/route-argument-resolver.js +0 -77
- package/commonjs/src/routing/services/route-registry.js +0 -194
- package/commonjs/src/routing/services/router.js +0 -158
- package/commonjs/src/sse/index.js +0 -26
- package/commonjs/src/sse/sse.decorator.js +0 -42
- package/commonjs/src/sse/sse.encoder.js +0 -73
- package/commonjs/src/sse/sse.types.js +0 -36
- package/commonjs/src/sse/sse.wire.js +0 -40
- package/commonjs/src/testing/boot-module.js +0 -38
- package/commonjs/src/testing/index.js +0 -20
- package/commonjs/src/testing/sse-reader.js +0 -98
- package/es6/src/app/app.js +0 -148
- package/es6/src/app/index.js +0 -2
- package/es6/src/app/module.js +0 -7
- package/es6/src/auth/ability.js +0 -45
- package/es6/src/auth/auth.config.js +0 -19
- package/es6/src/auth/auth.extensions.js +0 -83
- package/es6/src/auth/auth.types.js +0 -35
- package/es6/src/auth/casl-authorization.service.js +0 -40
- package/es6/src/auth/index.js +0 -6
- package/es6/src/auth/principal-provider.js +0 -14
- package/es6/src/auth/safe-outbound-url.js +0 -99
- package/es6/src/auth/token-service.js +0 -141
- package/es6/src/builder/builder.js +0 -85
- package/es6/src/builder/index.js +0 -1
- package/es6/src/health/health.controller.js +0 -65
- package/es6/src/health/health.extensions.js +0 -40
- package/es6/src/health/index.js +0 -4
- package/es6/src/health/readiness.service.js +0 -57
- package/es6/src/health/readiness.types.js +0 -4
- package/es6/src/hosting/hosted-service-host.js +0 -81
- package/es6/src/hosting/hosted-service.js +0 -8
- package/es6/src/hosting/index.js +0 -2
- package/es6/src/http/http.compression.js +0 -9
- package/es6/src/http/http.extensions.js +0 -259
- package/es6/src/http/http.options.js +0 -16
- package/es6/src/http/http.options.store.js +0 -13
- package/es6/src/http/http.security.js +0 -9
- package/es6/src/http/http.static.js +0 -224
- package/es6/src/http/index.js +0 -5
- package/es6/src/openapi/index.js +0 -3
- package/es6/src/openapi/openapi.generator.js +0 -82
- package/es6/src/openapi/openapi.types.js +0 -1
- package/es6/src/openapi/request-schema.js +0 -160
- package/es6/src/openapi/request-validator.js +0 -71
- package/es6/src/providers/fastify.provider.js +0 -376
- package/es6/src/providers/index.js +0 -1
- package/es6/src/routing/anvil.types.js +0 -57
- package/es6/src/routing/decorators/argument.decorator.js +0 -28
- package/es6/src/routing/decorators/auth.decorator.js +0 -58
- package/es6/src/routing/decorators/controller.decorator.js +0 -6
- package/es6/src/routing/decorators/field.decorator.js +0 -15
- package/es6/src/routing/decorators/index.js +0 -7
- package/es6/src/routing/decorators/request.decorator.js +0 -24
- package/es6/src/routing/decorators/response.decorator.js +0 -16
- package/es6/src/routing/decorators/route.decorator.js +0 -37
- package/es6/src/routing/filters/exception-filter-host.js +0 -127
- package/es6/src/routing/filters/exception-filter.js +0 -10
- package/es6/src/routing/index.js +0 -8
- package/es6/src/routing/middleware/http-access-log.middleware.js +0 -37
- package/es6/src/routing/middleware/middleware.js +0 -10
- package/es6/src/routing/middleware/request-id.js +0 -14
- package/es6/src/routing/path.util.js +0 -33
- package/es6/src/routing/pipes/index.js +0 -1
- package/es6/src/routing/pipes/pipes.js +0 -118
- package/es6/src/routing/services/index.js +0 -1
- package/es6/src/routing/services/route-argument-resolver.js +0 -67
- package/es6/src/routing/services/route-registry.js +0 -184
- package/es6/src/routing/services/router.js +0 -148
- package/es6/src/sse/index.js +0 -2
- package/es6/src/sse/sse.decorator.js +0 -31
- package/es6/src/sse/sse.encoder.js +0 -67
- package/es6/src/sse/sse.types.js +0 -22
- package/es6/src/sse/sse.wire.js +0 -30
- package/es6/src/testing/boot-module.js +0 -35
- package/es6/src/testing/index.js +0 -2
- package/es6/src/testing/sse-reader.js +0 -91
- package/types/src/app/app.d.ts +0 -41
- package/types/src/app/module.d.ts +0 -15
- package/types/src/auth/ability.d.ts +0 -19
- package/types/src/auth/auth.config.d.ts +0 -28
- package/types/src/auth/auth.extensions.d.ts +0 -53
- package/types/src/auth/auth.types.d.ts +0 -73
- package/types/src/auth/casl-authorization.service.d.ts +0 -28
- package/types/src/auth/safe-outbound-url.d.ts +0 -9
- package/types/src/auth/token-service.d.ts +0 -31
- package/types/src/builder/builder.d.ts +0 -36
- package/types/src/const.d.ts +0 -32
- package/types/src/health/health.controller.d.ts +0 -16
- package/types/src/health/health.extensions.d.ts +0 -31
- package/types/src/health/readiness.service.d.ts +0 -9
- package/types/src/hosting/hosted-service-host.d.ts +0 -37
- package/types/src/hosting/hosted-service.d.ts +0 -31
- package/types/src/http/http.compression.d.ts +0 -32
- package/types/src/http/http.extensions.d.ts +0 -79
- package/types/src/http/http.options.d.ts +0 -39
- package/types/src/http/http.static.d.ts +0 -160
- package/types/src/index.d.ts +0 -10
- package/types/src/openapi/index.d.ts +0 -6
- package/types/src/openapi/openapi.generator.d.ts +0 -6
- package/types/src/openapi/openapi.types.d.ts +0 -58
- package/types/src/openapi/request-schema.d.ts +0 -21
- package/types/src/openapi/request-validator.d.ts +0 -9
- package/types/src/providers/fastify.provider.d.ts +0 -56
- package/types/src/routing/anvil.types.d.ts +0 -119
- package/types/src/routing/decorators/argument.decorator.d.ts +0 -11
- package/types/src/routing/decorators/auth.decorator.d.ts +0 -30
- package/types/src/routing/decorators/controller.decorator.d.ts +0 -3
- package/types/src/routing/decorators/field.decorator.d.ts +0 -2
- package/types/src/routing/decorators/request.decorator.d.ts +0 -8
- package/types/src/routing/decorators/response.decorator.d.ts +0 -4
- package/types/src/routing/decorators/route.decorator.d.ts +0 -7
- package/types/src/routing/filters/exception-filter-host.d.ts +0 -19
- package/types/src/routing/filters/exception-filter.d.ts +0 -16
- package/types/src/routing/middleware/http-access-log.middleware.d.ts +0 -8
- package/types/src/routing/middleware/middleware.d.ts +0 -10
- package/types/src/routing/middleware/request-id.d.ts +0 -6
- package/types/src/routing/path.util.d.ts +0 -9
- package/types/src/routing/pipes/pipes.d.ts +0 -21
- package/types/src/routing/services/route-argument-resolver.d.ts +0 -10
- package/types/src/routing/services/route-registry.d.ts +0 -20
- package/types/src/routing/services/router.d.ts +0 -22
- package/types/src/sse/sse.decorator.d.ts +0 -17
- package/types/src/sse/sse.encoder.d.ts +0 -16
- package/types/src/sse/sse.types.d.ts +0 -37
- package/types/src/sse/sse.wire.d.ts +0 -9
- package/types/src/testing/boot-module.d.ts +0 -34
- package/types/src/testing/sse-reader.d.ts +0 -29
- /package/{types/src/app/index.d.ts → src/app/index.ts} +0 -0
- /package/{types/src/auth/index.d.ts → src/auth/index.ts} +0 -0
- /package/{types/src/builder/index.d.ts → src/builder/index.ts} +0 -0
- /package/{types/src/health/index.d.ts → src/health/index.ts} +0 -0
- /package/{types/src/hosting/index.d.ts → src/hosting/index.ts} +0 -0
- /package/{types/src/http/index.d.ts → src/http/index.ts} +0 -0
- /package/{types/src/providers/index.d.ts → src/providers/index.ts} +0 -0
- /package/{types/src/routing/decorators/index.d.ts → src/routing/decorators/index.ts} +0 -0
- /package/{types/src/routing/index.d.ts → src/routing/index.ts} +0 -0
- /package/{types/src/routing/pipes/index.d.ts → src/routing/pipes/index.ts} +0 -0
- /package/{types/src/routing/services/index.d.ts → src/routing/services/index.ts} +0 -0
- /package/{types/src/sse/index.d.ts → src/sse/index.ts} +0 -0
- /package/{types/src/testing/index.d.ts → src/testing/index.ts} +0 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import picomatch from "picomatch";
|
|
3
|
+
import { isDevelopmentNodeEnv } from "../auth/auth.types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Serving a module's built UI from its API (`builder.withStatic`). Everything here is
|
|
7
|
+
* provider-neutral: the options, the caching rules and the missing-directory policy.
|
|
8
|
+
* Only the HTTP provider knows how to put files on the wire. See ADR 0007.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Cache header for files whose name carries a content hash. The name changes when the content does. */
|
|
12
|
+
export const STATIC_IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
|
13
|
+
|
|
14
|
+
/** Cache header for files that must be revalidated on every use (the entry points of a UI). */
|
|
15
|
+
export const STATIC_NO_CACHE_CONTROL = "no-cache";
|
|
16
|
+
|
|
17
|
+
/** Cache header for every other file: cacheable, but always revalidated (ETag / Last-Modified). */
|
|
18
|
+
export const STATIC_DEFAULT_CACHE_CONTROL = "public, max-age=0, must-revalidate";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The entry points of a UI. `index.html` names the hashed bundles, and a module-federation host
|
|
22
|
+
* loads `remoteEntry.js` by a fixed name to find the current remote, so neither can be cached.
|
|
23
|
+
*/
|
|
24
|
+
export const STATIC_DEFAULT_NO_CACHE: readonly string[] = ["index.html", "remoteEntry.js"];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A build tool's hashed file name: `index-DiwrgTda.js`, `chunk.3f9a8c1b.css`, `app-abc123de.js.map`.
|
|
28
|
+
* The hash is 8 or more letters, digits or `_` after a `-` or `.`, and holds a digit or a lowercase
|
|
29
|
+
* letter followed by an uppercase one. Plain words (`logo-background.png`) are not hashes.
|
|
30
|
+
*/
|
|
31
|
+
export const STATIC_DEFAULT_IMMUTABLE =
|
|
32
|
+
/[.-](?=[A-Za-z0-9_]*(?:\d|[a-z][A-Z]))[A-Za-z0-9_]{8,}\.[A-Za-z0-9]+(?:\.map)?$/;
|
|
33
|
+
|
|
34
|
+
export type StaticOptions = {
|
|
35
|
+
/**
|
|
36
|
+
* Serve `index.html` for an unknown path under the prefix, so a client-side route survives a
|
|
37
|
+
* reload. Only for GET and HEAD, and never for a path that looks like a file (has an
|
|
38
|
+
* extension), so a missing asset stays a 404. Defaults to `true`.
|
|
39
|
+
*/
|
|
40
|
+
spaFallback?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Path patterns this mount never answers, neither a file nor the fallback: the request gets the
|
|
43
|
+
* normal JSON 404. Globs on the full request path (not relative to the prefix), for example
|
|
44
|
+
* `["/api/**", "/docs/**"]`: `*` is any run of characters inside one segment, `**` crosses
|
|
45
|
+
* segments, `?` is one character. `/api/**` also covers `/api` itself. Rarely needed: a path
|
|
46
|
+
* that starts with the first segment of a registered controller route is already never
|
|
47
|
+
* answered with the fallback (see `staticFiles.isUnderRoute`).
|
|
48
|
+
*/
|
|
49
|
+
exclude?: string[];
|
|
50
|
+
/** The file served for the prefix itself and for the fallback. Defaults to `index.html`. */
|
|
51
|
+
index?: string;
|
|
52
|
+
/**
|
|
53
|
+
* Files sent with `Cache-Control: no-cache`, by file name (`index.html`) or by path relative to
|
|
54
|
+
* the directory (`remote/remoteEntry.js`). Replaces the default `["index.html", "remoteEntry.js"]`,
|
|
55
|
+
* so list `index.html` again if you override it.
|
|
56
|
+
*/
|
|
57
|
+
noCache?: string[];
|
|
58
|
+
/**
|
|
59
|
+
* Files whose name holds a content hash get `public, max-age=31536000, immutable`. Replace the
|
|
60
|
+
* pattern if your build names files differently (it is tested against the path relative to the
|
|
61
|
+
* directory), or pass `false` to give no file the immutable header.
|
|
62
|
+
*/
|
|
63
|
+
immutable?: RegExp | false;
|
|
64
|
+
/** `Cache-Control` for every other file. Defaults to `public, max-age=0, must-revalidate`. */
|
|
65
|
+
cacheControl?: string;
|
|
66
|
+
/**
|
|
67
|
+
* What to do when the directory does not exist at start. `"fail"` stops the app with an error;
|
|
68
|
+
* `"warn"` logs a warning and serves nothing, so the API still boots before the UI is built.
|
|
69
|
+
* Defaults to `"warn"` when NODE_ENV is `development` or `dev`, and to `"fail"` otherwise.
|
|
70
|
+
*/
|
|
71
|
+
onMissingDirectory?: "fail" | "warn";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A `withStatic` call, as the builder keeps it. */
|
|
75
|
+
export type StaticMount = {
|
|
76
|
+
/** Normalised: starts and ends with `/`, for example `/ui/`. */
|
|
77
|
+
prefix: string;
|
|
78
|
+
/** As given by the caller, resolved from the process working directory when the app starts. */
|
|
79
|
+
directory: string;
|
|
80
|
+
options: StaticOptions;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** A mount the provider can register: the directory exists and every option has its value. */
|
|
84
|
+
export type ResolvedStaticMount = {
|
|
85
|
+
prefix: string;
|
|
86
|
+
/** Absolute path of the directory. */
|
|
87
|
+
root: string;
|
|
88
|
+
spaFallback: boolean;
|
|
89
|
+
index: string;
|
|
90
|
+
noCache: readonly string[];
|
|
91
|
+
immutable: RegExp | false;
|
|
92
|
+
cacheControl: string;
|
|
93
|
+
exclude: readonly string[];
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** `"ui"`, `"/ui"` and `"/ui/"` all mean `/ui/`; `"/"` is the root. Refuses an empty prefix. */
|
|
97
|
+
function normalizePrefix(prefix: string): string {
|
|
98
|
+
const segments = prefix.split("/").filter((segment) => segment !== "");
|
|
99
|
+
if (segments.length === 0) {
|
|
100
|
+
if (prefix.trim() === "") {
|
|
101
|
+
throw new Error('withStatic needs a prefix, such as "/ui/" or "/" for the root.');
|
|
102
|
+
}
|
|
103
|
+
return "/";
|
|
104
|
+
}
|
|
105
|
+
if (
|
|
106
|
+
segments.some((segment) => segment === "." || segment === ".." || /[*:?]/.test(segment))
|
|
107
|
+
) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`withStatic prefix "${prefix}" must be plain path segments, such as "/ui/".`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return `/${segments.join("/")}/`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toMount(prefix: string, directory: string, options: StaticOptions = {}): StaticMount {
|
|
116
|
+
if (directory.trim() === "") {
|
|
117
|
+
throw new Error(`withStatic("${prefix}") needs the directory that holds the built files.`);
|
|
118
|
+
}
|
|
119
|
+
return { prefix: normalizePrefix(prefix), directory, options };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
type DirectoryProbe = (absolutePath: string) => Promise<boolean>;
|
|
123
|
+
|
|
124
|
+
export type ResolveStaticOptions = {
|
|
125
|
+
/** Where relative directories are resolved from. Defaults to the process working directory. */
|
|
126
|
+
cwd?: string;
|
|
127
|
+
nodeEnv?: string;
|
|
128
|
+
directoryExists: DirectoryProbe;
|
|
129
|
+
warn: (message: string) => void;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Checks every mount at start. Returns the ones that can be served; a missing directory fails or
|
|
134
|
+
* warns according to the mount's `onMissingDirectory`. Two mounts on one prefix always fail.
|
|
135
|
+
*/
|
|
136
|
+
async function resolve(
|
|
137
|
+
mounts: readonly StaticMount[],
|
|
138
|
+
env: ResolveStaticOptions,
|
|
139
|
+
): Promise<ResolvedStaticMount[]> {
|
|
140
|
+
const cwd = env.cwd ?? process.cwd();
|
|
141
|
+
const resolved: ResolvedStaticMount[] = [];
|
|
142
|
+
const seen = new Set<string>();
|
|
143
|
+
for (const mount of mounts) {
|
|
144
|
+
if (seen.has(mount.prefix)) {
|
|
145
|
+
throw new Error(`withStatic: ${mount.prefix} is mounted twice.`);
|
|
146
|
+
}
|
|
147
|
+
seen.add(mount.prefix);
|
|
148
|
+
|
|
149
|
+
const root = path.resolve(cwd, mount.directory);
|
|
150
|
+
if (!(await env.directoryExists(root))) {
|
|
151
|
+
const policy =
|
|
152
|
+
mount.options.onMissingDirectory ??
|
|
153
|
+
(isDevelopmentNodeEnv(env.nodeEnv) ? "warn" : "fail");
|
|
154
|
+
const message =
|
|
155
|
+
`withStatic("${mount.prefix}", "${mount.directory}"): ${root} does not exist. ` +
|
|
156
|
+
"Build the UI first, or run the app from the folder that holds it.";
|
|
157
|
+
if (policy === "fail") {
|
|
158
|
+
throw new Error(message);
|
|
159
|
+
}
|
|
160
|
+
env.warn(
|
|
161
|
+
`${message} ${mount.prefix} will answer 404 until it is built and the app restarts.`,
|
|
162
|
+
);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const options = mount.options;
|
|
167
|
+
resolved.push({
|
|
168
|
+
prefix: mount.prefix,
|
|
169
|
+
root,
|
|
170
|
+
spaFallback: options.spaFallback ?? true,
|
|
171
|
+
index: options.index ?? "index.html",
|
|
172
|
+
noCache: options.noCache ?? STATIC_DEFAULT_NO_CACHE,
|
|
173
|
+
immutable: options.immutable ?? STATIC_DEFAULT_IMMUTABLE,
|
|
174
|
+
cacheControl: options.cacheControl ?? STATIC_DEFAULT_CACHE_CONTROL,
|
|
175
|
+
exclude: options.exclude ?? [],
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
return resolved;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** `Cache-Control` for a file, given its path relative to the mount's directory. */
|
|
182
|
+
function cacheControlFor(mount: ResolvedStaticMount, relativePath: string): string {
|
|
183
|
+
const posix = relativePath.split(path.sep).join("/");
|
|
184
|
+
const name = path.posix.basename(posix);
|
|
185
|
+
if (mount.noCache.some((entry) => entry === name || entry === posix)) {
|
|
186
|
+
return STATIC_NO_CACHE_CONTROL;
|
|
187
|
+
}
|
|
188
|
+
if (mount.immutable !== false && mount.immutable.test(posix)) {
|
|
189
|
+
return STATIC_IMMUTABLE_CACHE_CONTROL;
|
|
190
|
+
}
|
|
191
|
+
return mount.cacheControl;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Does this path look like a file: an extension on the last segment, or a dotfile anywhere?
|
|
196
|
+
* Then a miss is a real 404, not a client-side route.
|
|
197
|
+
*/
|
|
198
|
+
function looksLikeFile(pathname: string): boolean {
|
|
199
|
+
const segments = pathname.split("/").filter((segment) => segment !== "");
|
|
200
|
+
if (segments.some((segment) => segment.startsWith("."))) {
|
|
201
|
+
return true;
|
|
202
|
+
}
|
|
203
|
+
const last = pathname.endsWith("/") ? undefined : segments.at(-1);
|
|
204
|
+
return last?.includes(".") === true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The request path as the mount sees it: no query, decoded, repeated slashes collapsed. */
|
|
208
|
+
function normalizePathname(url: string): string {
|
|
209
|
+
const raw = url.split("?")[0] ?? "";
|
|
210
|
+
let decoded = raw;
|
|
211
|
+
try {
|
|
212
|
+
decoded = decodeURIComponent(raw);
|
|
213
|
+
} catch {
|
|
214
|
+
// Keep the raw text: a malformed escape is matched as written.
|
|
215
|
+
}
|
|
216
|
+
return decoded.replace(/\/{2,}/g, "/");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Builds the test for a mount's `exclude` patterns. Matches on the normalised path. */
|
|
220
|
+
function excluder(patterns: readonly string[]): (pathname: string) => boolean {
|
|
221
|
+
if (patterns.length === 0) {
|
|
222
|
+
return () => false;
|
|
223
|
+
}
|
|
224
|
+
const matches = picomatch([...patterns], { dot: true });
|
|
225
|
+
return (pathname) => matches(pathname);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The first path segment of every registered route, for {@link isUnderRoute}. A segment that is a
|
|
230
|
+
* parameter or wildcard (`:id`, `*`) says nothing about the path and is skipped.
|
|
231
|
+
*/
|
|
232
|
+
function routeSegments(routePaths: Iterable<string>): Set<string> {
|
|
233
|
+
const segments = new Set<string>();
|
|
234
|
+
for (const routePath of routePaths) {
|
|
235
|
+
const first = routePath.split("/").find((segment) => segment !== "");
|
|
236
|
+
if (first != null && !/[:*]/.test(first)) {
|
|
237
|
+
segments.add(first);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return segments;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Is this path under one of the app's own routes? True when its first segment is the first
|
|
245
|
+
* segment of a registered route: with a controller route `/lp/portfolio`, `/lp/portfolo` and
|
|
246
|
+
* `/lp/anything/else` belong to the API and keep the JSON 404, while `/lpx` does not.
|
|
247
|
+
*/
|
|
248
|
+
function isUnderRoute(pathname: string, segments: ReadonlySet<string>): boolean {
|
|
249
|
+
const first = pathname.split("/").find((segment) => segment !== "");
|
|
250
|
+
return first != null && segments.has(first);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Is `pathname` the path `base` or below it? `/api` holds `/api` and `/api/x`, not `/apiary`. */
|
|
254
|
+
function isWithin(pathname: string, base: string): boolean {
|
|
255
|
+
const trimmed = base.replace(/\/+$/, "");
|
|
256
|
+
return pathname === trimmed || pathname.startsWith(`${trimmed}/`);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The literal start of a route path, up to its first parameter or wildcard: `/webhooks/:id` gives
|
|
261
|
+
* `/webhooks`, `/:id` gives `""`.
|
|
262
|
+
*/
|
|
263
|
+
function staticStem(routePath: string): string {
|
|
264
|
+
const stem: string[] = [];
|
|
265
|
+
for (const segment of routePath.split("/")) {
|
|
266
|
+
if (segment.startsWith(":") || segment.includes("*")) {
|
|
267
|
+
break;
|
|
268
|
+
}
|
|
269
|
+
if (segment !== "") {
|
|
270
|
+
stem.push(segment);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return stem.length === 0 ? "" : `/${stem.join("/")}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* What the fallback treats as the API, as a test on the normalised path. With a global prefix it
|
|
278
|
+
* is the prefix, the OpenAPI path and every route left outside the prefix
|
|
279
|
+
* (`@Controller({ globalPrefix: false })`, such as `/health`), each with the paths below it, so a
|
|
280
|
+
* client route may share a first segment with a controller. Without a prefix it is
|
|
281
|
+
* {@link isUnderRoute} (the first segment of every registered route), the safe guess when nothing
|
|
282
|
+
* says where the API lives.
|
|
283
|
+
*/
|
|
284
|
+
function apiBoundary(boundary: {
|
|
285
|
+
/** Normalised global prefix (`/api`), or `""` when there is none. */
|
|
286
|
+
globalPrefix: string;
|
|
287
|
+
openApiPath?: string;
|
|
288
|
+
/** Every registered route path, global prefix included. */
|
|
289
|
+
routePaths: Iterable<string>;
|
|
290
|
+
}): (pathname: string) => boolean {
|
|
291
|
+
if (boundary.globalPrefix === "") {
|
|
292
|
+
const segments = routeSegments([
|
|
293
|
+
...boundary.routePaths,
|
|
294
|
+
...(boundary.openApiPath == null ? [] : [boundary.openApiPath]),
|
|
295
|
+
]);
|
|
296
|
+
return (pathname) => isUnderRoute(pathname, segments);
|
|
297
|
+
}
|
|
298
|
+
const outside = [...boundary.routePaths]
|
|
299
|
+
.filter((routePath) => !isWithin(routePath, boundary.globalPrefix))
|
|
300
|
+
.map(staticStem)
|
|
301
|
+
.filter((stem) => stem !== "");
|
|
302
|
+
const bases = [boundary.globalPrefix, boundary.openApiPath, ...outside].filter(
|
|
303
|
+
(base): base is string => base != null,
|
|
304
|
+
);
|
|
305
|
+
return (pathname) => bases.some((base) => isWithin(pathname, base));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Does the `Accept` header ask for HTML (a browser navigation)? `text/html;q=0` does not. */
|
|
309
|
+
function acceptsHtml(accept: string | undefined): boolean {
|
|
310
|
+
if (accept == null) {
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
return accept.split(",").some((entry) => {
|
|
314
|
+
const [type = "", ...params] = entry.split(";").map((part) => part.trim().toLowerCase());
|
|
315
|
+
if (type !== "text/html") {
|
|
316
|
+
return false;
|
|
317
|
+
}
|
|
318
|
+
const quality = params.find((param) => param.startsWith("q="));
|
|
319
|
+
return quality == null || Number.parseFloat(quality.slice(2)) > 0;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export type FallbackRequest = {
|
|
324
|
+
method: string;
|
|
325
|
+
/** Normalised, see `normalizePathname`. */
|
|
326
|
+
pathname: string;
|
|
327
|
+
accept: string | undefined;
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Should an unknown path get the app's `index.html`? Only a browser navigation: GET or HEAD, no
|
|
332
|
+
* file extension and no dotfile in the path, an `Accept` that includes `text/html`, and not under
|
|
333
|
+
* the API (see {@link apiBoundary}). Everything else keeps the normal JSON 404.
|
|
334
|
+
*/
|
|
335
|
+
function shouldFallBack(
|
|
336
|
+
request: FallbackRequest,
|
|
337
|
+
belongsToApi: (pathname: string) => boolean,
|
|
338
|
+
): boolean {
|
|
339
|
+
return (
|
|
340
|
+
(request.method === "GET" || request.method === "HEAD") &&
|
|
341
|
+
acceptsHtml(request.accept) &&
|
|
342
|
+
!looksLikeFile(request.pathname) &&
|
|
343
|
+
!belongsToApi(request.pathname)
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export const staticFiles = {
|
|
348
|
+
toMount,
|
|
349
|
+
resolve,
|
|
350
|
+
cacheControlFor,
|
|
351
|
+
looksLikeFile,
|
|
352
|
+
normalizePathname,
|
|
353
|
+
excluder,
|
|
354
|
+
routeSegments,
|
|
355
|
+
isUnderRoute,
|
|
356
|
+
apiBoundary,
|
|
357
|
+
acceptsHtml,
|
|
358
|
+
shouldFallBack,
|
|
359
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type { ValidationIssue } from "../routing/anvil.types.js";
|
|
2
|
+
export {
|
|
3
|
+
generateOpenApiDocument,
|
|
4
|
+
resolveOpenApiPath,
|
|
5
|
+
toOpenApiPath,
|
|
6
|
+
} from "./openapi.generator.js";
|
|
7
|
+
export type {
|
|
8
|
+
JsonSchema,
|
|
9
|
+
OpenApiDocument,
|
|
10
|
+
OpenApiOperation,
|
|
11
|
+
OpenApiOptions,
|
|
12
|
+
OpenApiParameter,
|
|
13
|
+
} from "./openapi.types.js";
|
|
14
|
+
export type { RouteRequestSchemas } from "./request-schema.js";
|
|
15
|
+
export {
|
|
16
|
+
buildRouteRequestSchemas,
|
|
17
|
+
mergeObjectSchemas,
|
|
18
|
+
objectArgToParts,
|
|
19
|
+
pathParamsToSchema,
|
|
20
|
+
typeToSchema,
|
|
21
|
+
} from "./request-schema.js";
|
|
22
|
+
export { ValidationError, validateRequest } from "./request-validator.js";
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MappedRoute } from "../routing/anvil.types.js";
|
|
2
|
+
import type { RouteRegistry } from "../routing/services/route-registry.js";
|
|
3
|
+
import type { OpenApiDocument, OpenApiOperation, OpenApiOptions } from "./openapi.types.js";
|
|
4
|
+
import { buildRouteRequestSchemas } from "./request-schema.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_SPEC_PATH = "/openapi.json";
|
|
7
|
+
|
|
8
|
+
export function resolveOpenApiPath(options: OpenApiOptions): string {
|
|
9
|
+
const path = options.path?.trim() || DEFAULT_SPEC_PATH;
|
|
10
|
+
return path.startsWith("/") ? path : `/${path}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Convert Fastify-style `:id` path params to OpenAPI `{id}`. */
|
|
14
|
+
export function toOpenApiPath(fastifyPath: string): string {
|
|
15
|
+
return fastifyPath.replace(/:([A-Za-z_][\w]*)/g, "{$1}");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateOpenApiDocument(
|
|
19
|
+
registry: RouteRegistry,
|
|
20
|
+
options: OpenApiOptions,
|
|
21
|
+
): OpenApiDocument {
|
|
22
|
+
const paths: OpenApiDocument["paths"] = {};
|
|
23
|
+
|
|
24
|
+
for (const route of registry.routes) {
|
|
25
|
+
const path = toOpenApiPath(route.path);
|
|
26
|
+
const method = route.method.toLowerCase();
|
|
27
|
+
let pathItem = paths[path];
|
|
28
|
+
if (pathItem == null) {
|
|
29
|
+
pathItem = {};
|
|
30
|
+
paths[path] = pathItem;
|
|
31
|
+
}
|
|
32
|
+
pathItem[method] = buildOperation(route);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const document: OpenApiDocument = {
|
|
36
|
+
openapi: "3.0.3",
|
|
37
|
+
info: options.info,
|
|
38
|
+
paths,
|
|
39
|
+
};
|
|
40
|
+
if (options.servers?.length) {
|
|
41
|
+
document.servers = options.servers;
|
|
42
|
+
}
|
|
43
|
+
return document;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function buildOperation(route: MappedRoute): OpenApiOperation {
|
|
47
|
+
const { queryParams, pathParams, body: bodySchema } = buildRouteRequestSchemas(route);
|
|
48
|
+
const parameters = [...queryParams, ...pathParams];
|
|
49
|
+
|
|
50
|
+
const status = String(route.httpCode ?? 200);
|
|
51
|
+
const operation: OpenApiOperation = {
|
|
52
|
+
operationId: `${route.controller.name}.${route.handler}`,
|
|
53
|
+
tags: [route.controller.name],
|
|
54
|
+
responses: {
|
|
55
|
+
[status]: route.sse
|
|
56
|
+
? {
|
|
57
|
+
description: "Server-sent event stream",
|
|
58
|
+
content: { "text/event-stream": { schema: { type: "string" } } },
|
|
59
|
+
}
|
|
60
|
+
: { description: "Successful response" },
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
if (route.routeArgs?.some((arg) => arg.argumentLocation === "lastEventId")) {
|
|
64
|
+
parameters.push({ name: "Last-Event-ID", in: "header", schema: { type: "string" } });
|
|
65
|
+
}
|
|
66
|
+
if (parameters.length > 0) {
|
|
67
|
+
operation.parameters = parameters;
|
|
68
|
+
}
|
|
69
|
+
if (bodySchema != null) {
|
|
70
|
+
operation.requestBody = {
|
|
71
|
+
required: true,
|
|
72
|
+
content: {
|
|
73
|
+
"application/json": { schema: bodySchema },
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
return operation;
|
|
78
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export type OpenApiInfo = {
|
|
2
|
+
title: string;
|
|
3
|
+
version: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type OpenApiServer = {
|
|
8
|
+
url: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type OpenApiOptions = {
|
|
13
|
+
/** Defaults to `/openapi.json`. Absolute Fastify path (not affected by globalPrefix). */
|
|
14
|
+
path?: string;
|
|
15
|
+
info: OpenApiInfo;
|
|
16
|
+
servers?: OpenApiServer[];
|
|
17
|
+
/** When true (default), validate request params/query/body before the handler. */
|
|
18
|
+
validateRequests?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type JsonSchema = {
|
|
22
|
+
type?: string;
|
|
23
|
+
properties?: Record<string, JsonSchema>;
|
|
24
|
+
required?: string[];
|
|
25
|
+
items?: JsonSchema;
|
|
26
|
+
additionalProperties?: boolean | JsonSchema;
|
|
27
|
+
format?: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
minLength?: number;
|
|
30
|
+
maxLength?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type OpenApiParameter = {
|
|
34
|
+
name: string;
|
|
35
|
+
in: "query" | "path" | "header" | "cookie";
|
|
36
|
+
required?: boolean;
|
|
37
|
+
schema?: JsonSchema;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type OpenApiOperation = {
|
|
41
|
+
operationId?: string;
|
|
42
|
+
summary?: string;
|
|
43
|
+
tags?: string[];
|
|
44
|
+
parameters?: OpenApiParameter[];
|
|
45
|
+
requestBody?: {
|
|
46
|
+
required?: boolean;
|
|
47
|
+
content: Record<string, { schema: JsonSchema }>;
|
|
48
|
+
};
|
|
49
|
+
responses: Record<
|
|
50
|
+
string,
|
|
51
|
+
{ description: string; content?: Record<string, { schema: JsonSchema }> }
|
|
52
|
+
>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type OpenApiDocument = {
|
|
56
|
+
openapi: "3.0.3";
|
|
57
|
+
info: OpenApiInfo;
|
|
58
|
+
servers?: OpenApiServer[];
|
|
59
|
+
paths: Record<string, Partial<Record<string, OpenApiOperation>>>;
|
|
60
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { isPrimitiveOrDateType } from "@decohere/core/reflection";
|
|
2
|
+
import { InjectedParts, type MappedRoute, type RouteArg } from "../routing/anvil.types.js";
|
|
3
|
+
import type { JsonSchema, OpenApiParameter } from "./openapi.types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Request shapes derived from route / `@Field` metadata (same source as OpenAPI).
|
|
7
|
+
* Used by the document generator and the AJV request validator.
|
|
8
|
+
*/
|
|
9
|
+
export type RouteRequestSchemas = {
|
|
10
|
+
queryParams: OpenApiParameter[];
|
|
11
|
+
pathParams: OpenApiParameter[];
|
|
12
|
+
body?: JsonSchema;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function buildRouteRequestSchemas(route: MappedRoute): RouteRequestSchemas {
|
|
16
|
+
const queryParams: OpenApiParameter[] = [];
|
|
17
|
+
const pathParams: OpenApiParameter[] = [];
|
|
18
|
+
let bodySchema: JsonSchema | undefined;
|
|
19
|
+
|
|
20
|
+
for (const routeArg of route.routeArgs ?? []) {
|
|
21
|
+
if (
|
|
22
|
+
routeArg.argumentLocation != null &&
|
|
23
|
+
InjectedParts.includes(routeArg.argumentLocation)
|
|
24
|
+
) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (isPrimitiveOrDateType(routeArg.argumentType)) {
|
|
29
|
+
const location = routeArg.argumentLocation;
|
|
30
|
+
if (location === "query" || location === "params") {
|
|
31
|
+
const param: OpenApiParameter = {
|
|
32
|
+
name: routeArg.mappingKey ?? "value",
|
|
33
|
+
in: location === "params" ? "path" : "query",
|
|
34
|
+
required: location === "params",
|
|
35
|
+
schema: typeToSchema(routeArg.argumentType.name),
|
|
36
|
+
};
|
|
37
|
+
if (location === "params") {
|
|
38
|
+
pathParams.push(param);
|
|
39
|
+
} else {
|
|
40
|
+
queryParams.push(param);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const parts = objectArgToParts(routeArg);
|
|
47
|
+
queryParams.push(...parts.queryParams);
|
|
48
|
+
pathParams.push(...parts.pathParams);
|
|
49
|
+
if (parts.body != null) {
|
|
50
|
+
bodySchema = mergeObjectSchemas(bodySchema, parts.body);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { queryParams, pathParams, body: bodySchema };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function objectArgToParts(routeArg: RouteArg): {
|
|
58
|
+
queryParams: OpenApiParameter[];
|
|
59
|
+
pathParams: OpenApiParameter[];
|
|
60
|
+
body?: JsonSchema;
|
|
61
|
+
} {
|
|
62
|
+
const queryParams: OpenApiParameter[] = [];
|
|
63
|
+
const pathParams: OpenApiParameter[] = [];
|
|
64
|
+
const bodyProperties: Record<string, JsonSchema> = {};
|
|
65
|
+
const bodyRequired: string[] = [];
|
|
66
|
+
|
|
67
|
+
const argLocation = routeArg.argumentLocation;
|
|
68
|
+
for (const [, meta] of routeArg.fieldMap?.entries() ?? []) {
|
|
69
|
+
const location = meta.fieldLocation ?? argLocation;
|
|
70
|
+
const name = meta.mappingKey || meta.fieldKey;
|
|
71
|
+
const schema = typeToSchema(meta.fieldType?.name);
|
|
72
|
+
|
|
73
|
+
if (location === "query") {
|
|
74
|
+
queryParams.push({ name, in: "query", required: false, schema });
|
|
75
|
+
} else if (location === "params") {
|
|
76
|
+
pathParams.push({ name, in: "path", required: true, schema });
|
|
77
|
+
} else if (location === "body" || (location == null && argLocation === "body")) {
|
|
78
|
+
bodyProperties[name] = schema;
|
|
79
|
+
bodyRequired.push(name);
|
|
80
|
+
} else if (location == null && argLocation == null) {
|
|
81
|
+
// Unlocated object fields default to body for OpenAPI (common DTO pattern).
|
|
82
|
+
bodyProperties[name] = schema;
|
|
83
|
+
bodyRequired.push(name);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Whole-object body argument with no fields still advertises an object body.
|
|
88
|
+
if (argLocation === "body" && Object.keys(bodyProperties).length === 0) {
|
|
89
|
+
return {
|
|
90
|
+
queryParams,
|
|
91
|
+
pathParams,
|
|
92
|
+
body: { type: "object", additionalProperties: true },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const body =
|
|
97
|
+
Object.keys(bodyProperties).length > 0
|
|
98
|
+
? {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: bodyProperties,
|
|
101
|
+
required: bodyRequired,
|
|
102
|
+
additionalProperties: false,
|
|
103
|
+
}
|
|
104
|
+
: undefined;
|
|
105
|
+
|
|
106
|
+
return { queryParams, pathParams, body };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function mergeObjectSchemas(a: JsonSchema | undefined, b: JsonSchema): JsonSchema {
|
|
110
|
+
if (a == null) {
|
|
111
|
+
return b;
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
type: "object",
|
|
115
|
+
properties: { ...a.properties, ...b.properties },
|
|
116
|
+
required: [...new Set([...(a.required ?? []), ...(b.required ?? [])])],
|
|
117
|
+
additionalProperties: false,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function typeToSchema(typeName?: string): JsonSchema {
|
|
122
|
+
switch (typeName) {
|
|
123
|
+
case "Number":
|
|
124
|
+
return { type: "number" };
|
|
125
|
+
case "Boolean":
|
|
126
|
+
return { type: "boolean" };
|
|
127
|
+
case "Date":
|
|
128
|
+
return { type: "string", format: "date-time" };
|
|
129
|
+
case "String":
|
|
130
|
+
return { type: "string" };
|
|
131
|
+
default:
|
|
132
|
+
return {};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** JSON Schema object for required path params (HTTP path values are always strings). */
|
|
137
|
+
export function pathParamsToSchema(params: OpenApiParameter[]): JsonSchema | undefined {
|
|
138
|
+
if (params.length === 0) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
const properties: Record<string, JsonSchema> = {};
|
|
142
|
+
const required: string[] = [];
|
|
143
|
+
for (const param of params) {
|
|
144
|
+
properties[param.name] = { type: "string", minLength: 1 };
|
|
145
|
+
if (param.required !== false) {
|
|
146
|
+
required.push(param.name);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
type: "object",
|
|
151
|
+
properties,
|
|
152
|
+
required,
|
|
153
|
+
additionalProperties: true,
|
|
154
|
+
};
|
|
155
|
+
}
|