Compare commits

...
5 changed files with 1050 additions and 82 deletions
@@ -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>
))}
+479 -64
View File
@@ -20,8 +20,15 @@ type ImportResources = {
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
};
type ImportedAuthentication = Pick<HttpRequest, "authentication" | "authenticationType"> & {
headers: HttpRequestHeader[];
urlParameters: HttpUrlParameter[];
};
type AuthenticationVariableRegistry = Map<string, { name: string; value: string }>;
type OAuthVariableNames = { clientId: string; clientSecret: string };
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 +69,25 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
folders: [],
httpRequests: [],
};
const authenticationVariables: AuthenticationVariableRegistry = new Map();
const oauthVariablesByScheme = buildOAuthVariablesByScheme(importState, spec);
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(),
});
}
const serverEnvironments = importServerEnvironments(spec);
// 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 +114,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,
@@ -119,21 +127,76 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
importState,
method,
operation,
oauthVariablesByScheme,
path: rawPath,
pathItem,
pathParameters,
requestBaseUrl,
serverOverrides,
useDynamicServerUrls: serverEnvironments.length > 1,
spec,
workspaceId: workspace.id,
folderId,
authenticationVariables,
});
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
resources.httpRequests.push(request);
}
}
if (resources.httpRequests.some((request) => request.authenticationType === "oauth2")) {
const variableNames = new Set(
[...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [
clientId,
clientSecret,
]),
);
if (
resources.httpRequests.some(
(request) =>
request.authenticationType === "oauth2" &&
Object.values(toRecord(request.authentication)).some(
(value) =>
typeof value === "string" && value.includes(templateVariable("baseUrlOrigin")),
),
)
) {
variableNames.add("baseUrlOrigin");
}
resources.environments[0]?.variables.push(
...[...variableNames].map((name) => ({ name, value: "" })),
);
}
if (resources.httpRequests.length === 0) return undefined;
const baseEnvironment = resources.environments[0];
if (baseEnvironment == null) return undefined;
baseEnvironment.variables.push(...authenticationVariables.values());
const environmentSpecificVariables = baseEnvironment.variables;
baseEnvironment.variables = [...serverOverrides.values()];
resources.environments.push(
...serverEnvironments.map(({ name, url }) => ({
model: "environment" as const,
id: importState.generateId("environment"),
workspaceId: workspace.id,
name,
variables: environmentSpecificVariables.map((variable) => ({
...variable,
value:
variable.name === "baseUrl"
? url
: variable.name === "baseUrlOrigin"
? serverUrlOrigin(url)
: variable.value,
})),
parentModel: "environment" as const,
parentId: null,
sortPriority: importState.nextSortPriority(),
})),
);
disambiguateNames(resources.httpRequests, routeLabels);
return {
@@ -150,6 +213,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
@@ -178,24 +259,32 @@ function importOperation({
importState,
method,
operation,
oauthVariablesByScheme,
path,
pathItem,
pathParameters,
requestBaseUrl,
serverOverrides,
useDynamicServerUrls,
spec,
workspaceId,
folderId,
authenticationVariables,
}: {
importState: ImportState;
method: string;
operation: UnknownRecord;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
path: string;
pathItem: UnknownRecord;
pathParameters: unknown[];
requestBaseUrl: string;
serverOverrides: Map<string, ServerOverrideVariable>;
useDynamicServerUrls: boolean;
spec: UnknownRecord;
workspaceId: string;
folderId: string | null;
authenticationVariables: AuthenticationVariableRegistry;
}): ImportResources["httpRequests"][0] {
importState.beginOperation();
const parameters = mergeParameters({
@@ -204,13 +293,29 @@ function importOperation({
operationParameters: toArray(operation.parameters),
});
const body = importBody({ importState, operation, parameters, spec });
const urlParameters = importUrlParameters({ importState, parameters });
const authentication = importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
operation,
spec,
useDynamicServerUrls,
});
const urlParameters = [
...importUrlParameters({ importState, parameters }),
...authentication.urlParameters,
];
const headers = mergeHeaders(
authentication.headers,
importHeaderParameters({ importState, parameters }),
body.headers,
importAcceptHeader({ importState, operation, spec }),
);
const authentication = importAuthentication({ importState, operation, spec });
const {
headers: _authenticationHeaders,
urlParameters: _authenticationParameters,
...auth
} = authentication;
// Built after everything else, so it can report the refs they left unresolved
const description = importOperationDescription({
@@ -228,13 +333,16 @@ 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,
bodyType: body.bodyType,
sortPriority: importState.nextSortPriority(),
...authentication,
...auth,
};
}
@@ -283,18 +391,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 +663,48 @@ 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 === 0) {
const hasSwaggerServer =
stringAt(spec, "swagger") === "2.0" &&
(stringAt(spec, "host") != null || stringAt(spec, "basePath") != null);
return [
{
name: hasSwaggerServer ? "Server 1" : "Default",
url: hasSwaggerServer ? importBaseUrl(spec) : "",
},
];
}
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 serverUrlOrigin(value: string): string {
try {
const origin = new URL(value).origin;
return origin === "null" ? "" : origin;
} catch {
if (!value.startsWith("//")) return "";
try {
return `//${new URL(`https:${value}`).host}`;
} catch {
return "";
}
}
}
function interpolateServerUrl(server: UnknownRecord): string {
let url = stringAt(server, "url") ?? "";
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
@@ -827,52 +985,164 @@ function inferSchemaType(schema: UnknownRecord): string {
}
function importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
operation,
spec,
useDynamicServerUrls,
}: {
authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
operation: UnknownRecord;
spec: UnknownRecord;
}): Pick<HttpRequest, "authentication" | "authenticationType"> {
useDynamicServerUrls: boolean;
}): ImportedAuthentication {
const security = operation.security ?? spec.security;
if (Array.isArray(operation.security) && operation.security.length === 0) {
return { ...emptyAuthentication(), authenticationType: "none" };
}
if (!Array.isArray(security) || security.length === 0) {
return { authenticationType: null, authentication: {} };
return emptyAuthentication();
}
// Security Requirement Objects are alternatives. If any alternative is
// empty, authentication is optional regardless of where it appears.
if (
security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length === 0)
) {
return { ...emptyAuthentication(), authenticationType: "none" };
}
const schemes = {
...toRecord(toRecord(spec.components).securitySchemes),
...toRecord(spec.securityDefinitions),
};
for (const requirement of security) {
for (const [schemeName, rawScopes] of Object.entries(toRecord(requirement))) {
const scheme = toRecord(importState.resolve(schemes[schemeName]));
const type = stringAt(scheme, "type");
if (type === "oauth2") {
const oauth2 = importOAuth2(scheme, rawScopes);
if (oauth2 != null) return oauth2;
continue;
}
if (type === "apiKey") {
return { authenticationType: "apikey", authentication: importApiKey(scheme, schemeName) };
}
// Swagger 2.0 spells basic auth as its own type rather than an HTTP scheme
if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) {
return {
authenticationType: "basic",
authentication: { username: "", password: "" },
};
}
if (type === "http" && schemeIs(scheme, "bearer")) {
return {
authenticationType: "bearer",
authentication: { token: "", prefix: "Bearer" },
};
}
}
for (const rawRequirement of security) {
if (!isRecord(rawRequirement)) continue;
const imported = importSecurityRequirement({
authenticationVariables,
importState,
oauthVariablesByScheme,
requirement: rawRequirement,
schemes,
spec,
useDynamicServerUrls,
});
if (imported != null) return imported;
}
return { authenticationType: null, authentication: {} };
return emptyAuthentication();
}
function importSecurityRequirement({
authenticationVariables,
importState,
oauthVariablesByScheme,
requirement,
schemes,
spec,
useDynamicServerUrls,
}: {
authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
requirement: UnknownRecord;
schemes: UnknownRecord;
spec: UnknownRecord;
useDynamicServerUrls: boolean;
}): ImportedAuthentication | null {
const entries = Object.entries(requirement);
const headers: HttpRequestHeader[] = [];
const urlParameters: HttpUrlParameter[] = [];
let primaryAuthentication: Pick<HttpRequest, "authentication" | "authenticationType"> | null =
null;
for (const [schemeName, rawScopes] of entries) {
const scheme = toRecord(importState.resolve(schemes[schemeName]));
const type = stringAt(scheme, "type");
if (type === "apiKey") {
const variable = registerAuthenticationVariable(authenticationVariables, schemeName, "key");
if (entries.length === 1) {
primaryAuthentication = {
authenticationType: "apikey",
authentication: importApiKey(scheme, schemeName, variable),
};
} else {
materializeApiKey(scheme, schemeName, variable, headers, urlParameters);
}
continue;
}
let candidate: Pick<HttpRequest, "authentication" | "authenticationType"> | null = null;
if (type === "oauth2") {
candidate = importOAuth2(
scheme,
rawScopes,
importBaseUrl(spec),
oauthVariablesByScheme.get(schemeName) ?? {
clientId: "oauth_client_id",
clientSecret: "oauth_client_secret",
},
useDynamicServerUrls,
);
} else if (type === "openIdConnect") {
const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
candidate = {
authenticationType: "bearer",
authentication: { token: templateVariable(token), prefix: "Bearer" },
};
} else if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) {
const username = registerAuthenticationVariable(
authenticationVariables,
schemeName,
"username",
);
const password = registerAuthenticationVariable(
authenticationVariables,
schemeName,
"password",
);
candidate = {
authenticationType: "basic",
authentication: {
username: templateVariable(username),
password: templateVariable(password),
},
};
} else if (type === "http" && schemeIs(scheme, "bearer")) {
const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
candidate = {
authenticationType: "bearer",
authentication: { token: templateVariable(token), prefix: "Bearer" },
};
}
// A requirement is an AND. Yaak can combine one auth plugin with explicit
// API-key parameters, but cannot represent two auth plugins on one request.
if (candidate == null || primaryAuthentication != null) return null;
primaryAuthentication = candidate;
}
return {
...(primaryAuthentication ?? {
authenticationType: entries.length > 1 ? "none" : null,
authentication: {},
}),
headers,
urlParameters,
};
}
function emptyAuthentication(): ImportedAuthentication {
return {
authenticationType: null,
authentication: {},
headers: [],
urlParameters: [],
};
}
function schemeIs(scheme: UnknownRecord, name: string): boolean {
@@ -884,14 +1154,67 @@ function schemeIs(scheme: UnknownRecord, name: string): boolean {
* cookie key becomes the Cookie header it would have ended up in, pre-filled
* with its name. Sending it as a header named after the cookie would just fail.
*/
function importApiKey(scheme: UnknownRecord, schemeName: string): Record<string, string> {
function importApiKey(
scheme: UnknownRecord,
schemeName: string,
variableName: string,
): Record<string, string> {
const key = stringAt(scheme, "name") ?? schemeName;
const location = stringAt(scheme, "in");
const value = templateVariable(variableName);
if (location === "cookie") {
return { location: "header", key: "Cookie", value: `${key}=` };
return { location: "header", key: "Cookie", value: `${key}=${value}` };
}
return { location: location === "query" ? "query" : "header", key, value: "" };
return { location: location === "query" ? "query" : "header", key, value };
}
function materializeApiKey(
scheme: UnknownRecord,
schemeName: string,
variableName: string,
headers: HttpRequestHeader[],
urlParameters: HttpUrlParameter[],
): void {
const key = stringAt(scheme, "name") ?? schemeName;
const location = stringAt(scheme, "in");
const value = templateVariable(variableName);
if (location === "query") {
urlParameters.push({ enabled: true, name: key, value });
} else if (location === "cookie") {
headers.push({ enabled: true, name: "Cookie", value: `${key}=${value}` });
} else {
headers.push({ enabled: true, name: key, value });
}
}
function registerAuthenticationVariable(
variables: AuthenticationVariableRegistry,
schemeName: string,
field: string,
): string {
const identity = JSON.stringify([schemeName, field]);
const existing = variables.get(identity);
if (existing != null) return existing.name;
const schemePart = schemeName
.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
.replaceAll(/[^a-zA-Z0-9]+/g, "_")
.replaceAll(/^_+|_+$/g, "")
.toLowerCase();
const baseName = `auth_${schemePart || "security"}_${field}`;
let name = baseName;
let suffix = 2;
const names = new Set([...variables.values()].map((variable) => variable.name));
while (names.has(name)) {
name = `${baseName}_${suffix++}`;
}
variables.set(identity, { name, value: "" });
return name;
}
function templateVariable(name: string): string {
return `\${[${name}]}`;
}
/**
@@ -902,6 +1225,9 @@ function importApiKey(scheme: UnknownRecord, schemeName: string): Record<string,
function importOAuth2(
scheme: UnknownRecord,
rawScopes: unknown,
baseUrl: string,
variableNames: OAuthVariableNames,
useDynamicServerUrls: boolean,
): Pick<HttpRequest, "authentication" | "authenticationType"> | null {
const scope = toArray(rawScopes)
.filter((s): s is string => typeof s === "string")
@@ -929,24 +1255,44 @@ function importOAuth2(
}
for (const { grantType, flow } of candidates) {
const authorizationUrl = stringAt(flow, "authorizationUrl");
const accessTokenUrl = stringAt(flow, "tokenUrl");
const authorizationUrl = resolveOAuthUrl(
stringAt(flow, "authorizationUrl"),
baseUrl,
useDynamicServerUrls,
);
const accessTokenUrl = resolveOAuthUrl(
stringAt(flow, "tokenUrl"),
baseUrl,
useDynamicServerUrls,
);
if (authorizationUrl == null && accessTokenUrl == null) continue;
const grantPatch =
grantType === "authorization_code"
? { authorizationUrl, accessTokenUrl, clientSecret: "" }
? {
authorizationUrl,
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
}
: grantType === "implicit"
? { authorizationUrl }
: grantType === "password"
? { accessTokenUrl, clientSecret: "", username: "", password: "" }
: { accessTokenUrl, clientSecret: "" };
? {
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
username: "",
password: "",
}
: {
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
};
return {
authenticationType: "oauth2",
authentication: {
grantType,
clientId: "",
clientId: templateVariable(variableNames.clientId),
headerPrefix: "Bearer",
...(scope.length > 0 ? { scope } : {}),
...grantPatch,
@@ -957,6 +1303,75 @@ function importOAuth2(
return null;
}
function resolveOAuthUrl(
value: string | undefined,
baseUrl: string,
useDynamicServerUrls: boolean,
): string | undefined {
if (value == null) return undefined;
try {
return new URL(value).toString();
} catch {
// Relative endpoint; resolve it against the API base below.
}
if (value.startsWith("//")) return value;
if (useDynamicServerUrls) {
return value.startsWith("/")
? `${templateVariable("baseUrlOrigin")}${value}`
: joinUrlParts(templateVariable("baseUrl"), value);
}
if (baseUrl.length > 0) {
try {
return new URL(value, `${trimTrailingSlashes(baseUrl)}/`).toString();
} catch {
// A path-only server has no origin to resolve against. Preserve whether
// the OAuth endpoint is relative to that path or to the eventual origin.
}
}
try {
const placeholderOrigin = "https://openapi-import.invalid";
const relativeBase = new URL(`${trimTrailingSlashes(baseUrl)}/`, placeholderOrigin);
const resolved = new URL(value, relativeBase);
return `${templateVariable("baseUrlOrigin")}${resolved.pathname}${resolved.search}${resolved.hash}`;
} catch {
return joinUrlParts(templateVariable("baseUrlOrigin"), value);
}
}
function buildOAuthVariablesByScheme(
importState: ImportState,
spec: UnknownRecord,
): Map<string, OAuthVariableNames> {
const schemes = {
...toRecord(toRecord(spec.components).securitySchemes),
...toRecord(spec.securityDefinitions),
};
const oauthSchemeNames = Object.entries(schemes)
.filter(([, scheme]) => stringAt(importState.resolve(scheme), "type") === "oauth2")
.map(([name]) => name);
const usedPrefixes = new Set<string>();
return new Map(
oauthSchemeNames.map((schemeName) => {
const basePrefix =
oauthSchemeNames.length === 1
? "oauth"
: `oauth_${schemeName.replaceAll(/[^a-zA-Z0-9_]+/g, "_").replaceAll(/^_+|_+$/g, "") || "auth"}`;
let prefix = basePrefix;
let suffix = 2;
while (usedPrefixes.has(prefix)) prefix = `${basePrefix}_${suffix++}`;
usedPrefixes.add(prefix);
return [
schemeName,
{ clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` },
];
}),
);
}
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
const headers: HttpRequestHeader[] = [];
for (const header of headerGroups.flat()) {
@@ -11,6 +11,16 @@ exports[`importer-openapi > Snapshots real-world fixture apis-guru.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"model": "environment",
"name": "Server 1",
"parentId": null,
"parentModel": "environment",
"sortPriority": 9,
"variables": [
{
"name": "baseUrl",
@@ -326,6 +336,16 @@ exports[`importer-openapi > Snapshots real-world fixture httpbin.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"model": "environment",
"name": "Server 1",
"parentId": null,
"parentModel": "environment",
"sortPriority": 90,
"variables": [
{
"name": "baseUrl",
@@ -2612,11 +2632,44 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"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",
},
{
"name": "auth_api_key_key",
"value": "",
},
],
"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",
},
{
"name": "auth_api_key_key",
"value": "",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -2640,7 +2693,7 @@ Here's a link: https://example.com",
"authentication": {
"key": "api_key",
"location": "query",
"value": "",
"value": "\${[auth_api_key_key]}",
},
"authenticationType": "apikey",
"body": {},
@@ -2711,6 +2764,16 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = `
"parentId": null,
"parentModel": "workspace",
"sortPriority": 0,
"variables": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
"id": "GENERATE_ID::ENVIRONMENT_1",
"model": "environment",
"name": "Server 1",
"parentId": null,
"parentModel": "environment",
"sortPriority": 3,
"variables": [
{
"name": "baseUrl",
+463 -11
View File
@@ -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({
@@ -120,7 +150,15 @@ describe("importer-openapi", () => {
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
variables: [],
}),
expect.objectContaining({
name: "Server 1",
parentModel: "environment",
variables: [
{ name: "baseUrl", value: "https://api.example.com/v1" },
{ name: "auth_token_auth_token", value: "" },
],
}),
]);
expect(imported?.resources.httpRequests).toEqual([
@@ -129,7 +167,7 @@ describe("importer-openapi", () => {
method: "POST",
url: "${[baseUrl]}/accounts/:accountId/members",
authenticationType: "bearer",
authentication: { token: "", prefix: "Bearer" },
authentication: { token: "${[auth_token_auth_token]}", prefix: "Bearer" },
bodyType: "application/json",
body: {
text: JSON.stringify(
@@ -219,6 +257,11 @@ describe("importer-openapi", () => {
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [],
}),
expect.objectContaining({
name: "Server 1",
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
}),
]);
@@ -229,6 +272,31 @@ 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: [],
}),
expect.objectContaining({
name: "Default",
parentModel: "environment",
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 +319,119 @@ 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).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [
{ name: "serverUrl", value: "https://path.example.com" },
{ name: "serverUrl2", value: "https://operation.example.com" },
],
}),
expect.objectContaining({
name: "Server 1",
variables: [{ name: "baseUrl", value: "https://root.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: {
"/oauth": { get: { security: [{ oauth: [] }], responses: {} } },
"/api-key": { get: { security: [{ apiKey: [] }], responses: {} } },
"/fixed": {
servers: [{ url: "https://fixed.example.com" }],
get: { responses: {} },
},
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "/oauth/authorize",
tokenUrl: "oauth/token",
scopes: {},
},
},
},
apiKey: { type: "apiKey", in: "header", name: "X-API-Key" },
},
},
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [{ name: "serverUrl", value: "https://fixed.example.com" }],
}),
expect.objectContaining({
name: "Production",
parentModel: "environment",
variables: [
{ name: "baseUrl", value: "https://api.example.com/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "baseUrlOrigin", value: "https://api.example.com" },
{ name: "auth_api_key_key", value: "" },
],
}),
expect.objectContaining({
name: "Sandbox",
parentModel: "environment",
variables: [
{ name: "baseUrl", value: "https://sandbox.example.com/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "baseUrlOrigin", value: "https://sandbox.example.com" },
{ name: "auth_api_key_key", value: "" },
],
}),
]);
expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
expect.objectContaining({
authorizationUrl: "${[baseUrlOrigin]}/oauth/authorize",
accessTokenUrl: "${[baseUrl]}/oauth/token",
}),
);
});
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: "serverUrl", value: "https://path.example.com" }],
}),
expect.objectContaining({
name: "Default",
variables: [{ name: "baseUrl", value: "" }],
}),
]);
});
@@ -289,8 +468,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "client_credentials",
clientId: "",
clientSecret: "",
clientId: "${[oauth_oauth_client_id]}",
clientSecret: "${[oauth_oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "read write",
accessTokenUrl: "https://example.com/token",
@@ -302,12 +481,125 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "implicit",
clientId: "",
clientId: "${[oauth_implicitOauth_client_id]}",
headerPrefix: "Bearer",
authorizationUrl: "https://example.com/authorize",
},
}),
);
expect(imported?.resources.environments[0]?.variables).toEqual([]);
expect(imported?.resources.environments[1]).toEqual(
expect.objectContaining({
name: "Default",
variables: [
{ name: "baseUrl", value: "" },
{ name: "oauth_oauth_client_id", value: "" },
{ name: "oauth_oauth_client_secret", value: "" },
{ name: "oauth_implicitOauth_client_id", value: "" },
{ name: "oauth_implicitOauth_client_secret", value: "" },
],
}),
);
});
test("Uses server environment variables for OAuth2 client credentials", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "OAuth Environment Test", version: "1.0.0" },
servers: [{ url: "https://api.example.com" }],
paths: {
"/users": {
get: { security: [{ oauth: ["read"] }], responses: {} },
post: { security: [{ oauth: ["write"] }], responses: {} },
},
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "/oauth/authorize",
tokenUrl: "/oauth/token",
scopes: { read: "Read users", write: "Write users" },
},
},
},
},
},
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({ name: "Global Variables", variables: [] }),
expect.objectContaining({
name: "Server 1",
variables: [
{ name: "baseUrl", value: "https://api.example.com" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
],
}),
]);
expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([
expect.objectContaining({
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
authorizationUrl: "https://api.example.com/oauth/authorize",
accessTokenUrl: "https://api.example.com/oauth/token",
}),
expect.objectContaining({
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
}),
]);
});
test("Uses the server environment origin for OAuth endpoints with a path-only API base", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.4",
info: { title: "Path-only OAuth Test", version: "1.0.0" },
servers: [{ url: "/api/v1" }],
paths: {
"/users": { get: { security: [{ oauth: [] }], responses: {} } },
},
components: {
securitySchemes: {
oauth: {
type: "oauth2",
flows: {
authorizationCode: {
authorizationUrl: "oauth/authorize",
tokenUrl: "/oauth/token",
scopes: {},
},
},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
expect.objectContaining({
authorizationUrl: "${[baseUrlOrigin]}/api/v1/oauth/authorize",
accessTokenUrl: "${[baseUrlOrigin]}/oauth/token",
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({ name: "Global Variables", variables: [] }),
expect.objectContaining({
name: "Server 1",
variables: [
{ name: "baseUrl", value: "/api/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "baseUrlOrigin", value: "" },
],
}),
]);
});
test("Imports Swagger 2 OAuth2 flows and produces", async () => {
@@ -335,8 +627,8 @@ describe("importer-openapi", () => {
authenticationType: "oauth2",
authentication: {
grantType: "authorization_code",
clientId: "",
clientSecret: "",
clientId: "${[oauth_client_id]}",
clientSecret: "${[oauth_client_secret]}",
headerPrefix: "Bearer",
scope: "admin",
authorizationUrl: "https://example.com/authorize",
@@ -523,16 +815,176 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests[0]).toEqual(
expect.objectContaining({
authenticationType: "basic",
authentication: { username: "", password: "" },
authentication: {
username: "${[auth_basic_auth_username]}",
password: "${[auth_basic_auth_password]}",
},
}),
);
// The auth plugin has no cookie location, so it becomes the Cookie header
expect(imported?.resources.httpRequests[1]).toEqual(
expect.objectContaining({
authenticationType: "apikey",
authentication: { location: "header", key: "Cookie", value: "session=" },
authentication: {
location: "header",
key: "Cookie",
value: "session=${[auth_cookie_key_key]}",
},
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [],
}),
expect.objectContaining({
name: "Server 1",
variables: [
{ name: "baseUrl", value: "https://example.com/" },
{ name: "auth_basic_auth_username", value: "" },
{ name: "auth_basic_auth_password", value: "" },
{ name: "auth_cookie_key_key", value: "" },
],
}),
]);
});
test("Preserves anonymous security alternatives and explicit auth overrides", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Optional Auth", version: "1.0.0" },
security: [{ bearerAuth: [] }],
paths: {
"/optional-auth-first": {
get: { security: [{ bearerAuth: [] }, {}], responses: {} },
},
"/optional-anonymous-first": {
get: { security: [{}, { bearerAuth: [] }], responses: {} },
},
"/public": { get: { security: [], responses: {} } },
},
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
},
},
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({ authenticationType: "none", authentication: {} }),
expect.objectContaining({ authenticationType: "none", authentication: {} }),
expect.objectContaining({ authenticationType: "none", authentication: {} }),
]);
});
test("Imports AND security requirements without dropping API keys", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Combined Auth", version: "1.0.0" },
paths: {
"/combined": {
get: {
security: [{ bearerAuth: [], tenantKey: [], queryKey: [] }],
parameters: [
{
in: "header",
name: "X-Tenant-Key",
example: "operation-value-must-not-replace-auth",
},
],
responses: {},
},
},
},
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
tenantKey: { type: "apiKey", in: "header", name: "X-Tenant-Key" },
queryKey: { type: "apiKey", in: "query", name: "api_key" },
},
},
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
authenticationType: "bearer",
authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
headers: [{ enabled: true, name: "X-Tenant-Key", value: "${[auth_tenant_key_key]}" }],
urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }],
}),
]);
});
test("Keeps distinct credentials for security scheme names that normalize alike", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Auth Variable Names", version: "1.0.0" },
paths: {
"/hyphen": { get: { security: [{ "api-key": [] }], responses: {} } },
"/underscore": { get: { security: [{ api_key: [] }], responses: {} } },
"/hyphen-again": { get: { security: [{ "api-key": [] }], responses: {} } },
},
components: {
securitySchemes: {
"api-key": { type: "apiKey", in: "header", name: "X-Hyphen-Key" },
api_key: { type: "apiKey", in: "header", name: "X-Underscore-Key" },
},
},
}),
);
expect(imported?.resources.environments[0]?.variables).toEqual([]);
expect(imported?.resources.environments[1]).toEqual(
expect.objectContaining({
name: "Default",
variables: [
{ name: "baseUrl", value: "" },
{ name: "auth_api_key_key", value: "" },
{ name: "auth_api_key_key_2", value: "" },
],
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
}),
expect.objectContaining({
authentication: expect.objectContaining({ value: "${[auth_api_key_key_2]}" }),
}),
expect.objectContaining({
authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
}),
]);
});
test("Imports OpenID Connect as bearer authentication", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "OpenID Connect", version: "1.0.0" },
paths: { "/me": { get: { security: [{ oidc: [] }], responses: {} } } },
components: {
securitySchemes: {
oidc: {
type: "openIdConnect",
openIdConnectUrl: "https://accounts.example.com/.well-known/openid-configuration",
},
},
},
}),
);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
authenticationType: "bearer",
authentication: { token: "${[auth_oidc_token]}", prefix: "Bearer" },
}),
]);
});
test("Reports references that point outside the document", async () => {