Import OpenAPI security the way Yaak inherits it (#601)

This commit is contained in:
Gregory Schier
2026-08-20 07:39:18 -07:00
committed by GitHub
parent 4eebd606ef
commit 2350689d7a
3 changed files with 274 additions and 46 deletions
+116 -44
View File
@@ -15,7 +15,7 @@ import YAML from "yaml";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
type UnknownRecord = Record<string, unknown>;
type ImportResources = {
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
workspaces: AtLeast<Workspace, "name" | "id" | "model" | "authentication">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId" | "variables">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
@@ -61,6 +61,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
id: importState.generateId("workspace"),
name: stringAt(spec.info, "title") ?? "OpenAPI Import",
description: importInfoDescription(toRecord(spec.info)),
authentication: {},
};
const resources: ImportResources = {
@@ -89,6 +90,23 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
sortPriority: importState.nextSortPriority(),
});
// Spec-level security is the default for every operation, which is exactly
// Yaak's inheritance model: it lives on the workspace, and only operations
// that declare their own security carry per-request authentication. API keys
// materialized as headers or query parameters go onto inheriting requests
// individually — workspace headers would also reach operations that override
// or disable security, leaking the credential to endpoints that opted out.
const workspaceAuthentication = importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
security: spec.security,
spec,
useDynamicServerUrls: serverEnvironments.length > 1,
});
workspace.authentication = workspaceAuthentication.authentication;
workspace.authenticationType = workspaceAuthentication.authenticationType;
const folderIdsByTag = new Map<string, string>();
const routeLabels = new Map<string, string>();
for (const tag of toArray(spec.tags)) {
@@ -125,6 +143,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
const request = importOperation({
importState,
inheritedAuthentication: workspaceAuthentication,
method,
operation,
oauthVariablesByScheme,
@@ -144,7 +163,8 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
}
}
if (resources.httpRequests.some((request) => request.authenticationType === "oauth2")) {
const authenticationConfigs = [workspace, ...resources.httpRequests];
if (authenticationConfigs.some((model) => model.authenticationType === "oauth2")) {
const variableNames = new Set(
[...oauthVariablesByScheme.values()].flatMap(({ clientId, clientSecret }) => [
clientId,
@@ -152,10 +172,10 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
]),
);
if (
resources.httpRequests.some(
(request) =>
request.authenticationType === "oauth2" &&
Object.values(toRecord(request.authentication)).some(
authenticationConfigs.some(
(model) =>
model.authenticationType === "oauth2" &&
Object.values(toRecord(model.authentication)).some(
(value) =>
typeof value === "string" && value.includes(templateVariable("baseUrlOrigin")),
),
@@ -255,6 +275,7 @@ function disambiguateNames(
function importOperation({
importState,
inheritedAuthentication,
method,
operation,
oauthVariablesByScheme,
@@ -270,6 +291,7 @@ function importOperation({
authenticationVariables,
}: {
importState: ImportState;
inheritedAuthentication: ImportedAuthentication;
method: string;
operation: UnknownRecord;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
@@ -291,14 +313,23 @@ function importOperation({
operationParameters: toArray(operation.parameters),
});
const body = importBody({ importState, operation, parameters, spec });
const authentication = importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
operation,
spec,
useDynamicServerUrls,
});
// Operations without their own security inherit the workspace's (null
// authenticationType), the same way an operation inherits spec security
const hasOwnSecurity = Array.isArray(operation.security);
const authentication = hasOwnSecurity
? importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
security: operation.security,
spec,
useDynamicServerUrls,
})
: {
...emptyAuthentication(),
headers: inheritedAuthentication.headers,
urlParameters: inheritedAuthentication.urlParameters,
};
const pathExampleValues = new Map(
parameters
.map((p) => importState.resolve(p))
@@ -792,18 +823,52 @@ function importUrlParameters({
stringAt(p, "in") === "query" ||
(stringAt(p, "in") === "path" && placeholderNames.has(stringAt(p, "name") ?? "")),
)
.map((p) => ({
.flatMap((p) => {
const name = stringAt(p, "name") ?? "";
if (name.length === 0) return [];
// Path parameters are required by definition, and a disabled one would
// leave the literal `:name` in the sent URL even for sloppy specs that
// omit `required: true`
enabled: p.required === true || stringAt(p, "in") === "path",
name:
stringAt(p, "in") === "path"
? `:${stringAt(p, "name") ?? ""}`
: (stringAt(p, "name") ?? ""),
value: parameterExample(p, importState),
}))
.filter(({ name }) => name.length > 0);
const enabled = p.required === true || stringAt(p, "in") === "path";
if (stringAt(p, "in") === "query") {
const raw = rawParameterExample(p, importState);
if (Array.isArray(raw)) {
const { separator } = queryArraySerialization(p);
if (separator == null) {
return raw.map((item) => ({ enabled, name, value: stringifyExampleValue(item) }));
}
return [{ enabled, name, value: raw.map(stringifyExampleValue).join(separator) }];
}
}
return [
{
enabled,
name: stringAt(p, "in") === "path" ? `:${name}` : name,
value: parameterExample(p, importState),
},
];
});
}
/**
* OpenAPI 3 query arrays default to form/explode, one parameter per item;
* Swagger 2 defaults to comma-separated unless collectionFormat says otherwise.
* A null separator means repeated parameters.
*/
function queryArraySerialization(parameter: UnknownRecord): { separator: string | null } {
const collectionFormat = stringAt(parameter, "collectionFormat");
if (collectionFormat != null || parameter.schema == null) {
if (collectionFormat === "multi") return { separator: null };
return {
separator: { csv: ",", ssv: " ", tsv: "\t", pipes: "|" }[collectionFormat ?? "csv"] ?? ",",
};
}
const style = stringAt(parameter, "style");
if (style === "spaceDelimited") return { separator: " " };
if (style === "pipeDelimited") return { separator: "|" };
return parameter.explode === false ? { separator: "," } : { separator: null };
}
// The spec says header parameters with these names SHALL be ignored; Accept and
@@ -857,15 +922,23 @@ function importCookieHeader({
];
}
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
function rawParameterExample(parameter: UnknownRecord, importState: ImportState): unknown {
const directExample = firstPresent(
parameter.example,
firstExampleValue(parameter.examples, importState),
);
if (directExample != null) return stringifyExampleValue(directExample);
const example = stringifyExampleValue(
schemaToExample(importState.resolve(parameter.schema), importState),
);
if (directExample != null) return directExample;
// Swagger 2 parameters carry the schema keywords (type, items, default)
// directly on the parameter object
return schemaToExample(importState.resolve(parameter.schema ?? parameter), importState);
}
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
const raw = rawParameterExample(parameter, importState);
// Simple/csv style, the default everywhere but query strings
const example = Array.isArray(raw)
? raw.map(stringifyExampleValue).join(",")
: stringifyExampleValue(raw);
// 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") {
@@ -1158,34 +1231,28 @@ function inferSchemaType(schema: UnknownRecord): string {
return "string";
}
/**
* Security Requirement Objects are ordered alternatives, so the first one this
* importer can represent wins. That makes `[{bearer}, {}]` import the bearer
* auth the author listed first, while `[{}, {bearer}]` imports as anonymous.
*/
function importAuthentication({
authenticationVariables,
importState,
oauthVariablesByScheme,
operation,
security,
spec,
useDynamicServerUrls,
}: {
authenticationVariables: AuthenticationVariableRegistry;
importState: ImportState;
oauthVariablesByScheme: Map<string, OAuthVariableNames>;
operation: UnknownRecord;
security: unknown;
spec: UnknownRecord;
useDynamicServerUrls: boolean;
}): 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 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)
) {
if (!Array.isArray(security)) return emptyAuthentication();
if (security.length === 0) {
return { ...emptyAuthentication(), authenticationType: "none" };
}
@@ -1195,6 +1262,9 @@ function importAuthentication({
};
for (const rawRequirement of security) {
if (!isRecord(rawRequirement)) continue;
if (Object.keys(rawRequirement).length === 0) {
return { ...emptyAuthentication(), authenticationType: "none" };
}
const imported = importSecurityRequirement({
authenticationVariables,
@@ -1208,7 +1278,9 @@ function importAuthentication({
if (imported != null) return imported;
}
return emptyAuthentication();
// Declared security this importer cannot represent (e.g. mutualTLS alone)
// should not fall back to inheriting some other authentication
return { ...emptyAuthentication(), authenticationType: "none" };
}
function importSecurityRequirement({
@@ -292,6 +292,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
"authentication": {},
"authenticationType": "none",
"description": "Wikipedia for Web APIs. Repository of API definitions in OpenAPI format.
**Warning**: If you want to be notified about changes in advance please join our [Slack channel](https://join.slack.com/t/mermade/shared_invite/zt-g78g7xir-MLE_CTCcXCdfJfG3CJe9qA).
Client sample: [[Demo]](https://apis.guru/simple-ui) [[Repo]](https://github.com/APIs-guru/simple-ui)
@@ -2587,6 +2589,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
"authentication": {},
"authenticationType": null,
"description": "A simple HTTP Request & Response Service.<br/> <br/> <b>Run locally: </b> <code>$ docker run -p 80:80 kennethreitz/httpbin</code>
Contact: me@kennethreitz.org",
@@ -2717,6 +2721,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
"authentication": {},
"authenticationType": null,
"description": "This endpoint structures the APOD imagery and associated metadata so that it can be repurposed for other applications. In addition, if the concept_tags parameter is set to True, then keywords derived from the image explanation are returned. These keywords could be used as auto-generated hashtags for twitter or instagram feeds; but generally help with discoverability of relevant imagery
Contact: evan.t.yates@nasa.gov
@@ -2819,6 +2825,8 @@ Responses:
"websocketRequests": [],
"workspaces": [
{
"authentication": {},
"authenticationType": null,
"description": "Webcomic of romance, sarcasm, math, and language.",
"id": "GENERATE_ID::WORKSPACE_0",
"model": "workspace",
+150 -2
View File
@@ -850,7 +850,7 @@ describe("importer-openapi", () => {
]);
});
test("Preserves anonymous security alternatives and explicit auth overrides", async () => {
test("Respects the order of security alternatives and explicit auth overrides", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
@@ -873,10 +873,158 @@ describe("importer-openapi", () => {
}),
);
expect(imported?.resources.workspaces[0]).toEqual(
expect.objectContaining({
authenticationType: "bearer",
authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
}),
);
expect(imported?.resources.httpRequests).toEqual([
// The author listed bearer before the anonymous alternative
expect.objectContaining({
authenticationType: "bearer",
authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
}),
expect.objectContaining({ authenticationType: "none", authentication: {} }),
expect.objectContaining({ authenticationType: "none", authentication: {} }),
expect.objectContaining({ authenticationType: "none", authentication: {} }),
]);
});
test("Imports spec-level security as inherited workspace authentication", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Inherited Auth", version: "1.0.0" },
security: [{ bearerAuth: [], queryKey: [], headerKey: [] }],
paths: {
"/inherits": { get: { responses: {} } },
"/own-auth": { get: { security: [{ otherKey: [] }], responses: {} } },
"/public": { get: { security: [], responses: {} } },
},
components: {
securitySchemes: {
bearerAuth: { type: "http", scheme: "bearer" },
queryKey: { type: "apiKey", in: "query", name: "api_key" },
headerKey: { type: "apiKey", in: "header", name: "X-Tenant" },
otherKey: { type: "apiKey", in: "header", name: "X-Other" },
},
},
}),
);
expect(imported?.resources.workspaces[0]).toEqual(
expect.objectContaining({
authenticationType: "bearer",
authentication: { token: "${[auth_bearer_auth_token]}", prefix: "Bearer" },
}),
);
expect(imported?.resources.httpRequests).toEqual([
// No authenticationType means inherit; materialized API keys ride along
// on the request since headers or parameters at the workspace level
// would also reach operations that opted out
expect.objectContaining({
authentication: {},
headers: [{ enabled: true, name: "X-Tenant", value: "${[auth_header_key_key]}" }],
urlParameters: [{ enabled: true, name: "api_key", value: "${[auth_query_key_key]}" }],
}),
expect.objectContaining({
authenticationType: "apikey",
authentication: expect.objectContaining({ key: "X-Other" }),
headers: [],
urlParameters: [],
}),
expect.objectContaining({
authenticationType: "none",
headers: [],
urlParameters: [],
}),
]);
expect(imported?.resources.httpRequests[0]?.authenticationType).toBeNull();
});
test("Serializes array query parameters per style", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Array Params", version: "1.0.0" },
paths: {
"/exploded": {
get: {
parameters: [
{
name: "tags",
in: "query",
required: true,
schema: { type: "array", items: { type: "string" }, example: ["a", "b"] },
},
],
responses: {},
},
},
"/csv": {
get: {
parameters: [
{
name: "ids",
in: "query",
required: true,
explode: false,
schema: { type: "array", example: [1, 2, 3] },
},
],
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests.map((r) => r.urlParameters)).toEqual([
[
{ enabled: true, name: "tags", value: "a" },
{ enabled: true, name: "tags", value: "b" },
],
[{ enabled: true, name: "ids", value: "1,2,3" }],
]);
});
test("Serializes Swagger 2 array parameters per collectionFormat", async () => {
const imported = await convertOpenApi(
JSON.stringify({
swagger: "2.0",
info: { title: "Swagger Arrays", version: "1.0.0" },
host: "example.com",
paths: {
"/a": {
get: {
parameters: [
{
name: "tags",
in: "query",
required: true,
type: "array",
items: { type: "string", default: "x" },
},
{
name: "multi",
in: "query",
required: true,
type: "array",
collectionFormat: "multi",
items: { type: "string", enum: ["m1"] },
},
],
responses: {},
},
},
},
}),
);
expect(imported?.resources.httpRequests[0]?.urlParameters).toEqual([
// Swagger defaults to csv
{ enabled: true, name: "tags", value: "x" },
{ enabled: true, name: "multi", value: "m1" },
]);
});