Compare commits

..

13 Commits

Author SHA1 Message Date
Gregory Schier 3a349bccfe Fix wording 2026-06-30 10:28:39 -07:00
Gregory Schier 13a667a9b1 Add commercial use nudge banners (#478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Nguyễn Huỳnh Anh Khoa <113995598+anhkhoakz@users.noreply.github.com>
Co-authored-by: startsevdenis <mail@startsevds.ru>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 10:23:13 -07:00
Gregory Schier 420c6e2c4a Tweak message size placeholder and notifications 2026-06-30 09:44:11 -07:00
Gregory Schier bbdfbcb9ca Add configurable gRPC and WebSocket message size limit (#487) 2026-06-30 09:14:41 -07:00
dependabot[bot] d1e6f8fb33 Bump js-cookie and react-use (#488)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:33:14 -07:00
dependabot[bot] 930a816f42 Bump ws from 8.20.1 to 8.21.0 (#480)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:54 -07:00
dependabot[bot] ec0143aa93 Bump hono from 4.12.18 to 4.12.25 (#479)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:47 -07:00
dependabot[bot] 3cc54dea22 Bump vite-plus from 0.1.20 to 0.1.24 (#473)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:41 -07:00
dependabot[bot] a8fb144c09 Bump esbuild and tsx (#472)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:33 -07:00
dependabot[bot] 6813fa8bf2 Bump shell-quote from 1.8.3 to 1.8.4 (#471)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:25 -07:00
dependabot[bot] cf7de26a2e Bump tar from 0.4.45 to 0.4.46 (#469)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:16 -07:00
dependabot[bot] 8676272657 Bump qs from 6.14.1 to 6.15.2 (#467)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-30 07:25:10 -07:00
startsevdenis c3aecfdc0c fix: increase tonic gRPC max_decoding_message_size to 64MB 2026-06-29 16:06:02 -07:00
67 changed files with 2543 additions and 8893 deletions
Generated
+9 -9
View File
@@ -215,7 +215,7 @@ dependencies = [
"objc2-foundation 0.3.1", "objc2-foundation 0.3.1",
"parking_lot", "parking_lot",
"percent-encoding", "percent-encoding",
"windows-sys 0.59.0", "windows-sys 0.52.0",
"wl-clipboard-rs", "wl-clipboard-rs",
"x11rb", "x11rb",
] ]
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [ dependencies = [
"lazy_static 1.5.0", "lazy_static 1.5.0",
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6534,7 +6534,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.4.15", "linux-raw-sys 0.4.15",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -6547,7 +6547,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.9.4", "linux-raw-sys 0.9.4",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tar" name = "tar"
version = "0.4.45" version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [ dependencies = [
"filetime", "filetime",
"libc", "libc",
@@ -7988,7 +7988,7 @@ dependencies = [
"getrandom 0.3.3", "getrandom 0.3.3",
"once_cell", "once_cell",
"rustix 1.0.7", "rustix 1.0.7",
"windows-sys 0.59.0", "windows-sys 0.52.0",
] ]
[[package]] [[package]]
@@ -9317,7 +9317,7 @@ version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.48.0",
] ]
[[package]] [[package]]
@@ -4,6 +4,7 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir"; import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast"; import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
@@ -89,6 +90,8 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
</Banner> </Banner>
)} )}
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
<PlainInput <PlainInput
required required
label="Repository URL" label="Repository URL"
@@ -0,0 +1,130 @@
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,6 +8,7 @@ import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize"; import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri"; import { invokeCmd } from "../lib/tauri";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox"; import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner"; import { DetailsBanner } from "./core/DetailsBanner";
@@ -85,8 +86,10 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length; const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0; const noneSelected = numSelected === 0;
return ( return (
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]"> <div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6"> <VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight"> <table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead> <thead>
<tr> <tr>
@@ -137,9 +140,9 @@ function ExportDataDialogContent({
/> />
</DetailsBanner> </DetailsBanner>
</VStack> </VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle"> <footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div> <div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle"> <Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button Create Run Button
</Link> </Link>
</div> </div>
@@ -10,14 +10,17 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useCallback } from "react"; import { useCallback } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig"; import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication"; import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
import { useRenderTemplate } from "../hooks/useRenderTemplate"; import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { resolvedModelName } from "../lib/resolvedModelName"; import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown"; import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { IconButton } from "./core/IconButton"; import { IconButton } from "./core/IconButton";
import { Input, type InputProps } from "./core/Input"; import { Input, type InputProps } from "./core/Input";
import { Link } from "./core/Link"; import { Link } from "./core/Link";
import { RadioDropdown } from "./core/RadioDropdown";
import { SegmentedControl } from "./core/SegmentedControl"; import { SegmentedControl } from "./core/SegmentedControl";
import { DynamicForm } from "./DynamicForm"; import { DynamicForm } from "./DynamicForm";
import { EmptyStateText } from "./EmptyStateText"; import { EmptyStateText } from "./EmptyStateText";
@@ -35,7 +38,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
const handleChange = useCallback( const handleChange = useCallback(
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }), async (authentication: Record<string, unknown>) =>
await patchModel(model, { authentication }),
[model], [model],
); );
@@ -47,7 +51,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
return ( return (
<EmptyStateText> <EmptyStateText>
<p> <p>
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode> Auth plugin not found for{" "}
<InlineCode>{model.authenticationType}</InlineCode>
</p> </p>
</EmptyStateText> </EmptyStateText>
); );
@@ -56,11 +61,20 @@ export function HttpAuthenticationEditor({ model }: Props) {
if (inheritedAuth == null) { if (inheritedAuth == null) {
if (model.model === "workspace" || model.model === "folder") { if (model.model === "workspace" || model.model === "folder") {
return ( return (
<EmptyStateText className="flex-col gap-1"> <EmptyStateText className="flex-col gap-3">
<p> <div className="not-italic flex flex-col items-center gap-3 text-center">
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong> <p className="max-w-md text-sm text-text-subtle">
</p> Choose an auth method to apply it to all requests in{" "}
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link> <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> </EmptyStateText>
); );
} }
@@ -83,7 +97,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
type="submit" type="submit"
className="underline hover:text-text" className="underline hover:text-text"
onClick={() => { onClick={() => {
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth"); if (inheritedAuth.model === "folder")
openFolderSettings(inheritedAuth.id, "auth");
else openWorkspaceSettings("auth"); else openWorkspaceSettings("auth");
}} }}
> >
@@ -103,7 +118,8 @@ export function HttpAuthenticationEditor({ model }: Props) {
hideLabel hideLabel
name="enabled" name="enabled"
value={ value={
model.authentication.disabled === false || model.authentication.disabled == null model.authentication.disabled === false ||
model.authentication.disabled == null
? "__TRUE__" ? "__TRUE__"
: model.authentication.disabled === true : model.authentication.disabled === true
? "__FALSE__" ? "__FALSE__"
@@ -151,7 +167,9 @@ export function HttpAuthenticationEditor({ model }: Props) {
className="w-full" className="w-full"
stateKey={`auth.${model.id}.dynamic`} stateKey={`auth.${model.id}.dynamic`}
value={model.authentication.disabled} value={model.authentication.disabled}
onChange={(v) => handleChange({ ...model.authentication, disabled: v })} onChange={(v) =>
handleChange({ ...model.authentication, disabled: v })
}
/> />
</div> </div>
)} )}
@@ -169,6 +187,33 @@ export function HttpAuthenticationEditor({ model }: Props) {
); );
} }
function AuthenticationTypeDropdown({ model }: Props) {
const options = useAuthDropdownOptions(model);
if (options == null) return null;
return (
<RadioDropdown
items={options.items}
itemsAfter={options.itemsAfter}
itemsBefore={options.itemsBefore}
value={options.value}
onChange={options.onChange}
>
<Button
color="secondary"
variant="border"
size="sm"
rightSlot={
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
}
>
Select Auth
</Button>
</RadioDropdown>
);
}
function AuthenticationDisabledInput({ function AuthenticationDisabledInput({
value, value,
onChange, onChange,
@@ -198,7 +243,11 @@ function AuthenticationDisabledInput({
rightSlot={ rightSlot={
<div className="px-1 flex items-center"> <div className="px-1 flex items-center">
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap"> <div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"} {rendered.isPending
? "loading"
: rendered.data
? "enabled"
: "disabled"}
</div> </div>
</div> </div>
} }
@@ -1,6 +1,7 @@
import { VStack } from "@yaakapp-internal/ui"; import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react"; import { useState } from "react";
import { useLocalStorage } from "react-use"; import { useLocalStorage } from "react-use";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button"; import { Button } from "./core/Button";
import { SelectFile } from "./SelectFile"; import { SelectFile } from "./SelectFile";
@@ -14,6 +15,8 @@ export function ImportDataDialog({ importData }: Props) {
return ( return (
<VStack space={5} className="pb-4"> <VStack space={5} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack space={1}> <VStack space={1}>
<ul className="list-disc pl-5"> <ul className="list-disc pl-5">
<li>OpenAPI 3.0, 3.1</li> <li>OpenAPI 3.0, 3.1</li>
@@ -13,6 +13,7 @@ import {
modelSupportsSetting, modelSupportsSetting,
type RequestSettingDefinition, type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS, SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT, SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES, SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES, SETTING_STORE_COOKIES,
@@ -22,21 +23,44 @@ import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput"; import { PlainInput } from "./core/PlainInput";
import { import {
SettingOverrideRow, SettingOverrideRow,
SettingRow,
SettingRowBoolean, SettingRowBoolean,
SettingRowNumber,
SettingsList, SettingsList,
SettingsSection, SettingsSection,
} from "./core/SettingRow"; } from "./core/SettingRow";
const BYTES_PER_MB = 1024 * 1024;
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
interface Props { interface Props {
showSectionTitles?: boolean; showSectionTitles?: boolean;
model: ModelWithSettings; model: ModelWithSettings;
} }
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest; type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest; type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest; type ModelWithTlsSettings =
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest; | Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting; type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting; type IntegerSetting = number | InheritedIntSetting;
type CookieSettingsPatch = { type CookieSettingsPatch = {
@@ -50,12 +74,19 @@ type HttpSettingsPatch = {
type TlsSettingsPatch = { type TlsSettingsPatch = {
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"]; 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 ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model); const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model); const supportsCookieSettings = modelSupportsCookieSettings(model);
const supportsTlsSettings = modelSupportsTlsSettings(model); const supportsTlsSettings = modelSupportsTlsSettings(model);
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
return ( return (
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
@@ -77,6 +108,22 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
} }
/> />
)} )}
{supportsMessageSizeSettings && (
<MessageSizeSettingRow
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
setting={model.settingRequestMessageSize}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
model.settingRequestMessageSize,
)}
onChange={(settingRequestMessageSize) =>
patchMessageSizeSettings(model, {
settingRequestMessageSize,
})
}
/>
)}
<BooleanSettingRow <BooleanSettingRow
settingDefinition={SETTING_VALIDATE_CERTIFICATES} settingDefinition={SETTING_VALIDATE_CERTIFICATES}
setting={model.settingValidateCertificates} setting={model.settingValidateCertificates}
@@ -110,7 +157,9 @@ export function ModelSettingsEditor({ model, showSectionTitles = false }: Props)
</SettingsSection> </SettingsSection>
)} )}
{supportsCookieSettings && ( {supportsCookieSettings && (
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}> <SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<BooleanSettingRow <BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES} settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies} setting={model.settingSendCookies}
@@ -158,46 +207,103 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout); settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
} }
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true) if (modelSupportsMessageSizeSettings(model)) {
.length; settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
} }
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) { function patchCookieSettings(
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>); model: ModelWithCookieSettings,
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>); patch: Partial<CookieSettingsPatch>,
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>); ) {
if (model.model === "websocket_request") switch (model.model) {
return patchModel(model, patch as Partial<WebsocketRequest>); case "workspace":
throw new Error("Unsupported cookie settings model"); 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 patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) { function patchHttpSettings(
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>); model: ModelWithHttpSettings,
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>); patch: Partial<HttpSettingsPatch>,
return patchModel(model, patch as Partial<HttpRequest>); ) {
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 patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) { function patchTlsSettings(
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>); model: ModelWithTlsSettings,
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>); patch: Partial<TlsSettingsPatch>,
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>); ) {
if (model.model === "websocket_request") switch (model.model) {
return patchModel(model, patch as Partial<WebsocketRequest>); case "workspace":
return patchModel(model, patch as Partial<GrpcRequest>); 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 modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings { 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 {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT); 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); 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); return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
} }
function modelSupportsMessageSizeSettings(
model: ModelWithSettings,
): model is ModelWithMessageSizeSettings {
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
}
function BooleanSettingRow({ function BooleanSettingRow({
inheritedValue, inheritedValue,
setting, setting,
@@ -211,7 +317,11 @@ function BooleanSettingRow({
}) { }) {
const inherited = isInheritedSetting(setting); const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false; const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting; const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) { if (!inherited) {
return ( return (
@@ -255,19 +365,28 @@ function IntegerSettingRow({
}) { }) {
const inherited = isInheritedSetting(setting); const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false; const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting; const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
if (!inherited) { if (!inherited) {
return ( return (
<SettingRowNumber <SettingRow
name={settingDefinition.modelKey}
title={settingDefinition.title} title={settingDefinition.title}
description={settingDefinition.description} description={settingDefinition.description}
value={value} >
placeholder={`${settingDefinition.defaultValue}`} <NumberUnitInput
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0} name={settingDefinition.modelKey}
onChange={(value) => onChange(value)} label={settingDefinition.title}
/> unit="ms"
value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`}
validate={isValidInteger}
onChange={(value) => onChange(parseInteger(value))}
/>
</SettingRow>
); );
} }
@@ -278,21 +397,18 @@ function IntegerSettingRow({
overridden={overridden} overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })} onResetOverride={() => onChange({ ...setting, enabled: false })}
> >
<PlainInput <NumberUnitInput
hideLabel
name={settingDefinition.modelKey} name={settingDefinition.modelKey}
label={settingDefinition.title} label={settingDefinition.title}
size="sm" unit="ms"
type="number" value={`${value}`}
placeholder={`${settingDefinition.defaultValue}`} placeholder={`${settingDefinition.defaultValue}`}
defaultValue={`${value}`} validate={isValidInteger}
containerClassName="!w-48"
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
onChange={(value) => onChange={(value) =>
onChange({ onChange({
...setting, ...setting,
enabled: true, enabled: true,
value: Number.parseInt(value, 10) || 0, value: parseInteger(value),
}) })
} }
/> />
@@ -300,6 +416,141 @@ function IntegerSettingRow({
); );
} }
function MessageSizeSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: number;
setting: IntegerSetting;
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
onChange: (setting: IntegerSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) => onChange(parseMegabytes(value))}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
value={displayValue}
placeholder={placeholder}
onChange={(value) =>
onChange({
...setting,
enabled: true,
value: parseMegabytes(value),
})
}
/>
</SettingOverrideRow>
);
}
function MessageSizeInput({
label,
name,
onChange,
placeholder,
value,
}: {
label: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
return (
<NumberUnitInput
name={name}
label={label}
unit="MB"
value={value}
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>( function isInheritedSetting<T>(
setting: T | { enabled?: boolean; value: T }, setting: T | { enabled?: boolean; value: T },
): setting is { enabled?: boolean; value: T } { ): setting is { enabled?: boolean; value: T } {
@@ -308,7 +559,7 @@ function isInheritedSetting<T>(
function resolveInheritedValue( function resolveInheritedValue(
ancestors: (Folder | Workspace)[], ancestors: (Folder | Workspace)[],
key: "settingRequestTimeout", key: "settingRequestTimeout" | "settingRequestMessageSize",
fallback: IntegerSetting, fallback: IntegerSetting,
): number; ): number;
function resolveInheritedValue( function resolveInheritedValue(
@@ -338,10 +589,46 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick< type WorkspaceSettings = Pick<
Workspace, Workspace,
| "settingFollowRedirects" | "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout" | "settingRequestTimeout"
| "settingSendCookies" | "settingSendCookies"
| "settingStoreCookies" | "settingStoreCookies"
| "settingValidateCertificates" | "settingValidateCertificates"
>; >;
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">; 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
);
}
@@ -4,6 +4,7 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useRef } from "react"; import { useRef } from "react";
import { showConfirmDelete } from "../../lib/confirm"; import { showConfirmDelete } from "../../lib/confirm";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
import { DetailsBanner } from "../core/DetailsBanner"; import { DetailsBanner } from "../core/DetailsBanner";
@@ -232,6 +233,8 @@ export function SettingsCertificates() {
</HStack> </HStack>
</div> </div>
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
{certificates.length > 0 && ( {certificates.length > 0 && (
<VStack space={3}> <VStack space={3}>
{certificates.map((cert, index) => ( {certificates.map((cert, index) => (
@@ -2,22 +2,15 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models"; import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui"; import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates"; import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
import { appInfo } from "../../lib/appInfo"; import { appInfo } from "../../lib/appInfo";
import {
SETTING_FOLLOW_REDIRECTS,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
SETTING_STORE_COOKIES,
SETTING_VALIDATE_CERTIFICATES,
} from "../../lib/requestSettings";
import { revealInFinderText } from "../../lib/reveal"; import { revealInFinderText } from "../../lib/reveal";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton"; import { IconButton } from "../core/IconButton";
import { import {
ModelSettingRowBoolean, ModelSettingRowBoolean,
ModelSettingRowNumber,
ModelSettingSelectControl, ModelSettingSelectControl,
SettingValue, SettingValue,
SettingRow, SettingRow,
@@ -27,20 +20,29 @@ import {
SettingsSection, SettingsSection,
} from "../core/SettingRow"; } from "../core/SettingRow";
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
export function SettingsGeneral() { export function SettingsGeneral() {
const workspace = useAtomValue(activeWorkspaceAtom);
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const checkForUpdates = useCheckForUpdates(); const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) { if (settings == null) {
return null; return null;
} }
const showWorkspaceSettingsMovedBanner =
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
return ( return (
<VStack space={1.5} className="mb-4"> <VStack space={1.5} className="mb-4">
<div className="mb-4"> <div>
<Heading>General</Heading> <Heading>General</Heading>
<p className="text-text-subtle">Configure general settings for update behavior and more.</p> <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?" />
</div> </div>
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
<CargoFeature feature="updater"> <CargoFeature feature="updater">
@@ -76,7 +78,9 @@ export function SettingsGeneral() {
description="Choose whether updates are installed automatically or manually." description="Choose whether updates are installed automatically or manually."
name="autoupdate" name="autoupdate"
value={settings.autoupdate ? "auto" : "manual"} value={settings.autoupdate ? "auto" : "manual"}
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })} onChange={(v) =>
patchModel(settings, { autoupdate: v === "auto" })
}
options={[ options={[
{ label: "Automatic", value: "auto" }, { label: "Automatic", value: "auto" },
{ label: "Manual", value: "manual" }, { label: "Manual", value: "manual" },
@@ -108,54 +112,19 @@ export function SettingsGeneral() {
</SettingsSection> </SettingsSection>
</CargoFeature> </CargoFeature>
<SettingsSection {showWorkspaceSettingsMovedBanner && (
title={ <DismissibleBanner
<> id="workspace-settings-moved-2026-06-30"
Workspace{" "} color="info"
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text"> className="p-4 max-w-xl mx-auto"
{workspace.name} >
</span> <p>
</> Workspace specific settings have moved to{" "}
} <b>Workspace Settings</b>, accessible from the workspace switcher
> menu.
<ModelSettingRowNumber </p>
model={workspace} </DismissibleBanner>
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"> <SettingsSection title="App Info">
<SettingRow title="Version" description="Current Yaak version."> <SettingRow title="Version" description="Current Yaak version.">
@@ -8,6 +8,7 @@ import { useAtomValue } from "jotai";
import { useState } from "react"; import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace"; import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri"; import { invokeCmd } from "../../lib/tauri";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
@@ -252,7 +253,9 @@ function LicenseSettings({ settings }: { settings: Settings }) {
</p> </p>
<p> <p>
Licenses help keep Yaak independent and sustainable.{" "} Licenses help keep Yaak independent and sustainable.{" "}
<Link href="https://yaak.app/pricing?s=badge">Purchase a License </Link> <Link href={pricingUrl("app.license.badge-hide-confirm")}>
Purchase a License
</Link>
</p> </p>
</VStack> </VStack>
), ),
@@ -6,6 +6,7 @@ import { formatDate } from "date-fns/format";
import { useState } from "react"; import { useState } from "react";
import { useToggle } from "../../hooks/useToggle"; import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize"; import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature"; import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import { Link } from "../core/Link"; import { Link } from "../core/Link";
@@ -48,7 +49,7 @@ function SettingsLicenseCmp() {
<span className="opacity-50">Personal use is always free, forever.</span> <span className="opacity-50">Personal use is always free, forever.</span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -68,7 +69,7 @@ function SettingsLicenseCmp() {
</span> </span>
<Separator className="my-2" /> <Separator className="my-2" />
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice"> <div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}> <Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
Learn More Learn More
</Link> </Link>
</div> </div>
@@ -134,7 +135,7 @@ function SettingsLicenseCmp() {
<Button <Button
color="secondary" color="secondary"
size="sm" size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")} onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
> >
Direct Support Direct Support
@@ -150,9 +151,7 @@ function SettingsLicenseCmp() {
color="primary" color="primary"
rightSlot={<Icon icon="external_link" />} rightSlot={<Icon icon="external_link" />}
onClick={() => onClick={() =>
openUrl( openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
)
} }
> >
Purchase License Purchase License
@@ -2,6 +2,7 @@ import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import type { ProxySetting } from "@yaakapp-internal/models"; import type { ProxySetting } from "@yaakapp-internal/models";
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui"; import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { import {
SettingRowBoolean, SettingRowBoolean,
SettingRowSelect, SettingRowSelect,
@@ -33,6 +34,7 @@ export function SettingsProxy() {
traffic, or routing through specific infrastructure. traffic, or routing through specific infrastructure.
</p> </p>
</div> </div>
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
<SettingsList className="space-y-8"> <SettingsList className="space-y-8">
<SettingsSection title="Proxy"> <SettingsSection title="Proxy">
<SettingRowSelect <SettingRowSelect
@@ -7,6 +7,7 @@ import { useExportData } from "../hooks/useExportData";
import { appInfo } from "../lib/appInfo"; import { appInfo } from "../lib/appInfo";
import { showDialog } from "../lib/dialog"; import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData"; import { importData } from "../lib/importData";
import { pricingUrl } from "../lib/pricingUrl";
import type { DropdownRef } from "./core/Dropdown"; import type { DropdownRef } from "./core/Dropdown";
import { Dropdown } from "./core/Dropdown"; import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui"; import { Icon } from "@yaakapp-internal/ui";
@@ -76,7 +77,8 @@ export function SettingsDropdown() {
hidden: check.data == null || check.data.status === "active", hidden: check.data == null || check.data.status === "active",
leftSlot: <Icon icon="circle_dollar_sign" />, leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />, rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () => openUrl("https://yaak.app/pricing"), onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
}, },
{ {
label: "Install CLI", label: "Install CLI",
@@ -105,10 +105,18 @@ function WebsocketEventRow({
: ""; : "";
const iconColor = const iconColor =
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary"; messageType === "error"
? "warning"
: messageType === "close" || messageType === "open"
? "secondary"
: isServer
? "info"
: "primary";
const icon = const icon =
messageType === "close" || messageType === "open" messageType === "error"
? "alert_triangle"
: messageType === "close" || messageType === "open"
? "info" ? "info"
: isServer : isServer
? "arrow_big_down_dash" ? "arrow_big_down_dash"
@@ -119,6 +127,8 @@ function WebsocketEventRow({
"Disconnected from server" "Disconnected from server"
) : messageType === "open" ? ( ) : messageType === "open" ? (
"Connected to server" "Connected to server"
) : messageType === "error" ? (
<span className="text-warning">{message}</span>
) : message === "" ? ( ) : message === "" ? (
<em className="italic text-text-subtlest">No content</em> <em className="italic text-text-subtlest">No content</em>
) : ( ) : (
@@ -170,7 +180,9 @@ function WebsocketEventDetail({
? "Connection Closed" ? "Connection Closed"
: event.messageType === "open" : event.messageType === "open"
? "Connection Open" ? "Connection Open"
: `Message ${event.isServer ? "Received" : "Sent"}`; : event.messageType === "error"
? "WebSocket Error"
: `Message ${event.isServer ? "Received" : "Sent"}`;
const actions: EventDetailAction[] = const actions: EventDetailAction[] =
message !== "" message !== ""
@@ -112,7 +112,9 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
onCreateNewWorkspace={hide} onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })} onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/> />
<WorkspaceEncryptionSetting layout="settings" size="xs" /> <div className="mt-4">
<WorkspaceEncryptionSetting layout="settings" size="xs" />
</div>
</SettingsSection> </SettingsSection>
<ModelSettingsEditor model={workspace} showSectionTitles /> <ModelSettingsEditor model={workspace} showSectionTitles />
</SettingsList> </SettingsList>
@@ -1,57 +1,84 @@
import type { Color } from "@yaakapp-internal/plugins"; import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui"; import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner, HStack } from "@yaakapp-internal/ui"; import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue"; import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button"; import { Button } from "./Button";
export function DismissibleBanner({ export function DismissibleBanner({
children, children,
className, className,
id, id,
onDismiss,
onShow,
actions, actions,
...props ...props
}: BannerProps & { }: BannerProps & {
id: string; id: string;
actions?: { label: string; onClick: () => void; color?: Color }[]; onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>;
actions?: {
label: string;
onClick: () => void;
color?: Color;
variant?: ButtonProps["variant"];
}[];
}) { }) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({ const {
isLoading,
set: setDismissed,
value: dismissed,
} = useKeyValue<boolean>({
namespace: "global", namespace: "global",
key: ["dismiss-banner", id], key: ["dismiss-banner", id],
fallback: false, fallback: false,
}); });
if (dismissed) return null; const shouldShow = !isLoading && !dismissed;
useEffect(() => {
if (shouldShow) {
Promise.resolve(onShow?.()).catch(console.error);
}
}, [onShow, shouldShow]);
if (!shouldShow) return null;
return ( return (
<Banner <Banner className={classNames(className, "relative")} {...props}>
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")} <div className="@container">
{...props} <div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
> {children}
{children} <div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<HStack space={1.5}> <Button
{actions?.map((a) => ( variant="border"
<Button color={props.color}
key={a.label} size="xs"
variant="border" onClick={() => {
color={a.color ?? props.color} setDismissed(true).catch(console.error);
size="xs" Promise.resolve(onDismiss?.()).catch(console.error);
onClick={a.onClick} }}
title={a.label} title="Dismiss message"
> >
{a.label} Dismiss
</Button> </Button>
))} {actions?.map((a) => (
<Button <Button
variant="border" key={a.label}
color={props.color} variant={a.variant ?? "border"}
size="xs" color={a.color ?? props.color}
onClick={() => setDismissed((d) => !d)} size="xs"
title="Dismiss message" onClick={a.onClick}
> title={a.label}
Dismiss >
</Button> {a.label}
</HStack> </Button>
))}
</div>
</div>
</div>
</Banner> </Banner>
); );
} }
@@ -1,6 +1,6 @@
import { HStack } from "@yaakapp-internal/ui"; import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames"; import classNames from "classnames";
import type { FocusEvent, HTMLAttributes, ReactNode } from "react"; import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
import { import {
forwardRef, forwardRef,
useCallback, useCallback,
@@ -28,10 +28,9 @@ export type PlainInputProps = Omit<
| "extraExtensions" | "extraExtensions"
| "forcedEnvironmentId" | "forcedEnvironmentId"
> & > &
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & { Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"]; onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
type?: "text" | "password" | "number"; type?: "text" | "password" | "number";
step?: number;
hideObscureToggle?: boolean; hideObscureToggle?: boolean;
labelRightSlot?: ReactNode; labelRightSlot?: ReactNode;
}; };
@@ -52,6 +51,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
labelClassName, labelClassName,
labelPosition = "top", labelPosition = "top",
labelRightSlot, labelRightSlot,
inputMode,
leftSlot, leftSlot,
name, name,
onBlur, onBlur,
@@ -64,6 +64,7 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
required, required,
rightSlot, rightSlot,
size = "md", size = "md",
step,
tint, tint,
type = "text", type = "text",
validate, validate,
@@ -204,12 +205,14 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
autoComplete="off" autoComplete="off"
autoCapitalize="off" autoCapitalize="off"
autoCorrect="off" autoCorrect="off"
inputMode={inputMode}
onChange={(e) => handleChange(e.target.value)} onChange={(e) => handleChange(e.target.value)}
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))} onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
className={classNames(commonClassName, "h-full disabled:opacity-disabled")} className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
required={required} required={required}
step={step}
placeholder={placeholder} placeholder={placeholder}
onKeyDownCapture={onKeyDownCapture} onKeyDownCapture={onKeyDownCapture}
/> />
@@ -16,6 +16,7 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
import { showConfirm } from "../../lib/confirm"; import { showConfirm } from "../../lib/confirm";
import { showErrorToast } from "../../lib/toast"; import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync"; import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button"; import { Button } from "../core/Button";
import type { CheckboxProps } from "../core/Checkbox"; import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox"; import { Checkbox } from "../core/Checkbox";
@@ -205,7 +206,8 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
layout="horizontal" layout="horizontal"
defaultRatio={0.6} defaultRatio={0.6}
firstSlot={({ style }) => ( firstSlot={({ style }) => (
<div style={style} className="h-full px-4"> <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?" />
<SplitLayout <SplitLayout
storageKey="commit-vertical" storageKey="commit-vertical"
layout="vertical" layout="vertical"
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
title: "Add Remote", title: "Add Remote",
inputs: [ inputs: [
{ type: "text", label: "Name", name: "name", defaultValue: defaultName }, { type: "text", label: "Name", name: "name", defaultValue: defaultName },
{ type: "text", label: "URL", name: "url" }, { type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" },
], ],
}); });
if (r == null) throw new Error("Cancelled remote prompt"); if (r == null) throw new Error("Cancelled remote prompt");
+160 -123
View File
@@ -5,6 +5,7 @@ import { useMemo } from "react";
import { openFolderSettings } from "../commands/openFolderSettings"; import { openFolderSettings } from "../commands/openFolderSettings";
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings"; import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
import { IconTooltip } from "../components/core/IconTooltip"; import { IconTooltip } from "../components/core/IconTooltip";
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
import type { TabItem } from "../components/core/Tabs/Tabs"; import type { TabItem } from "../components/core/Tabs/Tabs";
import { capitalize } from "../lib/capitalize"; import { capitalize } from "../lib/capitalize";
import { showConfirm } from "../lib/confirm"; import { showConfirm } from "../lib/confirm";
@@ -14,156 +15,192 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
import { useInheritedAuthentication } from "./useInheritedAuthentication"; import { useInheritedAuthentication } from "./useInheritedAuthentication";
import { useModelAncestors } from "./useModelAncestors"; import { useModelAncestors } from "./useModelAncestors";
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) { 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 {
const authentication = useHttpAuthenticationSummaries(); const authentication = useHttpAuthenticationSummaries();
const inheritedAuth = useInheritedAuthentication(model); const inheritedAuth = useInheritedAuthentication(model);
const ancestors = useModelAncestors(model); const ancestors = useModelAncestors(model);
const parentModel = ancestors[0] ?? null; const parentModel = ancestors[0] ?? null;
return useMemo<TabItem[]>(() => { return useMemo(() => {
if (model == null) return []; if (model == null) return null;
const tab: TabItem = { return {
value: tabValue, value: model.authenticationType,
label: "Auth", items: [
options: { ...authentication.map((a) => ({
value: model.authenticationType, label: a.label || "UNKNOWN",
items: [ shortLabel: a.shortLabel,
...authentication.map((a) => ({ value: a.name,
label: a.label || "UNKNOWN", })),
shortLabel: a.shortLabel, { type: "separator" },
value: a.name, {
})), label: "Inherit from Parent",
{ type: "separator" }, shortLabel:
{ inheritedAuth != null &&
label: "Inherit from Parent", inheritedAuth.authenticationType !== "none" ? (
shortLabel: <HStack space={1.5}>
inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? ( {authentication.find(
<HStack space={1.5}> (a) => a.name === inheritedAuth.authenticationType,
{authentication.find((a) => a.name === inheritedAuth.authenticationType) )?.shortLabel ?? "UNKNOWN"}
?.shortLabel ?? "UNKNOWN"} <IconTooltip
<IconTooltip icon="zap_off"
icon="zap_off" iconSize="xs"
iconSize="xs" content="Authentication was inherited from an ancestor"
content="Authentication was inherited from an ancestor" />
/> </HStack>
</HStack> ) : (
) : ( "Auth"
"Auth" ),
), value: null,
value: null, },
}, { label: "No Auth", shortLabel: "No Auth", value: "none" },
{ label: "No Auth", shortLabel: "No Auth", value: "none" }, ],
], itemsAfter: (() => {
itemsAfter: (() => { const actions: (
const actions: ( | { type: "separator"; label: string }
| { type: "separator"; label: string } | {
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> } label: string;
)[] = []; leftSlot: React.ReactNode;
onSelect: () => Promise<void>;
// Promote: move auth from current model up to parent
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" });
} }
actions.push({ )[] = [];
label: `Copy from ${modelTypeLabel(ancestorWithAuth)}`,
// 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: ( leftSlot: (
<Icon <Icon
icon={ icon={
ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down" parentModel.model === "workspace"
? "corner_right_up"
: "folder_up"
} }
/> />
), ),
onSelect: async () => { onSelect: async () => {
const confirmed = await showConfirm({ const confirmed = await showConfirm({
id: "copy-auth-confirm", id: "promote-auth-confirm",
title: "Copy Authentication", title: "Promote Authentication",
confirmText: "Copy", confirmText: "Promote",
description: ( description: (
<> <>
Copy{" "} Move authentication config to{" "}
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType) <InlineCode>{resolvedModelName(parentModel)}</InlineCode>?
?.label ?? "authentication"}{" "}
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
This will override the current authentication but will not affect the{" "}
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
</> </>
), ),
}); });
if (confirmed) { if (confirmed) {
await patchModel(model, { await patchModel(model, {
authentication: { ...ancestorWithAuth.authentication }, authentication: {},
authenticationType: ancestorWithAuth.authenticationType, authenticationType: null,
}); });
await patchModel(parentModel, {
authentication: model.authentication,
authenticationType: model.authenticationType,
});
if (parentModel.model === "folder") {
openFolderSettings(parentModel.id, "auth");
} else {
openWorkspaceSettings("auth");
}
} }
}, },
}); },
} );
}
return actions.length > 0 ? actions : undefined; // Copy from ancestor: copy auth config down to current model
})(), const ancestorWithAuth = ancestors.find(
onChange: async (authenticationType) => { (a) =>
let authentication: Folder["authentication"] = model.authentication; a.authenticationType != null && a.authenticationType !== "none",
if (model.authenticationType !== authenticationType) { );
authentication = { if (ancestorWithAuth) {
// Reset auth if changing types if (actions.length === 0) {
}; actions.push({ type: "separator", label: "Actions" });
} }
await patchModel(model, { authentication, authenticationType }); 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 });
}, },
}; };
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
return [tab];
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
} }
+3
View File
@@ -0,0 +1,3 @@
export function pricingUrl(intent: string): string {
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
}
+24 -4
View File
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick< type WorkspaceRequestSettings = Pick<
Workspace, Workspace,
| "settingFollowRedirects" | "settingFollowRedirects"
| "settingRequestMessageSize"
| "settingRequestTimeout" | "settingRequestTimeout"
| "settingSendCookies" | "settingSendCookies"
| "settingStoreCookies" | "settingStoreCookies"
@@ -17,7 +18,9 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never; [M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType]; }[ModelType];
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = { export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
defaultValue: WorkspaceRequestSettings[K]; defaultValue: WorkspaceRequestSettings[K];
description: string; description: string;
modelKey: K; modelKey: K;
@@ -41,11 +44,26 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
title: "Request Timeout", title: "Request Timeout",
}); });
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
defaultValue: 64 * 1024 * 1024,
description:
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
modelKey: "settingRequestMessageSize",
models: ["workspace", "folder", "websocket_request", "grpc_request"],
title: "Message Size Limit",
});
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true, defaultValue: true,
description: "When disabled, skip validation of server certificates.", description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates", modelKey: "settingValidateCertificates",
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"], models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
title: "Validate TLS certificates", title: "Validate TLS certificates",
}); });
@@ -59,7 +77,8 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
export const SETTING_SEND_COOKIES = defineRequestSetting({ export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true, 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", modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"], models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies", title: "Automatically send cookies",
@@ -67,7 +86,8 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({ export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true, 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", modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"], models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies", title: "Automatically store cookies",
+3 -3
View File
@@ -66,7 +66,7 @@
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-pdf": "^10.0.1", "react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.0", "react-use": "^17.6.1",
"rehype-stringify": "^10.0.1", "rehype-stringify": "^10.0.1",
"remark-frontmatter": "^5.0.0", "remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
@@ -102,11 +102,11 @@
"postcss-nesting": "^13.0.2", "postcss-nesting": "^13.0.2",
"rollup": "^4.60.3", "rollup": "^4.60.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plugin-static-copy": "^3.3.0", "vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0", "vite-plugin-svgr": "^4.5.0",
"vite-plugin-top-level-await": "^1.5.0", "vite-plugin-top-level-await": "^1.5.0",
"vite-plugin-wasm": "^3.5.0", "vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+1
View File
@@ -39,6 +39,7 @@ export default defineConfig(async () => {
}), }),
], ],
build: { build: {
target: "esnext",
sourcemap: true, sourcemap: true,
outDir: "../../dist/apps/yaak-client", outDir: "../../dist/apps/yaak-client",
emptyOutDir: true, emptyOutDir: true,
+2 -2
View File
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0", "babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.1.20" "vite-plus": "^0.2.1"
} }
} }
+5 -1
View File
@@ -2,7 +2,7 @@ use crate::models_ext::QueryManagerExt;
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use log::debug; use log::debug;
use std::sync::OnceLock; use std::sync::OnceLock;
use tauri::{AppHandle, Runtime}; use tauri::{AppHandle, Runtime, is_dev};
use yaak_models::util::UpdateSource; use yaak_models::util::UpdateSource;
const NAMESPACE: &str = "analytics"; const NAMESPACE: &str = "analytics";
@@ -36,6 +36,10 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
..Default::default() ..Default::default()
}; };
if is_dev() {
info.current_version = "0.0.1".to_string();
}
app_handle app_handle
.with_tx(|tx| { .with_tx(|tx| {
// Load the previously tracked version // Load the previously tracked version
+15 -9
View File
@@ -295,7 +295,8 @@ async fn cmd_grpc_reflect<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let resolved_settings = 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 plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -332,6 +333,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
&metadata, &metadata,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_certificate, client_certificate,
resolved_settings.request_message_size.value,
) )
.await .await
.map_err(|e| GenericError(e.to_string()))?) .map_err(|e| GenericError(e.to_string()))?)
@@ -353,7 +355,8 @@ async fn cmd_grpc_go<R: Runtime>(
unrendered_request.folder_id.as_deref(), unrendered_request.folder_id.as_deref(),
environment_id, environment_id,
)?; )?;
let resolved_settings = 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 plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone()); let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
@@ -425,6 +428,7 @@ async fn cmd_grpc_go<R: Runtime>(
&metadata, &metadata,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_cert.clone(), client_cert.clone(),
resolved_settings.request_message_size.value,
) )
.await; .await;
@@ -714,7 +718,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -722,7 +726,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -738,7 +742,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Request failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -781,7 +785,7 @@ async fn cmd_grpc_go<R: Runtime>(
Some(s) => GrpcEvent { Some(s) => GrpcEvent {
error: Some(s.message().to_string()), error: Some(s.message().to_string()),
status: Some(s.code() as i32), status: Some(s.code() as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
metadata: metadata_to_map(s.metadata().clone()), metadata: metadata_to_map(s.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
@@ -789,7 +793,7 @@ async fn cmd_grpc_go<R: Runtime>(
None => GrpcEvent { None => GrpcEvent {
error: Some(e.message), error: Some(e.message),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -806,7 +810,7 @@ async fn cmd_grpc_go<R: Runtime>(
&GrpcEvent { &GrpcEvent {
error: Some(e.to_string()), error: Some(e.to_string()),
status: Some(Code::Unknown as i32), status: Some(Code::Unknown as i32),
content: "Failed to connect".to_string(), content: "Stream failed".to_string(),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
..base_event.clone() ..base_event.clone()
}, },
@@ -878,7 +882,8 @@ async fn cmd_grpc_go<R: Runtime>(
.db() .db()
.upsert_grpc_event( .upsert_grpc_event(
&GrpcEvent { &GrpcEvent {
content: status.to_string(), content: "Stream failed".to_string(),
error: Some(status.message().to_string()),
status: Some(status.code() as i32), status: Some(status.code() as i32),
metadata: metadata_to_map(status.metadata().clone()), metadata: metadata_to_map(status.metadata().clone()),
event_type: GrpcEventType::ConnectionEnd, event_type: GrpcEventType::ConnectionEnd,
@@ -887,6 +892,7 @@ async fn cmd_grpc_go<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
) )
.unwrap(); .unwrap();
break;
} }
} }
} }
@@ -79,7 +79,7 @@ impl YaakNotifier {
return Ok(()); return Ok(());
} }
debug!("Checking for notifications"); info!("Checking for notifications");
#[cfg(feature = "license")] #[cfg(feature = "license")]
let license_check = { let license_check = {
@@ -115,17 +115,20 @@ impl YaakNotifier {
]); ]);
let resp = req.send().await?; let resp = req.send().await?;
if resp.status() != 200 { if resp.status() != 200 {
debug!("Skipping notification status code {}", resp.status()); info!("Skipping notification status code {}", resp.status());
return Ok(()); return Ok(());
} }
for notification in resp.json::<Vec<YaakNotification>>().await? { let notifications = resp.json::<Vec<YaakNotification>>().await?;
debug!("Received {} notifications", notifications.len());
for notification in notifications {
let seen = get_kv(app_handle).await?; let seen = get_kv(app_handle).await?;
if seen.contains(&notification.id) { if seen.contains(&notification.id) {
debug!("Already seen notification {}", notification.id); debug!("Already seen notification {}", notification.id);
continue; continue;
} }
debug!("Got notification {:?}", notification); info!("Got notification {:?}", notification);
let _ = app_handle.emit_to(window.label(), "notification", notification.clone()); let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
break; // Only show one notification break; // Only show one notification
+33 -1
View File
@@ -50,6 +50,37 @@ pub async fn cmd_ws_send<R: Runtime>(
ws_manager: State<'_, Mutex<WebsocketManager>>, ws_manager: State<'_, Mutex<WebsocketManager>>,
) -> Result<WebsocketConnection> { ) -> Result<WebsocketConnection> {
let connection = app_handle.db().get_websocket_connection(connection_id)?; let connection = app_handle.db().get_websocket_connection(connection_id)?;
match send_websocket_message(&connection, environment_id, &app_handle, &window, &ws_manager)
.await
{
Ok(connection) => Ok(connection),
Err(e) => {
app_handle.db().upsert_websocket_event(
&WebsocketEvent {
connection_id: connection.id.clone(),
request_id: connection.request_id.clone(),
workspace_id: connection.workspace_id.clone(),
is_server: false,
message_type: WebsocketEventType::Error,
message: e.to_string().into(),
..Default::default()
},
&UpdateSource::from_window_label(window.label()),
)?;
Ok(connection)
}
}
}
async fn send_websocket_message<R: Runtime>(
connection: &WebsocketConnection,
environment_id: Option<&str>,
app_handle: &AppHandle<R>,
window: &WebviewWindow<R>,
ws_manager: &Mutex<WebsocketManager>,
) -> Result<WebsocketConnection> {
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?; let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
let environment_chain = app_handle.db().resolve_environments( let environment_chain = app_handle.db().resolve_environments(
&unrendered_request.workspace_id, &unrendered_request.workspace_id,
@@ -91,7 +122,7 @@ pub async fn cmd_ws_send<R: Runtime>(
&UpdateSource::from_window_label(window.label()), &UpdateSource::from_window_label(window.label()),
)?; )?;
Ok(connection) Ok(connection.clone())
} }
#[command] #[command]
@@ -299,6 +330,7 @@ pub async fn cmd_ws_connect<R: Runtime>(
receive_tx, receive_tx,
resolved_settings.validate_certificates.value, resolved_settings.validate_certificates.value,
client_cert, client_cert,
resolved_settings.request_message_size.value,
) )
.await .await
{ {
+4
View File
@@ -46,6 +46,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GrpcRequest = { export type GrpcRequest = {
@@ -69,6 +70,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -146,6 +148,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -162,6 +165,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+11 -5
View File
@@ -33,15 +33,21 @@ impl AutoReflectionClient {
uri: &Uri, uri: &Uri,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<Self> { ) -> Result<Self> {
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin( let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
get_transport(validate_certificates, client_cert.clone())?, get_transport(validate_certificates, client_cert.clone())?,
uri.clone(), uri.clone(),
); )
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin( .max_decoding_message_size(max_message_size)
get_transport(validate_certificates, client_cert.clone())?, .max_encoding_message_size(max_message_size);
uri.clone(), 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);
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha }) Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
} }
+82 -14
View File
@@ -39,6 +39,7 @@ pub struct GrpcConnection {
conn: Client<HttpsConnector<HttpConnector>, BoxBody>, conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
pub uri: Uri, pub uri: Uri,
use_reflection: bool, use_reflection: bool,
max_message_size: usize,
} }
#[derive(Default, Debug)] #[derive(Default, Debug)]
@@ -97,8 +98,15 @@ impl GrpcConnection {
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
) -> Result<Response<DynamicMessage>> { ) -> Result<Response<DynamicMessage>> {
if self.use_reflection { if self.use_reflection {
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert) reflect_types_for_message(
.await?; self.pool.clone(),
&self.uri,
message,
metadata,
client_cert,
self.max_message_size,
)
.await?;
} }
let method = &self.method(&service, &method).await?; let method = &self.method(&service, &method).await?;
let input_message = method.input(); let input_message = method.input();
@@ -107,7 +115,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -132,6 +140,7 @@ impl GrpcConnection {
message, message,
metadata, metadata,
client_cert, client_cert,
self.max_message_size,
) )
.await?; .await?;
@@ -171,6 +180,7 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -183,8 +193,15 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = if let Err(e) = reflect_types_for_message(
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -206,7 +223,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -237,6 +254,7 @@ impl GrpcConnection {
let md = metadata.clone(); let md = metadata.clone();
let use_reflection = self.use_reflection.clone(); let use_reflection = self.use_reflection.clone();
let client_cert = client_cert.clone(); let client_cert = client_cert.clone();
let max_message_size = self.max_message_size;
stream stream
.then(move |json| { .then(move |json| {
let pool = pool.clone(); let pool = pool.clone();
@@ -249,8 +267,15 @@ impl GrpcConnection {
let json_clone = json.clone(); let json_clone = json.clone();
async move { async move {
if use_reflection { if use_reflection {
if let Err(e) = if let Err(e) = reflect_types_for_message(
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await pool,
&uri,
&json,
&md,
client_cert,
max_message_size,
)
.await
{ {
warn!("Failed to resolve Any types: {e}"); warn!("Failed to resolve Any types: {e}");
} }
@@ -272,7 +297,7 @@ impl GrpcConnection {
.filter_map(|x| x) .filter_map(|x| x)
}; };
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let path = method_desc_to_path(method); let path = method_desc_to_path(method);
let codec = DynamicCodec::new(method.clone()); let codec = DynamicCodec::new(method.clone());
@@ -300,7 +325,7 @@ impl GrpcConnection {
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?; let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
deserializer.end()?; deserializer.end()?;
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone()); let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
let mut req = req_message.into_request(); let mut req = req_message.into_request();
decorate_req(metadata, &mut req)?; decorate_req(metadata, &mut req)?;
@@ -312,6 +337,23 @@ impl GrpcConnection {
} }
} }
fn grpc_client(
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
uri: Uri,
max_message_size: usize,
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
tonic::client::Grpc::with_origin(conn, uri)
.max_decoding_message_size(max_message_size)
.max_encoding_message_size(max_message_size)
}
fn message_size_limit(setting: i32) -> usize {
match setting.try_into() {
Ok(0) | Err(_) => usize::MAX,
Ok(limit) => limit,
}
}
/// Configuration for GrpcHandle to compile proto files /// Configuration for GrpcHandle to compile proto files
#[derive(Clone)] #[derive(Clone)]
pub struct GrpcConfig { pub struct GrpcConfig {
@@ -348,6 +390,7 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<bool> { ) -> Result<bool> {
let server_reflection = proto_files.is_empty(); let server_reflection = proto_files.is_empty();
let key = make_pool_key(id, uri, proto_files); let key = make_pool_key(id, uri, proto_files);
@@ -359,7 +402,14 @@ impl GrpcHandle {
let pool = if server_reflection { let pool = if server_reflection {
let full_uri = uri_from_str(uri)?; let full_uri = uri_from_str(uri)?;
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await fill_pool_from_reflection(
&full_uri,
metadata,
validate_certificates,
client_cert,
message_size_limit(request_message_size),
)
.await
} else { } else {
fill_pool_from_files(&self.config, proto_files).await fill_pool_from_files(&self.config, proto_files).await
}?; }?;
@@ -376,12 +426,21 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Vec<ServiceDefinition>> { ) -> Result<Vec<ServiceDefinition>> {
// Ensure we have a pool; reflect only if missing // Ensure we have a pool; reflect only if missing
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
info!("Reflecting gRPC services for {} at {}", id, uri); info!("Reflecting gRPC services for {} at {}", id, uri);
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert) self.reflect(
.await?; id,
uri,
proto_files,
metadata,
validate_certificates,
client_cert,
request_message_size,
)
.await?;
} }
let pool = self let pool = self
@@ -421,8 +480,10 @@ impl GrpcHandle {
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<GrpcConnection> { ) -> Result<GrpcConnection> {
let use_reflection = proto_files.is_empty(); let use_reflection = proto_files.is_empty();
let max_message_size = message_size_limit(request_message_size);
if self.get_pool(id, uri, proto_files).is_none() { if self.get_pool(id, uri, proto_files).is_none() {
self.reflect( self.reflect(
id, id,
@@ -431,6 +492,7 @@ impl GrpcHandle {
metadata, metadata,
validate_certificates, validate_certificates,
client_cert.clone(), client_cert.clone(),
request_message_size,
) )
.await?; .await?;
} }
@@ -440,7 +502,13 @@ impl GrpcHandle {
.clone(); .clone();
let uri = uri_from_str(uri)?; let uri = uri_from_str(uri)?;
let conn = get_transport(validate_certificates, client_cert.clone())?; let conn = get_transport(validate_certificates, client_cert.clone())?;
Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri }) Ok(GrpcConnection {
pool: Arc::new(RwLock::new(pool)),
use_reflection,
conn,
uri,
max_message_size,
})
} }
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> { fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
+7 -3
View File
@@ -119,9 +119,11 @@ pub async fn fill_pool_from_reflection(
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<DescriptorPool> { ) -> Result<DescriptorPool> {
let mut pool = DescriptorPool::new(); let mut pool = DescriptorPool::new();
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?; let mut client =
AutoReflectionClient::new(uri, validate_certificates, client_cert, max_message_size)?;
for service in list_services(&mut client, metadata).await? { for service in list_services(&mut client, metadata).await? {
if service == "grpc.reflection.v1alpha.ServerReflection" { if service == "grpc.reflection.v1alpha.ServerReflection" {
@@ -192,6 +194,7 @@ pub(crate) async fn reflect_types_for_message(
json: &str, json: &str,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> { ) -> Result<()> {
// 1. Collect all Any types in the JSON // 1. Collect all Any types in the JSON
let mut extra_types = Vec::new(); let mut extra_types = Vec::new();
@@ -201,7 +204,7 @@ pub(crate) async fn reflect_types_for_message(
return Ok(()); // nothing to do return Ok(()); // nothing to do
} }
let mut client = AutoReflectionClient::new(uri, false, client_cert)?; let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
for extra_type in extra_types { for extra_type in extra_types {
{ {
let guard = pool.read().await; let guard = pool.read().await;
@@ -239,6 +242,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
message: &DynamicMessage, message: &DynamicMessage,
metadata: &BTreeMap<String, String>, metadata: &BTreeMap<String, String>,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
max_message_size: usize,
) -> Result<()> { ) -> Result<()> {
let mut extra_types = HashSet::new(); let mut extra_types = HashSet::new();
collect_any_types_from_dynamic_message(message, &mut extra_types); collect_any_types_from_dynamic_message(message, &mut extra_types);
@@ -247,7 +251,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
return Ok(()); return Ok(());
} }
let mut client = AutoReflectionClient::new(uri, false, client_cert)?; let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
for extra_type in extra_types { for extra_type in extra_types {
{ {
let guard = pool.read().await; let guard = pool.read().await;
+6 -1
View File
@@ -109,6 +109,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -184,6 +185,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -456,7 +458,8 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketMessageType = "text" | "binary"; export type WebsocketMessageType = "text" | "binary";
@@ -482,6 +485,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -498,6 +502,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -54,7 +54,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
} }
export async function flushAllModelWrites(): Promise<void> { export async function flushAllModelWrites(): Promise<void> {
const results = await Promise.allSettled([...pendingModelWrites]); const results = await Promise.allSettled(pendingModelWrites);
const rejected = results.find((result) => result.status === "rejected"); const rejected = results.find((result) => result.status === "rejected");
if (rejected?.status === "rejected") { if (rejected?.status === "rejected") {
throw rejected.reason; throw rejected.reason;
@@ -0,0 +1,7 @@
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;
+48 -1
View File
@@ -21,6 +21,8 @@ use ts_rs::TS;
use yaak_database::{Result as DbResult, UpdateSource}; use yaak_database::{Result as DbResult, UpdateSource};
pub use yaak_database::{UpsertModelInfo, upsert_date}; pub use yaak_database::{UpsertModelInfo, upsert_date};
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
#[macro_export] #[macro_export]
macro_rules! impl_model { macro_rules! impl_model {
($t:ty, $variant:ident) => { ($t:ty, $variant:ident) => {
@@ -120,6 +122,7 @@ pub struct ResolvedHttpRequestSettings {
pub validate_certificates: ResolvedSetting<bool>, pub validate_certificates: ResolvedSetting<bool>,
pub follow_redirects: ResolvedSetting<bool>, pub follow_redirects: ResolvedSetting<bool>,
pub request_timeout: ResolvedSetting<i32>, pub request_timeout: ResolvedSetting<i32>,
pub request_message_size: ResolvedSetting<i32>,
pub send_cookies: ResolvedSetting<bool>, pub send_cookies: ResolvedSetting<bool>,
pub store_cookies: ResolvedSetting<bool>, pub store_cookies: ResolvedSetting<bool>,
} }
@@ -130,6 +133,7 @@ impl Default for ResolvedHttpRequestSettings {
validate_certificates: ResolvedSetting::default_source(true), validate_certificates: ResolvedSetting::default_source(true),
follow_redirects: ResolvedSetting::default_source(true), follow_redirects: ResolvedSetting::default_source(true),
request_timeout: ResolvedSetting::default_source(0), request_timeout: ResolvedSetting::default_source(0),
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
send_cookies: ResolvedSetting::default_source(true), send_cookies: ResolvedSetting::default_source(true),
store_cookies: ResolvedSetting::default_source(true), store_cookies: ResolvedSetting::default_source(true),
} }
@@ -400,6 +404,8 @@ pub struct Workspace {
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub setting_follow_redirects: bool, pub setting_follow_redirects: bool,
pub setting_request_timeout: i32, pub setting_request_timeout: i32,
#[serde(default = "default_request_message_size")]
pub setting_request_message_size: i32,
#[serde(default)] #[serde(default)]
pub setting_dns_overrides: Vec<DnsOverride>, pub setting_dns_overrides: Vec<DnsOverride>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
@@ -445,6 +451,7 @@ impl UpsertModelInfo for Workspace {
(EncryptionKeyChallenge, self.encryption_key_challenge.into()), (EncryptionKeyChallenge, self.encryption_key_challenge.into()),
(SettingFollowRedirects, self.setting_follow_redirects.into()), (SettingFollowRedirects, self.setting_follow_redirects.into()),
(SettingRequestTimeout, self.setting_request_timeout.into()), (SettingRequestTimeout, self.setting_request_timeout.into()),
(SettingRequestMessageSize, self.setting_request_message_size.into()),
(SettingValidateCertificates, self.setting_validate_certificates.into()), (SettingValidateCertificates, self.setting_validate_certificates.into()),
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()), (SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
(SettingSendCookies, self.setting_send_cookies.into()), (SettingSendCookies, self.setting_send_cookies.into()),
@@ -463,7 +470,7 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::EncryptionKeyChallenge, WorkspaceIden::EncryptionKeyChallenge,
WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingRequestTimeout,
WorkspaceIden::SettingFollowRedirects, WorkspaceIden::SettingFollowRedirects,
WorkspaceIden::SettingRequestTimeout, WorkspaceIden::SettingRequestMessageSize,
WorkspaceIden::SettingValidateCertificates, WorkspaceIden::SettingValidateCertificates,
WorkspaceIden::SettingDnsOverrides, WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies, WorkspaceIden::SettingSendCookies,
@@ -491,6 +498,7 @@ impl UpsertModelInfo for Workspace {
authentication_type: row.get("authentication_type")?, authentication_type: row.get("authentication_type")?,
setting_follow_redirects: row.get("setting_follow_redirects")?, setting_follow_redirects: row.get("setting_follow_redirects")?,
setting_request_timeout: row.get("setting_request_timeout")?, setting_request_timeout: row.get("setting_request_timeout")?,
setting_request_message_size: row.get("setting_request_message_size")?,
setting_validate_certificates: row.get("setting_validate_certificates")?, setting_validate_certificates: row.get("setting_validate_certificates")?,
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(), setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
setting_send_cookies: row.get("setting_send_cookies")?, setting_send_cookies: row.get("setting_send_cookies")?,
@@ -962,6 +970,8 @@ pub struct Folder {
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting, pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting, pub setting_request_timeout: InheritedIntSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for Folder { impl UpsertModelInfo for Folder {
@@ -1009,6 +1019,10 @@ impl UpsertModelInfo for Folder {
), ),
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()), (SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()), (SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -1027,6 +1041,7 @@ impl UpsertModelInfo for Folder {
FolderIden::SettingValidateCertificates, FolderIden::SettingValidateCertificates,
FolderIden::SettingFollowRedirects, FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout, FolderIden::SettingRequestTimeout,
FolderIden::SettingRequestMessageSize,
] ]
} }
@@ -1041,6 +1056,7 @@ impl UpsertModelInfo for Folder {
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_follow_redirects: String = row.get("setting_follow_redirects")?; let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
let setting_request_timeout: String = row.get("setting_request_timeout")?; let setting_request_timeout: String = row.get("setting_request_timeout")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -1062,6 +1078,8 @@ impl UpsertModelInfo for Folder {
.unwrap_or_default(), .unwrap_or_default(),
setting_request_timeout: serde_json::from_str(&setting_request_timeout) setting_request_timeout: serde_json::from_str(&setting_request_timeout)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -1398,6 +1416,8 @@ pub struct WebsocketRequest {
pub setting_send_cookies: InheritedBoolSetting, pub setting_send_cookies: InheritedBoolSetting,
pub setting_store_cookies: InheritedBoolSetting, pub setting_store_cookies: InheritedBoolSetting,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for WebsocketRequest { impl UpsertModelInfo for WebsocketRequest {
@@ -1446,6 +1466,10 @@ impl UpsertModelInfo for WebsocketRequest {
SettingValidateCertificates, SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(), serde_json::to_string(&self.setting_validate_certificates)?.into(),
), ),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -1466,6 +1490,7 @@ impl UpsertModelInfo for WebsocketRequest {
WebsocketRequestIden::SettingSendCookies, WebsocketRequestIden::SettingSendCookies,
WebsocketRequestIden::SettingStoreCookies, WebsocketRequestIden::SettingStoreCookies,
WebsocketRequestIden::SettingValidateCertificates, WebsocketRequestIden::SettingValidateCertificates,
WebsocketRequestIden::SettingRequestMessageSize,
] ]
} }
@@ -1479,6 +1504,7 @@ impl UpsertModelInfo for WebsocketRequest {
let setting_send_cookies: String = row.get("setting_send_cookies")?; let setting_send_cookies: String = row.get("setting_send_cookies")?;
let setting_store_cookies: String = row.get("setting_store_cookies")?; let setting_store_cookies: String = row.get("setting_store_cookies")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -1499,6 +1525,8 @@ impl UpsertModelInfo for WebsocketRequest {
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(), setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates) setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -1509,6 +1537,7 @@ impl UpsertModelInfo for WebsocketRequest {
pub enum WebsocketEventType { pub enum WebsocketEventType {
Binary, Binary,
Close, Close,
Error,
Frame, Frame,
Open, Open,
Ping, Ping,
@@ -2039,6 +2068,8 @@ pub struct GrpcRequest {
/// Server URL (http for plaintext or https for secure) /// Server URL (http for plaintext or https for secure)
pub url: String, pub url: String,
pub setting_validate_certificates: InheritedBoolSetting, pub setting_validate_certificates: InheritedBoolSetting,
#[serde(default = "default_request_message_size_setting")]
pub setting_request_message_size: InheritedIntSetting,
} }
impl UpsertModelInfo for GrpcRequest { impl UpsertModelInfo for GrpcRequest {
@@ -2086,6 +2117,10 @@ impl UpsertModelInfo for GrpcRequest {
SettingValidateCertificates, SettingValidateCertificates,
serde_json::to_string(&self.setting_validate_certificates)?.into(), serde_json::to_string(&self.setting_validate_certificates)?.into(),
), ),
(
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
]) ])
} }
@@ -2105,6 +2140,7 @@ impl UpsertModelInfo for GrpcRequest {
GrpcRequestIden::Authentication, GrpcRequestIden::Authentication,
GrpcRequestIden::Metadata, GrpcRequestIden::Metadata,
GrpcRequestIden::SettingValidateCertificates, GrpcRequestIden::SettingValidateCertificates,
GrpcRequestIden::SettingRequestMessageSize,
] ]
} }
@@ -2115,6 +2151,7 @@ impl UpsertModelInfo for GrpcRequest {
let authentication: String = row.get("authentication")?; let authentication: String = row.get("authentication")?;
let metadata: String = row.get("metadata")?; let metadata: String = row.get("metadata")?;
let setting_validate_certificates: String = row.get("setting_validate_certificates")?; let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
Ok(Self { Ok(Self {
id: row.get("id")?, id: row.get("id")?,
model: row.get("model")?, model: row.get("model")?,
@@ -2134,6 +2171,8 @@ impl UpsertModelInfo for GrpcRequest {
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(), metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates) setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
.unwrap_or_default(), .unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
}) })
} }
} }
@@ -2684,6 +2723,14 @@ fn default_true() -> bool {
true true
} }
fn default_request_message_size() -> i32 {
DEFAULT_REQUEST_MESSAGE_SIZE
}
fn default_request_message_size_setting() -> InheritedIntSetting {
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
}
fn default_http_method() -> String { fn default_http_method() -> String {
"GET".to_string() "GET".to_string()
} }
@@ -180,6 +180,14 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.request_timeout parent.request_timeout
}, },
request_message_size: if folder.setting_request_message_size.enabled {
ResolvedSetting::from_model(
folder.setting_request_message_size.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if folder.setting_send_cookies.enabled { send_cookies: if folder.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
folder.setting_send_cookies.value, folder.setting_send_cookies.value,
@@ -129,6 +129,14 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.validate_certificates parent.validate_certificates
}, },
request_message_size: if grpc_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
grpc_request.setting_request_message_size.value,
AnyModel::GrpcRequest(grpc_request.clone()),
)
} else {
parent.request_message_size
},
..parent ..parent
}) })
} }
@@ -131,6 +131,7 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.request_timeout parent.request_timeout
}, },
request_message_size: parent.request_message_size,
send_cookies: if http_request.setting_send_cookies.enabled { send_cookies: if http_request.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
http_request.setting_send_cookies.value, http_request.setting_send_cookies.value,
@@ -139,6 +139,14 @@ impl<'a> ClientDb<'a> {
} else { } else {
parent.validate_certificates parent.validate_certificates
}, },
request_message_size: if websocket_request.setting_request_message_size.enabled {
ResolvedSetting::from_model(
websocket_request.setting_request_message_size.value,
AnyModel::WebsocketRequest(websocket_request.clone()),
)
} else {
parent.request_message_size
},
send_cookies: if websocket_request.setting_send_cookies.enabled { send_cookies: if websocket_request.setting_send_cookies.enabled {
ResolvedSetting::from_model( ResolvedSetting::from_model(
websocket_request.setting_send_cookies.value, websocket_request.setting_send_cookies.value,
@@ -21,6 +21,7 @@ impl<'a> ClientDb<'a> {
&Workspace { &Workspace {
name: "Yaak".to_string(), name: "Yaak".to_string(),
setting_follow_redirects: true, setting_follow_redirects: true,
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true, setting_validate_certificates: true,
..Default::default() ..Default::default()
}, },
@@ -102,6 +103,10 @@ impl<'a> ClientDb<'a> {
workspace.setting_request_timeout, workspace.setting_request_timeout,
AnyModel::Workspace(workspace.clone()), AnyModel::Workspace(workspace.clone()),
), ),
request_message_size: ResolvedSetting::from_model(
workspace.setting_request_message_size,
AnyModel::Workspace(workspace.clone()),
),
send_cookies: ResolvedSetting::from_model( send_cookies: ResolvedSetting::from_model(
workspace.setting_send_cookies, workspace.setting_send_cookies,
AnyModel::Workspace(workspace.clone()), AnyModel::Workspace(workspace.clone()),
+6 -1
View File
@@ -108,6 +108,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -183,6 +184,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -426,7 +428,8 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { export type WebsocketRequest = {
model: "websocket_request"; model: "websocket_request";
@@ -450,6 +453,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -466,6 +470,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -1070,7 +1070,7 @@ impl PluginManager {
&InternalEventPayload::ImportRequest(ImportRequest { &InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(), content: content.to_string(),
}), }),
Duration::from_secs(60), Duration::from_secs(5),
) )
.await?; .await?;
+4
View File
@@ -46,6 +46,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GrpcRequest = { export type GrpcRequest = {
@@ -69,6 +70,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -159,6 +161,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -175,6 +178,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+11 -1
View File
@@ -20,6 +20,7 @@ pub async fn ws_connect(
headers: HeaderMap<HeaderValue>, headers: HeaderMap<HeaderValue>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> { ) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
info!("Connecting to WS {url}"); info!("Connecting to WS {url}");
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?; let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
@@ -34,7 +35,7 @@ pub async fn ws_connect(
let (stream, response) = connect_async_tls_with_config( let (stream, response) = connect_async_tls_with_config(
req, req,
Some(WebSocketConfig::default()), Some(websocket_config(request_message_size)),
false, false,
Some(Connector::Rustls(Arc::new(tls_config))), Some(Connector::Rustls(Arc::new(tls_config))),
) )
@@ -48,3 +49,12 @@ pub async fn ws_connect(
Ok((stream, response)) Ok((stream, response))
} }
fn websocket_config(request_message_size: i32) -> WebSocketConfig {
let max_message_size = message_size_limit(request_message_size);
WebSocketConfig::default().max_message_size(max_message_size).max_frame_size(max_message_size)
}
pub(crate) fn message_size_limit(setting: i32) -> Option<usize> {
setting.try_into().ok().filter(|limit| *limit > 0)
}
+2 -2
View File
@@ -4,7 +4,7 @@ use tokio_tungstenite::tungstenite;
#[derive(Error, Debug)] #[derive(Error, Debug)]
pub enum Error { pub enum Error {
#[error("WebSocket error: {0}")] #[error("{0}")]
WebSocketErr(#[from] tungstenite::Error), WebSocketErr(#[from] tungstenite::Error),
#[error(transparent)] #[error(transparent)]
@@ -16,7 +16,7 @@ pub enum Error {
#[error(transparent)] #[error(transparent)]
TlsError(#[from] yaak_tls::error::Error), TlsError(#[from] yaak_tls::error::Error),
#[error("WebSocket error: {0}")] #[error("{0}")]
GenericError(String), GenericError(String),
} }
+28 -8
View File
@@ -1,4 +1,5 @@
use crate::connect::ws_connect; use crate::connect::{message_size_limit, ws_connect};
use crate::error::Error::GenericError;
use crate::error::Result; use crate::error::Result;
use futures_util::stream::SplitSink; use futures_util::stream::SplitSink;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@@ -15,10 +16,16 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
use yaak_tls::ClientCertificateConfig; use yaak_tls::ClientCertificateConfig;
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
struct WebsocketConnection {
max_message_size: Option<usize>,
sink: WebsocketSink,
}
#[derive(Clone)] #[derive(Clone)]
pub struct WebsocketManager { pub struct WebsocketManager {
connections: connections: Arc<Mutex<HashMap<String, WebsocketConnection>>>,
Arc<Mutex<HashMap<String, SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>>,
read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>, read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,
} }
@@ -35,14 +42,20 @@ impl WebsocketManager {
receive_tx: mpsc::Sender<Message>, receive_tx: mpsc::Sender<Message>,
validate_certificates: bool, validate_certificates: bool,
client_cert: Option<ClientCertificateConfig>, client_cert: Option<ClientCertificateConfig>,
request_message_size: i32,
) -> Result<Response> { ) -> Result<Response> {
let tx = receive_tx.clone(); let tx = receive_tx.clone();
let max_message_size = message_size_limit(request_message_size);
let (stream, response) = let (stream, response) =
ws_connect(url, headers, validate_certificates, client_cert).await?; ws_connect(url, headers, validate_certificates, client_cert, request_message_size)
.await?;
let (write, mut read) = stream.split(); let (write, mut read) = stream.split();
self.connections.lock().await.insert(id.to_string(), write); self.connections
.lock()
.await
.insert(id.to_string(), WebsocketConnection { max_message_size, sink: write });
let handle = { let handle = {
let connection_id = id.to_string(); let connection_id = id.to_string();
@@ -70,13 +83,20 @@ impl WebsocketManager {
} }
pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> { pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> {
debug!("Send websocket message {msg:?}");
let mut connections = self.connections.lock().await; let mut connections = self.connections.lock().await;
let connection = match connections.get_mut(id) { let connection = match connections.get_mut(id) {
None => return Ok(()), None => return Ok(()),
Some(c) => c, Some(c) => c,
}; };
connection.send(msg).await?; 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?;
Ok(()) Ok(())
} }
@@ -84,7 +104,7 @@ impl WebsocketManager {
info!("Closing websocket"); info!("Closing websocket");
if let Some(mut connection) = self.connections.lock().await.remove(id) { if let Some(mut connection) = self.connections.lock().await.remove(id) {
// Wait a maximum of 1 second for the connection to close // Wait a maximum of 1 second for the connection to close
if let Err(e) = connection.close().await { if let Err(e) = connection.sink.close().await {
warn!("Failed to close websocket connection {e:?}"); warn!("Failed to close websocket connection {e:?}");
}; };
} }
+1320 -838
View File
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -121,14 +121,13 @@
"nodejs-file-downloader": "^4.13.0", "nodejs-file-downloader": "^4.13.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"typescript": "^5.8.3", "typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
"vite-plus": "^0.1.20", "vite-plus": "^0.2.1",
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20" "vitest": "^4.1.9"
}, },
"overrides": { "overrides": {
"js-yaml": "^4.1.1", "js-yaml": "^4.1.1",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20", "vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1"
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
}, },
"packageManager": "npm@11.11.1" "packageManager": "npm@11.11.1"
} }
+6 -1
View File
@@ -108,6 +108,7 @@ export type Folder = {
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting; settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting; settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type GraphQlIntrospection = { export type GraphQlIntrospection = {
@@ -183,6 +184,7 @@ export type GrpcRequest = {
*/ */
url: string; url: string;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type HttpRequest = { export type HttpRequest = {
@@ -426,7 +428,8 @@ export type WebsocketEvent = {
messageType: WebsocketEventType; messageType: WebsocketEventType;
}; };
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text"; export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketRequest = { export type WebsocketRequest = {
model: "websocket_request"; model: "websocket_request";
@@ -450,6 +453,7 @@ export type WebsocketRequest = {
settingSendCookies: InheritedBoolSetting; settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting; settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting; settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
}; };
export type Workspace = { export type Workspace = {
@@ -466,6 +470,7 @@ export type Workspace = {
settingValidateCertificates: boolean; settingValidateCertificates: boolean;
settingFollowRedirects: boolean; settingFollowRedirects: boolean;
settingRequestTimeout: number; settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>; settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean; settingSendCookies: boolean;
settingStoreCookies: boolean; settingStoreCookies: boolean;
+1 -1
View File
@@ -6,7 +6,7 @@
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs" "build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
}, },
"dependencies": { "dependencies": {
"ws": "^8.20.1" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.0.13", "@types/node": "^24.0.13",
+3
View File
@@ -166,6 +166,9 @@ function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables { function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
return completeFullColorVariables(theme, { return completeFullColorVariables(theme, {
text: color.desaturate(0.5).lift(0.12).css(),
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
surface: color.translucify(0.95).css(), surface: color.translucify(0.95).css(),
surfaceHighlight: color.translucify(0.85).css(), surfaceHighlight: color.translucify(0.85).css(),
border: color.lift(0.3).translucify(0.8).css(), border: color.lift(0.3).translucify(0.8).css(),
+1 -1
View File
@@ -17,7 +17,7 @@
"@hono/mcp": "^0.2.3", "@hono/mcp": "^0.2.3",
"@hono/node-server": "^1.19.13", "@hono/node-server": "^1.19.13",
"@modelcontextprotocol/sdk": "^1.26.0", "@modelcontextprotocol/sdk": "^1.26.0",
"hono": "^4.12.14", "hono": "^4.12.25",
"zod": "^3.25.76" "zod": "^3.25.76"
}, },
"devDependencies": { "devDependencies": {
+2 -2
View File
@@ -10,10 +10,10 @@
"test": "vp test --run tests" "test": "vp test --run tests"
}, },
"dependencies": { "dependencies": {
"openapi-to-postmanv2": "^5.8.0",
"yaml": "^2.8.3" "yaml": "^2.8.3"
}, },
"devDependencies": { "devDependencies": {
"@types/openapi-to-postmanv2": "^5.0.0", "@types/openapi-to-postmanv2": "^5.0.0"
"openapi-to-postmanv2": "^5.8.0"
} }
} }
+17 -809
View File
@@ -1,37 +1,7 @@
import type { import { convertPostman } from "@yaak/importer-postman/src";
Context, import type { Context, PluginDefinition } from "@yaakapp/api";
Environment,
Folder,
HttpRequest,
HttpRequestHeader,
HttpUrlParameter,
PartialImportResources,
PluginDefinition,
Workspace,
} from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin"; import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import YAML from "yaml"; import { convert } from "openapi-to-postmanv2";
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
type UnknownRecord = Record<string, unknown>;
type ImportResources = {
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId" | "variables">[];
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
};
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
const BODY_CONTENT_TYPE_PREFERENCE = [
"application/json",
"application/x-www-form-urlencoded",
"multipart/form-data",
"application/xml",
"text/plain",
];
const MAX_EXAMPLE_DEPTH = 8;
const MAX_EXAMPLE_PROPERTIES = 25;
const MAX_DESCRIPTION_ITEMS = 40;
export const plugin: PluginDefinition = { export const plugin: PluginDefinition = {
importer: { importer: {
@@ -44,785 +14,23 @@ export const plugin: PluginDefinition = {
}; };
export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> { export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {
const spec = parseSpec(contents); // oxlint-disable-next-line no-explicit-any
if (!isOpenApiSpec(spec)) return undefined; 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 importState = new ImportState(spec); if (Array.isArray(result.output) && result.output.length > 0) {
const workspace: ImportResources["workspaces"][0] = { resolve(result.output[0].data);
model: "workspace", }
id: importState.generateId("workspace"),
name: stringAt(spec.info, "title") ?? "OpenAPI Import",
description: importInfoDescription(toRecord(spec.info)),
};
const resources: ImportResources = {
workspaces: [workspace],
environments: [],
folders: [],
httpRequests: [],
};
const baseUrl = importBaseUrl(spec);
const requestBaseUrl = baseUrl.length > 0 ? "${[baseUrl]}" : "";
if (baseUrl.length > 0) {
resources.environments.push({
model: "environment",
id: importState.generateId("environment"),
workspaceId: workspace.id,
name: "Global Variables",
variables: [{ name: "baseUrl", value: baseUrl }],
parentModel: "workspace",
parentId: null,
sortPriority: importState.nextSortPriority(),
});
}
const folderIdsByTag = new Map<string, string>();
for (const tag of toArray(spec.tags)) {
const tagRecord = toRecord(tag);
const name = stringAt(tagRecord, "name");
if (name == null || folderIdsByTag.has(name)) continue;
const folder: ImportResources["folders"][0] = {
model: "folder",
id: importState.generateId("folder"),
workspaceId: workspace.id,
name,
description: importTagDescription(tagRecord),
folderId: null,
sortPriority: importState.nextSortPriority(),
};
resources.folders.push(folder);
folderIdsByTag.set(name, folder.id);
}
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
const pathItem = importState.resolve(rawPathItem);
if (!isRecord(pathItem)) continue;
const pathParameters = toArray(pathItem.parameters);
for (const method of HTTP_METHODS) {
const operation = importState.resolve(pathItem[method]);
if (!isRecord(operation)) continue;
const folderId = findOrCreateFolderId({
folderIdsByTag,
importState,
operation,
resources,
workspaceId: workspace.id,
}); });
});
resources.httpRequests.push(
importOperation({
importState,
method,
operation,
path: rawPath,
pathParameters,
requestBaseUrl,
spec,
workspaceId: workspace.id,
folderId,
}),
);
}
}
if (resources.httpRequests.length === 0) return undefined;
return {
resources: deleteUndefinedAttrs(
convertTemplateSyntax({
environments: resources.environments,
folders: resources.folders,
grpcRequests: [],
httpRequests: resources.httpRequests,
websocketRequests: [],
workspaces: resources.workspaces,
}),
) as PartialImportResources,
};
}
function importOperation({
importState,
method,
operation,
path,
pathParameters,
requestBaseUrl,
spec,
workspaceId,
folderId,
}: {
importState: ImportState;
method: string;
operation: UnknownRecord;
path: string;
pathParameters: unknown[];
requestBaseUrl: string;
spec: UnknownRecord;
workspaceId: string;
folderId: string | null;
}): ImportResources["httpRequests"][0] {
const parameters = [...pathParameters, ...toArray(operation.parameters)].map((p) =>
importState.resolve(p),
);
const body = importBody({ importState, operation, parameters, spec });
const urlParameters = importUrlParameters({ importState, parameters });
const headers = mergeHeaders(importHeaderParameters({ importState, parameters }), body.headers);
return {
model: "http_request",
id: importState.generateId("http_request"),
workspaceId,
folderId,
name: importOperationName(operation, method, path),
description: importOperationDescription({
importState,
operation,
parameters,
bodyContentType: body.bodyType,
}),
method: method.toUpperCase(),
url: buildOperationUrl(requestBaseUrl, path),
urlParameters,
headers,
body: body.body,
bodyType: body.bodyType,
sortPriority: importState.nextSortPriority(),
...importAuthentication({ importState, operation, spec }),
};
}
function parseSpec(contents: string): unknown {
try {
return JSON.parse(contents);
} catch { } catch {
// Fall through to YAML. // Probably not an OpenAPI file, so skip it
return undefined;
} }
try { return convertPostman(JSON.stringify(postmanCollection));
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
@@ -1,26 +0,0 @@
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
@@ -1,8 +0,0 @@
# 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
@@ -1,399 +0,0 @@
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
@@ -1,69 +0,0 @@
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
@@ -1,78 +0,0 @@
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
+3 -205
View File
@@ -5,13 +5,7 @@ import { convertOpenApi } from "../src";
describe("importer-openapi", () => { describe("importer-openapi", () => {
const p = path.join(__dirname, "fixtures"); const p = path.join(__dirname, "fixtures");
const fixtures = fs.readdirSync(p).filter((fixture) => { const fixtures = fs.readdirSync(p);
return fs.statSync(path.join(p, fixture)).isFile();
});
const realWorldFixturesPath = path.join(p, "real-world");
const realWorldFixtures = fs
.readdirSync(realWorldFixturesPath)
.filter((fixture) => fixture.endsWith(".yaml"));
test("Maps operation description to request description", async () => { test("Maps operation description to request description", async () => {
const imported = await convertOpenApi( const imported = await convertOpenApi(
@@ -31,195 +25,7 @@ describe("importer-openapi", () => {
expect(imported?.resources.httpRequests).toEqual([ expect(imported?.resources.httpRequests).toEqual([
expect.objectContaining({ expect.objectContaining({
description: expect.stringContaining("Lijst van klanten"), description: "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" }],
}), }),
]); ]);
}); });
@@ -240,15 +46,7 @@ describe("importer-openapi", () => {
}), }),
]); ]);
expect(imported?.resources.httpRequests.length).toBe(19); expect(imported?.resources.httpRequests.length).toBe(19);
expect(imported?.resources.folders.map((f) => f.name)).toEqual(["pet", "store", "user"]); expect(imported?.resources.folders.length).toBe(7);
});
}
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();
}); });
} }
}); });
@@ -1,19 +0,0 @@
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,6 +69,9 @@ const config = JSON.stringify({
const normalizedAdditionalArgs = []; const normalizedAdditionalArgs = [];
for (let i = 0; i < additionalArgs.length; i++) { for (let i = 0; i < additionalArgs.length; i++) {
const arg = additionalArgs[i]; const arg = additionalArgs[i];
if (arg === "--") {
continue;
}
if (arg === "--config" && i + 1 < additionalArgs.length) { if (arg === "--config" && i + 1 < additionalArgs.length) {
const value = additionalArgs[i + 1]; const value = additionalArgs[i + 1];
const isInlineJson = value.trimStart().startsWith("{"); const isInlineJson = value.trimStart().startsWith("{");