@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,1062 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import ts from "typescript";
|
|
6
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
createTypeInfoApiSession,
|
|
9
|
+
PluginManager,
|
|
10
|
+
resolveTypeTargetsFromSpecs,
|
|
11
|
+
} from "@typed/virtual-modules";
|
|
12
|
+
import type { VirtualModuleBuildError } from "@typed/virtual-modules";
|
|
13
|
+
import {
|
|
14
|
+
createHttpApiVirtualModulePlugin,
|
|
15
|
+
parseHttpApiVirtualModuleId,
|
|
16
|
+
resolveHttpApiTargetDirectory,
|
|
17
|
+
HTTPAPI_TYPE_TARGET_SPECS,
|
|
18
|
+
} from "./index.js";
|
|
19
|
+
import {
|
|
20
|
+
collectExposureRoutes,
|
|
21
|
+
normalizeOpenApiConfig,
|
|
22
|
+
validateOpenApiExposureRouteConflicts,
|
|
23
|
+
validateOpenApiExposureScope,
|
|
24
|
+
validateOpenApiGenerationScope,
|
|
25
|
+
} from "./internal/httpapiOpenApiConfig.js";
|
|
26
|
+
|
|
27
|
+
const tempDirs: string[] = [];
|
|
28
|
+
|
|
29
|
+
const APP_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
30
|
+
|
|
31
|
+
const createTempDir = (): string => {
|
|
32
|
+
const base = join(APP_ROOT, "tmp-httpapi-test");
|
|
33
|
+
try {
|
|
34
|
+
mkdirSync(base, { recursive: true });
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
const dir = mkdtempSync(join(base, "run-"));
|
|
39
|
+
tempDirs.push(dir);
|
|
40
|
+
return dir;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type FixtureSpec = Record<string, string>;
|
|
44
|
+
|
|
45
|
+
const VALID_ENDPOINT_SOURCE = `
|
|
46
|
+
import * as Effect from "effect/Effect";
|
|
47
|
+
import * as Schema from "effect/Schema";
|
|
48
|
+
import * as Route from "@typed/router";
|
|
49
|
+
|
|
50
|
+
export const route = Route.Parse("/status");
|
|
51
|
+
|
|
52
|
+
export const method = "GET";
|
|
53
|
+
export const success = Schema.Struct({ status: Schema.Literal("ok") });
|
|
54
|
+
export const error = Schema.Struct({ message: Schema.String });
|
|
55
|
+
|
|
56
|
+
export const handler = ({ path, query, headers, body }: {
|
|
57
|
+
path: Record<string, string>;
|
|
58
|
+
query: Record<string, string | string[] | undefined>;
|
|
59
|
+
headers: Record<string, string>;
|
|
60
|
+
body: unknown;
|
|
61
|
+
}) => Effect.succeed({ status: "ok" });
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
function createApiFixture(spec: FixtureSpec): {
|
|
65
|
+
root: string;
|
|
66
|
+
importer: string;
|
|
67
|
+
paths: string[];
|
|
68
|
+
} {
|
|
69
|
+
const root = createTempDir();
|
|
70
|
+
const normalized: FixtureSpec = { ...spec };
|
|
71
|
+
if (!("src/entry.ts" in normalized)) {
|
|
72
|
+
normalized["src/entry.ts"] = "export {};";
|
|
73
|
+
}
|
|
74
|
+
const sortedKeys = Object.keys(normalized).sort();
|
|
75
|
+
const paths: string[] = [];
|
|
76
|
+
for (const rel of sortedKeys) {
|
|
77
|
+
const abs = join(root, rel);
|
|
78
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
79
|
+
writeFileSync(abs, normalized[rel], "utf8");
|
|
80
|
+
paths.push(abs);
|
|
81
|
+
}
|
|
82
|
+
const importer = join(root, "src/entry.ts");
|
|
83
|
+
return { root, importer, paths };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const BOOTSTRAP_HTTPAPI_FILE = resolve(
|
|
87
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
88
|
+
"..",
|
|
89
|
+
"src",
|
|
90
|
+
"internal",
|
|
91
|
+
"typeTargetBootstrapHttpApi.ts",
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
function buildApiFromFixture(spec: FixtureSpec) {
|
|
95
|
+
const fixture = createApiFixture(spec);
|
|
96
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
97
|
+
const files =
|
|
98
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
99
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
100
|
+
: fixture.paths;
|
|
101
|
+
const program = makeProgram(files, fixture.root);
|
|
102
|
+
const session = createTypeInfoApiSession({
|
|
103
|
+
ts,
|
|
104
|
+
program,
|
|
105
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
106
|
+
});
|
|
107
|
+
return plugin.build("api:./apis", fixture.importer, session.api);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Extract source text from build result (string or { sourceText, warnings }). */
|
|
111
|
+
function getSourceText(
|
|
112
|
+
result: unknown,
|
|
113
|
+
): string | undefined {
|
|
114
|
+
if (typeof result === "string") return result;
|
|
115
|
+
if (result && typeof result === "object" && "sourceText" in result) {
|
|
116
|
+
return (result as { sourceText?: string }).sourceText;
|
|
117
|
+
}
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const NM = join(APP_ROOT, "node_modules");
|
|
122
|
+
|
|
123
|
+
const HTTPAPI_MODULE_FALLBACKS: Record<string, string> = {
|
|
124
|
+
"@typed/router": join(NM, "@typed", "router", "src", "index.ts"),
|
|
125
|
+
effect: join(NM, "effect", "dist", "index.d.ts"),
|
|
126
|
+
"effect/Effect": join(NM, "effect", "dist", "Effect.d.ts"),
|
|
127
|
+
"effect/Schema": join(NM, "effect", "dist", "Schema.d.ts"),
|
|
128
|
+
"effect/unstable/httpapi/HttpApi": join(NM, "effect", "dist", "unstable", "httpapi", "HttpApi.d.ts"),
|
|
129
|
+
"effect/unstable/httpapi/HttpApiGroup": join(NM, "effect", "dist", "unstable", "httpapi", "HttpApiGroup.d.ts"),
|
|
130
|
+
"effect/unstable/httpapi/HttpApiEndpoint": join(NM, "effect", "dist", "unstable", "httpapi", "HttpApiEndpoint.d.ts"),
|
|
131
|
+
"effect/unstable/httpapi/HttpApiBuilder": join(NM, "effect", "dist", "unstable", "httpapi", "HttpApiBuilder.d.ts"),
|
|
132
|
+
"effect/unstable/http/HttpServerResponse": join(NM, "effect", "dist", "unstable", "http", "HttpServerResponse.d.ts"),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
function makeProgram(rootFiles: readonly string[], fixtureRoot?: string): ts.Program {
|
|
136
|
+
const projectRoot =
|
|
137
|
+
fixtureRoot ?? (rootFiles.length > 0 ? dirname(dirname(rootFiles[0]!)) : APP_ROOT);
|
|
138
|
+
const options: ts.CompilerOptions = {
|
|
139
|
+
strict: true,
|
|
140
|
+
target: ts.ScriptTarget.ESNext,
|
|
141
|
+
module: ts.ModuleKind.ESNext,
|
|
142
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
143
|
+
skipLibCheck: true,
|
|
144
|
+
noEmit: true,
|
|
145
|
+
};
|
|
146
|
+
const defaultHost = ts.createCompilerHost(options);
|
|
147
|
+
const moduleResolutionHost: ts.ModuleResolutionHost = {
|
|
148
|
+
getCurrentDirectory: () => projectRoot,
|
|
149
|
+
fileExists: defaultHost.fileExists?.bind(defaultHost),
|
|
150
|
+
readFile: defaultHost.readFile?.bind(defaultHost),
|
|
151
|
+
useCaseSensitiveFileNames: () => defaultHost.useCaseSensitiveFileNames?.() ?? true,
|
|
152
|
+
};
|
|
153
|
+
const customHost: ts.CompilerHost = {
|
|
154
|
+
...defaultHost,
|
|
155
|
+
getCurrentDirectory: () => projectRoot,
|
|
156
|
+
resolveModuleNames: (
|
|
157
|
+
moduleNames: string[],
|
|
158
|
+
containingFile: string,
|
|
159
|
+
): (ts.ResolvedModule | undefined)[] =>
|
|
160
|
+
moduleNames.map((moduleName) => {
|
|
161
|
+
const resolved = ts.resolveModuleName(
|
|
162
|
+
moduleName,
|
|
163
|
+
containingFile,
|
|
164
|
+
options,
|
|
165
|
+
moduleResolutionHost,
|
|
166
|
+
);
|
|
167
|
+
if (resolved.resolvedModule) return resolved.resolvedModule;
|
|
168
|
+
const fallback = HTTPAPI_MODULE_FALLBACKS[moduleName];
|
|
169
|
+
if (fallback && defaultHost.fileExists?.(fallback)) {
|
|
170
|
+
return {
|
|
171
|
+
resolvedFileName: fallback,
|
|
172
|
+
extension: fallback.endsWith(".ts") ? ts.Extension.Ts : ts.Extension.Js,
|
|
173
|
+
isExternalLibraryImport: false,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return undefined;
|
|
177
|
+
}),
|
|
178
|
+
};
|
|
179
|
+
return ts.createProgram(rootFiles, options, customHost);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
afterEach(() => {
|
|
183
|
+
for (const dir of tempDirs) {
|
|
184
|
+
try {
|
|
185
|
+
rmSync(dir, { recursive: true, force: true });
|
|
186
|
+
} catch {
|
|
187
|
+
// ignore
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
tempDirs.length = 0;
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("parseHttpApiVirtualModuleId", () => {
|
|
194
|
+
it("returns ok with relativeDirectory when id is api:./apis", () => {
|
|
195
|
+
const result = parseHttpApiVirtualModuleId("api:./apis");
|
|
196
|
+
expect(result.ok).toBe(true);
|
|
197
|
+
if (result.ok) expect(result.relativeDirectory).toBe("./apis");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("normalizes api:apis to api:./apis", () => {
|
|
201
|
+
const result = parseHttpApiVirtualModuleId("api:apis");
|
|
202
|
+
expect(result.ok).toBe(true);
|
|
203
|
+
if (result.ok) expect(result.relativeDirectory).toBe("./apis");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("returns not ok when id does not start with prefix", () => {
|
|
207
|
+
expect(parseHttpApiVirtualModuleId("router:./routes")).toMatchInlineSnapshot(`
|
|
208
|
+
{
|
|
209
|
+
"ok": false,
|
|
210
|
+
"reason": "id must start with "api:"",
|
|
211
|
+
}
|
|
212
|
+
`);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("returns not ok when id is empty after prefix", () => {
|
|
216
|
+
const result = parseHttpApiVirtualModuleId("api:");
|
|
217
|
+
expect(result.ok).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("resolveHttpApiTargetDirectory", () => {
|
|
222
|
+
it("resolves api:./apis relative to importer directory", () => {
|
|
223
|
+
const fixture = createApiFixture({ "src/apis/status.ts": "export {};" });
|
|
224
|
+
const result = resolveHttpApiTargetDirectory("api:./apis", fixture.importer);
|
|
225
|
+
expect(result.ok).toBe(true);
|
|
226
|
+
if (result.ok) expect(result.targetDirectory).toContain("apis");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("returns not ok when path escapes base", () => {
|
|
230
|
+
const fixture = createApiFixture({ "src/entry.ts": "export {};" });
|
|
231
|
+
const result = resolveHttpApiTargetDirectory("api:../../../etc", fixture.importer);
|
|
232
|
+
expect(result.ok).toBe(false);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("createHttpApiVirtualModulePlugin", () => {
|
|
237
|
+
it("shouldResolve returns true when target directory exists with .ts files", () => {
|
|
238
|
+
const fixture = createApiFixture({ "src/apis/status.ts": "export {};" });
|
|
239
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
240
|
+
expect(plugin.shouldResolve("api:./apis", fixture.importer)).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("shouldResolve returns false when directory has no script files", () => {
|
|
244
|
+
const fixture = createApiFixture({ "src/apis/readme.txt": "no ts" });
|
|
245
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
246
|
+
expect(plugin.shouldResolve("api:./apis", fixture.importer)).toBe(false);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("shouldResolve returns false when target directory is missing", () => {
|
|
250
|
+
const fixture = createApiFixture({ "src/entry.ts": "export {};" });
|
|
251
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
252
|
+
expect(plugin.shouldResolve("api:./apis", fixture.importer)).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("build renders deterministic HttpApi assembly source when contracts are valid", () => {
|
|
256
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
257
|
+
expect(result).not.toHaveProperty("errors");
|
|
258
|
+
const sourceText =
|
|
259
|
+
typeof result === "string" ? result : (result as { sourceText?: string }).sourceText;
|
|
260
|
+
expect(sourceText).toBeDefined();
|
|
261
|
+
expect(sourceText).toMatchInlineSnapshot(`
|
|
262
|
+
"import { emptyRecordString, emptyRecordStringArray, composeWithLayers, resolveConfig, type AppConfig, type ComputeLayers, type LayerOrGroup, type RunConfig } from "@typed/app";
|
|
263
|
+
import * as Effect from "effect/Effect";
|
|
264
|
+
import * as Layer from "effect/Layer";
|
|
265
|
+
import * as HttpApi from "effect/unstable/httpapi/HttpApi";
|
|
266
|
+
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
|
|
267
|
+
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
|
|
268
|
+
import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
|
|
269
|
+
import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
|
|
270
|
+
import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar";
|
|
271
|
+
import * as HttpApiSwagger from "effect/unstable/httpapi/HttpApiSwagger";
|
|
272
|
+
import * as HttpServer from "effect/unstable/http/HttpServer";
|
|
273
|
+
import * as HttpRouter from "effect/unstable/http/HttpRouter";
|
|
274
|
+
import * as OpenApiModule from "effect/unstable/httpapi/OpenApi";
|
|
275
|
+
import http from "node:http";
|
|
276
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
277
|
+
import * as Status from "./apis/status.js";
|
|
278
|
+
|
|
279
|
+
export const Api = HttpApi.make("apis").add(HttpApiGroup.make("root").add(HttpApiEndpoint.get("status", Status.route.path, { params: Status.route.pathSchema, query: Status.route.querySchema, success: Status.success, error: Status.error })));
|
|
280
|
+
export const ApiLayer = HttpApiBuilder.layer(Api).pipe(Layer.provideMerge(HttpApiBuilder.group(Api, "root", (handlers) => handlers.handle("status", (ctx) => Status.handler({ path: ctx.params ?? emptyRecordString, query: ctx.query ?? emptyRecordStringArray, headers: emptyRecordString, body: undefined })))));
|
|
281
|
+
export const OpenApi = OpenApiModule.fromApi(Api);
|
|
282
|
+
export const Swagger = HttpApiSwagger.layer(Api);
|
|
283
|
+
export const Scalar = HttpApiScalar.layer(Api);
|
|
284
|
+
export const Client = HttpApiClient.make(Api);
|
|
285
|
+
|
|
286
|
+
export const App = <const Layers extends readonly LayerOrGroup[] = []>(
|
|
287
|
+
config?: AppConfig,
|
|
288
|
+
...layersToMergeIntoRouter: Layers
|
|
289
|
+
): Layer.Layer<
|
|
290
|
+
Layer.Success<ComputeLayers<Layers, typeof ApiLayer>>,
|
|
291
|
+
Layer.Error<ComputeLayers<Layers, typeof ApiLayer>>,
|
|
292
|
+
Exclude<Layer.Services<ComputeLayers<Layers, typeof ApiLayer>>, HttpRouter.HttpRouter> | HttpServer.HttpServer
|
|
293
|
+
> => {
|
|
294
|
+
const disableListenLog = config?.disableListenLog ?? false;
|
|
295
|
+
const appLayer = composeWithLayers(ApiLayer, layersToMergeIntoRouter) as ComputeLayers<
|
|
296
|
+
Layers,
|
|
297
|
+
typeof ApiLayer
|
|
298
|
+
>;
|
|
299
|
+
return HttpRouter.serve(appLayer, { disableListenLog })
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
export const serve = <const Layers extends readonly LayerOrGroup[] = []>(
|
|
303
|
+
config?: RunConfig,
|
|
304
|
+
...layersToMergeIntoRouter: Layers
|
|
305
|
+
) =>
|
|
306
|
+
Layer.unwrap(
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
const host = yield* resolveConfig(config?.host, "0.0.0.0");
|
|
309
|
+
const port = yield* resolveConfig(config?.port, 3000);
|
|
310
|
+
const disableListenLog = yield* resolveConfig(config?.disableListenLog, false);
|
|
311
|
+
const appConfig: AppConfig = { disableListenLog };
|
|
312
|
+
const appLayer = App(appConfig, ...layersToMergeIntoRouter);
|
|
313
|
+
const serverLayer = NodeHttpServer.layer(http.createServer, { host, port });
|
|
314
|
+
return appLayer.pipe(Layer.provide(serverLayer));
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
"
|
|
318
|
+
`);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("build returns AVM-LEAF-001 when directory has no endpoint primary modules", () => {
|
|
322
|
+
const result = buildApiFromFixture({
|
|
323
|
+
"src/apis/_api.ts": "export const name = 'x';",
|
|
324
|
+
"src/apis/_group.ts": "export const name = 'group';",
|
|
325
|
+
"src/apis/list.openapi.ts": "export default {};",
|
|
326
|
+
});
|
|
327
|
+
expect(result).toHaveProperty("errors");
|
|
328
|
+
const err = result as VirtualModuleBuildError;
|
|
329
|
+
expect(err.errors[0].code).toBe("AVM-LEAF-001");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("build returns AVM-CONTRACT-002 when endpoint misses required exports", () => {
|
|
333
|
+
const result = buildApiFromFixture({
|
|
334
|
+
"src/apis/status.ts": "export const route = { path: '/status' };",
|
|
335
|
+
});
|
|
336
|
+
expect(result).toHaveProperty("errors");
|
|
337
|
+
expect((result as VirtualModuleBuildError).errors[0]).toMatchInlineSnapshot(`
|
|
338
|
+
{
|
|
339
|
+
"code": "AVM-CONTRACT-002",
|
|
340
|
+
"message": "endpoint "status.ts" missing required export(s): method, handler",
|
|
341
|
+
"pluginName": "httpapi-virtual-module",
|
|
342
|
+
}
|
|
343
|
+
`);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("build returns AVM-CONTRACT-003 when route lacks pathSchema or querySchema", () => {
|
|
347
|
+
const result = buildApiFromFixture({
|
|
348
|
+
"src/apis/status.ts": `
|
|
349
|
+
import * as Effect from "effect/Effect";
|
|
350
|
+
import * as Schema from "effect/Schema";
|
|
351
|
+
export const route = { path: "/status" };
|
|
352
|
+
export const method = "GET";
|
|
353
|
+
export const handler = () => Effect.succeed({});
|
|
354
|
+
`,
|
|
355
|
+
});
|
|
356
|
+
expect(result).toHaveProperty("errors");
|
|
357
|
+
const err = (result as VirtualModuleBuildError).errors;
|
|
358
|
+
expect(err.some((e) => e.code === "AVM-CONTRACT-003")).toBe(true);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("build returns warnings for unsupported reserved files while still emitting source", () => {
|
|
362
|
+
const fixture = createApiFixture({
|
|
363
|
+
"src/apis/users/list.ts": VALID_ENDPOINT_SOURCE,
|
|
364
|
+
"src/apis/users/_unknown.ts": "export {};",
|
|
365
|
+
});
|
|
366
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
367
|
+
const files =
|
|
368
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
369
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
370
|
+
: fixture.paths;
|
|
371
|
+
const program = makeProgram(
|
|
372
|
+
files,
|
|
373
|
+
files.includes(BOOTSTRAP_HTTPAPI_FILE) ? APP_ROOT : fixture.root,
|
|
374
|
+
);
|
|
375
|
+
const session = createTypeInfoApiSession({
|
|
376
|
+
ts,
|
|
377
|
+
program,
|
|
378
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
379
|
+
});
|
|
380
|
+
const result = plugin.build("api:./apis", fixture.importer, session.api);
|
|
381
|
+
|
|
382
|
+
expect(typeof result).toBe("object");
|
|
383
|
+
if (typeof result === "string" || !result || !("sourceText" in result)) {
|
|
384
|
+
throw new Error("expected sourceText + warnings build result");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const r = result as { sourceText: string; warnings?: Array<{ code: string; message?: string }> };
|
|
388
|
+
expect({ sourceText: r.sourceText, warnings: r.warnings }).toMatchInlineSnapshot(`
|
|
389
|
+
{
|
|
390
|
+
"sourceText": "import { emptyRecordString, emptyRecordStringArray, composeWithLayers, resolveConfig, type AppConfig, type ComputeLayers, type LayerOrGroup, type RunConfig } from "@typed/app";
|
|
391
|
+
import * as Effect from "effect/Effect";
|
|
392
|
+
import * as Layer from "effect/Layer";
|
|
393
|
+
import * as HttpApi from "effect/unstable/httpapi/HttpApi";
|
|
394
|
+
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
|
|
395
|
+
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
|
|
396
|
+
import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
|
|
397
|
+
import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
|
|
398
|
+
import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar";
|
|
399
|
+
import * as HttpApiSwagger from "effect/unstable/httpapi/HttpApiSwagger";
|
|
400
|
+
import * as HttpServer from "effect/unstable/http/HttpServer";
|
|
401
|
+
import * as HttpRouter from "effect/unstable/http/HttpRouter";
|
|
402
|
+
import * as OpenApiModule from "effect/unstable/httpapi/OpenApi";
|
|
403
|
+
import http from "node:http";
|
|
404
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
405
|
+
import * as UsersList from "./apis/users/list.js";
|
|
406
|
+
|
|
407
|
+
export const Api = HttpApi.make("apis").add(HttpApiGroup.make("users").add(HttpApiEndpoint.get("list", UsersList.route.path, { params: UsersList.route.pathSchema, query: UsersList.route.querySchema, success: UsersList.success, error: UsersList.error })));
|
|
408
|
+
export const ApiLayer = HttpApiBuilder.layer(Api).pipe(Layer.provideMerge(HttpApiBuilder.group(Api, "users", (handlers) => handlers.handle("list", (ctx) => UsersList.handler({ path: ctx.params ?? emptyRecordString, query: ctx.query ?? emptyRecordStringArray, headers: emptyRecordString, body: undefined })))));
|
|
409
|
+
export const OpenApi = OpenApiModule.fromApi(Api);
|
|
410
|
+
export const Swagger = HttpApiSwagger.layer(Api);
|
|
411
|
+
export const Scalar = HttpApiScalar.layer(Api);
|
|
412
|
+
export const Client = HttpApiClient.make(Api);
|
|
413
|
+
|
|
414
|
+
export const App = <const Layers extends readonly LayerOrGroup[] = []>(
|
|
415
|
+
config?: AppConfig,
|
|
416
|
+
...layersToMergeIntoRouter: Layers
|
|
417
|
+
): Layer.Layer<
|
|
418
|
+
Layer.Success<ComputeLayers<Layers, typeof ApiLayer>>,
|
|
419
|
+
Layer.Error<ComputeLayers<Layers, typeof ApiLayer>>,
|
|
420
|
+
Exclude<Layer.Services<ComputeLayers<Layers, typeof ApiLayer>>, HttpRouter.HttpRouter> | HttpServer.HttpServer
|
|
421
|
+
> => {
|
|
422
|
+
const disableListenLog = config?.disableListenLog ?? false;
|
|
423
|
+
const appLayer = composeWithLayers(ApiLayer, layersToMergeIntoRouter) as ComputeLayers<
|
|
424
|
+
Layers,
|
|
425
|
+
typeof ApiLayer
|
|
426
|
+
>;
|
|
427
|
+
return HttpRouter.serve(appLayer, { disableListenLog })
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
export const serve = <const Layers extends readonly LayerOrGroup[] = []>(
|
|
431
|
+
config?: RunConfig,
|
|
432
|
+
...layersToMergeIntoRouter: Layers
|
|
433
|
+
) =>
|
|
434
|
+
Layer.unwrap(
|
|
435
|
+
Effect.gen(function* () {
|
|
436
|
+
const host = yield* resolveConfig(config?.host, "0.0.0.0");
|
|
437
|
+
const port = yield* resolveConfig(config?.port, 3000);
|
|
438
|
+
const disableListenLog = yield* resolveConfig(config?.disableListenLog, false);
|
|
439
|
+
const appConfig: AppConfig = { disableListenLog };
|
|
440
|
+
const appLayer = App(appConfig, ...layersToMergeIntoRouter);
|
|
441
|
+
const serverLayer = NodeHttpServer.layer(http.createServer, { host, port });
|
|
442
|
+
return appLayer.pipe(Layer.provide(serverLayer));
|
|
443
|
+
}),
|
|
444
|
+
);
|
|
445
|
+
",
|
|
446
|
+
"warnings": [
|
|
447
|
+
{
|
|
448
|
+
"code": "HTTPAPI-ROLE-006",
|
|
449
|
+
"message": "reserved underscore-prefixed filename not in supported matrix: _unknown.ts",
|
|
450
|
+
"pluginName": "httpapi-virtual-module",
|
|
451
|
+
},
|
|
452
|
+
],
|
|
453
|
+
}
|
|
454
|
+
`);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("build returns AVM-ID-001 when virtual module id is invalid", () => {
|
|
458
|
+
const fixture = createApiFixture({ "src/apis/status.ts": "export {};" });
|
|
459
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
460
|
+
const program = makeProgram(fixture.paths);
|
|
461
|
+
const session = createTypeInfoApiSession({ ts, program });
|
|
462
|
+
const result = plugin.build("api:", fixture.importer, session.api);
|
|
463
|
+
expect(result).toHaveProperty("errors");
|
|
464
|
+
const err = result as VirtualModuleBuildError;
|
|
465
|
+
expect(err.errors[0].code).toBe("AVM-ID-001");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("build returns AVM-DISC-001 when target directory does not exist", () => {
|
|
469
|
+
const fixture = createApiFixture({ "src/entry.ts": "export {};" });
|
|
470
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
471
|
+
const program = makeProgram(fixture.paths);
|
|
472
|
+
const session = createTypeInfoApiSession({ ts, program });
|
|
473
|
+
const result = plugin.build("api:./apis", fixture.importer, session.api);
|
|
474
|
+
expect(result).toHaveProperty("errors");
|
|
475
|
+
const err = result as VirtualModuleBuildError;
|
|
476
|
+
expect(err.errors[0].code).toBe("AVM-DISC-001");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it("build returns deterministic output for same input", () => {
|
|
480
|
+
const fixture = createApiFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
481
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
482
|
+
const files =
|
|
483
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
484
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
485
|
+
: fixture.paths;
|
|
486
|
+
const program = makeProgram(
|
|
487
|
+
files,
|
|
488
|
+
files.includes(BOOTSTRAP_HTTPAPI_FILE) ? APP_ROOT : fixture.root,
|
|
489
|
+
);
|
|
490
|
+
const session1 = createTypeInfoApiSession({
|
|
491
|
+
ts,
|
|
492
|
+
program,
|
|
493
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
494
|
+
});
|
|
495
|
+
const session2 = createTypeInfoApiSession({
|
|
496
|
+
ts,
|
|
497
|
+
program,
|
|
498
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
499
|
+
});
|
|
500
|
+
const source1 = plugin.build("api:./apis", fixture.importer, session1.api);
|
|
501
|
+
const source2 = plugin.build("api:./apis", fixture.importer, session2.api);
|
|
502
|
+
expect(typeof source1).toBe(typeof source2);
|
|
503
|
+
if (typeof source1 === "string") expect(source1).toBe(source2);
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
describe("HttpApiVirtualModulePlugin integration", () => {
|
|
508
|
+
it("resolves through PluginManager when target exists with script files", () => {
|
|
509
|
+
const fixture = createApiFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
510
|
+
const files =
|
|
511
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
512
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
513
|
+
: fixture.paths;
|
|
514
|
+
const program = makeProgram(
|
|
515
|
+
files,
|
|
516
|
+
files.includes(BOOTSTRAP_HTTPAPI_FILE) ? APP_ROOT : fixture.root,
|
|
517
|
+
);
|
|
518
|
+
const sessionFactory = () =>
|
|
519
|
+
createTypeInfoApiSession({ ts, program, typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS });
|
|
520
|
+
const manager = new PluginManager([createHttpApiVirtualModulePlugin()]);
|
|
521
|
+
|
|
522
|
+
const resolved = manager.resolveModule({
|
|
523
|
+
id: "api:./apis",
|
|
524
|
+
importer: fixture.importer,
|
|
525
|
+
createTypeInfoApiSession: sessionFactory,
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
expect(resolved.status).toBe("resolved");
|
|
529
|
+
if (resolved.status !== "resolved") return;
|
|
530
|
+
expect(resolved.pluginName).toBe("httpapi-virtual-module");
|
|
531
|
+
expect(resolved.sourceText).toMatchInlineSnapshot(`
|
|
532
|
+
"import { emptyRecordString, emptyRecordStringArray, composeWithLayers, resolveConfig, type AppConfig, type ComputeLayers, type LayerOrGroup, type RunConfig } from "@typed/app";
|
|
533
|
+
import * as Effect from "effect/Effect";
|
|
534
|
+
import * as Layer from "effect/Layer";
|
|
535
|
+
import * as HttpApi from "effect/unstable/httpapi/HttpApi";
|
|
536
|
+
import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder";
|
|
537
|
+
import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
|
|
538
|
+
import * as HttpApiEndpoint from "effect/unstable/httpapi/HttpApiEndpoint";
|
|
539
|
+
import * as HttpApiGroup from "effect/unstable/httpapi/HttpApiGroup";
|
|
540
|
+
import * as HttpApiScalar from "effect/unstable/httpapi/HttpApiScalar";
|
|
541
|
+
import * as HttpApiSwagger from "effect/unstable/httpapi/HttpApiSwagger";
|
|
542
|
+
import * as HttpServer from "effect/unstable/http/HttpServer";
|
|
543
|
+
import * as HttpRouter from "effect/unstable/http/HttpRouter";
|
|
544
|
+
import * as OpenApiModule from "effect/unstable/httpapi/OpenApi";
|
|
545
|
+
import http from "node:http";
|
|
546
|
+
import { NodeHttpServer } from "@effect/platform-node";
|
|
547
|
+
import * as Status from "./apis/status.js";
|
|
548
|
+
|
|
549
|
+
export const Api = HttpApi.make("apis").add(HttpApiGroup.make("root").add(HttpApiEndpoint.get("status", Status.route.path, { params: Status.route.pathSchema, query: Status.route.querySchema, success: Status.success, error: Status.error })));
|
|
550
|
+
export const ApiLayer = HttpApiBuilder.layer(Api).pipe(Layer.provideMerge(HttpApiBuilder.group(Api, "root", (handlers) => handlers.handle("status", (ctx) => Status.handler({ path: ctx.params ?? emptyRecordString, query: ctx.query ?? emptyRecordStringArray, headers: emptyRecordString, body: undefined })))));
|
|
551
|
+
export const OpenApi = OpenApiModule.fromApi(Api);
|
|
552
|
+
export const Swagger = HttpApiSwagger.layer(Api);
|
|
553
|
+
export const Scalar = HttpApiScalar.layer(Api);
|
|
554
|
+
export const Client = HttpApiClient.make(Api);
|
|
555
|
+
|
|
556
|
+
export const App = <const Layers extends readonly LayerOrGroup[] = []>(
|
|
557
|
+
config?: AppConfig,
|
|
558
|
+
...layersToMergeIntoRouter: Layers
|
|
559
|
+
): Layer.Layer<
|
|
560
|
+
Layer.Success<ComputeLayers<Layers, typeof ApiLayer>>,
|
|
561
|
+
Layer.Error<ComputeLayers<Layers, typeof ApiLayer>>,
|
|
562
|
+
Exclude<Layer.Services<ComputeLayers<Layers, typeof ApiLayer>>, HttpRouter.HttpRouter> | HttpServer.HttpServer
|
|
563
|
+
> => {
|
|
564
|
+
const disableListenLog = config?.disableListenLog ?? false;
|
|
565
|
+
const appLayer = composeWithLayers(ApiLayer, layersToMergeIntoRouter) as ComputeLayers<
|
|
566
|
+
Layers,
|
|
567
|
+
typeof ApiLayer
|
|
568
|
+
>;
|
|
569
|
+
return HttpRouter.serve(appLayer, { disableListenLog })
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
export const serve = <const Layers extends readonly LayerOrGroup[] = []>(
|
|
573
|
+
config?: RunConfig,
|
|
574
|
+
...layersToMergeIntoRouter: Layers
|
|
575
|
+
) =>
|
|
576
|
+
Layer.unwrap(
|
|
577
|
+
Effect.gen(function* () {
|
|
578
|
+
const host = yield* resolveConfig(config?.host, "0.0.0.0");
|
|
579
|
+
const port = yield* resolveConfig(config?.port, 3000);
|
|
580
|
+
const disableListenLog = yield* resolveConfig(config?.disableListenLog, false);
|
|
581
|
+
const appConfig: AppConfig = { disableListenLog };
|
|
582
|
+
const appLayer = App(appConfig, ...layersToMergeIntoRouter);
|
|
583
|
+
const serverLayer = NodeHttpServer.layer(http.createServer, { host, port });
|
|
584
|
+
return appLayer.pipe(Layer.provide(serverLayer));
|
|
585
|
+
}),
|
|
586
|
+
);
|
|
587
|
+
"
|
|
588
|
+
`);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it("returns unresolved when id does not match", () => {
|
|
592
|
+
const { importer } = createApiFixture({});
|
|
593
|
+
const manager = new PluginManager([createHttpApiVirtualModulePlugin()]);
|
|
594
|
+
const resolved = manager.resolveModule({ id: "router:./routes", importer });
|
|
595
|
+
expect(resolved.status).toBe("unresolved");
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
describe("resolveTypeTargetsFromSpecs with HTTPAPI_TYPE_TARGET_SPECS", () => {
|
|
600
|
+
it("returns array (possibly empty) from program without bootstrap", () => {
|
|
601
|
+
const fixture = createApiFixture({ "src/apis/status.ts": "export {};" });
|
|
602
|
+
const program = makeProgram(fixture.paths);
|
|
603
|
+
const targets = resolveTypeTargetsFromSpecs(program, ts, HTTPAPI_TYPE_TARGET_SPECS);
|
|
604
|
+
expect(Array.isArray(targets)).toBe(true);
|
|
605
|
+
const targetIds = targets.map((t) => t.id).sort();
|
|
606
|
+
expect(targetIds).toMatchInlineSnapshot(`[]`);
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe("explicit type target resolution", () => {
|
|
610
|
+
it("resolves all HTTPAPI_TYPE_TARGET_SPECS when bootstrap in program", () => {
|
|
611
|
+
const fixture = createApiFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
612
|
+
const files =
|
|
613
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
614
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
615
|
+
: fixture.paths;
|
|
616
|
+
const program = makeProgram(files, fixture.root);
|
|
617
|
+
const targets = resolveTypeTargetsFromSpecs(program, ts, HTTPAPI_TYPE_TARGET_SPECS);
|
|
618
|
+
const targetIds = targets.map((t) => t.id).sort();
|
|
619
|
+
expect(targetIds).toMatchInlineSnapshot(`
|
|
620
|
+
[
|
|
621
|
+
"Effect",
|
|
622
|
+
"HttpApi",
|
|
623
|
+
"HttpApiEndpoint",
|
|
624
|
+
"HttpApiGroup",
|
|
625
|
+
"HttpServerResponse",
|
|
626
|
+
"Route",
|
|
627
|
+
"Schema",
|
|
628
|
+
]
|
|
629
|
+
`);
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("route with local Route module and typeMember: assignableTo.Route is true", () => {
|
|
634
|
+
const routeSource = `
|
|
635
|
+
export interface Route<P, S> { readonly path: P; readonly schema: S }
|
|
636
|
+
export namespace Route {
|
|
637
|
+
export type Any = Route<any, any>;
|
|
638
|
+
export const Parse = <P extends string>(path: P): Route<P, any> =>
|
|
639
|
+
({ path, schema: {} } as Route<P, any>);
|
|
640
|
+
}
|
|
641
|
+
`;
|
|
642
|
+
const endpointSource = `
|
|
643
|
+
import * as Route from "../route.js";
|
|
644
|
+
export const route = Route.Parse("/status");
|
|
645
|
+
export const method = "GET";
|
|
646
|
+
export const handler = () => ({});
|
|
647
|
+
`;
|
|
648
|
+
const fixture = createApiFixture({
|
|
649
|
+
"src/route.ts": routeSource,
|
|
650
|
+
"src/apis/status.ts": endpointSource,
|
|
651
|
+
});
|
|
652
|
+
const files = fixture.paths;
|
|
653
|
+
const program = makeProgram(files, fixture.root);
|
|
654
|
+
const specs = [
|
|
655
|
+
{ id: "Route", module: "../route.js", exportName: "Route", typeMember: "Any" },
|
|
656
|
+
] as const;
|
|
657
|
+
const session = createTypeInfoApiSession({
|
|
658
|
+
ts,
|
|
659
|
+
program,
|
|
660
|
+
typeTargetSpecs: specs,
|
|
661
|
+
failWhenNoTargetsResolved: false,
|
|
662
|
+
});
|
|
663
|
+
const result = session.api.file("src/apis/status.ts", { baseDir: fixture.root });
|
|
664
|
+
expect(result.ok).toBe(true);
|
|
665
|
+
if (!result.ok) return;
|
|
666
|
+
const routeExport = result.snapshot.exports.find((e) => e.name === "route");
|
|
667
|
+
expect(routeExport).toBeDefined();
|
|
668
|
+
expect(session.api.isAssignableTo(routeExport!.type, "Route")).toBe(true);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("route export has assignableTo.Route when fixture uses Route.Parse and bootstrap present", () => {
|
|
672
|
+
const fixture = createApiFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
673
|
+
const files =
|
|
674
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
675
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
676
|
+
: fixture.paths;
|
|
677
|
+
const program = makeProgram(files, fixture.root);
|
|
678
|
+
const session = createTypeInfoApiSession({
|
|
679
|
+
ts,
|
|
680
|
+
program,
|
|
681
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
682
|
+
});
|
|
683
|
+
const result = session.api.file("src/apis/status.ts", { baseDir: fixture.root });
|
|
684
|
+
expect(result.ok).toBe(true);
|
|
685
|
+
if (!result.ok) return;
|
|
686
|
+
const routeExport = result.snapshot.exports.find((e) => e.name === "route");
|
|
687
|
+
expect(routeExport).toBeDefined();
|
|
688
|
+
expect(session.api.isAssignableTo(routeExport!.type, "Route")).toBe(true);
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
describe("explicit assignability against @typed/router and effect/*", () => {
|
|
692
|
+
it("status endpoint exports have expected assignability (route, handler, success, error)", () => {
|
|
693
|
+
const fixture = createApiFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
694
|
+
const files =
|
|
695
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
696
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
697
|
+
: fixture.paths;
|
|
698
|
+
const program = makeProgram(
|
|
699
|
+
files,
|
|
700
|
+
files.includes(BOOTSTRAP_HTTPAPI_FILE) ? APP_ROOT : fixture.root,
|
|
701
|
+
);
|
|
702
|
+
const session = createTypeInfoApiSession({
|
|
703
|
+
ts,
|
|
704
|
+
program,
|
|
705
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
706
|
+
});
|
|
707
|
+
const result = session.api.file("src/apis/status.ts", { baseDir: fixture.root });
|
|
708
|
+
expect(result.ok).toBe(true);
|
|
709
|
+
if (!result.ok) return;
|
|
710
|
+
const { api } = session;
|
|
711
|
+
const routeExport = result.snapshot.exports.find((e) => e.name === "route");
|
|
712
|
+
const handlerExport = result.snapshot.exports.find((e) => e.name === "handler");
|
|
713
|
+
const successExport = result.snapshot.exports.find((e) => e.name === "success");
|
|
714
|
+
const errorExport = result.snapshot.exports.find((e) => e.name === "error");
|
|
715
|
+
expect(routeExport).toBeDefined();
|
|
716
|
+
expect(handlerExport).toBeDefined();
|
|
717
|
+
expect(successExport).toBeDefined();
|
|
718
|
+
expect(errorExport).toBeDefined();
|
|
719
|
+
expect(api.isAssignableTo(routeExport!.type, "Route")).toBe(true);
|
|
720
|
+
expect(api.isAssignableTo(handlerExport!.type, "Effect", [{ kind: "returnType" }])).toBe(true);
|
|
721
|
+
expect(api.isAssignableTo(successExport!.type, "Schema")).toBe(true);
|
|
722
|
+
expect(api.isAssignableTo(errorExport!.type, "Schema")).toBe(true);
|
|
723
|
+
expect(api.isAssignableTo(handlerExport!.type, "Route")).toBe(false);
|
|
724
|
+
expect(api.isAssignableTo(routeExport!.type, "Schema")).toBe(false);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("wrong module path for Route: Route target not resolved; build fails with AVM-CONTRACT-003", () => {
|
|
729
|
+
const wrongSpecs = [
|
|
730
|
+
...HTTPAPI_TYPE_TARGET_SPECS.filter((s) => s.id !== "Route"),
|
|
731
|
+
{ id: "Route", module: "wrong/path/Route", exportName: "Route" },
|
|
732
|
+
];
|
|
733
|
+
const fixture = createApiFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
734
|
+
const files =
|
|
735
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
736
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
737
|
+
: fixture.paths;
|
|
738
|
+
const program = makeProgram(files, fixture.root);
|
|
739
|
+
const session = createTypeInfoApiSession({ ts, program, typeTargetSpecs: wrongSpecs });
|
|
740
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
741
|
+
const result = plugin.build("api:./apis", fixture.importer, session.api);
|
|
742
|
+
expect(result).toHaveProperty("errors");
|
|
743
|
+
expect((result as VirtualModuleBuildError).errors.some((e) => e.code === "AVM-CONTRACT-003")).toBe(
|
|
744
|
+
true,
|
|
745
|
+
);
|
|
746
|
+
});
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
describe("HttpApi assignableTo and validation (comprehensive)", () => {
|
|
750
|
+
describe("3a. Type-target resolution", () => {
|
|
751
|
+
it("Resolution with bootstrap: build succeeds; assignableTo populated for Route, Effect, Schema", () => {
|
|
752
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
753
|
+
const sourceText = getSourceText(result);
|
|
754
|
+
expect(sourceText).toBeDefined();
|
|
755
|
+
expect(sourceText).toContain("handlers.handle(");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("Wrong typeTargetSpecs: wrong module paths; assignableTo missing; build fails", () => {
|
|
759
|
+
const wrongSpecs = [
|
|
760
|
+
{ id: "Route", module: "effect", exportName: "Route" },
|
|
761
|
+
{ id: "Effect", module: "wrong/module", exportName: "Effect" },
|
|
762
|
+
];
|
|
763
|
+
const fixture = createApiFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
764
|
+
const files =
|
|
765
|
+
existsSync(BOOTSTRAP_HTTPAPI_FILE) && !fixture.paths.includes(BOOTSTRAP_HTTPAPI_FILE)
|
|
766
|
+
? [...fixture.paths, BOOTSTRAP_HTTPAPI_FILE]
|
|
767
|
+
: fixture.paths;
|
|
768
|
+
const program = makeProgram(files, fixture.root);
|
|
769
|
+
expect(() =>
|
|
770
|
+
createTypeInfoApiSession({
|
|
771
|
+
ts,
|
|
772
|
+
program,
|
|
773
|
+
typeTargetSpecs: wrongSpecs,
|
|
774
|
+
}),
|
|
775
|
+
).toThrow(/type targets could not be resolved/);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("Missing bootstrap when specs provided: program has no canonical imports; session creation throws", () => {
|
|
779
|
+
const fixture = createApiFixture({
|
|
780
|
+
"src/apis/status.ts": `
|
|
781
|
+
export const route = { path: "/status" };
|
|
782
|
+
export const method = "GET";
|
|
783
|
+
export const handler = () => ({});
|
|
784
|
+
`,
|
|
785
|
+
});
|
|
786
|
+
expect(() =>
|
|
787
|
+
createTypeInfoApiSession({
|
|
788
|
+
ts,
|
|
789
|
+
program: makeProgram(fixture.paths, fixture.root),
|
|
790
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
791
|
+
}),
|
|
792
|
+
).toThrow(/type targets could not be resolved/);
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
describe("3b. Route validation (assignableTo.Route only)", () => {
|
|
797
|
+
it("Route from @typed/router: Route.Parse passes", () => {
|
|
798
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
799
|
+
expect(getSourceText(result)).toBeDefined();
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
it("Route invalid (no assignableTo.Route): plain object; AVM-CONTRACT-003", () => {
|
|
803
|
+
const result = buildApiFromFixture({
|
|
804
|
+
"src/apis/status.ts": `
|
|
805
|
+
import * as Effect from "effect/Effect";
|
|
806
|
+
import * as Schema from "effect/Schema";
|
|
807
|
+
export const route = { path: "/status" };
|
|
808
|
+
export const method = "GET";
|
|
809
|
+
export const handler = () => Effect.succeed({});
|
|
810
|
+
`,
|
|
811
|
+
});
|
|
812
|
+
expect(result).toHaveProperty("errors");
|
|
813
|
+
expect((result as VirtualModuleBuildError).errors.some((e) => e.code === "AVM-CONTRACT-003")).toBe(true);
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it("Route invalid (assignableTo absent): type targets unresolved; session throws when no bootstrap", () => {
|
|
817
|
+
const fixture = createApiFixture({
|
|
818
|
+
"src/apis/status.ts": `
|
|
819
|
+
const route = { path: "/status" };
|
|
820
|
+
export { route };
|
|
821
|
+
export const method = "GET";
|
|
822
|
+
export const handler = () => ({});
|
|
823
|
+
`,
|
|
824
|
+
});
|
|
825
|
+
expect(() => {
|
|
826
|
+
const program = makeProgram(fixture.paths, fixture.root);
|
|
827
|
+
const session = createTypeInfoApiSession({
|
|
828
|
+
ts,
|
|
829
|
+
program,
|
|
830
|
+
typeTargetSpecs: HTTPAPI_TYPE_TARGET_SPECS,
|
|
831
|
+
});
|
|
832
|
+
const plugin = createHttpApiVirtualModulePlugin();
|
|
833
|
+
plugin.build("api:./apis", fixture.importer, session.api);
|
|
834
|
+
}).toThrow(/type targets could not be resolved/);
|
|
835
|
+
});
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
describe("3c. Handler validation", () => {
|
|
839
|
+
it("Handler returns Effect: passes", () => {
|
|
840
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
841
|
+
expect(getSourceText(result)).toBeDefined();
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
it("Handler returns non-Effect: AVM-CONTRACT-004", () => {
|
|
845
|
+
const result = buildApiFromFixture({
|
|
846
|
+
"src/apis/status.ts": `
|
|
847
|
+
import * as Effect from "effect/Effect";
|
|
848
|
+
import * as Schema from "effect/Schema";
|
|
849
|
+
import * as Route from "@typed/router";
|
|
850
|
+
export const route = Route.Parse("/status");
|
|
851
|
+
export const method = "GET";
|
|
852
|
+
export const handler = () => ({ status: "ok" });
|
|
853
|
+
`,
|
|
854
|
+
});
|
|
855
|
+
expect(result).toHaveProperty("errors");
|
|
856
|
+
expect((result as VirtualModuleBuildError).errors[0].code).toBe("AVM-CONTRACT-004");
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it("Handler returns HttpServerResponse: uses handleRaw", () => {
|
|
860
|
+
const rawHandlerSource = `
|
|
861
|
+
import * as Effect from "effect/Effect";
|
|
862
|
+
import * as Schema from "effect/Schema";
|
|
863
|
+
import * as Route from "@typed/router";
|
|
864
|
+
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
|
|
865
|
+
export const route = Route.Parse("/raw");
|
|
866
|
+
export const method = "GET";
|
|
867
|
+
export const success = Schema.Struct({});
|
|
868
|
+
export const error = Schema.Struct({ message: Schema.String });
|
|
869
|
+
export const handler = () => Effect.succeed(HttpServerResponse.empty());
|
|
870
|
+
`;
|
|
871
|
+
const result = buildApiFromFixture({ "src/apis/raw.ts": rawHandlerSource });
|
|
872
|
+
const sourceText = getSourceText(result);
|
|
873
|
+
expect(sourceText).toBeDefined();
|
|
874
|
+
expect(sourceText).toContain("handleRaw");
|
|
875
|
+
expect(sourceText).toContain("raw");
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
it("Handler returns value vs raw: both in same API", () => {
|
|
879
|
+
const rawHandlerSource = `
|
|
880
|
+
import * as Effect from "effect/Effect";
|
|
881
|
+
import * as Schema from "effect/Schema";
|
|
882
|
+
import * as Route from "@typed/router";
|
|
883
|
+
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
|
|
884
|
+
export const route = Route.Parse("/raw");
|
|
885
|
+
export const method = "GET";
|
|
886
|
+
export const success = Schema.Struct({});
|
|
887
|
+
export const error = Schema.Struct({ message: Schema.String });
|
|
888
|
+
export const handler = () => Effect.succeed(HttpServerResponse.empty());
|
|
889
|
+
`;
|
|
890
|
+
const result = buildApiFromFixture({
|
|
891
|
+
"src/apis/status.ts": VALID_ENDPOINT_SOURCE,
|
|
892
|
+
"src/apis/raw.ts": rawHandlerSource,
|
|
893
|
+
});
|
|
894
|
+
const sourceText = getSourceText(result);
|
|
895
|
+
expect(sourceText).toBeDefined();
|
|
896
|
+
expect(sourceText).toContain("handle(\"status\"");
|
|
897
|
+
expect(sourceText).toContain("handleRaw(\"raw\"");
|
|
898
|
+
});
|
|
899
|
+
});
|
|
900
|
+
|
|
901
|
+
describe("3d. Success and error schemas", () => {
|
|
902
|
+
it("success present, Schema: passes", () => {
|
|
903
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
904
|
+
expect(getSourceText(result)).toBeDefined();
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("success present, not Schema: AVM-CONTRACT-005", () => {
|
|
908
|
+
const result = buildApiFromFixture({
|
|
909
|
+
"src/apis/status.ts": `
|
|
910
|
+
import * as Effect from "effect/Effect";
|
|
911
|
+
import * as Route from "@typed/router";
|
|
912
|
+
export const route = Route.Parse("/status");
|
|
913
|
+
export const method = "GET";
|
|
914
|
+
export const success = { foo: "bar" };
|
|
915
|
+
export const error = { message: "err" };
|
|
916
|
+
export const handler = () => Effect.succeed({ status: "ok" });
|
|
917
|
+
`,
|
|
918
|
+
});
|
|
919
|
+
expect(result).toHaveProperty("errors");
|
|
920
|
+
expect((result as VirtualModuleBuildError).errors.some((e) => e.code === "AVM-CONTRACT-005")).toBe(true);
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
it("error present, Schema: passes", () => {
|
|
924
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
925
|
+
expect(getSourceText(result)).toBeDefined();
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it("error present, not Schema: AVM-CONTRACT-006", () => {
|
|
929
|
+
const result = buildApiFromFixture({
|
|
930
|
+
"src/apis/status.ts": `
|
|
931
|
+
import * as Effect from "effect/Effect";
|
|
932
|
+
import * as Schema from "effect/Schema";
|
|
933
|
+
import * as Route from "@typed/router";
|
|
934
|
+
export const route = Route.Parse("/status");
|
|
935
|
+
export const method = "GET";
|
|
936
|
+
export const success = Schema.Struct({ status: Schema.Literal("ok") });
|
|
937
|
+
export const error = { message: "err" };
|
|
938
|
+
export const handler = () => Effect.succeed({ status: "ok" });
|
|
939
|
+
`,
|
|
940
|
+
});
|
|
941
|
+
expect(result).toHaveProperty("errors");
|
|
942
|
+
expect((result as VirtualModuleBuildError).errors.some((e) => e.code === "AVM-CONTRACT-006")).toBe(true);
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
it("Both success and error valid Schema: passes", () => {
|
|
946
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
947
|
+
expect(getSourceText(result)).toBeDefined();
|
|
948
|
+
});
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
describe("3e. Groups and structure", () => {
|
|
952
|
+
it("Nested groups: correct HttpApiGroup composition", () => {
|
|
953
|
+
const result = buildApiFromFixture({
|
|
954
|
+
"src/apis/users/list.ts": VALID_ENDPOINT_SOURCE,
|
|
955
|
+
"src/apis/users/items/get.ts": VALID_ENDPOINT_SOURCE,
|
|
956
|
+
});
|
|
957
|
+
const sourceText = getSourceText(result);
|
|
958
|
+
expect(sourceText).toBeDefined();
|
|
959
|
+
expect(sourceText).toContain("HttpApiGroup.make(\"users\")");
|
|
960
|
+
expect(sourceText).toContain("list");
|
|
961
|
+
expect(sourceText).toContain("items/get");
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
it("Multiple endpoints per group: correct wiring", () => {
|
|
965
|
+
const result = buildApiFromFixture({
|
|
966
|
+
"src/apis/users/list.ts": VALID_ENDPOINT_SOURCE,
|
|
967
|
+
"src/apis/users/get.ts": VALID_ENDPOINT_SOURCE,
|
|
968
|
+
"src/apis/users/update.ts": VALID_ENDPOINT_SOURCE,
|
|
969
|
+
});
|
|
970
|
+
const sourceText = getSourceText(result);
|
|
971
|
+
expect(sourceText).toBeDefined();
|
|
972
|
+
expect(sourceText).toContain("handle(\"list\"");
|
|
973
|
+
expect(sourceText).toContain("handle(\"get\"");
|
|
974
|
+
expect(sourceText).toContain("handle(\"update\"");
|
|
975
|
+
});
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
describe("3f. Coercion paths (handle vs handleRaw)", () => {
|
|
979
|
+
it("Direct handler export: emitted correctly", () => {
|
|
980
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
981
|
+
const sourceText = getSourceText(result);
|
|
982
|
+
expect(sourceText).toBeDefined();
|
|
983
|
+
expect(sourceText).toContain("Status.handler(");
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
it("handle for value return: handlers.handle with decoded params", () => {
|
|
987
|
+
const result = buildApiFromFixture({ "src/apis/status.ts": VALID_ENDPOINT_SOURCE });
|
|
988
|
+
const sourceText = getSourceText(result);
|
|
989
|
+
expect(sourceText).toBeDefined();
|
|
990
|
+
expect(sourceText).toContain("handlers.handle(\"status\", (ctx) => Status.handler({ path:");
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
it("handleRaw for HttpServerResponse: handlers.handleRaw, handler receives ctx", () => {
|
|
994
|
+
const rawHandlerSource = `
|
|
995
|
+
import * as Effect from "effect/Effect";
|
|
996
|
+
import * as Schema from "effect/Schema";
|
|
997
|
+
import * as Route from "@typed/router";
|
|
998
|
+
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
|
|
999
|
+
export const route = Route.Parse("/raw");
|
|
1000
|
+
export const method = "GET";
|
|
1001
|
+
export const success = Schema.Struct({});
|
|
1002
|
+
export const error = Schema.Struct({ message: Schema.String });
|
|
1003
|
+
export const handler = () => Effect.succeed(HttpServerResponse.empty());
|
|
1004
|
+
`;
|
|
1005
|
+
const result = buildApiFromFixture({ "src/apis/raw.ts": rawHandlerSource });
|
|
1006
|
+
const sourceText = getSourceText(result);
|
|
1007
|
+
expect(sourceText).toBeDefined();
|
|
1008
|
+
expect(sourceText).toContain("handlers.handleRaw(\"raw\", (ctx) => Raw.handler(ctx))");
|
|
1009
|
+
});
|
|
1010
|
+
});
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
describe("httpapiOpenApiConfig", () => {
|
|
1014
|
+
it("normalizeOpenApiConfig at api scope returns no diagnostics for empty config", () => {
|
|
1015
|
+
const { diagnostics } = normalizeOpenApiConfig("api", {
|
|
1016
|
+
annotations: {},
|
|
1017
|
+
generation: {},
|
|
1018
|
+
exposure: {},
|
|
1019
|
+
});
|
|
1020
|
+
expect(diagnostics).toHaveLength(0);
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
it("validateOpenApiGenerationScope returns diagnostic when generation used at group scope", () => {
|
|
1024
|
+
const diag = validateOpenApiGenerationScope("group", {
|
|
1025
|
+
additionalProperties: true,
|
|
1026
|
+
});
|
|
1027
|
+
expect(diag).toHaveLength(1);
|
|
1028
|
+
expect(diag[0].code).toBe("AVM-OPENAPI-001");
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("validateOpenApiExposureScope returns diagnostic when exposure used at endpoint scope", () => {
|
|
1032
|
+
const diag = validateOpenApiExposureScope("endpoint", {
|
|
1033
|
+
jsonPath: "/openapi.json",
|
|
1034
|
+
});
|
|
1035
|
+
expect(diag).toHaveLength(1);
|
|
1036
|
+
expect(diag[0].code).toBe("AVM-OPENAPI-002");
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
it("validateOpenApiExposureRouteConflicts detects same path for json and swagger", () => {
|
|
1040
|
+
const diag = validateOpenApiExposureRouteConflicts({
|
|
1041
|
+
jsonPath: "/spec",
|
|
1042
|
+
swaggerPath: "/spec",
|
|
1043
|
+
});
|
|
1044
|
+
expect(diag).toHaveLength(1);
|
|
1045
|
+
expect(diag[0]).toMatchInlineSnapshot(`
|
|
1046
|
+
{
|
|
1047
|
+
"code": "AVM-OPENAPI-003",
|
|
1048
|
+
"message": "OpenAPI exposure route conflict: path "/spec" used for multiple modes: json, swagger",
|
|
1049
|
+
}
|
|
1050
|
+
`);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("collectExposureRoutes returns entries for jsonPath, swaggerPath, scalar", () => {
|
|
1054
|
+
const routes = collectExposureRoutes({
|
|
1055
|
+
jsonPath: "/openapi.json",
|
|
1056
|
+
swaggerPath: "/swagger",
|
|
1057
|
+
scalar: { path: "/scalar", source: "inline" },
|
|
1058
|
+
});
|
|
1059
|
+
expect(routes).toHaveLength(3);
|
|
1060
|
+
expect(routes.map((r) => r.mode).sort()).toEqual(["json", "scalar", "swagger"]);
|
|
1061
|
+
});
|
|
1062
|
+
});
|