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",
"parking_lot",
"percent-encoding",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
"wl-clipboard-rs",
"x11rb",
]
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static 1.5.0",
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6534,7 +6534,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.4.15",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -6547,7 +6547,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
dependencies = [
"filetime",
"libc",
@@ -7988,7 +7988,7 @@ dependencies = [
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.7",
"windows-sys 0.52.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -9317,7 +9317,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.59.0",
]
[[package]]
@@ -4,7 +4,6 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
@@ -90,8 +89,6 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
</Banner>
)}
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
<PlainInput
required
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 { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
@@ -86,10 +85,8 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
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">
<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">
<thead>
<tr>
@@ -140,9 +137,9 @@ function ExportDataDialogContent({
/>
</DetailsBanner>
</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>
<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
</Link>
</div>
@@ -10,17 +10,14 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText";
@@ -38,8 +35,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
);
const handleChange = useCallback(
async (authentication: Record<string, unknown>) =>
await patchModel(model, { authentication }),
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
[model],
);
@@ -51,8 +47,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
return (
<EmptyStateText>
<p>
Auth plugin not found for{" "}
<InlineCode>{model.authenticationType}</InlineCode>
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
</p>
</EmptyStateText>
);
@@ -61,20 +56,11 @@ export function HttpAuthenticationEditor({ model }: Props) {
if (inheritedAuth == null) {
if (model.model === "workspace" || model.model === "folder") {
return (
<EmptyStateText className="flex-col gap-3">
<div className="not-italic flex flex-col items-center gap-3 text-center">
<p className="max-w-md text-sm text-text-subtle">
Choose an auth method to apply it to all requests in{" "}
<strong className="font-semibold text-text-subtle">
{resolvedModelName(model)}
</strong>
.
</p>
<AuthenticationTypeDropdown model={model} />
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
Documentation
</Link>
</div>
<EmptyStateText className="flex-col gap-1">
<p>
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
</p>
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
</EmptyStateText>
);
}
@@ -97,8 +83,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
type="submit"
className="underline hover:text-text"
onClick={() => {
if (inheritedAuth.model === "folder")
openFolderSettings(inheritedAuth.id, "auth");
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth");
}}
>
@@ -118,8 +103,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
hideLabel
name="enabled"
value={
model.authentication.disabled === false ||
model.authentication.disabled == null
model.authentication.disabled === false || model.authentication.disabled == null
? "__TRUE__"
: model.authentication.disabled === true
? "__FALSE__"
@@ -167,9 +151,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
className="w-full"
stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled}
onChange={(v) =>
handleChange({ ...model.authentication, disabled: v })
}
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
/>
</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({
value,
onChange,
@@ -243,11 +198,7 @@ function AuthenticationDisabledInput({
rightSlot={
<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">
{rendered.isPending
? "loading"
: rendered.data
? "enabled"
: "disabled"}
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
</div>
</div>
}
@@ -1,7 +1,6 @@
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile";
@@ -15,8 +14,6 @@ export function ImportDataDialog({ importData }: Props) {
return (
<VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}>
<ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li>
@@ -13,7 +13,6 @@ import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
@@ -23,44 +22,21 @@ import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import {
SettingOverrideRow,
SettingRow,
SettingRowBoolean,
SettingRowNumber,
SettingsList,
SettingsSection,
} 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 {
showSectionTitles?: boolean;
model: ModelWithSettings;
}
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = {
@@ -74,19 +50,12 @@ type HttpSettingsPatch = {
type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
};
type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return (
<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
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates}
@@ -157,9 +110,7 @@ export function ModelSettingsEditor({
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
@@ -207,103 +158,46 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
}
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>);
}
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
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")
return patchModel(model, patch as Partial<WebsocketRequest>);
throw new Error("Unsupported cookie settings model");
}
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
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>);
}
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
return patchModel(model, patch as Partial<HttpRequest>);
}
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
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>);
case "grpc_request":
return patchModel(model, patch as Partial<GrpcRequest>);
}
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
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")
return patchModel(model, patch as Partial<WebsocketRequest>);
return patchModel(model, patch as Partial<GrpcRequest>);
}
function patchMessageSizeSettings(
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 {
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({
inheritedValue,
setting,
@@ -317,11 +211,7 @@ function BooleanSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
@@ -365,92 +255,19 @@ function IntegerSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow
<SettingRowNumber
name={settingDefinition.modelKey}
title={settingDefinition.title}
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}`}
value={value}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseInteger(value),
})
}
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) => onChange(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>
);
}
@@ -461,16 +278,21 @@ function MessageSizeSettingRow({
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<MessageSizeInput
<PlainInput
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
size="sm"
type="number"
placeholder={`${settingDefinition.defaultValue}`}
defaultValue={`${value}`}
containerClassName="!w-48"
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseMegabytes(value),
value: Number.parseInt(value, 10) || 0,
})
}
/>
@@ -478,79 +300,6 @@ function MessageSizeSettingRow({
);
}
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}
inputMode="decimal"
step="any"
placeholder={placeholder}
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 (
<PlainInput
hideLabel
name={name}
label={label}
size="sm"
type="number"
inputMode={inputMode}
step={step}
placeholder={placeholder}
defaultValue={value}
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
containerClassName="!w-48"
validate={validate}
rightSlot={
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
{unit}
</span>
}
onChange={onChange}
/>
);
}
function isInheritedSetting<T>(
setting: T | { enabled?: boolean; value: T },
): setting is { enabled?: boolean; value: T } {
@@ -559,7 +308,7 @@ function isInheritedSetting<T>(
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout" | "settingRequestMessageSize",
key: "settingRequestTimeout",
fallback: IntegerSetting,
): number;
function resolveInheritedValue(
@@ -589,46 +338,10 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
| "settingValidateCertificates"
>;
type BooleanWorkspaceSettingKey = Exclude<
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
);
}
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
@@ -4,7 +4,6 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { DetailsBanner } from "../core/DetailsBanner";
@@ -233,8 +232,6 @@ export function SettingsCertificates() {
</HStack>
</div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && (
<VStack space={3}>
{certificates.map((cert, index) => (
@@ -2,15 +2,22 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
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 { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton";
import {
ModelSettingRowBoolean,
ModelSettingRowNumber,
ModelSettingSelectControl,
SettingValue,
SettingRow,
@@ -20,29 +27,20 @@ import {
SettingsSection,
} from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates();
if (settings == null) {
if (settings == null || workspace == null) {
return null;
}
const showWorkspaceSettingsMovedBanner =
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return (
<VStack space={1.5} className="mb-4">
<div>
<div className="mb-4">
<Heading>General</Heading>
<p className="text-text-subtle">
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?" />
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
</div>
<SettingsList className="space-y-8">
<CargoFeature feature="updater">
@@ -78,9 +76,7 @@ export function SettingsGeneral() {
description="Choose whether updates are installed automatically or manually."
name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) =>
patchModel(settings, { autoupdate: v === "auto" })
}
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
options={[
{ label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" },
@@ -112,19 +108,54 @@ export function SettingsGeneral() {
</SettingsSection>
</CargoFeature>
{showWorkspaceSettingsMovedBanner && (
<DismissibleBanner
id="workspace-settings-moved-2026-06-30"
color="info"
className="p-4 max-w-xl mx-auto"
>
<p>
Workspace specific settings have moved to{" "}
<b>Workspace Settings</b>, accessible from the workspace switcher
menu.
</p>
</DismissibleBanner>
)}
<SettingsSection
title={
<>
Workspace{" "}
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
{workspace.name}
</span>
</>
}
>
<ModelSettingRowNumber
model={workspace}
modelKey={SETTING_REQUEST_TIMEOUT.modelKey}
title={SETTING_REQUEST_TIMEOUT.title}
description={SETTING_REQUEST_TIMEOUT.description}
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">
<SettingRow title="Version" description="Current Yaak version.">
@@ -8,7 +8,6 @@ import { useAtomValue } from "jotai";
import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
@@ -253,9 +252,7 @@ function LicenseSettings({ settings }: { settings: Settings }) {
</p>
<p>
Licenses help keep Yaak independent and sustainable.{" "}
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
Purchase a License
</Link>
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link>
</p>
</VStack>
),
@@ -6,7 +6,6 @@ import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Link } from "../core/Link";
@@ -49,7 +48,7 @@ function SettingsLicenseCmp() {
<span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" />
<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
</Link>
</div>
@@ -69,7 +68,7 @@ function SettingsLicenseCmp() {
</span>
<Separator className="my-2" />
<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
</Link>
</div>
@@ -135,7 +134,7 @@ function SettingsLicenseCmp() {
<Button
color="secondary"
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" />}
>
Direct Support
@@ -151,7 +150,9 @@ function SettingsLicenseCmp() {
color="primary"
rightSlot={<Icon icon="external_link" />}
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
@@ -2,7 +2,6 @@ import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import {
SettingRowBoolean,
SettingRowSelect,
@@ -34,7 +33,6 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure.
</p>
</div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsList className="space-y-8">
<SettingsSection title="Proxy">
<SettingRowSelect
@@ -7,7 +7,6 @@ import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import { pricingUrl } from "../lib/pricingUrl";
import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
@@ -77,8 +76,7 @@ export function SettingsDropdown() {
hidden: check.data == null || check.data.status === "active",
leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
onSelect: () => openUrl("https://yaak.app/pricing"),
},
{
label: "Install CLI",
@@ -105,18 +105,10 @@ function WebsocketEventRow({
: "";
const iconColor =
messageType === "error"
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary";
const icon =
messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
messageType === "close" || messageType === "open"
? "info"
: isServer
? "arrow_big_down_dash"
@@ -127,8 +119,6 @@ function WebsocketEventRow({
"Disconnected from server"
) : messageType === "open" ? (
"Connected to server"
) : messageType === "error" ? (
<span className="text-warning">{message}</span>
) : message === "" ? (
<em className="italic text-text-subtlest">No content</em>
) : (
@@ -180,9 +170,7 @@ function WebsocketEventDetail({
? "Connection Closed"
: event.messageType === "open"
? "Connection Open"
: event.messageType === "error"
? "WebSocket Error"
: `Message ${event.isServer ? "Received" : "Sent"}`;
: `Message ${event.isServer ? "Received" : "Sent"}`;
const actions: EventDetailAction[] =
message !== ""
@@ -112,9 +112,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList>
@@ -1,84 +1,57 @@
import type { Color } from "@yaakapp-internal/plugins";
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 { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
export function DismissibleBanner({
children,
className,
id,
onDismiss,
onShow,
actions,
...props
}: BannerProps & {
id: string;
onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>;
actions?: {
label: string;
onClick: () => void;
color?: Color;
variant?: ButtonProps["variant"];
}[];
actions?: { label: string; onClick: () => void; color?: Color }[];
}) {
const {
isLoading,
set: setDismissed,
value: dismissed,
} = useKeyValue<boolean>({
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
namespace: "global",
key: ["dismiss-banner", id],
fallback: false,
});
const shouldShow = !isLoading && !dismissed;
useEffect(() => {
if (shouldShow) {
Promise.resolve(onShow?.()).catch(console.error);
}
}, [onShow, shouldShow]);
if (!shouldShow) return null;
if (dismissed) return null;
return (
<Banner className={classNames(className, "relative")} {...props}>
<div className="@container">
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
{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
</Button>
{actions?.map((a) => (
<Button
key={a.label}
variant={a.variant ?? "border"}
color={a.color ?? props.color}
size="xs"
onClick={a.onClick}
title={a.label}
>
{a.label}
</Button>
))}
</div>
</div>
</div>
<Banner
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
{...props}
>
{children}
<HStack space={1.5}>
{actions?.map((a) => (
<Button
key={a.label}
variant="border"
color={a.color ?? props.color}
size="xs"
onClick={a.onClick}
title={a.label}
>
{a.label}
</Button>
))}
<Button
variant="border"
color={props.color}
size="xs"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
>
Dismiss
</Button>
</HStack>
</Banner>
);
}
@@ -1,6 +1,6 @@
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
import type { FocusEvent, HTMLAttributes, ReactNode } from "react";
import {
forwardRef,
useCallback,
@@ -28,9 +28,10 @@ export type PlainInputProps = Omit<
| "extraExtensions"
| "forcedEnvironmentId"
> &
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & {
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number";
step?: number;
hideObscureToggle?: boolean;
labelRightSlot?: ReactNode;
};
@@ -51,7 +52,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
labelClassName,
labelPosition = "top",
labelRightSlot,
inputMode,
leftSlot,
name,
onBlur,
@@ -64,7 +64,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
required,
rightSlot,
size = "md",
step,
tint,
type = "text",
validate,
@@ -205,14 +204,12 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus}
onBlur={handleBlur}
required={required}
step={step}
placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture}
/>
@@ -16,7 +16,6 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
@@ -206,8 +205,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal"
defaultRatio={0.6}
firstSlot={({ style }) => (
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
<div style={style} className="h-full px-4">
<SplitLayout
storageKey="commit-vertical"
layout="vertical"
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
title: "Add Remote",
inputs: [
{ 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");
+123 -160
View File
@@ -5,7 +5,6 @@ import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm";
@@ -15,192 +14,156 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>(
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 {
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null;
return useMemo(() => {
if (model == null) return null;
return useMemo<TabItem[]>(() => {
if (model == null) return [];
return {
value: model.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || "UNKNOWN",
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: "separator" },
{
label: "Inherit from Parent",
shortLabel:
inheritedAuth != null &&
inheritedAuth.authenticationType !== "none" ? (
<HStack space={1.5}>
{authentication.find(
(a) => a.name === inheritedAuth.authenticationType,
)?.shortLabel ?? "UNKNOWN"}
<IconTooltip
icon="zap_off"
iconSize="xs"
content="Authentication was inherited from an ancestor"
/>
</HStack>
) : (
"Auth"
),
value: null,
},
{ label: "No Auth", shortLabel: "No Auth", value: "none" },
],
itemsAfter: (() => {
const actions: (
| { type: "separator"; label: string }
| {
label: string;
leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
const tab: TabItem = {
value: tabValue,
label: "Auth",
options: {
value: model.authenticationType,
items: [
...authentication.map((a) => ({
label: a.label || "UNKNOWN",
shortLabel: a.shortLabel,
value: a.name,
})),
{ type: "separator" },
{
label: "Inherit from Parent",
shortLabel:
inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? (
<HStack space={1.5}>
{authentication.find((a) => a.name === inheritedAuth.authenticationType)
?.shortLabel ?? "UNKNOWN"}
<IconTooltip
icon="zap_off"
iconSize="xs"
content="Authentication was inherited from an ancestor"
/>
</HStack>
) : (
"Auth"
),
value: null,
},
{ label: "No Auth", shortLabel: "No Auth", value: "none" },
],
itemsAfter: (() => {
const actions: (
| { type: "separator"; label: string }
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
)[] = [];
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null || parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
leftSlot: (
<Icon
icon={parentModel.model === "workspace" ? "corner_right_up" : "folder_up"}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "promote-auth-confirm",
title: "Promote Authentication",
confirmText: "Promote",
description: (
<>
Move authentication config to{" "}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
</>
),
});
if (confirmed) {
await patchModel(model, { authentication: {}, authenticationType: null });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
}
},
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) => a.authenticationType != null && a.authenticationType !== "none",
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: "separator", label: "Actions" });
}
)[] = [];
// Promote: move auth from current model up to parent
if (
parentModel &&
model.authenticationType &&
model.authenticationType !== "none" &&
(parentModel.authenticationType == null ||
parentModel.authenticationType === "none")
) {
actions.push(
{ type: "separator", label: "Actions" },
{
label: `Promote to ${capitalize(parentModel.model)}`,
actions.push({
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "promote-auth-confirm",
title: "Promote Authentication",
confirmText: "Promote",
id: "copy-auth-confirm",
title: "Copy Authentication",
confirmText: "Copy",
description: (
<>
Move authentication config to{" "}
<InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
Copy{" "}
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType)
?.label ?? "authentication"}{" "}
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
This will override the current authentication but will not affect the{" "}
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: {},
authenticationType: null,
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
}
},
},
);
}
// Copy from ancestor: copy auth config down to current model
const ancestorWithAuth = ancestors.find(
(a) =>
a.authenticationType != null && a.authenticationType !== "none",
);
if (ancestorWithAuth) {
if (actions.length === 0) {
actions.push({ type: "separator", label: "Actions" });
});
}
actions.push({
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
leftSlot: (
<Icon
icon={
ancestorWithAuth.model === "workspace"
? "corner_right_down"
: "folder_down"
}
/>
),
onSelect: async () => {
const confirmed = await showConfirm({
id: "copy-auth-confirm",
title: "Copy Authentication",
confirmText: "Copy",
description: (
<>
Copy{" "}
{authentication.find(
(a) => a.name === ancestorWithAuth.authenticationType,
)?.label ?? "authentication"}{" "}
config from{" "}
<InlineCode>
{resolvedModelName(ancestorWithAuth)}
</InlineCode>
? This will override the current authentication but will not
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</>
),
});
if (confirmed) {
await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication },
authenticationType: ancestorWithAuth.authenticationType,
});
}
},
});
}
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder["authentication"] = model.authentication;
if (model.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
await patchModel(model, { authentication, authenticationType });
return actions.length > 0 ? actions : undefined;
})(),
onChange: async (authenticationType) => {
let authentication: Folder["authentication"] = model.authentication;
if (model.authenticationType !== authenticationType) {
authentication = {
// Reset auth if changing types
};
}
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<
Workspace,
| "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
| "settingStoreCookies"
@@ -18,9 +17,7 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
@@ -44,26 +41,11 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
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({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
title: "Validate TLS certificates",
});
@@ -77,8 +59,7 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
@@ -86,8 +67,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
+3 -3
View File
@@ -66,7 +66,7 @@
"react-markdown": "^10.1.0",
"react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.1",
"react-use": "^17.6.0",
"rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
@@ -102,11 +102,11 @@
"postcss-nesting": "^13.0.2",
"rollup": "^4.60.3",
"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-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.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: {
target: "esnext",
sourcemap: true,
outDir: "../../dist/apps/yaak-client",
emptyOutDir: true,
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.2.1"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
"vite-plus": "^0.1.20"
}
}
+1 -5
View File
@@ -2,7 +2,7 @@ use crate::models_ext::QueryManagerExt;
use chrono::{NaiveDateTime, Utc};
use log::debug;
use std::sync::OnceLock;
use tauri::{AppHandle, Runtime, is_dev};
use tauri::{AppHandle, Runtime};
use yaak_models::util::UpdateSource;
const NAMESPACE: &str = "analytics";
@@ -36,10 +36,6 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
..Default::default()
};
if is_dev() {
info.current_version = "0.0.1".to_string();
}
app_handle
.with_tx(|tx| {
// 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(),
environment_id,
)?;
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -333,7 +332,6 @@ async fn cmd_grpc_reflect<R: Runtime>(
&metadata,
resolved_settings.validate_certificates.value,
client_certificate,
resolved_settings.request_message_size.value,
)
.await
.map_err(|e| GenericError(e.to_string()))?)
@@ -355,8 +353,7 @@ async fn cmd_grpc_go<R: Runtime>(
unrendered_request.folder_id.as_deref(),
environment_id,
)?;
let resolved_settings =
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -428,7 +425,6 @@ async fn cmd_grpc_go<R: Runtime>(
&metadata,
resolved_settings.validate_certificates.value,
client_cert.clone(),
resolved_settings.request_message_size.value,
)
.await;
@@ -718,7 +714,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Request failed".to_string(),
content: "Failed to connect".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
@@ -726,7 +722,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
@@ -742,7 +738,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Request failed".to_string(),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
@@ -785,7 +781,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent {
error: Some(s.message().to_string()),
status: Some(s.code() as i32),
content: "Stream failed".to_string(),
content: "Failed to connect".to_string(),
metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
@@ -793,7 +789,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent {
error: Some(e.message),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
@@ -810,7 +806,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent {
error: Some(e.to_string()),
status: Some(Code::Unknown as i32),
content: "Stream failed".to_string(),
content: "Failed to connect".to_string(),
event_type: GrpcEventType::ConnectionEnd,
..base_event.clone()
},
@@ -882,8 +878,7 @@ async fn cmd_grpc_go<R: Runtime>(
.db()
.upsert_grpc_event(
&GrpcEvent {
content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
content: status.to_string(),
status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd,
@@ -892,7 +887,6 @@ async fn cmd_grpc_go<R: Runtime>(
&UpdateSource::from_window_label(window.label()),
)
.unwrap();
break;
}
}
}
@@ -79,7 +79,7 @@ impl YaakNotifier {
return Ok(());
}
info!("Checking for notifications");
debug!("Checking for notifications");
#[cfg(feature = "license")]
let license_check = {
@@ -115,20 +115,17 @@ impl YaakNotifier {
]);
let resp = req.send().await?;
if resp.status() != 200 {
info!("Skipping notification status code {}", resp.status());
debug!("Skipping notification status code {}", resp.status());
return Ok(());
}
let notifications = resp.json::<Vec<YaakNotification>>().await?;
debug!("Received {} notifications", notifications.len());
for notification in notifications {
for notification in resp.json::<Vec<YaakNotification>>().await? {
let seen = get_kv(app_handle).await?;
if seen.contains(&notification.id) {
debug!("Already seen notification {}", notification.id);
continue;
}
info!("Got notification {:?}", notification);
debug!("Got notification {:?}", notification);
let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
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>>,
) -> Result<WebsocketConnection> {
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 environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id,
@@ -122,7 +91,7 @@ async fn send_websocket_message<R: Runtime>(
&UpdateSource::from_window_label(window.label()),
)?;
Ok(connection.clone())
Ok(connection)
}
#[command]
@@ -330,7 +299,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
receive_tx,
resolved_settings.validate_certificates.value,
client_cert,
resolved_settings.request_message_size.value,
)
.await
{
-4
View File
@@ -46,7 +46,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GrpcRequest = {
@@ -70,7 +69,6 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -148,7 +146,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -165,7 +162,6 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+5 -11
View File
@@ -33,21 +33,15 @@ impl AutoReflectionClient {
uri: &Uri,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
let client_v1alpha =
v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size);
);
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?,
uri.clone(),
);
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
}
+14 -82
View File
@@ -39,7 +39,6 @@ pub struct GrpcConnection {
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
pub uri: Uri,
use_reflection: bool,
max_message_size: usize,
}
#[derive(Default, Debug)]
@@ -98,15 +97,8 @@ impl GrpcConnection {
client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> {
if self.use_reflection {
reflect_types_for_message(
self.pool.clone(),
&self.uri,
message,
metadata,
client_cert,
self.max_message_size,
)
.await?;
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
.await?;
}
let method = &self.method(&service, &method).await?;
let input_message = method.input();
@@ -115,7 +107,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
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();
decorate_req(metadata, &mut req)?;
@@ -140,7 +132,6 @@ impl GrpcConnection {
message,
metadata,
client_cert,
self.max_message_size,
)
.await?;
@@ -180,7 +171,6 @@ impl GrpcConnection {
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream
.then(move |json| {
let pool = pool.clone();
@@ -193,15 +183,8 @@ impl GrpcConnection {
let json_clone = json.clone();
async move {
if use_reflection {
if let Err(e) = reflect_types_for_message(
pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
@@ -223,7 +206,7 @@ impl GrpcConnection {
.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 codec = DynamicCodec::new(method.clone());
@@ -254,7 +237,6 @@ impl GrpcConnection {
let md = metadata.clone();
let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream
.then(move |json| {
let pool = pool.clone();
@@ -267,15 +249,8 @@ impl GrpcConnection {
let json_clone = json.clone();
async move {
if use_reflection {
if let Err(e) = reflect_types_for_message(
pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
if let Err(e) =
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
{
warn!("Failed to resolve Any types: {e}");
}
@@ -297,7 +272,7 @@ impl GrpcConnection {
.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 codec = DynamicCodec::new(method.clone());
@@ -325,7 +300,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
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();
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
#[derive(Clone)]
pub struct GrpcConfig {
@@ -390,7 +348,6 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<bool> {
let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files);
@@ -402,14 +359,7 @@ impl GrpcHandle {
let pool = if server_reflection {
let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(
&full_uri,
metadata,
validate_certificates,
client_cert,
message_size_limit(request_message_size),
)
.await
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await
} else {
fill_pool_from_files(&self.config, proto_files).await
}?;
@@ -426,21 +376,12 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(
id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert,
request_message_size,
)
.await?;
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
.await?;
}
let pool = self
@@ -480,10 +421,8 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<GrpcConnection> {
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() {
self.reflect(
id,
@@ -492,7 +431,6 @@ impl GrpcHandle {
metadata,
validate_certificates,
client_cert.clone(),
request_message_size,
)
.await?;
}
@@ -502,13 +440,7 @@ impl GrpcHandle {
.clone();
let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection {
pool: Arc::new(RwLock::new(pool)),
use_reflection,
conn,
uri,
max_message_size,
})
Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri })
}
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>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new();
let mut client =
AutoReflectionClient::new(uri, validate_certificates, client_cert, max_message_size)?;
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;
for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" {
@@ -194,7 +192,6 @@ pub(crate) async fn reflect_types_for_message(
json: &str,
metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> {
// 1. Collect all Any types in the JSON
let mut extra_types = Vec::new();
@@ -204,7 +201,7 @@ pub(crate) async fn reflect_types_for_message(
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 {
{
let guard = pool.read().await;
@@ -242,7 +239,6 @@ pub(crate) async fn reflect_types_for_dynamic_message(
message: &DynamicMessage,
metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> {
let mut extra_types = HashSet::new();
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(());
}
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 {
{
let guard = pool.read().await;
+1 -6
View File
@@ -109,7 +109,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GraphQlIntrospection = {
@@ -185,7 +184,6 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -458,8 +456,7 @@ export type WebsocketEvent = {
messageType: WebsocketEventType;
};
export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketMessageType = "text" | "binary";
@@ -485,7 +482,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -502,7 +498,6 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: 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> {
const results = await Promise.allSettled(pendingModelWrites);
const results = await Promise.allSettled([...pendingModelWrites]);
const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") {
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};
pub use yaak_database::{UpsertModelInfo, upsert_date};
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
#[macro_export]
macro_rules! impl_model {
($t:ty, $variant:ident) => {
@@ -122,7 +120,6 @@ pub struct ResolvedHttpRequestSettings {
pub validate_certificates: ResolvedSetting<bool>,
pub follow_redirects: ResolvedSetting<bool>,
pub request_timeout: ResolvedSetting<i32>,
pub request_message_size: ResolvedSetting<i32>,
pub send_cookies: ResolvedSetting<bool>,
pub store_cookies: ResolvedSetting<bool>,
}
@@ -133,7 +130,6 @@ impl Default for ResolvedHttpRequestSettings {
validate_certificates: ResolvedSetting::default_source(true),
follow_redirects: ResolvedSetting::default_source(true),
request_timeout: ResolvedSetting::default_source(0),
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
send_cookies: ResolvedSetting::default_source(true),
store_cookies: ResolvedSetting::default_source(true),
}
@@ -404,8 +400,6 @@ pub struct Workspace {
#[serde(default = "default_true")]
pub setting_follow_redirects: bool,
pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32,
#[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")]
@@ -451,7 +445,6 @@ impl UpsertModelInfo for Workspace {
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingRequestMessageSize, self.setting_request_message_size.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
(SettingSendCookies, self.setting_send_cookies.into()),
@@ -470,7 +463,7 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::EncryptionKeyChallenge,
WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingFollowRedirects,
WorkspaceIden::SettingRequestMessageSize,
WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies,
@@ -498,7 +491,6 @@ impl UpsertModelInfo for Workspace {
authentication_type: row.get("authentication_type")?,
setting_follow_redirects: row.get("setting_follow_redirects")?,
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_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
setting_send_cookies: row.get("setting_send_cookies")?,
@@ -970,8 +962,6 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
impl UpsertModelInfo for Folder {
@@ -1019,10 +1009,6 @@ impl UpsertModelInfo for Folder {
),
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.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::SettingFollowRedirects,
FolderIden::SettingRequestTimeout,
FolderIden::SettingRequestMessageSize,
]
}
@@ -1056,7 +1041,6 @@ impl UpsertModelInfo for Folder {
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
let setting_request_timeout: String = row.get("setting_request_timeout")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -1078,8 +1062,6 @@ impl UpsertModelInfo for Folder {
.unwrap_or_default(),
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
.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_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
impl UpsertModelInfo for WebsocketRequest {
@@ -1466,10 +1446,6 @@ impl UpsertModelInfo for WebsocketRequest {
SettingValidateCertificates,
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::SettingStoreCookies,
WebsocketRequestIden::SettingValidateCertificates,
WebsocketRequestIden::SettingRequestMessageSize,
]
}
@@ -1504,7 +1479,6 @@ impl UpsertModelInfo for WebsocketRequest {
let setting_send_cookies: String = row.get("setting_send_cookies")?;
let setting_store_cookies: String = row.get("setting_store_cookies")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self {
id: row.get("id")?,
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_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.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 {
Binary,
Close,
Error,
Frame,
Open,
Ping,
@@ -2068,8 +2039,6 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure)
pub url: String,
pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
}
impl UpsertModelInfo for GrpcRequest {
@@ -2117,10 +2086,6 @@ impl UpsertModelInfo for GrpcRequest {
SettingValidateCertificates,
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::Metadata,
GrpcRequestIden::SettingValidateCertificates,
GrpcRequestIden::SettingRequestMessageSize,
]
}
@@ -2151,7 +2115,6 @@ impl UpsertModelInfo for GrpcRequest {
let authentication: String = row.get("authentication")?;
let metadata: String = row.get("metadata")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -2171,8 +2134,6 @@ impl UpsertModelInfo for GrpcRequest {
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.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
}
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 {
"GET".to_string()
}
@@ -180,14 +180,6 @@ impl<'a> ClientDb<'a> {
} else {
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 {
ResolvedSetting::from_model(
folder.setting_send_cookies.value,
@@ -129,14 +129,6 @@ impl<'a> ClientDb<'a> {
} else {
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
})
}
@@ -131,7 +131,6 @@ impl<'a> ClientDb<'a> {
} else {
parent.request_timeout
},
request_message_size: parent.request_message_size,
send_cookies: if http_request.setting_send_cookies.enabled {
ResolvedSetting::from_model(
http_request.setting_send_cookies.value,
@@ -139,14 +139,6 @@ impl<'a> ClientDb<'a> {
} else {
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 {
ResolvedSetting::from_model(
websocket_request.setting_send_cookies.value,
@@ -21,7 +21,6 @@ impl<'a> ClientDb<'a> {
&Workspace {
name: "Yaak".to_string(),
setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
..Default::default()
},
@@ -103,10 +102,6 @@ impl<'a> ClientDb<'a> {
workspace.setting_request_timeout,
AnyModel::Workspace(workspace.clone()),
),
request_message_size: ResolvedSetting::from_model(
workspace.setting_request_message_size,
AnyModel::Workspace(workspace.clone()),
),
send_cookies: ResolvedSetting::from_model(
workspace.setting_send_cookies,
AnyModel::Workspace(workspace.clone()),
+1 -6
View File
@@ -108,7 +108,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GraphQlIntrospection = {
@@ -184,7 +183,6 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -428,8 +426,7 @@ export type WebsocketEvent = {
messageType: WebsocketEventType;
};
export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = {
model: "websocket_request";
@@ -453,7 +450,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -470,7 +466,6 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+1 -1
View File
@@ -1070,7 +1070,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(),
}),
Duration::from_secs(5),
Duration::from_secs(60),
)
.await?;
-4
View File
@@ -46,7 +46,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GrpcRequest = {
@@ -70,7 +69,6 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -161,7 +159,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -178,7 +175,6 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
+1 -11
View File
@@ -20,7 +20,6 @@ pub async fn ws_connect(
headers: HeaderMap<HeaderValue>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
info!("Connecting to WS {url}");
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(
req,
Some(websocket_config(request_message_size)),
Some(WebSocketConfig::default()),
false,
Some(Connector::Rustls(Arc::new(tls_config))),
)
@@ -49,12 +48,3 @@ pub async fn ws_connect(
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)]
pub enum Error {
#[error("{0}")]
#[error("WebSocket error: {0}")]
WebSocketErr(#[from] tungstenite::Error),
#[error(transparent)]
@@ -16,7 +16,7 @@ pub enum Error {
#[error(transparent)]
TlsError(#[from] yaak_tls::error::Error),
#[error("{0}")]
#[error("WebSocket error: {0}")]
GenericError(String),
}
+8 -28
View File
@@ -1,5 +1,4 @@
use crate::connect::{message_size_limit, ws_connect};
use crate::error::Error::GenericError;
use crate::connect::ws_connect;
use crate::error::Result;
use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt};
@@ -16,16 +15,10 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig;
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
struct WebsocketConnection {
max_message_size: Option<usize>,
sink: WebsocketSink,
}
#[derive(Clone)]
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<()>>>>,
}
@@ -42,20 +35,14 @@ impl WebsocketManager {
receive_tx: mpsc::Sender<Message>,
validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Response> {
let tx = receive_tx.clone();
let max_message_size = message_size_limit(request_message_size);
let (stream, response) =
ws_connect(url, headers, validate_certificates, client_cert, request_message_size)
.await?;
ws_connect(url, headers, validate_certificates, client_cert).await?;
let (write, mut read) = stream.split();
self.connections
.lock()
.await
.insert(id.to_string(), WebsocketConnection { max_message_size, sink: write });
self.connections.lock().await.insert(id.to_string(), write);
let handle = {
let connection_id = id.to_string();
@@ -83,20 +70,13 @@ impl WebsocketManager {
}
pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> {
debug!("Send websocket message {msg:?}");
let mut connections = self.connections.lock().await;
let connection = match connections.get_mut(id) {
None => return Ok(()),
Some(c) => c,
};
if let Some(limit) = connection.max_message_size {
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?;
connection.send(msg).await?;
Ok(())
}
@@ -104,7 +84,7 @@ impl WebsocketManager {
info!("Closing websocket");
if let Some(mut connection) = self.connections.lock().await.remove(id) {
// 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:?}");
};
}
+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",
"npm-run-all": "^4.1.5",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.2.1",
"vitest": "^4.1.9"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
"vite-plus": "^0.1.20",
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
},
"overrides": {
"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"
}
+1 -6
View File
@@ -108,7 +108,6 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type GraphQlIntrospection = {
@@ -184,7 +183,6 @@ export type GrpcRequest = {
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = {
@@ -428,8 +426,7 @@ export type WebsocketEvent = {
messageType: WebsocketEventType;
};
export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = {
model: "websocket_request";
@@ -453,7 +450,6 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = {
@@ -470,7 +466,6 @@ export type Workspace = {
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: 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"
},
"dependencies": {
"ws": "^8.21.0"
"ws": "^8.20.1"
},
"devDependencies": {
"@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 {
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(),
surfaceHighlight: color.translucify(0.85).css(),
border: color.lift(0.3).translucify(0.8).css(),
+1 -1
View File
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13",
"@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.25",
"hono": "^4.12.14",
"zod": "^3.25.76"
},
"devDependencies": {
+2 -2
View File
@@ -10,10 +10,10 @@
"test": "vp test --run tests"
},
"dependencies": {
"openapi-to-postmanv2": "^5.8.0",
"yaml": "^2.8.3"
},
"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 { Context, PluginDefinition } from "@yaakapp/api";
import type {
Context,
Environment,
Folder,
HttpRequest,
HttpRequestHeader,
HttpUrlParameter,
PartialImportResources,
PluginDefinition,
Workspace,
} from "@yaakapp/api";
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 = {
importer: {
@@ -14,23 +44,785 @@ export const plugin: PluginDefinition = {
};
export async function convertOpenApi(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);
const spec = parseSpec(contents);
if (!isOpenApiSpec(spec)) return undefined;
if (Array.isArray(result.output) && result.output.length > 0) {
resolve(result.output[0].data);
}
});
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(),
});
} catch {
// Probably not an OpenAPI file, so skip it
return undefined;
}
return convertPostman(JSON.stringify(postmanCollection));
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 {
return JSON.parse(contents);
} catch {
// Fall through to YAML.
}
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", () => {
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 () => {
const imported = await convertOpenApi(
@@ -25,7 +31,195 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests).toEqual([
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.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 = [];
for (let i = 0; i < additionalArgs.length; i++) {
const arg = additionalArgs[i];
if (arg === "--") {
continue;
}
if (arg === "--config" && i + 1 < additionalArgs.length) {
const value = additionalArgs[i + 1];
const isInlineJson = value.trimStart().startsWith("{");