Fix OpenAPI importer issues found by spec review (#599)

This commit is contained in:
Gregory Schier
2026-08-20 07:16:44 -07:00
committed by GitHub
parent c4f96f3f11
commit 4eebd606ef
3 changed files with 617 additions and 152 deletions
+260 -94
View File
@@ -200,16 +200,14 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
disambiguateNames(resources.httpRequests, routeLabels);
return {
resources: deleteUndefinedAttrs(
convertTemplateSyntax({
environments: resources.environments,
folders: resources.folders,
grpcRequests: [],
httpRequests: resources.httpRequests,
websocketRequests: [],
workspaces: resources.workspaces,
}),
) as PartialImportResources,
resources: deleteUndefinedAttrs({
environments: resources.environments,
folders: resources.folders,
grpcRequests: [],
httpRequests: resources.httpRequests,
websocketRequests: [],
workspaces: resources.workspaces,
}) as PartialImportResources,
};
}
@@ -301,13 +299,26 @@ function importOperation({
spec,
useDynamicServerUrls,
});
const pathExampleValues = new Map(
parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "path" && stringAt(p, "name") != null)
.map((p) => [stringAt(p, "name") as string, parameterExample(p, importState)] as const),
);
const { url, placeholderNames } = buildOperationUrl(
operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
path,
pathExampleValues,
);
const urlParameters = [
...importUrlParameters({ importState, parameters }),
...importUrlParameters({ importState, parameters, placeholderNames }),
...authentication.urlParameters,
];
const headers = mergeHeaders(
authentication.headers,
importHeaderParameters({ importState, parameters }),
importCookieHeader({ importState, parameters }),
body.headers,
importAcceptHeader({ importState, operation, spec }),
);
@@ -333,10 +344,7 @@ function importOperation({
name: importOperationName(operation, method, path),
description,
method: method.toUpperCase(),
url: buildOperationUrl(
operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
path,
),
url,
urlParameters,
headers,
body: body.body,
@@ -466,11 +474,25 @@ function parseSpec(contents: string): unknown {
}
}
/**
* The spec requires string versions, but unquoted YAML like `swagger: 2.0`
* parses as a number and such documents are common enough to accept.
*/
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");
const openapi = versionString(spec.openapi);
return isRecord(spec.paths) && (/^3(\.|$)/.test(openapi ?? "") || isSwagger2(spec));
}
function isSwagger2(spec: UnknownRecord): boolean {
const swagger = versionString(spec.swagger);
return swagger === "2.0" || swagger === "2";
}
function versionString(value: unknown): string | undefined {
if (typeof value === "string") return value;
if (typeof value === "number") return String(value);
return undefined;
}
function importInfoDescription(info: UnknownRecord): string | undefined {
@@ -645,8 +667,27 @@ function findOrCreateFolderId({
return folder.id;
}
function buildOperationUrl(baseUrl: string, path: string): string {
return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1"));
/**
* Yaak's `:name` placeholders only substitute when they span a whole path
* segment. A template elsewhere in a segment, like `/report.{format}`, would
* import as text that never substitutes, and its leftover parameter would then
* be sent as a query parameter — so those get their example inlined instead.
*/
function buildOperationUrl(
baseUrl: string,
path: string,
inlineValues: Map<string, string>,
): { url: string; placeholderNames: Set<string> } {
const placeholderNames = new Set<string>();
const converted = path.replaceAll(/(^|\/){([^}/]+)}(?=[/?#:]|$)/g, (_, prefix, name) => {
placeholderNames.add(name);
return `${prefix}:${name}`;
});
const inlined = converted.replaceAll(/{([^}/]+)}/g, (match, name) => {
const value = inlineValues.get(name);
return value == null || value === "" ? match : value;
});
return { url: joinUrlParts(baseUrl, inlined), placeholderNames };
}
function importBaseUrl(spec: UnknownRecord): string {
@@ -660,7 +701,7 @@ function importBaseUrl(spec: UnknownRecord): string {
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") ?? "");
return trimTrailingSlashes(joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? ""));
}
function importServerEnvironments(spec: UnknownRecord): { name: string; url: string }[] {
@@ -673,8 +714,7 @@ function importServerEnvironments(spec: UnknownRecord): { name: string; url: str
.filter(({ url }) => url.length > 0);
if (servers.length === 0) {
const hasSwaggerServer =
stringAt(spec, "swagger") === "2.0" &&
(stringAt(spec, "host") != null || stringAt(spec, "basePath") != null);
isSwagger2(spec) && (stringAt(spec, "host") != null || stringAt(spec, "basePath") != null);
return [
{
name: hasSwaggerServer ? "Server 1" : "Default",
@@ -705,12 +745,17 @@ function serverUrlOrigin(value: string): string {
}
}
/**
* Request URLs are `${[baseUrl]}/path`, so a trailing slash here would put a
* double slash on the wire. Trimming also turns a bare `/` server into "",
* which renders the same URLs without a protocol-relative `//path`.
*/
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;
return trimTrailingSlashes(url);
}
function joinUrlParts(baseUrl: string, path: string): string {
@@ -733,16 +778,25 @@ function trimTrailingSlashes(value: string): string {
function importUrlParameters({
importState,
parameters,
placeholderNames,
}: {
importState: ImportState;
parameters: unknown[];
placeholderNames: Set<string>;
}): HttpUrlParameter[] {
return parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
.filter(
(p) =>
stringAt(p, "in") === "query" ||
(stringAt(p, "in") === "path" && placeholderNames.has(stringAt(p, "name") ?? "")),
)
.map((p) => ({
enabled: p.required === true,
// Path parameters are required by definition, and a disabled one would
// leave the literal `:name` in the sent URL even for sloppy specs that
// omit `required: true`
enabled: p.required === true || stringAt(p, "in") === "path",
name:
stringAt(p, "in") === "path"
? `:${stringAt(p, "name") ?? ""}`
@@ -752,6 +806,11 @@ function importUrlParameters({
.filter(({ name }) => name.length > 0);
}
// The spec says header parameters with these names SHALL be ignored; Accept and
// Content-Type come from the operation's media types, Authorization from its
// security requirements
const IGNORED_HEADER_PARAMETERS = new Set(["accept", "authorization", "content-type"]);
function importHeaderParameters({
importState,
parameters,
@@ -763,6 +822,7 @@ function importHeaderParameters({
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "header")
.filter((p) => !IGNORED_HEADER_PARAMETERS.has((stringAt(p, "name") ?? "").toLowerCase()))
.map((p) => ({
enabled: p.required === true,
name: stringAt(p, "name") ?? "",
@@ -771,10 +831,47 @@ function importHeaderParameters({
.filter(({ name }) => name.length > 0);
}
/** Yaak has no cookie parameter row, so cookie parameters become the header they would produce */
function importCookieHeader({
importState,
parameters,
}: {
importState: ImportState;
parameters: unknown[];
}): HttpRequestHeader[] {
const cookieParameters = parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "cookie")
.filter((p) => (stringAt(p, "name") ?? "").length > 0);
if (cookieParameters.length === 0) return [];
return [
{
enabled: cookieParameters.some((p) => p.required === true),
name: "Cookie",
value: cookieParameters
.map((p) => `${stringAt(p, "name")}=${parameterExample(p, importState)}`)
.join("; "),
},
];
}
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
const directExample = firstPresent(
parameter.example,
firstExampleValue(parameter.examples, importState),
);
if (directExample != null) return stringifyExampleValue(directExample);
return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
const example = stringifyExampleValue(
schemaToExample(importState.resolve(parameter.schema), importState),
);
// An empty path segment makes a URL that matches nothing, so the name at
// least keeps the request sendable and shows what belongs there
if (example === "" && stringAt(parameter, "in") === "path") {
return stringAt(parameter, "name") ?? "";
}
return example;
}
function importBody({
@@ -801,13 +898,13 @@ function importBody({
.map((p) => importState.resolve(p))
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
if (isRecord(bodyParameter)) {
const contentType = toArray(operation.consumes ?? spec.consumes).find(
(c): c is string => typeof c === "string",
);
const bodyType = contentType ?? "application/json";
const contentType =
toArray(operation.consumes ?? spec.consumes).find(
(c): c is string => typeof c === "string",
) ?? "application/json";
return {
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
bodyType,
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: yaakBodyType(contentType),
body: {
text: formatBodyText(
schemaToExample(importState.resolve(bodyParameter.schema), importState),
@@ -847,15 +944,12 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
if (contentType == null) return { headers: [], body: {}, bodyType: null };
const mediaType = toRecord(content[contentType]);
const example = mediaTypeExample(mediaType, importState);
const bodyType = yaakBodyType(contentType);
if (
contentType === "application/x-www-form-urlencoded" ||
contentType === "multipart/form-data"
) {
if (bodyType === "application/x-www-form-urlencoded" || bodyType === "multipart/form-data") {
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType,
bodyType,
body: {
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
},
@@ -864,34 +958,65 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
bodyType,
body:
bodyType === "binary"
? {}
: { text: formatBodyText(mediaTypeExample(mediaType, importState)) },
};
}
function chooseContentType(contentTypes: string[]): string | null {
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
const exact = contentTypes.find((c) => mediaTypeOf(c) === preference);
if (exact != null) return exact;
}
return contentTypes[0] ?? null;
}
function mediaTypeOf(contentType: string): string {
return contentType.toLowerCase().split(";")[0]?.trim() ?? "";
}
/**
* Yaak's body editors key off a fixed set of body types, while the Content-Type
* header keeps the spec's exact media type. Anything unrecognized becomes
* "other", the app's plain-text body with an explicit Content-Type.
*/
function yaakBodyType(contentType: string): string {
const mediaType = mediaTypeOf(contentType);
if (mediaType === "application/json" || mediaType.endsWith("+json")) return "application/json";
if (mediaType === "application/xml" || mediaType === "text/xml" || mediaType.endsWith("+xml")) {
return "text/xml";
}
if (mediaType === "application/x-www-form-urlencoded" || mediaType === "multipart/form-data") {
return mediaType;
}
if (mediaType === "application/octet-stream") return "binary";
return "other";
}
function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples));
const directExample = firstPresent(
mediaType.example,
firstExampleValue(mediaType.examples, importState),
);
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,
);
const sources = [
...toArray(resolvedSchema.allOf).map((s) => toRecord(importState.resolve(s))),
resolvedSchema,
];
const required = sources
.flatMap((s) => toArray(s.required))
.filter((name): name is string => typeof name === "string");
const properties = [
...new Map(sources.flatMap((s) => Object.entries(toRecord(s.properties)))).entries(),
].slice(0, MAX_EXAMPLE_PROPERTIES);
return properties.map(([name, property]) => {
const resolvedProperty = toRecord(importState.resolve(property));
@@ -920,20 +1045,22 @@ function schemaToExample(
const explicitExample = firstPresent(
resolved.example,
firstExampleValue(resolved.examples),
firstExampleValue(resolved.examples, importState),
resolved.default,
);
if (explicitExample != null) return explicitExample;
if (explicitExample != null) return coerceToDeclaredType(explicitExample, resolved);
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 merged = allOf.reduce<UnknownRecord>((merged, childSchema) => {
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
}, {});
// Sibling properties are their own constraint alongside the allOf branches
return { ...merged, ...objectPropertiesExample(resolved, importState, depth, visitedRefs) };
}
const oneOf = toArray(resolved.oneOf);
@@ -947,29 +1074,76 @@ function schemaToExample(
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),
]),
);
return objectPropertiesExample(resolved, importState, depth, 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 "";
return FORMAT_EXAMPLES[stringAt(resolved, "format") ?? ""] ?? "";
}
const FORMAT_EXAMPLES: Record<string, string> = {
"date-time": "2026-01-01T00:00:00Z",
date: "2026-01-01",
email: "user@example.com",
hostname: "example.com",
ipv4: "127.0.0.1",
ipv6: "::1",
uri: "https://example.com",
url: "https://example.com",
uuid: "00000000-0000-0000-0000-000000000000",
};
/**
* YAML coerces unquoted scalars, so specs routinely carry `example: 12345` on a
* `type: string` field. Sending the number fails the spec's own schema, and the
* declared type is the author's stated intent.
*/
function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown {
const rawType = schema.type;
const declared =
typeof rawType === "string"
? rawType
: Array.isArray(rawType)
? rawType.find((t) => t !== "null")
: null;
if (declared === "string" && (typeof example === "number" || typeof example === "boolean")) {
return String(example);
}
if (
(declared === "integer" || declared === "number") &&
typeof example === "string" &&
example.trim() !== "" &&
Number.isFinite(Number(example))
) {
return Number(example);
}
return example;
}
function objectPropertiesExample(
schema: UnknownRecord,
importState: ImportState,
depth: number,
visitedRefs: Set<string>,
): UnknownRecord {
const required = toArray(schema.required).filter(
(name): name is string => typeof name === "string",
);
const properties = Object.entries(toRecord(schema.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),
]),
);
}
function inferSchemaType(schema: UnknownRecord): string {
@@ -1394,8 +1568,13 @@ function stringifyExampleValue(value: unknown): string {
return JSON.stringify(value);
}
function firstExampleValue(examples: unknown): unknown {
const firstExample = Object.values(toRecord(examples))[0];
/**
* `examples` is a map of (possibly `$ref`) Example objects on media types and
* parameters, but a plain array of values on OpenAPI 3.1 schemas.
*/
function firstExampleValue(examples: unknown, importState: ImportState): unknown {
if (Array.isArray(examples)) return examples[0];
const firstExample = importState.resolve(Object.values(toRecord(examples))[0]);
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
return firstExample;
}
@@ -1425,23 +1604,6 @@ 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;
@@ -1503,7 +1665,11 @@ class ImportState {
.slice(2)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
.reduce<unknown>(
(current, part) =>
Array.isArray(current) ? current[Number(part)] : toRecord(current)[part],
this.#spec,
);
return this.resolve(resolved, nextVisitedRefs);
}
@@ -163,18 +163,13 @@ Responses:
"model": "http_request",
"name": "Retrieve one version of a particular API",
"sortPriority": 5,
"url": "\${[baseUrl]}/specs/:provider/:api.json",
"url": "\${[baseUrl]}/specs/:provider/2.1.0.json",
"urlParameters": [
{
"enabled": true,
"name": ":provider",
"value": "apis.guru",
},
{
"enabled": true,
"name": ":api",
"value": "2.1.0",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -207,7 +202,7 @@ Responses:
"model": "http_request",
"name": "Retrieve one version of a particular API with a serviceName.",
"sortPriority": 6,
"url": "\${[baseUrl]}/specs/:provider/:service/:api.json",
"url": "\${[baseUrl]}/specs/:provider/:service/2.1.0.json",
"urlParameters": [
{
"enabled": true,
@@ -219,11 +214,6 @@ Responses:
"name": ":service",
"value": "graph",
},
{
"enabled": true,
"name": ":api",
"value": "2.1.0",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
@@ -256,14 +246,8 @@ Responses:
"model": "http_request",
"name": "List all APIs for a particular provider",
"sortPriority": 7,
"url": "\${[baseUrl]}/:provider.json",
"urlParameters": [
{
"enabled": true,
"name": ":provider",
"value": "apis.guru",
},
],
"url": "\${[baseUrl]}/apis.guru.json",
"urlParameters": [],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
},
{
@@ -631,7 +615,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
"value": "",
"value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -660,7 +644,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
"value": "",
"value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -689,7 +673,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
"value": "",
"value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -718,7 +702,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
"value": "",
"value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -747,7 +731,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
"value": "",
"value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -776,7 +760,7 @@ Responses:
{
"enabled": true,
"name": ":anything",
"value": "",
"value": "anything",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -836,12 +820,12 @@ Responses:
{
"enabled": true,
"name": ":user",
"value": "",
"value": "user",
},
{
"enabled": true,
"name": ":passwd",
"value": "",
"value": "passwd",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -860,13 +844,7 @@ Responses:
- 200: Sucessful authentication.
- 401: Unsuccessful authentication.",
"folderId": "GENERATE_ID::FOLDER_1",
"headers": [
{
"enabled": false,
"name": "Authorization",
"value": "",
},
],
"headers": [],
"id": "GENERATE_ID::HTTP_REQUEST_15",
"method": "GET",
"model": "http_request",
@@ -1093,12 +1071,12 @@ Responses:
{
"enabled": true,
"name": ":name",
"value": "",
"value": "name",
},
{
"enabled": true,
"name": ":value",
"value": "",
"value": "value",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -1364,17 +1342,17 @@ Responses:
{
"enabled": true,
"name": ":qop",
"value": "",
"value": "qop",
},
{
"enabled": true,
"name": ":user",
"value": "",
"value": "user",
},
{
"enabled": true,
"name": ":passwd",
"value": "",
"value": "passwd",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -1407,17 +1385,17 @@ Responses:
{
"enabled": true,
"name": ":qop",
"value": "",
"value": "qop",
},
{
"enabled": true,
"name": ":user",
"value": "",
"value": "user",
},
{
"enabled": true,
"name": ":passwd",
"value": "",
"value": "passwd",
},
{
"enabled": true,
@@ -1457,17 +1435,17 @@ Responses:
{
"enabled": true,
"name": ":qop",
"value": "",
"value": "qop",
},
{
"enabled": true,
"name": ":user",
"value": "",
"value": "user",
},
{
"enabled": true,
"name": ":passwd",
"value": "",
"value": "passwd",
},
{
"enabled": true,
@@ -1587,7 +1565,7 @@ Responses:
{
"enabled": true,
"name": ":etag",
"value": "",
"value": "etag",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -1678,12 +1656,12 @@ Responses:
{
"enabled": true,
"name": ":user",
"value": "",
"value": "user",
},
{
"enabled": true,
"name": ":passwd",
"value": "",
"value": "passwd",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2317,7 +2295,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
"value": "",
"value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2350,7 +2328,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
"value": "",
"value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2383,7 +2361,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
"value": "",
"value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2416,7 +2394,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
"value": "",
"value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2449,7 +2427,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
"value": "",
"value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2482,7 +2460,7 @@ Responses:
{
"enabled": true,
"name": ":codes",
"value": "",
"value": "codes",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
@@ -2777,7 +2755,7 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = `
"variables": [
{
"name": "baseUrl",
"value": "http://xkcd.com/",
"value": "http://xkcd.com",
},
],
"workspaceId": "GENERATE_ID::WORKSPACE_0",
+323 -2
View File
@@ -787,7 +787,8 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests[0]).toEqual(
expect.objectContaining({
bodyType: "application/xml",
// Yaak's XML body type; the header keeps the spec's media type
bodyType: "text/xml",
headers: expect.arrayContaining([
{ enabled: true, name: "Content-Type", value: "application/xml" },
]),
@@ -840,7 +841,7 @@ describe("importer-openapi", () => {
expect.objectContaining({
name: "Server 1",
variables: [
{ name: "baseUrl", value: "https://example.com/" },
{ name: "baseUrl", value: "https://example.com" },
{ name: "auth_basic_auth_username", value: "" },
{ name: "auth_basic_auth_password", value: "" },
{ name: "auth_cookie_key_key", value: "" },
@@ -987,6 +988,326 @@ describe("importer-openapi", () => {
]);
});
test("Resolves references that point into arrays", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Array Ref Test", version: "1.0.0" },
paths: {
"/a": {
get: {
parameters: [
{ name: "limit", in: "query", required: true, schema: { example: "42" } },
],
responses: {},
},
},
"/b": {
get: { parameters: [{ $ref: "#/paths/~1a/get/parameters/0" }], responses: {} },
},
},
}),
);
expect(imported?.resources.httpRequests[1]?.urlParameters).toEqual([
{ enabled: true, name: "limit", value: "42" },
]);
});
test("Resolves example references and 3.1 example arrays", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Examples Test", version: "1.0.0" },
paths: {
"/a": {
post: {
parameters: [
{ name: "q", in: "query", schema: { type: "string", examples: ["hello"] } },
],
requestBody: {
content: {
"application/json": {
schema: { type: "object" },
examples: { main: { $ref: "#/components/examples/Main" } },
},
},
},
responses: {},
},
},
},
components: { examples: { Main: { value: { x: "from-example" } } } },
}),
);
expect(imported?.resources.httpRequests[0]?.body).toEqual({
text: JSON.stringify({ x: "from-example" }, null, 2),
});
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
{ enabled: false, name: "q", value: "hello" },
]);
});
test("Keeps template-looking braces in descriptions and examples", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Braces Test", version: "1.0.0" },
paths: {
"/a": {
post: {
description: "Use {{placeholders}} in the template",
requestBody: {
content: {
"application/json": { schema: { type: "string", example: "Hi {{name}}" } },
},
},
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.description).toContain("{{placeholders}}");
expect(imported?.resources.httpRequests[0]?.body).toEqual({ text: "Hi {{name}}" });
});
test("Ignores header parameters the spec reserves for other mechanisms", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Reserved Headers Test", version: "1.0.0" },
paths: {
"/a": {
post: {
parameters: [
{ name: "Content-Type", in: "header", schema: { example: "application/xml" } },
{ name: "Accept", in: "header", schema: { example: "text/html" } },
{ name: "Authorization", in: "header", schema: { example: "custom" } },
{ name: "X-Custom", in: "header", schema: { example: "kept" } },
],
requestBody: { content: { "application/json": { schema: { type: "object" } } } },
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
{ enabled: false, name: "X-Custom", value: "kept" },
{ enabled: true, name: "Content-Type", value: "application/json" },
]);
});
test("Imports cookie parameters as a Cookie header", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Cookie Test", version: "1.0.0" },
paths: {
"/a": {
get: {
parameters: [
{ name: "session", in: "cookie", required: true, schema: { example: "abc" } },
{ name: "theme", in: "cookie", schema: { example: "dark" } },
],
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
{ enabled: true, name: "Cookie", value: "session=abc; theme=dark" },
]);
});
test("Inlines path templates that Yaak placeholders cannot express", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Mid-Segment Test", version: "1.0.0" },
paths: {
"/report.{format}": {
get: {
parameters: [
{ name: "format", in: "path", required: true, schema: { example: "csv" } },
],
responses: {},
},
},
"/tasks/{id}:cancel": {
post: {
parameters: [{ name: "id", in: "path", required: true, schema: { example: "7" } }],
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests.map((r) => [r.url, r.urlParameters])).toEqual([
["${[baseUrl]}/report.csv", []],
["${[baseUrl]}/tasks/:id:cancel", [{ enabled: true, name: ":id", value: "7" }]],
]);
});
test("Enables path parameters even when required is omitted", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Sloppy Path Test", version: "1.0.0" },
paths: {
"/users/{userId}": {
get: {
parameters: [{ name: "userId", in: "path", schema: { type: "string" } }],
responses: {},
},
},
},
}),
);
// No example either, so the name stands in for an empty path segment
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
{ enabled: true, name: ":userId", value: "userId" },
]);
});
test("Coerces examples to the declared schema type", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Coercion Test", version: "1.0.0" },
paths: {
"/a": {
post: {
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: {
password: { type: "string", example: 12345 },
count: { type: "integer", example: "3" },
note: { example: 7 },
},
},
},
},
},
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.body).toEqual({
text: JSON.stringify({ password: "12345", count: 3, note: 7 }, null, 2),
});
});
test("Merges allOf branches with sibling properties", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "AllOf Test", version: "1.0.0" },
paths: {
"/a": {
post: {
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
allOf: [
{ type: "object", properties: { fromAllOf: { example: "a" } } },
],
properties: { sibling: { example: "b" } },
},
},
},
},
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.body).toEqual({
text: JSON.stringify({ fromAllOf: "a", sibling: "b" }, null, 2),
});
});
test("Accepts unquoted YAML version numbers", async () => {
const imported = await convertOpenApi(
["swagger: 2.0", "info:", " title: Unquoted Test", ' version: "1"', "host: example.com", "paths:", " /a:", " get:", " responses: {}"].join("\n"),
);
expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/a");
// The numeric version must feed server detection too, or the selectable
// environment overrides baseUrl with an empty value
expect(imported?.resources.environments[1]).toEqual(
expect.objectContaining({
name: "Server 1",
variables: [{ name: "baseUrl", value: "https://example.com" }],
}),
);
});
test("Normalizes body types to Yaak's editors", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Body Type Test", version: "1.0.0" },
paths: {
"/vnd-json": {
post: {
requestBody: {
content: { "application/vnd.api+json": { schema: { type: "object" } } },
},
responses: {},
},
},
"/plain": {
post: {
requestBody: { content: { "text/plain": { schema: { type: "string" } } } },
responses: {},
},
},
},
}),
);
expect(
imported?.resources.httpRequests.map((r) => [r.bodyType, r.headers?.[0]?.value]),
).toEqual([
["application/json", "application/vnd.api+json"],
["other", "text/plain"],
]);
});
test("Trims trailing slashes from server URLs", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Trailing Slash Test", version: "1.0.0" },
servers: [{ url: "https://api.example.com/v1/" }],
paths: { "/pets": { get: { responses: {} } } },
}),
);
expect(imported?.resources.environments[1]?.variables).toEqual([
{ name: "baseUrl", value: "https://api.example.com/v1" },
]);
});
test("Reports references that point outside the document", async () => {
const imported = await convertOpenApi(
JSON.stringify({