mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-20 18:34:04 +02:00
Fix OpenAPI importer issues found by spec review (#599)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user