Compare commits

..

3 Commits

Author SHA1 Message Date
Gregory Schier 580302cbd2 Use baseUrl variable for OpenAPI imports 2026-06-29 14:42:29 -07:00
Gregory Schier 3b9c311dc5 Avoid regex trimming in OpenAPI importer 2026-06-29 14:32:21 -07:00
Gregory Schier 016fcba1c6 Add native OpenAPI importer 2026-06-29 14:23:36 -07:00
69 changed files with 8892 additions and 3140 deletions
@@ -1,566 +0,0 @@
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
const MAINTAINER_LOGINS = new Set(["gschier"]);
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
const LARGE_DIFF_CHANGED_FILES = 20;
const LARGE_DIFF_CHANGED_LINES = 800;
const LABELS = {
accepted: {
name: "contribution: accepted",
color: "0E8A16",
description: "Community PR appears to match Yaak's contribution policy.",
},
approvedFeedback: {
name: "contribution: approved feedback",
color: "5319E7",
description: "Community PR links an approved feedback item.",
},
needsTemplate: {
name: "contribution: needs template",
color: "D93F0B",
description: "Community PR needs a completed pull request template.",
},
needsApproval: {
name: "contribution: needs approval",
color: "B60205",
description: "Community PR needs an approved feedback item before review.",
},
largeDiff: {
name: "contribution: large diff",
color: "FBCA04",
description:
"Community PR has a larger-than-usual diff for a small-scope contribution.",
},
};
const MANAGED_LABEL_NAMES = Object.values(LABELS).map((label) => label.name);
const CHECKBOXES = {
smallScope: "This PR is a bug fix or small-scope improvement.",
approvedFeedback:
"If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.",
readContributing:
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
testedLocally: "I tested this change locally.",
testsUpdated: "I added or updated tests when reasonable.",
};
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function normalizeBody(body) {
return (body || "").replace(/\r\n/g, "\n");
}
function stripComments(value) {
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
}
function getSection(body, heading) {
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
const match = pattern.exec(body);
if (match == null) {
return null;
}
const rest = body.slice(match.index + match[0].length);
const nextHeadingIndex = rest.search(/^##\s+/m);
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
}
function hasMeaningfulText(value) {
return stripComments(value || "").length > 0;
}
function checkboxState(body, label) {
const flexibleLabel = escapeRegExp(label).replace(/\\ /g, "\\s+");
const pattern = new RegExp(
`^\\s*[-*]\\s*\\[([ xX])\\]\\s*${flexibleLabel}\\s*$`,
"im",
);
const match = body.match(pattern);
if (match == null) {
return null;
}
return match[1].toLowerCase() === "x";
}
function findFeedbackUrl(body) {
return (
body.match(
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
)?.[0] ?? null
);
}
function analyzePullRequest(pr) {
const body = normalizeBody(pr.body);
const states = Object.fromEntries(
Object.entries(CHECKBOXES).map(([key, label]) => [
key,
checkboxState(body, label),
]),
);
const sectionCount = ["Summary", "Submission", "Related"].filter(
(heading) => getSection(body, heading) != null,
).length;
const checkboxCount = Object.values(states).filter(
(state) => state != null,
).length;
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
const blockers = [];
const totalChangedLines =
Number(pr.additions || 0) + Number(pr.deletions || 0);
const changedFiles = Number(pr.changed_files || 0);
const largeDiff =
changedFiles > LARGE_DIFF_CHANGED_FILES ||
totalChangedLines > LARGE_DIFF_CHANGED_LINES;
if (!templateUsed) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Update the PR description with the repository pull request template.",
});
} else {
const summary = getSection(body, "Summary");
const hasSummary = hasMeaningfulText(summary);
const feedbackUrl = findFeedbackUrl(body);
const smallScope = states.smallScope === true;
const approvedFeedback = states.approvedFeedback === true;
if (!hasSummary) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Add a short summary describing the bug fix or improvement.",
});
}
if (smallScope && approvedFeedback) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Choose either the small-scope checkbox or the approved-feedback checkbox, not both.",
});
} else if (!smallScope && !approvedFeedback) {
blockers.push({
label: LABELS.needsTemplate.name,
message:
"Check whether this is a bug fix or small-scope improvement, or confirm that an approved feedback item is linked.",
});
} else if (approvedFeedback && feedbackUrl == null) {
blockers.push({
label: LABELS.needsApproval.name,
message:
"Link the approved feedback item where contribution approval was explicitly stated.",
});
}
if (states.readContributing !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
});
}
if (states.testedLocally !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that the change was tested locally.",
});
}
if (states.testsUpdated !== true) {
blockers.push({
label: LABELS.needsTemplate.name,
message: "Confirm that tests were added or updated when reasonable.",
});
}
}
const desiredLabels = new Set(blockers.map((blocker) => blocker.label));
if (blockers.length === 0) {
desiredLabels.add(
states.approvedFeedback
? LABELS.approvedFeedback.name
: LABELS.accepted.name,
);
}
if (largeDiff) {
desiredLabels.add(LABELS.largeDiff.name);
}
return {
blockers,
changedFiles,
desiredLabels: [...desiredLabels],
largeDiff,
templateUsed,
totalChangedLines,
};
}
function buildBlockingComment(analysis) {
const lines = [
COMMENT_MARKER,
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes and small-scope improvements, plus larger changes that link an approved feedback item from https://yaak.app/feedback.",
"",
"This PR cannot be accepted yet. Please update the PR description to address:",
"",
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
];
if (analysis.largeDiff) {
lines.push(
"",
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as a large diff. That label is advisory, but maintainers may ask for the scope to be reduced.`,
);
}
lines.push(
"",
"I did not overwrite the PR body, since that can remove useful context. Editing the description directly is the safest way to keep your notes while completing the template.",
);
return lines.join("\n");
}
function summarizeResult({ pr, analysis, skipped, skipReason }) {
if (skipped) {
return `#${pr.number} ${pr.title} - skipped (${skipReason})`;
}
const status =
analysis.blockers.length > 0
? `blocked: ${analysis.blockers.map((blocker) => blocker.message).join("; ")}`
: "accepted";
const labels =
analysis.desiredLabels.length > 0
? analysis.desiredLabels.join(", ")
: "none";
return `#${pr.number} ${pr.title} - ${status}; labels: ${labels}`;
}
async function isOfficialMaintainer({ github, owner, repo, pr }) {
if (MAINTAINER_LOGINS.has(pr.user.login)) {
return true;
}
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
return true;
}
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: pr.user.login,
});
return MAINTAINER_PERMISSIONS.has(response.data.permission);
} catch (error) {
if (error.status === 404) {
return false;
}
throw error;
}
}
async function ensureManagedLabels({ github, owner, repo }) {
for (const label of Object.values(LABELS)) {
try {
await github.rest.issues.getLabel({
owner,
repo,
name: label.name,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
await github.rest.issues.createLabel({
owner,
repo,
name: label.name,
color: label.color,
description: label.description,
});
}
}
}
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
const desired = new Set(desiredLabels);
await ensureManagedLabels({ github, owner, repo });
for (const labelName of MANAGED_LABEL_NAMES) {
if (desired.has(labelName)) {
continue;
}
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: issueNumber,
name: labelName,
});
} catch (error) {
if (error.status !== 404) {
throw error;
}
}
}
if (desired.size > 0) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: issueNumber,
labels: [...desired],
});
}
}
async function findPolicyComment({ github, owner, repo, issueNumber }) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
return comments.find(
(comment) =>
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
);
}
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
return;
}
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existingComment.id,
body,
});
}
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
const existingComment = await findPolicyComment({
github,
owner,
repo,
issueNumber,
});
if (existingComment == null) {
return;
}
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: existingComment.id,
});
}
async function checkPullRequest({
github,
core,
owner,
repo,
pullNumber,
dryRun,
}) {
const response = await github.rest.pulls.get({
owner,
repo,
pull_number: pullNumber,
});
const pr = response.data;
const issueNumber = pr.number;
if (pr.draft) {
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: "draft",
}),
skipped: true,
};
}
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
core.notice(
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
);
if (!dryRun) {
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
await deletePolicyComment({ github, owner, repo, issueNumber });
}
return {
blocked: false,
number: pr.number,
summary: summarizeResult({
pr,
skipped: true,
skipReason: `maintainer @${pr.user.login}`,
}),
skipped: true,
};
}
const analysis = analyzePullRequest(pr);
if (dryRun) {
const summary = summarizeResult({ pr, analysis });
core.notice(`[dry-run] ${summary}`);
return {
blocked: analysis.blockers.length > 0,
number: pr.number,
summary,
skipped: false,
};
}
await syncLabels({
github,
owner,
repo,
issueNumber,
desiredLabels: analysis.desiredLabels,
});
if (analysis.blockers.length > 0) {
await upsertPolicyComment({
github,
owner,
repo,
issueNumber,
body: buildBlockingComment(analysis),
});
return {
blocked: true,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
await deletePolicyComment({ github, owner, repo, issueNumber });
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
return {
blocked: false,
number: pr.number,
summary: summarizeResult({ pr, analysis }),
skipped: false,
};
}
async function listOpenPullRequests({ github, owner, repo }) {
return github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
});
}
async function run({ github, context, core }) {
const { owner, repo } = context.repo;
const payloadPr = context.payload.pull_request;
const dryRun =
context.eventName === "workflow_dispatch" &&
context.payload.inputs?.dry_run !== "false";
const pullRequests =
payloadPr == null
? await listOpenPullRequests({ github, owner, repo })
: [payloadPr];
const results = [];
if (dryRun) {
core.notice("Running contribution policy in dry-run mode.");
}
for (const pr of pullRequests) {
results.push(
await checkPullRequest({
github,
core,
owner,
repo,
pullNumber: pr.number,
dryRun,
}),
);
}
await core.summary
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
.addTable([
[
{ data: "PR", header: true },
{ data: "Result", header: true },
],
...results.map((result) => [`#${result.number}`, result.summary]),
])
.write();
const blockedPullRequests = results.filter((result) => result.blocked);
if (blockedPullRequests.length > 0) {
if (dryRun) {
core.warning(
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
return;
}
core.setFailed(
`Contribution policy failed for PR(s): ${blockedPullRequests
.map((result) => `#${result.number}`)
.join(", ")}`,
);
}
}
module.exports = {
analyzePullRequest,
run,
};
-32
View File
@@ -1,32 +0,0 @@
name: Contribution Policy
on:
workflow_dispatch:
inputs:
dry_run:
description: Preview labels and comments without changing PRs
required: true
default: true
type: boolean
permissions:
contents: read
issues: write
pull-requests: read
jobs:
check:
name: Check contribution policy
runs-on: ubuntu-latest
steps:
- name: Checkout policy script
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Check contribution policy
uses: actions/github-script@v7
with:
script: |
const { run } = require("./.github/scripts/check-contribution-policy.js");
await run({ github, context, core });
Generated
+9 -9
View File
@@ -215,7 +215,7 @@ dependencies = [
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.52.0", "windows-sys 0.59.0",
"wl-clipboard-rs", "wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static 1.5.0", "lazy_static 1.5.0",
"windows-sys 0.48.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -6534,7 +6534,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -6547,7 +6547,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.4", "linux-raw-sys 0.9.4",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.46" version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -7988,7 +7988,7 @@ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
"once_cell", "once_cell",
"rustix 1.0.7", "rustix 1.0.7",
"windows-sys 0.52.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -9317,7 +9317,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.59.0",
] ]
[[package]] [[package]]
@@ -4,7 +4,6 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast"; import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
@@ -90,8 +89,6 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
</Banner> </Banner>
)} )}
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
<PlainInput <PlainInput
required required
label="Repository URL" label="Repository URL"
@@ -1,130 +0,0 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
"Personal use of Yaak is free. If youre using Yaak at work, please purchase a license.";
export function CommercialUseBanner({
source,
title,
}: {
source: string;
title: string;
}) {
const [visible, setVisible] = useState(false);
const snoozeStartedRef = useRef(false);
const {
isLoading: isSnoozeLoading,
set: setSnoozedAt,
value: snoozedAt,
} = useKeyValue<string | null>({
namespace: "global",
key: "commercial-use-banner-snoozed-at",
fallback: null,
});
useEffect(() => {
let canceled = false;
shouldShowCommercialUsePrompt()
.then((shouldShow) => {
if (!canceled) setVisible(shouldShow);
})
.catch(console.error);
return () => {
canceled = true;
};
}, [source]);
const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
const handleShow = useCallback(() => {
if (snoozeStartedRef.current || snoozed) {
return;
}
snoozeStartedRef.current = true;
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
}, [setSnoozedAt, snoozed, source]);
if (!visible || isSnoozeLoading || snoozed) {
return null;
}
return (
<div className="w-full">
<DismissibleBanner
id={`commercial-use:${source}`}
color="info"
className="w-full"
onDismiss={() =>
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
}
onShow={handleShow}
actions={[
{
label: "Purchase License",
color: "info",
variant: "solid",
onClick: () => {
openCommercialUsePricing(source).catch(console.error);
},
},
]}
>
<div className="text-sm">
<p className="font-semibold text-text">{title}</p>
<p className="mt-0.5 text-text-subtle">{COMMERCIAL_USE_BANNER_MESSAGE}</p>
</div>
</DismissibleBanner>
</div>
);
}
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
if (appInfo.featureLicense !== true) {
return false;
}
try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
return license.status !== "active" && license.status !== "trialing";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
return true;
}
}
async function openCommercialUsePricing(source: string): Promise<void> {
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
}
function isSnoozed(value: string | null, ms: number): boolean {
if (value == null) return false;
try {
const snooze = JSON.parse(value) as { at?: unknown };
const at = typeof snooze.at === "string" ? snooze.at : null;
return isWithinMs(at, ms);
} catch {
// Older builds stored only the timestamp, so keep respecting that as a global snooze.
return isWithinMs(value, ms);
}
}
function isWithinMs(date: string | null, ms: number): boolean {
if (date == null) return false;
const time = new Date(date).getTime();
if (Number.isNaN(time)) return false;
return Date.now() - time < ms;
}
@@ -8,7 +8,6 @@ import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
@@ -86,10 +85,8 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0; const noneSelected = numSelected === 0;
return ( return (
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg"> <div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
<VStack space={3} className="overflow-auto px-5 pb-6"> <VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> <table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead> <thead>
<tr> <tr>
@@ -140,9 +137,9 @@ function ExportDataDialogContent({
/> />
</DetailsBanner> </DetailsBanner>
</VStack> </VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle"> <footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
<div> <div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest"> <Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
Create Run Button Create Run Button
</Link> </Link>
</div> </div>
@@ -10,17 +10,14 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react"; import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig"; import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication"; import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input"; import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl"; import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
@@ -38,8 +35,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
const handleChange = useCallback( const handleChange = useCallback(
async (authentication: Record<string, unknown>) => async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
await patchModel(model, { authentication }),
[model], [model],
); );
@@ -51,8 +47,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
return ( return (
<EmptyStateText> <EmptyStateText>
<p> <p>
Auth plugin not found for{" "} Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
<InlineCode>{model.authenticationType}</InlineCode>
</p> </p>
</EmptyStateText> </EmptyStateText>
); );
@@ -61,20 +56,11 @@ export function HttpAuthenticationEditor({ model }: Props) {
if (inheritedAuth == null) { if (inheritedAuth == null) {
if (model.model === "workspace" || model.model === "folder") { if (model.model === "workspace" || model.model === "folder") {
return ( return (
<EmptyStateText className="flex-col gap-3"> <EmptyStateText className="flex-col gap-1">
<div className="not-italic flex flex-col items-center gap-3 text-center"> <p>
<p className="max-w-md text-sm text-text-subtle"> Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
Choose an auth method to apply it to all requests in{" "}
<strong className="font-semibold text-text-subtle">
{resolvedModelName(model)}
</strong>
.
</p> </p>
<AuthenticationTypeDropdown model={model} /> <Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
</div>
</EmptyStateText> </EmptyStateText>
); );
} }
@@ -97,8 +83,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
type="submit" type="submit"
className="underline hover:text-text" className="underline hover:text-text"
onClick={() => { onClick={() => {
if (inheritedAuth.model === "folder") if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth"); else openWorkspaceSettings("auth");
}} }}
> >
@@ -118,8 +103,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
hideLabel hideLabel
name="enabled" name="enabled"
value={ value={
model.authentication.disabled === false || model.authentication.disabled === false || model.authentication.disabled == null
model.authentication.disabled == null
? "__TRUE__" ? "__TRUE__"
: model.authentication.disabled === true : model.authentication.disabled === true
? "__FALSE__" ? "__FALSE__"
@@ -167,9 +151,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
className="w-full" className="w-full"
stateKey={`auth.${model.id}.dynamic`} stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled} value={model.authentication.disabled}
onChange={(v) => onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
handleChange({ ...model.authentication, disabled: v })
}
/> />
</div> </div>
)} )}
@@ -187,33 +169,6 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
} }
function AuthenticationTypeDropdown({ model }: Props) {
const options = useAuthDropdownOptions(model);
if (options == null) return null;
return (
<RadioDropdown
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
color="secondary"
variant="border"
size="sm"
rightSlot={
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
}
>
Select Auth
</Button>
</RadioDropdown>
);
}
function AuthenticationDisabledInput({ function AuthenticationDisabledInput({
value, value,
onChange, onChange,
@@ -243,11 +198,7 @@ function AuthenticationDisabledInput({
rightSlot={ rightSlot={
<div className="px-1 flex items-center"> <div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap"> <div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending {rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
? "loading"
: rendered.data
? "enabled"
: "disabled"}
</div> </div>
</div> </div>
} }
@@ -1,7 +1,6 @@
import { VStack } from "@yaakapp-internal/ui"; import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile"; import { SelectFile } from "./SelectFile";
@@ -15,8 +14,6 @@ export function ImportDataDialog({ importData }: Props) {
return ( return (
<VStack space={5} className="pb-4"> <VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}> <VStack space={1}>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li> <li>OpenAPI 3.0, 3.1</li>
@@ -13,7 +13,6 @@ import {
modelSupportsSetting, modelSupportsSetting,
type RequestSettingDefinition, type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS, SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT, SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES, SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES, SETTING_STORE_COOKIES,
@@ -23,44 +22,21 @@ import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { import {
SettingOverrideRow, SettingOverrideRow,
SettingRow,
SettingRowBoolean, SettingRowBoolean,
SettingRowNumber,
SettingsList, SettingsList,
SettingsSection, SettingsSection,
} from "./core/SettingRow"; } from "./core/SettingRow";
const BYTES_PER_MB = 1024 * 1024;
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
interface Props { interface Props {
showSectionTitles?: boolean; showSectionTitles?: boolean;
model: ModelWithSettings; model: ModelWithSettings;
} }
type ModelWithSettings = type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest; type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings = type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
| Workspace type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting; type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting; type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = { type CookieSettingsPatch = {
@@ -74,19 +50,12 @@ type HttpSettingsPatch = {
type TlsSettingsPatch = { type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"]; settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
}; };
type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
model,
showSectionTitles = false,
}: Props) {
const ancestors = useModelAncestors(model); const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model); const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model); const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model); const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return ( return (
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
@@ -108,22 +77,6 @@ export function ModelSettingsEditor({
} }
/> />
)} )}
{supportsMessageSizeSettings && (
<MessageSizeSettingRow
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
setting={model.settingRequestMessageSize}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
model.settingRequestMessageSize,
)}
onChange={(settingRequestMessageSize) =>
patchMessageSizeSettings(model, {
settingRequestMessageSize,
})
}
/>
)}
<BooleanSettingRow <BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES} settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates} setting={model.settingValidateCertificates}
@@ -157,9 +110,7 @@ export function ModelSettingsEditor({
</SettingsSection> </SettingsSection>
)} )}
{supportsCookieSettings && ( {supportsCookieSettings && (
<SettingsSection <SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<BooleanSettingRow <BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES} settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies} setting={model.settingSendCookies}
@@ -207,103 +158,46 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout); settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
} }
if (modelSupportsMessageSizeSettings(model)) { return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
settings.push(model.settingRequestMessageSize); .length;
} }
return settings.filter( function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
(setting) => isInheritedSetting(setting) && setting.enabled === true, if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
).length; if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
} if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
if (model.model === "websocket_request")
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>); return patchModel(model, patch as Partial<WebsocketRequest>);
} throw new Error("Unsupported cookie settings model");
} }
function patchHttpSettings( function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
model: ModelWithHttpSettings, if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
patch: Partial<HttpSettingsPatch>, if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>); return patchModel(model, patch as Partial<HttpRequest>);
} }
}
function patchTlsSettings( function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
model: ModelWithTlsSettings, if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
patch: Partial<TlsSettingsPatch>, if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
) { if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
switch (model.model) { if (model.model === "websocket_request")
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "http_request":
return patchModel(model, patch as Partial<HttpRequest>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>); return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>); return patchModel(model, patch as Partial<GrpcRequest>);
} }
}
function patchMessageSizeSettings( function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
model: ModelWithMessageSizeSettings,
patch: Partial<MessageSizeSettingsPatch>,
) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
case "folder":
return patchModel(model, patch as Partial<Folder>);
case "websocket_request":
return patchModel(model, patch as Partial<WebsocketRequest>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT); return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
} }
function modelSupportsCookieSettings( function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
model: ModelWithSettings,
): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES); return modelSupportsSetting(model, SETTING_SEND_COOKIES);
} }
function modelSupportsTlsSettings( function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
model: ModelWithSettings,
): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES); return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
} }
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({ function BooleanSettingRow({
inheritedValue, inheritedValue,
setting, setting,
@@ -317,11 +211,7 @@ function BooleanSettingRow({
}) { }) {
const inherited = isInheritedSetting(setting); const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false; const overridden = inherited ? setting.enabled === true : false;
const value = inherited const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) { if (!inherited) {
return ( return (
@@ -365,189 +255,48 @@ function IntegerSettingRow({
}) { }) {
const inherited = isInheritedSetting(setting); const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false; const overridden = inherited ? setting.enabled === true : false;
const value = inherited const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) { if (!inherited) {
return ( return (
<SettingRow <SettingRowNumber
name={settingDefinition.modelKey}
title={settingDefinition.title} title={settingDefinition.title}
description={settingDefinition.description} description={settingDefinition.description}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) => onChange(parseInteger(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseInteger(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) => onChange(parseMegabytes(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseMegabytes(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeInput({
label,
name,
onChange,
placeholder,
value,
}: {
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<NumberUnitInput
name={name}
label={label}
unit="MB"
value={value} value={value}
inputMode="decimal" placeholder={`${settingDefinition.defaultValue}`}
step="any" validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
placeholder={placeholder} onChange={(value) => onChange(value)}
validate={isValidMegabytes}
onChange={onChange}
/> />
); );
} }
function NumberUnitInput({
inputMode,
label,
name,
onChange,
placeholder,
step,
unit,
validate,
value,
}: {
inputMode?: "decimal" | "numeric";
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
step?: number | "any";
unit: string;
validate: (value: string) => boolean;
value: string;
}) {
return ( return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<PlainInput <PlainInput
hideLabel hideLabel
name={name} name={settingDefinition.modelKey}
label={label} label={settingDefinition.title}
size="sm" size="sm"
type="number" type="number"
inputMode={inputMode} placeholder={`${settingDefinition.defaultValue}`}
step={step} defaultValue={`${value}`}
placeholder={placeholder}
defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="!w-48" containerClassName="!w-48"
validate={validate} validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
rightSlot={ onChange={(value) =>
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle"> onChange({
{unit} ...setting,
</span> enabled: true,
value: Number.parseInt(value, 10) || 0,
})
} }
onChange={onChange}
/> />
</SettingOverrideRow>
); );
} }
@@ -559,7 +308,7 @@ function isInheritedSetting<T>(
function resolveInheritedValue( function resolveInheritedValue(
ancestors: (Folder | Workspace)[], ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout" | "settingRequestMessageSize", key: "settingRequestTimeout",
fallback: IntegerSetting, fallback: IntegerSetting,
): number; ): number;
function resolveInheritedValue( function resolveInheritedValue(
@@ -589,46 +338,10 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick< type WorkspaceSettings = Pick<
Workspace, Workspace,
| "settingFollowRedirects" | "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout" | "settingRequestTimeout"
| "settingSendCookies" | "settingSendCookies"
| "settingStoreCookies" | "settingStoreCookies"
| "settingValidateCertificates" | "settingValidateCertificates"
>; >;
type BooleanWorkspaceSettingKey = Exclude< type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
const megabytes = Number(value);
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
}
function parseInteger(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
}
function isValidInteger(value: string) {
const parsed = Number(value);
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
}
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
}
@@ -4,7 +4,6 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useRef } from "react"; import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm"; import { showConfirmDelete } from "../../lib/confirm";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
import { DetailsBanner } from "../core/DetailsBanner"; import { DetailsBanner } from "../core/DetailsBanner";
@@ -233,8 +232,6 @@ export function SettingsCertificates() {
</HStack> </HStack>
</div> </div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && ( {certificates.length > 0 && (
<VStack space={3}> <VStack space={3}>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => (
@@ -2,15 +2,22 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui"; import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import {
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../../lib/requestSettings";
import { revealInFinderText } from "../../lib/reveal"; import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { import {
ModelSettingRowBoolean, ModelSettingRowBoolean,
ModelSettingRowNumber,
ModelSettingSelectControl, ModelSettingSelectControl,
SettingValue, SettingValue,
SettingRow, SettingRow,
@@ -20,29 +27,20 @@ import {
SettingsSection, SettingsSection,
} from "../core/SettingRow"; } from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() { export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates(); const checkForUpdates = useCheckForUpdates();
if (settings == null) { if (settings == null || workspace == null) {
return null; return null;
} }
const showWorkspaceSettingsMovedBanner =
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={1.5} className="mb-4">
<div> <div className="mb-4">
<Heading>General</Heading> <Heading>General</Heading>
<p className="text-text-subtle"> <p className="text-text-subtle">Configure general settings for update behavior and more.</p>
Configure general settings for update behavior and more.
</p>
</div>
<div className="mt-3 mb-5">
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
</div> </div>
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
<CargoFeature feature="updater"> <CargoFeature feature="updater">
@@ -78,9 +76,7 @@ export function SettingsGeneral() {
description="Choose whether updates are installed automatically or manually." description="Choose whether updates are installed automatically or manually."
name="autoupdate" name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"} value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) => onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
patchModel(settings, { autoupdate: v === "auto" })
}
options={[ options={[
{ label: "Automatic", value: "auto" }, { label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" }, { label: "Manual", value: "manual" },
@@ -112,19 +108,54 @@ export function SettingsGeneral() {
</SettingsSection> </SettingsSection>
</CargoFeature> </CargoFeature>
{showWorkspaceSettingsMovedBanner && ( <SettingsSection
<DismissibleBanner title={
id="workspace-settings-moved-2026-06-30" <>
color="info" Workspace{" "}
className="p-4 max-w-xl mx-auto" <span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
{workspace.name}
</span>
</>
}
> >
<p> <ModelSettingRowNumber
Workspace specific settings have moved to{" "} model={workspace}
<b>Workspace Settings</b>, accessible from the workspace switcher modelKey={SETTING_REQUEST_TIMEOUT.modelKey}
menu. title={SETTING_REQUEST_TIMEOUT.title}
</p> description={SETTING_REQUEST_TIMEOUT.description}
</DismissibleBanner> placeholder={`${SETTING_REQUEST_TIMEOUT.defaultValue}`}
)} required
validate={(value) => Number.parseInt(value, 10) >= 0}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_VALIDATE_CERTIFICATES.modelKey}
title={SETTING_VALIDATE_CERTIFICATES.title}
description={SETTING_VALIDATE_CERTIFICATES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_FOLLOW_REDIRECTS.modelKey}
title={SETTING_FOLLOW_REDIRECTS.title}
description={SETTING_FOLLOW_REDIRECTS.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_SEND_COOKIES.modelKey}
title={SETTING_SEND_COOKIES.title}
description={SETTING_SEND_COOKIES.description}
/>
<ModelSettingRowBoolean
model={workspace}
modelKey={SETTING_STORE_COOKIES.modelKey}
title={SETTING_STORE_COOKIES.title}
description={SETTING_STORE_COOKIES.description}
/>
</SettingsSection>
<SettingsSection title="App Info"> <SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version."> <SettingRow title="Version" description="Current Yaak version.">
@@ -8,7 +8,6 @@ import { useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri"; import { invokeCmd } from "../../lib/tauri";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
@@ -253,9 +252,7 @@ function LicenseSettings({ settings }: { settings: Settings }) {
</p> </p>
<p> <p>
Licenses help keep Yaak independent and sustainable.{" "} Licenses help keep Yaak independent and sustainable.{" "}
<Link href={pricingUrl("app.license.badge-hide-confirm")}> <Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
Purchase a License
</Link>
</p> </p>
</VStack> </VStack>
), ),
@@ -6,7 +6,6 @@ import { formatDate } from "date-fns/format";
import { useState } from "react"; import { useState } from "react";
import { useToggle } from "../../hooks/useToggle"; import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize"; import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
@@ -49,7 +48,7 @@ function SettingsLicenseCmp() {
<span className="opacity-50">Personal use is always free, forever.</span> <span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}> <Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -69,7 +68,7 @@ function SettingsLicenseCmp() {
</span> </span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}> <Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -135,7 +134,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")} onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Direct Support Direct Support
@@ -151,7 +150,9 @@ function SettingsLicenseCmp() {
color="primary" color="primary"
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
onClick={() => onClick={() =>
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`)) openUrl(
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
)
} }
> >
Purchase License Purchase License
@@ -2,7 +2,6 @@ import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models"; import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { import {
SettingRowBoolean, SettingRowBoolean,
SettingRowSelect, SettingRowSelect,
@@ -34,7 +33,6 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure. traffic, or routing through specific infrastructure.
</p> </p>
</div> </div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
<SettingsSection title="Proxy"> <SettingsSection title="Proxy">
<SettingRowSelect <SettingRowSelect
@@ -7,7 +7,6 @@ import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData"; import { importData } from "../lib/importData";
import { pricingUrl } from "../lib/pricingUrl";
import type { DropdownRef } from "./core/Dropdown"; import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui"; import { Icon } from "@yaakapp-internal/ui";
@@ -77,8 +76,7 @@ export function SettingsDropdown() {
hidden: check.data == null || check.data.status === "active", hidden: check.data == null || check.data.status === "active",
leftSlot: <Icon icon="circle_dollar_sign" />, leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />, rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () => onSelect: () => openUrl("https://yaak.app/pricing"),
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
}, },
{ {
label: "Install CLI", label: "Install CLI",
@@ -105,18 +105,10 @@ function WebsocketEventRow({
: ""; : "";
const iconColor = const iconColor =
messageType === "error" messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary";
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
const icon = const icon =
messageType === "error" messageType === "close" || messageType === "open"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info" ? "info"
: isServer : isServer
? "arrow_big_down_dash" ? "arrow_big_down_dash"
@@ -127,8 +119,6 @@ function WebsocketEventRow({
"Disconnected from server" "Disconnected from server"
) : messageType === "open" ? ( ) : messageType === "open" ? (
"Connected to server" "Connected to server"
) : messageType === "error" ? (
<span className="text-warning">{message}</span>
) : message === "" ? ( ) : message === "" ? (
<em className="italic text-text-subtlest">No content</em> <em className="italic text-text-subtlest">No content</em>
) : ( ) : (
@@ -180,8 +170,6 @@ function WebsocketEventDetail({
? "Connection Closed" ? "Connection Closed"
: event.messageType === "open" : event.messageType === "open"
? "Connection Open" ? "Connection Open"
: event.messageType === "error"
? "WebSocket Error"
: `Message ${event.isServer ? "Received" : "Sent"}`; : `Message ${event.isServer ? "Received" : "Sent"}`;
const actions: EventDetailAction[] = const actions: EventDetailAction[] =
@@ -112,9 +112,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
onCreateNewWorkspace={hide} onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })} onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/> />
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" /> <WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</SettingsSection> </SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles /> <ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList> </SettingsList>
@@ -1,73 +1,39 @@
import type { Color } from "@yaakapp-internal/plugins"; import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui"; import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui"; import { Banner, HStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button"; import { Button } from "./Button";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
onDismiss,
onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
onDismiss?: () => void | Promise<void>; actions?: { label: string; onClick: () => void; color?: Color }[];
onShow?: () => void | Promise<void>;
actions?: {
label: string;
onClick: () => void;
color?: Color;
variant?: ButtonProps["variant"];
}[];
}) { }) {
const { const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
isLoading,
set: setDismissed,
value: dismissed,
} = useKeyValue<boolean>({
namespace: "global", namespace: "global",
key: ["dismiss-banner", id], key: ["dismiss-banner", id],
fallback: false, fallback: false,
}); });
const shouldShow = !isLoading && !dismissed; if (dismissed) return null;
useEffect(() => {
if (shouldShow) {
Promise.resolve(onShow?.()).catch(console.error);
}
}, [onShow, shouldShow]);
if (!shouldShow) return null;
return ( return (
<Banner className={classNames(className, "relative")} {...props}> <Banner
<div className="@container"> className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3"> {...props}
{children}
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<Button
variant="border"
color={props.color}
size="xs"
onClick={() => {
setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error);
}}
title="Dismiss message"
> >
Dismiss {children}
</Button> <HStack space={1.5}>
{actions?.map((a) => ( {actions?.map((a) => (
<Button <Button
key={a.label} key={a.label}
variant={a.variant ?? "border"} variant="border"
color={a.color ?? props.color} color={a.color ?? props.color}
size="xs" size="xs"
onClick={a.onClick} onClick={a.onClick}
@@ -76,9 +42,16 @@ export function DismissibleBanner({
{a.label} {a.label}
</Button> </Button>
))} ))}
</div> <Button
</div> variant="border"
</div> color={props.color}
size="xs"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
>
Dismiss
</Button>
</HStack>
</Banner> </Banner>
); );
} }
@@ -1,6 +1,6 @@
import { HStack } from "@yaakapp-internal/ui"; import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react"; import type { FocusEvent, HTMLAttributes, ReactNode } from "react";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -28,9 +28,10 @@ export type PlainInputProps = Omit<
| "extraExtensions" | "extraExtensions"
| "forcedEnvironmentId" | "forcedEnvironmentId"
> & > &
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & { Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & {
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"]; onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number"; type?: "text" | "password" | "number";
step?: number;
hideObscureToggle?: boolean; hideObscureToggle?: boolean;
labelRightSlot?: ReactNode; labelRightSlot?: ReactNode;
}; };
@@ -51,7 +52,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
labelClassName, labelClassName,
labelPosition = "top", labelPosition = "top",
labelRightSlot, labelRightSlot,
inputMode,
leftSlot, leftSlot,
name, name,
onBlur, onBlur,
@@ -64,7 +64,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
required, required,
rightSlot, rightSlot,
size = "md", size = "md",
step,
tint, tint,
type = "text", type = "text",
validate, validate,
@@ -205,14 +204,12 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))} onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")} className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
required={required} required={required}
step={step}
placeholder={placeholder} placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture} onKeyDownCapture={onKeyDownCapture}
/> />
@@ -16,7 +16,6 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast"; import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync"; import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox"; import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
@@ -206,8 +205,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal" layout="horizontal"
defaultRatio={0.6} defaultRatio={0.6}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3"> <div style={style} className="h-full px-4">
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
<SplitLayout <SplitLayout
storageKey="commit-vertical" storageKey="commit-vertical"
layout="vertical" layout="vertical"
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
title: "Add Remote", title: "Add Remote",
inputs: [ inputs: [
{ type: "text", label: "Name", name: "name", defaultValue: defaultName }, { type: "text", label: "Name", name: "name", defaultValue: defaultName },
{ type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" }, { type: "text", label: "URL", name: "url" },
], ],
}); });
if (r == null) throw new Error("Cancelled remote prompt"); if (r == null) throw new Error("Cancelled remote prompt");
+25 -62
View File
@@ -5,7 +5,6 @@ import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip"; import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs"; import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize"; import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm"; import { showConfirm } from "../lib/confirm";
@@ -15,37 +14,19 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication"; import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors"; import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>( export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
tabValue: T,
model: AuthenticatedModel | null,
) {
const options = useAuthDropdownOptions(model);
return useMemo<TabItem[]>(() => {
if (model == null || options == null) return [];
const tab: TabItem = {
value: tabValue,
label: "Auth",
options,
};
return [tab];
}, [model, options, tabValue]);
}
export function useAuthDropdownOptions(
model: AuthenticatedModel | null,
): Omit<RadioDropdownProps, "children"> | null {
const authentication = useHttpAuthenticationSummaries(); const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model); const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model); const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null; const parentModel = ancestors[0] ?? null;
return useMemo(() => { return useMemo<TabItem[]>(() => {
if (model == null) return null; if (model == null) return [];
return { const tab: TabItem = {
value: tabValue,
label: "Auth",
options: {
value: model.authenticationType, value: model.authenticationType,
items: [ items: [
...authentication.map((a) => ({ ...authentication.map((a) => ({
@@ -57,12 +38,10 @@ export function useAuthDropdownOptions(
{ {
label: "Inherit from Parent", label: "Inherit from Parent",
shortLabel: shortLabel:
inheritedAuth != null && inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? (
inheritedAuth.authenticationType !== "none" ? (
<HStack space={1.5}> <HStack space={1.5}>
{authentication.find( {authentication.find((a) => a.name === inheritedAuth.authenticationType)
(a) => a.name === inheritedAuth.authenticationType, ?.shortLabel ?? "UNKNOWN"}
)?.shortLabel ?? "UNKNOWN"}
<IconTooltip <IconTooltip
icon="zap_off" icon="zap_off"
iconSize="xs" iconSize="xs"
@@ -79,11 +58,7 @@ export function useAuthDropdownOptions(
itemsAfter: (() => { itemsAfter: (() => {
const actions: ( const actions: (
| { type: "separator"; label: string } | { type: "separator"; label: string }
| { | { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
label: string;
leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
}
)[] = []; )[] = [];
// Promote: move auth from current model up to parent // Promote: move auth from current model up to parent
@@ -91,8 +66,7 @@ export function useAuthDropdownOptions(
parentModel && parentModel &&
model.authenticationType && model.authenticationType &&
model.authenticationType !== "none" && model.authenticationType !== "none" &&
(parentModel.authenticationType == null || (parentModel.authenticationType == null || parentModel.authenticationType === "none")
parentModel.authenticationType === "none")
) { ) {
actions.push( actions.push(
{ type: "separator", label: "Actions" }, { type: "separator", label: "Actions" },
@@ -100,11 +74,7 @@ export function useAuthDropdownOptions(
label: `Promote to ${capitalize(parentModel.model)}`, label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: ( leftSlot: (
<Icon <Icon
icon={ icon={parentModel.model === "workspace" ? "corner_right_up" : "folder_up"}
parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
}
/> />
), ),
onSelect: async () => { onSelect: async () => {
@@ -120,10 +90,7 @@ export function useAuthDropdownOptions(
), ),
}); });
if (confirmed) { if (confirmed) {
await patchModel(model, { await patchModel(model, { authentication: {}, authenticationType: null });
authentication: {},
authenticationType: null,
});
await patchModel(parentModel, { await patchModel(parentModel, {
authentication: model.authentication, authentication: model.authentication,
authenticationType: model.authenticationType, authenticationType: model.authenticationType,
@@ -142,8 +109,7 @@ export function useAuthDropdownOptions(
// Copy from ancestor: copy auth config down to current model // Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find( const ancestorWithAuth = ancestors.find(
(a) => (a) => a.authenticationType != null && a.authenticationType !== "none",
a.authenticationType != null && a.authenticationType !== "none",
); );
if (ancestorWithAuth) { if (ancestorWithAuth) {
if (actions.length === 0) { if (actions.length === 0) {
@@ -154,9 +120,7 @@ export function useAuthDropdownOptions(
leftSlot: ( leftSlot: (
<Icon <Icon
icon={ icon={
ancestorWithAuth.model === "workspace" ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down"
? "corner_right_down"
: "folder_down"
} }
/> />
), ),
@@ -168,15 +132,11 @@ export function useAuthDropdownOptions(
description: ( description: (
<> <>
Copy{" "} Copy{" "}
{authentication.find( {authentication.find((a) => a.name === ancestorWithAuth.authenticationType)
(a) => a.name === ancestorWithAuth.authenticationType, ?.label ?? "authentication"}{" "}
)?.label ?? "authentication"}{" "} config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
config from{" "} This will override the current authentication but will not affect the{" "}
<InlineCode> {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
{resolvedModelName(ancestorWithAuth)}
</InlineCode>
? This will override the current authentication but will not
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</> </>
), ),
}); });
@@ -201,6 +161,9 @@ export function useAuthDropdownOptions(
} }
await patchModel(model, { authentication, authenticationType }); await patchModel(model, { authentication, authenticationType });
}, },
},
}; };
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
} }
-3
View File
@@ -1,3 +0,0 @@
export function pricingUrl(intent: string): string {
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
}
+4 -24
View File
@@ -5,7 +5,6 @@ type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick< type WorkspaceRequestSettings = Pick<
Workspace, Workspace,
| "settingFollowRedirects" | "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout" | "settingRequestTimeout"
| "settingSendCookies" | "settingSendCookies"
| "settingStoreCookies" | "settingStoreCookies"
@@ -18,9 +17,7 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never; [M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType]; }[ModelType];
export type RequestSettingDefinition< export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K]; defaultValue: WorkspaceRequestSettings[K];
description: string; description: string;
modelKey: K; modelKey: K;
@@ -44,26 +41,11 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
title: "Request Timeout", title: "Request Timeout",
}); });
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description:
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true, defaultValue: true,
description: "When disabled, skip validation of server certificates.", description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates", modelKey: "settingValidateCertificates",
models: [ models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates", title: "Validate TLS certificates",
}); });
@@ -77,8 +59,7 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
export const SETTING_SEND_COOKIES = defineRequestSetting({ export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true, defaultValue: true,
description: description: "Attach matching cookies from the active cookie jar to outgoing requests.",
"Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies", modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"], models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies", title: "Automatically send cookies",
@@ -86,8 +67,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({ export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true, defaultValue: true,
description: description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
"Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies", modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"], models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies", title: "Automatically store cookies",
+3 -3
View File
@@ -66,7 +66,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-pdf": "^10.0.1", "react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.1", "react-use": "^17.6.0",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -102,11 +102,11 @@
"postcss-nesting": "^13.0.2", "postcss-nesting": "^13.0.2",
"rollup": "^4.60.3", "rollup": "^4.60.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1", "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
"vite-plugin-static-copy": "^3.3.0", "vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0", "vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.2.1" "vite-plus": "^0.1.20"
} }
} }
-1
View File
@@ -39,7 +39,6 @@ export default defineConfig(async () => {
}), }),
], ],
build: { build: {
target: "esnext",
sourcemap: true, sourcemap: true,
outDir: "../../dist/apps/yaak-client", outDir: "../../dist/apps/yaak-client",
emptyOutDir: true, emptyOutDir: true,
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1", "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
"vite-plus": "^0.2.1" "vite-plus": "^0.1.20"
} }
} }
+1 -5
View File
@@ -2,7 +2,7 @@ use crate::models_ext::QueryManagerExt;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use log::debug; use log::debug;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::{AppHandle, Runtime, is_dev}; use tauri::{AppHandle, Runtime};
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
const NAMESPACE: &str = "analytics"; const NAMESPACE: &str = "analytics";
@@ -36,10 +36,6 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
..Default::default() ..Default::default()
}; };
if is_dev() {
info.current_version = "0.0.1".to_string();
}
app_handle app_handle
.with_tx(|tx| { .with_tx(|tx| {
// Load the previously tracked version // Load the previously tracked version
+9 -15
View File
@@ -295,8 +295,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let resolved_settings = let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -333,7 +332,6 @@ async fn cmd_grpc_reflect<R: Runtime>(
&metadata, &metadata,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_certificate, client_certificate,
resolved_settings.request_message_size.value,
) )
.await .await
.map_err(|e| GenericError(e.to_string()))?) .map_err(|e| GenericError(e.to_string()))?)
@@ -355,8 +353,7 @@ async fn cmd_grpc_go<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let resolved_settings = let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone()); let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -428,7 +425,6 @@ async fn cmd_grpc_go<R: Runtime>(
&metadata, &metadata,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_cert.clone(), client_cert.clone(),
resolved_settings.request_message_size.value,
) )
.await; .await;
@@ -718,7 +714,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Request failed".to_string(), content: "Failed to connect".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -726,7 +722,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Request failed".to_string(), content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -742,7 +738,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Request failed".to_string(), content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -785,7 +781,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Stream failed".to_string(), content: "Failed to connect".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -793,7 +789,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(), content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -810,7 +806,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(), content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -882,8 +878,7 @@ async fn cmd_grpc_go<R: Runtime>(
.db() .db()
.upsert_grpc_event( .upsert_grpc_event(
&GrpcEvent { &GrpcEvent {
content: "Stream failed".to_string(), content: status.to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32), status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()), metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
@@ -892,7 +887,6 @@ async fn cmd_grpc_go<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
) )
.unwrap(); .unwrap();
break;
} }
} }
} }
@@ -79,7 +79,7 @@ impl YaakNotifier {
return Ok(()); return Ok(());
} }
info!("Checking for notifications"); debug!("Checking for notifications");
#[cfg(feature = "license")] #[cfg(feature = "license")]
let license_check = { let license_check = {
@@ -115,20 +115,17 @@ impl YaakNotifier {
]); ]);
let resp = req.send().await?; let resp = req.send().await?;
if resp.status() != 200 { if resp.status() != 200 {
info!("Skipping notification status code {}", resp.status()); debug!("Skipping notification status code {}", resp.status());
return Ok(()); return Ok(());
} }
let notifications = resp.json::<Vec<YaakNotification>>().await?; for notification in resp.json::<Vec<YaakNotification>>().await? {
debug!("Received {} notifications", notifications.len());
for notification in notifications {
let seen = get_kv(app_handle).await?; let seen = get_kv(app_handle).await?;
if seen.contains(&notification.id) { if seen.contains(&notification.id) {
debug!("Already seen notification {}", notification.id); debug!("Already seen notification {}", notification.id);
continue; continue;
} }
info!("Got notification {:?}", notification); debug!("Got notification {:?}", notification);
let _ = app_handle.emit_to(window.label(), "notification", notification.clone()); let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
break; // Only show one notification break; // Only show one notification
+1 -33
View File
@@ -50,37 +50,6 @@ pub async fn cmd_ws_send<R: Runtime>(
ws_manager: State<'_, Mutex<WebsocketManager>>, ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
let connection = app_handle.db().get_websocket_connection(connection_id)?; let connection = app_handle.db().get_websocket_connection(connection_id)?;
match send_websocket_message(&connection, environment_id, &app_handle, &window, &ws_manager)
.await
{
Ok(connection) => Ok(connection),
Err(e) => {
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: connection.request_id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Error,
message: e.to_string().into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
Ok(connection)
}
}
}
async fn send_websocket_message<R: Runtime>(
connection: &WebsocketConnection,
environment_id: Option<&str>,
app_handle: &AppHandle<R>,
window: &WebviewWindow<R>,
ws_manager: &Mutex<WebsocketManager>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?; let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
let environment_chain = app_handle.db().resolve_environments( let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id, &unrendered_request.workspace_id,
@@ -122,7 +91,7 @@ async fn send_websocket_message<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
)?; )?;
Ok(connection.clone()) Ok(connection)
} }
#[command] #[command]
@@ -330,7 +299,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
receive_tx, receive_tx,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_cert, client_cert,
resolved_settings.request_message_size.value,
) )
.await .await
{ {
-4
View File
@@ -46,7 +46,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GrpcRequest = { export type GrpcRequest = {
@@ -70,7 +69,6 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -148,7 +146,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -165,7 +162,6 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+3 -9
View File
@@ -33,21 +33,15 @@ impl AutoReflectionClient {
uri: &Uri, uri: &Uri,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<Self> { ) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin( let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?, get_transport(validate_certificates, client_cert.clone())?,
uri.clone(), uri.clone(),
) );
.max_decoding_message_size(max_message_size) let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
.max_encoding_message_size(max_message_size);
let client_v1alpha =
v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?, get_transport(validate_certificates, client_cert.clone())?,
uri.clone(), uri.clone(),
) );
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha }) Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
} }
+12 -80
View File
@@ -39,7 +39,6 @@ pub struct GrpcConnection {
conn: Client<HttpsConnector<HttpConnector>, BoxBody>, conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
pub uri: Uri, pub uri: Uri,
use_reflection: bool, use_reflection: bool,
max_message_size: usize,
} }
#[derive(Default, Debug)] #[derive(Default, Debug)]
@@ -98,14 +97,7 @@ impl GrpcConnection {
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> { ) -> Result<Response<DynamicMessage>> {
if self.use_reflection { if self.use_reflection {
reflect_types_for_message( reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
self.pool.clone(),
&self.uri,
message,
metadata,
client_cert,
self.max_message_size,
)
.await?; .await?;
} }
let method = &self.method(&service, &method).await?; let method = &self.method(&service, &method).await?;
@@ -115,7 +107,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -140,7 +132,6 @@ impl GrpcConnection {
message, message,
metadata, metadata,
client_cert, client_cert,
self.max_message_size,
) )
.await?; .await?;
@@ -180,7 +171,6 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -193,15 +183,8 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = reflect_types_for_message( if let Err(e) =
pool, reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -223,7 +206,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -254,7 +237,6 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -267,15 +249,8 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = reflect_types_for_message( if let Err(e) =
pool, reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -297,7 +272,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -325,7 +300,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size); let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -337,23 +312,6 @@ impl GrpcConnection {
} }
} }
fn grpc_client(
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
uri: Uri,
max_message_size: usize,
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
tonic::client::Grpc::with_origin(conn, uri)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size)
}
fn message_size_limit(setting: i32) -> usize {
match setting.try_into() {
Ok(0) | Err(_) => usize::MAX,
Ok(limit) => limit,
}
}
/// Configuration for GrpcHandle to compile proto files /// Configuration for GrpcHandle to compile proto files
#[derive(Clone)] #[derive(Clone)]
pub struct GrpcConfig { pub struct GrpcConfig {
@@ -390,7 +348,6 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<bool> { ) -> Result<bool> {
let server_reflection = proto_files.is_empty(); let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files); let key = make_pool_key(id, uri, proto_files);
@@ -402,14 +359,7 @@ impl GrpcHandle {
let pool = if server_reflection { let pool = if server_reflection {
let full_uri = uri_from_str(uri)?; let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection( fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await
&full_uri,
metadata,
validate_certificates,
client_cert,
message_size_limit(request_message_size),
)
.await
} else { } else {
fill_pool_from_files(&self.config, proto_files).await fill_pool_from_files(&self.config, proto_files).await
}?; }?;
@@ -426,20 +376,11 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Vec<ServiceDefinition>> { ) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing // Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri); info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect( self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert,
request_message_size,
)
.await?; .await?;
} }
@@ -480,10 +421,8 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<GrpcConnection> { ) -> Result<GrpcConnection> {
let use_reflection = proto_files.is_empty(); let use_reflection = proto_files.is_empty();
let max_message_size = message_size_limit(request_message_size);
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
self.reflect( self.reflect(
id, id,
@@ -492,7 +431,6 @@ impl GrpcHandle {
metadata, metadata,
validate_certificates, validate_certificates,
client_cert.clone(), client_cert.clone(),
request_message_size,
) )
.await?; .await?;
} }
@@ -502,13 +440,7 @@ impl GrpcHandle {
.clone(); .clone();
let uri = uri_from_str(uri)?; let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates, client_cert.clone())?; let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection { Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri })
pool: Arc::new(RwLock::new(pool)),
use_reflection,
conn,
uri,
max_message_size,
})
} }
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> { fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
+3 -7
View File
@@ -119,11 +119,9 @@ pub async fn fill_pool_from_reflection(
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<DescriptorPool> { ) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new(); let mut pool = DescriptorPool::new();
let mut client = let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;
AutoReflectionClient::new(uri, validate_certificates, client_cert, max_message_size)?;
for service in list_services(&mut client, metadata).await? { for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" { if service == "grpc.reflection.v1alpha.ServerReflection" {
@@ -194,7 +192,6 @@ pub(crate) async fn reflect_types_for_message(
json: &str, json: &str,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> { ) -> Result<()> {
// 1. Collect all Any types in the JSON // 1. Collect all Any types in the JSON
let mut extra_types = Vec::new(); let mut extra_types = Vec::new();
@@ -204,7 +201,7 @@ pub(crate) async fn reflect_types_for_message(
return Ok(()); // nothing to do return Ok(()); // nothing to do
} }
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?; let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
for extra_type in extra_types { for extra_type in extra_types {
{ {
let guard = pool.read().await; let guard = pool.read().await;
@@ -242,7 +239,6 @@ pub(crate) async fn reflect_types_for_dynamic_message(
message: &DynamicMessage, message: &DynamicMessage,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> { ) -> Result<()> {
let mut extra_types = HashSet::new(); let mut extra_types = HashSet::new();
collect_any_types_from_dynamic_message(message, &mut extra_types); collect_any_types_from_dynamic_message(message, &mut extra_types);
@@ -251,7 +247,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
return Ok(()); return Ok(());
} }
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?; let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
for extra_type in extra_types { for extra_type in extra_types {
{ {
let guard = pool.read().await; let guard = pool.read().await;
+1 -6
View File
@@ -109,7 +109,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -185,7 +184,6 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -458,8 +456,7 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketMessageType = "text" | "binary"; export type WebsocketMessageType = "text" | "binary";
@@ -485,7 +482,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -502,7 +498,6 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -54,7 +54,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
} }
export async function flushAllModelWrites(): Promise<void> { export async function flushAllModelWrites(): Promise<void> {
const results = await Promise.allSettled(pendingModelWrites); const results = await Promise.allSettled([...pendingModelWrites]);
const rejected = results.find((result) => result.status === "rejected"); const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") { if (rejected?.status === "rejected") {
throw rejected.reason; throw rejected.reason;
@@ -1,7 +0,0 @@
ALTER TABLE workspaces ADD COLUMN setting_request_message_size INTEGER DEFAULT 67108864 NOT NULL;
ALTER TABLE folders ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
ALTER TABLE websocket_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
ALTER TABLE grpc_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
+1 -48
View File
@@ -21,8 +21,6 @@ use ts_rs::TS;
use yaak_database::{Result as DbResult, UpdateSource}; use yaak_database::{Result as DbResult, UpdateSource};
pub use yaak_database::{UpsertModelInfo, upsert_date}; pub use yaak_database::{UpsertModelInfo, upsert_date};
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
#[macro_export] #[macro_export]
macro_rules! impl_model { macro_rules! impl_model {
($t:ty, $variant:ident) => { ($t:ty, $variant:ident) => {
@@ -122,7 +120,6 @@ pub struct ResolvedHttpRequestSettings {
pub validate_certificates: ResolvedSetting<bool>, pub validate_certificates: ResolvedSetting<bool>,
pub follow_redirects: ResolvedSetting<bool>, pub follow_redirects: ResolvedSetting<bool>,
pub request_timeout: ResolvedSetting<i32>, pub request_timeout: ResolvedSetting<i32>,
pub request_message_size: ResolvedSetting<i32>,
pub send_cookies: ResolvedSetting<bool>, pub send_cookies: ResolvedSetting<bool>,
pub store_cookies: ResolvedSetting<bool>, pub store_cookies: ResolvedSetting<bool>,
} }
@@ -133,7 +130,6 @@ impl Default for ResolvedHttpRequestSettings {
validate_certificates: ResolvedSetting::default_source(true), validate_certificates: ResolvedSetting::default_source(true),
follow_redirects: ResolvedSetting::default_source(true), follow_redirects: ResolvedSetting::default_source(true),
request_timeout: ResolvedSetting::default_source(0), request_timeout: ResolvedSetting::default_source(0),
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
send_cookies: ResolvedSetting::default_source(true), send_cookies: ResolvedSetting::default_source(true),
store_cookies: ResolvedSetting::default_source(true), store_cookies: ResolvedSetting::default_source(true),
} }
@@ -404,8 +400,6 @@ pub struct Workspace {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub setting_follow_redirects: bool, pub setting_follow_redirects: bool,
pub setting_request_timeout: i32, pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32,
#[serde(default)] #[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>, pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -451,7 +445,6 @@ impl UpsertModelInfo for Workspace {
(EncryptionKeyChallenge, self.encryption_key_challenge.into()), (EncryptionKeyChallenge, self.encryption_key_challenge.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()), (SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingRequestMessageSize, self.setting_request_message_size.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()), (SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
(SettingSendCookies, self.setting_send_cookies.into()), (SettingSendCookies, self.setting_send_cookies.into()),
@@ -470,7 +463,7 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::EncryptionKeyChallenge, WorkspaceIden::EncryptionKeyChallenge,
WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingFollowRedirects,
WorkspaceIden::SettingRequestMessageSize, WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingValidateCertificates, WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides, WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies, WorkspaceIden::SettingSendCookies,
@@ -498,7 +491,6 @@ impl UpsertModelInfo for Workspace {
authentication_type: row.get("authentication_type")?, authentication_type: row.get("authentication_type")?,
setting_follow_redirects: row.get("setting_follow_redirects")?, setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?, setting_request_timeout: row.get("setting_request_timeout")?,
setting_request_message_size: row.get("setting_request_message_size")?,
setting_validate_certificates: row.get("setting_validate_certificates")?, setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(), setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
setting_send_cookies: row.get("setting_send_cookies")?, setting_send_cookies: row.get("setting_send_cookies")?,
@@ -970,8 +962,6 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting, pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting, pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for Folder { impl UpsertModelInfo for Folder {
@@ -1019,10 +1009,6 @@ impl UpsertModelInfo for Folder {
), ),
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()), (SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()), (SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -1041,7 +1027,6 @@ impl UpsertModelInfo for Folder {
FolderIden::SettingValidateCertificates, FolderIden::SettingValidateCertificates,
FolderIden::SettingFollowRedirects, FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout, FolderIden::SettingRequestTimeout,
FolderIden::SettingRequestMessageSize,
] ]
} }
@@ -1056,7 +1041,6 @@ impl UpsertModelInfo for Folder {
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_follow_redirects: String = row.get("setting_follow_redirects")?; let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
let setting_request_timeout: String = row.get("setting_request_timeout")?; let setting_request_timeout: String = row.get("setting_request_timeout")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -1078,8 +1062,6 @@ impl UpsertModelInfo for Folder {
.unwrap_or_default(), .unwrap_or_default(),
setting_request_timeout: serde_json::from_str(&setting_request_timeout) setting_request_timeout: serde_json::from_str(&setting_request_timeout)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -1416,8 +1398,6 @@ pub struct WebsocketRequest {
pub setting_send_cookies: InheritedBoolSetting, pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting, pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for WebsocketRequest { impl UpsertModelInfo for WebsocketRequest {
@@ -1466,10 +1446,6 @@ impl UpsertModelInfo for WebsocketRequest {
SettingValidateCertificates, SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(), serde_json::to_string(&self.setting_validate_certificates)?.into(),
), ),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -1490,7 +1466,6 @@ impl UpsertModelInfo for WebsocketRequest {
WebsocketRequestIden::SettingSendCookies, WebsocketRequestIden::SettingSendCookies,
WebsocketRequestIden::SettingStoreCookies, WebsocketRequestIden::SettingStoreCookies,
WebsocketRequestIden::SettingValidateCertificates, WebsocketRequestIden::SettingValidateCertificates,
WebsocketRequestIden::SettingRequestMessageSize,
] ]
} }
@@ -1504,7 +1479,6 @@ impl UpsertModelInfo for WebsocketRequest {
let setting_send_cookies: String = row.get("setting_send_cookies")?; let setting_send_cookies: String = row.get("setting_send_cookies")?;
let setting_store_cookies: String = row.get("setting_store_cookies")?; let setting_store_cookies: String = row.get("setting_store_cookies")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -1525,8 +1499,6 @@ impl UpsertModelInfo for WebsocketRequest {
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(), setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates) setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -1537,7 +1509,6 @@ impl UpsertModelInfo for WebsocketRequest {
pub enum WebsocketEventType { pub enum WebsocketEventType {
Binary, Binary,
Close, Close,
Error,
Frame, Frame,
Open, Open,
Ping, Ping,
@@ -2068,8 +2039,6 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure) /// Server URL (http for plaintext or https for secure)
pub url: String, pub url: String,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for GrpcRequest { impl UpsertModelInfo for GrpcRequest {
@@ -2117,10 +2086,6 @@ impl UpsertModelInfo for GrpcRequest {
SettingValidateCertificates, SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(), serde_json::to_string(&self.setting_validate_certificates)?.into(),
), ),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -2140,7 +2105,6 @@ impl UpsertModelInfo for GrpcRequest {
GrpcRequestIden::Authentication, GrpcRequestIden::Authentication,
GrpcRequestIden::Metadata, GrpcRequestIden::Metadata,
GrpcRequestIden::SettingValidateCertificates, GrpcRequestIden::SettingValidateCertificates,
GrpcRequestIden::SettingRequestMessageSize,
] ]
} }
@@ -2151,7 +2115,6 @@ impl UpsertModelInfo for GrpcRequest {
let authentication: String = row.get("authentication")?; let authentication: String = row.get("authentication")?;
let metadata: String = row.get("metadata")?; let metadata: String = row.get("metadata")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -2171,8 +2134,6 @@ impl UpsertModelInfo for GrpcRequest {
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(), metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates) setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -2723,14 +2684,6 @@ fn default_true() -> bool {
true true
} }
fn default_request_message_size() -> i32 {
DEFAULT_REQUEST_MESSAGE_SIZE
}
fn default_request_message_size_setting() -> InheritedIntSetting {
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
}
fn default_http_method() -> String { fn default_http_method() -> String {
"GET".to_string() "GET".to_string()
} }
@@ -180,14 +180,6 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.request_timeout parent.request_timeout
}, },
request_message_size: if folder.setting_request_message_size.enabled {
ResolvedSetting::from_model(
folder.setting_request_message_size.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if folder.setting_send_cookies.enabled { send_cookies: if folder.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
folder.setting_send_cookies.value, folder.setting_send_cookies.value,
@@ -129,14 +129,6 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.validate_certificates parent.validate_certificates
}, },
request_message_size: if grpc_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
grpc_request.setting_request_message_size.value,
AnyModel::GrpcRequest(grpc_request.clone()),
)
} else {
parent.request_message_size
},
..parent ..parent
}) })
} }
@@ -131,7 +131,6 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.request_timeout parent.request_timeout
}, },
request_message_size: parent.request_message_size,
send_cookies: if http_request.setting_send_cookies.enabled { send_cookies: if http_request.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
http_request.setting_send_cookies.value, http_request.setting_send_cookies.value,
@@ -139,14 +139,6 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.validate_certificates parent.validate_certificates
}, },
request_message_size: if websocket_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
websocket_request.setting_request_message_size.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if websocket_request.setting_send_cookies.enabled { send_cookies: if websocket_request.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
websocket_request.setting_send_cookies.value, websocket_request.setting_send_cookies.value,
@@ -21,7 +21,6 @@ impl<'a> ClientDb<'a> {
&Workspace { &Workspace {
name: "Yaak".to_string(), name: "Yaak".to_string(),
setting_follow_redirects: true, setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true, setting_validate_certificates: true,
..Default::default() ..Default::default()
}, },
@@ -103,10 +102,6 @@ impl<'a> ClientDb<'a> {
workspace.setting_request_timeout, workspace.setting_request_timeout,
AnyModel::Workspace(workspace.clone()), AnyModel::Workspace(workspace.clone()),
), ),
request_message_size: ResolvedSetting::from_model(
workspace.setting_request_message_size,
AnyModel::Workspace(workspace.clone()),
),
send_cookies: ResolvedSetting::from_model( send_cookies: ResolvedSetting::from_model(
workspace.setting_send_cookies, workspace.setting_send_cookies,
AnyModel::Workspace(workspace.clone()), AnyModel::Workspace(workspace.clone()),
+1 -6
View File
@@ -108,7 +108,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -184,7 +183,6 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -428,8 +426,7 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { export type WebsocketRequest = {
model: "websocket_request"; model: "websocket_request";
@@ -453,7 +450,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -470,7 +466,6 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -1070,7 +1070,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest { &InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(), content: content.to_string(),
}), }),
Duration::from_secs(5), Duration::from_secs(60),
) )
.await?; .await?;
-4
View File
@@ -46,7 +46,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GrpcRequest = { export type GrpcRequest = {
@@ -70,7 +69,6 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -161,7 +159,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -178,7 +175,6 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -11
View File
@@ -20,7 +20,6 @@ pub async fn ws_connect(
headers: HeaderMap<HeaderValue>, headers: HeaderMap<HeaderValue>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> { ) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
info!("Connecting to WS {url}"); info!("Connecting to WS {url}");
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?; let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
@@ -35,7 +34,7 @@ pub async fn ws_connect(
let (stream, response) = connect_async_tls_with_config( let (stream, response) = connect_async_tls_with_config(
req, req,
Some(websocket_config(request_message_size)), Some(WebSocketConfig::default()),
false, false,
Some(Connector::Rustls(Arc::new(tls_config))), Some(Connector::Rustls(Arc::new(tls_config))),
) )
@@ -49,12 +48,3 @@ pub async fn ws_connect(
Ok((stream, response)) Ok((stream, response))
} }
fn websocket_config(request_message_size: i32) -> WebSocketConfig {
let max_message_size = message_size_limit(request_message_size);
WebSocketConfig::default().max_message_size(max_message_size).max_frame_size(max_message_size)
}
pub(crate) fn message_size_limit(setting: i32) -> Option<usize> {
setting.try_into().ok().filter(|limit| *limit > 0)
}
+2 -2
View File
@@ -4,7 +4,7 @@ use tokio_tungstenite::tungstenite;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {
#[error("{0}")] #[error("WebSocket error: {0}")]
WebSocketErr(#[from] tungstenite::Error), WebSocketErr(#[from] tungstenite::Error),
#[error(transparent)] #[error(transparent)]
@@ -16,7 +16,7 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
TlsError(#[from] yaak_tls::error::Error), TlsError(#[from] yaak_tls::error::Error),
#[error("{0}")] #[error("WebSocket error: {0}")]
GenericError(String), GenericError(String),
} }
+8 -28
View File
@@ -1,5 +1,4 @@
use crate::connect::{message_size_limit, ws_connect}; use crate::connect::ws_connect;
use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use futures_util::stream::SplitSink; use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@@ -16,16 +15,10 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig; use yaak_tls::ClientCertificateConfig;
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
struct WebsocketConnection {
max_message_size: Option<usize>,
sink: WebsocketSink,
}
#[derive(Clone)] #[derive(Clone)]
pub struct WebsocketManager { pub struct WebsocketManager {
connections: Arc<Mutex<HashMap<String, WebsocketConnection>>>, connections:
Arc<Mutex<HashMap<String, SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>>,
read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>, read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,
} }
@@ -42,20 +35,14 @@ impl WebsocketManager {
receive_tx: mpsc::Sender<Message>, receive_tx: mpsc::Sender<Message>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Response> { ) -> Result<Response> {
let tx = receive_tx.clone(); let tx = receive_tx.clone();
let max_message_size = message_size_limit(request_message_size);
let (stream, response) = let (stream, response) =
ws_connect(url, headers, validate_certificates, client_cert, request_message_size) ws_connect(url, headers, validate_certificates, client_cert).await?;
.await?;
let (write, mut read) = stream.split(); let (write, mut read) = stream.split();
self.connections self.connections.lock().await.insert(id.to_string(), write);
.lock()
.await
.insert(id.to_string(), WebsocketConnection { max_message_size, sink: write });
let handle = { let handle = {
let connection_id = id.to_string(); let connection_id = id.to_string();
@@ -83,20 +70,13 @@ impl WebsocketManager {
} }
pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> { pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> {
debug!("Send websocket message {msg:?}");
let mut connections = self.connections.lock().await; let mut connections = self.connections.lock().await;
let connection = match connections.get_mut(id) { let connection = match connections.get_mut(id) {
None => return Ok(()), None => return Ok(()),
Some(c) => c, Some(c) => c,
}; };
if let Some(limit) = connection.max_message_size { connection.send(msg).await?;
let message_size = msg.len();
if message_size > limit {
return Err(GenericError(format!(
"WebSocket message too large: found {message_size} bytes, the limit is {limit} bytes"
)));
}
}
connection.sink.send(msg).await?;
Ok(()) Ok(())
} }
@@ -104,7 +84,7 @@ impl WebsocketManager {
info!("Closing websocket"); info!("Closing websocket");
if let Some(mut connection) = self.connections.lock().await.remove(id) { if let Some(mut connection) = self.connections.lock().await.remove(id) {
// Wait a maximum of 1 second for the connection to close // Wait a maximum of 1 second for the connection to close
if let Err(e) = connection.sink.close().await { if let Err(e) = connection.close().await {
warn!("Failed to close websocket connection {e:?}"); warn!("Failed to close websocket connection {e:?}");
}; };
} }
+837 -1319
View File
File diff suppressed because it is too large Load Diff
+5 -4
View File
@@ -121,13 +121,14 @@
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1", "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
"vite-plus": "^0.2.1", "vite-plus": "^0.1.20",
"vitest": "^4.1.9" "vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
}, },
"overrides": { "overrides": {
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1" "vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
}, },
"packageManager": "npm@11.11.1" "packageManager": "npm@11.11.1"
} }
+1 -6
View File
@@ -108,7 +108,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -184,7 +183,6 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -428,8 +426,7 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { export type WebsocketRequest = {
model: "websocket_request"; model: "websocket_request";
@@ -453,7 +450,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -470,7 +466,6 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -6,7 +6,7 @@
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs" "build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
}, },
"dependencies": { "dependencies": {
"ws": "^8.21.0" "ws": "^8.20.1"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.13", "@types/node": "^24.0.13",
-3
View File
@@ -166,9 +166,6 @@ function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables { function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, { return completeFullColorVariables(theme, {
text: color.desaturate(0.5).lift(0.12).css(),
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
surface: color.translucify(0.95).css(), surface: color.translucify(0.95).css(),
surfaceHighlight: color.translucify(0.85).css(), surfaceHighlight: color.translucify(0.85).css(),
border: color.lift(0.3).translucify(0.8).css(), border: color.lift(0.3).translucify(0.8).css(),
+1 -1
View File
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^1.19.13",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25", "hono": "^4.12.14",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -10,10 +10,10 @@
"test": "vp test --run tests" "test": "vp test --run tests"
}, },
"dependencies": { "dependencies": {
"openapi-to-postmanv2": "^5.8.0",
"yaml": "^2.8.3" "yaml": "^2.8.3"
}, },
"devDependencies": { "devDependencies": {
"@types/openapi-to-postmanv2": "^5.0.0" "@types/openapi-to-postmanv2": "^5.0.0",
"openapi-to-postmanv2": "^5.8.0"
} }
} }
+810 -18
View File
@@ -1,7 +1,37 @@
import { convertPostman } from "@yaak/importer-postman/src"; import type {
import type { Context, PluginDefinition } from "@yaakapp/api"; Context,
Environment,
Folder,
HttpRequest,
HttpRequestHeader,
HttpUrlParameter,
PartialImportResources,
PluginDefinition,
Workspace,
} from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin"; import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import { convert } from "openapi-to-postmanv2"; 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">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId" | "variables">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
};
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
const BODY_CONTENT_TYPE_PREFERENCE = [
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data",
"application/xml",
"text/plain",
];
const MAX_EXAMPLE_DEPTH = 8;
const MAX_EXAMPLE_PROPERTIES = 25;
const MAX_DESCRIPTION_ITEMS = 40;
export const plugin: PluginDefinition = { export const plugin: PluginDefinition = {
importer: { importer: {
@@ -14,23 +44,785 @@ export const plugin: PluginDefinition = {
}; };
export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> { export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {
// oxlint-disable-next-line no-explicit-any const spec = parseSpec(contents);
let postmanCollection: any; if (!isOpenApiSpec(spec)) return undefined;
const importState = new ImportState(spec);
const workspace: ImportResources["workspaces"][0] = {
model: "workspace",
id: importState.generateId("workspace"),
name: stringAt(spec.info, "title") ?? "OpenAPI Import",
description: importInfoDescription(toRecord(spec.info)),
};
const resources: ImportResources = {
workspaces: [workspace],
environments: [],
folders: [],
httpRequests: [],
};
const baseUrl = importBaseUrl(spec);
const requestBaseUrl = baseUrl.length > 0 ? "${[baseUrl]}" : "";
if (baseUrl.length > 0) {
resources.environments.push({
model: "environment",
id: importState.generateId("environment"),
workspaceId: workspace.id,
name: "Global Variables",
variables: [{ name: "baseUrl", value: baseUrl }],
parentModel: "workspace",
parentId: null,
sortPriority: importState.nextSortPriority(),
});
}
const folderIdsByTag = new Map<string, string>();
for (const tag of toArray(spec.tags)) {
const tagRecord = toRecord(tag);
const name = stringAt(tagRecord, "name");
if (name == null || folderIdsByTag.has(name)) continue;
const folder: ImportResources["folders"][0] = {
model: "folder",
id: importState.generateId("folder"),
workspaceId: workspace.id,
name,
description: importTagDescription(tagRecord),
folderId: null,
sortPriority: importState.nextSortPriority(),
};
resources.folders.push(folder);
folderIdsByTag.set(name, folder.id);
}
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
const pathItem = importState.resolve(rawPathItem);
if (!isRecord(pathItem)) continue;
const pathParameters = toArray(pathItem.parameters);
for (const method of HTTP_METHODS) {
const operation = importState.resolve(pathItem[method]);
if (!isRecord(operation)) continue;
const folderId = findOrCreateFolderId({
folderIdsByTag,
importState,
operation,
resources,
workspaceId: workspace.id,
});
resources.httpRequests.push(
importOperation({
importState,
method,
operation,
path: rawPath,
pathParameters,
requestBaseUrl,
spec,
workspaceId: workspace.id,
folderId,
}),
);
}
}
if (resources.httpRequests.length === 0) return undefined;
return {
resources: deleteUndefinedAttrs(
convertTemplateSyntax({
environments: resources.environments,
folders: resources.folders,
grpcRequests: [],
httpRequests: resources.httpRequests,
websocketRequests: [],
workspaces: resources.workspaces,
}),
) as PartialImportResources,
};
}
function importOperation({
importState,
method,
operation,
path,
pathParameters,
requestBaseUrl,
spec,
workspaceId,
folderId,
}: {
importState: ImportState;
method: string;
operation: UnknownRecord;
path: string;
pathParameters: unknown[];
requestBaseUrl: string;
spec: UnknownRecord;
workspaceId: string;
folderId: string | null;
}): ImportResources["httpRequests"][0] {
const parameters = [...pathParameters, ...toArray(operation.parameters)].map((p) =>
importState.resolve(p),
);
const body = importBody({ importState, operation, parameters, spec });
const urlParameters = importUrlParameters({ importState, parameters });
const headers = mergeHeaders(importHeaderParameters({ importState, parameters }), body.headers);
return {
model: "http_request",
id: importState.generateId("http_request"),
workspaceId,
folderId,
name: importOperationName(operation, method, path),
description: importOperationDescription({
importState,
operation,
parameters,
bodyContentType: body.bodyType,
}),
method: method.toUpperCase(),
url: buildOperationUrl(requestBaseUrl, path),
urlParameters,
headers,
body: body.body,
bodyType: body.bodyType,
sortPriority: importState.nextSortPriority(),
...importAuthentication({ importState, operation, spec }),
};
}
function parseSpec(contents: string): unknown {
try { try {
postmanCollection = await new Promise((resolve, reject) => { return JSON.parse(contents);
// oxlint-disable-next-line no-explicit-any
convert({ type: "string", data: contents }, {}, (err, result: any) => {
if (err != null) reject(err);
if (Array.isArray(result.output) && result.output.length > 0) {
resolve(result.output[0].data);
}
});
});
} catch { } catch {
// Probably not an OpenAPI file, so skip it // Fall through to YAML.
return undefined;
} }
return convertPostman(JSON.stringify(postmanCollection)); try {
return YAML.parse(contents);
} catch {
return null;
}
}
function isOpenApiSpec(value: unknown): value is UnknownRecord {
const spec = toRecord(value);
const openapi = stringAt(spec, "openapi");
const swagger = stringAt(spec, "swagger");
return isRecord(spec.paths) && (openapi?.startsWith("3.") === true || swagger === "2.0");
}
function importInfoDescription(info: UnknownRecord): string | undefined {
const parts = [
stringAt(info, "description"),
stringAt(info, "termsOfService")
? `Terms of service: ${stringAt(info, "termsOfService")}`
: null,
isRecord(info.contact) && stringAt(info.contact, "email")
? `Contact: ${stringAt(info.contact, "email")}`
: null,
isRecord(info.license) && stringAt(info.license, "name")
? `License: ${stringAt(info.license, "name")}${
stringAt(info.license, "url") ? ` (${stringAt(info.license, "url")})` : ""
}`
: null,
].filter(isPresent);
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
function importTagDescription(tag: UnknownRecord): string | undefined {
const externalDocs = toRecord(tag.externalDocs);
const parts = [
stringAt(tag, "description"),
stringAt(externalDocs, "url")
? `${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`
: null,
].filter(isPresent);
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
function importOperationName(operation: UnknownRecord, method: string, path: string): string {
return (
stringAt(operation, "summary") ??
stringAt(operation, "operationId") ??
`${method.toUpperCase()} ${path}`
);
}
function importOperationDescription({
importState,
operation,
parameters,
bodyContentType,
}: {
importState: ImportState;
operation: UnknownRecord;
parameters: unknown[];
bodyContentType: string | null;
}): string | undefined {
const parts: string[] = [];
const summary = stringAt(operation, "summary");
const description = stringAt(operation, "description");
const operationId = stringAt(operation, "operationId");
if (description != null) {
parts.push(description);
} else if (summary != null) {
parts.push(summary);
}
if (operationId != null) {
parts.push(`Operation ID: ${operationId}`);
}
const parameterDescriptions = parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.slice(0, MAX_DESCRIPTION_ITEMS)
.map((p) => {
const name = stringAt(p, "name") ?? "parameter";
const location = stringAt(p, "in") ?? "unknown";
const required = p.required === true ? ", required" : "";
const description = stringAt(p, "description");
return `- ${name} (${location}${required})${description ? `: ${description}` : ""}`;
});
if (parameterDescriptions.length > 0) {
parts.push(["Parameters:", ...parameterDescriptions].join("\n"));
}
const requestBody = importState.resolve(operation.requestBody);
if (isRecord(requestBody)) {
const content = toRecord(requestBody.content);
const contentTypes = Object.keys(content);
const bodyLines = [
stringAt(requestBody, "description"),
bodyContentType ? `Selected content type: ${bodyContentType}` : null,
contentTypes.length > 0 ? `Available content types: ${contentTypes.join(", ")}` : null,
].filter(isPresent);
if (bodyLines.length > 0) {
parts.push(["Request body:", ...bodyLines].join("\n"));
}
}
const responseDescriptions = Object.entries(toRecord(operation.responses))
.slice(0, MAX_DESCRIPTION_ITEMS)
.map(([status, response]) => {
const responseRecord = toRecord(importState.resolve(response));
return `- ${status}: ${stringAt(responseRecord, "description") ?? ""}`.trimEnd();
});
if (responseDescriptions.length > 0) {
parts.push(["Responses:", ...responseDescriptions].join("\n"));
}
const externalDocs = toRecord(operation.externalDocs);
if (stringAt(externalDocs, "url")) {
parts.push(
`${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`,
);
}
return parts.length > 0 ? parts.join("\n\n") : undefined;
}
function findOrCreateFolderId({
folderIdsByTag,
importState,
operation,
resources,
workspaceId,
}: {
folderIdsByTag: Map<string, string>;
importState: ImportState;
operation: UnknownRecord;
resources: ImportResources;
workspaceId: string;
}): string | null {
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
if (tag == null) return null;
const existingFolderId = folderIdsByTag.get(tag);
if (existingFolderId != null) return existingFolderId;
const folder: ImportResources["folders"][0] = {
model: "folder",
id: importState.generateId("folder"),
workspaceId,
name: tag,
folderId: null,
sortPriority: importState.nextSortPriority(),
};
resources.folders.push(folder);
folderIdsByTag.set(tag, folder.id);
return folder.id;
}
function buildOperationUrl(baseUrl: string, path: string): string {
return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1"));
}
function importBaseUrl(spec: UnknownRecord): string {
const openApiServer = toArray(spec.servers)
.map((s) => toRecord(s))
.map((s) => interpolateServerUrl(s))
.find((url) => url.length > 0);
if (openApiServer != null) return openApiServer;
const host = stringAt(spec, "host");
if (host == null) return stringAt(spec, "basePath") ?? "";
const scheme = toArray(spec.schemes).find((s): s is string => typeof s === "string") ?? "https";
return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? "");
}
function interpolateServerUrl(server: UnknownRecord): string {
let url = stringAt(server, "url") ?? "";
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default));
}
return url;
}
function joinUrlParts(baseUrl: string, path: string): string {
if (baseUrl.length === 0) return path;
return `${trimTrailingSlashes(baseUrl)}/${trimLeadingSlashes(path)}`;
}
function trimLeadingSlashes(value: string): string {
let index = 0;
while (value[index] === "/") index++;
return value.slice(index);
}
function trimTrailingSlashes(value: string): string {
let index = value.length;
while (value[index - 1] === "/") index--;
return value.slice(0, index);
}
function importUrlParameters({
importState,
parameters,
}: {
importState: ImportState;
parameters: unknown[];
}): HttpUrlParameter[] {
return parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
.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 importHeaderParameters({
importState,
parameters,
}: {
importState: ImportState;
parameters: unknown[];
}): HttpRequestHeader[] {
return parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "header")
.map((p) => ({
enabled: p.required === true,
name: stringAt(p, "name") ?? "",
value: parameterExample(p, importState),
}))
.filter(({ name }) => name.length > 0);
}
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
if (directExample != null) return stringifyExampleValue(directExample);
return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
}
function importBody({
importState,
operation,
parameters,
spec,
}: {
importState: ImportState;
operation: UnknownRecord;
parameters: unknown[];
spec: UnknownRecord;
}): {
headers: HttpRequestHeader[];
body: Record<string, unknown>;
bodyType: string | null;
} {
const openApiRequestBody = importState.resolve(operation.requestBody);
if (isRecord(openApiRequestBody)) {
return importBodyFromContent(importState, toRecord(openApiRequestBody.content));
}
const bodyParameter = parameters
.map((p) => importState.resolve(p))
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
if (isRecord(bodyParameter)) {
const contentType = toArray(spec.consumes).find((c): c is string => typeof c === "string");
const bodyType = contentType ?? "application/json";
return {
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
bodyType,
body: {
text: formatBodyText(
schemaToExample(importState.resolve(bodyParameter.schema), importState),
),
},
};
}
const formParameters = parameters
.map((p) => importState.resolve(p))
.filter(isRecord)
.filter((p) => stringAt(p, "in") === "formData");
if (formParameters.length > 0) {
const contentType =
toArray(spec.consumes).find((c): c is string => typeof c === "string") ??
(formParameters.some((p) => stringAt(p, "type") === "file")
? "multipart/form-data"
: "application/x-www-form-urlencoded");
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType,
body: {
form: formParameters.map((p) => ({
enabled: p.required === true,
name: stringAt(p, "name") ?? "",
value: parameterExample(p, importState),
})),
},
};
}
return { headers: [], body: {}, bodyType: null };
}
function importBodyFromContent(importState: ImportState, content: UnknownRecord) {
const contentType = chooseContentType(Object.keys(content));
if (contentType == null) return { headers: [], body: {}, bodyType: null };
const mediaType = toRecord(content[contentType]);
const example = mediaTypeExample(mediaType, importState);
if (
contentType === "application/x-www-form-urlencoded" ||
contentType === "multipart/form-data"
) {
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType,
body: {
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
},
};
}
return {
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
};
}
function chooseContentType(contentTypes: string[]): string | null {
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
if (exact != null) return exact;
}
return contentTypes[0] ?? null;
}
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) {
const resolvedSchema = toRecord(importState.resolve(schema));
const required = toArray(resolvedSchema.required).filter(
(name): name is string => typeof name === "string",
);
const properties = Object.entries(toRecord(resolvedSchema.properties)).slice(
0,
MAX_EXAMPLE_PROPERTIES,
);
return properties.map(([name, property]) => {
const resolvedProperty = toRecord(importState.resolve(property));
const example = schemaToExample(resolvedProperty, importState);
const base = {
enabled: required.includes(name),
name,
};
if (stringAt(resolvedProperty, "format") === "binary") {
return { ...base, file: "" };
}
return { ...base, value: stringifyExampleValue(example) };
});
}
function schemaToExample(
schema: unknown,
importState: ImportState,
depth = 0,
visitedRefs = new Set<string>(),
): unknown {
if (depth > MAX_EXAMPLE_DEPTH) return {};
const resolved = importState.resolve(schema, visitedRefs);
if (!isRecord(resolved)) return "";
const explicitExample = firstPresent(
resolved.example,
firstExampleValue(resolved.examples),
resolved.default,
);
if (explicitExample != null) return explicitExample;
const enumValues = toArray(resolved.enum);
if (enumValues.length > 0) return enumValues[0];
const allOf = toArray(resolved.allOf);
if (allOf.length > 0) {
return allOf.reduce<UnknownRecord>((merged, childSchema) => {
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
}, {});
}
const oneOf = toArray(resolved.oneOf);
const anyOf = toArray(resolved.anyOf);
if (oneOf.length > 0 || anyOf.length > 0) {
return schemaToExample(oneOf[0] ?? anyOf[0], importState, depth + 1, visitedRefs);
}
const type = inferSchemaType(resolved);
if (type === "array") {
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
}
if (type === "object") {
const required = toArray(resolved.required).filter(
(name): name is string => typeof name === "string",
);
const properties = Object.entries(toRecord(resolved.properties)).sort(([a], [b]) => {
const aRequired = required.includes(a);
const bRequired = required.includes(b);
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
});
return Object.fromEntries(
properties
.slice(0, MAX_EXAMPLE_PROPERTIES)
.map(([name, property]) => [
name,
schemaToExample(property, importState, depth + 1, visitedRefs),
]),
);
}
if (type === "integer" || type === "number") return 0;
if (type === "boolean") return false;
if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
if (stringAt(resolved, "format") === "date") return "2026-01-01";
return "";
}
function inferSchemaType(schema: UnknownRecord): string {
const rawType = schema.type;
if (typeof rawType === "string") return rawType;
if (Array.isArray(rawType)) {
const nonNullType = rawType.find((t) => t !== "null");
if (typeof nonNullType === "string") return nonNullType;
}
if (isRecord(schema.properties) || isRecord(schema.additionalProperties)) return "object";
if (schema.items != null) return "array";
return "string";
}
function importAuthentication({
importState,
operation,
spec,
}: {
importState: ImportState;
operation: UnknownRecord;
spec: UnknownRecord;
}): Pick<HttpRequest, "authentication" | "authenticationType"> {
const security = operation.security ?? spec.security;
if (!Array.isArray(security) || security.length === 0) {
return { authenticationType: null, authentication: {} };
}
const schemes = {
...toRecord(toRecord(spec.components).securitySchemes),
...toRecord(spec.securityDefinitions),
};
for (const requirement of security) {
for (const schemeName of Object.keys(toRecord(requirement))) {
const scheme = toRecord(importState.resolve(schemes[schemeName]));
const type = stringAt(scheme, "type");
if (type === "apiKey") {
return {
authenticationType: "apikey",
authentication: {
location: stringAt(scheme, "in") === "query" ? "query" : "header",
key: stringAt(scheme, "name") ?? schemeName,
value: "",
},
};
}
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "basic") {
return {
authenticationType: "basic",
authentication: { username: "", password: "" },
};
}
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "bearer") {
return {
authenticationType: "bearer",
authentication: { token: "", prefix: "Bearer" },
};
}
}
}
return { authenticationType: null, authentication: {} };
}
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
const headers: HttpRequestHeader[] = [];
for (const header of headerGroups.flat()) {
const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase());
if (existing == null) {
headers.push(header);
}
}
return headers;
}
function formatBodyText(example: unknown): string {
return typeof example === "string" ? example : JSON.stringify(example, null, 2);
}
function stringifyExampleValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return JSON.stringify(value);
}
function firstExampleValue(examples: unknown): unknown {
const firstExample = Object.values(toRecord(examples))[0];
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
return firstExample;
}
function firstPresent(...values: unknown[]): unknown {
return values.find((value) => value !== undefined && value !== null);
}
function stringAt(record: unknown, key: string): string | undefined {
const value = toRecord(record)[key];
return typeof value === "string" ? value : undefined;
}
function toArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function toRecord(value: unknown): UnknownRecord {
return isRecord(value) ? value : {};
}
function isRecord(value: unknown): value is UnknownRecord {
return value != null && typeof value === "object" && !Array.isArray(value);
}
function isPresent<T>(value: T | null | undefined): value is T {
return value != null && value !== "";
}
/** Recursively render all nested object properties */
function convertTemplateSyntax<T>(obj: T): T {
if (typeof obj === "string") {
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T;
}
if (Array.isArray(obj) && obj != null) {
return obj.map(convertTemplateSyntax) as T;
}
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
) as T;
}
return obj;
}
function deleteUndefinedAttrs<T>(obj: T): T {
if (Array.isArray(obj) && obj != null) {
return obj.map(deleteUndefinedAttrs) as T;
}
if (typeof obj === "object" && obj != null) {
return Object.fromEntries(
Object.entries(obj)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
) as T;
}
return obj;
}
class ImportState {
readonly #spec: UnknownRecord;
readonly #idCount: Partial<Record<string, number>> = {};
#sortPriority = 0;
constructor(spec: UnknownRecord) {
this.#spec = spec;
}
generateId(model: string): string {
this.#idCount[model] = (this.#idCount[model] ?? -1) + 1;
return `GENERATE_ID::${model.toUpperCase()}_${this.#idCount[model]}`;
}
nextSortPriority(): number {
return this.#sortPriority++;
}
resolve(value: unknown, visitedRefs = new Set<string>()): unknown {
if (!isRecord(value) || typeof value.$ref !== "string") return value;
if (visitedRefs.has(value.$ref)) return {};
const nextVisitedRefs = new Set(visitedRefs);
nextVisitedRefs.add(value.$ref);
if (!value.$ref.startsWith("#/")) return value;
const resolved = value.$ref
.slice(2)
.split("/")
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
return this.resolve(resolved, nextVisitedRefs);
}
} }
+26
View File
@@ -0,0 +1,26 @@
import { convertPostman } from "@yaak/importer-postman/src";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import { convert } from "openapi-to-postmanv2";
export async function convertOpenApiWithPostman(
contents: string,
): Promise<ImportPluginResponse | undefined> {
// oxlint-disable-next-line no-explicit-any
let postmanCollection: any;
try {
postmanCollection = await new Promise((resolve, reject) => {
// oxlint-disable-next-line no-explicit-any
convert({ type: "string", data: contents }, {}, (err, result: any) => {
if (err != null) reject(err);
if (Array.isArray(result.output) && result.output.length > 0) {
resolve(result.output[0].data);
}
});
});
} catch {
return undefined;
}
return convertPostman(JSON.stringify(postmanCollection));
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
# Real-World OpenAPI Fixtures
These fixtures were copied from the public APIs.guru OpenAPI directory:
- `apis-guru.yaml`: https://api.apis.guru/v2/specs/apis.guru/2.2.0/openapi.yaml
- `httpbin.yaml`: https://api.apis.guru/v2/specs/httpbin.org/0.9.2/openapi.yaml
- `nasa-apod.yaml`: https://api.apis.guru/v2/specs/nasa.gov/apod/1.0.0/openapi.yaml
- `xkcd.yaml`: https://api.apis.guru/v2/specs/xkcd.com/1.0.0/openapi.yaml
@@ -0,0 +1,399 @@
openapi: 3.0.0
servers:
- url: https://api.apis.guru/v2
info:
contact:
email: mike.ralphson@gmail.com
name: APIs.guru
url: https://APIs.guru
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)
license:
name: CC0 1.0
url: https://github.com/APIs-guru/openapi-directory#licenses
title: APIs.guru
version: 2.2.0
x-apisguru-categories:
- open_data
- developer_tools
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_branding_logo_vertical.svg
x-origin:
- format: openapi
url: https://api.apis.guru/v2/openapi.yaml
version: "3.0"
x-providerName: apis.guru
x-tags:
- API
- Catalog
- Directory
- REST
- Swagger
- OpenAPI
externalDocs:
url: https://github.com/APIs-guru/openapi-directory/blob/master/API.md
security: []
tags:
- description: Actions relating to APIs in the collection
name: APIs
paths:
/list.json:
get:
description: |
List all APIs in the directory.
Returns links to the OpenAPI definitions for each API in the directory.
If API exist in multiple versions `preferred` one is explicitly marked.
Some basic info from the OpenAPI definition is cached inside each object.
This allows you to generate some simple views without needing to fetch the OpenAPI definition for each API.
operationId: listAPIs
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/APIs"
description: OK
summary: List all APIs
tags:
- APIs
/metrics.json:
get:
description: |
Some basic metrics for the entire directory.
Just stunning numbers to put on a front page and are intended purely for WoW effect :)
operationId: getMetrics
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/Metrics"
description: OK
summary: Get basic metrics
tags:
- APIs
/providers.json:
get:
description: |
List all the providers in the directory
operationId: getProviders
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
minLength: 1
type: string
minItems: 1
type: array
type: object
description: OK
summary: List all providers
tags:
- APIs
"/specs/{provider}/{api}.json":
get:
description: Returns the API entry for one specific version of an API where there is no serviceName.
operationId: getAPI
parameters:
- $ref: "#/components/parameters/provider"
- $ref: "#/components/parameters/api"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/API"
description: OK
summary: Retrieve one version of a particular API
tags:
- APIs
"/specs/{provider}/{service}/{api}.json":
get:
description: Returns the API entry for one specific version of an API where there is a serviceName.
operationId: getServiceAPI
parameters:
- $ref: "#/components/parameters/provider"
- in: path
name: service
required: true
schema:
example: graph
maxLength: 255
minLength: 1
type: string
- $ref: "#/components/parameters/api"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/API"
description: OK
summary: Retrieve one version of a particular API with a serviceName.
tags:
- APIs
"/{provider}.json":
get:
description: |
List all APIs in the directory for a particular providerName
Returns links to the individual API entry for each API.
operationId: getProvider
parameters:
- $ref: "#/components/parameters/provider"
responses:
"200":
content:
application/json:
schema:
$ref: "#/components/schemas/APIs"
description: OK
summary: List all APIs for a particular provider
tags:
- APIs
"/{provider}/services.json":
get:
description: |
List all serviceNames in the directory for a particular providerName
operationId: getServices
parameters:
- $ref: "#/components/parameters/provider"
responses:
"200":
content:
application/json:
schema:
properties:
data:
items:
minLength: 0
type: string
minItems: 1
type: array
type: object
description: OK
summary: List all serviceNames for a particular provider
tags:
- APIs
components:
parameters:
api:
in: path
name: api
required: true
schema:
example: 2.1.0
maxLength: 255
minLength: 1
type: string
provider:
in: path
name: provider
required: true
schema:
example: apis.guru
maxLength: 255
minLength: 1
type: string
schemas:
API:
additionalProperties: false
description: Meta information about API
properties:
added:
description: Timestamp when the API was first added to the directory
format: date-time
type: string
preferred:
description: Recommended version
type: string
versions:
additionalProperties:
$ref: "#/components/schemas/ApiVersion"
description: List of supported versions of the API
minProperties: 1
type: object
required:
- added
- preferred
- versions
type: object
APIs:
additionalProperties:
$ref: "#/components/schemas/API"
description: |
List of API details.
It is a JSON object with API IDs(`<provider>[:<service>]`) as keys.
example:
googleapis.com:drive:
added: 2015-02-22T20:00:45.000Z
preferred: v3
versions:
v2:
added: 2015-02-22T20:00:45.000Z
info:
title: Drive
version: v2
x-apiClientRegistration:
url: https://console.developers.google.com
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
x-origin:
format: google
url: https://www.googleapis.com/discovery/v1/apis/drive/v2/rest
version: v1
x-preferred: false
x-providerName: googleapis.com
x-serviceName: drive
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.json
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.yaml
updated: 2016-06-17T00:21:44.000Z
v3:
added: 2015-12-12T00:25:13.000Z
info:
title: Drive
version: v3
x-apiClientRegistration:
url: https://console.developers.google.com
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
x-origin:
format: google
url: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest
version: v1
x-preferred: true
x-providerName: googleapis.com
x-serviceName: drive
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.json
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.yaml
updated: 2016-06-17T00:21:44.000Z
minProperties: 1
type: object
ApiVersion:
additionalProperties: false
properties:
added:
description: Timestamp when the version was added
format: date-time
type: string
externalDocs:
description: Copy of `externalDocs` section from OpenAPI definition
minProperties: 1
type: object
info:
description: Copy of `info` section from OpenAPI definition
minProperties: 1
type: object
link:
description: Link to the individual API entry for this API
format: url
type: string
openapiVer:
description: The value of the `openapi` or `swagger` property of the source definition
type: string
swaggerUrl:
description: URL to OpenAPI definition in JSON format
format: url
type: string
swaggerYamlUrl:
description: URL to OpenAPI definition in YAML format
format: url
type: string
updated:
description: Timestamp when the version was updated
format: date-time
type: string
required:
- added
- updated
- swaggerUrl
- swaggerYamlUrl
- info
- openapiVer
type: object
Metrics:
additionalProperties: false
description: List of basic metrics
example:
datasets: []
fixedPct: 22
fixes: 81119
invalid: 598
issues: 28
numAPIs: 2501
numDrivers: 10
numEndpoints: 106448
numProviders: 659
numSpecs: 3329
stars: 2429
thisWeek:
added: 45
updated: 171
unofficial: 25
unreachable: 123
properties:
datasets:
description: Data used for charting etc
items: {}
type: array
fixedPct:
description: Percentage of all APIs where auto fixes have been applied
type: integer
fixes:
description: Total number of fixes applied across all APIs
type: integer
invalid:
description: Number of newly invalid APIs
type: integer
issues:
description: Open GitHub issues on our main repo
type: integer
numAPIs:
description: Number of unique APIs
minimum: 1
type: integer
numDrivers:
description: Number of methods of API retrieval
type: integer
numEndpoints:
description: Total number of endpoints inside all definitions
minimum: 1
type: integer
numProviders:
description: Number of API providers in directory
type: integer
numSpecs:
description: Number of API definitions including different versions of the same API
minimum: 1
type: integer
stars:
description: GitHub stars for our main repo
type: integer
thisWeek:
description: Summary totals for the last 7 days
properties:
added:
description: APIs added in the last week
type: integer
updated:
description: APIs updated in the last week
type: integer
type: object
unofficial:
description: Number of unofficial APIs
type: integer
unreachable:
description: Number of unreachable (4XX,5XX status) APIs
type: integer
required:
- numSpecs
- numAPIs
- numEndpoints
type: object
x-optic-standard: "@febf8ac6-ee67-4565-b45a-5c85a469dca7/Fz6KU3_wMIO5iJ6_VUZ30"
x-optic-url: https://app.useoptic.com/organizations/febf8ac6-ee67-4565-b45a-5c85a469dca7/apis/_0fKWqUvhs9ssYNkq1k-c
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
openapi: 3.0.0
servers:
- url: https://api.nasa.gov/planetary
- url: http://api.nasa.gov/planetary
info:
contact:
email: evan.t.yates@nasa.gov
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
license:
name: Apache 2.0
url: http://www.apache.org/licenses/LICENSE-2.0.html
title: APOD
version: 1.0.0
x-apisguru-categories:
- media
- open_data
x-origin:
- format: swagger
url: https://raw.githubusercontent.com/nasa/api-docs/gh-pages/assets/json/APOD
version: "2.0"
x-providerName: nasa.gov
x-serviceName: apod
x-logo:
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_assets_images_no-logo.svg
tags:
- description: An example tag
externalDocs:
description: Here's a link
url: https://example.com
name: request tag
paths:
/apod:
get:
description: Returns the picture of the day
parameters:
- description: The date of the APOD image to retrieve
in: query
name: date
required: false
schema:
type: string
- description: Retrieve the URL for the high resolution image
in: query
name: hd
required: false
schema:
type: boolean
responses:
"200":
content:
application/json:
schema:
items:
x-thing: ok
type: array
description: successful operation
"400":
description: Date must be between Jun 16, 1995 and Mar 28, 2019.
security:
- api_key: []
summary: Returns images
tags:
- request tag
components:
securitySchemes:
api_key:
in: query
name: api_key
type: apiKey
@@ -0,0 +1,78 @@
openapi: 3.0.0
servers:
- url: http://xkcd.com/
info:
description: Webcomic of romance, sarcasm, math, and language.
title: XKCD
version: 1.0.0
x-apisguru-categories:
- media
x-logo:
url: https://api.apis.guru/v2/cache/logo/http_imgs.xkcd.com_static_terrible_small_logo.png
x-origin:
- format: openapi
url: https://raw.githubusercontent.com/APIs-guru/unofficial_openapi_specs/master/xkcd.com/1.0.0/openapi.yaml
version: "3.0"
x-providerName: xkcd.com
x-tags:
- humor
- comics
x-unofficialSpec: true
externalDocs:
url: https://xkcd.com/json.html
paths:
/info.0.json:
get:
description: |
Fetch current comic and metadata.
responses:
"200":
content:
"*/*":
schema:
$ref: "#/components/schemas/comic"
description: OK
"/{comicId}/info.0.json":
get:
description: |
Fetch comics and metadata by comic id.
parameters:
- in: path
name: comicId
required: true
schema:
type: number
responses:
"200":
content:
"*/*":
schema:
$ref: "#/components/schemas/comic"
description: OK
components:
schemas:
comic:
properties:
alt:
type: string
day:
type: string
img:
type: string
link:
type: string
month:
type: string
news:
type: string
num:
type: number
safe_title:
type: string
title:
type: string
transcript:
type: string
year:
type: string
type: object
+205 -3
View File
@@ -5,7 +5,13 @@ import { convertOpenApi } from "../src";
describe("importer-openapi", () => { describe("importer-openapi", () => {
const p = path.join(__dirname, "fixtures"); const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p); const fixtures = fs.readdirSync(p).filter((fixture) => {
return fs.statSync(path.join(p, fixture)).isFile();
});
const realWorldFixturesPath = path.join(p, "real-world");
const realWorldFixtures = fs
.readdirSync(realWorldFixturesPath)
.filter((fixture) => fixture.endsWith(".yaml"));
test("Maps operation description to request description", async () => { test("Maps operation description to request description", async () => {
const imported = await convertOpenApi( const imported = await convertOpenApi(
@@ -25,7 +31,195 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests).toEqual([ expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({ expect.objectContaining({
description: "Lijst van klanten", description: expect.stringContaining("Lijst van klanten"),
}),
]);
});
test("Imports requests directly from OpenAPI details", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Native Import Test", version: "1.0.0" },
servers: [
{ url: "https://api.example.com/{version}", variables: { version: { default: "v1" } } },
],
tags: [{ name: "accounts", description: "Account operations" }],
paths: {
"/accounts/{accountId}/members": {
parameters: [
{
name: "accountId",
in: "path",
required: true,
description: "Account identifier",
schema: { type: "string", example: "acct_123" },
},
],
post: {
tags: ["accounts"],
summary: "Create member",
operationId: "createMember",
parameters: [
{
name: "include",
in: "query",
description: "Related resources to include",
schema: { type: "string", enum: ["roles"] },
},
{
name: "X-Trace-Id",
in: "header",
schema: { type: "string", example: "trace-123" },
},
],
security: [{ tokenAuth: [] }],
requestBody: {
description: "Member payload",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/MemberInput" },
},
},
},
responses: {
"201": { description: "Created" },
},
},
},
},
components: {
securitySchemes: {
tokenAuth: { type: "http", scheme: "bearer" },
},
schemas: {
MemberInput: {
type: "object",
required: ["email"],
properties: {
email: { type: "string", example: "me@example.com" },
admin: { type: "boolean", default: false },
primaryContact: { $ref: "#/components/schemas/Contact" },
secondaryContact: { $ref: "#/components/schemas/Contact" },
},
},
Contact: {
type: "object",
properties: {
name: { type: "string", example: "Taylor" },
},
},
},
},
}),
);
expect(imported?.resources.folders).toEqual([
expect.objectContaining({ name: "accounts", description: "Account operations" }),
]);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
name: "Global Variables",
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
}),
]);
expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({
name: "Create member",
method: "POST",
url: "${[baseUrl]}/accounts/:accountId/members",
authenticationType: "bearer",
authentication: { token: "", prefix: "Bearer" },
bodyType: "application/json",
body: {
text: JSON.stringify(
{
email: "me@example.com",
admin: false,
primaryContact: { name: "Taylor" },
secondaryContact: { name: "Taylor" },
},
null,
2,
),
},
headers: expect.arrayContaining([
{ enabled: false, name: "X-Trace-Id", value: "trace-123" },
{ enabled: true, name: "Content-Type", value: "application/json" },
]),
urlParameters: [
{ enabled: true, name: ":accountId", value: "acct_123" },
{ enabled: false, name: "include", value: "roles" },
],
description: expect.stringContaining("Operation ID: createMember"),
}),
]);
expect(imported?.resources.httpRequests[0]?.description).toContain("Member payload");
expect(imported?.resources.httpRequests[0]?.description).toContain("201: Created");
});
test("Handles large schemas without the Postman converter path", async () => {
const paths: Record<string, unknown> = {};
for (let i = 0; i < 500; i++) {
paths[`/zones/{zoneId}/resources/${i}`] = {
get: {
tags: ["zones"],
summary: `Read resource ${i}`,
parameters: [
{ name: "zoneId", in: "path", required: true, schema: { type: "string" } },
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
],
responses: {
"200": {
description: "OK",
content: {
"application/json": { schema: { $ref: "#/components/schemas/Resource" } },
},
},
},
},
};
}
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.1.0",
info: { title: "Large API", version: "1.0.0" },
servers: [{ url: "https://api.example.com/client/v4" }],
tags: [{ name: "zones" }],
paths,
components: {
schemas: {
Resource: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
metadata: { $ref: "#/components/schemas/Metadata" },
},
},
Metadata: {
type: "object",
properties: {
createdOn: { type: "string", format: "date-time" },
tags: { type: "array", items: { type: "string" } },
},
},
},
},
}),
);
expect(imported?.resources.httpRequests.length).toBe(500);
expect(imported?.resources.httpRequests[499]).toEqual(
expect.objectContaining({
name: "Read resource 499",
url: "${[baseUrl]}/zones/:zoneId/resources/499",
}),
);
expect(imported?.resources.environments).toEqual([
expect.objectContaining({
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
}), }),
]); ]);
}); });
@@ -46,7 +240,15 @@ describe("importer-openapi", () => {
}), }),
]); ]);
expect(imported?.resources.httpRequests.length).toBe(19); expect(imported?.resources.httpRequests.length).toBe(19);
expect(imported?.resources.folders.length).toBe(7); expect(imported?.resources.folders.map((f) => f.name)).toEqual(["pet", "store", "user"]);
});
}
for (const fixture of realWorldFixtures) {
test(`Snapshots real-world fixture ${fixture}`, async () => {
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
const imported = await convertOpenApi(contents);
expect(imported).toMatchSnapshot();
}); });
} }
}); });
@@ -0,0 +1,19 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { describe, expect, test } from "vite-plus/test";
import { convertOpenApiWithPostman } from "../src/legacy";
describe("importer-openapi legacy converter", () => {
const realWorldFixturesPath = path.join(__dirname, "fixtures", "real-world");
const realWorldFixtures = fs
.readdirSync(realWorldFixturesPath)
.filter((fixture) => fixture.endsWith(".yaml"));
for (const fixture of realWorldFixtures) {
test(`Snapshots legacy Postman-converter output for ${fixture}`, async () => {
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
const imported = await convertOpenApiWithPostman(contents);
expect(imported).toMatchSnapshot();
});
}
});
-3
View File
@@ -69,9 +69,6 @@ const config = JSON.stringify({
const normalizedAdditionalArgs = []; const normalizedAdditionalArgs = [];
for (let i = 0; i < additionalArgs.length; i++) { for (let i = 0; i < additionalArgs.length; i++) {
const arg = additionalArgs[i]; const arg = additionalArgs[i];
if (arg === "--") {
continue;
}
if (arg === "--config" && i + 1 < additionalArgs.length) { if (arg === "--config" && i + 1 < additionalArgs.length) {
const value = additionalArgs[i + 1]; const value = additionalArgs[i + 1];
const isInlineJson = value.trimStart().startsWith("{"); const isInlineJson = value.trimStart().startsWith("{");