Compare commits

..

3 Commits

Author SHA1 Message Date
Gregory Schier 580302cbd2 Use baseUrl variable for OpenAPI imports 2026-06-29 14:42:29 -07:00
Gregory Schier 3b9c311dc5 Avoid regex trimming in OpenAPI importer 2026-06-29 14:32:21 -07:00
Gregory Schier 016fcba1c6 Add native OpenAPI importer 2026-06-29 14:23:36 -07:00
14 changed files with 7984 additions and 470 deletions
+1 -1
View File
@@ -1070,7 +1070,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(),
}),
Duration::from_secs(5),
Duration::from_secs(60),
)
.await?;
+303 -446
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -10,10 +10,10 @@
"test": "vp test --run tests"
},
"dependencies": {
"openapi-to-postmanv2": "^5.8.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@types/openapi-to-postmanv2": "^5.0.0"
"@types/openapi-to-postmanv2": "^5.0.0",
"openapi-to-postmanv2": "^5.8.0"
}
}
+810 -18
View File
@@ -1,7 +1,37 @@
import { convertPostman } from "@yaak/importer-postman/src";
import type { Context, PluginDefinition } from "@yaakapp/api";
import type {
Context,
Environment,
Folder,
HttpRequest,
HttpRequestHeader,
HttpUrlParameter,
PartialImportResources,
PluginDefinition,
Workspace,
} from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import { convert } from "openapi-to-postmanv2";
import YAML from "yaml";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
type UnknownRecord = Record<string, unknown>;
type ImportResources = {
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId" | "variables">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
};
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
const BODY_CONTENT_TYPE_PREFERENCE = [
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data",
"application/xml",
"text/plain",
];
const MAX_EXAMPLE_DEPTH = 8;
const MAX_EXAMPLE_PROPERTIES = 25;
const MAX_DESCRIPTION_ITEMS = 40;
export const plugin: PluginDefinition = {
importer: {
@@ -14,23 +44,785 @@ export const plugin: PluginDefinition = {
};
export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {
// oxlint-disable-next-line no-explicit-any
let postmanCollection: any;
try {
postmanCollection = await new Promise((resolve, reject) => {
// oxlint-disable-next-line no-explicit-any
convert({ type: "string", data: contents }, {}, (err, result: any) => {
if (err != null) reject(err);
const spec = parseSpec(contents);
if (!isOpenApiSpec(spec)) return undefined;
if (Array.isArray(result.output) && result.output.length > 0) {
resolve(result.output[0].data);
}
});
const importState = new ImportState(spec);
const workspace: ImportResources["workspaces"][0] = {
model: "workspace",
id: importState.generateId("workspace"),
name: stringAt(spec.info, "title") ?? "OpenAPI Import",
description: importInfoDescription(toRecord(spec.info)),
};
const resources: ImportResources = {
workspaces: [workspace],
environments: [],
folders: [],
httpRequests: [],
};
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(),
});
} catch {
// Probably not an OpenAPI file, so skip it
return undefined;
}
return convertPostman(JSON.stringify(postmanCollection));
const folderIdsByTag = new Map<string, string>();
for (const tag of toArray(spec.tags)) {
const tagRecord = toRecord(tag);
const name = stringAt(tagRecord, "name");
if (name == null || folderIdsByTag.has(name)) continue;
const folder: ImportResources["folders"][0] = {
model: "folder",
id: importState.generateId("folder"),
workspaceId: workspace.id,
name,
description: importTagDescription(tagRecord),
folderId: null,
sortPriority: importState.nextSortPriority(),
};
resources.folders.push(folder);
folderIdsByTag.set(name, folder.id);
}
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
const pathItem = importState.resolve(rawPathItem);
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;
const folderId = findOrCreateFolderId({
folderIdsByTag,
importState,
operation,
resources,
workspaceId: workspace.id,
});
resources.httpRequests.push(
importOperation({
importState,
method,
operation,
path: rawPath,
pathParameters,
requestBaseUrl,
spec,
workspaceId: workspace.id,
folderId,
}),
);
}
}
if (resources.httpRequests.length === 0) return undefined;
return {
resources: deleteUndefinedAttrs(
convertTemplateSyntax({
environments: resources.environments,
folders: resources.folders,
grpcRequests: [],
httpRequests: resources.httpRequests,
websocketRequests: [],
workspaces: resources.workspaces,
}),
) as PartialImportResources,
};
}
function importOperation({
importState,
method,
operation,
path,
pathParameters,
requestBaseUrl,
spec,
workspaceId,
folderId,
}: {
importState: ImportState;
method: string;
operation: UnknownRecord;
path: string;
pathParameters: unknown[];
requestBaseUrl: string;
spec: UnknownRecord;
workspaceId: string;
folderId: string | null;
}): ImportResources["httpRequests"][0] {
const parameters = [...pathParameters, ...toArray(operation.parameters)].map((p) =>
importState.resolve(p),
);
const body = importBody({ importState, operation, parameters, spec });
const urlParameters = importUrlParameters({ importState, parameters });
const headers = mergeHeaders(importHeaderParameters({ importState, parameters }), body.headers);
return {
model: "http_request",
id: importState.generateId("http_request"),
workspaceId,
folderId,
name: importOperationName(operation, method, path),
description: importOperationDescription({
importState,
operation,
parameters,
bodyContentType: body.bodyType,
}),
method: method.toUpperCase(),
url: buildOperationUrl(requestBaseUrl, path),
urlParameters,
headers,
body: body.body,
bodyType: body.bodyType,
sortPriority: importState.nextSortPriority(),
...importAuthentication({ importState, operation, spec }),
};
}
function parseSpec(contents: string): unknown {
try {
return JSON.parse(contents);
} catch {
// Fall through to YAML.
}
try {
return YAML.parse(contents);
} catch {
return null;
}
}
function isOpenApiSpec(value: unknown): value is UnknownRecord {
const spec = toRecord(value);
const openapi = stringAt(spec, "openapi");
const swagger = stringAt(spec, "swagger");
return isRecord(spec.paths) && (openapi?.startsWith("3.") === true || swagger === "2.0");
}
function importInfoDescription(info: UnknownRecord): string | undefined {
const parts = [
stringAt(info, "description"),
stringAt(info, "termsOfService")
? `Terms of service: ${stringAt(info, "termsOfService")}`
: null,
isRecord(info.contact) && stringAt(info.contact, "email")
? `Contact: ${stringAt(info.contact, "email")}`
: null,
isRecord(info.license) && stringAt(info.license, "name")
? `License: ${stringAt(info.license, "name")}${
stringAt(info.license, "url") ? ` (${stringAt(info.license, "url")})` : ""
}`
: null,
].filter(isPresent);
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
function importTagDescription(tag: UnknownRecord): string | undefined {
const externalDocs = toRecord(tag.externalDocs);
const parts = [
stringAt(tag, "description"),
stringAt(externalDocs, "url")
? `${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`
: null,
].filter(isPresent);
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
function importOperationName(operation: UnknownRecord, method: string, path: string): string {
return (
stringAt(operation, "summary") ??
stringAt(operation, "operationId") ??
`${method.toUpperCase()} ${path}`
);
}
function importOperationDescription({
importState,
operation,
parameters,
bodyContentType,
}: {
importState: ImportState;
operation: UnknownRecord;
parameters: unknown[];
bodyContentType: string | null;
}): string | undefined {
const parts: string[] = [];
const summary = stringAt(operation, "summary");
const description = stringAt(operation, "description");
const operationId = stringAt(operation, "operationId");
if (description != null) {
parts.push(description);
} else if (summary != null) {
parts.push(summary);
}
if (operationId != null) {
parts.push(`Operation ID: ${operationId}`);
}
const parameterDescriptions = parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.slice(0, MAX_DESCRIPTION_ITEMS)
.map((p) => {
const name = stringAt(p, "name") ?? "parameter";
const location = stringAt(p, "in") ?? "unknown";
const required = p.required === true ? ", required" : "";
const description = stringAt(p, "description");
return `- ${name} (${location}${required})${description ? `: ${description}` : ""}`;
});
if (parameterDescriptions.length > 0) {
parts.push(["Parameters:", ...parameterDescriptions].join("\n"));
}
const requestBody = importState.resolve(operation.requestBody);
if (isRecord(requestBody)) {
const content = toRecord(requestBody.content);
const contentTypes = Object.keys(content);
const bodyLines = [
stringAt(requestBody, "description"),
bodyContentType ? `Selected content type: ${bodyContentType}` : null,
contentTypes.length > 0 ? `Available content types: ${contentTypes.join(", ")}` : null,
].filter(isPresent);
if (bodyLines.length > 0) {
parts.push(["Request body:", ...bodyLines].join("\n"));
}
}
const responseDescriptions = Object.entries(toRecord(operation.responses))
.slice(0, MAX_DESCRIPTION_ITEMS)
.map(([status, response]) => {
const responseRecord = toRecord(importState.resolve(response));
return `- ${status}: ${stringAt(responseRecord, "description") ?? ""}`.trimEnd();
});
if (responseDescriptions.length > 0) {
parts.push(["Responses:", ...responseDescriptions].join("\n"));
}
const externalDocs = toRecord(operation.externalDocs);
if (stringAt(externalDocs, "url")) {
parts.push(
`${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`,
);
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
function findOrCreateFolderId({
folderIdsByTag,
importState,
operation,
resources,
workspaceId,
}: {
folderIdsByTag: Map<string, string>;
importState: ImportState;
operation: UnknownRecord;
resources: ImportResources;
workspaceId: string;
}): string | null {
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
if (tag == null) return null;
const existingFolderId = folderIdsByTag.get(tag);
if (existingFolderId != null) return existingFolderId;
const folder: ImportResources["folders"][0] = {
model: "folder",
id: importState.generateId("folder"),
workspaceId,
name: tag,
folderId: null,
sortPriority: importState.nextSortPriority(),
};
resources.folders.push(folder);
folderIdsByTag.set(tag, folder.id);
return folder.id;
}
function buildOperationUrl(baseUrl: string, path: string): string {
return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1"));
}
function importBaseUrl(spec: UnknownRecord): string {
const openApiServer = toArray(spec.servers)
.map((s) => toRecord(s))
.map((s) => interpolateServerUrl(s))
.find((url) => url.length > 0);
if (openApiServer != null) return openApiServer;
const host = stringAt(spec, "host");
if (host == null) return stringAt(spec, "basePath") ?? "";
const scheme = toArray(spec.schemes).find((s): s is string => typeof s === "string") ?? "https";
return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? "");
}
function interpolateServerUrl(server: UnknownRecord): string {
let url = stringAt(server, "url") ?? "";
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default));
}
return url;
}
function joinUrlParts(baseUrl: string, path: string): string {
if (baseUrl.length === 0) return path;
return `${trimTrailingSlashes(baseUrl)}/${trimLeadingSlashes(path)}`;
}
function trimLeadingSlashes(value: string): string {
let index = 0;
while (value[index] === "/") index++;
return value.slice(index);
}
function trimTrailingSlashes(value: string): string {
let index = value.length;
while (value[index - 1] === "/") index--;
return value.slice(0, index);
}
function importUrlParameters({
importState,
parameters,
}: {
importState: ImportState;
parameters: unknown[];
}): HttpUrlParameter[] {
return parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
.map((p) => ({
enabled: p.required === true,
name:
stringAt(p, "in") === "path"
? `:${stringAt(p, "name") ?? ""}`
: (stringAt(p, "name") ?? ""),
value: parameterExample(p, importState),
}))
.filter(({ name }) => name.length > 0);
}
function importHeaderParameters({
importState,
parameters,
}: {
importState: ImportState;
parameters: unknown[];
}): HttpRequestHeader[] {
return parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "header")
.map((p) => ({
enabled: p.required === true,
name: stringAt(p, "name") ?? "",
value: parameterExample(p, importState),
}))
.filter(({ name }) => name.length > 0);
}
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
if (directExample != null) return stringifyExampleValue(directExample);
return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
}
function importBody({
importState,
operation,
parameters,
spec,
}: {
importState: ImportState;
operation: UnknownRecord;
parameters: unknown[];
spec: UnknownRecord;
}): {
headers: HttpRequestHeader[];
body: Record<string, unknown>;
bodyType: string | null;
} {
const openApiRequestBody = importState.resolve(operation.requestBody);
if (isRecord(openApiRequestBody)) {
return importBodyFromContent(importState, toRecord(openApiRequestBody.content));
}
const bodyParameter = parameters
.map((p) => importState.resolve(p))
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
if (isRecord(bodyParameter)) {
const contentType = toArray(spec.consumes).find((c): c is string => typeof c === "string");
const bodyType = contentType ?? "application/json";
return {
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
bodyType,
body: {
text: formatBodyText(
schemaToExample(importState.resolve(bodyParameter.schema), importState),
),
},
};
}
const formParameters = parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "formData");
if (formParameters.length > 0) {
const contentType =
toArray(spec.consumes).find((c): c is string => typeof c === "string") ??
(formParameters.some((p) => stringAt(p, "type") === "file")
? "multipart/form-data"
: "application/x-www-form-urlencoded");
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType,
body: {
form: formParameters.map((p) => ({
enabled: p.required === true,
name: stringAt(p, "name") ?? "",
value: parameterExample(p, importState),
})),
},
};
}
return { headers: [], body: {}, bodyType: null };
}
function importBodyFromContent(importState: ImportState, content: UnknownRecord) {
const contentType = chooseContentType(Object.keys(content));
if (contentType == null) return { headers: [], body: {}, bodyType: null };
const mediaType = toRecord(content[contentType]);
const example = mediaTypeExample(mediaType, importState);
if (
contentType === "application/x-www-form-urlencoded" ||
contentType === "multipart/form-data"
) {
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType,
body: {
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
},
};
}
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
};
}
function chooseContentType(contentTypes: string[]): string | null {
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
if (exact != null) return exact;
}
return contentTypes[0] ?? null;
}
function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples));
if (directExample != null) return directExample;
return schemaToExample(importState.resolve(mediaType.schema), importState);
}
function schemaToFormParameters(schema: unknown, importState: ImportState) {
const resolvedSchema = toRecord(importState.resolve(schema));
const required = toArray(resolvedSchema.required).filter(
(name): name is string => typeof name === "string",
);
const properties = Object.entries(toRecord(resolvedSchema.properties)).slice(
0,
MAX_EXAMPLE_PROPERTIES,
);
return properties.map(([name, property]) => {
const resolvedProperty = toRecord(importState.resolve(property));
const example = schemaToExample(resolvedProperty, importState);
const base = {
enabled: required.includes(name),
name,
};
if (stringAt(resolvedProperty, "format") === "binary") {
return { ...base, file: "" };
}
return { ...base, value: stringifyExampleValue(example) };
});
}
function schemaToExample(
schema: unknown,
importState: ImportState,
depth = 0,
visitedRefs = new Set<string>(),
): unknown {
if (depth > MAX_EXAMPLE_DEPTH) return {};
const resolved = importState.resolve(schema, visitedRefs);
if (!isRecord(resolved)) return "";
const explicitExample = firstPresent(
resolved.example,
firstExampleValue(resolved.examples),
resolved.default,
);
if (explicitExample != null) return explicitExample;
const enumValues = toArray(resolved.enum);
if (enumValues.length > 0) return enumValues[0];
const allOf = toArray(resolved.allOf);
if (allOf.length > 0) {
return allOf.reduce<UnknownRecord>((merged, childSchema) => {
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
}, {});
}
const oneOf = toArray(resolved.oneOf);
const anyOf = toArray(resolved.anyOf);
if (oneOf.length > 0 || anyOf.length > 0) {
return schemaToExample(oneOf[0] ?? anyOf[0], importState, depth + 1, visitedRefs);
}
const type = inferSchemaType(resolved);
if (type === "array") {
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
}
if (type === "object") {
const required = toArray(resolved.required).filter(
(name): name is string => typeof name === "string",
);
const properties = Object.entries(toRecord(resolved.properties)).sort(([a], [b]) => {
const aRequired = required.includes(a);
const bRequired = required.includes(b);
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
});
return Object.fromEntries(
properties
.slice(0, MAX_EXAMPLE_PROPERTIES)
.map(([name, property]) => [
name,
schemaToExample(property, importState, depth + 1, visitedRefs),
]),
);
}
if (type === "integer" || type === "number") return 0;
if (type === "boolean") return false;
if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
if (stringAt(resolved, "format") === "date") return "2026-01-01";
return "";
}
function inferSchemaType(schema: UnknownRecord): string {
const rawType = schema.type;
if (typeof rawType === "string") return rawType;
if (Array.isArray(rawType)) {
const nonNullType = rawType.find((t) => t !== "null");
if (typeof nonNullType === "string") return nonNullType;
}
if (isRecord(schema.properties) || isRecord(schema.additionalProperties)) return "object";
if (schema.items != null) return "array";
return "string";
}
function importAuthentication({
importState,
operation,
spec,
}: {
importState: ImportState;
operation: UnknownRecord;
spec: UnknownRecord;
}): Pick<HttpRequest, "authentication" | "authenticationType"> {
const security = operation.security ?? spec.security;
if (!Array.isArray(security) || security.length === 0) {
return { authenticationType: null, authentication: {} };
}
const schemes = {
...toRecord(toRecord(spec.components).securitySchemes),
...toRecord(spec.securityDefinitions),
};
for (const requirement of security) {
for (const schemeName of Object.keys(toRecord(requirement))) {
const scheme = toRecord(importState.resolve(schemes[schemeName]));
const type = stringAt(scheme, "type");
if (type === "apiKey") {
return {
authenticationType: "apikey",
authentication: {
location: stringAt(scheme, "in") === "query" ? "query" : "header",
key: stringAt(scheme, "name") ?? schemeName,
value: "",
},
};
}
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "basic") {
return {
authenticationType: "basic",
authentication: { username: "", password: "" },
};
}
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "bearer") {
return {
authenticationType: "bearer",
authentication: { token: "", prefix: "Bearer" },
};
}
}
}
return { authenticationType: null, authentication: {} };
}
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
const headers: HttpRequestHeader[] = [];
for (const header of headerGroups.flat()) {
const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase());
if (existing == null) {
headers.push(header);
}
}
return headers;
}
function formatBodyText(example: unknown): string {
return typeof example === "string" ? example : JSON.stringify(example, null, 2);
}
function stringifyExampleValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function firstExampleValue(examples: unknown): unknown {
const firstExample = Object.values(toRecord(examples))[0];
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
return firstExample;
}
function firstPresent(...values: unknown[]): unknown {
return values.find((value) => value !== undefined && value !== null);
}
function stringAt(record: unknown, key: string): string | undefined {
const value = toRecord(record)[key];
return typeof value === "string" ? value : undefined;
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toRecord(value: unknown): UnknownRecord {
return isRecord(value) ? value : {};
}
function isRecord(value: unknown): value is UnknownRecord {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function isPresent<T>(value: T | null | undefined): value is T {
return value != null && value !== "";
}
/** Recursively render all nested object properties */
function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === "string") {
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T;
}
if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
}
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;
}
return obj;
}
function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
}
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
) as T;
}
return obj;
}
class ImportState {
readonly #spec: UnknownRecord;
readonly #idCount: Partial<Record<string, number>> = {};
#sortPriority = 0;
constructor(spec: UnknownRecord) {
this.#spec = spec;
}
generateId(model: string): string {
this.#idCount[model] = (this.#idCount[model] ?? -1) + 1;
return `GENERATE_ID::${model.toUpperCase()}_${this.#idCount[model]}`;
}
nextSortPriority(): number {
return this.#sortPriority++;
}
resolve(value: unknown, visitedRefs = new Set<string>()): unknown {
if (!isRecord(value) || typeof value.$ref !== "string") return value;
if (visitedRefs.has(value.$ref)) return {};
const nextVisitedRefs = new Set(visitedRefs);
nextVisitedRefs.add(value.$ref);
if (!value.$ref.startsWith("#/")) return value;
const resolved = value.$ref
.slice(2)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
return this.resolve(resolved, nextVisitedRefs);
}
}
+26
View File
@@ -0,0 +1,26 @@
import { convertPostman } from "@yaak/importer-postman/src";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import { convert } from "openapi-to-postmanv2";
export async function convertOpenApiWithPostman(
contents: string,
): Promise<ImportPluginResponse | undefined> {
// oxlint-disable-next-line no-explicit-any
let postmanCollection: any;
try {
postmanCollection = await new Promise((resolve, reject) => {
// oxlint-disable-next-line no-explicit-any
convert({ type: "string", data: contents }, {}, (err, result: any) => {
if (err != null) reject(err);
if (Array.isArray(result.output) && result.output.length > 0) {
resolve(result.output[0].data);
}
});
});
} catch {
return undefined;
}
return convertPostman(JSON.stringify(postmanCollection));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
# Real-World OpenAPI Fixtures
These fixtures were copied from the public APIs.guru OpenAPI directory:
- `apis-guru.yaml`: https://api.apis.guru/v2/specs/apis.guru/2.2.0/openapi.yaml
- `httpbin.yaml`: https://api.apis.guru/v2/specs/httpbin.org/0.9.2/openapi.yaml
- `nasa-apod.yaml`: https://api.apis.guru/v2/specs/nasa.gov/apod/1.0.0/openapi.yaml
- `xkcd.yaml`: https://api.apis.guru/v2/specs/xkcd.com/1.0.0/openapi.yaml
@@ -0,0 +1,399 @@
openapi: 3.0.0
servers:
- url: https://api.apis.guru/v2
info:
contact:
email: mike.ralphson@gmail.com
name: APIs.guru
url: https://APIs.guru
description: |
Wikipedia for Web APIs. Repository of API definitions in OpenAPI format.
**Warning**: If you want to be notified about changes in advance please join our [Slack channel](https://join.slack.com/t/mermade/shared_invite/zt-g78g7xir-MLE_CTCcXCdfJfG3CJe9qA).
Client sample: [[Demo]](https://apis.guru/simple-ui) [[Repo]](https://github.com/APIs-guru/simple-ui)
license:
name: CC0 1.0
url: https://github.com/APIs-guru/openapi-directory#licenses
title: APIs.guru
version: 2.2.0
x-apisguru-categories:
- open_data
- developer_tools
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_branding_logo_vertical.svg
x-origin:
- format: openapi
url: https://api.apis.guru/v2/openapi.yaml
version: "3.0"
x-providerName: apis.guru
x-tags:
- API
- Catalog
- Directory
- REST
- Swagger
- OpenAPI
externalDocs:
url: https://github.com/APIs-guru/openapi-directory/blob/master/API.md
security: []
tags:
- description: Actions relating to APIs in the collection
name: APIs
paths:
/list.json:
get:
description: |
List all APIs in the directory.
Returns links to the OpenAPI definitions for each API in the directory.
If API exist in multiple versions `preferred` one is explicitly marked.
Some basic info from the OpenAPI definition is cached inside each object.
This allows you to generate some simple views without needing to fetch the OpenAPI definition for each API.
operationId: listAPIs
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/APIs"
description: OK
summary: List all APIs
tags:
- APIs
/metrics.json:
get:
description: |
Some basic metrics for the entire directory.
Just stunning numbers to put on a front page and are intended purely for WoW effect :)
operationId: getMetrics
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Metrics"
description: OK
summary: Get basic metrics
tags:
- APIs
/providers.json:
get:
description: |
List all the providers in the directory
operationId: getProviders
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
minLength: 1
type: string
minItems: 1
type: array
type: object
description: OK
summary: List all providers
tags:
- APIs
"/specs/{provider}/{api}.json":
get:
description: Returns the API entry for one specific version of an API where there is no serviceName.
operationId: getAPI
parameters:
- $ref: "#/components/parameters/provider"
- $ref: "#/components/parameters/api"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/API"
description: OK
summary: Retrieve one version of a particular API
tags:
- APIs
"/specs/{provider}/{service}/{api}.json":
get:
description: Returns the API entry for one specific version of an API where there is a serviceName.
operationId: getServiceAPI
parameters:
- $ref: "#/components/parameters/provider"
- in: path
name: service
required: true
schema:
example: graph
maxLength: 255
minLength: 1
type: string
- $ref: "#/components/parameters/api"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/API"
description: OK
summary: Retrieve one version of a particular API with a serviceName.
tags:
- APIs
"/{provider}.json":
get:
description: |
List all APIs in the directory for a particular providerName
Returns links to the individual API entry for each API.
operationId: getProvider
parameters:
- $ref: "#/components/parameters/provider"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/APIs"
description: OK
summary: List all APIs for a particular provider
tags:
- APIs
"/{provider}/services.json":
get:
description: |
List all serviceNames in the directory for a particular providerName
operationId: getServices
parameters:
- $ref: "#/components/parameters/provider"
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
minLength: 0
type: string
minItems: 1
type: array
type: object
description: OK
summary: List all serviceNames for a particular provider
tags:
- APIs
components:
parameters:
api:
in: path
name: api
required: true
schema:
example: 2.1.0
maxLength: 255
minLength: 1
type: string
provider:
in: path
name: provider
required: true
schema:
example: apis.guru
maxLength: 255
minLength: 1
type: string
schemas:
API:
additionalProperties: false
description: Meta information about API
properties:
added:
description: Timestamp when the API was first added to the directory
format: date-time
type: string
preferred:
description: Recommended version
type: string
versions:
additionalProperties:
$ref: "#/components/schemas/ApiVersion"
description: List of supported versions of the API
minProperties: 1
type: object
required:
- added
- preferred
- versions
type: object
APIs:
additionalProperties:
$ref: "#/components/schemas/API"
description: |
List of API details.
It is a JSON object with API IDs(`<provider>[:<service>]`) as keys.
example:
googleapis.com:drive:
added: 2015-02-22T20:00:45.000Z
preferred: v3
versions:
v2:
added: 2015-02-22T20:00:45.000Z
info:
title: Drive
version: v2
x-apiClientRegistration:
url: https://console.developers.google.com
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
x-origin:
format: google
url: https://www.googleapis.com/discovery/v1/apis/drive/v2/rest
version: v1
x-preferred: false
x-providerName: googleapis.com
x-serviceName: drive
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.json
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.yaml
updated: 2016-06-17T00:21:44.000Z
v3:
added: 2015-12-12T00:25:13.000Z
info:
title: Drive
version: v3
x-apiClientRegistration:
url: https://console.developers.google.com
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
x-origin:
format: google
url: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest
version: v1
x-preferred: true
x-providerName: googleapis.com
x-serviceName: drive
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.json
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.yaml
updated: 2016-06-17T00:21:44.000Z
minProperties: 1
type: object
ApiVersion:
additionalProperties: false
properties:
added:
description: Timestamp when the version was added
format: date-time
type: string
externalDocs:
description: Copy of `externalDocs` section from OpenAPI definition
minProperties: 1
type: object
info:
description: Copy of `info` section from OpenAPI definition
minProperties: 1
type: object
link:
description: Link to the individual API entry for this API
format: url
type: string
openapiVer:
description: The value of the `openapi` or `swagger` property of the source definition
type: string
swaggerUrl:
description: URL to OpenAPI definition in JSON format
format: url
type: string
swaggerYamlUrl:
description: URL to OpenAPI definition in YAML format
format: url
type: string
updated:
description: Timestamp when the version was updated
format: date-time
type: string
required:
- added
- updated
- swaggerUrl
- swaggerYamlUrl
- info
- openapiVer
type: object
Metrics:
additionalProperties: false
description: List of basic metrics
example:
datasets: []
fixedPct: 22
fixes: 81119
invalid: 598
issues: 28
numAPIs: 2501
numDrivers: 10
numEndpoints: 106448
numProviders: 659
numSpecs: 3329
stars: 2429
thisWeek:
added: 45
updated: 171
unofficial: 25
unreachable: 123
properties:
datasets:
description: Data used for charting etc
items: {}
type: array
fixedPct:
description: Percentage of all APIs where auto fixes have been applied
type: integer
fixes:
description: Total number of fixes applied across all APIs
type: integer
invalid:
description: Number of newly invalid APIs
type: integer
issues:
description: Open GitHub issues on our main repo
type: integer
numAPIs:
description: Number of unique APIs
minimum: 1
type: integer
numDrivers:
description: Number of methods of API retrieval
type: integer
numEndpoints:
description: Total number of endpoints inside all definitions
minimum: 1
type: integer
numProviders:
description: Number of API providers in directory
type: integer
numSpecs:
description: Number of API definitions including different versions of the same API
minimum: 1
type: integer
stars:
description: GitHub stars for our main repo
type: integer
thisWeek:
description: Summary totals for the last 7 days
properties:
added:
description: APIs added in the last week
type: integer
updated:
description: APIs updated in the last week
type: integer
type: object
unofficial:
description: Number of unofficial APIs
type: integer
unreachable:
description: Number of unreachable (4XX,5XX status) APIs
type: integer
required:
- numSpecs
- numAPIs
- numEndpoints
type: object
x-optic-standard: "@febf8ac6-ee67-4565-b45a-5c85a469dca7/Fz6KU3_wMIO5iJ6_VUZ30"
x-optic-url: https://app.useoptic.com/organizations/febf8ac6-ee67-4565-b45a-5c85a469dca7/apis/_0fKWqUvhs9ssYNkq1k-c
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
openapi: 3.0.0
servers:
- url: https://api.nasa.gov/planetary
- url: http://api.nasa.gov/planetary
info:
contact:
email: evan.t.yates@nasa.gov
description: This endpoint structures the APOD imagery and associated metadata so that it can be repurposed for other applications. In addition, if the concept_tags parameter is set to True, then keywords derived from the image explanation are returned. These keywords could be used as auto-generated hashtags for twitter or instagram feeds; but generally help with discoverability of relevant imagery
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
title: APOD
version: 1.0.0
x-apisguru-categories:
- media
- open_data
x-origin:
- format: swagger
url: https://raw.githubusercontent.com/nasa/api-docs/gh-pages/assets/json/APOD
version: "2.0"
x-providerName: nasa.gov
x-serviceName: apod
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_assets_images_no-logo.svg
tags:
- description: An example tag
externalDocs:
description: Here's a link
url: https://example.com
name: request tag
paths:
/apod:
get:
description: Returns the picture of the day
parameters:
- description: The date of the APOD image to retrieve
in: query
name: date
required: false
schema:
type: string
- description: Retrieve the URL for the high resolution image
in: query
name: hd
required: false
schema:
type: boolean
responses:
"200":
content:
application/json:
schema:
items:
x-thing: ok
type: array
description: successful operation
"400":
description: Date must be between Jun 16, 1995 and Mar 28, 2019.
security:
- api_key: []
summary: Returns images
tags:
- request tag
components:
securitySchemes:
api_key:
in: query
name: api_key
type: apiKey
@@ -0,0 +1,78 @@
openapi: 3.0.0
servers:
- url: http://xkcd.com/
info:
description: Webcomic of romance, sarcasm, math, and language.
title: XKCD
version: 1.0.0
x-apisguru-categories:
- media
x-logo:
url: https://api.apis.guru/v2/cache/logo/http_imgs.xkcd.com_static_terrible_small_logo.png
x-origin:
- format: openapi
url: https://raw.githubusercontent.com/APIs-guru/unofficial_openapi_specs/master/xkcd.com/1.0.0/openapi.yaml
version: "3.0"
x-providerName: xkcd.com
x-tags:
- humor
- comics
x-unofficialSpec: true
externalDocs:
url: https://xkcd.com/json.html
paths:
/info.0.json:
get:
description: |
Fetch current comic and metadata.
responses:
"200":
content:
"*/*":
schema:
$ref: "#/components/schemas/comic"
description: OK
"/{comicId}/info.0.json":
get:
description: |
Fetch comics and metadata by comic id.
parameters:
- in: path
name: comicId
required: true
schema:
type: number
responses:
"200":
content:
"*/*":
schema:
$ref: "#/components/schemas/comic"
description: OK
components:
schemas:
comic:
properties:
alt:
type: string
day:
type: string
img:
type: string
link:
type: string
month:
type: string
news:
type: string
num:
type: number
safe_title:
type: string
title:
type: string
transcript:
type: string
year:
type: string
type: object
+205 -3
View File
@@ -5,7 +5,13 @@ import { convertOpenApi } from "../src";
describe("importer-openapi", () => {
const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p);
const fixtures = fs.readdirSync(p).filter((fixture) => {
return fs.statSync(path.join(p, fixture)).isFile();
});
const realWorldFixturesPath = path.join(p, "real-world");
const realWorldFixtures = fs
.readdirSync(realWorldFixturesPath)
.filter((fixture) => fixture.endsWith(".yaml"));
test("Maps operation description to request description", async () => {
const imported = await convertOpenApi(
@@ -25,7 +31,195 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
description: "Lijst van klanten",
description: expect.stringContaining("Lijst van klanten"),
}),
]);
});
test("Imports requests directly from OpenAPI details", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Native Import Test", version: "1.0.0" },
servers: [
{ url: "https://api.example.com/{version}", variables: { version: { default: "v1" } } },
],
tags: [{ name: "accounts", description: "Account operations" }],
paths: {
"/accounts/{accountId}/members": {
parameters: [
{
name: "accountId",
in: "path",
required: true,
description: "Account identifier",
schema: { type: "string", example: "acct_123" },
},
],
post: {
tags: ["accounts"],
summary: "Create member",
operationId: "createMember",
parameters: [
{
name: "include",
in: "query",
description: "Related resources to include",
schema: { type: "string", enum: ["roles"] },
},
{
name: "X-Trace-Id",
in: "header",
schema: { type: "string", example: "trace-123" },
},
],
security: [{ tokenAuth: [] }],
requestBody: {
description: "Member payload",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/MemberInput" },
},
},
},
responses: {
"201": { description: "Created" },
},
},
},
},
components: {
securitySchemes: {
tokenAuth: { type: "http", scheme: "bearer" },
},
schemas: {
MemberInput: {
type: "object",
required: ["email"],
properties: {
email: { type: "string", example: "me@example.com" },
admin: { type: "boolean", default: false },
primaryContact: { $ref: "#/components/schemas/Contact" },
secondaryContact: { $ref: "#/components/schemas/Contact" },
},
},
Contact: {
type: "object",
properties: {
name: { type: "string", example: "Taylor" },
},
},
},
},
}),
);
expect(imported?.resources.folders).toEqual([
expect.objectContaining({ name: "accounts", description: "Account operations" }),
]);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
}),
]);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
name: "Create member",
method: "POST",
url: "${[baseUrl]}/accounts/:accountId/members",
authenticationType: "bearer",
authentication: { token: "", prefix: "Bearer" },
bodyType: "application/json",
body: {
text: JSON.stringify(
{
email: "me@example.com",
admin: false,
primaryContact: { name: "Taylor" },
secondaryContact: { name: "Taylor" },
},
null,
2,
),
},
headers: expect.arrayContaining([
{ enabled: false, name: "X-Trace-Id", value: "trace-123" },
{ enabled: true, name: "Content-Type", value: "application/json" },
]),
urlParameters: [
{ enabled: true, name: ":accountId", value: "acct_123" },
{ enabled: false, name: "include", value: "roles" },
],
description: expect.stringContaining("Operation ID: createMember"),
}),
]);
expect(imported?.resources.httpRequests[0]?.description).toContain("Member payload");
expect(imported?.resources.httpRequests[0]?.description).toContain("201: Created");
});
test("Handles large schemas without the Postman converter path", async () => {
const paths: Record<string, unknown> = {};
for (let i = 0; i < 500; i++) {
paths[`/zones/{zoneId}/resources/${i}`] = {
get: {
tags: ["zones"],
summary: `Read resource ${i}`,
parameters: [
{ name: "zoneId", in: "path", required: true, schema: { type: "string" } },
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
],
responses: {
"200": {
description: "OK",
content: {
"application/json": { schema: { $ref: "#/components/schemas/Resource" } },
},
},
},
},
};
}
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Large API", version: "1.0.0" },
servers: [{ url: "https://api.example.com/client/v4" }],
tags: [{ name: "zones" }],
paths,
components: {
schemas: {
Resource: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
metadata: { $ref: "#/components/schemas/Metadata" },
},
},
Metadata: {
type: "object",
properties: {
createdOn: { type: "string", format: "date-time" },
tags: { type: "array", items: { type: "string" } },
},
},
},
},
}),
);
expect(imported?.resources.httpRequests.length).toBe(500);
expect(imported?.resources.httpRequests[499]).toEqual(
expect.objectContaining({
name: "Read resource 499",
url: "${[baseUrl]}/zones/:zoneId/resources/499",
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
}),
]);
});
@@ -46,7 +240,15 @@ describe("importer-openapi", () => {
}),
]);
expect(imported?.resources.httpRequests.length).toBe(19);
expect(imported?.resources.folders.length).toBe(7);
expect(imported?.resources.folders.map((f) => f.name)).toEqual(["pet", "store", "user"]);
});
}
for (const fixture of realWorldFixtures) {
test(`Snapshots real-world fixture ${fixture}`, async () => {
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
const imported = await convertOpenApi(contents);
expect(imported).toMatchSnapshot();
});
}
});
@@ -0,0 +1,19 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { convertOpenApiWithPostman } from "../src/legacy";
describe("importer-openapi legacy converter", () => {
const realWorldFixturesPath = path.join(__dirname, "fixtures", "real-world");
const realWorldFixtures = fs
.readdirSync(realWorldFixturesPath)
.filter((fixture) => fixture.endsWith(".yaml"));
for (const fixture of realWorldFixtures) {
test(`Snapshots legacy Postman-converter output for ${fixture}`, async () => {
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
const imported = await convertOpenApiWithPostman(contents);
expect(imported).toMatchSnapshot();
});
}
});