mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-19 18:04:06 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12755ff0a8 | ||
|
|
145c2315e5 | ||
|
|
df1fd864b2 | ||
|
|
a2d54ca774 | ||
|
|
36fec8b005 |
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import { CsvViewerInner } from "./CsvViewer";
|
||||
|
||||
vi.mock("@yaakapp-internal/ui", () => ({
|
||||
Table: ({ children }: { children: ReactNode }) => <table>{children}</table>,
|
||||
TableBody: ({ children }: { children: ReactNode }) => <tbody>{children}</tbody>,
|
||||
TableCell: ({ children }: { children: ReactNode }) => <td>{children}</td>,
|
||||
TableHead: ({ children }: { children: ReactNode }) => <thead>{children}</thead>,
|
||||
TableHeaderCell: ({ children }: { children: ReactNode }) => <th>{children}</th>,
|
||||
TableRow: ({ children }: { children: ReactNode }) => <tr>{children}</tr>,
|
||||
}));
|
||||
|
||||
describe("CsvViewer", () => {
|
||||
test("renders columns that extend beyond the first row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<CsvViewerInner
|
||||
text={[
|
||||
"startDate,2026-02-03T00:00-03:00",
|
||||
"endDate,2026-02-03T23:59:59-03:00",
|
||||
"id,Fecha de inicio,Nombre,Estado,Perfil de puesto,ID de sucursal,Sucursal,Fecha de fin,ID de usuario",
|
||||
"391118210,2026-02-03 12:58:55,atencion1,Disponible,ATD,3549,sucursal,2026-02-03 12:59:08,42041",
|
||||
].join("\n")}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("ID de usuario");
|
||||
expect(markup).toContain("42041");
|
||||
expect(markup.match(/<td>/g)).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
@@ -26,27 +26,33 @@ export function CsvViewer({ text, className }: Props) {
|
||||
export function CsvViewerInner({ text, className }: { text: string | null; className?: string }) {
|
||||
const parsed = useMemo(() => {
|
||||
if (text == null) return null;
|
||||
return Papa.parse<Record<string, string>>(text, { header: true, skipEmptyLines: true });
|
||||
return Papa.parse<string[]>(text, { skipEmptyLines: true });
|
||||
}, [text]);
|
||||
|
||||
if (parsed === null) return null;
|
||||
|
||||
const header = parsed.data[0] ?? [];
|
||||
const rows = parsed.data.slice(1);
|
||||
const columnCount = parsed.data.reduce((count, row) => Math.max(count, row.length), 0);
|
||||
const columnIndexes = Array.from({ length: columnCount }, (_, index) => index);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full">
|
||||
<Table className={classNames(className, "text-sm")}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{parsed.meta.fields?.map((field) => (
|
||||
<TableHeaderCell key={field}>{field}</TableHeaderCell>
|
||||
{columnIndexes.map((columnIndex) => (
|
||||
<TableHeaderCell key={columnIndex}>{header[columnIndex] ?? ""}</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{parsed.data.map((row, i) => (
|
||||
{rows.map((row, i) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableRow key={i}>
|
||||
{parsed.meta.fields?.map((key) => (
|
||||
<TableCell key={key}>{row[key] ?? ""}</TableCell>
|
||||
{row.map((cell, columnIndex) => (
|
||||
// oxlint-disable-next-line react/no-array-index-key
|
||||
<TableCell key={columnIndex}>{cell}</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -20,8 +20,9 @@ type ImportResources = {
|
||||
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
|
||||
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
|
||||
};
|
||||
type ServerOverrideVariable = { name: string; value: string };
|
||||
|
||||
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
|
||||
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"];
|
||||
const BODY_CONTENT_TYPE_PREFERENCE = [
|
||||
"application/json",
|
||||
"application/x-www-form-urlencoded",
|
||||
@@ -62,21 +63,22 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
folders: [],
|
||||
httpRequests: [],
|
||||
};
|
||||
const serverOverrides = new Map<string, ServerOverrideVariable>();
|
||||
const baseUrl = importBaseUrl(spec);
|
||||
const requestBaseUrl = baseUrl.length > 0 ? "${[baseUrl]}" : "";
|
||||
|
||||
if (baseUrl.length > 0) {
|
||||
resources.environments.push({
|
||||
model: "environment",
|
||||
id: importState.generateId("environment"),
|
||||
workspaceId: workspace.id,
|
||||
name: "Global Variables",
|
||||
variables: [{ name: "baseUrl", value: baseUrl }],
|
||||
parentModel: "workspace",
|
||||
parentId: null,
|
||||
sortPriority: importState.nextSortPriority(),
|
||||
});
|
||||
}
|
||||
// A local spec has no document URL against which OpenAPI's implicit "/"
|
||||
// server can resolve. Keep the shared variable even when its initial value
|
||||
// is empty so users can configure the host once instead of editing requests.
|
||||
const requestBaseUrl = "${[baseUrl]}";
|
||||
resources.environments.push({
|
||||
model: "environment",
|
||||
id: importState.generateId("environment"),
|
||||
workspaceId: workspace.id,
|
||||
name: "Global Variables",
|
||||
variables: [{ name: "baseUrl", value: baseUrl }],
|
||||
parentModel: "workspace",
|
||||
parentId: null,
|
||||
sortPriority: importState.nextSortPriority(),
|
||||
});
|
||||
|
||||
const folderIdsByTag = new Map<string, string>();
|
||||
const routeLabels = new Map<string, string>();
|
||||
@@ -103,10 +105,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
if (!isRecord(pathItem)) continue;
|
||||
|
||||
const pathParameters = toArray(pathItem.parameters);
|
||||
for (const method of HTTP_METHODS) {
|
||||
const operation = importState.resolve(pathItem[method]);
|
||||
if (!isRecord(operation)) continue;
|
||||
|
||||
for (const { method, operation } of pathItemOperations(pathItem, importState)) {
|
||||
const folderId = findOrCreateFolderId({
|
||||
folderIdsByTag,
|
||||
importState,
|
||||
@@ -123,6 +122,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
pathItem,
|
||||
pathParameters,
|
||||
requestBaseUrl,
|
||||
serverOverrides,
|
||||
spec,
|
||||
workspaceId: workspace.id,
|
||||
folderId,
|
||||
@@ -132,6 +132,36 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
}
|
||||
}
|
||||
|
||||
if (serverOverrides.size > 0) {
|
||||
let environment = resources.environments[0];
|
||||
if (environment == null) {
|
||||
environment = {
|
||||
model: "environment",
|
||||
id: importState.generateId("environment"),
|
||||
workspaceId: workspace.id,
|
||||
name: "Global Variables",
|
||||
variables: [],
|
||||
parentModel: "workspace",
|
||||
parentId: null,
|
||||
sortPriority: importState.nextSortPriority(),
|
||||
};
|
||||
resources.environments.push(environment);
|
||||
}
|
||||
environment.variables.push(...serverOverrides.values());
|
||||
}
|
||||
resources.environments.push(
|
||||
...importServerEnvironments(spec).map(({ name, url }) => ({
|
||||
model: "environment" as const,
|
||||
id: importState.generateId("environment"),
|
||||
workspaceId: workspace.id,
|
||||
name,
|
||||
variables: [{ name: "baseUrl", value: url }],
|
||||
parentModel: "environment",
|
||||
parentId: null,
|
||||
sortPriority: importState.nextSortPriority(),
|
||||
})),
|
||||
);
|
||||
|
||||
if (resources.httpRequests.length === 0) return undefined;
|
||||
|
||||
disambiguateNames(resources.httpRequests, routeLabels);
|
||||
@@ -150,6 +180,24 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
};
|
||||
}
|
||||
|
||||
/** OpenAPI 3.2 adds QUERY plus a map for extension HTTP methods. */
|
||||
function pathItemOperations(
|
||||
pathItem: UnknownRecord,
|
||||
importState: ImportState,
|
||||
): { method: string; operation: UnknownRecord }[] {
|
||||
const operations = HTTP_METHODS.flatMap((method) => {
|
||||
const operation = importState.resolve(pathItem[method]);
|
||||
return isRecord(operation) ? [{ method, operation }] : [];
|
||||
});
|
||||
|
||||
for (const [method, rawOperation] of Object.entries(toRecord(pathItem.additionalOperations))) {
|
||||
if (HTTP_METHODS.includes(method.toLowerCase())) continue;
|
||||
const operation = importState.resolve(rawOperation);
|
||||
if (isRecord(operation)) operations.push({ method, operation });
|
||||
}
|
||||
return operations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two operations sharing a summary are indistinguishable once imported, so the
|
||||
* colliding ones get their route appended. Names that are already unique within
|
||||
@@ -182,6 +230,7 @@ function importOperation({
|
||||
pathItem,
|
||||
pathParameters,
|
||||
requestBaseUrl,
|
||||
serverOverrides,
|
||||
spec,
|
||||
workspaceId,
|
||||
folderId,
|
||||
@@ -193,6 +242,7 @@ function importOperation({
|
||||
pathItem: UnknownRecord;
|
||||
pathParameters: unknown[];
|
||||
requestBaseUrl: string;
|
||||
serverOverrides: Map<string, ServerOverrideVariable>;
|
||||
spec: UnknownRecord;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
@@ -228,7 +278,10 @@ function importOperation({
|
||||
name: importOperationName(operation, method, path),
|
||||
description,
|
||||
method: method.toUpperCase(),
|
||||
url: buildOperationUrl(operationBaseUrl({ operation, pathItem, requestBaseUrl }), path),
|
||||
url: buildOperationUrl(
|
||||
operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
|
||||
path,
|
||||
),
|
||||
urlParameters,
|
||||
headers,
|
||||
body: body.body,
|
||||
@@ -283,18 +336,26 @@ function operationBaseUrl({
|
||||
operation,
|
||||
pathItem,
|
||||
requestBaseUrl,
|
||||
serverOverrides,
|
||||
}: {
|
||||
operation: UnknownRecord;
|
||||
pathItem: UnknownRecord;
|
||||
requestBaseUrl: string;
|
||||
serverOverrides: Map<string, ServerOverrideVariable>;
|
||||
}): string {
|
||||
for (const servers of [operation.servers, pathItem.servers]) {
|
||||
const override = toArray(servers)
|
||||
.map((s) => interpolateServerUrl(toRecord(s)))
|
||||
.find((url) => url.length > 0);
|
||||
// Overrides are inlined rather than shared, since only the spec-level base
|
||||
// URL becomes the baseUrl variable
|
||||
if (override != null) return override;
|
||||
if (override != null) {
|
||||
let variable = serverOverrides.get(override);
|
||||
if (variable == null) {
|
||||
const suffix = serverOverrides.size === 0 ? "" : String(serverOverrides.size + 1);
|
||||
variable = { name: `serverUrl${suffix}`, value: override };
|
||||
serverOverrides.set(override, variable);
|
||||
}
|
||||
return `\${[${variable.name}]}`;
|
||||
}
|
||||
}
|
||||
return requestBaseUrl;
|
||||
}
|
||||
@@ -547,6 +608,24 @@ function importBaseUrl(spec: UnknownRecord): string {
|
||||
return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? "");
|
||||
}
|
||||
|
||||
function importServerEnvironments(spec: UnknownRecord): { name: string; url: string }[] {
|
||||
const servers = toArray(spec.servers)
|
||||
.map(toRecord)
|
||||
.map((server, index) => ({
|
||||
name: stringAt(server, "description")?.trim() || `Server ${index + 1}`,
|
||||
url: interpolateServerUrl(server),
|
||||
}))
|
||||
.filter(({ url }) => url.length > 0);
|
||||
if (servers.length < 2) return [];
|
||||
|
||||
const nameCounts = new Map<string, number>();
|
||||
return servers.map((server) => {
|
||||
const count = (nameCounts.get(server.name) ?? 0) + 1;
|
||||
nameCounts.set(server.name, count);
|
||||
return { ...server, name: count === 1 ? server.name : `${server.name} ${count}` };
|
||||
});
|
||||
}
|
||||
|
||||
function interpolateServerUrl(server: UnknownRecord): string {
|
||||
let url = stringAt(server, "url") ?? "";
|
||||
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
|
||||
|
||||
@@ -2620,6 +2620,36 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = `
|
||||
],
|
||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||
},
|
||||
{
|
||||
"id": "GENERATE_ID::ENVIRONMENT_1",
|
||||
"model": "environment",
|
||||
"name": "Server 1",
|
||||
"parentId": null,
|
||||
"parentModel": "environment",
|
||||
"sortPriority": 3,
|
||||
"variables": [
|
||||
{
|
||||
"name": "baseUrl",
|
||||
"value": "https://api.nasa.gov/planetary",
|
||||
},
|
||||
],
|
||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||
},
|
||||
{
|
||||
"id": "GENERATE_ID::ENVIRONMENT_2",
|
||||
"model": "environment",
|
||||
"name": "Server 2",
|
||||
"parentId": null,
|
||||
"parentModel": "environment",
|
||||
"sortPriority": 4,
|
||||
"variables": [
|
||||
{
|
||||
"name": "baseUrl",
|
||||
"value": "http://api.nasa.gov/planetary",
|
||||
},
|
||||
],
|
||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||
},
|
||||
],
|
||||
"folders": [
|
||||
{
|
||||
|
||||
@@ -13,6 +13,36 @@ describe("importer-openapi", () => {
|
||||
.readdirSync(realWorldFixturesPath)
|
||||
.filter((fixture) => fixture.endsWith(".yaml"));
|
||||
|
||||
test("Imports OpenAPI 3.2 QUERY and additional operations", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.2.0",
|
||||
info: { title: "OpenAPI 3.2 Operations", version: "1.0.0" },
|
||||
paths: {
|
||||
"/resources": {
|
||||
query: { summary: "Query resources", responses: {} },
|
||||
additionalOperations: {
|
||||
COPY: { summary: "Copy resources", responses: {} },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
method: "QUERY",
|
||||
name: "Query resources",
|
||||
url: "${[baseUrl]}/resources",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: "COPY",
|
||||
name: "Copy resources",
|
||||
url: "${[baseUrl]}/resources",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Maps operation description to request description", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
@@ -229,6 +259,26 @@ describe("importer-openapi", () => {
|
||||
expect(imported).toBeUndefined();
|
||||
});
|
||||
|
||||
test("Creates an editable baseUrl variable when OpenAPI omits servers", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Serverless OpenAPI Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/api/widgets": { get: { responses: {} } },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.environments).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Global Variables",
|
||||
variables: [{ name: "baseUrl", value: "" }],
|
||||
}),
|
||||
]);
|
||||
expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/api/widgets");
|
||||
});
|
||||
|
||||
test("Prefers operation and path servers over the spec base URL", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
@@ -251,8 +301,70 @@ describe("importer-openapi", () => {
|
||||
|
||||
expect(imported?.resources.httpRequests.map((r) => r.url)).toEqual([
|
||||
"${[baseUrl]}/root",
|
||||
"https://path.example.com/path-level",
|
||||
"https://operation.example.com/operation-level",
|
||||
"${[serverUrl]}/path-level",
|
||||
"${[serverUrl2]}/operation-level",
|
||||
]);
|
||||
expect(imported?.resources.environments[0]?.variables).toEqual([
|
||||
{ name: "baseUrl", value: "https://root.example.com" },
|
||||
{ name: "serverUrl", value: "https://path.example.com" },
|
||||
{ name: "serverUrl2", value: "https://operation.example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Creates selectable environments for multiple OpenAPI servers", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Server Environments Test", version: "1.0.0" },
|
||||
servers: [
|
||||
{ url: "https://api.example.com/v1", description: "Production" },
|
||||
{ url: "https://sandbox.example.com/v1", description: "Sandbox" },
|
||||
],
|
||||
paths: { "/items": { get: { responses: {} } } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.environments).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Global Variables",
|
||||
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Production",
|
||||
parentModel: "environment",
|
||||
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "Sandbox",
|
||||
parentModel: "environment",
|
||||
variables: [{ name: "baseUrl", value: "https://sandbox.example.com/v1" }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Creates variables for path servers without a top-level server", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Path Server Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/items": {
|
||||
servers: [{ url: "https://path.example.com" }],
|
||||
get: { responses: {} },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.url).toBe("${[serverUrl]}/items");
|
||||
expect(imported?.resources.environments).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Global Variables",
|
||||
variables: [
|
||||
{ name: "baseUrl", value: "" },
|
||||
{ name: "serverUrl", value: "https://path.example.com" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user