@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,191 @@
|
|
|
1
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
buildRouterDescriptorTree,
|
|
4
|
+
renderRouterDescriptorTree,
|
|
5
|
+
type RouteMatchDescriptor,
|
|
6
|
+
} from "./routerDescriptorTree.js";
|
|
7
|
+
import type {
|
|
8
|
+
CatchFormByPath,
|
|
9
|
+
CatchExportByPath,
|
|
10
|
+
DepsFormByPath,
|
|
11
|
+
GuardExportByPath,
|
|
12
|
+
RouteDescriptor,
|
|
13
|
+
} from "./buildRouteDescriptors.js";
|
|
14
|
+
import { siblingCompanionPath, type ConcernKind } from "./buildRouteDescriptors.js";
|
|
15
|
+
import { catchExprFor, depsExprFor, handlerExprFor } from "./emitRouterHelpers.js";
|
|
16
|
+
import { makeUniqueVarNames, pathToIdentifier, routeModuleIdentifier } from "./routeIdentifiers.js";
|
|
17
|
+
import { stripScriptExtension, toPosixPath } from "./path.js";
|
|
18
|
+
|
|
19
|
+
/** Path relative to baseDir → import specifier relative to importerDir (script ext → .js for ESM). */
|
|
20
|
+
function toImportSpecifier(
|
|
21
|
+
importerDir: string,
|
|
22
|
+
targetDir: string,
|
|
23
|
+
relativeFilePath: string,
|
|
24
|
+
): string {
|
|
25
|
+
const absPath = join(targetDir, relativeFilePath);
|
|
26
|
+
const rel = toPosixPath(relative(importerDir, absPath));
|
|
27
|
+
const specifier = rel.startsWith(".") ? rel : `./${rel}`;
|
|
28
|
+
return stripScriptExtension(specifier) + ".js";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Canonical root directory: Node's dirname returns "." for root-level files; we use "" consistently. */
|
|
32
|
+
function normalizeDir(dir: string): string {
|
|
33
|
+
return dir === "." ? "" : dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True iff the companion path is directory-level (e.g. api/_layout.ts), not sibling (e.g. route.layout.ts). */
|
|
37
|
+
function isDirectoryCompanion(p: string): boolean {
|
|
38
|
+
return basename(p).startsWith("_");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Directory path -> companion paths for that directory (only _layout, _dependencies, _catch; guard is per-route). */
|
|
42
|
+
function directoryCompanionPaths(
|
|
43
|
+
descriptors: readonly RouteDescriptor[],
|
|
44
|
+
): Map<string, { layout?: string; dependencies?: string; catch?: string }> {
|
|
45
|
+
const map = new Map<string, { layout?: string; dependencies?: string; catch?: string }>();
|
|
46
|
+
for (const d of descriptors) {
|
|
47
|
+
for (const kind of ["layout", "dependencies", "catch"] as const) {
|
|
48
|
+
for (const p of d.composedConcerns[kind]) {
|
|
49
|
+
if (!isDirectoryCompanion(p)) continue;
|
|
50
|
+
const dir = normalizeDir(dirname(p));
|
|
51
|
+
let entry = map.get(dir);
|
|
52
|
+
if (!entry) {
|
|
53
|
+
entry = {};
|
|
54
|
+
map.set(dir, entry);
|
|
55
|
+
}
|
|
56
|
+
if (!entry[kind]) entry[kind] = p;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return map;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Collect unique paths in leaf→ancestor order (closest to route first; first occurrence wins). */
|
|
64
|
+
function collectOrderedCompanionPaths(
|
|
65
|
+
descriptors: readonly RouteDescriptor[],
|
|
66
|
+
kind: ConcernKind,
|
|
67
|
+
): readonly string[] {
|
|
68
|
+
const seen = new Set<string>();
|
|
69
|
+
const out: string[] = [];
|
|
70
|
+
for (const d of descriptors) {
|
|
71
|
+
const paths = d.composedConcerns[kind];
|
|
72
|
+
for (const p of paths) {
|
|
73
|
+
if (!seen.has(p)) {
|
|
74
|
+
seen.add(p);
|
|
75
|
+
out.push(p);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Emit Router.merge(...) of directory matchers. Each route compiles to .match(route, { handler, ...opts })
|
|
84
|
+
* with opts only from in-file or sibling. Directory companions (_layout, _dependencies, _catch) apply to
|
|
85
|
+
* all routes in that directory and are added once per directory via .layout(), .provide(), .catchCause().
|
|
86
|
+
*/
|
|
87
|
+
export function emitRouterMatchSource(
|
|
88
|
+
descriptors: readonly RouteDescriptor[],
|
|
89
|
+
targetDirectory: string,
|
|
90
|
+
importer: string,
|
|
91
|
+
guardExportByPath: GuardExportByPath,
|
|
92
|
+
catchExportByPath: CatchExportByPath,
|
|
93
|
+
catchFormByPath: CatchFormByPath,
|
|
94
|
+
depsFormByPath: DepsFormByPath,
|
|
95
|
+
): string {
|
|
96
|
+
const importerDir = dirname(toPosixPath(importer));
|
|
97
|
+
const needsFnErrorImports = Object.values(catchFormByPath).some((f) => f.form === "fn-error");
|
|
98
|
+
const needsLayerImport = Object.values(depsFormByPath).includes("servicemap");
|
|
99
|
+
const depPaths = collectOrderedCompanionPaths(descriptors, "dependencies");
|
|
100
|
+
const layoutPaths = collectOrderedCompanionPaths(descriptors, "layout");
|
|
101
|
+
const guardPaths = collectOrderedCompanionPaths(descriptors, "guard");
|
|
102
|
+
const catchPaths = collectOrderedCompanionPaths(descriptors, "catch");
|
|
103
|
+
|
|
104
|
+
const nameEntries: { path: string; proposedName: string }[] = [
|
|
105
|
+
...descriptors.map((d) => ({
|
|
106
|
+
path: d.filePath,
|
|
107
|
+
proposedName: routeModuleIdentifier(d.filePath),
|
|
108
|
+
})),
|
|
109
|
+
...depPaths.map((p) => ({ path: p, proposedName: pathToIdentifier(p) })),
|
|
110
|
+
...layoutPaths.map((p) => ({ path: p, proposedName: pathToIdentifier(p) })),
|
|
111
|
+
...guardPaths.map((p) => ({ path: p, proposedName: pathToIdentifier(p) })),
|
|
112
|
+
...catchPaths.map((p) => ({ path: p, proposedName: pathToIdentifier(p) })),
|
|
113
|
+
];
|
|
114
|
+
const varNameByPath = makeUniqueVarNames(nameEntries);
|
|
115
|
+
|
|
116
|
+
const importLines: string[] = [
|
|
117
|
+
`import * as Router from "@typed/router";`,
|
|
118
|
+
`import * as Fx from "@typed/fx/Fx";`,
|
|
119
|
+
`import { constant } from "effect/Function";`,
|
|
120
|
+
...(needsFnErrorImports
|
|
121
|
+
? [
|
|
122
|
+
`import * as Effect from "effect/Effect";`,
|
|
123
|
+
`import * as Cause from "effect/Cause";`,
|
|
124
|
+
`import * as Result from "effect/Result";`,
|
|
125
|
+
]
|
|
126
|
+
: []),
|
|
127
|
+
...(needsLayerImport ? [`import * as Layer from "effect/Layer";`] : []),
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
for (const d of descriptors) {
|
|
131
|
+
const spec = toImportSpecifier(importerDir, targetDirectory, d.filePath);
|
|
132
|
+
const varName = varNameByPath.get(d.filePath)!;
|
|
133
|
+
importLines.push(`import * as ${varName} from ${JSON.stringify(spec)};`);
|
|
134
|
+
}
|
|
135
|
+
for (const p of depPaths) {
|
|
136
|
+
importLines.push(
|
|
137
|
+
`import * as ${varNameByPath.get(p)} from ${JSON.stringify(toImportSpecifier(importerDir, targetDirectory, p))};`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
for (const p of layoutPaths) {
|
|
141
|
+
importLines.push(
|
|
142
|
+
`import * as ${varNameByPath.get(p)} from ${JSON.stringify(toImportSpecifier(importerDir, targetDirectory, p))};`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
for (const p of guardPaths) {
|
|
146
|
+
importLines.push(
|
|
147
|
+
`import * as ${varNameByPath.get(p)} from ${JSON.stringify(toImportSpecifier(importerDir, targetDirectory, p))};`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
for (const p of catchPaths) {
|
|
151
|
+
importLines.push(
|
|
152
|
+
`import * as ${varNameByPath.get(p)} from ${JSON.stringify(toImportSpecifier(importerDir, targetDirectory, p))};`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dirToCompanions = directoryCompanionPaths(descriptors);
|
|
157
|
+
const descriptorTree = buildRouterDescriptorTree({
|
|
158
|
+
descriptors,
|
|
159
|
+
dirToCompanions,
|
|
160
|
+
guardExportByPath,
|
|
161
|
+
catchExportByPath,
|
|
162
|
+
catchFormByPath,
|
|
163
|
+
normalizeDir,
|
|
164
|
+
isDirectoryCompanion,
|
|
165
|
+
siblingCompanionPath,
|
|
166
|
+
});
|
|
167
|
+
const handlerExprForMatch = (match: RouteMatchDescriptor, varName: string) =>
|
|
168
|
+
handlerExprFor(
|
|
169
|
+
match.runtimeKind,
|
|
170
|
+
match.entrypointIsFunction,
|
|
171
|
+
match.entrypointExpectsRefSubject,
|
|
172
|
+
varName,
|
|
173
|
+
match.entrypointExport,
|
|
174
|
+
);
|
|
175
|
+
const rootSource = renderRouterDescriptorTree(descriptorTree, {
|
|
176
|
+
varNameByPath,
|
|
177
|
+
guardExportByPath,
|
|
178
|
+
catchExportByPath,
|
|
179
|
+
catchFormByPath,
|
|
180
|
+
depsFormByPath,
|
|
181
|
+
handlerExprFor: handlerExprForMatch,
|
|
182
|
+
catchExprFor,
|
|
183
|
+
depsExprFor,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return `${importLines.join("\n")}
|
|
187
|
+
|
|
188
|
+
const router = ${rootSource};
|
|
189
|
+
export default router;
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract path and method from endpoint TypeInfo snapshot.
|
|
3
|
+
* Uses TypeInfoApi export types instead of parsing source with TypeScript.
|
|
4
|
+
* Handles literal, object, and reference (e.g. Route<P,S>) shapes; unknown kinds return null.
|
|
5
|
+
*/
|
|
6
|
+
import type {
|
|
7
|
+
LiteralTypeNode,
|
|
8
|
+
ObjectTypeNode,
|
|
9
|
+
ReferenceTypeNode,
|
|
10
|
+
TypeInfoFileSnapshot,
|
|
11
|
+
TypeNode,
|
|
12
|
+
} from "@typed/virtual-modules";
|
|
13
|
+
|
|
14
|
+
export interface ExtractedEndpointLiterals {
|
|
15
|
+
readonly path: string;
|
|
16
|
+
readonly method: string;
|
|
17
|
+
readonly name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getLiteralText(node: TypeNode | undefined): string | null {
|
|
21
|
+
if (!node || node.kind !== "literal") return null;
|
|
22
|
+
return (node as LiteralTypeNode).text || null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getPathFromRouteType(type: TypeNode): string | null {
|
|
26
|
+
if (type.kind === "literal") {
|
|
27
|
+
const raw = (type as LiteralTypeNode).text;
|
|
28
|
+
return raw ? (raw.startsWith("/") ? raw : `/${raw}`) : null;
|
|
29
|
+
}
|
|
30
|
+
if (type.kind === "object") {
|
|
31
|
+
const pathProp = (type as ObjectTypeNode).properties.find((p) => p.name === "path");
|
|
32
|
+
if (!pathProp) return null;
|
|
33
|
+
const raw = getLiteralText(pathProp.type);
|
|
34
|
+
return raw ? (raw.startsWith("/") ? raw : `/${raw}`) : null;
|
|
35
|
+
}
|
|
36
|
+
if (type.kind === "reference") {
|
|
37
|
+
const typeArgs = (type as ReferenceTypeNode).typeArguments;
|
|
38
|
+
if (typeArgs?.length) return getPathFromRouteType(typeArgs[0]);
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extracts route path and method from TypeInfo snapshot exports.
|
|
45
|
+
* Reads from route and method exports; path from route type (literal or object.path).
|
|
46
|
+
*/
|
|
47
|
+
export function extractEndpointLiterals(
|
|
48
|
+
snapshot: TypeInfoFileSnapshot,
|
|
49
|
+
stem: string,
|
|
50
|
+
): ExtractedEndpointLiterals {
|
|
51
|
+
let path = `/${stem}`;
|
|
52
|
+
let method = "GET";
|
|
53
|
+
|
|
54
|
+
const routeExport = snapshot.exports.find((e) => e.name === "route");
|
|
55
|
+
if (routeExport) {
|
|
56
|
+
const p = getPathFromRouteType(routeExport.type);
|
|
57
|
+
if (p) path = p;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const methodExport = snapshot.exports.find((e) => e.name === "method");
|
|
61
|
+
if (methodExport) {
|
|
62
|
+
const m = getLiteralText(methodExport.type);
|
|
63
|
+
if (m) method = m.toUpperCase();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return { path, method, name: stem };
|
|
67
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem tree AST for HttpApi virtual module discovery.
|
|
3
|
+
* Converts role-classified artifacts into a deterministic tree (ApiRoot, Group, PathlessDirectory, Endpoint, Convention metadata).
|
|
4
|
+
* Parenthesized pathless directories do not create a named group but preserve composition order.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
HttpApiDirectoryCompanionKind,
|
|
9
|
+
HttpApiEndpointCompanionKind,
|
|
10
|
+
HttpApiFileRole,
|
|
11
|
+
} from "./httpapiFileRoles.js";
|
|
12
|
+
import {
|
|
13
|
+
compareHttpApiPathOrder,
|
|
14
|
+
sortHttpApiPaths,
|
|
15
|
+
} from "./httpapiFileRoles.js";
|
|
16
|
+
|
|
17
|
+
/** Path segment is pathless when it matches (name) and does not create an HttpApiGroup. */
|
|
18
|
+
const PATHLESS_DIRECTORY_PATTERN = /^\([^)]*\)$/;
|
|
19
|
+
|
|
20
|
+
export function isPathlessDirectorySegment(segment: string): boolean {
|
|
21
|
+
return PATHLESS_DIRECTORY_PATTERN.test(segment);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Reference to a directory-level convention file (e.g. _dependencies.ts). */
|
|
25
|
+
export type DirectoryConventionRef = {
|
|
26
|
+
readonly path: string;
|
|
27
|
+
readonly kind: HttpApiDirectoryCompanionKind;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Reference to API root or group override convention. */
|
|
31
|
+
export type RootOrGroupConventionRef = {
|
|
32
|
+
readonly path: string;
|
|
33
|
+
readonly kind: "api_root" | "group_override";
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Reference to an endpoint companion file (e.g. list.openapi.ts). */
|
|
37
|
+
export type EndpointCompanionRef = {
|
|
38
|
+
readonly path: string;
|
|
39
|
+
readonly kind: HttpApiEndpointCompanionKind;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Leaf node: endpoint primary with optional companion refs. */
|
|
43
|
+
export type HttpApiEndpointNode = {
|
|
44
|
+
readonly type: "endpoint";
|
|
45
|
+
readonly path: string;
|
|
46
|
+
/** Filename stem without extension (e.g. "list"). */
|
|
47
|
+
readonly stem: string;
|
|
48
|
+
readonly companions: readonly EndpointCompanionRef[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Directory that does not create a named group (e.g. (internal)). */
|
|
52
|
+
export type HttpApiPathlessDirectoryNode = {
|
|
53
|
+
readonly type: "pathless_directory";
|
|
54
|
+
/** Full relative dir path (e.g. "users/(internal)"). */
|
|
55
|
+
readonly dirPath: string;
|
|
56
|
+
readonly conventions: readonly (DirectoryConventionRef | RootOrGroupConventionRef)[];
|
|
57
|
+
readonly children: readonly HttpApiTreeNode[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Named group directory (creates HttpApiGroup). */
|
|
61
|
+
export type HttpApiGroupNode = {
|
|
62
|
+
readonly type: "group";
|
|
63
|
+
/** Group name (default from directory segment, override from _group.ts). */
|
|
64
|
+
readonly groupName: string;
|
|
65
|
+
/** Full relative dir path (e.g. "users"). */
|
|
66
|
+
readonly dirPath: string;
|
|
67
|
+
readonly children: readonly HttpApiTreeNode[];
|
|
68
|
+
readonly conventions: readonly (DirectoryConventionRef | RootOrGroupConventionRef)[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export type HttpApiTreeNode =
|
|
72
|
+
| HttpApiEndpointNode
|
|
73
|
+
| HttpApiGroupNode
|
|
74
|
+
| HttpApiPathlessDirectoryNode;
|
|
75
|
+
|
|
76
|
+
/** Root of the descriptor tree. */
|
|
77
|
+
export type HttpApiDescriptorTree = {
|
|
78
|
+
readonly type: "api_root";
|
|
79
|
+
readonly dirPath: "";
|
|
80
|
+
readonly children: readonly HttpApiTreeNode[];
|
|
81
|
+
readonly conventions: readonly (DirectoryConventionRef | RootOrGroupConventionRef)[];
|
|
82
|
+
/** Diagnostics-ready: unsupported roles collected during parse. */
|
|
83
|
+
readonly diagnostics: readonly HttpApiTreeDiagnostic[];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export type HttpApiTreeDiagnostic = {
|
|
87
|
+
readonly code: string;
|
|
88
|
+
readonly message: string;
|
|
89
|
+
readonly path: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type BuildHttpApiDescriptorTreeInput = {
|
|
93
|
+
/** Classified file roles (supported and unsupported_reserved). */
|
|
94
|
+
readonly roles: readonly HttpApiFileRole[];
|
|
95
|
+
/** Optional: only include paths under this base (relative); not used for filtering if empty. */
|
|
96
|
+
readonly baseDir?: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Builds a deterministic tree AST from classified file roles.
|
|
101
|
+
* Pathless directory segments (e.g. "(internal)") are represented as PathlessDirectoryNode and do not create a group name.
|
|
102
|
+
*/
|
|
103
|
+
export function buildHttpApiDescriptorTree(
|
|
104
|
+
input: BuildHttpApiDescriptorTreeInput,
|
|
105
|
+
): HttpApiDescriptorTree {
|
|
106
|
+
const { roles } = input;
|
|
107
|
+
const diagnostics: HttpApiTreeDiagnostic[] = [];
|
|
108
|
+
const supported: HttpApiFileRole[] = [];
|
|
109
|
+
for (const r of roles) {
|
|
110
|
+
if (r.role === "unsupported_reserved") {
|
|
111
|
+
diagnostics.push({
|
|
112
|
+
code: r.diagnosticCode,
|
|
113
|
+
message: r.diagnosticMessage,
|
|
114
|
+
path: r.path,
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
supported.push(r);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const apiRootConventions: (DirectoryConventionRef | RootOrGroupConventionRef)[] = [];
|
|
122
|
+
const directoryConventionsByDir = new Map<string, (DirectoryConventionRef | RootOrGroupConventionRef)[]>();
|
|
123
|
+
const endpointCompanionsByKey = new Map<string, EndpointCompanionRef[]>();
|
|
124
|
+
|
|
125
|
+
for (const r of supported) {
|
|
126
|
+
if (r.role === "api_root") {
|
|
127
|
+
apiRootConventions.push({ path: r.path, kind: "api_root" });
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (r.role === "group_override") {
|
|
131
|
+
const dir = dirname(r.path);
|
|
132
|
+
directoryConventionsByDir.set(dir, [
|
|
133
|
+
...(directoryConventionsByDir.get(dir) ?? []),
|
|
134
|
+
{ path: r.path, kind: "group_override" },
|
|
135
|
+
]);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (r.role === "directory_companion") {
|
|
139
|
+
const dir = dirname(r.path);
|
|
140
|
+
const list = directoryConventionsByDir.get(dir) ?? [];
|
|
141
|
+
list.push({ path: r.path, kind: r.kind });
|
|
142
|
+
directoryConventionsByDir.set(dir, list);
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (r.role === "endpoint_primary") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (r.role === "endpoint_companion") {
|
|
149
|
+
const dir = dirname(r.path);
|
|
150
|
+
const key = dir ? `${dir}/${r.endpointStem}` : r.endpointStem;
|
|
151
|
+
const list = endpointCompanionsByKey.get(key) ?? [];
|
|
152
|
+
list.push({ path: r.path, kind: r.kind });
|
|
153
|
+
endpointCompanionsByKey.set(key, list);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const allDirs = new Set<string>();
|
|
158
|
+
function addDirAndAncestors(d: string): void {
|
|
159
|
+
allDirs.add(d);
|
|
160
|
+
const idx = d.lastIndexOf("/");
|
|
161
|
+
if (idx > 0) addDirAndAncestors(d.slice(0, idx));
|
|
162
|
+
}
|
|
163
|
+
for (const r of supported) {
|
|
164
|
+
const p = "path" in r ? r.path : "";
|
|
165
|
+
addDirAndAncestors(dirname(p));
|
|
166
|
+
}
|
|
167
|
+
for (const [dir] of directoryConventionsByDir) {
|
|
168
|
+
addDirAndAncestors(dir);
|
|
169
|
+
}
|
|
170
|
+
const sortedDirs = sortHttpApiPaths([...allDirs]);
|
|
171
|
+
|
|
172
|
+
const dirSegments = (dirPath: string): string[] =>
|
|
173
|
+
dirPath ? dirPath.split("/").filter(Boolean) : [];
|
|
174
|
+
|
|
175
|
+
const childrenByParentDir = new Map<string, string[]>();
|
|
176
|
+
for (const d of sortedDirs) {
|
|
177
|
+
const segments = dirSegments(d);
|
|
178
|
+
if (segments.length === 0) continue;
|
|
179
|
+
const parent = segments.length === 1 ? "" : segments.slice(0, -1).join("/");
|
|
180
|
+
const list = childrenByParentDir.get(parent) ?? [];
|
|
181
|
+
if (!list.includes(d)) list.push(d);
|
|
182
|
+
childrenByParentDir.set(parent, list);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function buildNode(dirPath: string): HttpApiTreeNode[] {
|
|
186
|
+
const directFiles = supported
|
|
187
|
+
.filter((r) => {
|
|
188
|
+
if (r.role !== "endpoint_primary") return false;
|
|
189
|
+
const p = r.path;
|
|
190
|
+
const d = dirname(p);
|
|
191
|
+
return d === dirPath;
|
|
192
|
+
})
|
|
193
|
+
.map((r) => r as { role: "endpoint_primary"; path: string })
|
|
194
|
+
.sort((a, b) => compareHttpApiPathOrder(a.path, b.path));
|
|
195
|
+
|
|
196
|
+
const childDirs = (childrenByParentDir.get(dirPath) ?? []).slice().sort(compareHttpApiPathOrder);
|
|
197
|
+
const nodes: HttpApiTreeNode[] = [];
|
|
198
|
+
|
|
199
|
+
for (const f of directFiles) {
|
|
200
|
+
const stem = stemFromPath(f.path);
|
|
201
|
+
const companionKey = dirPath ? `${dirPath}/${stem}` : stem;
|
|
202
|
+
const companions = endpointCompanionsByKey.get(companionKey) ?? [];
|
|
203
|
+
nodes.push({
|
|
204
|
+
type: "endpoint",
|
|
205
|
+
path: f.path,
|
|
206
|
+
stem,
|
|
207
|
+
companions: [...companions].sort((a, b) => compareHttpApiPathOrder(a.path, b.path)),
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const childDir of childDirs) {
|
|
212
|
+
const segments = dirSegments(childDir);
|
|
213
|
+
const segmentName = segments[segments.length - 1] ?? "";
|
|
214
|
+
if (isPathlessDirectorySegment(segmentName)) {
|
|
215
|
+
const childNodes = buildNode(childDir);
|
|
216
|
+
const conventions = directoryConventionsByDir.get(childDir) ?? [];
|
|
217
|
+
nodes.push({
|
|
218
|
+
type: "pathless_directory",
|
|
219
|
+
dirPath: childDir,
|
|
220
|
+
conventions: [...conventions].sort((a, b) => compareHttpApiPathOrder(a.path, b.path)),
|
|
221
|
+
children: childNodes.length === 0 ? [] : childNodes.slice().sort(compareTreeNodes),
|
|
222
|
+
});
|
|
223
|
+
} else {
|
|
224
|
+
const groupName = segmentName;
|
|
225
|
+
const childNodes = buildNode(childDir);
|
|
226
|
+
const conventions = directoryConventionsByDir.get(childDir) ?? [];
|
|
227
|
+
nodes.push({
|
|
228
|
+
type: "group",
|
|
229
|
+
groupName,
|
|
230
|
+
dirPath: childDir,
|
|
231
|
+
children: childNodes.length === 0 ? [] : childNodes.slice().sort(compareTreeNodes),
|
|
232
|
+
conventions: [...conventions].sort((a, b) => compareHttpApiPathOrder(a.path, b.path)),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return nodes.slice().sort(compareTreeNodes);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const rootChildren = buildNode("");
|
|
241
|
+
const rootConventions = [...apiRootConventions];
|
|
242
|
+
const rootDirConventions = directoryConventionsByDir.get("") ?? [];
|
|
243
|
+
for (const c of rootDirConventions) {
|
|
244
|
+
rootConventions.push(c);
|
|
245
|
+
}
|
|
246
|
+
rootConventions.sort((a, b) => compareHttpApiPathOrder(a.path, b.path));
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
type: "api_root",
|
|
250
|
+
dirPath: "",
|
|
251
|
+
children: rootChildren,
|
|
252
|
+
conventions: rootConventions,
|
|
253
|
+
diagnostics,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function dirname(p: string): string {
|
|
258
|
+
const i = p.lastIndexOf("/");
|
|
259
|
+
return i < 0 ? "" : p.slice(0, i);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function stemFromPath(path: string): string {
|
|
263
|
+
const base = path.slice(path.lastIndexOf("/") + 1);
|
|
264
|
+
const extIdx = base.lastIndexOf(".");
|
|
265
|
+
return extIdx <= 0 ? base : base.slice(0, extIdx);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function nodeSortKey(n: HttpApiTreeNode): string {
|
|
269
|
+
switch (n.type) {
|
|
270
|
+
case "endpoint":
|
|
271
|
+
return n.path;
|
|
272
|
+
case "group":
|
|
273
|
+
case "pathless_directory":
|
|
274
|
+
return n.dirPath;
|
|
275
|
+
default:
|
|
276
|
+
return "";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function compareTreeNodes(a: HttpApiTreeNode, b: HttpApiTreeNode): number {
|
|
281
|
+
return compareHttpApiPathOrder(nodeSortKey(a), nodeSortKey(b));
|
|
282
|
+
}
|
|
283
|
+
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Endpoint contract validation for HttpApi virtual module.
|
|
3
|
+
* Validates required fields (route, method, handler). Optional: headers, body, error, success.
|
|
4
|
+
* Route covers path + query; headers/body mirror decoders from HttpServerRequest;
|
|
5
|
+
* error/success encode response payloads with annotated status codes.
|
|
6
|
+
* Handler returns require structural type checking (assignable to success/error schemas)
|
|
7
|
+
* and coercion into HttpServerResponse via Effect's HttpApi layer.
|
|
8
|
+
*
|
|
9
|
+
* @see .docs/specs/httpapi-virtual-module-plugin/spec.md (Endpoint contract, Endpoint Contract Validator)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ValidationResult } from "./validation.js";
|
|
13
|
+
import { validateNonEmptyString } from "./validation.js";
|
|
14
|
+
|
|
15
|
+
/** Supported HTTP method literals for endpoint contract */
|
|
16
|
+
const HTTP_METHODS = new Set<string>([
|
|
17
|
+
"GET",
|
|
18
|
+
"POST",
|
|
19
|
+
"PUT",
|
|
20
|
+
"PATCH",
|
|
21
|
+
"DELETE",
|
|
22
|
+
"HEAD",
|
|
23
|
+
"OPTIONS",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Minimal endpoint contract shape (runtime check) */
|
|
27
|
+
export interface EndpointContractInput {
|
|
28
|
+
readonly route?: unknown;
|
|
29
|
+
readonly method?: unknown;
|
|
30
|
+
readonly handler?: unknown;
|
|
31
|
+
readonly headers?: unknown;
|
|
32
|
+
readonly body?: unknown;
|
|
33
|
+
readonly error?: unknown;
|
|
34
|
+
readonly success?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Validated endpoint contract */
|
|
38
|
+
export interface ValidatedEndpointContract {
|
|
39
|
+
readonly route: unknown;
|
|
40
|
+
readonly method: string;
|
|
41
|
+
readonly handler: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Checks that value is a non-null object (route/schema-like values are objects).
|
|
46
|
+
*/
|
|
47
|
+
function isObject(value: unknown): value is object {
|
|
48
|
+
return typeof value === "object" && value !== null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Validates endpoint contract: required route, method, handler; optional headers, body, error, success.
|
|
53
|
+
*/
|
|
54
|
+
export function validateEndpointContract(
|
|
55
|
+
input: unknown,
|
|
56
|
+
): ValidationResult<ValidatedEndpointContract> {
|
|
57
|
+
if (!isObject(input)) {
|
|
58
|
+
return { ok: false, reason: "Endpoint contract must be an object" };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const contract = input as Record<string, unknown>;
|
|
62
|
+
|
|
63
|
+
if (contract.route === undefined) {
|
|
64
|
+
return { ok: false, reason: "Endpoint contract missing required field: route" };
|
|
65
|
+
}
|
|
66
|
+
if (!isObject(contract.route)) {
|
|
67
|
+
return { ok: false, reason: "Endpoint contract route must be an object (path + pathSchema + querySchema)" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const route = contract.route as Record<string, unknown>;
|
|
71
|
+
if (route.pathSchema === undefined) {
|
|
72
|
+
return { ok: false, reason: "Endpoint contract route must include pathSchema" };
|
|
73
|
+
}
|
|
74
|
+
if (route.querySchema === undefined) {
|
|
75
|
+
return { ok: false, reason: "Endpoint contract route must include querySchema" };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const methodResult = validateNonEmptyString(contract.method, "method");
|
|
79
|
+
if (!methodResult.ok) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: `Endpoint contract method: ${methodResult.reason}`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (!HTTP_METHODS.has(methodResult.value)) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: `Endpoint contract method must be one of: ${[...HTTP_METHODS].join(", ")}`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (contract.handler === undefined) {
|
|
93
|
+
return { ok: false, reason: "Endpoint contract missing required field: handler" };
|
|
94
|
+
}
|
|
95
|
+
if (typeof contract.handler !== "function") {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: "Endpoint contract handler must be a function",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
ok: true,
|
|
104
|
+
value: {
|
|
105
|
+
route: contract.route,
|
|
106
|
+
method: methodResult.value,
|
|
107
|
+
handler: contract.handler,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|