mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-24 20:34:05 +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);
|
disambiguateNames(resources.httpRequests, routeLabels);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
resources: deleteUndefinedAttrs(
|
resources: deleteUndefinedAttrs({
|
||||||
convertTemplateSyntax({
|
environments: resources.environments,
|
||||||
environments: resources.environments,
|
folders: resources.folders,
|
||||||
folders: resources.folders,
|
grpcRequests: [],
|
||||||
grpcRequests: [],
|
httpRequests: resources.httpRequests,
|
||||||
httpRequests: resources.httpRequests,
|
websocketRequests: [],
|
||||||
websocketRequests: [],
|
workspaces: resources.workspaces,
|
||||||
workspaces: resources.workspaces,
|
}) as PartialImportResources,
|
||||||
}),
|
|
||||||
) as PartialImportResources,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,13 +299,26 @@ function importOperation({
|
|||||||
spec,
|
spec,
|
||||||
useDynamicServerUrls,
|
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 = [
|
const urlParameters = [
|
||||||
...importUrlParameters({ importState, parameters }),
|
...importUrlParameters({ importState, parameters, placeholderNames }),
|
||||||
...authentication.urlParameters,
|
...authentication.urlParameters,
|
||||||
];
|
];
|
||||||
const headers = mergeHeaders(
|
const headers = mergeHeaders(
|
||||||
authentication.headers,
|
authentication.headers,
|
||||||
importHeaderParameters({ importState, parameters }),
|
importHeaderParameters({ importState, parameters }),
|
||||||
|
importCookieHeader({ importState, parameters }),
|
||||||
body.headers,
|
body.headers,
|
||||||
importAcceptHeader({ importState, operation, spec }),
|
importAcceptHeader({ importState, operation, spec }),
|
||||||
);
|
);
|
||||||
@@ -333,10 +344,7 @@ function importOperation({
|
|||||||
name: importOperationName(operation, method, path),
|
name: importOperationName(operation, method, path),
|
||||||
description,
|
description,
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
url: buildOperationUrl(
|
url,
|
||||||
operationBaseUrl({ operation, pathItem, requestBaseUrl, serverOverrides }),
|
|
||||||
path,
|
|
||||||
),
|
|
||||||
urlParameters,
|
urlParameters,
|
||||||
headers,
|
headers,
|
||||||
body: body.body,
|
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 {
|
function isOpenApiSpec(value: unknown): value is UnknownRecord {
|
||||||
const spec = toRecord(value);
|
const spec = toRecord(value);
|
||||||
const openapi = stringAt(spec, "openapi");
|
const openapi = versionString(spec.openapi);
|
||||||
const swagger = stringAt(spec, "swagger");
|
return isRecord(spec.paths) && (/^3(\.|$)/.test(openapi ?? "") || isSwagger2(spec));
|
||||||
return isRecord(spec.paths) && (openapi?.startsWith("3.") === true || swagger === "2.0");
|
}
|
||||||
|
|
||||||
|
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 {
|
function importInfoDescription(info: UnknownRecord): string | undefined {
|
||||||
@@ -645,8 +667,27 @@ function findOrCreateFolderId({
|
|||||||
return folder.id;
|
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 {
|
function importBaseUrl(spec: UnknownRecord): string {
|
||||||
@@ -660,7 +701,7 @@ function importBaseUrl(spec: UnknownRecord): string {
|
|||||||
if (host == null) return stringAt(spec, "basePath") ?? "";
|
if (host == null) return stringAt(spec, "basePath") ?? "";
|
||||||
|
|
||||||
const scheme = toArray(spec.schemes).find((s): s is string => typeof s === "string") ?? "https";
|
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 }[] {
|
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);
|
.filter(({ url }) => url.length > 0);
|
||||||
if (servers.length === 0) {
|
if (servers.length === 0) {
|
||||||
const hasSwaggerServer =
|
const hasSwaggerServer =
|
||||||
stringAt(spec, "swagger") === "2.0" &&
|
isSwagger2(spec) && (stringAt(spec, "host") != null || stringAt(spec, "basePath") != null);
|
||||||
(stringAt(spec, "host") != null || stringAt(spec, "basePath") != null);
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
name: hasSwaggerServer ? "Server 1" : "Default",
|
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 {
|
function interpolateServerUrl(server: UnknownRecord): string {
|
||||||
let url = stringAt(server, "url") ?? "";
|
let url = stringAt(server, "url") ?? "";
|
||||||
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
|
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
|
||||||
url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default));
|
url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default));
|
||||||
}
|
}
|
||||||
return url;
|
return trimTrailingSlashes(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function joinUrlParts(baseUrl: string, path: string): string {
|
function joinUrlParts(baseUrl: string, path: string): string {
|
||||||
@@ -733,16 +778,25 @@ function trimTrailingSlashes(value: string): string {
|
|||||||
function importUrlParameters({
|
function importUrlParameters({
|
||||||
importState,
|
importState,
|
||||||
parameters,
|
parameters,
|
||||||
|
placeholderNames,
|
||||||
}: {
|
}: {
|
||||||
importState: ImportState;
|
importState: ImportState;
|
||||||
parameters: unknown[];
|
parameters: unknown[];
|
||||||
|
placeholderNames: Set<string>;
|
||||||
}): HttpUrlParameter[] {
|
}): HttpUrlParameter[] {
|
||||||
return parameters
|
return parameters
|
||||||
.map((p) => importState.resolve(p))
|
.map((p) => importState.resolve(p))
|
||||||
.filter(isRecord)
|
.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) => ({
|
.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:
|
name:
|
||||||
stringAt(p, "in") === "path"
|
stringAt(p, "in") === "path"
|
||||||
? `:${stringAt(p, "name") ?? ""}`
|
? `:${stringAt(p, "name") ?? ""}`
|
||||||
@@ -752,6 +806,11 @@ function importUrlParameters({
|
|||||||
.filter(({ name }) => name.length > 0);
|
.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({
|
function importHeaderParameters({
|
||||||
importState,
|
importState,
|
||||||
parameters,
|
parameters,
|
||||||
@@ -763,6 +822,7 @@ function importHeaderParameters({
|
|||||||
.map((p) => importState.resolve(p))
|
.map((p) => importState.resolve(p))
|
||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.filter((p) => stringAt(p, "in") === "header")
|
.filter((p) => stringAt(p, "in") === "header")
|
||||||
|
.filter((p) => !IGNORED_HEADER_PARAMETERS.has((stringAt(p, "name") ?? "").toLowerCase()))
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
enabled: p.required === true,
|
enabled: p.required === true,
|
||||||
name: stringAt(p, "name") ?? "",
|
name: stringAt(p, "name") ?? "",
|
||||||
@@ -771,10 +831,47 @@ function importHeaderParameters({
|
|||||||
.filter(({ name }) => name.length > 0);
|
.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 {
|
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);
|
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({
|
function importBody({
|
||||||
@@ -801,13 +898,13 @@ function importBody({
|
|||||||
.map((p) => importState.resolve(p))
|
.map((p) => importState.resolve(p))
|
||||||
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
|
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
|
||||||
if (isRecord(bodyParameter)) {
|
if (isRecord(bodyParameter)) {
|
||||||
const contentType = toArray(operation.consumes ?? spec.consumes).find(
|
const contentType =
|
||||||
(c): c is string => typeof c === "string",
|
toArray(operation.consumes ?? spec.consumes).find(
|
||||||
);
|
(c): c is string => typeof c === "string",
|
||||||
const bodyType = contentType ?? "application/json";
|
) ?? "application/json";
|
||||||
return {
|
return {
|
||||||
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
bodyType,
|
bodyType: yaakBodyType(contentType),
|
||||||
body: {
|
body: {
|
||||||
text: formatBodyText(
|
text: formatBodyText(
|
||||||
schemaToExample(importState.resolve(bodyParameter.schema), importState),
|
schemaToExample(importState.resolve(bodyParameter.schema), importState),
|
||||||
@@ -847,15 +944,12 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
|
|||||||
if (contentType == null) return { headers: [], body: {}, bodyType: null };
|
if (contentType == null) return { headers: [], body: {}, bodyType: null };
|
||||||
|
|
||||||
const mediaType = toRecord(content[contentType]);
|
const mediaType = toRecord(content[contentType]);
|
||||||
const example = mediaTypeExample(mediaType, importState);
|
const bodyType = yaakBodyType(contentType);
|
||||||
|
|
||||||
if (
|
if (bodyType === "application/x-www-form-urlencoded" || bodyType === "multipart/form-data") {
|
||||||
contentType === "application/x-www-form-urlencoded" ||
|
|
||||||
contentType === "multipart/form-data"
|
|
||||||
) {
|
|
||||||
return {
|
return {
|
||||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
bodyType: contentType,
|
bodyType,
|
||||||
body: {
|
body: {
|
||||||
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
|
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
|
||||||
},
|
},
|
||||||
@@ -864,34 +958,65 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
|
bodyType,
|
||||||
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
|
body:
|
||||||
|
bodyType === "binary"
|
||||||
|
? {}
|
||||||
|
: { text: formatBodyText(mediaTypeExample(mediaType, importState)) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function chooseContentType(contentTypes: string[]): string | null {
|
function chooseContentType(contentTypes: string[]): string | null {
|
||||||
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
|
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;
|
if (exact != null) return exact;
|
||||||
}
|
}
|
||||||
return contentTypes[0] ?? null;
|
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 {
|
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;
|
if (directExample != null) return directExample;
|
||||||
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaToFormParameters(schema: unknown, importState: ImportState) {
|
function schemaToFormParameters(schema: unknown, importState: ImportState) {
|
||||||
const resolvedSchema = toRecord(importState.resolve(schema));
|
const resolvedSchema = toRecord(importState.resolve(schema));
|
||||||
const required = toArray(resolvedSchema.required).filter(
|
const sources = [
|
||||||
(name): name is string => typeof name === "string",
|
...toArray(resolvedSchema.allOf).map((s) => toRecord(importState.resolve(s))),
|
||||||
);
|
resolvedSchema,
|
||||||
const properties = Object.entries(toRecord(resolvedSchema.properties)).slice(
|
];
|
||||||
0,
|
const required = sources
|
||||||
MAX_EXAMPLE_PROPERTIES,
|
.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]) => {
|
return properties.map(([name, property]) => {
|
||||||
const resolvedProperty = toRecord(importState.resolve(property));
|
const resolvedProperty = toRecord(importState.resolve(property));
|
||||||
@@ -920,20 +1045,22 @@ function schemaToExample(
|
|||||||
|
|
||||||
const explicitExample = firstPresent(
|
const explicitExample = firstPresent(
|
||||||
resolved.example,
|
resolved.example,
|
||||||
firstExampleValue(resolved.examples),
|
firstExampleValue(resolved.examples, importState),
|
||||||
resolved.default,
|
resolved.default,
|
||||||
);
|
);
|
||||||
if (explicitExample != null) return explicitExample;
|
if (explicitExample != null) return coerceToDeclaredType(explicitExample, resolved);
|
||||||
|
|
||||||
const enumValues = toArray(resolved.enum);
|
const enumValues = toArray(resolved.enum);
|
||||||
if (enumValues.length > 0) return enumValues[0];
|
if (enumValues.length > 0) return enumValues[0];
|
||||||
|
|
||||||
const allOf = toArray(resolved.allOf);
|
const allOf = toArray(resolved.allOf);
|
||||||
if (allOf.length > 0) {
|
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);
|
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
|
||||||
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
|
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);
|
const oneOf = toArray(resolved.oneOf);
|
||||||
@@ -947,29 +1074,76 @@ function schemaToExample(
|
|||||||
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
|
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
|
||||||
}
|
}
|
||||||
if (type === "object") {
|
if (type === "object") {
|
||||||
const required = toArray(resolved.required).filter(
|
return objectPropertiesExample(resolved, importState, depth, visitedRefs);
|
||||||
(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 === "integer" || type === "number") return 0;
|
||||||
if (type === "boolean") return false;
|
if (type === "boolean") return false;
|
||||||
if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
|
return FORMAT_EXAMPLES[stringAt(resolved, "format") ?? ""] ?? "";
|
||||||
if (stringAt(resolved, "format") === "date") return "2026-01-01";
|
}
|
||||||
return "";
|
|
||||||
|
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 {
|
function inferSchemaType(schema: UnknownRecord): string {
|
||||||
@@ -1394,8 +1568,13 @@ function stringifyExampleValue(value: unknown): string {
|
|||||||
return JSON.stringify(value);
|
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;
|
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
|
||||||
return firstExample;
|
return firstExample;
|
||||||
}
|
}
|
||||||
@@ -1425,23 +1604,6 @@ function isPresent<T>(value: T | null | undefined): value is T {
|
|||||||
return value != null && value !== "";
|
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 {
|
function deleteUndefinedAttrs<T>(obj: T): T {
|
||||||
if (Array.isArray(obj) && obj != null) {
|
if (Array.isArray(obj) && obj != null) {
|
||||||
return obj.map(deleteUndefinedAttrs) as T;
|
return obj.map(deleteUndefinedAttrs) as T;
|
||||||
@@ -1503,7 +1665,11 @@ class ImportState {
|
|||||||
.slice(2)
|
.slice(2)
|
||||||
.split("/")
|
.split("/")
|
||||||
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
.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);
|
return this.resolve(resolved, nextVisitedRefs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,18 +163,13 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "Retrieve one version of a particular API",
|
"name": "Retrieve one version of a particular API",
|
||||||
"sortPriority": 5,
|
"sortPriority": 5,
|
||||||
"url": "\${[baseUrl]}/specs/:provider/:api.json",
|
"url": "\${[baseUrl]}/specs/:provider/2.1.0.json",
|
||||||
"urlParameters": [
|
"urlParameters": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":provider",
|
"name": ":provider",
|
||||||
"value": "apis.guru",
|
"value": "apis.guru",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":api",
|
|
||||||
"value": "2.1.0",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
@@ -207,7 +202,7 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "Retrieve one version of a particular API with a serviceName.",
|
"name": "Retrieve one version of a particular API with a serviceName.",
|
||||||
"sortPriority": 6,
|
"sortPriority": 6,
|
||||||
"url": "\${[baseUrl]}/specs/:provider/:service/:api.json",
|
"url": "\${[baseUrl]}/specs/:provider/:service/2.1.0.json",
|
||||||
"urlParameters": [
|
"urlParameters": [
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -219,11 +214,6 @@ Responses:
|
|||||||
"name": ":service",
|
"name": ":service",
|
||||||
"value": "graph",
|
"value": "graph",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":api",
|
|
||||||
"value": "2.1.0",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
@@ -256,14 +246,8 @@ Responses:
|
|||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
"name": "List all APIs for a particular provider",
|
"name": "List all APIs for a particular provider",
|
||||||
"sortPriority": 7,
|
"sortPriority": 7,
|
||||||
"url": "\${[baseUrl]}/:provider.json",
|
"url": "\${[baseUrl]}/apis.guru.json",
|
||||||
"urlParameters": [
|
"urlParameters": [],
|
||||||
{
|
|
||||||
"enabled": true,
|
|
||||||
"name": ":provider",
|
|
||||||
"value": "apis.guru",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -631,7 +615,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -660,7 +644,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -689,7 +673,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -718,7 +702,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -747,7 +731,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -776,7 +760,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":anything",
|
"name": ":anything",
|
||||||
"value": "",
|
"value": "anything",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -836,12 +820,12 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -860,13 +844,7 @@ Responses:
|
|||||||
- 200: Sucessful authentication.
|
- 200: Sucessful authentication.
|
||||||
- 401: Unsuccessful authentication.",
|
- 401: Unsuccessful authentication.",
|
||||||
"folderId": "GENERATE_ID::FOLDER_1",
|
"folderId": "GENERATE_ID::FOLDER_1",
|
||||||
"headers": [
|
"headers": [],
|
||||||
{
|
|
||||||
"enabled": false,
|
|
||||||
"name": "Authorization",
|
|
||||||
"value": "",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
"id": "GENERATE_ID::HTTP_REQUEST_15",
|
"id": "GENERATE_ID::HTTP_REQUEST_15",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"model": "http_request",
|
"model": "http_request",
|
||||||
@@ -1093,12 +1071,12 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":name",
|
"name": ":name",
|
||||||
"value": "",
|
"value": "name",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":value",
|
"name": ":value",
|
||||||
"value": "",
|
"value": "value",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -1364,17 +1342,17 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":qop",
|
"name": ":qop",
|
||||||
"value": "",
|
"value": "qop",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -1407,17 +1385,17 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":qop",
|
"name": ":qop",
|
||||||
"value": "",
|
"value": "qop",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -1457,17 +1435,17 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":qop",
|
"name": ":qop",
|
||||||
"value": "",
|
"value": "qop",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
@@ -1587,7 +1565,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":etag",
|
"name": ":etag",
|
||||||
"value": "",
|
"value": "etag",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -1678,12 +1656,12 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":user",
|
"name": ":user",
|
||||||
"value": "",
|
"value": "user",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":passwd",
|
"name": ":passwd",
|
||||||
"value": "",
|
"value": "passwd",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2317,7 +2295,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2350,7 +2328,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2383,7 +2361,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2416,7 +2394,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2449,7 +2427,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2482,7 +2460,7 @@ Responses:
|
|||||||
{
|
{
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"name": ":codes",
|
"name": ":codes",
|
||||||
"value": "",
|
"value": "codes",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
@@ -2777,7 +2755,7 @@ exports[`importer-openapi > Snapshots real-world fixture xkcd.yaml 1`] = `
|
|||||||
"variables": [
|
"variables": [
|
||||||
{
|
{
|
||||||
"name": "baseUrl",
|
"name": "baseUrl",
|
||||||
"value": "http://xkcd.com/",
|
"value": "http://xkcd.com",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||||
|
|||||||
@@ -787,7 +787,8 @@ describe("importer-openapi", () => {
|
|||||||
|
|
||||||
expect(imported?.resources.httpRequests[0]).toEqual(
|
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
bodyType: "application/xml",
|
// Yaak's XML body type; the header keeps the spec's media type
|
||||||
|
bodyType: "text/xml",
|
||||||
headers: expect.arrayContaining([
|
headers: expect.arrayContaining([
|
||||||
{ enabled: true, name: "Content-Type", value: "application/xml" },
|
{ enabled: true, name: "Content-Type", value: "application/xml" },
|
||||||
]),
|
]),
|
||||||
@@ -840,7 +841,7 @@ describe("importer-openapi", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: "Server 1",
|
name: "Server 1",
|
||||||
variables: [
|
variables: [
|
||||||
{ name: "baseUrl", value: "https://example.com/" },
|
{ name: "baseUrl", value: "https://example.com" },
|
||||||
{ name: "auth_basic_auth_username", value: "" },
|
{ name: "auth_basic_auth_username", value: "" },
|
||||||
{ name: "auth_basic_auth_password", value: "" },
|
{ name: "auth_basic_auth_password", value: "" },
|
||||||
{ name: "auth_cookie_key_key", 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 () => {
|
test("Reports references that point outside the document", async () => {
|
||||||
const imported = await convertOpenApi(
|
const imported = await convertOpenApi(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|||||||
Reference in New Issue
Block a user