Skip to content

Commit 7cf457f

Browse files
committed
feat: build extracted api spec
1 parent 92b8640 commit 7cf457f

1 file changed

Lines changed: 210 additions & 0 deletions

File tree

src/spec.ts

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
import type { ParameterLocation } from "./config-schema.js";
2+
import { extractPathName, normalizePathSegments, sortedReadonly } from "./text.js";
3+
import { isValueType } from "./types.js";
4+
import type {
5+
ApiDiagnostic,
6+
ApiParameterSpec,
7+
ApiRouteSpec,
8+
ApiSpec,
9+
CollectedParameter,
10+
ExtractorConfig,
11+
HttpMethod,
12+
RouteExtraction,
13+
RouteOverride,
14+
SourceLocation,
15+
ValueType,
16+
} from "./types.js";
17+
18+
export function buildSpec(
19+
config: ExtractorConfig,
20+
extractions: readonly RouteExtraction[],
21+
diagnostics: readonly ApiDiagnostic[],
22+
): ApiSpec {
23+
const routes = extractions
24+
.map((extraction) => applyOverride(extraction, config.overrides))
25+
.filter((overridden): overridden is RouteExtraction => overridden !== undefined)
26+
.filter((overridden) => !isPathBlacklisted(overridden.route.pathTemplate, config.blacklist.paths))
27+
.map(
28+
(overridden): ApiRouteSpec => ({
29+
method: overridden.route.method,
30+
pathTemplate: overridden.route.pathTemplate,
31+
urlTemplate: buildUrlTemplate(config.baseUrl, overridden.route.pathTemplate),
32+
handler: overridden.route.handler,
33+
pathParams: materializeParameters(overridden.pathParams, "path", config.blacklist.params),
34+
queryParams: materializeParameters(overridden.queryParams, "query", config.blacklist.params),
35+
bodyParams: materializeParameters(overridden.bodyParams, "body", config.blacklist.params),
36+
source: overridden.route.source,
37+
diagnostics: filterDiagnostics(overridden.diagnostics, overridden.route.method, overridden.route.pathTemplate),
38+
}),
39+
);
40+
41+
return {
42+
rootDir: config.rootDir,
43+
routeFile: config.routeFile,
44+
routes: sortedReadonly(routes, compareRoutes),
45+
diagnostics,
46+
};
47+
}
48+
49+
function compareRoutes(left: ApiRouteSpec, right: ApiRouteSpec): number {
50+
if (left.pathTemplate !== right.pathTemplate) {
51+
return left.pathTemplate.localeCompare(right.pathTemplate);
52+
}
53+
return left.method.localeCompare(right.method);
54+
}
55+
56+
function materializeParameters(
57+
parameters: readonly CollectedParameter[],
58+
location: ParameterLocation,
59+
blacklistedNames: readonly string[],
60+
): readonly ApiParameterSpec[] {
61+
const validParameters = parameters
62+
.filter((parameter) => parameter.location === location)
63+
.filter((parameter) => isValidCanonicalPath(parameter.canonicalPath))
64+
.filter((parameter) => !isParamBlacklisted(extractPathName(parameter.canonicalPath), blacklistedNames));
65+
66+
const deduped = new Map<string, ApiParameterSpec>();
67+
validParameters.forEach((parameter) => {
68+
const name = extractPathName(parameter.canonicalPath);
69+
const existing = deduped.get(parameter.canonicalPath);
70+
if (existing === undefined) {
71+
deduped.set(parameter.canonicalPath, {
72+
name,
73+
canonicalPath: parameter.canonicalPath,
74+
location,
75+
valueType: normalizeValueType(parameter.valueType, location),
76+
sources: [parameter.source],
77+
});
78+
return;
79+
}
80+
81+
deduped.set(parameter.canonicalPath, {
82+
...existing,
83+
valueType: mergeValueTypes(existing.valueType, normalizeValueType(parameter.valueType, location)),
84+
sources: [...existing.sources, parameter.source],
85+
});
86+
});
87+
88+
return sortedReadonly(
89+
Array.from(deduped.values()).map((spec) => ({
90+
...spec,
91+
sources: sortedReadonly(spec.sources, compareLocations),
92+
})),
93+
compareParameters,
94+
);
95+
}
96+
97+
function isValidCanonicalPath(canonicalPath: string): boolean {
98+
return canonicalPath.length > 0 && !canonicalPath.endsWith(".") && !canonicalPath.includes("..");
99+
}
100+
101+
function mergeValueTypes(left: ValueType | undefined, right: ValueType | undefined): ValueType | undefined {
102+
if (left === undefined) {
103+
return right;
104+
}
105+
if (right === undefined || left === right) {
106+
return left;
107+
}
108+
if (left === "array" && right.endsWith("[]")) {
109+
return right;
110+
}
111+
if (right === "array" && left.endsWith("[]")) {
112+
return left;
113+
}
114+
115+
const variants = new Set([...left.split(" | "), ...right.split(" | ")]);
116+
const merged = [...variants].sort((a, b) => a.localeCompare(b)).join(" | ");
117+
if (isValueType(merged)) {
118+
return merged;
119+
}
120+
return left;
121+
}
122+
123+
function normalizeValueType(valueType: ValueType | undefined, location: ParameterLocation): ValueType {
124+
if (valueType !== undefined) {
125+
return valueType;
126+
}
127+
return location === "body" ? "unknown" : "string";
128+
}
129+
130+
function compareParameters(left: ApiParameterSpec, right: ApiParameterSpec): number {
131+
return left.canonicalPath.localeCompare(right.canonicalPath);
132+
}
133+
134+
function compareLocations(left: SourceLocation, right: SourceLocation): number {
135+
if (left.filePath !== right.filePath) {
136+
return left.filePath.localeCompare(right.filePath);
137+
}
138+
if (left.line !== right.line) {
139+
return left.line - right.line;
140+
}
141+
return left.column - right.column;
142+
}
143+
144+
function buildUrlTemplate(baseUrl: string | undefined, pathTemplate: string): string | undefined {
145+
if (baseUrl === undefined || baseUrl.length === 0) {
146+
return undefined;
147+
}
148+
return `${baseUrl.replace(/\/$/, "")}${pathTemplate}`;
149+
}
150+
151+
function isPathBlacklisted(pathTemplate: string, blacklistedPaths: readonly string[]): boolean {
152+
return blacklistedPaths.includes(pathTemplate);
153+
}
154+
155+
function isParamBlacklisted(name: string, blacklistedNames: readonly string[]): boolean {
156+
return blacklistedNames.includes(name);
157+
}
158+
159+
function filterDiagnostics(
160+
diagnostics: readonly ApiDiagnostic[],
161+
method: HttpMethod,
162+
pathTemplate: string,
163+
): readonly ApiDiagnostic[] {
164+
const routeKey = `${method} ${pathTemplate}`;
165+
return diagnostics.filter((diagnostic) => diagnostic.routeKey === undefined || diagnostic.routeKey === routeKey);
166+
}
167+
168+
function applyOverride(extraction: RouteExtraction, overrides: readonly RouteOverride[]): RouteExtraction | undefined {
169+
const matchingOverrides = overrides.filter(
170+
(override) =>
171+
override.path === extraction.route.pathTemplate &&
172+
(override.method === undefined || override.method === extraction.route.method),
173+
);
174+
175+
if (matchingOverrides.some((override) => override.skip === true)) {
176+
return undefined;
177+
}
178+
179+
return matchingOverrides.reduce<RouteExtraction>(
180+
(current, override) => ({
181+
...current,
182+
pathParams: applyOverrideParams(current.pathParams, override, "path"),
183+
queryParams: applyOverrideParams(current.queryParams, override, "query"),
184+
bodyParams: applyOverrideParams(current.bodyParams, override, "body"),
185+
}),
186+
extraction,
187+
);
188+
}
189+
190+
function applyOverrideParams(
191+
parameters: readonly CollectedParameter[],
192+
override: RouteOverride,
193+
location: ParameterLocation,
194+
): readonly CollectedParameter[] {
195+
const removeSet = new Set(override.removeParams ?? []);
196+
const filtered = parameters.filter((parameter) => !removeSet.has(extractPathName(parameter.canonicalPath)));
197+
198+
const additions = (override.addParams ?? [])
199+
.filter((addition) => addition.location === location)
200+
.map(
201+
(addition): CollectedParameter => ({
202+
location,
203+
canonicalPath: addition.canonicalPath ?? normalizePathSegments([addition.name]),
204+
valueType: undefined,
205+
source: { filePath: "config", line: 1, column: 1 },
206+
}),
207+
);
208+
209+
return [...filtered, ...additions];
210+
}

0 commit comments

Comments
 (0)