Compare commits

...
Author SHA1 Message Date
l0ng-ai adc89f3d13 feat(http-client): request bodies from DTOs in other modules, header completion
Generating a request from a Spring mapping looked for the @RequestBody
type only beside the controller or in the same source root, so a DTO in
another Maven module came out as {}. Types not found there are now looked
up in the project index, in the imported (or same / wildcard-imported)
package, and the fields of their superclasses are included.

Header names complete on the lines after a request line, and values of
the common headers (Content-Type, Accept, Authorization, …) after the
colon. Requests opened from an endpoint use its module's server address
instead of a fixed localhost:8080.
2026-11-13 13:53:06 +08:00
7 changed files with 323 additions and 34 deletions

No files matched your search

+13 -4
View File
@@ -10,6 +10,9 @@ import { gutterIconMarkup, showContextMenu } from "@/ui";
import { emptyGutterSpacer, gutterSpacer } from "@/editor/api";
import { notifications, onEndpointsChanged, searchEndpoints, type EndpointEntry } from "@/workbench/contributions";
import { appendGeneratedRequests } from "./generatedRequests";
import { headerCompletion } from "./headers";
import { sourceReader } from "./spring/editor";
import { findHost } from "./spring/request";
import { METHODS } from "./language";
import styles from "./HttpClient.module.css";
@@ -17,13 +20,15 @@ export const OPEN_ENDPOINT = "http.openEndpoint";
export const endpointUrl = (path: string) => path.replace(/\{(?!\{)([\w.-]+)\}(?!\})/g, "{{$1}}");
export function renderEndpoint(e: EndpointEntry): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} http://localhost:8080${endpointUrl(e.url)}\n`;
export function renderEndpoint(e: EndpointEntry, host = "http://localhost:8080"): string {
return `### ${e.handler ?? `${e.method} ${e.url}`}\n${e.method === "*" ? "GET" : e.method} ${host}${endpointUrl(e.url)}\n`;
}
export async function openEndpoint(endpoint: EndpointEntry): Promise<void> {
try {
await appendGeneratedRequests([renderEndpoint(endpoint)]);
// The server address of the endpoint's module (`server.port`, context path), as for Spring mappings.
const host = await findHost(endpoint.path, useProject.getState().project?.root ?? null, sourceReader);
await appendGeneratedRequests([renderEndpoint(endpoint, host)]);
} catch (e) {
notifications().notify({ level: "error", title: tx("Cannot generate the HTTP request"), body: String(e) });
}
@@ -94,7 +99,11 @@ class EndpointMarker extends GutterMarker {
const endpointSpacer = gutterSpacer(styles.runMarker ?? "");
export function endpointEditorExtension(ctx: EditorDocContext): Extension {
if (ctx.language === "http") return EditorState.languageData.of(() => [{ openideaCompletion: endpointCompletion }]);
if (ctx.language === "http")
return EditorState.languageData.of(() => [
{ openideaCompletion: endpointCompletion },
{ openideaCompletion: headerCompletion },
]);
if (ctx.virtual) return [];
return [
marks,
+33
View File
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { headerTarget } from "./headers";
const at = (text: string) => headerTarget(text.replace("|", ""), text.indexOf("|"));
describe("header completion", () => {
it("completes header names on the lines after the request line", () => {
expect(at("POST http://localhost:8080/users\nCon|")).toEqual({ kind: "name", from: 33, prefix: "Con" });
expect(at("### Create\n# @name create\nPOST {{host}}/users\nAccept: */*\n|")).toMatchObject({
kind: "name",
prefix: "",
});
expect(at("http://localhost/a\nX-Re|")).toMatchObject({ kind: "name", prefix: "X-Re" });
});
it("completes the values of a header", () => {
expect(at("POST http://x/a\nContent-Type: app|")).toEqual({
kind: "value",
name: "Content-Type",
from: 30,
prefix: "app",
});
expect(at("GET http://x/a\nAuthorization:|")).toMatchObject({ kind: "value", name: "Authorization", prefix: "" });
});
it("stays out of request lines, bodies, comments and blocks without a request", () => {
expect(at("POS|")).toBeNull();
expect(at('POST http://x/a\nContent-Type: application/json\n\n{"a|')).toBeNull();
expect(at("POST http://x/a\n# Acc|")).toBeNull();
expect(at("@host = http://x\nAcc|")).toBeNull();
expect(at("POST http://x/a\n\n###\nAcc|")).toBeNull();
});
});
+131
View File
@@ -0,0 +1,131 @@
/** Header name and value completion in `.http` request blocks (the lines after the request line). */
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { METHODS } from "./language";
const REQUEST_LINE = new RegExp(`^\\s*(?:(?:${METHODS.join("|")})\\s+\\S|https?://|\\{\\{)`);
const COMMENT = /^\s*(?:#|\/\/)/;
const HEADERS = [
"Accept",
"Accept-Charset",
"Accept-Encoding",
"Accept-Language",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
"Authorization",
"Cache-Control",
"Connection",
"Content-Disposition",
"Content-Encoding",
"Content-Language",
"Content-Length",
"Content-Type",
"Cookie",
"Date",
"Expect",
"Forwarded",
"From",
"Host",
"If-Match",
"If-Modified-Since",
"If-None-Match",
"If-Range",
"If-Unmodified-Since",
"Max-Forwards",
"Origin",
"Pragma",
"Proxy-Authorization",
"Range",
"Referer",
"TE",
"Upgrade",
"User-Agent",
"Via",
"X-Correlation-ID",
"X-CSRF-Token",
"X-Forwarded-For",
"X-Forwarded-Host",
"X-Forwarded-Proto",
"X-Request-ID",
"X-Requested-With",
];
const MEDIA_TYPES = [
"application/json",
"application/xml",
"application/x-www-form-urlencoded",
"multipart/form-data",
"text/plain",
"text/html",
"text/xml",
"text/csv",
"application/octet-stream",
"application/graphql",
"application/x-ndjson",
"application/problem+json",
"*/*",
];
const VALUES: Record<string, string[]> = {
accept: MEDIA_TYPES,
"content-type": MEDIA_TYPES,
"accept-encoding": ["gzip, deflate, br", "gzip", "deflate", "br", "identity"],
"content-encoding": ["gzip", "deflate", "br"],
"accept-charset": ["utf-8", "iso-8859-1"],
"accept-language": ["en-US", "en", "zh-CN", "zh"],
authorization: ["Bearer ", "Basic "],
"proxy-authorization": ["Bearer ", "Basic "],
"cache-control": ["no-cache", "no-store", "max-age=0", "must-revalidate"],
pragma: ["no-cache"],
connection: ["keep-alive", "close", "Upgrade"],
upgrade: ["websocket", "h2c"],
expect: ["100-continue"],
"x-requested-with": ["XMLHttpRequest"],
"content-disposition": ["attachment", "inline", 'form-data; name=""'],
"access-control-request-method": METHODS,
};
export type HeaderTarget =
| { kind: "name"; from: number; prefix: string }
| { kind: "value"; name: string; from: number; prefix: string };
/** What is being typed at `pos`: a header name or a header's value, or null outside a request's headers. */
export function headerTarget(text: string, pos: number): HeaderTarget | null {
const before = text.slice(0, pos);
const lineStart = before.lastIndexOf("\n") + 1;
const line = before.slice(lineStart);
if (COMMENT.test(line)) return null;
const lines = (
before
.slice(0, lineStart)
.split(/^\s*###.*$/m)
.at(-1) ?? ""
)
.split("\n")
.slice(0, -1);
let request = false;
for (const l of lines) {
if (COMMENT.test(l)) continue;
if (!request) request = REQUEST_LINE.test(l);
// A blank line ends the headers; the body follows.
else if (!l.trim()) return null;
}
if (!request) return null;
const colon = line.indexOf(":");
if (colon < 0) return /^[\w-]*$/.test(line) ? { kind: "name", from: lineStart, prefix: line } : null;
const prefix = line.slice(colon + 1).replace(/^\s+/, "");
return { kind: "value", name: line.slice(0, colon).trim(), from: pos - prefix.length, prefix };
}
export function headerCompletion(context: CompletionContext): CompletionResult | null {
const target = headerTarget(context.state.doc.toString(), context.pos);
if (!target) return null;
if (target.kind === "name") {
if (!target.prefix && !context.explicit) return null;
const options: Completion[] = HEADERS.map((name) => ({ label: name, type: "property", apply: `${name}: ` }));
return { from: target.from, options, validFor: /^[\w-]*$/ };
}
const values = VALUES[target.name.toLowerCase()];
if (!values) return null;
return { from: target.from, options: values.map((v) => ({ label: v, type: "constant" })) };
}
+4 -3
View File
@@ -21,7 +21,7 @@ export const SPRING_LANGUAGES = new Set(["java", "kotlin"]);
export const GENERATE_REQUEST = "http.generateSpringRequest";
export const GENERATE_CONTROLLER_REQUESTS = "http.generateSpringControllerRequests";
const reader: SourceReader = {
export const sourceReader: SourceReader = {
read: async (path) => {
try {
return await call(commands.fsReadText(path));
@@ -29,6 +29,7 @@ const reader: SourceReader = {
return null;
}
},
filesNamed: (name) => call(commands.findFilesNamed(name)),
};
/** One request per endpoint (`paths[i]` picks another path of the mapping). */
@@ -43,12 +44,12 @@ export async function generateRequests(sourcePath: string, text: string, targets
try {
const root = useProject.getState().project?.root ?? null;
const [host, lookup] = await Promise.all([
findHost(sourcePath, root, reader),
findHost(sourcePath, root, sourceReader),
bodyLookup(
sourcePath,
parseSource(text),
targets.map((t) => t.endpoint),
reader,
sourceReader,
),
]);
const blocks = targets.map(({ endpoint, path }) =>
+79 -27
View File
@@ -115,9 +115,18 @@ export function skeleton(type: string, lookup: (name: string) => ClassDecl | nul
if (MAP.test(t) || t === "Object" || t === "Any" || !t) return {};
const cls = lookup(t.replace(/<.*$/, ""));
if (!cls || depth > 1) return {};
// Superclass properties first, as Jackson writes them.
const chain = [cls];
for (let c = cls; c.superclass && chain.length <= MAX_SUPERCLASSES;) {
const parent = lookup(bareType(c.superclass).replace(/<.*$/, ""));
if (!parent || chain.includes(parent)) break;
chain.unshift((c = parent));
}
const obj: { [k: string]: Json } = {};
for (const p of cls.ctorParams) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of cls.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
for (const c of chain) {
for (const p of c.ctorParams) if (!(p.name in obj)) obj[p.name] = skeleton(p.type, lookup, depth + 1);
for (const f of c.fields) if (!f.isStatic && !(f.name in obj)) obj[f.name] = skeleton(f.type, lookup, depth + 1);
}
return obj;
}
@@ -175,8 +184,13 @@ export function appendRequests(existing: string, blocks: string[]): { insert: st
export interface SourceReader {
/** File text, null when missing. */
read(path: string): Promise<string | null>;
/** The project's files called `name` (from the index). */
filesNamed?(name: string): Promise<string[]>;
}
/** How many superclasses of a body type are followed for their fields. */
const MAX_SUPERCLASSES = 5;
const dirname = (p: string) => p.replace(/[\\/][^\\/]*$/, "");
/** The Spring Boot configuration files for `sourcePath` (its module's `src/main/resources`), most specific first. */
@@ -201,8 +215,10 @@ export async function findHost(sourcePath: string, projectRoot: string | null, f
}
/**
* Body types of `endpoints`: classes of the controller's file, then
* `<Type>.java|.kt` beside it or where its import points (same source root).
* Body types of `endpoints`: classes of the controller's file, then `<Type>.java|.kt` beside
* it or where its import points (same source root), else the project file of that name in the
* imported (or same / wildcard-imported) package, e.g. a DTO in another Maven module. Their
* superclasses are loaded too.
*/
export async function bodyLookup(
sourcePath: string,
@@ -211,42 +227,78 @@ export async function bodyLookup(
fs: SourceReader,
): Promise<(name: string) => ClassDecl | null> {
const known = new Map<string, ClassDecl>();
const add = (f: SourceFile) => {
for (const c of allClasses(f)) if (!known.has(c.name)) known.set(c.name, c);
/** Where each class was read: names in its declarations resolve from that file. */
const origins = new Map<string, Origin>();
const add = (o: Origin) => {
for (const c of allClasses(o.file))
if (!known.has(c.name)) {
known.set(c.name, c);
origins.set(c.name, o);
}
};
add(file);
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
add(origin);
const sourceRoot = (path: string, f: SourceFile) => {
let root = dirname(path);
for (let i = f.packageName ? f.packageName.split(".").length : 0; i > 0; i--) root = dirname(root);
return root;
};
const ext = /\.kt$/.test(sourcePath) ? [".kt", ".java"] : [".java", ".kt"];
/** Loads `name` as seen from `from` (its imports, then its directory). */
const load = async (name: string, from: { path: string; file: SourceFile }) => {
if (known.has(name) || !/^[A-Z]\w*$/.test(name)) return null;
const readAt = async (path: string) => {
const text = await fs.read(path).catch(() => null);
if (text === null) return null;
const loaded = { path, file: parseSource(text) };
add(loaded);
return loaded;
};
/** Loads `name` as seen from `from` (its imports, then its package). */
const load = async (name: string, from: Origin) => {
if (known.has(name)) return origins.get(name) ?? null;
if (!/^[A-Z]\w*$/.test(name)) return null;
const imported = from.file.imports.find((i) => i.endsWith(`.${name}`));
const dir = dirname(from.path);
const pkgDir = imported && `${sourceRoot(from.path, from.file)}/${imported.split(".").slice(0, -1).join("/")}`;
for (const d of pkgDir ? [pkgDir, dir] : [dir])
const packages = imported
? [imported.split(".").slice(0, -1).join(".")]
: [from.file.packageName, ...from.file.imports.filter((i) => i.endsWith(".*")).map((i) => i.slice(0, -2))];
const root = sourceRoot(from.path, from.file);
const dirs = new Set([...packages.map((p) => (p ? `${root}/${p.replace(/\./g, "/")}` : root)), dirname(from.path)]);
for (const d of dirs)
for (const x of ext) {
const path = `${d}/${name}${x}`;
const text = await fs.read(path).catch(() => null);
if (text === null) continue;
const loaded = parseSource(text);
add(loaded);
return { path, file: loaded };
const loaded = await readAt(`${d}/${name}${x}`);
if (loaded) return loaded;
}
// Another source root or module: the project's files of that name, in one of those packages.
if (!fs.filesNamed) return null;
const suffixes = packages.map((p) => `/${p ? `${p.replace(/\./g, "/")}/` : ""}${name}`);
for (const x of ext) {
const paths = (await fs.filesNamed(`${name}${x}`).catch(() => [])).map((p) => p.replace(/\\/g, "/"));
const path = paths.find((p) => suffixes.some((s) => p.endsWith(`${s}${x}`)));
const loaded = path ? await readAt(path) : null;
if (loaded) return loaded;
}
return null;
};
const typeName = (t: string) => bareType(t).replace(/<.*$/, "");
const origin = { path: sourcePath.replace(/\\/g, "/"), file };
/** `name`, its superclasses, and the property types of each, one level down. */
const loadWithProperties = async (name: string, from: Origin, depth: number) => {
const seen = new Set<string>();
let [current, at]: [string | undefined, Origin | null] = [name, from];
while (current && at && !seen.has(current) && seen.size < MAX_SUPERCLASSES) {
seen.add(current);
const loaded = await load(current, at);
const c = known.get(current);
if (!c || !loaded) break;
if (depth > 0)
for (const p of [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)])
await loadWithProperties(typeName(p.type), loaded, depth - 1);
[current, at] = [c.superclass && typeName(c.superclass), loaded];
}
};
const names = new Set(endpoints.flatMap((e) => (e.bodyType ? [typeName(e.bodyType)] : [])));
for (const n of names) {
const from = (await load(n, origin)) ?? origin;
// Property types one level down, as seen from the body type's file.
const c = known.get(n);
for (const p of c ? [...c.ctorParams, ...c.fields.filter((f) => !f.isStatic)] : [])
await load(typeName(p.type), from);
}
for (const n of names) await loadWithProperties(n, origin, 1);
return (name) => known.get(name) ?? null;
}
interface Origin {
path: string;
file: SourceFile;
}
+28
View File
@@ -65,6 +65,8 @@ export interface ClassDecl {
/** Java record components / Kotlin primary constructor parameters (`val`/`var` ones are properties). */
ctorParams: Param[];
classes: ClassDecl[];
/** The class it extends (`extends Base<T>` / Kotlin `: Base()`), as written. */
superclass?: string;
}
export interface SourceFile {
@@ -220,6 +222,26 @@ class Reader {
return !!t && t.text === text && (t.kind === "p" || t.kind === "id");
}
/** A type name at `i` (`a.b.Base<T>`): its text and the index after it. */
typeName(i: number, to: number): { name: string; end: number } | null {
let j = i;
while (j < to && this.toks[j]?.kind === "id" && (j === i || this.is(j - 1, "."))) {
j++;
if (!this.is(j, ".") || this.toks[j + 1]?.kind !== "id") break;
j++;
}
if (j === i) return null;
if (this.is(j, "<")) {
let depth = 0;
for (; j < to; j++) {
if (this.is(j, "<")) depth++;
else if (this.is(j, ">") && --depth === 0) break;
}
j++;
}
return { name: joined(this.toks.slice(i, j)), end: j };
}
/** Kotlin: a token on a new line that starts a member ends the previous one. */
startsMember(i: number): boolean {
const t = this.toks[i];
@@ -415,6 +437,12 @@ class Reader {
}
for (; j < to; j++) {
if (this.is(j, "{") || this.is(j, ";") || this.startsMember(j)) break;
// Java `extends Base`; Kotlin `: Base(…)` (a supertype called with arguments is the class).
const javaSuper = this.toks[j]!.kind === "id" && this.toks[j]!.text === "extends";
if (!cls.superclass && (javaSuper || this.is(j, ":") || this.is(j, ","))) {
const type = this.typeName(j + 1, to);
if (type && (javaSuper || this.is(type.end, "("))) cls.superclass = type.name;
}
if (OPEN[this.toks[j]!.text] && this.toks[j]!.kind === "p") j = this.match[j]!;
}
if (this.is(j, "{")) {
@@ -271,6 +271,41 @@ describe("body types from other files", () => {
expect(skeleton("List<CreateUser>", lookup)).toEqual([]);
expect(skeleton("Unknown", lookup)).toEqual({});
});
it("finds a DTO in another module through the index, with its superclass fields", async () => {
const files: Record<string, string> = {
"/ws/common/src/main/java/com/example/demo/dto/CreateUser.java":
"package com.example.demo.dto;\nimport com.example.base.BaseEntity;\n@Data\npublic class CreateUser extends BaseEntity<Long> implements Serializable {\n private String name;\n private Address address;\n}\n",
"/ws/common/src/main/java/com/example/demo/dto/Address.java":
"package com.example.demo.dto;\npublic class Address { private String city; }\n",
"/ws/base/src/main/java/com/example/base/BaseEntity.java":
"package com.example.base;\npublic abstract class BaseEntity<ID> { private ID id; private LocalDateTime createdAt; }\n",
// Same name, other package: not the imported class.
"/ws/legacy/src/main/java/com/legacy/CreateUser.java":
"package com.legacy;\npublic class CreateUser { int old; }\n",
};
const fs: SourceReader = {
read: async (p) => files[p] ?? null,
filesNamed: async (name) => Object.keys(files).filter((p) => p.endsWith(`/${name}`)),
};
const eps = springControllers(JAVA)[0]!.endpoints;
const lookup = await bodyLookup(
"/ws/web/src/main/java/com/example/demo/web/UserController.java",
parseSource(JAVA),
eps,
fs,
);
expect(skeleton("CreateUser", lookup)).toEqual({ id: {}, createdAt: "", name: "", address: { city: "" } });
});
it("reads Java and Kotlin superclasses", () => {
const java = parseSource("class A extends com.x.Base<T> implements I { }\ninterface J extends K {}");
expect(java.classes.map((c) => c.superclass)).toEqual(["com.x.Base<T>", "K"]);
const kotlin = parseSource(
"class A(val a: Int) : Base(a), Serializable\nclass B : Serializable, Other()\nclass C : I",
);
expect(kotlin.classes.map((c) => c.superclass)).toEqual(["Base", "Other", undefined]);
});
});
describe("appending to the request file", () => {