mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-19 18:04:06 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71c217d3f0 | ||
|
|
d89831a84d | ||
|
|
3332ae263f |
@@ -20,6 +20,12 @@ type ImportResources = {
|
||||
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
|
||||
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
|
||||
};
|
||||
type ImportedAuthentication = Pick<HttpRequest, "authentication" | "authenticationType"> & {
|
||||
headers: HttpRequestHeader[];
|
||||
urlParameters: HttpUrlParameter[];
|
||||
};
|
||||
type AuthenticationVariableRegistry = Map<string, { name: string; value: string }>;
|
||||
type OAuthVariableNames = { clientId: string; clientSecret: string };
|
||||
|
||||
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"];
|
||||
const BODY_CONTENT_TYPE_PREFERENCE = [
|
||||
@@ -62,6 +68,8 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
folders: [],
|
||||
httpRequests: [],
|
||||
};
|
||||
const authenticationVariables: AuthenticationVariableRegistry = new Map();
|
||||
const oauthVariablesByScheme = buildOAuthVariablesByScheme(importState, spec);
|
||||
const baseUrl = importBaseUrl(spec);
|
||||
// A local spec has no document URL against which OpenAPI's implicit "/"
|
||||
// server can resolve. Keep the shared variable even when its initial value
|
||||
@@ -116,6 +124,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
importState,
|
||||
method,
|
||||
operation,
|
||||
oauthVariablesByScheme,
|
||||
path: rawPath,
|
||||
pathItem,
|
||||
pathParameters,
|
||||
@@ -123,14 +132,70 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
|
||||
spec,
|
||||
workspaceId: workspace.id,
|
||||
folderId,
|
||||
authenticationVariables,
|
||||
});
|
||||
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
|
||||
resources.httpRequests.push(request);
|
||||
}
|
||||
}
|
||||
|
||||
if (resources.httpRequests.some((request) => request.authenticationType === "oauth2")) {
|
||||
let globalEnvironment = resources.environments[0];
|
||||
if (globalEnvironment == null) {
|
||||
globalEnvironment = {
|
||||
model: "environment",
|
||||
id: importState.generateId("environment"),
|
||||
workspaceId: workspace.id,
|
||||
name: "Global Variables",
|
||||
variables: [],
|
||||
parentModel: "workspace",
|
||||
parentId: null,
|
||||
sortPriority: importState.nextSortPriority(),
|
||||
};
|
||||
resources.environments.push(globalEnvironment);
|
||||
}
|
||||
|
||||
const variableNames = new Set(
|
||||
[...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [
|
||||
clientId,
|
||||
clientSecret,
|
||||
]),
|
||||
);
|
||||
if (
|
||||
resources.httpRequests.some(
|
||||
(request) =>
|
||||
request.authenticationType === "oauth2" &&
|
||||
Object.values(toRecord(request.authentication)).some(
|
||||
(value) =>
|
||||
typeof value === "string" && value.includes(templateVariable("baseUrlOrigin")),
|
||||
),
|
||||
)
|
||||
) {
|
||||
variableNames.add("baseUrlOrigin");
|
||||
}
|
||||
globalEnvironment.variables.push(...[...variableNames].map((name) => ({ name, value: "" })));
|
||||
}
|
||||
|
||||
if (resources.httpRequests.length === 0) return undefined;
|
||||
|
||||
if (authenticationVariables.size > 0) {
|
||||
let environment = resources.environments[0];
|
||||
if (environment == null) {
|
||||
environment = {
|
||||
model: "environment",
|
||||
id: importState.generateId("environment"),
|
||||
workspaceId: workspace.id,
|
||||
name: "Global Variables",
|
||||
variables: [],
|
||||
parentModel: "workspace",
|
||||
parentId: null,
|
||||
sortPriority: importState.nextSortPriority(),
|
||||
};
|
||||
resources.environments.push(environment);
|
||||
}
|
||||
environment.variables.push(...authenticationVariables.values());
|
||||
}
|
||||
|
||||
disambiguateNames(resources.httpRequests, routeLabels);
|
||||
|
||||
return {
|
||||
@@ -193,6 +258,7 @@ function importOperation({
|
||||
importState,
|
||||
method,
|
||||
operation,
|
||||
oauthVariablesByScheme,
|
||||
path,
|
||||
pathItem,
|
||||
pathParameters,
|
||||
@@ -200,10 +266,12 @@ function importOperation({
|
||||
spec,
|
||||
workspaceId,
|
||||
folderId,
|
||||
authenticationVariables,
|
||||
}: {
|
||||
importState: ImportState;
|
||||
method: string;
|
||||
operation: UnknownRecord;
|
||||
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
|
||||
path: string;
|
||||
pathItem: UnknownRecord;
|
||||
pathParameters: unknown[];
|
||||
@@ -211,6 +279,7 @@ function importOperation({
|
||||
spec: UnknownRecord;
|
||||
workspaceId: string;
|
||||
folderId: string | null;
|
||||
authenticationVariables: AuthenticationVariableRegistry;
|
||||
}): ImportResources["httpRequests"][0] {
|
||||
importState.beginOperation();
|
||||
const parameters = mergeParameters({
|
||||
@@ -219,13 +288,28 @@ function importOperation({
|
||||
operationParameters: toArray(operation.parameters),
|
||||
});
|
||||
const body = importBody({ importState, operation, parameters, spec });
|
||||
const urlParameters = importUrlParameters({ importState, parameters });
|
||||
const authentication = importAuthentication({
|
||||
authenticationVariables,
|
||||
importState,
|
||||
oauthVariablesByScheme,
|
||||
operation,
|
||||
spec,
|
||||
});
|
||||
const urlParameters = [
|
||||
...importUrlParameters({ importState, parameters }),
|
||||
...authentication.urlParameters,
|
||||
];
|
||||
const headers = mergeHeaders(
|
||||
authentication.headers,
|
||||
importHeaderParameters({ importState, parameters }),
|
||||
body.headers,
|
||||
importAcceptHeader({ importState, operation, spec }),
|
||||
);
|
||||
const authentication = importAuthentication({ importState, operation, spec });
|
||||
const {
|
||||
headers: _authenticationHeaders,
|
||||
urlParameters: _authenticationParameters,
|
||||
...auth
|
||||
} = authentication;
|
||||
|
||||
// Built after everything else, so it can report the refs they left unresolved
|
||||
const description = importOperationDescription({
|
||||
@@ -249,7 +333,7 @@ function importOperation({
|
||||
body: body.body,
|
||||
bodyType: body.bodyType,
|
||||
sortPriority: importState.nextSortPriority(),
|
||||
...authentication,
|
||||
...auth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -598,70 +682,17 @@ function importUrlParameters({
|
||||
.map((p) => importState.resolve(p))
|
||||
.filter(isRecord)
|
||||
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
|
||||
.flatMap((p) => serializeUrlParameter(p, importState))
|
||||
.map((p) => ({
|
||||
enabled: p.required === true,
|
||||
name:
|
||||
stringAt(p, "in") === "path"
|
||||
? `:${stringAt(p, "name") ?? ""}`
|
||||
: (stringAt(p, "name") ?? ""),
|
||||
value: parameterExample(p, importState),
|
||||
}))
|
||||
.filter(({ name }) => name.length > 0);
|
||||
}
|
||||
|
||||
function serializeUrlParameter(
|
||||
parameter: UnknownRecord,
|
||||
importState: ImportState,
|
||||
): HttpUrlParameter[] {
|
||||
const name = stringAt(parameter, "name") ?? "";
|
||||
const location = stringAt(parameter, "in");
|
||||
const enabled = parameter.required === true;
|
||||
const value = parameterExampleValue(parameter, importState);
|
||||
if (isRecord(parameter.content)) {
|
||||
return [
|
||||
{
|
||||
enabled,
|
||||
name: location === "path" ? `:${name}` : name,
|
||||
value: serializeContentParameter(parameter, importState),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (location === "path") {
|
||||
return [{ enabled, name: `:${name}`, value: serializePathParameter(name, value, parameter) }];
|
||||
}
|
||||
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value);
|
||||
const style = stringAt(parameter, "style") ?? "form";
|
||||
const explode = parameter.explode !== false;
|
||||
if (style === "deepObject") {
|
||||
return entries.map(([key, entryValue]) => ({
|
||||
enabled,
|
||||
name: `${name}[${key}]`,
|
||||
value: stringifyExampleValue(entryValue),
|
||||
}));
|
||||
}
|
||||
if (style === "form" && explode) {
|
||||
return entries.map(([key, entryValue]) => ({
|
||||
enabled,
|
||||
name: key,
|
||||
value: stringifyExampleValue(entryValue),
|
||||
}));
|
||||
}
|
||||
const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
|
||||
return [{ enabled, name, value: entries.flat().map(stringifyExampleValue).join(separator) }];
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const style = stringAt(parameter, "style") ?? "form";
|
||||
const explode = parameter.explode !== false;
|
||||
if (style === "form" && explode) {
|
||||
return value.map((entryValue) => ({
|
||||
enabled,
|
||||
name,
|
||||
value: stringifyExampleValue(entryValue),
|
||||
}));
|
||||
}
|
||||
const separator = style === "spaceDelimited" ? " " : style === "pipeDelimited" ? "|" : ",";
|
||||
return [{ enabled, name, value: value.map(stringifyExampleValue).join(separator) }];
|
||||
}
|
||||
|
||||
return [{ enabled, name, value: stringifyExampleValue(value) }];
|
||||
}
|
||||
|
||||
function importHeaderParameters({
|
||||
importState,
|
||||
parameters,
|
||||
@@ -673,112 +704,18 @@ function importHeaderParameters({
|
||||
.map((p) => importState.resolve(p))
|
||||
.filter(isRecord)
|
||||
.filter((p) => stringAt(p, "in") === "header")
|
||||
.filter(
|
||||
(p) =>
|
||||
!["accept", "authorization", "content-type"].includes(
|
||||
(stringAt(p, "name") ?? "").toLowerCase(),
|
||||
),
|
||||
)
|
||||
.map((p) => ({
|
||||
enabled: p.required === true,
|
||||
name: stringAt(p, "name") ?? "",
|
||||
value: serializeParameterValue(p, importState),
|
||||
}))
|
||||
.filter(({ name }) => name.length > 0)
|
||||
.concat(importCookieHeader(parameters, importState));
|
||||
}
|
||||
|
||||
function importCookieHeader(parameters: unknown[], importState: ImportState): HttpRequestHeader[] {
|
||||
const cookies = parameters
|
||||
.map((p) => importState.resolve(p))
|
||||
.filter(isRecord)
|
||||
.filter((p) => stringAt(p, "in") === "cookie")
|
||||
.map((p) => ({
|
||||
enabled: p.required === true,
|
||||
name: stringAt(p, "name") ?? "",
|
||||
value: serializeParameterValue(p, importState),
|
||||
value: parameterExample(p, importState),
|
||||
}))
|
||||
.filter(({ name }) => name.length > 0);
|
||||
if (cookies.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
enabled: cookies.some(({ enabled }) => enabled),
|
||||
name: "Cookie",
|
||||
value: cookies.map(({ name, value }) => `${name}=${value}`).join("; "),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function serializeParameterValue(parameter: UnknownRecord, importState: ImportState): string {
|
||||
if (isRecord(parameter.content)) return serializeContentParameter(parameter, importState);
|
||||
return serializeSimpleParameter(parameterExampleValue(parameter, importState), parameter);
|
||||
}
|
||||
|
||||
function serializeContentParameter(parameter: UnknownRecord, importState: ImportState): string {
|
||||
const [contentType, rawMediaType] = Object.entries(toRecord(parameter.content))[0] ?? [];
|
||||
const value = mediaTypeExample(toRecord(rawMediaType), importState);
|
||||
return contentType?.toLowerCase().includes("json")
|
||||
? (JSON.stringify(value) ?? "")
|
||||
: stringifyExampleValue(value);
|
||||
}
|
||||
|
||||
function serializePathParameter(name: string, value: unknown, parameter: UnknownRecord): string {
|
||||
const style = stringAt(parameter, "style") ?? "simple";
|
||||
const explode = parameter.explode === true;
|
||||
const values = Array.isArray(value)
|
||||
? value.map(stringifyExampleValue)
|
||||
: isRecord(value)
|
||||
? Object.entries(value).flatMap(([key, entryValue]) => [
|
||||
key,
|
||||
stringifyExampleValue(entryValue),
|
||||
])
|
||||
: [stringifyExampleValue(value)];
|
||||
|
||||
if (style === "label") {
|
||||
if (explode && isRecord(value)) {
|
||||
return `.${Object.entries(value)
|
||||
.map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`)
|
||||
.join(".")}`;
|
||||
}
|
||||
return `.${values.join(explode ? "." : ",")}`;
|
||||
}
|
||||
if (style === "matrix") {
|
||||
if (explode && Array.isArray(value)) {
|
||||
return value.map((entryValue) => `;${name}=${stringifyExampleValue(entryValue)}`).join("");
|
||||
}
|
||||
if (explode && isRecord(value)) {
|
||||
return Object.entries(value)
|
||||
.map(([key, entryValue]) => `;${key}=${stringifyExampleValue(entryValue)}`)
|
||||
.join("");
|
||||
}
|
||||
return `;${name}=${values.join(",")}`;
|
||||
}
|
||||
return serializeSimpleParameter(value, parameter);
|
||||
}
|
||||
|
||||
function serializeSimpleParameter(value: unknown, parameter: UnknownRecord): string {
|
||||
if (Array.isArray(value)) return value.map(stringifyExampleValue).join(",");
|
||||
if (isRecord(value)) {
|
||||
const entries = Object.entries(value);
|
||||
return parameter.explode === true
|
||||
? entries.map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`).join(",")
|
||||
: entries.flat().map(stringifyExampleValue).join(",");
|
||||
}
|
||||
return stringifyExampleValue(value);
|
||||
}
|
||||
|
||||
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
|
||||
return stringifyExampleValue(parameterExampleValue(parameter, importState));
|
||||
}
|
||||
|
||||
function parameterExampleValue(parameter: UnknownRecord, importState: ImportState): unknown {
|
||||
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
|
||||
if (directExample != null) return directExample;
|
||||
if (isRecord(parameter.content)) {
|
||||
const mediaType = toRecord(Object.values(parameter.content)[0]);
|
||||
return mediaTypeExample(mediaType, importState);
|
||||
}
|
||||
return schemaToExample(importState.resolve(parameter.schema), importState);
|
||||
if (directExample != null) return stringifyExampleValue(directExample);
|
||||
return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
|
||||
}
|
||||
|
||||
function importBody({
|
||||
@@ -809,13 +746,14 @@ function importBody({
|
||||
(c): c is string => typeof c === "string",
|
||||
);
|
||||
const bodyType = contentType ?? "application/json";
|
||||
const schema = importState.resolve(bodyParameter.schema);
|
||||
const example = schemaToExample(schema, importState);
|
||||
const isBinary = stringAt(schema, "format") === "binary";
|
||||
return {
|
||||
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
|
||||
bodyType: isBinary ? "binary" : bodyType,
|
||||
body: isBinary ? {} : { text: formatMediaTypeBody(bodyType, example, schema, importState) },
|
||||
bodyType,
|
||||
body: {
|
||||
text: formatBodyText(
|
||||
schemaToExample(importState.resolve(bodyParameter.schema), importState),
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -833,15 +771,11 @@ function importBody({
|
||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||
bodyType: contentType,
|
||||
body: {
|
||||
form: formParameters.map((p) => {
|
||||
const base = {
|
||||
enabled: p.required === true,
|
||||
name: stringAt(p, "name") ?? "",
|
||||
};
|
||||
return stringAt(p, "type") === "file"
|
||||
? { ...base, file: "" }
|
||||
: { ...base, value: parameterExample(p, importState) };
|
||||
}),
|
||||
form: formParameters.map((p) => ({
|
||||
enabled: p.required === true,
|
||||
name: stringAt(p, "name") ?? "",
|
||||
value: parameterExample(p, importState),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -864,125 +798,45 @@ function importBodyFromContent(importState: ImportState, content: UnknownRecord)
|
||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||
bodyType: contentType,
|
||||
body: {
|
||||
form: schemaToFormParameters(
|
||||
importState.resolve(mediaType.schema),
|
||||
importState,
|
||||
isRecord(example) ? example : undefined,
|
||||
),
|
||||
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const schema = importState.resolve(mediaType.schema);
|
||||
const isBinary =
|
||||
contentType === "application/octet-stream" || stringAt(schema, "format") === "binary";
|
||||
|
||||
return {
|
||||
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||
bodyType: isBinary ? "binary" : contentType,
|
||||
body: isBinary ? {} : { text: formatMediaTypeBody(contentType, example, schema, importState) },
|
||||
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
|
||||
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
|
||||
};
|
||||
}
|
||||
|
||||
function chooseContentType(contentTypes: string[]): string | null {
|
||||
const jsonType = contentTypes.find((contentType) => {
|
||||
const normalized = contentType.toLowerCase().split(";", 1)[0];
|
||||
return normalized?.endsWith("+json") === true;
|
||||
});
|
||||
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
|
||||
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
|
||||
if (exact != null) return exact;
|
||||
if (preference === "application/json" && jsonType != null) return jsonType;
|
||||
}
|
||||
return contentTypes[0] ?? null;
|
||||
}
|
||||
|
||||
function formatMediaTypeBody(
|
||||
contentType: string,
|
||||
example: unknown,
|
||||
schema: unknown,
|
||||
importState: ImportState,
|
||||
): string {
|
||||
const normalized = contentType.toLowerCase().split(";", 1)[0] ?? "";
|
||||
if (normalized === "application/json" || normalized.endsWith("+json")) {
|
||||
return JSON.stringify(example, null, 2) ?? "";
|
||||
}
|
||||
if (
|
||||
normalized === "application/xml" ||
|
||||
normalized === "text/xml" ||
|
||||
normalized.endsWith("+xml")
|
||||
) {
|
||||
return typeof example === "string"
|
||||
? example
|
||||
: valueToXml(example, schema, importState, stringAt(toRecord(schema).xml, "name") ?? "root");
|
||||
}
|
||||
return formatBodyText(example);
|
||||
}
|
||||
|
||||
function valueToXml(
|
||||
value: unknown,
|
||||
schema: unknown,
|
||||
importState: ImportState,
|
||||
elementName: string,
|
||||
): string {
|
||||
const resolvedSchema = toRecord(importState.resolve(schema));
|
||||
if (Array.isArray(value)) {
|
||||
const itemSchema = importState.resolve(resolvedSchema.items);
|
||||
const itemName = stringAt(toRecord(itemSchema).xml, "name") ?? "item";
|
||||
const items = value.map((item) => valueToXml(item, itemSchema, importState, itemName)).join("");
|
||||
return `<${elementName}>${items}</${elementName}>`;
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
const properties = toRecord(resolvedSchema.properties);
|
||||
const attributes: string[] = [];
|
||||
const children: string[] = [];
|
||||
for (const [name, propertyValue] of Object.entries(value)) {
|
||||
const propertySchema = toRecord(importState.resolve(properties[name]));
|
||||
const xml = toRecord(propertySchema.xml);
|
||||
const xmlName = stringAt(xml, "name") ?? name;
|
||||
if (xml.attribute === true) {
|
||||
attributes.push(`${xmlName}="${escapeXml(stringifyExampleValue(propertyValue))}"`);
|
||||
} else {
|
||||
children.push(valueToXml(propertyValue, propertySchema, importState, xmlName));
|
||||
}
|
||||
}
|
||||
const attributeText = attributes.length > 0 ? ` ${attributes.join(" ")}` : "";
|
||||
return `<${elementName}${attributeText}>${children.join("")}</${elementName}>`;
|
||||
}
|
||||
return `<${elementName}>${escapeXml(stringifyExampleValue(value))}</${elementName}>`;
|
||||
}
|
||||
|
||||
function escapeXml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
|
||||
const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples));
|
||||
if (directExample != null) return directExample;
|
||||
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
||||
}
|
||||
|
||||
function schemaToFormParameters(
|
||||
schema: unknown,
|
||||
importState: ImportState,
|
||||
example?: UnknownRecord,
|
||||
) {
|
||||
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))
|
||||
.filter(([, property]) => toRecord(importState.resolve(property)).readOnly !== true)
|
||||
.slice(0, MAX_EXAMPLE_PROPERTIES);
|
||||
const properties = Object.entries(toRecord(resolvedSchema.properties)).slice(
|
||||
0,
|
||||
MAX_EXAMPLE_PROPERTIES,
|
||||
);
|
||||
|
||||
return properties.map(([name, property]) => {
|
||||
const resolvedProperty = toRecord(importState.resolve(property));
|
||||
const propertyExample = example?.[name] ?? schemaToExample(resolvedProperty, importState);
|
||||
const example = schemaToExample(resolvedProperty, importState);
|
||||
const base = {
|
||||
enabled: required.includes(name),
|
||||
name,
|
||||
@@ -990,7 +844,7 @@ function schemaToFormParameters(
|
||||
if (stringAt(resolvedProperty, "format") === "binary") {
|
||||
return { ...base, file: "" };
|
||||
}
|
||||
return { ...base, value: stringifyExampleValue(propertyExample) };
|
||||
return { ...base, value: stringifyExampleValue(example) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1037,13 +891,11 @@ function schemaToExample(
|
||||
const required = toArray(resolved.required).filter(
|
||||
(name): name is string => typeof name === "string",
|
||||
);
|
||||
const properties = Object.entries(toRecord(resolved.properties))
|
||||
.filter(([, property]) => toRecord(importState.resolve(property)).readOnly !== true)
|
||||
.sort(([a], [b]) => {
|
||||
const aRequired = required.includes(a);
|
||||
const bRequired = required.includes(b);
|
||||
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
|
||||
});
|
||||
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
|
||||
@@ -1074,52 +926,158 @@ function inferSchemaType(schema: UnknownRecord): string {
|
||||
}
|
||||
|
||||
function importAuthentication({
|
||||
authenticationVariables,
|
||||
importState,
|
||||
oauthVariablesByScheme,
|
||||
operation,
|
||||
spec,
|
||||
}: {
|
||||
authenticationVariables: AuthenticationVariableRegistry;
|
||||
importState: ImportState;
|
||||
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
|
||||
operation: UnknownRecord;
|
||||
spec: UnknownRecord;
|
||||
}): Pick<HttpRequest, "authentication" | "authenticationType"> {
|
||||
}): ImportedAuthentication {
|
||||
const security = operation.security ?? spec.security;
|
||||
if (Array.isArray(operation.security) && operation.security.length === 0) {
|
||||
return { ...emptyAuthentication(), authenticationType: "none" };
|
||||
}
|
||||
if (!Array.isArray(security) || security.length === 0) {
|
||||
return { authenticationType: null, authentication: {} };
|
||||
return emptyAuthentication();
|
||||
}
|
||||
|
||||
// Security Requirement Objects are alternatives. If any alternative is
|
||||
// empty, authentication is optional regardless of where it appears.
|
||||
if (
|
||||
security.some((requirement) => isRecord(requirement) && Object.keys(requirement).length === 0)
|
||||
) {
|
||||
return { ...emptyAuthentication(), authenticationType: "none" };
|
||||
}
|
||||
|
||||
const schemes = {
|
||||
...toRecord(toRecord(spec.components).securitySchemes),
|
||||
...toRecord(spec.securityDefinitions),
|
||||
};
|
||||
for (const requirement of security) {
|
||||
for (const [schemeName, rawScopes] of Object.entries(toRecord(requirement))) {
|
||||
const scheme = toRecord(importState.resolve(schemes[schemeName]));
|
||||
const type = stringAt(scheme, "type");
|
||||
if (type === "oauth2") {
|
||||
const oauth2 = importOAuth2(scheme, rawScopes);
|
||||
if (oauth2 != null) return oauth2;
|
||||
continue;
|
||||
}
|
||||
if (type === "apiKey") {
|
||||
return { authenticationType: "apikey", authentication: importApiKey(scheme, schemeName) };
|
||||
}
|
||||
// Swagger 2.0 spells basic auth as its own type rather than an HTTP scheme
|
||||
if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) {
|
||||
return {
|
||||
authenticationType: "basic",
|
||||
authentication: { username: "", password: "" },
|
||||
};
|
||||
}
|
||||
if (type === "http" && schemeIs(scheme, "bearer")) {
|
||||
return {
|
||||
authenticationType: "bearer",
|
||||
authentication: { token: "", prefix: "Bearer" },
|
||||
};
|
||||
}
|
||||
}
|
||||
for (const rawRequirement of security) {
|
||||
if (!isRecord(rawRequirement)) continue;
|
||||
|
||||
const imported = importSecurityRequirement({
|
||||
authenticationVariables,
|
||||
importState,
|
||||
oauthVariablesByScheme,
|
||||
requirement: rawRequirement,
|
||||
schemes,
|
||||
spec,
|
||||
});
|
||||
if (imported != null) return imported;
|
||||
}
|
||||
|
||||
return { authenticationType: null, authentication: {} };
|
||||
return emptyAuthentication();
|
||||
}
|
||||
|
||||
function importSecurityRequirement({
|
||||
authenticationVariables,
|
||||
importState,
|
||||
oauthVariablesByScheme,
|
||||
requirement,
|
||||
schemes,
|
||||
spec,
|
||||
}: {
|
||||
authenticationVariables: AuthenticationVariableRegistry;
|
||||
importState: ImportState;
|
||||
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
|
||||
requirement: UnknownRecord;
|
||||
schemes: UnknownRecord;
|
||||
spec: UnknownRecord;
|
||||
}): ImportedAuthentication | null {
|
||||
const entries = Object.entries(requirement);
|
||||
const headers: HttpRequestHeader[] = [];
|
||||
const urlParameters: HttpUrlParameter[] = [];
|
||||
let primaryAuthentication: Pick<HttpRequest, "authentication" | "authenticationType"> | null =
|
||||
null;
|
||||
|
||||
for (const [schemeName, rawScopes] of entries) {
|
||||
const scheme = toRecord(importState.resolve(schemes[schemeName]));
|
||||
const type = stringAt(scheme, "type");
|
||||
if (type === "apiKey") {
|
||||
const variable = registerAuthenticationVariable(authenticationVariables, schemeName, "key");
|
||||
if (entries.length === 1) {
|
||||
primaryAuthentication = {
|
||||
authenticationType: "apikey",
|
||||
authentication: importApiKey(scheme, schemeName, variable),
|
||||
};
|
||||
} else {
|
||||
materializeApiKey(scheme, schemeName, variable, headers, urlParameters);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let candidate: Pick<HttpRequest, "authentication" | "authenticationType"> | null = null;
|
||||
if (type === "oauth2") {
|
||||
candidate = importOAuth2(
|
||||
scheme,
|
||||
rawScopes,
|
||||
importBaseUrl(spec),
|
||||
oauthVariablesByScheme.get(schemeName) ?? {
|
||||
clientId: "oauth_client_id",
|
||||
clientSecret: "oauth_client_secret",
|
||||
},
|
||||
);
|
||||
} else if (type === "openIdConnect") {
|
||||
const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
|
||||
candidate = {
|
||||
authenticationType: "bearer",
|
||||
authentication: { token: templateVariable(token), prefix: "Bearer" },
|
||||
};
|
||||
} else if (type === "basic" || (type === "http" && schemeIs(scheme, "basic"))) {
|
||||
const username = registerAuthenticationVariable(
|
||||
authenticationVariables,
|
||||
schemeName,
|
||||
"username",
|
||||
);
|
||||
const password = registerAuthenticationVariable(
|
||||
authenticationVariables,
|
||||
schemeName,
|
||||
"password",
|
||||
);
|
||||
candidate = {
|
||||
authenticationType: "basic",
|
||||
authentication: {
|
||||
username: templateVariable(username),
|
||||
password: templateVariable(password),
|
||||
},
|
||||
};
|
||||
} else if (type === "http" && schemeIs(scheme, "bearer")) {
|
||||
const token = registerAuthenticationVariable(authenticationVariables, schemeName, "token");
|
||||
candidate = {
|
||||
authenticationType: "bearer",
|
||||
authentication: { token: templateVariable(token), prefix: "Bearer" },
|
||||
};
|
||||
}
|
||||
|
||||
// A requirement is an AND. Yaak can combine one auth plugin with explicit
|
||||
// API-key parameters, but cannot represent two auth plugins on one request.
|
||||
if (candidate == null || primaryAuthentication != null) return null;
|
||||
primaryAuthentication = candidate;
|
||||
}
|
||||
|
||||
return {
|
||||
...(primaryAuthentication ?? {
|
||||
authenticationType: entries.length > 1 ? "none" : null,
|
||||
authentication: {},
|
||||
}),
|
||||
headers,
|
||||
urlParameters,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyAuthentication(): ImportedAuthentication {
|
||||
return {
|
||||
authenticationType: null,
|
||||
authentication: {},
|
||||
headers: [],
|
||||
urlParameters: [],
|
||||
};
|
||||
}
|
||||
|
||||
function schemeIs(scheme: UnknownRecord, name: string): boolean {
|
||||
@@ -1131,14 +1089,67 @@ function schemeIs(scheme: UnknownRecord, name: string): boolean {
|
||||
* cookie key becomes the Cookie header it would have ended up in, pre-filled
|
||||
* with its name. Sending it as a header named after the cookie would just fail.
|
||||
*/
|
||||
function importApiKey(scheme: UnknownRecord, schemeName: string): Record<string, string> {
|
||||
function importApiKey(
|
||||
scheme: UnknownRecord,
|
||||
schemeName: string,
|
||||
variableName: string,
|
||||
): Record<string, string> {
|
||||
const key = stringAt(scheme, "name") ?? schemeName;
|
||||
const location = stringAt(scheme, "in");
|
||||
const value = templateVariable(variableName);
|
||||
|
||||
if (location === "cookie") {
|
||||
return { location: "header", key: "Cookie", value: `${key}=` };
|
||||
return { location: "header", key: "Cookie", value: `${key}=${value}` };
|
||||
}
|
||||
return { location: location === "query" ? "query" : "header", key, value: "" };
|
||||
return { location: location === "query" ? "query" : "header", key, value };
|
||||
}
|
||||
|
||||
function materializeApiKey(
|
||||
scheme: UnknownRecord,
|
||||
schemeName: string,
|
||||
variableName: string,
|
||||
headers: HttpRequestHeader[],
|
||||
urlParameters: HttpUrlParameter[],
|
||||
): void {
|
||||
const key = stringAt(scheme, "name") ?? schemeName;
|
||||
const location = stringAt(scheme, "in");
|
||||
const value = templateVariable(variableName);
|
||||
if (location === "query") {
|
||||
urlParameters.push({ enabled: true, name: key, value });
|
||||
} else if (location === "cookie") {
|
||||
headers.push({ enabled: true, name: "Cookie", value: `${key}=${value}` });
|
||||
} else {
|
||||
headers.push({ enabled: true, name: key, value });
|
||||
}
|
||||
}
|
||||
|
||||
function registerAuthenticationVariable(
|
||||
variables: AuthenticationVariableRegistry,
|
||||
schemeName: string,
|
||||
field: string,
|
||||
): string {
|
||||
const identity = JSON.stringify([schemeName, field]);
|
||||
const existing = variables.get(identity);
|
||||
if (existing != null) return existing.name;
|
||||
|
||||
const schemePart = schemeName
|
||||
.replaceAll(/([a-z0-9])([A-Z])/g, "$1_$2")
|
||||
.replaceAll(/[^a-zA-Z0-9]+/g, "_")
|
||||
.replaceAll(/^_+|_+$/g, "")
|
||||
.toLowerCase();
|
||||
const baseName = `auth_${schemePart || "security"}_${field}`;
|
||||
let name = baseName;
|
||||
let suffix = 2;
|
||||
const names = new Set([...variables.values()].map((variable) => variable.name));
|
||||
while (names.has(name)) {
|
||||
name = `${baseName}_${suffix++}`;
|
||||
}
|
||||
variables.set(identity, { name, value: "" });
|
||||
return name;
|
||||
}
|
||||
|
||||
function templateVariable(name: string): string {
|
||||
return `\${[${name}]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1149,6 +1160,8 @@ function importApiKey(scheme: UnknownRecord, schemeName: string): Record<string,
|
||||
function importOAuth2(
|
||||
scheme: UnknownRecord,
|
||||
rawScopes: unknown,
|
||||
baseUrl: string,
|
||||
variableNames: OAuthVariableNames,
|
||||
): Pick<HttpRequest, "authentication" | "authenticationType"> | null {
|
||||
const scope = toArray(rawScopes)
|
||||
.filter((s): s is string => typeof s === "string")
|
||||
@@ -1176,24 +1189,36 @@ function importOAuth2(
|
||||
}
|
||||
|
||||
for (const { grantType, flow } of candidates) {
|
||||
const authorizationUrl = stringAt(flow, "authorizationUrl");
|
||||
const accessTokenUrl = stringAt(flow, "tokenUrl");
|
||||
const authorizationUrl = resolveOAuthUrl(stringAt(flow, "authorizationUrl"), baseUrl);
|
||||
const accessTokenUrl = resolveOAuthUrl(stringAt(flow, "tokenUrl"), baseUrl);
|
||||
if (authorizationUrl == null && accessTokenUrl == null) continue;
|
||||
|
||||
const grantPatch =
|
||||
grantType === "authorization_code"
|
||||
? { authorizationUrl, accessTokenUrl, clientSecret: "" }
|
||||
? {
|
||||
authorizationUrl,
|
||||
accessTokenUrl,
|
||||
clientSecret: templateVariable(variableNames.clientSecret),
|
||||
}
|
||||
: grantType === "implicit"
|
||||
? { authorizationUrl }
|
||||
: grantType === "password"
|
||||
? { accessTokenUrl, clientSecret: "", username: "", password: "" }
|
||||
: { accessTokenUrl, clientSecret: "" };
|
||||
? {
|
||||
accessTokenUrl,
|
||||
clientSecret: templateVariable(variableNames.clientSecret),
|
||||
username: "",
|
||||
password: "",
|
||||
}
|
||||
: {
|
||||
accessTokenUrl,
|
||||
clientSecret: templateVariable(variableNames.clientSecret),
|
||||
};
|
||||
|
||||
return {
|
||||
authenticationType: "oauth2",
|
||||
authentication: {
|
||||
grantType,
|
||||
clientId: "",
|
||||
clientId: templateVariable(variableNames.clientId),
|
||||
headerPrefix: "Bearer",
|
||||
...(scope.length > 0 ? { scope } : {}),
|
||||
...grantPatch,
|
||||
@@ -1204,6 +1229,65 @@ function importOAuth2(
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveOAuthUrl(value: string | undefined, baseUrl: string): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
try {
|
||||
return new URL(value).toString();
|
||||
} catch {
|
||||
// Relative endpoint; resolve it against the API base below.
|
||||
}
|
||||
|
||||
if (baseUrl.length > 0) {
|
||||
try {
|
||||
return new URL(value, `${trimTrailingSlashes(baseUrl)}/`).toString();
|
||||
} catch {
|
||||
// A path-only server has no origin to resolve against. Preserve whether
|
||||
// the OAuth endpoint is relative to that path or to the eventual origin.
|
||||
}
|
||||
}
|
||||
|
||||
if (value.startsWith("//")) return value;
|
||||
try {
|
||||
const placeholderOrigin = "https://openapi-import.invalid";
|
||||
const relativeBase = new URL(`${trimTrailingSlashes(baseUrl)}/`, placeholderOrigin);
|
||||
const resolved = new URL(value, relativeBase);
|
||||
return `${templateVariable("baseUrlOrigin")}${resolved.pathname}${resolved.search}${resolved.hash}`;
|
||||
} catch {
|
||||
return joinUrlParts(templateVariable("baseUrlOrigin"), value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildOAuthVariablesByScheme(
|
||||
importState: ImportState,
|
||||
spec: UnknownRecord,
|
||||
): Map<string, OAuthVariableNames> {
|
||||
const schemes = {
|
||||
...toRecord(toRecord(spec.components).securitySchemes),
|
||||
...toRecord(spec.securityDefinitions),
|
||||
};
|
||||
const oauthSchemeNames = Object.entries(schemes)
|
||||
.filter(([, scheme]) => stringAt(importState.resolve(scheme), "type") === "oauth2")
|
||||
.map(([name]) => name);
|
||||
const usedPrefixes = new Set<string>();
|
||||
|
||||
return new Map(
|
||||
oauthSchemeNames.map((schemeName) => {
|
||||
const basePrefix =
|
||||
oauthSchemeNames.length === 1
|
||||
? "oauth"
|
||||
: `oauth_${schemeName.replaceAll(/[^a-zA-Z0-9_]+/g, "_").replaceAll(/^_+|_+$/g, "") || "auth"}`;
|
||||
let prefix = basePrefix;
|
||||
let suffix = 2;
|
||||
while (usedPrefixes.has(prefix)) prefix = `${basePrefix}_${suffix++}`;
|
||||
usedPrefixes.add(prefix);
|
||||
return [
|
||||
schemeName,
|
||||
{ clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` },
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
|
||||
const headers: HttpRequestHeader[] = [];
|
||||
for (const header of headerGroups.flat()) {
|
||||
|
||||
@@ -840,7 +840,13 @@ Responses:
|
||||
- 200: Sucessful authentication.
|
||||
- 401: Unsuccessful authentication.",
|
||||
"folderId": "GENERATE_ID::FOLDER_1",
|
||||
"headers": [],
|
||||
"headers": [
|
||||
{
|
||||
"enabled": false,
|
||||
"name": "Authorization",
|
||||
"value": "",
|
||||
},
|
||||
],
|
||||
"id": "GENERATE_ID::HTTP_REQUEST_15",
|
||||
"method": "GET",
|
||||
"model": "http_request",
|
||||
@@ -2611,6 +2617,10 @@ exports[`importer-openapi > Snapshots real-world fixture nasa-apod.yaml 1`] = `
|
||||
"name": "baseUrl",
|
||||
"value": "https://api.nasa.gov/planetary",
|
||||
},
|
||||
{
|
||||
"name": "auth_api_key_key",
|
||||
"value": "",
|
||||
},
|
||||
],
|
||||
"workspaceId": "GENERATE_ID::WORKSPACE_0",
|
||||
},
|
||||
@@ -2634,7 +2644,7 @@ Here's a link: https://example.com",
|
||||
"authentication": {
|
||||
"key": "api_key",
|
||||
"location": "query",
|
||||
"value": "",
|
||||
"value": "\${[auth_api_key_key]}",
|
||||
},
|
||||
"authenticationType": "apikey",
|
||||
"body": {},
|
||||
|
||||
@@ -150,7 +150,10 @@ describe("importer-openapi", () => {
|
||||
expect(imported?.resources.environments).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Global Variables",
|
||||
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
|
||||
variables: [
|
||||
{ name: "baseUrl", value: "https://api.example.com/v1" },
|
||||
{ name: "auth_token_auth_token", value: "" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
@@ -159,7 +162,7 @@ describe("importer-openapi", () => {
|
||||
method: "POST",
|
||||
url: "${[baseUrl]}/accounts/:accountId/members",
|
||||
authenticationType: "bearer",
|
||||
authentication: { token: "", prefix: "Bearer" },
|
||||
authentication: { token: "${[auth_token_auth_token]}", prefix: "Bearer" },
|
||||
bodyType: "application/json",
|
||||
body: {
|
||||
text: JSON.stringify(
|
||||
@@ -339,8 +342,8 @@ describe("importer-openapi", () => {
|
||||
authenticationType: "oauth2",
|
||||
authentication: {
|
||||
grantType: "client_credentials",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
clientId: "${[oauth_oauth_client_id]}",
|
||||
clientSecret: "${[oauth_oauth_client_secret]}",
|
||||
headerPrefix: "Bearer",
|
||||
scope: "read write",
|
||||
accessTokenUrl: "https://example.com/token",
|
||||
@@ -352,12 +355,107 @@ describe("importer-openapi", () => {
|
||||
authenticationType: "oauth2",
|
||||
authentication: {
|
||||
grantType: "implicit",
|
||||
clientId: "",
|
||||
clientId: "${[oauth_implicitOauth_client_id]}",
|
||||
headerPrefix: "Bearer",
|
||||
authorizationUrl: "https://example.com/authorize",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(imported?.resources.environments[0]?.variables).toEqual([
|
||||
{ name: "baseUrl", value: "" },
|
||||
{ name: "oauth_oauth_client_id", value: "" },
|
||||
{ name: "oauth_oauth_client_secret", value: "" },
|
||||
{ name: "oauth_implicitOauth_client_id", value: "" },
|
||||
{ name: "oauth_implicitOauth_client_secret", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Uses shared environment variables for OAuth2 client credentials", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "OAuth Environment Test", version: "1.0.0" },
|
||||
servers: [{ url: "https://api.example.com" }],
|
||||
paths: {
|
||||
"/users": {
|
||||
get: { security: [{ oauth: ["read"] }], responses: {} },
|
||||
post: { security: [{ oauth: ["write"] }], responses: {} },
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
oauth: {
|
||||
type: "oauth2",
|
||||
flows: {
|
||||
authorizationCode: {
|
||||
authorizationUrl: "/oauth/authorize",
|
||||
tokenUrl: "/oauth/token",
|
||||
scopes: { read: "Read users", write: "Write users" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.environments[0]?.variables).toEqual([
|
||||
{ name: "baseUrl", value: "https://api.example.com" },
|
||||
{ name: "oauth_client_id", value: "" },
|
||||
{ name: "oauth_client_secret", value: "" },
|
||||
]);
|
||||
expect(imported?.resources.httpRequests.map((request) => request.authentication)).toEqual([
|
||||
expect.objectContaining({
|
||||
clientId: "${[oauth_client_id]}",
|
||||
clientSecret: "${[oauth_client_secret]}",
|
||||
authorizationUrl: "https://api.example.com/oauth/authorize",
|
||||
accessTokenUrl: "https://api.example.com/oauth/token",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
clientId: "${[oauth_client_id]}",
|
||||
clientSecret: "${[oauth_client_secret]}",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Uses the shared base variable for OAuth endpoints with a path-only API base", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Path-only OAuth Test", version: "1.0.0" },
|
||||
servers: [{ url: "/api/v1" }],
|
||||
paths: {
|
||||
"/users": { get: { security: [{ oauth: [] }], responses: {} } },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
oauth: {
|
||||
type: "oauth2",
|
||||
flows: {
|
||||
authorizationCode: {
|
||||
authorizationUrl: "oauth/authorize",
|
||||
tokenUrl: "/oauth/token",
|
||||
scopes: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.authentication).toEqual(
|
||||
expect.objectContaining({
|
||||
authorizationUrl: "${[baseUrlOrigin]}/api/v1/oauth/authorize",
|
||||
accessTokenUrl: "${[baseUrlOrigin]}/oauth/token",
|
||||
}),
|
||||
);
|
||||
expect(imported?.resources.environments[0]?.variables).toEqual([
|
||||
{ name: "baseUrl", value: "/api/v1" },
|
||||
{ name: "oauth_client_id", value: "" },
|
||||
{ name: "oauth_client_secret", value: "" },
|
||||
{ name: "baseUrlOrigin", value: "" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports Swagger 2 OAuth2 flows and produces", async () => {
|
||||
@@ -385,8 +483,8 @@ describe("importer-openapi", () => {
|
||||
authenticationType: "oauth2",
|
||||
authentication: {
|
||||
grantType: "authorization_code",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
clientId: "${[oauth_client_id]}",
|
||||
clientSecret: "${[oauth_client_secret]}",
|
||||
headerPrefix: "Bearer",
|
||||
scope: "admin",
|
||||
authorizationUrl: "https://example.com/authorize",
|
||||
@@ -524,126 +622,6 @@ describe("importer-openapi", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports cookie and content-based parameters", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Parameter Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/items": {
|
||||
get: {
|
||||
parameters: [
|
||||
{
|
||||
name: "session",
|
||||
in: "cookie",
|
||||
required: true,
|
||||
schema: { type: "string", example: "abc" },
|
||||
},
|
||||
{
|
||||
name: "X-Filter",
|
||||
in: "header",
|
||||
required: true,
|
||||
content: { "text/plain": { example: "active" } },
|
||||
},
|
||||
],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.headers).toEqual([
|
||||
{ enabled: true, name: "X-Filter", value: "active" },
|
||||
{ enabled: true, name: "Cookie", value: "session=abc" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Serializes structured query parameters according to style and explode", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Serialization Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/items": {
|
||||
get: {
|
||||
parameters: [
|
||||
{
|
||||
name: "filter",
|
||||
in: "query",
|
||||
required: true,
|
||||
style: "deepObject",
|
||||
explode: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
role: { example: "admin" },
|
||||
active: { example: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tags",
|
||||
in: "query",
|
||||
style: "form",
|
||||
explode: true,
|
||||
schema: { type: "array", example: ["one", "two"] },
|
||||
},
|
||||
],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
|
||||
{ enabled: true, name: "filter[role]", value: "admin" },
|
||||
{ enabled: true, name: "filter[active]", value: "true" },
|
||||
{ enabled: false, name: "tags", value: "one" },
|
||||
{ enabled: false, name: "tags", value: "two" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Serializes label and matrix path parameters", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Path Serialization Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/labels/{labels}/matrix/{coordinates}": {
|
||||
get: {
|
||||
parameters: [
|
||||
{
|
||||
name: "labels",
|
||||
in: "path",
|
||||
required: true,
|
||||
style: "label",
|
||||
explode: true,
|
||||
schema: { type: "array", example: ["one", "two"] },
|
||||
},
|
||||
{
|
||||
name: "coordinates",
|
||||
in: "path",
|
||||
required: true,
|
||||
style: "matrix",
|
||||
explode: true,
|
||||
schema: { type: "object", example: { x: 1, y: 2 } },
|
||||
},
|
||||
],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
|
||||
{ enabled: true, name: ":labels", value: ".one.two" },
|
||||
{ enabled: true, name: ":coordinates", value: ";x=1;y=2" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Prefers operation-level consumes for Swagger bodies", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
@@ -673,136 +651,6 @@ describe("importer-openapi", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("Serializes request examples according to their media type", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Media Type Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/xml": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/xml": {
|
||||
schema: {
|
||||
type: "object",
|
||||
xml: { name: "user" },
|
||||
properties: { name: { type: "string", example: "Ada" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
"/json-string": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: { "application/json": { schema: { type: "string", example: "hello" } } },
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: "<user><name>Ada</name></user>",
|
||||
});
|
||||
expect(imported?.resources.httpRequests[1]?.body).toEqual({ text: '"hello"' });
|
||||
});
|
||||
|
||||
test("Omits read-only properties from generated request bodies", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.0.4",
|
||||
info: { title: "Read Only Test", version: "1.0.0" },
|
||||
paths: {
|
||||
"/users": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string", readOnly: true, example: "server-id" },
|
||||
name: { type: "string", example: "Ada" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: JSON.stringify({ name: "Ada" }, null, 2),
|
||||
});
|
||||
});
|
||||
|
||||
test("Imports Swagger 2 file parameters as file form entries", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
swagger: "2.0",
|
||||
info: { title: "File Upload Test", version: "1.0.0" },
|
||||
host: "example.com",
|
||||
consumes: ["multipart/form-data"],
|
||||
paths: {
|
||||
"/upload": {
|
||||
post: {
|
||||
parameters: [{ name: "upload", in: "formData", required: true, type: "file" }],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
form: [{ enabled: true, name: "upload", file: "" }],
|
||||
});
|
||||
});
|
||||
|
||||
test("Serializes Swagger 2 XML request bodies as XML", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
swagger: "2.0",
|
||||
info: { title: "Swagger XML Test", version: "1.0.0" },
|
||||
host: "example.com",
|
||||
consumes: ["application/xml"],
|
||||
paths: {
|
||||
"/users": {
|
||||
post: {
|
||||
parameters: [
|
||||
{
|
||||
name: "user",
|
||||
in: "body",
|
||||
required: true,
|
||||
schema: {
|
||||
type: "object",
|
||||
xml: { name: "user" },
|
||||
properties: { name: { type: "string", example: "Ada" } },
|
||||
},
|
||||
},
|
||||
],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests[0]?.body).toEqual({
|
||||
text: "<user><name>Ada</name></user>",
|
||||
});
|
||||
});
|
||||
|
||||
test("Imports Swagger 2 basic auth and cookie API keys", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
@@ -823,16 +671,166 @@ describe("importer-openapi", () => {
|
||||
expect(imported?.resources.httpRequests[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
authenticationType: "basic",
|
||||
authentication: { username: "", password: "" },
|
||||
authentication: {
|
||||
username: "${[auth_basic_auth_username]}",
|
||||
password: "${[auth_basic_auth_password]}",
|
||||
},
|
||||
}),
|
||||
);
|
||||
// The auth plugin has no cookie location, so it becomes the Cookie header
|
||||
expect(imported?.resources.httpRequests[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
authenticationType: "apikey",
|
||||
authentication: { location: "header", key: "Cookie", value: "session=" },
|
||||
authentication: {
|
||||
location: "header",
|
||||
key: "Cookie",
|
||||
value: "session=${[auth_cookie_key_key]}",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(imported?.resources.environments).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "Global Variables",
|
||||
variables: [
|
||||
{ name: "baseUrl", value: "https://example.com/" },
|
||||
{ name: "auth_basic_auth_username", value: "" },
|
||||
{ name: "auth_basic_auth_password", value: "" },
|
||||
{ name: "auth_cookie_key_key", value: "" },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Preserves anonymous security alternatives and explicit auth overrides", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Optional Auth", version: "1.0.0" },
|
||||
security: [{ bearerAuth: [] }],
|
||||
paths: {
|
||||
"/optional-auth-first": {
|
||||
get: { security: [{ bearerAuth: [] }, {}], responses: {} },
|
||||
},
|
||||
"/optional-anonymous-first": {
|
||||
get: { security: [{}, { bearerAuth: [] }], responses: {} },
|
||||
},
|
||||
"/public": { get: { security: [], responses: {} } },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: "http", scheme: "bearer" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({ authenticationType: "none", authentication: {} }),
|
||||
expect.objectContaining({ authenticationType: "none", authentication: {} }),
|
||||
expect.objectContaining({ authenticationType: "none", authentication: {} }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports AND security requirements without dropping API keys", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Combined Auth", version: "1.0.0" },
|
||||
paths: {
|
||||
"/combined": {
|
||||
get: {
|
||||
security: [{ bearerAuth: [], tenantKey: [], queryKey: [] }],
|
||||
parameters: [
|
||||
{
|
||||
in: "header",
|
||||
name: "X-Tenant-Key",
|
||||
example: "operation-value-must-not-replace-auth",
|
||||
},
|
||||
],
|
||||
responses: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: "http", scheme: "bearer" },
|
||||
tenantKey: { type: "apiKey", in: "header", name: "X-Tenant-Key" },
|
||||
queryKey: { type: "apiKey", in: "query", name: "api_key" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
authenticationType: "bearer",
|
||||
authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
|
||||
headers: [{ enabled: true, name: "X-Tenant-Key", value: "${[auth_tenant_key_key]}" }],
|
||||
urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Keeps distinct credentials for security scheme names that normalize alike", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "Auth Variable Names", version: "1.0.0" },
|
||||
paths: {
|
||||
"/hyphen": { get: { security: [{ "api-key": [] }], responses: {} } },
|
||||
"/underscore": { get: { security: [{ api_key: [] }], responses: {} } },
|
||||
"/hyphen-again": { get: { security: [{ "api-key": [] }], responses: {} } },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
"api-key": { type: "apiKey", in: "header", name: "X-Hyphen-Key" },
|
||||
api_key: { type: "apiKey", in: "header", name: "X-Underscore-Key" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.environments[0]?.variables).toEqual([
|
||||
{ name: "baseUrl", value: "" },
|
||||
{ name: "auth_api_key_key", value: "" },
|
||||
{ name: "auth_api_key_key_2", value: "" },
|
||||
]);
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
authentication: expect.objectContaining({ value: "${[auth_api_key_key_2]}" }),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
authentication: expect.objectContaining({ value: "${[auth_api_key_key]}" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Imports OpenID Connect as bearer authentication", async () => {
|
||||
const imported = await convertOpenApi(
|
||||
JSON.stringify({
|
||||
openapi: "3.1.0",
|
||||
info: { title: "OpenID Connect", version: "1.0.0" },
|
||||
paths: { "/me": { get: { security: [{ oidc: [] }], responses: {} } } },
|
||||
components: {
|
||||
securitySchemes: {
|
||||
oidc: {
|
||||
type: "openIdConnect",
|
||||
openIdConnectUrl: "https://accounts.example.com/.well-known/openid-configuration",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(imported?.resources.httpRequests).toEqual([
|
||||
expect.objectContaining({
|
||||
authenticationType: "bearer",
|
||||
authentication: { token: "${[auth_oidc_token]}", prefix: "Bearer" },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("Reports references that point outside the document", async () => {
|
||||
|
||||
Reference in New Issue
Block a user