@typed/app 1.0.0-beta.1
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 +166 -0
- package/dist/HttpApiVirtualModulePlugin.d.ts +26 -0
- package/dist/HttpApiVirtualModulePlugin.d.ts.map +1 -0
- package/dist/HttpApiVirtualModulePlugin.js +301 -0
- package/dist/RouterVirtualModulePlugin.d.ts +23 -0
- package/dist/RouterVirtualModulePlugin.d.ts.map +1 -0
- package/dist/RouterVirtualModulePlugin.js +176 -0
- package/dist/createTypeInfoApiSessionForApp.d.ts +29 -0
- package/dist/createTypeInfoApiSessionForApp.d.ts.map +1 -0
- package/dist/createTypeInfoApiSessionForApp.js +46 -0
- package/dist/httpapi/defineApiHandler.d.ts +70 -0
- package/dist/httpapi/defineApiHandler.d.ts.map +1 -0
- package/dist/httpapi/defineApiHandler.js +23 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/internal/appConfigTypes.d.ts +11 -0
- package/dist/internal/appConfigTypes.d.ts.map +1 -0
- package/dist/internal/appConfigTypes.js +1 -0
- package/dist/internal/appLayerTypes.d.ts +24 -0
- package/dist/internal/appLayerTypes.d.ts.map +1 -0
- package/dist/internal/appLayerTypes.js +28 -0
- package/dist/internal/buildRouteDescriptors.d.ts +48 -0
- package/dist/internal/buildRouteDescriptors.d.ts.map +1 -0
- package/dist/internal/buildRouteDescriptors.js +371 -0
- package/dist/internal/emitHttpApiSource.d.ts +18 -0
- package/dist/internal/emitHttpApiSource.d.ts.map +1 -0
- package/dist/internal/emitHttpApiSource.js +404 -0
- package/dist/internal/emitRouterHelpers.d.ts +17 -0
- package/dist/internal/emitRouterHelpers.d.ts.map +1 -0
- package/dist/internal/emitRouterHelpers.js +74 -0
- package/dist/internal/emitRouterSource.d.ts +8 -0
- package/dist/internal/emitRouterSource.d.ts.map +1 -0
- package/dist/internal/emitRouterSource.js +139 -0
- package/dist/internal/extractHttpApiLiterals.d.ts +17 -0
- package/dist/internal/extractHttpApiLiterals.d.ts.map +1 -0
- package/dist/internal/extractHttpApiLiterals.js +45 -0
- package/dist/internal/httpapiDescriptorTree.d.ts +75 -0
- package/dist/internal/httpapiDescriptorTree.d.ts.map +1 -0
- package/dist/internal/httpapiDescriptorTree.js +182 -0
- package/dist/internal/httpapiEndpointContract.d.ts +32 -0
- package/dist/internal/httpapiEndpointContract.d.ts.map +1 -0
- package/dist/internal/httpapiEndpointContract.js +79 -0
- package/dist/internal/httpapiFileRoles.d.ts +67 -0
- package/dist/internal/httpapiFileRoles.d.ts.map +1 -0
- package/dist/internal/httpapiFileRoles.js +145 -0
- package/dist/internal/httpapiId.d.ts +30 -0
- package/dist/internal/httpapiId.d.ts.map +1 -0
- package/dist/internal/httpapiId.js +57 -0
- package/dist/internal/httpapiOpenApiConfig.d.ts +87 -0
- package/dist/internal/httpapiOpenApiConfig.d.ts.map +1 -0
- package/dist/internal/httpapiOpenApiConfig.js +144 -0
- package/dist/internal/httpapiSort.d.ts +16 -0
- package/dist/internal/httpapiSort.d.ts.map +1 -0
- package/dist/internal/httpapiSort.js +29 -0
- package/dist/internal/path.d.ts +16 -0
- package/dist/internal/path.d.ts.map +1 -0
- package/dist/internal/path.js +38 -0
- package/dist/internal/resolveConfig.d.ts +8 -0
- package/dist/internal/resolveConfig.d.ts.map +1 -0
- package/dist/internal/resolveConfig.js +13 -0
- package/dist/internal/routeIdentifiers.d.ts +18 -0
- package/dist/internal/routeIdentifiers.d.ts.map +1 -0
- package/dist/internal/routeIdentifiers.js +90 -0
- package/dist/internal/routeTypeNode.d.ts +45 -0
- package/dist/internal/routeTypeNode.d.ts.map +1 -0
- package/dist/internal/routeTypeNode.js +93 -0
- package/dist/internal/routerDescriptorTree.d.ts +110 -0
- package/dist/internal/routerDescriptorTree.d.ts.map +1 -0
- package/dist/internal/routerDescriptorTree.js +230 -0
- package/dist/internal/typeTargetBootstrap.d.ts +2 -0
- package/dist/internal/typeTargetBootstrap.d.ts.map +1 -0
- package/dist/internal/typeTargetBootstrap.js +23 -0
- package/dist/internal/typeTargetBootstrapHttpApi.d.ts +2 -0
- package/dist/internal/typeTargetBootstrapHttpApi.d.ts.map +1 -0
- package/dist/internal/typeTargetBootstrapHttpApi.js +21 -0
- package/dist/internal/typeTargetSpecs.d.ts +15 -0
- package/dist/internal/typeTargetSpecs.d.ts.map +1 -0
- package/dist/internal/typeTargetSpecs.js +32 -0
- package/dist/internal/validation.d.ts +12 -0
- package/dist/internal/validation.d.ts.map +1 -0
- package/dist/internal/validation.js +32 -0
- package/package.json +45 -0
- package/src/HttpApiVirtualModulePlugin.test.ts +1062 -0
- package/src/HttpApiVirtualModulePlugin.ts +376 -0
- package/src/RouterVirtualModulePlugin.test.ts +1254 -0
- package/src/RouterVirtualModulePlugin.ts +242 -0
- package/src/createTypeInfoApiSessionForApp.ts +57 -0
- package/src/defineApiHandler.test.ts +100 -0
- package/src/httpapi/defineApiHandler.ts +141 -0
- package/src/httpapiDescriptorTree.test.ts +124 -0
- package/src/httpapiEndpointContract.test.ts +160 -0
- package/src/httpapiFileRoles.test.ts +105 -0
- package/src/index.ts +40 -0
- package/src/internal/appConfigTypes.ts +12 -0
- package/src/internal/appLayerTypes.ts +79 -0
- package/src/internal/buildRouteDescriptors.ts +489 -0
- package/src/internal/emitHttpApiSource.ts +563 -0
- package/src/internal/emitRouterHelpers.ts +89 -0
- package/src/internal/emitRouterSource.ts +191 -0
- package/src/internal/extractHttpApiLiterals.ts +67 -0
- package/src/internal/httpapiDescriptorTree.ts +283 -0
- package/src/internal/httpapiEndpointContract.ts +110 -0
- package/src/internal/httpapiFileRoles.ts +204 -0
- package/src/internal/httpapiId.ts +78 -0
- package/src/internal/httpapiOpenApiConfig.ts +228 -0
- package/src/internal/httpapiSort.ts +39 -0
- package/src/internal/path.ts +46 -0
- package/src/internal/resolveConfig.ts +15 -0
- package/src/internal/routeIdentifiers.ts +93 -0
- package/src/internal/routeTypeNode.ts +120 -0
- package/src/internal/routerDescriptorTree.ts +366 -0
- package/src/internal/typeTargetBootstrap.ts +24 -0
- package/src/internal/typeTargetBootstrapHttpApi.ts +22 -0
- package/src/internal/typeTargetSpecs.ts +35 -0
- package/src/internal/validation.ts +46 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-role classification for HttpApi virtual module discovery.
|
|
3
|
+
* Implements the supported file-role matrix: API root, group, endpoint primary, endpoint companions, directory companions.
|
|
4
|
+
* Diagnostics-ready metadata for unsupported reserved names.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { toPosixPath } from "./path.js";
|
|
8
|
+
|
|
9
|
+
/** Script extensions that count as API source candidates (aligned with router plugin). */
|
|
10
|
+
export const HTTPAPI_SCRIPT_EXTENSIONS = [
|
|
11
|
+
".ts",
|
|
12
|
+
".tsx",
|
|
13
|
+
".js",
|
|
14
|
+
".jsx",
|
|
15
|
+
".mts",
|
|
16
|
+
".cts",
|
|
17
|
+
".mjs",
|
|
18
|
+
".cjs",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export const HTTPAPI_SCRIPT_EXTENSION_SET = new Set<string>(
|
|
22
|
+
HTTPAPI_SCRIPT_EXTENSIONS.map((e) => e.toLowerCase()),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** Directory companion filenames (exact match). */
|
|
26
|
+
export const HTTPAPI_DIRECTORY_COMPANION_FILES = [
|
|
27
|
+
"_dependencies.ts",
|
|
28
|
+
"_middlewares.ts",
|
|
29
|
+
"_prefix.ts",
|
|
30
|
+
"_openapi.ts",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
/** Endpoint companion suffixes (stem ends with these before extension). */
|
|
34
|
+
export const HTTPAPI_ENDPOINT_COMPANION_SUFFIXES = [
|
|
35
|
+
".name",
|
|
36
|
+
".dependencies",
|
|
37
|
+
".middlewares",
|
|
38
|
+
".prefix",
|
|
39
|
+
".openapi",
|
|
40
|
+
] as const;
|
|
41
|
+
|
|
42
|
+
export type HttpApiDirectoryCompanionKind =
|
|
43
|
+
(typeof HTTPAPI_DIRECTORY_COMPANION_FILES)[number];
|
|
44
|
+
export type HttpApiEndpointCompanionKind =
|
|
45
|
+
(typeof HTTPAPI_ENDPOINT_COMPANION_SUFFIXES)[number];
|
|
46
|
+
|
|
47
|
+
/** Role of a discovered file relative to the API root. */
|
|
48
|
+
export type HttpApiFileRole =
|
|
49
|
+
| { readonly role: "api_root"; readonly path: string }
|
|
50
|
+
| { readonly role: "group_override"; readonly path: string }
|
|
51
|
+
| { readonly role: "endpoint_primary"; readonly path: string }
|
|
52
|
+
| {
|
|
53
|
+
readonly role: "endpoint_companion";
|
|
54
|
+
readonly path: string;
|
|
55
|
+
readonly kind: HttpApiEndpointCompanionKind;
|
|
56
|
+
readonly endpointStem: string;
|
|
57
|
+
}
|
|
58
|
+
| {
|
|
59
|
+
readonly role: "directory_companion";
|
|
60
|
+
readonly path: string;
|
|
61
|
+
readonly kind: HttpApiDirectoryCompanionKind;
|
|
62
|
+
}
|
|
63
|
+
| {
|
|
64
|
+
readonly role: "unsupported_reserved";
|
|
65
|
+
readonly path: string;
|
|
66
|
+
readonly diagnosticCode: string;
|
|
67
|
+
readonly diagnosticMessage: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** Diagnostic-ready metadata for classification issues (e.g. unsupported companion name). */
|
|
71
|
+
export type HttpApiFileRoleDiagnostic = {
|
|
72
|
+
readonly code: string;
|
|
73
|
+
readonly message: string;
|
|
74
|
+
readonly path: string;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const DIRECTORY_COMPANION_SET = new Set<string>(
|
|
78
|
+
HTTPAPI_DIRECTORY_COMPANION_FILES.map((f) => f.toLowerCase()),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
/** Normalized path (posix) relative to API base; used for stable ordering. */
|
|
82
|
+
export function normalizeHttpApiRelativePath(relativePath: string): string {
|
|
83
|
+
const posix = toPosixPath(relativePath);
|
|
84
|
+
return posix.replace(/^\.\//, "").replace(/\/+/g, "/");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Returns true if the file has a supported script extension for API discovery.
|
|
89
|
+
*/
|
|
90
|
+
export function isHttpApiScriptExtension(ext: string): boolean {
|
|
91
|
+
return HTTPAPI_SCRIPT_EXTENSION_SET.has(ext.toLowerCase());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Classifies a single file path (relative to API root, posix) into an HttpApi file role.
|
|
96
|
+
* Does not read the filesystem; only interprets path and filename.
|
|
97
|
+
* Returns diagnostic-ready metadata for unsupported reserved names.
|
|
98
|
+
*/
|
|
99
|
+
export function classifyHttpApiFileRole(relativePath: string): HttpApiFileRole {
|
|
100
|
+
const path = normalizeHttpApiRelativePath(relativePath);
|
|
101
|
+
const lastSlash = path.lastIndexOf("/");
|
|
102
|
+
const dir = lastSlash < 0 ? "" : path.slice(0, lastSlash);
|
|
103
|
+
const fileName = lastSlash < 0 ? path : path.slice(lastSlash + 1);
|
|
104
|
+
const lower = fileName.toLowerCase();
|
|
105
|
+
|
|
106
|
+
if (lower.endsWith(".d.ts")) {
|
|
107
|
+
return {
|
|
108
|
+
role: "unsupported_reserved",
|
|
109
|
+
path,
|
|
110
|
+
diagnosticCode: "HTTPAPI-ROLE-001",
|
|
111
|
+
diagnosticMessage: `declaration files are not API sources: ${fileName}`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const extIdx = fileName.lastIndexOf(".");
|
|
116
|
+
if (extIdx <= 0) {
|
|
117
|
+
return {
|
|
118
|
+
role: "unsupported_reserved",
|
|
119
|
+
path,
|
|
120
|
+
diagnosticCode: "HTTPAPI-ROLE-002",
|
|
121
|
+
diagnosticMessage: `filename must have a script extension: ${fileName}`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const ext = fileName.slice(extIdx);
|
|
125
|
+
if (!isHttpApiScriptExtension(ext)) {
|
|
126
|
+
return {
|
|
127
|
+
role: "unsupported_reserved",
|
|
128
|
+
path,
|
|
129
|
+
diagnosticCode: "HTTPAPI-ROLE-003",
|
|
130
|
+
diagnosticMessage: `unsupported extension for API source: ${fileName}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const stem = fileName.slice(0, extIdx);
|
|
135
|
+
|
|
136
|
+
if (stem === "_api" && dir === "") {
|
|
137
|
+
return { role: "api_root", path };
|
|
138
|
+
}
|
|
139
|
+
if (stem === "_group") {
|
|
140
|
+
return { role: "group_override", path };
|
|
141
|
+
}
|
|
142
|
+
if (DIRECTORY_COMPANION_SET.has(fileName)) {
|
|
143
|
+
const kind = HTTPAPI_DIRECTORY_COMPANION_FILES.find(
|
|
144
|
+
(k) => k.toLowerCase() === lower,
|
|
145
|
+
)!;
|
|
146
|
+
return { role: "directory_companion", path, kind };
|
|
147
|
+
}
|
|
148
|
+
if (stem === "_api" || stem === "_group") {
|
|
149
|
+
return {
|
|
150
|
+
role: "unsupported_reserved",
|
|
151
|
+
path,
|
|
152
|
+
diagnosticCode: "HTTPAPI-ROLE-004",
|
|
153
|
+
diagnosticMessage: `convention file "${fileName}" must be at API root (_api.ts) or directory root (_group.ts)`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
for (const suffix of HTTPAPI_ENDPOINT_COMPANION_SUFFIXES) {
|
|
158
|
+
if (stem.endsWith(suffix)) {
|
|
159
|
+
const endpointStem = stem.slice(0, -suffix.length);
|
|
160
|
+
if (endpointStem === "") {
|
|
161
|
+
return {
|
|
162
|
+
role: "unsupported_reserved",
|
|
163
|
+
path,
|
|
164
|
+
diagnosticCode: "HTTPAPI-ROLE-005",
|
|
165
|
+
diagnosticMessage: `endpoint companion must have a base name: ${fileName}`,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
role: "endpoint_companion",
|
|
170
|
+
path,
|
|
171
|
+
kind: suffix as HttpApiEndpointCompanionKind,
|
|
172
|
+
endpointStem,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (stem.startsWith("_")) {
|
|
178
|
+
return {
|
|
179
|
+
role: "unsupported_reserved",
|
|
180
|
+
path,
|
|
181
|
+
diagnosticCode: "HTTPAPI-ROLE-006",
|
|
182
|
+
diagnosticMessage: `reserved underscore-prefixed filename not in supported matrix: ${fileName}`,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { role: "endpoint_primary", path };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Stable sort key for discovered files: normalized relative path (posix).
|
|
191
|
+
* Use with Array.sort(compareHttpApiPathOrder) for deterministic ordering.
|
|
192
|
+
*/
|
|
193
|
+
export function compareHttpApiPathOrder(a: string, b: string): number {
|
|
194
|
+
const na = normalizeHttpApiRelativePath(a);
|
|
195
|
+
const nb = normalizeHttpApiRelativePath(b);
|
|
196
|
+
return na.localeCompare(nb, "en");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Sorts an array of relative paths in deterministic API discovery order.
|
|
201
|
+
*/
|
|
202
|
+
export function sortHttpApiPaths(paths: readonly string[]): string[] {
|
|
203
|
+
return [...paths].sort(compareHttpApiPathOrder);
|
|
204
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse and resolve `api:` virtual module IDs (router-plugin semantics).
|
|
3
|
+
* Used by HttpApi virtual module plugin for target-directory resolution.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { pathIsUnderBase, resolvePathUnderBase, toPosixPath } from "./path.js";
|
|
8
|
+
import { validateNonEmptyString, validatePathSegment } from "./validation.js";
|
|
9
|
+
|
|
10
|
+
export const HTTPAPI_VIRTUAL_MODULE_PREFIX = "api:";
|
|
11
|
+
|
|
12
|
+
export type ParseHttpApiVirtualModuleIdResult =
|
|
13
|
+
| { readonly ok: true; readonly relativeDirectory: string }
|
|
14
|
+
| { readonly ok: false; readonly reason: string };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Parses an `api:./<directory>` (or `api:<directory>`) ID.
|
|
18
|
+
* Normalizes bare directory to `./<directory>`.
|
|
19
|
+
*/
|
|
20
|
+
export function parseHttpApiVirtualModuleId(
|
|
21
|
+
id: string,
|
|
22
|
+
prefix: string = HTTPAPI_VIRTUAL_MODULE_PREFIX,
|
|
23
|
+
): ParseHttpApiVirtualModuleIdResult {
|
|
24
|
+
const idResult = validateNonEmptyString(id, "id");
|
|
25
|
+
if (!idResult.ok) return { ok: false, reason: idResult.reason };
|
|
26
|
+
const prefixResult = validateNonEmptyString(prefix, "prefix");
|
|
27
|
+
if (!prefixResult.ok) return { ok: false, reason: prefixResult.reason };
|
|
28
|
+
if (!id.startsWith(prefix)) {
|
|
29
|
+
return { ok: false, reason: `id must start with "${prefix}"` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let relativeDirectory = id.slice(prefix.length);
|
|
33
|
+
if (
|
|
34
|
+
relativeDirectory.length > 0 &&
|
|
35
|
+
relativeDirectory !== "." &&
|
|
36
|
+
relativeDirectory !== ".." &&
|
|
37
|
+
!relativeDirectory.startsWith("./") &&
|
|
38
|
+
!relativeDirectory.startsWith("../") &&
|
|
39
|
+
!relativeDirectory.startsWith("/")
|
|
40
|
+
) {
|
|
41
|
+
relativeDirectory = `./${relativeDirectory}`;
|
|
42
|
+
}
|
|
43
|
+
const relativeResult = validatePathSegment(relativeDirectory, "relativeDirectory");
|
|
44
|
+
if (!relativeResult.ok) return { ok: false, reason: relativeResult.reason };
|
|
45
|
+
|
|
46
|
+
return { ok: true, relativeDirectory: relativeResult.value };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type ResolveHttpApiTargetDirectoryResult =
|
|
50
|
+
| { readonly ok: true; readonly targetDirectory: string }
|
|
51
|
+
| { readonly ok: false; readonly reason: string };
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Resolves the target directory for an `api:` ID relative to the importer.
|
|
55
|
+
* Rejects paths that escape the importer base directory.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveHttpApiTargetDirectory(
|
|
58
|
+
id: string,
|
|
59
|
+
importer: string,
|
|
60
|
+
prefix: string = HTTPAPI_VIRTUAL_MODULE_PREFIX,
|
|
61
|
+
): ResolveHttpApiTargetDirectoryResult {
|
|
62
|
+
const parsed = parseHttpApiVirtualModuleId(id, prefix);
|
|
63
|
+
if (!parsed.ok) return parsed;
|
|
64
|
+
|
|
65
|
+
const importerResult = validatePathSegment(importer, "importer");
|
|
66
|
+
if (!importerResult.ok) return { ok: false, reason: importerResult.reason };
|
|
67
|
+
|
|
68
|
+
const importerDir = dirname(toPosixPath(importerResult.value));
|
|
69
|
+
const resolved = resolvePathUnderBase(importerDir, parsed.relativeDirectory);
|
|
70
|
+
if (!resolved.ok) {
|
|
71
|
+
return { ok: false, reason: "resolved target directory escapes importer base directory" };
|
|
72
|
+
}
|
|
73
|
+
if (!pathIsUnderBase(importerDir, resolved.path)) {
|
|
74
|
+
return { ok: false, reason: "resolved target directory is outside importer base directory" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { ok: true, targetDirectory: toPosixPath(resolved.path) };
|
|
78
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI config mapper for HttpApi virtual module plugin.
|
|
3
|
+
* Maps convention/option layers to Effect-backed surfaces:
|
|
4
|
+
* - OpenApi.annotations keys
|
|
5
|
+
* - OpenApi.fromApi additionalProperties (API scope only)
|
|
6
|
+
* - Exposure: jsonPath, swaggerPath, scalar (json/swagger/scalar modes)
|
|
7
|
+
* Scope validation and route-conflict diagnostics are applied.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Supported OpenApi.annotation keys (Effect-backed). */
|
|
11
|
+
export const OPENAPI_ANNOTATION_KEYS = [
|
|
12
|
+
"identifier",
|
|
13
|
+
"title",
|
|
14
|
+
"version",
|
|
15
|
+
"description",
|
|
16
|
+
"license",
|
|
17
|
+
"summary",
|
|
18
|
+
"deprecated",
|
|
19
|
+
"externalDocs",
|
|
20
|
+
"servers",
|
|
21
|
+
"format",
|
|
22
|
+
"override",
|
|
23
|
+
"exclude",
|
|
24
|
+
"transform",
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
export type OpenApiAnnotationKey = (typeof OPENAPI_ANNOTATION_KEYS)[number];
|
|
28
|
+
|
|
29
|
+
export const OPENAPI_ANNOTATION_KEY_SET = new Set<string>(OPENAPI_ANNOTATION_KEYS);
|
|
30
|
+
|
|
31
|
+
/** Scope at which OpenAPI config is applied. */
|
|
32
|
+
export type OpenApiConfigScope = "api" | "group" | "endpoint";
|
|
33
|
+
|
|
34
|
+
/** Annotation-only config (allowed at api, group, endpoint). */
|
|
35
|
+
export interface OpenApiAnnotationsConfig {
|
|
36
|
+
readonly [key: string]: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Spec generation options (API scope only). */
|
|
40
|
+
export interface OpenApiGenerationConfig {
|
|
41
|
+
readonly additionalProperties?: boolean | Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Scalar exposure config. */
|
|
45
|
+
export interface OpenApiScalarExposureConfig {
|
|
46
|
+
readonly path?: `/${string}`;
|
|
47
|
+
readonly source?: "inline" | "cdn";
|
|
48
|
+
readonly version?: string;
|
|
49
|
+
readonly config?: Record<string, unknown>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Exposure routes (API scope only). */
|
|
53
|
+
export interface OpenApiExposureConfig {
|
|
54
|
+
readonly jsonPath?: `/${string}` | false;
|
|
55
|
+
readonly swaggerPath?: `/${string}` | false;
|
|
56
|
+
readonly scalar?: OpenApiScalarExposureConfig | false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Normalized OpenAPI config after scope validation. */
|
|
60
|
+
export interface NormalizedOpenApiConfig {
|
|
61
|
+
readonly annotations: OpenApiAnnotationsConfig;
|
|
62
|
+
readonly generation: OpenApiGenerationConfig;
|
|
63
|
+
readonly exposure: OpenApiExposureConfig;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Diagnostic for OpenAPI config validation. */
|
|
67
|
+
export interface OpenApiConfigDiagnostic {
|
|
68
|
+
readonly code: string;
|
|
69
|
+
readonly message: string;
|
|
70
|
+
readonly scope?: OpenApiConfigScope;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const DIAG_SCOPE_GENERATION = "AVM-OPENAPI-001";
|
|
74
|
+
const DIAG_SCOPE_EXPOSURE = "AVM-OPENAPI-002";
|
|
75
|
+
const DIAG_ROUTE_CONFLICT = "AVM-OPENAPI-003";
|
|
76
|
+
const DIAG_INVALID_ANNOTATION_KEY = "AVM-OPENAPI-004";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Validates that generation options (e.g. additionalProperties) are only at API scope.
|
|
80
|
+
*/
|
|
81
|
+
export function validateOpenApiGenerationScope(
|
|
82
|
+
scope: OpenApiConfigScope,
|
|
83
|
+
generation: OpenApiGenerationConfig,
|
|
84
|
+
): OpenApiConfigDiagnostic[] {
|
|
85
|
+
const diagnostics: OpenApiConfigDiagnostic[] = [];
|
|
86
|
+
if (scope === "api") return diagnostics;
|
|
87
|
+
const hasGen =
|
|
88
|
+
generation.additionalProperties !== undefined &&
|
|
89
|
+
(typeof generation.additionalProperties === "boolean" ||
|
|
90
|
+
(typeof generation.additionalProperties === "object" && generation.additionalProperties !== null));
|
|
91
|
+
if (hasGen) {
|
|
92
|
+
diagnostics.push({
|
|
93
|
+
code: DIAG_SCOPE_GENERATION,
|
|
94
|
+
message: "OpenAPI generation options (e.g. additionalProperties) are allowed at API scope only.",
|
|
95
|
+
scope,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return diagnostics;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validates that exposure options (jsonPath, swaggerPath, scalar) are only at API scope.
|
|
103
|
+
*/
|
|
104
|
+
export function validateOpenApiExposureScope(
|
|
105
|
+
scope: OpenApiConfigScope,
|
|
106
|
+
exposure: OpenApiExposureConfig,
|
|
107
|
+
): OpenApiConfigDiagnostic[] {
|
|
108
|
+
const diagnostics: OpenApiConfigDiagnostic[] = [];
|
|
109
|
+
if (scope === "api") return diagnostics;
|
|
110
|
+
const hasExposure =
|
|
111
|
+
exposure.jsonPath !== undefined ||
|
|
112
|
+
exposure.swaggerPath !== undefined ||
|
|
113
|
+
exposure.scalar !== undefined;
|
|
114
|
+
if (hasExposure) {
|
|
115
|
+
diagnostics.push({
|
|
116
|
+
code: DIAG_SCOPE_EXPOSURE,
|
|
117
|
+
message: "OpenAPI exposure options (jsonPath, swaggerPath, scalar) are allowed at API scope only.",
|
|
118
|
+
scope,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return diagnostics;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Collects exposure route paths and detects conflicts (same path, different mode).
|
|
126
|
+
*/
|
|
127
|
+
export interface ExposureRouteEntry {
|
|
128
|
+
readonly path: string;
|
|
129
|
+
readonly mode: "json" | "swagger" | "scalar";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function collectExposureRoutes(exposure: OpenApiExposureConfig): ExposureRouteEntry[] {
|
|
133
|
+
const entries: ExposureRouteEntry[] = [];
|
|
134
|
+
if (exposure.jsonPath && typeof exposure.jsonPath === "string") {
|
|
135
|
+
entries.push({ path: exposure.jsonPath, mode: "json" });
|
|
136
|
+
}
|
|
137
|
+
if (exposure.swaggerPath && typeof exposure.swaggerPath === "string") {
|
|
138
|
+
entries.push({ path: exposure.swaggerPath, mode: "swagger" });
|
|
139
|
+
}
|
|
140
|
+
if (exposure.scalar && typeof exposure.scalar === "object" && exposure.scalar.path) {
|
|
141
|
+
entries.push({ path: exposure.scalar.path, mode: "scalar" });
|
|
142
|
+
}
|
|
143
|
+
return entries;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Detects route conflicts: same path used for more than one exposure mode.
|
|
148
|
+
*/
|
|
149
|
+
export function validateOpenApiExposureRouteConflicts(
|
|
150
|
+
exposure: OpenApiExposureConfig,
|
|
151
|
+
): OpenApiConfigDiagnostic[] {
|
|
152
|
+
const routes = collectExposureRoutes(exposure);
|
|
153
|
+
const byPath = new Map<string, string[]>();
|
|
154
|
+
for (const { path, mode } of routes) {
|
|
155
|
+
const normalized = path.toLowerCase().replace(/\/+$/, "") || "/";
|
|
156
|
+
const list = byPath.get(normalized) ?? [];
|
|
157
|
+
list.push(mode);
|
|
158
|
+
byPath.set(normalized, list);
|
|
159
|
+
}
|
|
160
|
+
const diagnostics: OpenApiConfigDiagnostic[] = [];
|
|
161
|
+
for (const [path, modes] of byPath) {
|
|
162
|
+
if (modes.length > 1) {
|
|
163
|
+
diagnostics.push({
|
|
164
|
+
code: DIAG_ROUTE_CONFLICT,
|
|
165
|
+
message: `OpenAPI exposure route conflict: path "${path}" used for multiple modes: ${[...new Set(modes)].join(", ")}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return diagnostics;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Filters raw annotation keys to Effect-supported set; emits diagnostic for unsupported keys.
|
|
174
|
+
*/
|
|
175
|
+
export function filterAnnotationKeys(
|
|
176
|
+
raw: Record<string, unknown>,
|
|
177
|
+
): { annotations: OpenApiAnnotationsConfig; diagnostics: OpenApiConfigDiagnostic[] } {
|
|
178
|
+
const annotations: OpenApiAnnotationsConfig = {};
|
|
179
|
+
const diagnostics: OpenApiConfigDiagnostic[] = [];
|
|
180
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
181
|
+
if (OPENAPI_ANNOTATION_KEY_SET.has(key)) {
|
|
182
|
+
(annotations as Record<string, unknown>)[key] = value;
|
|
183
|
+
} else {
|
|
184
|
+
diagnostics.push({
|
|
185
|
+
code: DIAG_INVALID_ANNOTATION_KEY,
|
|
186
|
+
message: `Unsupported OpenAPI annotation key: "${key}". Supported: ${OPENAPI_ANNOTATION_KEYS.join(", ")}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { annotations, diagnostics };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Normalizes and validates OpenAPI config for API scope.
|
|
195
|
+
* Merges annotations, validates generation/exposure scope and route conflicts.
|
|
196
|
+
*/
|
|
197
|
+
export function normalizeOpenApiConfig(
|
|
198
|
+
scope: OpenApiConfigScope,
|
|
199
|
+
raw: {
|
|
200
|
+
annotations?: Record<string, unknown>;
|
|
201
|
+
generation?: OpenApiGenerationConfig;
|
|
202
|
+
exposure?: OpenApiExposureConfig;
|
|
203
|
+
},
|
|
204
|
+
): { config: NormalizedOpenApiConfig; diagnostics: OpenApiConfigDiagnostic[] } {
|
|
205
|
+
const diagnostics: OpenApiConfigDiagnostic[] = [];
|
|
206
|
+
|
|
207
|
+
const { annotations: ann, diagnostics: annDiag } = filterAnnotationKeys(
|
|
208
|
+
(raw.annotations ?? {}) as Record<string, unknown>,
|
|
209
|
+
);
|
|
210
|
+
diagnostics.push(...annDiag);
|
|
211
|
+
|
|
212
|
+
const generation = raw.generation ?? {};
|
|
213
|
+
diagnostics.push(...validateOpenApiGenerationScope(scope, generation));
|
|
214
|
+
|
|
215
|
+
const exposure = raw.exposure ?? {};
|
|
216
|
+
diagnostics.push(...validateOpenApiExposureScope(scope, exposure));
|
|
217
|
+
if (scope === "api") {
|
|
218
|
+
diagnostics.push(...validateOpenApiExposureRouteConflicts(exposure));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const config: NormalizedOpenApiConfig = {
|
|
222
|
+
annotations: ann,
|
|
223
|
+
generation,
|
|
224
|
+
exposure,
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return { config, diagnostics };
|
|
228
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic ordering for HttpApi discovery: paths, roles, and AST nodes.
|
|
3
|
+
* Centralizes stable sort so AST nodes and file roles have consistent order.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { HttpApiTreeNode } from "./httpapiDescriptorTree.js";
|
|
7
|
+
import {
|
|
8
|
+
compareHttpApiPathOrder,
|
|
9
|
+
normalizeHttpApiRelativePath,
|
|
10
|
+
sortHttpApiPaths,
|
|
11
|
+
} from "./httpapiFileRoles.js";
|
|
12
|
+
|
|
13
|
+
export { compareHttpApiPathOrder, normalizeHttpApiRelativePath, sortHttpApiPaths };
|
|
14
|
+
|
|
15
|
+
function nodeOrderKey(n: HttpApiTreeNode): string {
|
|
16
|
+
switch (n.type) {
|
|
17
|
+
case "endpoint":
|
|
18
|
+
return n.path;
|
|
19
|
+
case "group":
|
|
20
|
+
case "pathless_directory":
|
|
21
|
+
return n.dirPath;
|
|
22
|
+
default:
|
|
23
|
+
return "";
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stable comparison for tree nodes (by path or dirPath).
|
|
29
|
+
*/
|
|
30
|
+
export function compareHttpApiTreeNodeOrder(a: HttpApiTreeNode, b: HttpApiTreeNode): number {
|
|
31
|
+
return compareHttpApiPathOrder(nodeOrderKey(a), nodeOrderKey(b));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Returns a new array of tree nodes in deterministic order.
|
|
36
|
+
*/
|
|
37
|
+
export function sortHttpApiTreeNodes(nodes: readonly HttpApiTreeNode[]): HttpApiTreeNode[] {
|
|
38
|
+
return [...nodes].sort(compareHttpApiTreeNodeOrder);
|
|
39
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { extname, isAbsolute, relative, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const toPosixPath = (path: string): string => path.replaceAll("\\", "/");
|
|
5
|
+
|
|
6
|
+
/** Strip the extension from a path using extname; returns path unchanged if no extension. */
|
|
7
|
+
export const stripScriptExtension = (path: string): string => {
|
|
8
|
+
const ext = extname(path);
|
|
9
|
+
return ext ? path.slice(0, -ext.length) : path;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const resolveRelativePath = (baseDir: string, relativePath: string): string =>
|
|
13
|
+
toPosixPath(resolve(baseDir, relativePath));
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolves relativePath against baseDir and ensures the result stays under baseDir.
|
|
17
|
+
*/
|
|
18
|
+
export function resolvePathUnderBase(
|
|
19
|
+
baseDir: string,
|
|
20
|
+
relativePath: string,
|
|
21
|
+
): { ok: true; path: string } | { ok: false; error: "path-escapes-base" } {
|
|
22
|
+
const baseAbs = resolve(baseDir);
|
|
23
|
+
const resolved = resolve(baseDir, relativePath);
|
|
24
|
+
const rel = relative(baseAbs, resolved);
|
|
25
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
26
|
+
return { ok: false, error: "path-escapes-base" };
|
|
27
|
+
}
|
|
28
|
+
return { ok: true, path: toPosixPath(resolved) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pathIsUnderBase(baseDir: string, absolutePath: string): boolean {
|
|
32
|
+
let baseAbs: string;
|
|
33
|
+
let resolvedAbs: string;
|
|
34
|
+
try {
|
|
35
|
+
baseAbs = realpathSync(resolve(baseDir));
|
|
36
|
+
resolvedAbs = realpathSync(resolve(absolutePath));
|
|
37
|
+
} catch {
|
|
38
|
+
baseAbs = resolve(baseDir);
|
|
39
|
+
resolvedAbs = resolve(absolutePath);
|
|
40
|
+
}
|
|
41
|
+
const rel = relative(baseAbs, resolvedAbs);
|
|
42
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves a config field: raw value or yield* Config.
|
|
3
|
+
* Requires ConfigProvider when value is Config.
|
|
4
|
+
*/
|
|
5
|
+
import * as Config from "effect/Config";
|
|
6
|
+
import * as Effect from "effect/Effect";
|
|
7
|
+
|
|
8
|
+
export function resolveConfig<T>(
|
|
9
|
+
value: T | Config.Config<T> | undefined,
|
|
10
|
+
def: T,
|
|
11
|
+
): Effect.Effect<T, Config.ConfigError> {
|
|
12
|
+
if (value === undefined) return Effect.succeed(def);
|
|
13
|
+
if (Config.isConfig(value)) return value.asEffect();
|
|
14
|
+
return Effect.succeed(value);
|
|
15
|
+
}
|