Compare commits

..
Author SHA1 Message Date
Gregory SchierandClaude Opus 5 333e3c1653 feat(import): add stable per-resource source keys to the importer contract
Re-importing an edited document currently has no way to tell an updated
request from a new one, because every import mints fresh model IDs. Give
each imported resource a key that identifies its source element instead,
so a later import can match against what it already created.

`ImportResponse` gains an optional `sourceKeys` map, keyed by the resource
IDs plugins already emit. A parallel map keeps `ImportResources` and the
partial TS types unchanged.

Bundled importers emit keys where the format carries them: Postman item
IDs, Insomnia `_id`s, OpenAPI `operationId` (falling back to the route),
and Yaak model IDs. curl has no identity of its own, so it emits none.

Planning derives a key for anything left over, hashed from the model type,
folder ancestry, and name or method+URL, prefixed `fb:` so later code can
tell derived keys from importer-provided ones. These break when the source
document renames or moves a resource, which is accepted.

Keys land in `ImportPlan.source_keys`, keyed by minted model ID so the
mapping stays lossless, ready for the commit step to persist. Nothing
consumes them yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 11:14:52 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
Gregory SchierandClaude Fable 5 50cccf1d25 fix(auth-oauth1): sign with the token secret when no access token is set (#611)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:40 -07:00
Gregory SchierandClaude Fable 5 0aae516f3a feat(importer-openapi): fill the OAuth redirect URI from an environment variable (#610)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:27:36 -07:00
Gregory SchierandClaude Fable 5 b7b8ae5f94 feat(settings): add HTTP version as an inherited request setting (#609)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 15:01:02 -07:00
Gregory SchierandClaude Fable 5 6777003b70 fix(updater): check for updates on Linux deb/rpm installs and prompt manual download (#604)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:43:54 -07:00
Gregory SchierandClaude Fable 5 426c6d5eb6 fix(plugins): render template function config values before plugins see them (#608)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 19:19:15 -07:00
Ngo Quoc Viet 068afe325e fix(importer-curl): keep an = inside --url-query and form values (#606) 2026-08-24 21:14:37 -07:00
Ngo Quoc Viet cef1129d4b fix(template-function-json): escape control characters in json.escape (#607) 2026-08-24 21:14:14 -07:00
Ngo Quoc Viet 9a7bcf73bb fix(auth-oauth1): use the signing key as the PLAINTEXT signature (#605) 2026-08-24 21:13:57 -07:00
Gregory SchierandClaude Opus 5 aa76d501f0 fix(appearance): detect the macOS system appearance via NSApp.effectiveAppearance (#603)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:04:26 -07:00
Gregory Schier ce8cb5e7bc fix(environments): avoid opening dropdown with editor 2026-08-20 11:14:36 -07:00
88 changed files with 4564 additions and 620 deletions
Generated
+4
View File
@@ -11225,6 +11225,7 @@ dependencies = [
"base64 0.22.1",
"log 0.4.29",
"md5 0.8.0",
"rusqlite",
"serde_json",
"tempfile",
"thiserror 2.0.17",
@@ -11723,7 +11724,10 @@ name = "yaak-system-appearance"
version = "0.1.0"
dependencies = [
"dark-light",
"dispatch2",
"log 0.4.29",
"objc2-app-kit",
"objc2-foundation 0.3.1",
"tauri",
]
@@ -67,7 +67,14 @@ export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdo
)}
// If no environments, the button simply opens the dialog.
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
onClick={
subEnvironments.length === 0
? (event) => {
event.preventDefault();
editEnvironment(null);
}
: undefined
}
{...buttonProps}
>
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
+180 -63
View File
@@ -1,9 +1,8 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import {
handleRefresh,
jsonCompletion,
@@ -11,12 +10,20 @@ import {
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
@@ -42,21 +54,16 @@ export function GrpcEditor({
setEditorView(h);
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorView == null ||
services === null ||
request.service === null ||
request.method === null
) {
return;
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
console.log("Failed to find service", { service: request.service, services });
showAlert({
return {
type: "error",
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
@@ -64,14 +71,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
log: ["Failed to find service", { service: request.service, services }],
};
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (request.method != null && schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({
if (schema == null) {
return {
type: "error",
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
@@ -80,18 +87,15 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
}
if (schema == null) {
return;
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
}
try {
updateSchema(editorView, JSON.parse(schema));
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) {
showAlert({
return {
type: "error",
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
@@ -103,9 +107,22 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
log: ["Failed to parse schema", err],
};
}
}, [editorView, services, request.method, request.service]);
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo(
() => [
@@ -124,45 +141,145 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
reflectionLoading
? "secondary"
: reflectionUnavailable
? "info"
: reflectionError
? "danger"
: "secondary"
}
isLoading={reflectionLoading}
onClick={() => {
showDialog({
title: "Configure Schema",
size: "md",
id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
}}
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0
? "Schema Detected"
: "Select Schema"}
</Button>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>,
],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
);
return (
+191 -20
View File
@@ -1,17 +1,37 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
modelTypeLabel,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { pluralizeCount } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
interface Props {
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
/** Sentinel for the "create a new workspace" option. Workspace IDs are prefixed `wk_`, so this
* can never collide with a real one. */
const NEW_WORKSPACE = "new_workspace";
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
@@ -31,8 +51,20 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({ importFile, importUrl }: Props) {
export function ImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
commit,
cancel,
onError,
}: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [destinationId, setDestinationId] = useState<string>(NEW_WORKSPACE);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
@@ -71,19 +103,117 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
selectSource(selected);
};
const handleImport = async () => {
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder =
selectedFolder != null && destinationId === currentWorkspace?.id;
const destination = (): ImportDestination => {
if (destinationId === NEW_WORKSPACE) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
try {
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit(plan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
if (plan != null) {
const counts = [
[plan.resources.workspaces[0], plan.resources.workspaces.length],
[plan.resources.environments[0], plan.resources.environments.length],
[plan.resources.folders[0], plan.resources.folders.length],
[plan.resources.httpRequests[0], plan.resources.httpRequests.length],
[plan.resources.grpcRequests[0], plan.resources.grpcRequests.length],
[plan.resources.websocketRequests[0], plan.resources.websocketRequests.length],
] as const;
const destinationLabel = (() => {
if (plan.destination.type === "new_workspace") return "New workspace";
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name;
})();
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div>
<div className="text-sm font-semibold mb-1">Resources</div>
<ul className="list-disc pl-6 text-sm text-text-subtle">
{counts.map(([model, count]) =>
model == null ? null : (
<li key={model.model}>{pluralizeCount(modelTypeLabel(model), count)}</li>
),
)}
</ul>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading ? "Importing" : "Confirm Import"}
</Button>
</HStack>
</VStack>
);
}
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -115,25 +245,66 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<PlainInput
label="Or enter a file path or URL"
<Select
name="import-destination"
label="Import location"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
value={destinationId}
onChange={setDestinationId}
// The native macOS select drops separators
filterable
options={[
{ value: NEW_WORKSPACE, label: "New Workspace" },
...(workspaces.length > 0
? [{ type: "separator" as const, label: "Existing Workspaces" }]
: []),
...workspaces.map((w) => ({
value: w.id,
label: w.id === currentWorkspace?.id ? `${w.name} (current workspace)` : w.name,
})),
]}
/>
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={trimmedSource === "" || isLoading}
isLoading={isLoading}
size="sm"
onClick={handleImport}
onClick={handlePreview}
>
{isLoading ? "Importing" : "Import"}
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</VStack>
</HStack>
</VStack>
);
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
@@ -2,7 +2,9 @@ import type {
Folder,
GrpcRequest,
HttpRequest,
HttpVersion,
InheritedBoolSetting,
InheritedHttpVersionSetting,
InheritedIntSetting,
WebsocketRequest,
Workspace,
@@ -13,6 +15,7 @@ import {
modelSupportsSetting,
type RequestSettingDefinition,
SETTING_FOLLOW_REDIRECTS,
SETTING_HTTP_VERSION,
SETTING_REQUEST_MESSAGE_SIZE,
SETTING_REQUEST_TIMEOUT,
SETTING_SEND_COOKIES,
@@ -21,6 +24,7 @@ import {
} from "../lib/requestSettings";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import {
SettingOverrideRow,
SettingRow,
@@ -38,37 +42,21 @@ interface Props {
model: ModelWithSettings;
}
type ModelWithSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
type ModelWithTlsSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest
| GrpcRequest;
type ModelWithCookieSettings =
| Workspace
| Folder
| HttpRequest
| WebsocketRequest;
type ModelWithMessageSizeSettings =
| Workspace
| Folder
| WebsocketRequest
| GrpcRequest;
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
type ModelWithMessageSizeSettings = Workspace | Folder | WebsocketRequest | GrpcRequest;
type BooleanSetting = boolean | InheritedBoolSetting;
type IntegerSetting = number | InheritedIntSetting;
type HttpVersionSetting = HttpVersion | InheritedHttpVersionSetting;
type CookieSettingsPatch = {
settingSendCookies?: ModelWithCookieSettings["settingSendCookies"];
settingStoreCookies?: ModelWithCookieSettings["settingStoreCookies"];
};
type HttpSettingsPatch = {
settingFollowRedirects?: ModelWithHttpSettings["settingFollowRedirects"];
settingHttpVersion?: ModelWithHttpSettings["settingHttpVersion"];
settingRequestTimeout?: ModelWithHttpSettings["settingRequestTimeout"];
};
type TlsSettingsPatch = {
@@ -78,10 +66,7 @@ type MessageSizeSettingsPatch = {
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
};
export function ModelSettingsEditor({
model,
showSectionTitles = false,
}: Props) {
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
const ancestors = useModelAncestors(model);
const supportsHttpSettings = modelSupportsHttpSettings(model);
const supportsCookieSettings = modelSupportsCookieSettings(model);
@@ -154,12 +139,26 @@ export function ModelSettingsEditor({
}
/>
)}
{supportsHttpSettings && (
<HttpVersionSettingRow
settingDefinition={SETTING_HTTP_VERSION}
setting={model.settingHttpVersion}
inheritedValue={resolveInheritedValue(
ancestors,
SETTING_HTTP_VERSION.modelKey,
model.settingHttpVersion,
)}
onChange={(settingHttpVersion) =>
patchHttpSettings(model, {
settingHttpVersion,
})
}
/>
)}
</SettingsSection>
)}
{supportsCookieSettings && (
<SettingsSection
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
>
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
<BooleanSettingRow
settingDefinition={SETTING_SEND_COOKIES}
setting={model.settingSendCookies}
@@ -195,7 +194,7 @@ export function ModelSettingsEditor({
}
export function countOverriddenSettings(model: ModelWithSettings) {
const settings: (BooleanSetting | IntegerSetting)[] = [];
const settings: (BooleanSetting | IntegerSetting | HttpVersionSetting)[] = [];
if (modelSupportsCookieSettings(model)) {
settings.push(model.settingSendCookies, model.settingStoreCookies);
@@ -204,22 +203,22 @@ export function countOverriddenSettings(model: ModelWithSettings) {
settings.push(model.settingValidateCertificates);
if (modelSupportsHttpSettings(model)) {
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
settings.push(
model.settingFollowRedirects,
model.settingRequestTimeout,
model.settingHttpVersion,
);
}
if (modelSupportsMessageSizeSettings(model)) {
settings.push(model.settingRequestMessageSize);
}
return settings.filter(
(setting) => isInheritedSetting(setting) && setting.enabled === true,
).length;
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
.length;
}
function patchCookieSettings(
model: ModelWithCookieSettings,
patch: Partial<CookieSettingsPatch>,
) {
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -232,10 +231,7 @@ function patchCookieSettings(
}
}
function patchHttpSettings(
model: ModelWithHttpSettings,
patch: Partial<HttpSettingsPatch>,
) {
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -246,10 +242,7 @@ function patchHttpSettings(
}
}
function patchTlsSettings(
model: ModelWithTlsSettings,
patch: Partial<TlsSettingsPatch>,
) {
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
switch (model.model) {
case "workspace":
return patchModel(model, patch as Partial<Workspace>);
@@ -280,21 +273,15 @@ function patchMessageSizeSettings(
}
}
function modelSupportsHttpSettings(
model: ModelWithSettings,
): model is ModelWithHttpSettings {
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
}
function modelSupportsCookieSettings(
model: ModelWithSettings,
): model is ModelWithCookieSettings {
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
}
function modelSupportsTlsSettings(
model: ModelWithSettings,
): model is ModelWithTlsSettings {
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
}
@@ -317,11 +304,7 @@ function BooleanSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
@@ -352,6 +335,63 @@ function BooleanSettingRow({
);
}
const HTTP_VERSION_OPTIONS: { label: string; value: HttpVersion }[] = [
{ label: "Automatic", value: "auto" },
{ label: "HTTP/1.1", value: "http1" },
{ label: "HTTP/2", value: "http2" },
];
function HttpVersionSettingRow({
inheritedValue,
setting,
settingDefinition,
onChange,
}: {
inheritedValue: HttpVersion;
setting: HttpVersionSetting;
settingDefinition: RequestSettingDefinition<"settingHttpVersion">;
onChange: (setting: HttpVersionSetting) => void;
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange(value)}
/>
</SettingRow>
);
}
return (
<SettingOverrideRow
title={settingDefinition.title}
description={settingDefinition.description}
overridden={overridden}
onResetOverride={() => onChange({ ...setting, enabled: false })}
>
<Select
hideLabel
name={settingDefinition.modelKey}
label={settingDefinition.title}
size="sm"
value={value}
options={HTTP_VERSION_OPTIONS}
onChange={(value) => onChange({ ...setting, enabled: true, value })}
/>
</SettingOverrideRow>
);
}
function IntegerSettingRow({
inheritedValue,
setting,
@@ -365,18 +405,11 @@ function IntegerSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<NumberUnitInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -429,20 +462,13 @@ function MessageSizeSettingRow({
}) {
const inherited = isInheritedSetting(setting);
const overridden = inherited ? setting.enabled === true : false;
const value = inherited
? overridden
? setting.value
: inheritedValue
: setting;
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
const displayValue = formatMegabytes(value);
const placeholder = "0";
if (!inherited) {
return (
<SettingRow
title={settingDefinition.title}
description={settingDefinition.description}
>
<SettingRow title={settingDefinition.title} description={settingDefinition.description}>
<MessageSizeInput
name={settingDefinition.modelKey}
label={settingDefinition.title}
@@ -567,13 +593,18 @@ function resolveInheritedValue(
key: BooleanWorkspaceSettingKey,
fallback: BooleanSetting,
): boolean;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: "settingHttpVersion",
fallback: HttpVersionSetting,
): HttpVersion;
function resolveInheritedValue(
ancestors: (Folder | Workspace)[],
key: keyof WorkspaceSettings,
fallback: BooleanSetting | IntegerSetting,
fallback: BooleanSetting | IntegerSetting | HttpVersionSetting,
) {
for (const ancestor of ancestors) {
const setting = ancestor[key] as BooleanSetting | IntegerSetting;
const setting = ancestor[key] as BooleanSetting | IntegerSetting | HttpVersionSetting;
if (isInheritedSetting(setting)) {
if (setting.enabled === true) {
return setting.value;
@@ -589,6 +620,7 @@ function resolveInheritedValue(
type WorkspaceSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -598,14 +630,12 @@ type WorkspaceSettings = Pick<
type BooleanWorkspaceSettingKey = Exclude<
keyof WorkspaceSettings,
"settingRequestTimeout" | "settingRequestMessageSize"
"settingRequestTimeout" | "settingRequestMessageSize" | "settingHttpVersion"
>;
function formatMegabytes(bytes: number) {
const megabytes = bytes / BYTES_PER_MB;
return Number.isInteger(megabytes)
? `${megabytes}`
: megabytes.toFixed(3).replace(/\.?0+$/, "");
return Number.isInteger(megabytes) ? `${megabytes}` : megabytes.toFixed(3).replace(/\.?0+$/, "");
}
function parseMegabytes(value: string) {
@@ -626,9 +656,5 @@ function isValidInteger(value: string) {
function isValidMegabytes(value: string) {
if (value === "") return true;
const megabytes = Number(value);
return (
Number.isFinite(megabytes) &&
megabytes >= 0 &&
megabytes <= MAX_MESSAGE_SIZE_MB
);
return Number.isFinite(megabytes) && megabytes >= 0 && megabytes <= MAX_MESSAGE_SIZE_MB;
}
+7 -5
View File
@@ -10,11 +10,13 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
disableBackdropClose?: boolean;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -27,7 +29,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableBackdropClose,
disableClose,
title,
description,
hideX,
@@ -42,7 +44,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -58,7 +60,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
onClose?.();
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -110,7 +112,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
@@ -0,0 +1,202 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,7 +1,232 @@
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { EditorState, Extension, Text } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/** Matches are counted no further than this, since an exact total stops being useful long before */
export const MAX_COUNT = 9999;
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
export interface Match {
from: number;
to: number;
}
/** A character whose normalized form is a different length, shifting every offset past it */
interface Expansion {
normFrom: number;
normTo: number;
docFrom: number;
docTo: number;
}
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
export interface NormalizedDoc {
text: string;
expansions: Expansion[];
}
/**
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
* case-sensitive — in a single pass rather than one call per code point.
*
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
* makes counting matches in a large response slow. Doing it a character at a time still matters
* for the result, since it keeps NFKD from reordering marks across characters, so the
* granularity stays and only the repeated work goes: each distinct character is normalized once
* and the answer reused, and ASCII runs never reach ICU at all.
*/
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
const rewritten = new Map<string, string>();
const expansions: Expansion[] = [];
let shift = 0;
const normalized = text.replace(
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
(chunk: string, at: number) => {
// An ASCII run only ever folds case, which can't change its length
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
let out = rewritten.get(chunk);
if (out === undefined) {
out = chunk.normalize("NFKD");
if (!caseSensitive) out = out.toLowerCase();
rewritten.set(chunk, out);
}
if (out.length !== chunk.length) {
expansions.push({
normFrom: at + shift,
normTo: at + shift + out.length,
docFrom: at,
docTo: at + chunk.length,
});
shift += out.length - chunk.length;
}
return out;
},
);
return { text: normalized, expansions };
}
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
export function normalizeSearch(search: string, caseSensitive: boolean): string {
const normalized = search.normalize("NFKD");
return caseSensitive ? normalized : normalized.toLowerCase();
}
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
const matches: Match[] = [];
if (needle === "") return matches;
const { text } = doc;
let pos = text.indexOf(needle);
while (pos >= 0) {
let end = pos + needle.length;
// However the query was cut, a match ends on a whole code point, as the cursor's do
if (isLowSurrogate(text.charCodeAt(end))) end++;
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
if (matches.length > MAX_COUNT) break;
pos = text.indexOf(needle, resumeAfter(doc, end));
}
return matches;
}
/** The same through the query's own cursor, which handles regexps and whole words */
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
const matches: Match[] = [];
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push({ from: result.value.from, to: result.value.to });
if (matches.length > MAX_COUNT) break;
}
return matches;
}
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/** The last character expansion beginning at or before `offset`, if there is one */
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
let low = 0;
let high = expansions.length - 1;
let found: Expansion | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
if (expansions[mid]!.normFrom <= offset) {
found = expansions[mid]!;
low = mid + 1;
} else {
high = mid - 1;
}
}
return found;
}
function docStart(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
// A match starting inside a character's expansion starts at the character
return offset < expansion.normTo
? expansion.docFrom
: offset - (expansion.normTo - expansion.docTo);
}
/**
* Where scanning picks up after a match ending at `offset`.
*
* The cursor moves through the document a character at a time, so once a match ends inside a
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
* ever counts as one match of ".".
*/
function resumeAfter(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
? expansion.normTo
: offset;
}
function docEnd(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
if (offset <= expansion.normFrom) return expansion.docFrom;
// A match ending inside a character's expansion covers the whole character
return offset < expansion.normTo
? expansion.docTo
: offset - (expansion.normTo - expansion.docTo);
}
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
let index = 0;
for (const match of matches) {
index++;
if (match.from <= selection.from && match.to >= selection.to) return index;
}
return 0;
}
/** The text a plain query looks for, or null when only the cursor can answer it */
export function literalSearch(query: SearchQuery): string | null {
if (query.regexp || query.wholeWord || query.test != null) return null;
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
return query.literal
? query.search
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
);
}
/**
* Finds the matches for the search panel, keeping the normalized document and the matches it
* last found, so neither moving the selection nor typing another character starts over.
*/
export class MatchCounter {
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
matches(state: EditorState, query: SearchQuery): Match[] {
const last = this.last;
if (last != null && last.doc === state.doc && last.query.eq(query)) {
return last.matches;
}
const matches = this.scan(state, query);
this.last = { doc: state.doc, query, matches };
return matches;
}
private scan(state: EditorState, query: SearchQuery): Match[] {
const search = literalSearch(query);
if (search == null) return scanQuery(state, query);
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
}
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
const cached = this.doc;
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
return cached.normalized;
}
const normalized = normalizeDoc(doc.toString(), caseSensitive);
this.doc = { doc, caseSensitive, normalized };
return normalized;
}
}
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
private counter = new MatchCounter();
constructor(private view: EditorView) {
this.updateCount();
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
}
this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = "0/0";
}
this.countEl.textContent = "0/0";
return;
}
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
const matches = this.counter.matches(state, query);
if (matches.length > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (matches.length === 0) {
this.countEl.textContent = "0/0";
} else {
const current = currentMatch(matches, state.selection.main);
this.countEl.textContent = `${current}/${matches.length}`;
}
}
+6 -2
View File
@@ -119,9 +119,13 @@ export function Select<T extends string>({
)}
>
<Button
className="w-full text-sm font-mono"
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="border"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
+1 -3
View File
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableBackdropClose: true,
onClose: () => resolve("cancel"),
disableClose: true,
render: ({ hide }) =>
DivergedDialog({
remote,
+1 -2
View File
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({
id,
title,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
+1 -2
View File
@@ -18,9 +18,8 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({
...extraProps,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});
});
+36 -14
View File
@@ -1,10 +1,18 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import {
type BatchUpsertResult,
type ImportDestination,
type ImportPlan,
workspacesAtom,
} from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
@@ -21,29 +29,43 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "sm",
disableClose: true,
render: ({ hide }) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
};
return (
<ImportDataDialog
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
currentWorkspace={currentWorkspace}
workspaces={workspaces}
selectedFolder={selectedFolder}
planFile={(filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
planUrl={(url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination })
}
commit={commit}
cancel={cancel}
onError={fail}
/>
);
},
+60 -14
View File
@@ -12,7 +12,7 @@ import type {
UpdateResponse,
YaakNotification,
} from "@yaakapp-internal/tauri-client";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { openSettings } from "../commands/openSettings";
import { Button } from "../components/core/Button";
import { ButtonInfiniteLoading } from "../components/core/ButtonInfiniteLoading";
@@ -180,9 +180,65 @@ function showUpdateInstalledToast(version: string) {
async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
const UPDATE_TOAST_ID = "update-info";
const { version, replyEventId, downloaded } = updateInfo;
const { version, replyEventId, downloaded, install } = updateInfo;
jotaiStore.set(updateAvailableAtom, { version, downloaded });
jotaiStore.set(updateAvailableAtom, { version, downloaded, install });
const whatsNewButton = (
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
);
if (install !== "integrated") {
// Nothing to reply to here; the backend only told us so we can say how to update
const flatpak = install === "flatpak";
showToast({
id: UPDATE_TOAST_ID,
color: "info",
timeout: null,
message: (
<VStack>
<h2 className="font-semibold">Yaak {version} is available</h2>
<p className="text-text-subtle text-sm">
{flatpak ? (
<>
Update with <InlineCode>flatpak update</InlineCode> or your software center.
</>
) : (
"Download the new version to upgrade."
)}
</p>
</VStack>
),
action: () => (
<HStack space={1.5}>
{!flatpak && (
<Button
size="xs"
color="info"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl("https://yaak.app/download");
}}
>
Download
</Button>
)}
{whatsNewButton}
</HStack>
),
});
return;
}
// Acknowledge the event, so we don't time out and try the fallback update logic
await platform.emit(replyEventId, { type: "ack" } satisfies UpdateResponse);
@@ -215,17 +271,7 @@ async function showUpdateAvailableToast(updateInfo: UpdateInfo) {
>
{downloaded ? "Install Now" : "Download and Install"}
</ButtonInfiniteLoading>
<Button
size="xs"
color="info"
variant="border"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
await platform.openUrl(`https://yaak.app/changelog/${version}`);
}}
>
What&apos;s New
</Button>
{whatsNewButton}
</HStack>
),
});
@@ -0,0 +1,251 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonSchema } from "./jsonSchemaExample";
import { buildExampleFromSchema } from "./jsonSchemaExample";
describe("buildExampleFromSchema", () => {
test("fills scalar fields with placeholders", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", format: "int32" },
active: { type: "boolean" },
data: { type: "string", format: "byte" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
name: "",
age: 0,
active: false,
data: "",
});
});
test("encodes 64-bit integers as strings", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string", format: "int64" },
count: { type: "string", format: "uint64" },
offset: { type: "string", format: "sfixed64" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
});
test("fills date-time with a parseable timestamp", () => {
const schema: JsonSchema = {
type: "object",
properties: { createdAt: { type: "string", format: "date-time" } },
};
const example = buildExampleFromSchema(schema) as { createdAt: string };
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
});
test("fills a duration with a value that parses", () => {
const schema: JsonSchema = {
type: "object",
properties: { timeout: { type: "string", format: "duration" } },
};
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
});
test("expands nested messages through $defs", () => {
const schema: JsonSchema = {
type: "object",
properties: { user: { $ref: "#/$defs/example.User" } },
$defs: {
"example.User": {
type: "object",
properties: {
name: { type: "string" },
address: { $ref: "#/$defs/example.Address" },
},
},
"example.Address": {
type: "object",
properties: { city: { type: "string" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
user: { name: "", address: { city: "" } },
});
});
test("gives repeated fields a single placeholder item", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
unknown: { type: "array" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
tags: [""],
users: [{ name: "" }],
unknown: [],
});
});
test("uses the first value of an enum", () => {
const schema: JsonSchema = {
type: "object",
properties: {
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
empty: { type: "string", enum: [] },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
});
test("gives maps a single placeholder entry", () => {
const schema: JsonSchema = {
type: "object",
properties: {
labels: { type: "object", additionalProperties: { type: "string" } },
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
labels: { key: "" },
users: { key: { name: "" } },
});
});
test("stops at the root self-reference", () => {
const schema: JsonSchema = {
type: "object",
properties: {
value: { type: "string" },
children: { type: "array", items: { $ref: "#" } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
});
test("stops at a cycle between messages", () => {
const schema: JsonSchema = {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
$defs: {
"example.Node": {
type: "object",
properties: {
name: { type: "string" },
parent: { $ref: "#/$defs/example.Node" },
leaf: { $ref: "#/$defs/example.Leaf" },
},
},
"example.Leaf": {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
node: { name: "", parent: {}, leaf: { node: {} } },
});
});
test("expands the same message twice when it is not on the same path", () => {
const schema: JsonSchema = {
type: "object",
properties: {
from: { $ref: "#/$defs/example.User" },
to: { $ref: "#/$defs/example.User" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
});
test("fills every branch of a flattened oneof", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
image: { $ref: "#/$defs/example.Image" },
},
$defs: {
"example.Image": { type: "object", properties: { url: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
id: "",
text: "",
image: { url: "" },
});
});
test("stops expanding once the node budget runs out", () => {
// Every level references the next one twice, so an unbounded walk would build 2^depth
// nodes without ever repeating a ref on the same path.
const depth = 16;
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
for (let i = 0; i < depth; i++) {
$defs[`d${i}`] = {
type: "object",
properties: {
a: { $ref: `#/$defs/d${i + 1}` },
b: { $ref: `#/$defs/d${i + 1}` },
},
};
}
const example = buildExampleFromSchema({
type: "object",
properties: { root: { $ref: "#/$defs/d0" } },
$defs,
});
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
expect(countNodes(example)).toBeLessThan(10_000);
});
test("handles messages without a known type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
empty: {},
struct: { type: "object" },
missing: { $ref: "#/$defs/example.Nope" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
});
});
function countNodes(value: unknown): number {
if (Array.isArray(value)) {
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
}
if (value !== null && typeof value === "object") {
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
}
return 1;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
*/
export type JsonSchema = {
type?: string;
format?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: JsonSchema;
enum?: unknown[];
$defs?: Record<string, JsonSchema>;
$ref?: string;
};
const DEFS_PREFIX = "#/$defs/";
const ROOT_REF = "#";
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
// Refs on sibling branches each expand their own subtree, so a schema that references the
// same messages repeatedly can produce exponentially many nodes without ever cycling.
const MAX_NODES = 5000;
type Budget = { remaining: number };
/** Build a sample message with placeholder values for every field in the schema */
export function buildExampleFromSchema(schema: JsonSchema): unknown {
// The root is already being built, so a `#` ref anywhere below it is a cycle
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
}
function buildValue(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
return null;
}
budget.remaining -= 1;
if (typeof schema.$ref === "string") {
if (refPath.has(schema.$ref)) {
return {};
}
const resolved = resolveRef(schema.$ref, root);
if (resolved == null) {
return {};
}
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
}
if (Array.isArray(schema.enum)) {
return schema.enum[0] ?? "";
}
switch (schema.type) {
case "object":
return buildObject(schema, root, refPath, budget);
case "array":
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
case "string":
return buildString(schema.format);
case "number":
return 0;
case "boolean":
return false;
default:
return null;
}
}
function buildObject(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema.properties != null && typeof schema.properties === "object") {
const example: Record<string, unknown> = {};
for (const [name, propertySchema] of Object.entries(schema.properties)) {
example[name] = buildValue(propertySchema, root, refPath, budget);
}
return example;
}
// Maps have no properties, only a value schema
if (schema.additionalProperties != null) {
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
}
return {};
}
function buildString(format: string | undefined): string {
if (format === "date-time") {
return new Date().toISOString();
}
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
if (format === "duration") {
return "0s";
}
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
return "0";
}
return "";
}
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
if (ref === ROOT_REF) {
return root;
}
if (!ref.startsWith(DEFS_PREFIX)) {
return null;
}
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
}
+1 -6
View File
@@ -25,13 +25,8 @@ export async function showPromptForm({
id,
title,
description,
hideX: true,
size: size ?? "sm",
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
disableClose: true,
render: ({ hide }) =>
Prompt({
onCancel: () => {
+14 -16
View File
@@ -5,6 +5,7 @@ type ModelType = AnyModel["model"];
type WorkspaceRequestSettings = Pick<
Workspace,
| "settingFollowRedirects"
| "settingHttpVersion"
| "settingRequestMessageSize"
| "settingRequestTimeout"
| "settingSendCookies"
@@ -18,9 +19,7 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
}[ModelType];
export type RequestSettingDefinition<
K extends RequestSettingKey = RequestSettingKey,
> = {
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
defaultValue: WorkspaceRequestSettings[K];
description: string;
modelKey: K;
@@ -46,8 +45,7 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
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.",
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",
@@ -57,13 +55,7 @@ export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
defaultValue: true,
description: "When disabled, skip validation of server certificates.",
modelKey: "settingValidateCertificates",
models: [
"workspace",
"folder",
"http_request",
"websocket_request",
"grpc_request",
],
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
title: "Validate TLS certificates",
});
@@ -75,10 +67,17 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
title: "Follow redirects",
});
export const SETTING_HTTP_VERSION = defineRequestSetting({
defaultValue: "auto",
description: "Force HTTP/1.1 or HTTP/2 for servers that don't negotiate the version correctly.",
modelKey: "settingHttpVersion",
models: ["workspace", "folder", "http_request"],
title: "HTTP version",
});
export const SETTING_SEND_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Attach matching cookies from the active cookie jar to outgoing requests.",
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
modelKey: "settingSendCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically send cookies",
@@ -86,8 +85,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
export const SETTING_STORE_COOKIES = defineRequestSetting({
defaultValue: true,
description:
"Save cookies from Set-Cookie response headers to the active cookie jar.",
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
modelKey: "settingStoreCookies",
models: ["workspace", "folder", "http_request", "websocket_request"],
title: "Automatically store cookies",
@@ -5,8 +5,7 @@ use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination};
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -51,6 +50,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -59,13 +59,19 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
let destination = match workspace_id {
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
None => ImportDestination::NewWorkspace,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
let plan = import::plan_import_resources(
ctx.query_manager(),
importer,
destination,
resources,
import_result.source_keys,
)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let imported = import::commit_import_plan(ctx.query_manager(), plan)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
.find(|workspace| workspace.name == "Imported Workspace")
.expect("workspace imported");
assert_ne!(workspace.id, "wrk_import");
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
}
fn write_postman_environment_fixture(path: &std::path::Path) {
+111 -24
View File
@@ -1,48 +1,135 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
export type Cookie = {
name: string;
value: string;
domain: CookieDomain;
expires: CookieExpires;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: CookieSameSite | null;
};
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieExpires = { AtUtc: string } | "SessionEnd";
export type CookieSameSite = "Strict" | "Lax" | "None";
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
export type HttpRequest = {
model: "http_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData =
| {
type: "setting";
name: string;
value: string;
source_model?: string;
source_id?: string;
source_name?: string;
}
| { type: "info"; message: string }
| {
type: "redirect";
url: string;
status: number;
behavior: string;
dropped_body: boolean;
dropped_headers: Array<string>;
}
| {
type: "send_url";
method: string;
scheme: string;
username: string;
password: string;
host: string;
port: number;
path: string;
query: string;
fragment: string;
}
| { type: "receive_url"; version: string; status: string }
| { type: "header_up"; name: string; value: string }
| { type: "header_down"; name: string; value: string }
| { type: "chunk_sent"; bytes: number }
| { type: "chunk_received"; bytes: number }
| {
type: "dns_resolved";
hostname: string;
addresses: Array<string>;
duration: bigint;
overridden: boolean;
};
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseHeader = { name: string; value: string };
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpSendSettings = {
validateCertificates: boolean;
followRedirects: boolean;
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number;
sendCookies: boolean;
storeCookies: boolean;
httpVersion: HttpVersion;
};
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type HttpUrlParameter = {
enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string;
value: string;
id?: string;
};
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedIntSetting = { enabled?: boolean, value: number, };
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
+1
View File
@@ -157,6 +157,7 @@ impl PreparedSend {
let (client, resolver) = HttpConnectionOptions {
id: uuid::Uuid::new_v4().to_string(),
validate_certificates: self.settings.validate_certificates,
http_version: self.settings.http_version,
// The proxy connects directly. Going through a system proxy would move DNS, and
// therefore the address check, somewhere this process can't see.
proxy: HttpConnectionProxySetting::Disabled,
+28 -6
View File
@@ -1,15 +1,37 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginUpdateInfo = { name: string, currentVersion: string, latestVersion: string, };
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
export type PluginUpdateNotification = { updateCount: number, plugins: Array<PluginUpdateInfo>, };
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
export type UpdateInfo = { replyEventId: string, version: string, downloaded: boolean, };
export type UpdateInfo = {
replyEventId: string;
version: string;
downloaded: boolean;
/**
* How this update gets applied. Anything but `Integrated` means the app can't do it
* itself and the user is told how to update instead.
*/
install: UpdateInstall;
};
export type UpdateResponse = { "type": "ack" } | { "type": "action", action: UpdateResponseAction, };
/**
* How an update can be applied to this install.
*/
export type UpdateInstall = "integrated" | "flatpak" | "manual";
export type UpdateResponse = { type: "ack" } | { type: "action"; action: UpdateResponseAction };
export type UpdateResponseAction = "install" | "skip";
export type YaakNotification = { timestamp: string, timeout: number | null, id: string, title: string | null, message: string, color: string | null, action: YaakNotificationAction | null, };
export type YaakNotification = {
timestamp: string;
timeout: number | null;
id: string;
title: string | null;
message: string;
color: string | null;
action: YaakNotificationAction | null;
};
export type YaakNotificationAction = { label: string, url: string, };
export type YaakNotificationAction = { label: string; url: string };
+29 -19
View File
@@ -4,53 +4,63 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
import_contents(window, &contents).await
let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?;
commit_import(window, plan)
}
pub(crate) async fn import_url<R: Runtime>(
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
plan_import_contents(window, &contents, destination).await
}
async fn import_contents<R: Runtime>(
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::import_data(ImportDataParams {
Ok(import::plan_import_data(PlanImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
workspace_context,
destination,
contents,
})
.await?)
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
+24 -5
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{import_data, import_url};
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -40,7 +40,7 @@ use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
@@ -1014,15 +1014,24 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
) -> YaakResult<BatchUpsertResult> {
import_data(&window, file_path).await
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
}
async fn cmd_commit_import<R: Runtime>(
window: WebviewWindow<R>,
plan: ImportPlan,
) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await
commit_import(&window, plan)
}
@@ -1367,6 +1376,16 @@ pub fn run() {
debug!("Launched Yaak {:?}", info);
});
}
RunEvent::WindowEvent { event: WindowEvent::ThemeChanged(_), .. } => {
// On macOS this is how OS appearance changes arrive: tao observes
// AppleInterfaceThemeChangedNotification and emits it for every window
#[cfg(any(target_os = "linux", target_os = "macos"))]
if let Some(state) =
app_handle.try_state::<yaak_system_appearance::SystemAppearanceState>()
{
yaak_system_appearance::emit_change(app_handle, &state);
}
}
RunEvent::WindowEvent { event: WindowEvent::Focused(true), label, .. } => {
#[cfg(any(target_os = "linux", target_os = "macos"))]
if let Some(state) =
+9 -6
View File
@@ -40,7 +40,7 @@ use yaak_models::models::{
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
@@ -441,12 +441,16 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).await?)
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
@@ -843,4 +847,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
}
+184 -11
View File
@@ -76,14 +76,6 @@ impl YaakUpdater {
auto_download: bool,
update_trigger: UpdateTrigger,
) -> Result<bool> {
// Only AppImage supports updates on Linux, so skip if it's not
#[cfg(target_os = "linux")]
{
if std::env::var("APPIMAGE").is_err() {
return Ok(false);
}
}
let settings = window.db().get_settings();
let update_key = format!("{:x}", md5::compute(settings.id));
self.last_check = Some(Instant::now());
@@ -130,6 +122,18 @@ impl YaakUpdater {
Some(update) => {
let w = window.clone();
tauri::async_runtime::spawn(async move {
// Only hand the artifact to the updater plugin when this install can
// apply it itself; otherwise tell the user how to update instead
let install = update_install_method(&update);
if install != UpdateInstall::Integrated {
info!(
"{} available, but this install updates via {install:?}",
update.version
);
notify_external_update(&w, &update, install);
return;
}
// Force native updater if specified (useful if a release broke the UI)
let native_install_mode =
update.raw_json.get("install_mode").map(|v| v.as_str()).unwrap_or_default()
@@ -207,6 +211,23 @@ struct UpdateInfo {
reply_event_id: String,
version: String,
downloaded: bool,
/// How this update gets applied. Anything but `Integrated` means the app can't do it
/// itself and the user is told how to update instead.
install: UpdateInstall,
}
/// How an update can be applied to this install.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Default, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "index.ts")]
enum UpdateInstall {
/// The app downloads and installs it itself
#[default]
Integrated,
/// Flatpak install: updated by `flatpak update` from its remote (e.g. FlatPark)
Flatpak,
/// Nothing can install it in-app (distro package, Nix, unknown); download by hand
Manual,
}
#[derive(Debug, Clone, PartialEq, Deserialize, TS)]
@@ -272,8 +293,12 @@ async fn start_integrated_update<R: Runtime>(
let _guard = Unlisten { win: window, id: event_id };
// 2) Emit the event now that listener is in place
let info =
UpdateInfo { version: update.version.to_string(), downloaded, reply_event_id: reply_id };
let info = UpdateInfo {
version: update.version.to_string(),
downloaded,
install: UpdateInstall::Integrated,
reply_event_id: reply_id,
};
window
.emit_to(window.label(), "update_available", &info)
.map_err(|e| GenericError(format!("Failed to emit update_available: {e}")))?;
@@ -306,6 +331,24 @@ async fn start_integrated_update<R: Runtime>(
}
}
/// Tell the frontend about an update this install can't apply itself, so the user can be
/// told how to get it. Unlike the integrated flow, there is nothing to reply to.
fn notify_external_update<R: Runtime>(
window: &WebviewWindow<R>,
update: &Update,
install: UpdateInstall,
) {
let info = UpdateInfo {
version: update.version.to_string(),
downloaded: false,
install,
reply_event_id: generate_id(),
};
if let Err(e) = window.emit_to(window.label(), "update_available", &info) {
warn!("Failed to emit update_available: {e}");
}
}
async fn start_native_update<R: Runtime>(window: &WebviewWindow<R>, update: &Update) {
// If the frontend doesn't respond, fallback to native dialogs
let confirmed = window
@@ -376,7 +419,137 @@ fn detect_install_mode() -> Option<&'static str> {
return Some("nsis");
}
#[allow(unreachable_code)]
None
if !cfg!(target_os = "linux") {
None
} else if is_flatpak() {
Some("flatpak")
} else {
linux_installer()
}
}
/// Flatpak installs (e.g. FlatPark) are updated by flatpak from their remote; the in-app
/// updater can't write inside the sandbox and must not try.
fn is_flatpak() -> bool {
std::env::var_os("FLATPAK_ID").is_some()
}
/// How Yaak was installed on Linux, as far as the updater plugin can install into it.
///
/// The bundle type is stamped into the binary by the bundler, but that only says how the
/// binary was *packaged*: third-party packages (AUR, Nix, ...) repackage the .deb, and
/// letting dpkg/rpm replace those would stomp on another package manager's files. So a
/// deb/rpm install also has to be one the package manager actually owns. The AppImage
/// updater needs `$APPIMAGE` since that's the file it replaces.
fn linux_installer() -> Option<&'static str> {
use tauri::utils::{config::BundleType, platform::bundle_type};
match bundle_type() {
Some(BundleType::Deb) if package_manager_owns_exe("dpkg", "-S") => Some("deb"),
Some(BundleType::Rpm) if package_manager_owns_exe("rpm", "-qf") => Some("rpm"),
Some(BundleType::Deb) | Some(BundleType::Rpm) => None,
_ if std::env::var_os("APPIMAGE").is_some() => Some("appimage"),
_ => None,
}
}
/// Whether `cmd query_arg <current exe>` succeeds, i.e. that package manager knows the
/// running executable as one of its files. False when the tool isn't installed at all.
fn package_manager_owns_exe(cmd: &str, query_arg: &str) -> bool {
let Ok(exe) = std::env::current_exe() else {
return false;
};
std::process::Command::new(cmd)
.arg(query_arg)
.arg(&exe)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
/// How the artifact the server returned can be applied to this install. On Linux the
/// server may hand back a different package format than the one installed, Flatpak can't
/// be written from inside the sandbox, and unknown install methods (distro packages,
/// Nix, ...) can't be updated in-app at all.
fn update_install_method(update: &Update) -> UpdateInstall {
// Dev-only override to preview the non-integrated flows on any OS:
// YAAK_SIMULATE_INSTALL=flatpak|manual
if is_dev() {
match std::env::var("YAAK_SIMULATE_INSTALL").as_deref() {
Ok("flatpak") => return UpdateInstall::Flatpak,
Ok("manual") => return UpdateInstall::Manual,
_ => {}
}
}
if !cfg!(target_os = "linux") {
return UpdateInstall::Integrated;
}
if is_flatpak() {
return UpdateInstall::Flatpak;
}
if artifact_matches_installer(linux_installer(), update.download_url.path()) {
UpdateInstall::Integrated
} else {
UpdateInstall::Manual
}
}
/// Whether the artifact at `url_path` is in the package format `installer` can install.
fn artifact_matches_installer(installer: Option<&str>, url_path: &str) -> bool {
let path = url_path.to_ascii_lowercase();
match installer {
Some("deb") => path.ends_with(".deb"),
Some("rpm") => path.ends_with(".rpm"),
Some("appimage") => path.ends_with(".appimage") || path.ends_with(".appimage.tar.gz"),
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::artifact_matches_installer;
const BASE: &str = "/mountain-loop/yaak/releases/download/v2026.6.0/";
#[test]
fn matching_package_format_is_installable() {
let cases = [
("deb", "yaak_2026.6.0_amd64.deb"),
("deb", "yaak_2026.6.0_arm64.deb"),
("rpm", "yaak-2026.6.0-1.x86_64.rpm"),
("rpm", "yaak-2026.6.0-1.aarch64.rpm"),
("appimage", "yaak_2026.6.0_amd64.AppImage"),
("appimage", "yaak_2026.6.0_amd64.AppImage.tar.gz"),
];
for (installer, asset) in cases {
assert!(
artifact_matches_installer(Some(installer), &format!("{BASE}{asset}")),
"{installer} should install {asset}"
);
}
}
#[test]
fn mismatched_package_format_is_not_installable() {
// What the server returns for every Linux install today
let appimage = format!("{BASE}yaak_2026.6.0_amd64.AppImage");
assert!(!artifact_matches_installer(Some("deb"), &appimage));
assert!(!artifact_matches_installer(Some("rpm"), &appimage));
let deb = format!("{BASE}yaak_2026.6.0_amd64.deb");
assert!(!artifact_matches_installer(Some("rpm"), &deb));
assert!(!artifact_matches_installer(Some("appimage"), &deb));
}
#[test]
fn unknown_installer_is_never_installable() {
for asset in ["yaak_2026.6.0_amd64.deb", "yaak_2026.6.0_amd64.AppImage"] {
assert!(!artifact_matches_installer(None, &format!("{BASE}{asset}")));
}
}
}
pub async fn install_update_maybe_download<R: Runtime>(
@@ -4,9 +4,14 @@ version = "0.1.0"
edition = "2024"
publish = false
[target.'cfg(any(target_os = "linux", target_os = "macos"))'.dependencies]
[target.'cfg(target_os = "linux")'.dependencies]
dark-light = "2.0.0"
[target.'cfg(target_os = "macos")'.dependencies]
dispatch2 = "0.3.0"
objc2-app-kit = { version = "0.3.1", features = ["NSAppearance", "NSApplication", "NSResponder"] }
objc2-foundation = { version = "0.3.1", features = ["NSArray", "NSString", "NSUserDefaults"] }
[dependencies]
log = { workspace = true }
tauri = { workspace = true }
+74 -12
View File
@@ -1,5 +1,5 @@
use std::sync::{Arc, Mutex};
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(target_os = "linux")]
use std::time::Duration;
#[cfg(any(target_os = "linux", target_os = "macos"))]
@@ -11,7 +11,7 @@ use tauri::{AppHandle, Runtime};
pub const INITIAL_APPEARANCE_GLOBAL: &str = "__YAAK_INITIAL_APPEARANCE__";
pub const SYSTEM_APPEARANCE_CHANGE_EVENT: &str = "system_appearance_change";
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(target_os = "linux")]
const SYSTEM_APPEARANCE_POLL_INTERVAL: Duration = Duration::from_secs(1);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -47,14 +47,12 @@ pub fn initialization_script(appearance: Appearance) -> String {
/// Detect the appearance the OS prefers, independent of any appearance that has
/// been forced onto app windows (which is what the webview itself reports).
#[cfg(any(target_os = "linux", target_os = "macos"))]
#[cfg(target_os = "linux")]
pub fn system_appearance() -> Option<Appearance> {
#[cfg(target_os = "linux")]
if let Some(appearance) = gsettings_system_appearance() {
return Some(appearance);
}
// On macOS this reads AppleInterfaceStyle from the global user defaults
match dark_light::detect() {
Ok(dark_light::Mode::Dark) => Some(Appearance::Dark),
Ok(dark_light::Mode::Light) => Some(Appearance::Light),
@@ -66,11 +64,69 @@ pub fn system_appearance() -> Option<Appearance> {
}
}
/// Detect the appearance the OS prefers, independent of any appearance that has
/// been forced onto app windows (which is what the webview itself reports).
///
/// This asks AppKit for the application's effective appearance, the same source tauri
/// uses for `window.theme()`, instead of reading `AppleInterfaceStyle` from the user
/// defaults: macOS 27 no longer reliably writes that key when dark mode is on, so anything
/// reading it sees light mode. Appearances forced per window (yaak-mac-window) don't reach
/// `NSApp`, so this is the OS preference.
#[cfg(target_os = "macos")]
pub fn system_appearance() -> Option<Appearance> {
use objc2_app_kit::{NSAppearanceNameAqua, NSAppearanceNameDarkAqua, NSApplication};
use objc2_foundation::NSArray;
// AppKit is main-thread only. Every caller runs there today; this keeps it correct if
// one ever doesn't.
dispatch2::run_on_main(|mtm| {
let app = NSApplication::sharedApplication(mtm);
// An appearance forced on the whole app (tauri's `set_theme` does this) would make
// the effective appearance report the override instead of the OS preference. Nothing
// in Yaak does that, but fall back to the user defaults if something ever does.
//
// SAFETY: Called on the main thread with the shared application
if unsafe { app.appearance() }.is_some() {
return defaults_appearance();
}
// SAFETY: The appearance names are AppKit constants that live for the whole process
let (dark, light) = unsafe { (NSAppearanceNameDarkAqua, NSAppearanceNameAqua) };
let names = NSArray::from_slice(&[dark, light]);
let best = app.effectiveAppearance().bestMatchFromAppearancesWithNames(&names)?;
// SAFETY: Both are valid strings
let is_dark = unsafe { best.isEqualToString(dark) };
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
})
}
/// The appearance macOS persists to the global user defaults. Absent means light, except
/// on macOS 27, which stopped reliably writing the key. Only used when the effective
/// appearance is forced and can't be trusted.
#[cfg(target_os = "macos")]
fn defaults_appearance() -> Option<Appearance> {
use objc2_foundation::{NSUserDefaults, ns_string};
// SAFETY: The standard defaults are a process-wide singleton and the key is a valid string
let style = unsafe {
NSUserDefaults::standardUserDefaults().stringForKey(ns_string!("AppleInterfaceStyle"))
};
// SAFETY: Both are valid strings
let is_dark = style.is_some_and(|style| unsafe { style.isEqualToString(ns_string!("Dark")) });
Some(if is_dark { Appearance::Dark } else { Appearance::Light })
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub fn system_appearance() -> Option<Appearance> {
None
}
/// Start tracking the OS appearance. Linux polls for changes. macOS gets them from tauri's
/// `WindowEvent::ThemeChanged` (tao observes `AppleInterfaceThemeChangedNotification`), which
/// the app forwards to [`emit_change`], so no thread is needed there.
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceState> {
let last_appearance = system_appearance();
@@ -80,13 +136,19 @@ pub fn watch<R: Runtime>(app_handle: AppHandle<R>) -> Option<SystemAppearanceSta
}
let state = SystemAppearanceState { last_appearance: Arc::new(Mutex::new(last_appearance)) };
let thread_state = state.clone();
let _ = std::thread::spawn(move || {
loop {
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
emit_change(&app_handle, &thread_state);
}
});
#[cfg(target_os = "linux")]
{
let thread_state = state.clone();
let _ = std::thread::spawn(move || {
loop {
std::thread::sleep(SYSTEM_APPEARANCE_POLL_INTERVAL);
emit_change(&app_handle, &thread_state);
}
});
}
#[cfg(target_os = "macos")]
let _ = app_handle;
Some(state)
}
+452 -70
View File
@@ -1,127 +1,509 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type AnyModel = CookieJar | Environment | Folder | GraphQlIntrospection | GrpcConnection | GrpcEvent | GrpcRequest | HttpRequest | HttpResponse | HttpResponseEvent | KeyValue | Plugin | Settings | SyncState | WebsocketConnection | WebsocketEvent | WebsocketRequest | Workspace | WorkspaceMeta;
export type AnyModel =
| CookieJar
| Environment
| Folder
| GraphQlIntrospection
| GrpcConnection
| GrpcEvent
| GrpcRequest
| HttpRequest
| HttpResponse
| HttpResponseEvent
| KeyValue
| Plugin
| Settings
| SyncState
| WebsocketConnection
| WebsocketEvent
| WebsocketRequest
| Workspace
| WorkspaceMeta;
export type ClientCertificate = { host: string, port: number | null, crtFile: string | null, keyFile: string | null, pfxFile: string | null, passphrase: string | null, enabled?: boolean, };
export type ClientCertificate = {
host: string;
port: number | null;
crtFile: string | null;
keyFile: string | null;
pfxFile: string | null;
passphrase: string | null;
enabled?: boolean;
};
export type Cookie = { name: string, value: string, domain: CookieDomain, expires: CookieExpires, path: string, secure: boolean, httpOnly: boolean, sameSite: CookieSameSite | null, };
export type Cookie = {
name: string;
value: string;
domain: CookieDomain;
expires: CookieExpires;
path: string;
secure: boolean;
httpOnly: boolean;
sameSite: CookieSameSite | null;
};
export type CookieDomain = { "HostOnly": string } | { "Suffix": string } | "NotPresent" | "Empty";
export type CookieDomain = { HostOnly: string } | { Suffix: string } | "NotPresent" | "Empty";
export type CookieExpires = { "AtUtc": string } | "SessionEnd";
export type CookieExpires = { AtUtc: string } | "SessionEnd";
export type CookieJar = { model: "cookie_jar", id: string, createdAt: string, updatedAt: string, workspaceId: string, cookies: Array<Cookie>, name: string, };
export type CookieJar = {
model: "cookie_jar";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
cookies: Array<Cookie>;
name: string;
};
export type CookieSameSite = "Strict" | "Lax" | "None";
export type DnsOverride = { hostname: string, ipv4: Array<string>, ipv6: Array<string>, enabled?: boolean, };
export type DnsOverride = {
hostname: string;
ipv4: Array<string>;
ipv6: Array<string>;
enabled?: boolean;
};
export type EditorKeymap = "default" | "vim" | "vscode" | "emacs";
export type EncryptedKey = { encryptedKey: string, };
export type EncryptedKey = { encryptedKey: string };
export type Environment = { model: "environment", id: string, workspaceId: string, createdAt: string, updatedAt: string, name: string, public: boolean, parentModel: string, parentId: string | null,
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>, color: string | null, sortPriority: number, };
export type Environment = {
model: "environment";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
name: string;
public: boolean;
parentModel: string;
parentId: string | null;
/**
* Variables defined in this environment scope.
* Child environments override parent variables by name.
*/
variables: Array<EnvironmentVariable>;
color: string | null;
sortPriority: number;
};
export type EnvironmentVariable = { enabled?: boolean, name: string, value: string, id?: string, };
export type EnvironmentVariable = { enabled?: boolean; name: string; value: string; id?: string };
export type Folder = { model: "folder", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, sortPriority: number, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, settingRequestMessageSize: InheritedIntSetting, };
export type Folder = {
model: "folder";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
sortPriority: number;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GraphQlIntrospection = { model: "graphql_introspection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, content: string | null, };
export type GraphQlIntrospection = {
model: "graphql_introspection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
content: string | null;
};
export type GrpcConnection = { model: "grpc_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, method: string, service: string, status: number, state: GrpcConnectionState, trailers: { [key in string]?: string }, url: string, };
export type GrpcConnection = {
model: "grpc_connection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
elapsed: number;
error: string | null;
method: string;
service: string;
status: number;
state: GrpcConnectionState;
trailers: { [key in string]?: string };
url: string;
};
export type GrpcConnectionState = "initialized" | "connected" | "closed";
export type GrpcEvent = { model: "grpc_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, content: string, error: string | null, eventType: GrpcEventType, metadata: { [key in string]?: string }, status: number | null, };
export type GrpcEvent = {
model: "grpc_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
connectionId: string;
content: string;
error: string | null;
eventType: GrpcEventType;
metadata: { [key in string]?: string };
status: number | null;
};
export type GrpcEventType = "info" | "error" | "client_message" | "server_message" | "connection_start" | "connection_end";
export type GrpcEventType =
| "info"
| "error"
| "client_message"
| "server_message"
| "connection_start"
| "connection_end";
export type GrpcRequest = { model: "grpc_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authenticationType: string | null, authentication: Record<string, any>, description: string, message: string, metadata: Array<HttpRequestHeader>, method: string | null, name: string, service: string | null, sortPriority: number,
/**
* Server URL (http for plaintext or https for secure)
*/
url: string, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
export type GrpcRequest = {
model: "grpc_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authenticationType: string | null;
authentication: Record<string, any>;
description: string;
message: string;
metadata: Array<HttpRequestHeader>;
method: string | null;
name: string;
service: string | null;
sortPriority: number;
/**
* Server URL (http for plaintext or https for secure)
*/
url: string;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type HttpRequest = { model: "http_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, body: Record<string, any>, bodyType: string | null, description: string, headers: Array<HttpRequestHeader>, method: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingFollowRedirects: InheritedBoolSetting, settingRequestTimeout: InheritedIntSetting, };
export type HttpRequest = {
model: "http_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
body: Record<string, any>;
bodyType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
method: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean, name: string, value: string, id?: string, };
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
export type HttpResponse = { model: "http_response", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, contentLength: number | null, contentLengthCompressed: number | null, elapsed: number, elapsedHeaders: number, elapsedDns: number, error: string | null, headers: Array<HttpResponseHeader>, remoteAddr: string | null, requestContentLength: number | null, requestHeaders: Array<HttpResponseHeader>, status: number, statusReason: string | null, state: HttpResponseState, url: string, version: string | null, };
export type HttpResponse = {
model: "http_response";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
contentLength: number | null;
contentLengthCompressed: number | null;
elapsed: number;
elapsedHeaders: number;
elapsedDns: number;
error: string | null;
headers: Array<HttpResponseHeader>;
remoteAddr: string | null;
requestContentLength: number | null;
requestHeaders: Array<HttpResponseHeader>;
status: number;
statusReason: string | null;
state: HttpResponseState;
url: string;
version: string | null;
};
export type HttpResponseEvent = { model: "http_response_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, responseId: string, event: HttpResponseEventData, };
export type HttpResponseEvent = {
model: "http_response_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
responseId: string;
event: HttpResponseEventData;
};
/**
* Serializable representation of HTTP response events for DB storage.
* This mirrors `yaak_http::sender::HttpResponseEvent` but with serde support.
* The `From` impl is in yaak-http to avoid circular dependencies.
*/
export type HttpResponseEventData = { "type": "setting", name: string, value: string, source_model?: string, source_id?: string, source_name?: string, } | { "type": "info", message: string, } | { "type": "redirect", url: string, status: number, behavior: string, dropped_body: boolean, dropped_headers: Array<string>, } | { "type": "send_url", method: string, scheme: string, username: string, password: string, host: string, port: number, path: string, query: string, fragment: string, } | { "type": "receive_url", version: string, status: string, } | { "type": "header_up", name: string, value: string, } | { "type": "header_down", name: string, value: string, } | { "type": "chunk_sent", bytes: number, } | { "type": "chunk_received", bytes: number, } | { "type": "dns_resolved", hostname: string, addresses: Array<string>, duration: bigint, overridden: boolean, };
export type HttpResponseEventData =
| {
type: "setting";
name: string;
value: string;
source_model?: string;
source_id?: string;
source_name?: string;
}
| { type: "info"; message: string }
| {
type: "redirect";
url: string;
status: number;
behavior: string;
dropped_body: boolean;
dropped_headers: Array<string>;
}
| {
type: "send_url";
method: string;
scheme: string;
username: string;
password: string;
host: string;
port: number;
path: string;
query: string;
fragment: string;
}
| { type: "receive_url"; version: string; status: string }
| { type: "header_up"; name: string; value: string }
| { type: "header_down"; name: string; value: string }
| { type: "chunk_sent"; bytes: number }
| { type: "chunk_received"; bytes: number }
| {
type: "dns_resolved";
hostname: string;
addresses: Array<string>;
duration: bigint;
overridden: boolean;
};
export type HttpResponseHeader = { name: string, value: string, };
export type HttpResponseHeader = { name: string; value: string };
export type HttpResponseState = "initialized" | "connected" | "closed";
/**
* The resolved send settings, values only: what an executor has to obey, with the sources
* (which model each came from) left behind in [`ResolvedHttpRequestSettings`]. This is what
* crosses from a tab to the Yaak server, and what the server reads.
*/
export type HttpSendSettings = { validateCertificates: boolean, followRedirects: boolean,
/**
* Milliseconds. Zero or negative means no timeout.
*/
timeoutMs: number, sendCookies: boolean, storeCookies: boolean, };
export type HttpUrlParameter = {
enabled?: boolean;
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string;
value: string;
id?: string;
};
export type HttpUrlParameter = { enabled?: boolean,
/**
* Colon-prefixed parameters are treated as path parameters if they match, like `/users/:id`
* Other entries are appended as query parameters
*/
name: string, value: string, id?: string, };
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedBoolSetting = { enabled?: boolean, value: boolean, };
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedIntSetting = { enabled?: boolean, value: number, };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type KeyValue = { model: "key_value", id: string, createdAt: string, updatedAt: string, key: string, namespace: string, value: string, };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type Plugin = { model: "plugin", id: string, createdAt: string, updatedAt: string, checkedAt: string | null, directory: string, enabled: boolean, url: string | null, source: PluginSource, };
export type KeyValue = {
model: "key_value";
id: string;
createdAt: string;
updatedAt: string;
key: string;
namespace: string;
value: string;
};
export type Plugin = {
model: "plugin";
id: string;
createdAt: string;
updatedAt: string;
checkedAt: string | null;
directory: string;
enabled: boolean;
url: string | null;
source: PluginSource;
};
export type PluginSource = "bundled" | "filesystem" | "registry";
export type ProxySetting = { "type": "enabled", http: string, https: string, auth: ProxySettingAuth | null, bypass: string, disabled: boolean, } | { "type": "disabled" };
export type ProxySetting =
| {
type: "enabled";
http: string;
https: string;
auth: ProxySettingAuth | null;
bypass: string;
disabled: boolean;
}
| { type: "disabled" };
export type ProxySettingAuth = { user: string, password: string, };
export type ProxySettingAuth = { user: string; password: string };
export type Settings = { model: "settings", id: string, createdAt: string, updatedAt: string, appearance: string, clientCertificates: Array<ClientCertificate>, coloredMethods: boolean, editorFont: string | null, editorFontSize: number, editorKeymap: EditorKeymap, editorSoftWrap: boolean, hideWindowControls: boolean, useNativeTitlebar: boolean, interfaceFont: string | null, interfaceFontSize: number, interfaceScale: number, openWorkspaceNewWindow: boolean | null, proxy: ProxySetting | null, themeDark: string, themeLight: string, updateChannel: string, hideLicenseBadge: boolean, promptFeedback: boolean, autoupdate: boolean, autoDownloadUpdates: boolean, checkNotifications: boolean, hotkeys: { [key in string]?: Array<string> }, };
export type Settings = {
model: "settings";
id: string;
createdAt: string;
updatedAt: string;
appearance: string;
clientCertificates: Array<ClientCertificate>;
coloredMethods: boolean;
editorFont: string | null;
editorFontSize: number;
editorKeymap: EditorKeymap;
editorSoftWrap: boolean;
hideWindowControls: boolean;
useNativeTitlebar: boolean;
interfaceFont: string | null;
interfaceFontSize: number;
interfaceScale: number;
openWorkspaceNewWindow: boolean | null;
proxy: ProxySetting | null;
themeDark: string;
themeLight: string;
updateChannel: string;
hideLicenseBadge: boolean;
promptFeedback: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
hotkeys: { [key in string]?: Array<string> };
};
export type SyncModel = { "type": "workspace" } & Workspace | { "type": "environment" } & Environment | { "type": "folder" } & Folder | { "type": "http_request" } & HttpRequest | { "type": "grpc_request" } & GrpcRequest | { "type": "websocket_request" } & WebsocketRequest;
export type SyncModel =
| ({ type: "workspace" } & Workspace)
| ({ type: "environment" } & Environment)
| ({ type: "folder" } & Folder)
| ({ type: "http_request" } & HttpRequest)
| ({ type: "grpc_request" } & GrpcRequest)
| ({ type: "websocket_request" } & WebsocketRequest);
export type SyncState = { model: "sync_state", id: string, workspaceId: string, createdAt: string, updatedAt: string, flushedAt: string, modelId: string, checksum: string, relPath: string, syncDir: string, };
export type SyncState = {
model: "sync_state";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
flushedAt: string;
modelId: string;
checksum: string;
relPath: string;
syncDir: string;
};
export type WebsocketConnection = { model: "websocket_connection", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, elapsed: number, error: string | null, headers: Array<HttpResponseHeader>, state: WebsocketConnectionState, status: number, url: string, };
export type WebsocketConnection = {
model: "websocket_connection";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
elapsed: number;
error: string | null;
headers: Array<HttpResponseHeader>;
state: WebsocketConnectionState;
status: number;
url: string;
};
export type WebsocketConnectionState = "initialized" | "connected" | "closing" | "closed";
export type WebsocketEvent = { model: "websocket_event", id: string, createdAt: string, updatedAt: string, workspaceId: string, requestId: string, connectionId: string, isServer: boolean, message: Array<number>, messageType: WebsocketEventType, };
export type WebsocketEvent = {
model: "websocket_event";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
requestId: string;
connectionId: string;
isServer: boolean;
message: Array<number>;
messageType: WebsocketEventType;
};
export type WebsocketEventType = "binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
export type WebsocketEventType =
| "binary"
| "close"
| "error"
| "frame"
| "open"
| "ping"
| "pong"
| "text";
export type WebsocketRequest = { model: "websocket_request", id: string, createdAt: string, updatedAt: string, workspaceId: string, folderId: string | null, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, message: string, name: string, sortPriority: number, url: string,
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>, settingSendCookies: InheritedBoolSetting, settingStoreCookies: InheritedBoolSetting, settingValidateCertificates: InheritedBoolSetting, settingRequestMessageSize: InheritedIntSetting, };
export type WebsocketRequest = {
model: "websocket_request";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
folderId: string | null;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
message: string;
name: string;
sortPriority: number;
url: string;
/**
* URL parameters used for both path placeholders (`:id`) and query string entries.
*/
urlParameters: Array<HttpUrlParameter>;
settingSendCookies: InheritedBoolSetting;
settingStoreCookies: InheritedBoolSetting;
settingValidateCertificates: InheritedBoolSetting;
settingRequestMessageSize: InheritedIntSetting;
};
export type Workspace = { model: "workspace", id: string, createdAt: string, updatedAt: string, authentication: Record<string, any>, authenticationType: string | null, description: string, headers: Array<HttpRequestHeader>, name: string, encryptionKeyChallenge: string | null, settingValidateCertificates: boolean, settingFollowRedirects: boolean, settingRequestTimeout: number, settingRequestMessageSize: number, settingDnsOverrides: Array<DnsOverride>, settingSendCookies: boolean, settingStoreCookies: boolean, };
export type Workspace = {
model: "workspace";
id: string;
createdAt: string;
updatedAt: string;
authentication: Record<string, any>;
authenticationType: string | null;
description: string;
headers: Array<HttpRequestHeader>;
name: string;
encryptionKeyChallenge: string | null;
settingValidateCertificates: boolean;
settingFollowRedirects: boolean;
settingRequestTimeout: number;
settingRequestMessageSize: number;
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type WorkspaceMeta = { model: "workspace_meta", id: string, workspaceId: string, createdAt: string, updatedAt: string, encryptionKey: EncryptedKey | null, settingSyncDir: string | null, };
export type WorkspaceMeta = {
model: "workspace_meta";
id: string;
workspaceId: string;
createdAt: string;
updatedAt: string;
encryptionKey: EncryptedKey | null;
settingSyncDir: string | null;
};
File diff suppressed because one or more lines are too long
+19
View File
@@ -2,3 +2,22 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its freshly minted ID.
*
* Committing the plan can persist these so a later import of the same document recognizes
* which models it already created, instead of duplicating them.
*/
sourceKeys: { [key in string]?: string }, };
export type ImportPlanWarning = { title: string, detail: string, };
+13 -3
View File
@@ -23,7 +23,7 @@ use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
@@ -236,6 +237,14 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
@@ -909,8 +918,9 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+5 -45
View File
@@ -6,14 +6,11 @@
//! own environment chain before a plugin sees them, or an auth plugin receives
//! `${[ api_key ]}` where it expected a key.
use crate::error::{Error, Result};
use crate::error::Result;
use crate::host::PluginHost;
use crate::render::render_json_value;
use std::collections::HashMap;
use yaak_models::models::AnyModel;
use crate::render::render_form_values;
use yaak_plugins::events::{
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, JsonPrimitive,
RenderPurpose,
GetHttpAuthenticationConfigResponse, GetHttpAuthenticationSummaryResponse, RenderPurpose,
};
use yaak_rpc_schema::*;
use yaak_templates::RenderOptions;
@@ -31,7 +28,7 @@ pub async fn cmd_get_http_authentication_config<H: PluginHost>(
) -> Result<GetHttpAuthenticationConfigResponse> {
// A config form is being displayed, so a template that cannot resolve
// should show as blank rather than refuse to open the form.
let values = render_auth_values(
let values = render_form_values(
&host,
&req.model,
req.environment_id.as_deref(),
@@ -50,7 +47,7 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
) -> Result<()> {
// An action actually uses these values, so an unresolvable template is an
// error rather than an empty string that would silently authenticate wrong.
let values = render_auth_values(
let values = render_form_values(
&host,
&req.model,
req.environment_id.as_deref(),
@@ -63,40 +60,3 @@ pub async fn cmd_call_http_authentication_action<H: PluginHost>(
host.call_http_authentication_action(&req.auth_name, req.action_index, values, req.model.id())
.await
}
/// Render the form's values against the environment chain the model sits in.
///
/// The chain depends on where the model lives — a request inherits through its
/// folder, a workspace has only its own — so the model is what decides which
/// variables are in scope.
async fn render_auth_values<H: PluginHost>(
host: &H,
model: &AnyModel,
environment_id: Option<&str>,
values: HashMap<String, JsonPrimitive>,
purpose: RenderPurpose,
options: &RenderOptions,
) -> Result<HashMap<String, JsonPrimitive>> {
let (workspace_id, folder_id) = match model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
other => {
return Err(Error::Generic(format!(
"Cannot resolve authentication for a {}",
other.model()
)));
}
};
let environment_chain =
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
let cb = host.template_callback(purpose);
let rendered =
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
Ok(serde_json::from_value(rendered)?)
}
+1
View File
@@ -130,6 +130,7 @@ pub trait PluginHost: Host {
) -> impl Future<Output = crate::Result<Vec<GetTemplateFunctionSummaryResponse>>>;
/// The form a template function wants to show for the given values.
/// `values` arrive already rendered.
fn template_function_config(
&self,
function_name: &str,
+47 -3
View File
@@ -1,12 +1,19 @@
//! Rendering a template against an environment chain.
//!
//! The variables come from the chain, the functions come from the host's
//! template callback. Neither of these knows which host it is running under —
//! that is the whole point of taking the callback as a parameter.
//! template callback. `render_template` and `render_json_value` know nothing
//! about which host they run under — that is the whole point of taking the
//! callback as a parameter. `render_form_values` sits one level up: resolving
//! the chain a model sits in is an ordinary database read, so it takes the
//! host and does that read before rendering.
use crate::error::{Error, Result};
use crate::host::PluginHost;
use serde_json::Value;
use yaak_models::models::Environment;
use std::collections::HashMap;
use yaak_models::models::{AnyModel, Environment};
use yaak_models::render::make_vars_hashmap;
use yaak_plugins::events::{JsonPrimitive, RenderPurpose};
use yaak_templates::{RenderOptions, TemplateCallback, parse_and_render, render_json_value_raw};
pub async fn render_template<T: TemplateCallback>(
@@ -28,3 +35,40 @@ pub async fn render_json_value<T: TemplateCallback>(
let vars = &make_vars_hashmap(environment_chain);
render_json_value_raw(value, vars, cb, opt).await
}
/// Render a config form's values against the environment chain the model sits in.
///
/// The chain depends on where the model lives — a request inherits through its
/// folder, a workspace has only its own — so the model is what decides which
/// variables are in scope.
pub(crate) async fn render_form_values<H: PluginHost>(
host: &H,
model: &AnyModel,
environment_id: Option<&str>,
values: HashMap<String, JsonPrimitive>,
purpose: RenderPurpose,
options: &RenderOptions,
) -> Result<HashMap<String, JsonPrimitive>> {
let (workspace_id, folder_id) = match model {
AnyModel::HttpRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::GrpcRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::WebsocketRequest(r) => (r.workspace_id.clone(), r.folder_id.clone()),
AnyModel::Folder(f) => (f.workspace_id.clone(), f.folder_id.clone()),
AnyModel::Workspace(w) => (w.id.clone(), None),
other => {
return Err(Error::Generic(format!(
"Cannot resolve environments for a {}",
other.model()
)));
}
};
let environment_chain =
host.db().resolve_environments(&workspace_id, folder_id.as_deref(), environment_id)?;
let cb = host.template_callback(purpose);
let rendered =
render_json_value(serde_json::to_value(&values)?, environment_chain, &cb, options).await?;
Ok(serde_json::from_value(rendered)?)
}
+14 -2
View File
@@ -7,7 +7,7 @@
use crate::error::Result;
use crate::host::PluginHost;
use crate::render::render_template;
use crate::render::{render_form_values, render_template};
use yaak_plugins::events::{
GetTemplateFunctionConfigResponse, GetTemplateFunctionSummaryResponse, GetThemesResponse,
RenderPurpose,
@@ -56,7 +56,19 @@ pub async fn cmd_template_function_config<H: PluginHost>(
host: H,
req: CmdTemplateFunctionConfigReq,
) -> Result<GetTemplateFunctionConfigResponse> {
host.template_function_config(&req.function_name, req.values, req.model.id()).await
// A config form is being displayed, so a template that cannot resolve
// should show as blank rather than refuse to open the form.
let values = render_form_values(
&host,
&req.model,
req.environment_id.as_deref(),
req.values,
RenderPurpose::Preview,
&RenderOptions::return_empty(),
)
.await?;
host.template_function_config(&req.function_name, values, req.model.id()).await
}
pub async fn cmd_get_themes<H: PluginHost>(
+69 -2
View File
@@ -18,7 +18,7 @@ use yaak_commands::models::{
cmd_default_headers, cmd_get_workspace_meta, models_delete, models_upsert,
models_workspace_models,
};
use yaak_commands::templates::cmd_render_template;
use yaak_commands::templates::{cmd_render_template, cmd_template_function_config};
use yaak_commands::{Host, PluginHost};
use yaak_core::WorkspaceContext;
use yaak_crypto::manager::EncryptionManager;
@@ -172,6 +172,8 @@ struct SingleThreadedHost {
/// The values the last auth-config call arrived with, so a test can check
/// they were rendered before the host ever saw them.
auth_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
/// Same, for the last template-function-config call.
fn_values: Rc<RefCell<Option<HashMap<String, JsonPrimitive>>>>,
}
impl Host for SingleThreadedHost {
@@ -261,9 +263,10 @@ impl PluginHost for SingleThreadedHost {
async fn template_function_config(
&self,
function_name: &str,
_values: HashMap<String, JsonPrimitive>,
values: HashMap<String, JsonPrimitive>,
_model_id: &str,
) -> yaak_commands::Result<GetTemplateFunctionConfigResponse> {
*self.fn_values.borrow_mut() = Some(values);
Err(yaak_commands::Error::Generic(format!("no plugin provides {function_name}()")))
}
@@ -376,6 +379,7 @@ async fn a_single_threaded_host_can_implement_the_trait() {
let host = SingleThreadedHost {
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
auth_values: Rc::new(RefCell::new(None)),
fn_values: Rc::new(RefCell::new(None)),
};
let workspace = Workspace { name: "From one thread".to_string(), ..Default::default() };
@@ -448,6 +452,7 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
let host = SingleThreadedHost {
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
auth_values: Rc::new(RefCell::new(None)),
fn_values: Rc::new(RefCell::new(None)),
};
let workspace = host
@@ -499,3 +504,65 @@ async fn auth_values_are_rendered_before_the_host_sees_them() {
seen.get("password"),
);
}
/// Same contract as auth: template function argument values may contain
/// templates (the 1Password token argument defaults to `${[1PASSWORD_TOKEN]}`),
/// and the shared handler renders them before the host is called.
#[tokio::test]
async fn template_function_values_are_rendered_before_the_host_sees_them() {
let TestHost { inner } = TestHost::new();
let host = SingleThreadedHost {
inner: Rc::new(Arc::into_inner(inner).expect("sole owner")),
auth_values: Rc::new(RefCell::new(None)),
fn_values: Rc::new(RefCell::new(None)),
};
let workspace = host
.db()
.upsert_workspace(
&Workspace { name: "Functions".to_string(), ..Default::default() },
&host.update_source(),
)
.expect("workspace");
host.db()
.upsert_environment(
&Environment {
workspace_id: workspace.id.clone(),
name: "Env".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "1PASSWORD_TOKEN".to_string(),
value: "ops_abc123".to_string(),
id: None,
}],
..Default::default()
},
&host.update_source(),
)
.expect("environment");
let environment =
host.db().list_environments_ensure_base(&workspace.id).expect("list").remove(0);
let mut values = HashMap::new();
values.insert("token".to_string(), JsonPrimitive::String("${[1PASSWORD_TOKEN]}".to_string()));
// The host refuses the call itself — it has no plugins — but only after the
// handler has rendered and handed over the values, which is what matters.
let _ = cmd_template_function_config(
host.clone(),
yaak_rpc_schema::CmdTemplateFunctionConfigReq {
function_name: "1password.item".to_string(),
values,
model: AnyModel::Workspace(workspace),
environment_id: Some(environment.id),
},
)
.await;
let seen = host.fn_values.borrow().clone().expect("the host should have been called");
assert!(
matches!(seen.get("token"), Some(JsonPrimitive::String(v)) if v == "ops_abc123"),
"the template should have been rendered before reaching the host, got {:?}",
seen.get("token"),
);
}
+7
View File
@@ -47,6 +47,7 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GrpcRequest = {
@@ -99,6 +100,7 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -114,8 +116,12 @@ export type HttpUrlParameter = {
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type SyncModel =
@@ -169,4 +175,5 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
+1 -1
View File
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
"google.protobuf.Duration" => JsonSchemaEntry::string(),
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
+24 -4
View File
@@ -3,7 +3,7 @@ use crate::error::Result;
use log::{debug, info, warn};
use reqwest::{Client, ClientBuilder, Proxy, redirect};
use std::sync::{Arc, Mutex};
use yaak_models::models::DnsOverride;
use yaak_models::models::{DnsOverride, HttpVersion};
use yaak_tls::{
ClientCertificateConfig, NativeClientIdentity, get_tls_config, load_native_client_identity,
};
@@ -39,6 +39,7 @@ impl ConfiguredClient {
/// supports TLS 1.0+ for legacy servers.
fn build_native_tls_connector(
client_cert: Option<ClientCertificateConfig>,
http_version: HttpVersion,
) -> Result<native_tls::TlsConnector> {
let mut builder = native_tls::TlsConnector::builder();
builder.danger_accept_invalid_certs(true);
@@ -46,7 +47,11 @@ fn build_native_tls_connector(
builder.min_protocol_version(Some(native_tls::Protocol::Tlsv10));
// reqwest cannot add ALPN to a connector it did not build, so without this
// the native path would silently negotiate HTTP/1.1 for every request.
builder.request_alpns(&["h2", "http/1.1"]);
match http_version {
HttpVersion::Auto => builder.request_alpns(&["h2", "http/1.1"]),
HttpVersion::Http1 => builder.request_alpns(&["http/1.1"]),
HttpVersion::Http2 => builder.request_alpns(&["h2"]),
};
if let Some(identity) = build_native_tls_identity(client_cert)? {
builder.identity(identity);
@@ -100,6 +105,7 @@ pub enum HttpConnectionProxySetting {
pub struct HttpConnectionOptions {
pub id: String,
pub validate_certificates: bool,
pub http_version: HttpVersion,
pub proxy: HttpConnectionProxySetting,
pub client_certificate: Option<ClientCertificateConfig>,
pub dns_overrides: Vec<DnsOverride>,
@@ -128,14 +134,28 @@ impl HttpConnectionOptions {
// This is needed so we can emit DNS timing events for each request
.pool_max_idle_per_host(0);
match self.http_version {
HttpVersion::Auto => {}
HttpVersion::Http1 => client = client.http1_only(),
HttpVersion::Http2 => client = client.http2_prior_knowledge(),
}
// Configure TLS
if self.validate_certificates {
// Use rustls with platform certificate verification (TLS 1.2+ only)
let config = get_tls_config(true, true, self.client_certificate.clone())?;
let mut config = get_tls_config(true, true, self.client_certificate.clone())?;
// A forced version must also constrain ALPN, or the server may
// negotiate a protocol the client then refuses to speak
match self.http_version {
HttpVersion::Auto => {}
HttpVersion::Http1 => config.alpn_protocols = vec![b"http/1.1".to_vec()],
HttpVersion::Http2 => config.alpn_protocols = vec![b"h2".to_vec()],
}
client = client.use_preconfigured_tls(config);
} else {
// Use native TLS for maximum compatibility (supports TLS 1.0+)
let connector = build_native_tls_connector(self.client_certificate.clone())?;
let connector =
build_native_tls_connector(self.client_certificate.clone(), self.http_version)?;
client = client.use_preconfigured_tls(connector);
}
+4 -1
View File
@@ -28,7 +28,10 @@ impl HttpConnectionManager {
pub async fn get_client(&self, opt: &HttpConnectionOptions) -> Result<CachedClient> {
let mut connections = self.connections.write().await;
let id = opt.id.clone();
// The key must include any per-request option that changes how the
// client is built, or a send after a settings change reuses a client
// built with the old value for up to the cache TTL.
let id = format!("{}::{}::{}", opt.id, opt.validate_certificates, opt.http_version);
// Clean old connections
connections.retain(|_, (_, last_used)| last_used.elapsed() <= self.ttl);
+16 -1
View File
@@ -110,6 +110,7 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GraphQlIntrospection = {
@@ -214,6 +215,7 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -318,6 +320,7 @@ export type HttpSendSettings = {
timeoutMs: number;
sendCookies: boolean;
storeCookies: boolean;
httpVersion: HttpVersion;
};
export type HttpUrlParameter = {
@@ -331,8 +334,12 @@ export type HttpUrlParameter = {
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type KeyValue = {
@@ -475,7 +482,14 @@ export type WebsocketEvent = {
};
export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
| "binary"
| "close"
| "error"
| "frame"
| "open"
| "ping"
| "pong"
| "text";
export type WebsocketMessageType = "text" | "binary";
@@ -522,6 +536,7 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type WorkspaceMeta = {
+19
View File
@@ -2,3 +2,22 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>,
/**
* Stable source key for every model in `resources`, keyed by its freshly minted ID.
*
* Committing the plan can persist these so a later import of the same document recognizes
* which models it already created, instead of duplicating them.
*/
sourceKeys: { [key in string]?: string }, };
export type ImportPlanWarning = { title: string, detail: string, };
@@ -0,0 +1,5 @@
ALTER TABLE workspaces ADD COLUMN setting_http_version TEXT DEFAULT 'auto' NOT NULL;
ALTER TABLE folders ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
ALTER TABLE http_requests ADD COLUMN setting_http_version TEXT DEFAULT '{"enabled":false,"value":"auto"}' NOT NULL;
+70 -3
View File
@@ -1,9 +1,9 @@
use crate::error::Result;
use crate::models::HttpRequestIden::{
Authentication, AuthenticationType, Body, BodyType, CreatedAt, Description, FolderId, Headers,
Method, Name, SettingFollowRedirects, SettingRequestTimeout, SettingSendCookies,
SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt, Url, UrlParameters,
WorkspaceId,
Method, Name, SettingFollowRedirects, SettingHttpVersion, SettingRequestTimeout,
SettingSendCookies, SettingStoreCookies, SettingValidateCertificates, SortPriority, UpdatedAt,
Url, UrlParameters, WorkspaceId,
};
use crate::util::generate_prefixed_id;
use chrono::{NaiveDateTime, Utc};
@@ -143,6 +143,7 @@ pub struct ResolvedHttpRequestSettings {
pub request_message_size: ResolvedSetting<i32>,
pub send_cookies: ResolvedSetting<bool>,
pub store_cookies: ResolvedSetting<bool>,
pub http_version: ResolvedSetting<HttpVersion>,
}
impl Default for ResolvedHttpRequestSettings {
@@ -154,6 +155,7 @@ impl Default for ResolvedHttpRequestSettings {
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
send_cookies: ResolvedSetting::default_source(true),
store_cookies: ResolvedSetting::default_source(true),
http_version: ResolvedSetting::default_source(HttpVersion::Auto),
}
}
}
@@ -191,6 +193,7 @@ impl ResolvedHttpRequestSettings {
event("timeout", timeout, &self.request_timeout),
event("send_cookies", self.send_cookies.value.to_string(), &self.send_cookies),
event("store_cookies", self.store_cookies.value.to_string(), &self.store_cookies),
event("http_version", self.http_version.value.to_string(), &self.http_version),
]
}
}
@@ -208,6 +211,8 @@ pub struct HttpSendSettings {
pub timeout_ms: i32,
pub send_cookies: bool,
pub store_cookies: bool,
#[serde(default)]
pub http_version: HttpVersion,
}
impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
@@ -218,6 +223,7 @@ impl From<&ResolvedHttpRequestSettings> for HttpSendSettings {
timeout_ms: s.request_timeout.value,
send_cookies: s.send_cookies.value,
store_cookies: s.store_cookies.value,
http_version: s.http_version.value,
}
}
}
@@ -255,6 +261,49 @@ impl Default for InheritedIntSetting {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
pub enum HttpVersion {
#[default]
Auto,
Http1,
Http2,
}
impl FromStr for HttpVersion {
type Err = crate::error::Error;
fn from_str(s: &str) -> Result<Self> {
match s {
"http1" => Ok(Self::Http1),
"http2" => Ok(Self::Http2),
_ => Ok(Self::Auto),
}
}
}
impl Display for HttpVersion {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let str = match self {
HttpVersion::Auto => "auto",
HttpVersion::Http1 => "http1",
HttpVersion::Http2 => "http2",
};
write!(f, "{}", str)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize, JsonSchema, TS)]
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_models.ts")]
pub struct InheritedHttpVersionSetting {
#[serde(default)]
#[ts(optional, as = "Option<bool>")]
pub enabled: bool,
pub value: HttpVersion,
}
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_models.ts")]
@@ -484,6 +533,7 @@ impl Default for Workspace {
setting_dns_overrides: Vec::new(),
setting_send_cookies: true,
setting_store_cookies: true,
setting_http_version: HttpVersion::Auto,
}
}
}
@@ -516,6 +566,7 @@ pub struct Workspace {
pub setting_dns_overrides: Vec<DnsOverride>,
pub setting_send_cookies: bool,
pub setting_store_cookies: bool,
pub setting_http_version: HttpVersion,
}
impl UpsertModelInfo for Workspace {
@@ -560,6 +611,7 @@ impl UpsertModelInfo for Workspace {
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
(SettingSendCookies, self.setting_send_cookies.into()),
(SettingStoreCookies, self.setting_store_cookies.into()),
(SettingHttpVersion, self.setting_http_version.to_string().into()),
])
}
@@ -579,6 +631,7 @@ impl UpsertModelInfo for Workspace {
WorkspaceIden::SettingDnsOverrides,
WorkspaceIden::SettingSendCookies,
WorkspaceIden::SettingStoreCookies,
WorkspaceIden::SettingHttpVersion,
]
}
@@ -589,6 +642,7 @@ impl UpsertModelInfo for Workspace {
let headers: String = row.get("headers")?;
let authentication: String = row.get("authentication")?;
let setting_dns_overrides: String = row.get("setting_dns_overrides")?;
let setting_http_version: String = row.get("setting_http_version")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -607,6 +661,7 @@ impl UpsertModelInfo for Workspace {
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
setting_send_cookies: row.get("setting_send_cookies")?,
setting_store_cookies: row.get("setting_store_cookies")?,
setting_http_version: setting_http_version.parse().unwrap_or_default(),
})
}
}
@@ -1078,6 +1133,7 @@ impl Default for Folder {
enabled: false,
value: DEFAULT_REQUEST_MESSAGE_SIZE,
},
setting_http_version: InheritedHttpVersionSetting::default(),
}
}
}
@@ -1108,6 +1164,7 @@ pub struct Folder {
pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting,
pub setting_request_message_size: InheritedIntSetting,
pub setting_http_version: InheritedHttpVersionSetting,
}
impl UpsertModelInfo for Folder {
@@ -1159,6 +1216,7 @@ impl UpsertModelInfo for Folder {
SettingRequestMessageSize,
serde_json::to_string(&self.setting_request_message_size)?.into(),
),
(SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()),
])
}
@@ -1178,6 +1236,7 @@ impl UpsertModelInfo for Folder {
FolderIden::SettingFollowRedirects,
FolderIden::SettingRequestTimeout,
FolderIden::SettingRequestMessageSize,
FolderIden::SettingHttpVersion,
]
}
@@ -1193,6 +1252,7 @@ impl UpsertModelInfo for Folder {
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
let setting_request_timeout: String = row.get("setting_request_timeout")?;
let setting_request_message_size: String = row.get("setting_request_message_size")?;
let setting_http_version: String = row.get("setting_http_version")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -1216,6 +1276,7 @@ impl UpsertModelInfo for Folder {
.unwrap_or_default(),
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
.unwrap_or_else(|_| default_request_message_size_setting()),
setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(),
})
}
}
@@ -1283,6 +1344,7 @@ impl Default for HttpRequest {
setting_validate_certificates: InheritedBoolSetting::default(),
setting_follow_redirects: InheritedBoolSetting::default(),
setting_request_timeout: InheritedIntSetting::default(),
setting_http_version: InheritedHttpVersionSetting::default(),
}
}
}
@@ -1319,6 +1381,7 @@ pub struct HttpRequest {
pub setting_validate_certificates: InheritedBoolSetting,
pub setting_follow_redirects: InheritedBoolSetting,
pub setting_request_timeout: InheritedIntSetting,
pub setting_http_version: InheritedHttpVersionSetting,
}
impl UpsertModelInfo for HttpRequest {
@@ -1370,6 +1433,7 @@ impl UpsertModelInfo for HttpRequest {
),
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
(SettingHttpVersion, serde_json::to_string(&self.setting_http_version)?.into()),
])
}
@@ -1394,6 +1458,7 @@ impl UpsertModelInfo for HttpRequest {
SettingValidateCertificates,
SettingFollowRedirects,
SettingRequestTimeout,
SettingHttpVersion,
]
}
@@ -1407,6 +1472,7 @@ impl UpsertModelInfo for HttpRequest {
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
let setting_request_timeout: String = row.get("setting_request_timeout")?;
let setting_http_version: String = row.get("setting_http_version")?;
Ok(Self {
id: row.get("id")?,
model: row.get("model")?,
@@ -1433,6 +1499,7 @@ impl UpsertModelInfo for HttpRequest {
.unwrap_or_default(),
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
.unwrap_or_default(),
setting_http_version: serde_json::from_str(&setting_http_version).unwrap_or_default(),
})
}
}
@@ -208,6 +208,14 @@ impl<'a> ClientDb<'a> {
} else {
parent.store_cookies
},
http_version: if folder.setting_http_version.enabled {
ResolvedSetting::from_model(
folder.setting_http_version.value,
AnyModel::Folder(folder.clone()),
)
} else {
parent.http_version
},
})
}
}
@@ -153,6 +153,14 @@ impl<'a> ClientDb<'a> {
} else {
parent.store_cookies
},
http_version: if http_request.setting_http_version.enabled {
ResolvedSetting::from_model(
http_request.setting_http_version.value,
AnyModel::HttpRequest(http_request.clone()),
)
} else {
parent.http_version
},
})
}
@@ -174,7 +182,10 @@ impl<'a> ClientDb<'a> {
#[cfg(test)]
mod tests {
use crate::init_in_memory;
use crate::models::{HttpRequest, HttpRequestHeader};
use crate::models::{
Folder, HttpRequest, HttpRequestHeader, HttpVersion, InheritedHttpVersionSetting, Workspace,
};
use crate::util::UpdateSource;
#[test]
fn request_resolution_preserves_duplicate_request_headers() {
@@ -210,4 +221,77 @@ mod tests {
assert_eq!(cookies[1].value, "optional=1");
assert!(!cookies[1].enabled);
}
#[test]
fn http_version_resolves_through_the_inheritance_chain() {
let (query_manager, _blob_manager, _rx) = init_in_memory().expect("Failed to init DB");
let db = query_manager.connect();
let workspace = db
.upsert_workspace(
&Workspace {
name: "Test".to_string(),
setting_http_version: HttpVersion::Http2,
..Default::default()
},
&UpdateSource::Background,
)
.expect("Failed to upsert workspace");
let folder = db
.upsert_folder(
&Folder { workspace_id: workspace.id.clone(), ..Default::default() },
&UpdateSource::Background,
)
.expect("Failed to upsert folder");
let request = db
.upsert_http_request(
&HttpRequest {
workspace_id: workspace.id.clone(),
folder_id: Some(folder.id.clone()),
..Default::default()
},
&UpdateSource::Background,
)
.expect("Failed to upsert request");
// No overrides, so the workspace base value applies
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
assert_eq!(resolved.http_version.value, HttpVersion::Http2);
assert_eq!(resolved.http_version.source_model, "workspace");
// A folder override beats the workspace base
db.upsert_folder(
&Folder {
setting_http_version: InheritedHttpVersionSetting {
enabled: true,
value: HttpVersion::Http1,
},
..folder
},
&UpdateSource::Background,
)
.expect("Failed to update folder");
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
assert_eq!(resolved.http_version.value, HttpVersion::Http1);
assert_eq!(resolved.http_version.source_model, "folder");
// A request override beats them both
let request = db
.upsert_http_request(
&HttpRequest {
setting_http_version: InheritedHttpVersionSetting {
enabled: true,
value: HttpVersion::Auto,
},
..request
},
&UpdateSource::Background,
)
.expect("Failed to update request");
let resolved = db.resolve_settings_for_http_request(&request).expect("Failed to resolve");
assert_eq!(resolved.http_version.value, HttpVersion::Auto);
assert_eq!(resolved.http_version.source_model, "http_request");
}
}
+6 -2
View File
@@ -96,8 +96,8 @@ impl<'a> ClientDb<'a> {
deleted
}
Err(e) => {
let _ = conn
.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
let _ =
conn.execute_batch("ROLLBACK TO delete_workspace; RELEASE delete_workspace");
return Err(e);
}
};
@@ -177,6 +177,10 @@ impl<'a> ClientDb<'a> {
workspace.setting_store_cookies,
AnyModel::Workspace(workspace.clone()),
),
http_version: ResolvedSetting::from_model(
workspace.setting_http_version,
AnyModel::Workspace(workspace.clone()),
),
}
}
}
+42
View File
@@ -85,6 +85,48 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>,
}
/// Where a staged import will be committed.
///
/// The destination workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
ExistingWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: BatchUpsertResult,
pub warnings: Vec<ImportPlanWarning>,
/// Stable source key for every model in `resources`, keyed by its freshly minted ID.
///
/// Committing the plan can persist these so a later import of the same document recognizes
/// which models it already created, instead of duplicating them.
pub source_keys: BTreeMap<String, String>,
}
pub fn get_workspace_export_resources(
db: &ClientDb,
yaak_version: &str,
+13 -1
View File
@@ -474,7 +474,19 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, };
export type ImportResponse = {
/**
* Display name of the importer that recognized the input.
*/
importer: string, resources: ImportResources,
/**
* Stable identity for imported resources, keyed by the resource IDs in `resources`.
*
* A key identifies the same source element across re-parses of an edited document, which
* lets a later import recognize what it already imported. Importers only populate it for
* formats that carry their own identifiers; the host derives a key for anything missing.
*/
sourceKeys?: { [key in string]?: string }, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
+16 -1
View File
@@ -109,6 +109,7 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GraphQlIntrospection = {
@@ -213,6 +214,7 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -314,8 +316,12 @@ export type HttpUrlParameter = {
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type KeyValue = {
@@ -378,6 +384,7 @@ export type Settings = {
themeLight: string;
updateChannel: string;
hideLicenseBadge: boolean;
promptFeedback: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
@@ -428,7 +435,14 @@ export type WebsocketEvent = {
};
export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
| "binary"
| "close"
| "error"
| "frame"
| "open"
| "ping"
| "pong"
| "text";
export type WebsocketRequest = {
model: "websocket_request";
@@ -473,6 +487,7 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type WorkspaceMeta = {
+11 -1
View File
@@ -1,5 +1,5 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap};
use ts_rs::TS;
use yaak_models::models::{
AnyModel, Environment, Folder, GrpcRequest, HttpRequest, HttpResponse, WebsocketRequest,
@@ -247,7 +247,17 @@ pub struct ImportRequest {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportResponse {
/// Display name of the importer that recognized the input.
pub importer: String,
pub resources: ImportResources,
/// Stable identity for imported resources, keyed by the resource IDs in `resources`.
///
/// A key identifies the same source element across re-parses of an edited document, which
/// lets a later import recognize what it already imported. Importers only populate it for
/// formats that carry their own identifiers; the host derives a key for anything missing.
#[ts(optional)]
pub source_keys: Option<BTreeMap<String, String>>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
+13 -2
View File
@@ -1104,8 +1104,19 @@ impl PluginManager {
.await?;
// TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e.payload {
InternalEventPayload::ImportResponse(resp) => Some(resp),
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
_ => None,
});
+7
View File
@@ -47,6 +47,7 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GrpcRequest = {
@@ -99,6 +100,7 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -114,8 +116,12 @@ export type HttpUrlParameter = {
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type SyncModel =
@@ -182,4 +188,5 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
+1
View File
@@ -21,5 +21,6 @@ yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+1028 -77
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -190,6 +190,7 @@ impl SendRequestExecutor for ConnectionManagerSendRequestExecutor<'_> {
.get_client(&HttpConnectionOptions {
id: self.plugin_context_id.clone(),
validate_certificates: runtime_config.settings.validate_certificates.value,
http_version: runtime_config.settings.http_version.value,
proxy: runtime_config.proxy.clone(),
client_certificate,
dns_overrides: runtime_config.dns_overrides.clone(),
+1
View File
@@ -267,6 +267,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
+13 -1
View File
@@ -474,7 +474,19 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, };
export type ImportResponse = {
/**
* Display name of the importer that recognized the input.
*/
importer: string, resources: ImportResources,
/**
* Stable identity for imported resources, keyed by the resource IDs in `resources`.
*
* A key identifies the same source element across re-parses of an edited document, which
* lets a later import recognize what it already imported. Importers only populate it for
* formats that carry their own identifiers; the host derives a key for anything missing.
*/
sourceKeys?: { [key in string]?: string }, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
+16 -1
View File
@@ -109,6 +109,7 @@ export type Folder = {
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingRequestMessageSize: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type GraphQlIntrospection = {
@@ -213,6 +214,7 @@ export type HttpRequest = {
settingValidateCertificates: InheritedBoolSetting;
settingFollowRedirects: InheritedBoolSetting;
settingRequestTimeout: InheritedIntSetting;
settingHttpVersion: InheritedHttpVersionSetting;
};
export type HttpRequestHeader = { enabled?: boolean; name: string; value: string; id?: string };
@@ -314,8 +316,12 @@ export type HttpUrlParameter = {
id?: string;
};
export type HttpVersion = "auto" | "http1" | "http2";
export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
export type InheritedIntSetting = { enabled?: boolean; value: number };
export type KeyValue = {
@@ -378,6 +384,7 @@ export type Settings = {
themeLight: string;
updateChannel: string;
hideLicenseBadge: boolean;
promptFeedback: boolean;
autoupdate: boolean;
autoDownloadUpdates: boolean;
checkNotifications: boolean;
@@ -428,7 +435,14 @@ export type WebsocketEvent = {
};
export type WebsocketEventType =
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
| "binary"
| "close"
| "error"
| "frame"
| "open"
| "ping"
| "pong"
| "text";
export type WebsocketRequest = {
model: "websocket_request";
@@ -473,6 +487,7 @@ export type Workspace = {
settingDnsOverrides: Array<DnsOverride>;
settingSendCookies: boolean;
settingStoreCookies: boolean;
settingHttpVersion: HttpVersion;
};
export type WorkspaceMeta = {
@@ -16,6 +16,16 @@ export type PartialImportResources = {
export type ImportPluginResponse = null | {
resources: PartialImportResources;
/**
* Stable identity for imported resources, keyed by the resource IDs in `resources`.
*
* A key identifies the same source element across re-parses of an edited document, so it must
* come from the document itself and must not be derived from anything the user can change in
* Yaak after importing. Omit resources the format gives no identifier the host derives a key
* for those.
*/
sourceKeys?: Record<string, string>;
};
export type ImporterPlugin = {
@@ -167,7 +167,9 @@ export class PluginInstance {
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: "import_response",
importer: this.#mod.importer.name,
resources: reply.resources as ImportResources,
sourceKeys: reply.sourceKeys ?? null,
};
this.#sendPayload(context, replyPayload, replyId);
return;
+4 -1
View File
@@ -5,7 +5,7 @@ import { forwardRef } from "react";
import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
type ButtonVariant = "border" | "solid";
type ButtonVariant = "border" | "solid" | "input";
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
@@ -88,6 +88,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
resolvedColor !== "custom" &&
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
// Chrome of a form input rather than a button: no hover state, and colors resolve from the
// surrounding x-theme-input rather than a button theme.
variant === "input" && "border-border text-text",
)}
disabled={isDisabled}
onClick={onClick}
+2 -1
View File
@@ -11,7 +11,8 @@
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"oauth-1.0a": "^2.2.6"
+9 -2
View File
@@ -161,7 +161,7 @@ export const plugin: PluginDefinition = {
if (values.timestamp) requestData.data.oauth_timestamp = String(values.timestamp);
if (values.verifier) requestData.data.oauth_verifier = String(values.verifier);
let token: OAuth.Token | { key: string } | undefined;
let token: OAuth.Token | { key: string } | { secret: string } | undefined;
if (pkSigs.includes(signatureMethod)) {
token = {
@@ -172,6 +172,10 @@ export const plugin: PluginDefinition = {
token = { key: String(values.tokenKey), secret: String(values.tokenSecret) };
} else if (values.tokenKey) {
token = { key: String(values.tokenKey) };
} else if (values.tokenSecret) {
// The secret still joins the signing key without an access token;
// leaving `key` out keeps oauth_token out of the header entirely
token = { secret: String(values.tokenSecret) };
}
const authParams = oauth.authorize(requestData, token as OAuth.Token | undefined);
@@ -202,7 +206,10 @@ function hashFunction(signatureMethod: SigMethod) {
return (base: string, privateKey: string) =>
crypto.createSign("RSA-SHA512").update(base).sign(privateKey, "base64");
case signatures.PLAINTEXT:
return (base: string) => base;
// RFC 5849 3.4.4: the PLAINTEXT signature IS the signing key,
// `encoded(consumer secret)&encoded(token secret)`. Returning the base
// string put the whole percent-encoded request into oauth_signature.
return (_base: string, key: string) => key;
default:
return (base: string, key: string) =>
crypto.createHmac("sha1", key).update(base).digest("base64");
@@ -0,0 +1,57 @@
import { describe, expect, test } from "vite-plus/test";
import { plugin } from "../src";
function sign(values: Record<string, string>): string {
const result = plugin.authentication!.onApply!(
{} as never,
{
values,
method: "GET",
url: "https://api.example.com/resource",
} as never,
) as { setHeaders: { name: string; value: string }[] };
const header = result.setHeaders[0]!.value;
const match = header.match(/oauth_signature="([^"]*)"/);
return decodeURIComponent(match![1]!);
}
describe("PLAINTEXT signature", () => {
const base = {
signatureMethod: "PLAINTEXT",
consumerKey: "ck",
consumerSecret: "cs",
nonce: "abc123",
timestamp: "1700000000",
};
// RFC 5849 3.4.4: the PLAINTEXT signature is the signing key itself --
// encoded(consumer secret) "&" encoded(token secret) -- not the base string.
test("is the signing key, not the signature base string", () => {
expect(sign({ ...base, tokenKey: "tk", tokenSecret: "ts" })).toBe("cs&ts");
});
test("keeps the trailing separator when there is no token secret", () => {
expect(sign(base)).toBe("cs&");
});
test("includes the token secret without an access token, omitting oauth_token", () => {
const result = plugin.authentication!.onApply!(
{} as never,
{
values: { ...base, tokenSecret: "ts" },
method: "GET",
url: "https://api.example.com/resource",
} as never,
) as { setHeaders: { name: string; value: string }[] };
const header = result.setHeaders[0]!.value;
const match = header.match(/oauth_signature="([^"]*)"/);
expect(decodeURIComponent(match![1]!)).toBe("cs&ts");
expect(header).not.toContain("oauth_token=");
});
test("percent-encodes reserved characters in the secrets", () => {
expect(sign({ ...base, consumerSecret: "c s", tokenKey: "tk", tokenSecret: "t&s" })).toBe(
"c%20s&t%26s",
);
});
});
+6 -2
View File
@@ -359,7 +359,9 @@ function importCommand(parseEntries: string[], workspaceId: string) {
if (typeof p !== "string") {
continue;
}
const [name, value] = p.split("=");
// splitOnce: a query value may itself contain "=" (a base64 payload, a
// nested filter), and only the first one separates name from value.
const [name, value] = splitOnce(p, "=");
urlParameters.push({
name: name ?? "",
value: value ?? "",
@@ -475,7 +477,9 @@ function importCommand(parseEntries: string[], workspaceId: string) {
...((flagsByName.form as string[] | undefined) || []),
...((flagsByName.F as string[] | undefined) || []),
].map((str) => {
const parts = str.split("=");
// splitOnce for the same reason as --url-query above: base64 padding
// ("...==") and any value containing "=" must survive intact.
const parts = splitOnce(str, "=");
const name = parts[0] ?? "";
const value = parts[1] ?? "";
const item: { name: string; value?: string; file?: string; enabled: boolean } = {
+22
View File
@@ -942,6 +942,28 @@ describe("importer-curl", () => {
},
});
});
test("Keeps an = inside a --url-query value", () => {
const imported = convertCurl(
'curl --url-query "filter=type=book" --url-query "t=eyJhIjoxfQ==" https://yaak.app',
);
expect(imported.resources.httpRequests?.[0]?.urlParameters).toEqual([
{ enabled: true, name: "filter", value: "type=book" },
{ enabled: true, name: "t", value: "eyJhIjoxfQ==" },
]);
});
test("Keeps an = inside a form value", () => {
const imported = convertCurl('curl -F "t=eyJhIjoxfQ==" -F "q=a=b" https://yaak.app');
expect(imported.resources.httpRequests?.[0]?.body?.form).toEqual([
{ enabled: true, name: "t", value: "eyJhIjoxfQ==" },
{ enabled: true, name: "q", value: "a=b" },
]);
});
// curl commands carry no identity of their own, so the host derives every key instead.
test("Emits no source keys", () => {
expect(convertCurl("curl https://yaak.app")).not.toHaveProperty("sourceKeys");
});
});
const idCount: Partial<Record<string, number>> = {};
+18
View File
@@ -15,6 +15,24 @@ export function convertId(id: string): string {
return `GENERATE_ID::${id}`;
}
/**
* Recover the Insomnia `_id` behind each emitted resource.
*
* Every resource ID here is `convertId` of the ID the document gave it, which Insomnia keeps
* across edits, so undoing that prefix recovers a stable source key.
*/
export function sourceKeysFromResources(resources: any): Record<string, string> {
const sourceKeys: Record<string, string> = {};
for (const models of Object.values(resources ?? {})) {
if (!Array.isArray(models)) continue;
for (const model of models) {
if (typeof model?.id !== "string") continue;
sourceKeys[model.id] = model.id.replace(/^GENERATE_ID::/, "");
}
}
return sourceKeys;
}
export function importHttpBodyAndHeaders(obj: any) {
const { headers } = importHeaders(obj);
const { body, bodyType } = importHttpBody(obj.body);
+6 -2
View File
@@ -1,6 +1,6 @@
import type { Context, PluginDefinition } from "@yaakapp/api";
import YAML from "yaml";
import { deleteUndefinedAttrs, isJSObject } from "./common";
import { deleteUndefinedAttrs, isJSObject, sourceKeysFromResources } from "./common";
import { convertInsomniaV4 } from "./v4";
import { convertInsomniaV5 } from "./v5";
@@ -32,6 +32,10 @@ export function convertInsomnia(contents: string) {
if (!isJSObject(parsed)) return null;
const result = convertInsomniaV5(parsed) ?? convertInsomniaV4(parsed);
if (result == null) return null;
return deleteUndefinedAttrs(result);
return deleteUndefinedAttrs({
...result,
sourceKeys: sourceKeysFromResources(result.resources),
});
}
@@ -132,5 +132,13 @@
"name": "Dummy"
}
]
},
"sourceKeys": {
"GENERATE_ID::env_16c0dec5b77c414ae0e419b8f10c3701300c5900": "env_16c0dec5b77c414ae0e419b8f10c3701300c5900",
"GENERATE_ID::env_799ae3d723ef44af91b4817e5d057e6d": "env_799ae3d723ef44af91b4817e5d057e6d",
"GENERATE_ID::env_030fbfdbb274426ebd78e2e6518f8553": "env_030fbfdbb274426ebd78e2e6518f8553",
"GENERATE_ID::fld_859d1df78261463480b6a3a1419517e3": "fld_859d1df78261463480b6a3a1419517e3",
"GENERATE_ID::req_84cd9ae4bd034dd8bb730e856a665cbb": "req_84cd9ae4bd034dd8bb730e856a665cbb",
"GENERATE_ID::wrk_d4d92f7c0ee947b89159243506687019": "wrk_d4d92f7c0ee947b89159243506687019"
}
}
@@ -116,5 +116,13 @@
"headers": []
}
]
},
"sourceKeys": {
"GENERATE_ID::env_e46dc73e8ccda30ca132153e8f11183bd08119ce": "env_e46dc73e8ccda30ca132153e8f11183bd08119ce",
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7folder": "fld_296933ea4ea84783a775d199997e9be7folder",
"GENERATE_ID::fld_296933ea4ea84783a775d199997e9be7": "fld_296933ea4ea84783a775d199997e9be7",
"GENERATE_ID::req_9a80320365ac4509ade406359dbc6a71": "req_9a80320365ac4509ade406359dbc6a71",
"GENERATE_ID::req_e3f8cdbd58784a539dd4c1e127d73451": "req_e3f8cdbd58784a539dd4c1e127d73451",
"GENERATE_ID::wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c": "wrk_9717dd1c9e0c4b2e9ed6d2abcf3bd45c"
}
}
@@ -189,5 +189,15 @@
"headers": []
}
]
},
"sourceKeys": {
"GENERATE_ID::env_20945044d3c8497ca8b717bef750987e": "env_20945044d3c8497ca8b717bef750987e",
"GENERATE_ID::env_6f7728bb7fc04d558d668e954d756ea2": "env_6f7728bb7fc04d558d668e954d756ea2",
"GENERATE_ID::env_976a8b6eb5d44fb6a20150f65c32d243": "env_976a8b6eb5d44fb6a20150f65c32d243",
"GENERATE_ID::fld_42eb2e2bb22b4cedacbd3d057634e80c": "fld_42eb2e2bb22b4cedacbd3d057634e80c",
"GENERATE_ID::greq_06d659324df94504a4d64632be7106b3": "greq_06d659324df94504a4d64632be7106b3",
"GENERATE_ID::req_d72fff2a6b104b91a2ebe9de9edd2785": "req_d72fff2a6b104b91a2ebe9de9edd2785",
"GENERATE_ID::ws-req_5d1a4c7c79494743962e5176f6add270": "ws-req_5d1a4c7c79494743962e5176f6add270",
"GENERATE_ID::wrk_c1eacfa750a04f3ea9985ef28043fa53": "wrk_c1eacfa750a04f3ea9985ef28043fa53"
}
}
@@ -24,6 +24,40 @@ describe("importer-yaak", () => {
expect(result).toEqual(parseJsonOrYaml(expected));
});
}
// A source key has to survive edits to the document and stay put when the user renames the
// request in Yaak, otherwise a re-import cannot tell an edit apart from a new request.
test("Keys resources by their Insomnia _id, unchanged by a rename", () => {
const collection = (requestName: string) =>
YAML.stringify({
type: "collection.insomnia.rest/5.0",
name: "Keys",
meta: { id: "wrk_1" },
environments: { meta: { id: "env_1" }, name: "Base", data: {} },
collection: [
{
meta: { id: "fld_1" },
name: "Folder",
children: [
{
meta: { id: "req_1" },
name: requestName,
method: "GET",
url: "https://yaak.app",
},
],
},
],
});
const before = convertInsomnia(collection("Original"));
const after = convertInsomnia(collection("Renamed"));
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("req_1");
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("req_1");
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("fld_1");
expect(before?.sourceKeys?.[before.resources.workspaces[0]!.id]).toBe("wrk_1");
});
});
function parseJsonOrYaml(text: string): unknown {
+66 -12
View File
@@ -25,7 +25,7 @@ type ImportedAuthentication = Pick<HttpRequest, "authentication" | "authenticati
urlParameters: HttpUrlParameter[];
};
type AuthenticationVariableRegistry = Map<string, { name: string; value: string }>;
type OAuthVariableNames = { clientId: string; clientSecret: string };
type OAuthVariableNames = { clientId: string; clientSecret: string; redirectUri: string };
type ServerOverrideVariable = { name: string; value: string };
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "query", "trace"];
@@ -110,6 +110,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
const folderIdsByTag = new Map<string, string>();
const routeLabels = new Map<string, string>();
const sourceKeys: Record<string, string> = {};
for (const tag of toArray(spec.tags)) {
const tagRecord = toRecord(tag);
const name = stringAt(tagRecord, "name");
@@ -126,6 +127,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
};
resources.folders.push(folder);
folderIdsByTag.set(name, folder.id);
sourceKeys[folder.id] = tagSourceKey(name);
}
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
@@ -139,6 +141,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
importState,
operation,
resources,
sourceKeys,
workspaceId: workspace.id,
});
@@ -160,6 +163,11 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
authenticationVariables,
});
routeLabels.set(request.id, `${method.toUpperCase()} ${rawPath}`);
sourceKeys[request.id] = operationSourceKey(
stringAt(operation, "operationId"),
method,
rawPath,
);
resources.httpRequests.push(request);
}
}
@@ -172,6 +180,18 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
clientSecret,
]),
);
// Only redirect-based grants reference the redirect variable, so don't
// create it for specs that import as client_credentials or password
for (const { redirectUri } of oauthVariablesByScheme.values()) {
const used = authenticationConfigs.some(
(model) =>
model.authenticationType === "oauth2" &&
Object.values(toRecord(model.authentication)).some(
(value) => typeof value === "string" && value.includes(templateVariable(redirectUri)),
),
);
if (used) variableNames.add(redirectUri);
}
if (
authenticationConfigs.some(
(model) =>
@@ -229,6 +249,7 @@ export async function convertOpenApi(contents: string): Promise<ImportPluginResp
websocketRequests: [],
workspaces: resources.workspaces,
}) as PartialImportResources,
sourceKeys,
};
}
@@ -565,7 +586,10 @@ function importOperationName(operation: UnknownRecord, method: string, path: str
* description, since a name that long is no easier to scan than the path.
*/
function firstLine(value: string | undefined): string | undefined {
const line = value?.split("\n").find((l) => l.trim().length > 0)?.trim();
const line = value
?.split("\n")
.find((l) => l.trim().length > 0)
?.trim();
if (line == null || line.length > MAX_NAME_LENGTH) return undefined;
return line;
}
@@ -666,12 +690,14 @@ function findOrCreateFolderId({
importState,
operation,
resources,
sourceKeys,
workspaceId,
}: {
folderIdsByTag: Map<string, string>;
importState: ImportState;
operation: UnknownRecord;
resources: ImportResources;
sourceKeys: Record<string, string>;
workspaceId: string;
}): string | null {
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
@@ -690,9 +716,26 @@ function findOrCreateFolderId({
};
resources.folders.push(folder);
folderIdsByTag.set(tag, folder.id);
sourceKeys[folder.id] = tagSourceKey(tag);
return folder.id;
}
/**
* Identify an operation by the parts of the spec that name the endpoint rather than describe it.
*
* `operationId` is the spec's own identifier for an operation and survives a summary being
* reworded; without one, the route is the only thing left that still points at the same endpoint.
*/
function operationSourceKey(operationId: string | undefined, method: string, path: string): string {
return operationId != null && operationId !== ""
? `op:${operationId}`
: `route:${method.toUpperCase()} ${path}`;
}
function tagSourceKey(tag: string): string {
return `tag:${tag}`;
}
/**
* Yaak's `:name` placeholders only substitute when they span a whole path
* segment and hold a single plain value. Templates elsewhere in a segment
@@ -738,8 +781,7 @@ function shouldInlinePathParameter(
// so `{id}` and `{id}:cancel` stay placeholders while `report.{format}` can't
const placeholderExpressible = matchingSegments.every(
(segment) =>
segment === template ||
(segment.startsWith(template) && segment[template.length] === ":"),
segment === template || (segment.startsWith(template) && segment[template.length] === ":"),
);
if (matchingSegments.length === 0 || !placeholderExpressible) return true;
if (isRecord(parameter.content)) return false;
@@ -1009,9 +1051,7 @@ function serializeCookieParameter(parameter: UnknownRecord, importState: ImportS
if (isRecord(value)) {
const entries = Object.entries(value);
return explode
? entries
.map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`)
.join("; ")
? entries.map(([key, entryValue]) => `${key}=${stringifyExampleValue(entryValue)}`).join("; ")
: `${name}=${entries.flat().map(stringifyExampleValue).join(",")}`;
}
return `${name}=${stringifyExampleValue(value)}`;
@@ -1158,7 +1198,9 @@ function importBody({
.filter((p) => stringAt(p, "in") === "formData");
if (formParameters.length > 0) {
const contentType =
toArray(operation.consumes ?? spec.consumes).find((c): c is string => typeof c === "string") ??
toArray(operation.consumes ?? 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");
@@ -1411,7 +1453,11 @@ function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): u
return schemaToExample(mediaType.schema, importState);
}
function schemaToFormParameters(schema: unknown, importState: ImportState, example?: UnknownRecord) {
function schemaToFormParameters(
schema: unknown,
importState: ImportState,
example?: UnknownRecord,
) {
const resolvedSchema = toRecord(importState.resolveSchema(schema));
const required = toArray(resolvedSchema.required).filter(
(name): name is string => typeof name === "string",
@@ -1575,7 +1621,6 @@ function coerceToDeclaredType(example: unknown, schema: UnknownRecord): unknown
return example;
}
function inferSchemaType(schema: UnknownRecord): string {
const rawType = schema.type;
if (typeof rawType === "string") return rawType;
@@ -1688,6 +1733,7 @@ function importSecurityRequirement({
oauthVariablesByScheme.get(schemeName) ?? {
clientId: "oauth_client_id",
clientSecret: "oauth_client_secret",
redirectUri: "oauth_redirect_uri",
},
useDynamicServerUrls,
);
@@ -1876,9 +1922,13 @@ function importOAuth2(
authorizationUrl,
accessTokenUrl,
clientSecret: templateVariable(variableNames.clientSecret),
redirectUri: templateVariable(variableNames.redirectUri),
}
: grantType === "implicit"
? { authorizationUrl }
? {
authorizationUrl,
redirectUri: templateVariable(variableNames.redirectUri),
}
: grantType === "password"
? {
accessTokenUrl,
@@ -1969,7 +2019,11 @@ function buildOAuthVariablesByScheme(
usedPrefixes.add(prefix);
return [
schemeName,
{ clientId: `${prefix}_client_id`, clientSecret: `${prefix}_client_secret` },
{
clientId: `${prefix}_client_id`,
clientSecret: `${prefix}_client_secret`,
redirectUri: `${prefix}_redirect_uri`,
},
];
}),
);
@@ -308,6 +308,16 @@ License: CC0 1.0 (https://github.com/APIs-guru/openapi-directory#licenses)",
},
],
},
"sourceKeys": {
"GENERATE_ID::FOLDER_0": "tag:APIs",
"GENERATE_ID::HTTP_REQUEST_0": "op:listAPIs",
"GENERATE_ID::HTTP_REQUEST_1": "op:getMetrics",
"GENERATE_ID::HTTP_REQUEST_2": "op:getProviders",
"GENERATE_ID::HTTP_REQUEST_3": "op:getAPI",
"GENERATE_ID::HTTP_REQUEST_4": "op:getServiceAPI",
"GENERATE_ID::HTTP_REQUEST_5": "op:getProvider",
"GENERATE_ID::HTTP_REQUEST_6": "op:getServices",
},
}
`;
@@ -2600,6 +2610,97 @@ Contact: me@kennethreitz.org",
},
],
},
"sourceKeys": {
"GENERATE_ID::FOLDER_0": "tag:HTTP Methods",
"GENERATE_ID::FOLDER_1": "tag:Auth",
"GENERATE_ID::FOLDER_10": "tag:Anything",
"GENERATE_ID::FOLDER_2": "tag:Status codes",
"GENERATE_ID::FOLDER_3": "tag:Request inspection",
"GENERATE_ID::FOLDER_4": "tag:Response inspection",
"GENERATE_ID::FOLDER_5": "tag:Response formats",
"GENERATE_ID::FOLDER_6": "tag:Dynamic data",
"GENERATE_ID::FOLDER_7": "tag:Cookies",
"GENERATE_ID::FOLDER_8": "tag:Images",
"GENERATE_ID::FOLDER_9": "tag:Redirects",
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /absolute-redirect/{n}",
"GENERATE_ID::HTTP_REQUEST_1": "route:DELETE /anything",
"GENERATE_ID::HTTP_REQUEST_10": "route:POST /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_11": "route:PUT /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_12": "route:TRACE /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_13": "route:GET /base64/{value}",
"GENERATE_ID::HTTP_REQUEST_14": "route:GET /basic-auth/{user}/{passwd}",
"GENERATE_ID::HTTP_REQUEST_15": "route:GET /bearer",
"GENERATE_ID::HTTP_REQUEST_16": "route:GET /brotli",
"GENERATE_ID::HTTP_REQUEST_17": "route:GET /bytes/{n}",
"GENERATE_ID::HTTP_REQUEST_18": "route:GET /cache",
"GENERATE_ID::HTTP_REQUEST_19": "route:GET /cache/{value}",
"GENERATE_ID::HTTP_REQUEST_2": "route:GET /anything",
"GENERATE_ID::HTTP_REQUEST_20": "route:GET /cookies",
"GENERATE_ID::HTTP_REQUEST_21": "route:GET /cookies/delete",
"GENERATE_ID::HTTP_REQUEST_22": "route:GET /cookies/set",
"GENERATE_ID::HTTP_REQUEST_23": "route:GET /cookies/set/{name}/{value}",
"GENERATE_ID::HTTP_REQUEST_24": "route:GET /deflate",
"GENERATE_ID::HTTP_REQUEST_25": "route:DELETE /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_26": "route:GET /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_27": "route:PATCH /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_28": "route:POST /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_29": "route:PUT /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_3": "route:PATCH /anything",
"GENERATE_ID::HTTP_REQUEST_30": "route:TRACE /delay/{delay}",
"GENERATE_ID::HTTP_REQUEST_31": "route:DELETE /delete",
"GENERATE_ID::HTTP_REQUEST_32": "route:GET /deny",
"GENERATE_ID::HTTP_REQUEST_33": "route:GET /digest-auth/{qop}/{user}/{passwd}",
"GENERATE_ID::HTTP_REQUEST_34": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}",
"GENERATE_ID::HTTP_REQUEST_35": "route:GET /digest-auth/{qop}/{user}/{passwd}/{algorithm}/{stale_after}",
"GENERATE_ID::HTTP_REQUEST_36": "route:GET /drip",
"GENERATE_ID::HTTP_REQUEST_37": "route:GET /encoding/utf8",
"GENERATE_ID::HTTP_REQUEST_38": "route:GET /etag/{etag}",
"GENERATE_ID::HTTP_REQUEST_39": "route:GET /get",
"GENERATE_ID::HTTP_REQUEST_4": "route:POST /anything",
"GENERATE_ID::HTTP_REQUEST_40": "route:GET /gzip",
"GENERATE_ID::HTTP_REQUEST_41": "route:GET /headers",
"GENERATE_ID::HTTP_REQUEST_42": "route:GET /hidden-basic-auth/{user}/{passwd}",
"GENERATE_ID::HTTP_REQUEST_43": "route:GET /html",
"GENERATE_ID::HTTP_REQUEST_44": "route:GET /image",
"GENERATE_ID::HTTP_REQUEST_45": "route:GET /image/jpeg",
"GENERATE_ID::HTTP_REQUEST_46": "route:GET /image/png",
"GENERATE_ID::HTTP_REQUEST_47": "route:GET /image/svg",
"GENERATE_ID::HTTP_REQUEST_48": "route:GET /image/webp",
"GENERATE_ID::HTTP_REQUEST_49": "route:GET /ip",
"GENERATE_ID::HTTP_REQUEST_5": "route:PUT /anything",
"GENERATE_ID::HTTP_REQUEST_50": "route:GET /json",
"GENERATE_ID::HTTP_REQUEST_51": "route:GET /links/{n}/{offset}",
"GENERATE_ID::HTTP_REQUEST_52": "route:PATCH /patch",
"GENERATE_ID::HTTP_REQUEST_53": "route:POST /post",
"GENERATE_ID::HTTP_REQUEST_54": "route:PUT /put",
"GENERATE_ID::HTTP_REQUEST_55": "route:GET /range/{numbytes}",
"GENERATE_ID::HTTP_REQUEST_56": "route:DELETE /redirect-to",
"GENERATE_ID::HTTP_REQUEST_57": "route:GET /redirect-to",
"GENERATE_ID::HTTP_REQUEST_58": "route:PATCH /redirect-to",
"GENERATE_ID::HTTP_REQUEST_59": "route:POST /redirect-to",
"GENERATE_ID::HTTP_REQUEST_6": "route:TRACE /anything",
"GENERATE_ID::HTTP_REQUEST_60": "route:PUT /redirect-to",
"GENERATE_ID::HTTP_REQUEST_61": "route:TRACE /redirect-to",
"GENERATE_ID::HTTP_REQUEST_62": "route:GET /redirect/{n}",
"GENERATE_ID::HTTP_REQUEST_63": "route:GET /relative-redirect/{n}",
"GENERATE_ID::HTTP_REQUEST_64": "route:GET /response-headers",
"GENERATE_ID::HTTP_REQUEST_65": "route:POST /response-headers",
"GENERATE_ID::HTTP_REQUEST_66": "route:GET /robots.txt",
"GENERATE_ID::HTTP_REQUEST_67": "route:DELETE /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_68": "route:GET /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_69": "route:PATCH /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_7": "route:DELETE /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_70": "route:POST /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_71": "route:PUT /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_72": "route:TRACE /status/{codes}",
"GENERATE_ID::HTTP_REQUEST_73": "route:GET /stream-bytes/{n}",
"GENERATE_ID::HTTP_REQUEST_74": "route:GET /stream/{n}",
"GENERATE_ID::HTTP_REQUEST_75": "route:GET /user-agent",
"GENERATE_ID::HTTP_REQUEST_76": "route:GET /uuid",
"GENERATE_ID::HTTP_REQUEST_77": "route:GET /xml",
"GENERATE_ID::HTTP_REQUEST_8": "route:GET /anything/{anything}",
"GENERATE_ID::HTTP_REQUEST_9": "route:PATCH /anything/{anything}",
},
}
`;
@@ -2734,6 +2835,10 @@ License: Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html)",
},
],
},
"sourceKeys": {
"GENERATE_ID::FOLDER_0": "tag:request tag",
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /apod",
},
}
`;
@@ -2834,5 +2939,9 @@ Responses:
},
],
},
"sourceKeys": {
"GENERATE_ID::HTTP_REQUEST_0": "route:GET /info.0.json",
"GENERATE_ID::HTTP_REQUEST_1": "route:GET /{comicId}/info.0.json",
},
}
`;
+57 -4
View File
@@ -384,6 +384,7 @@ describe("importer-openapi", () => {
{ name: "baseUrl", value: "https://api.example.com/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "oauth_redirect_uri", value: "" },
{ name: "baseUrlOrigin", value: "https://api.example.com" },
{ name: "auth_api_key_key", value: "" },
],
@@ -395,6 +396,7 @@ describe("importer-openapi", () => {
{ name: "baseUrl", value: "https://sandbox.example.com/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "oauth_redirect_uri", value: "" },
{ name: "baseUrlOrigin", value: "https://sandbox.example.com" },
{ name: "auth_api_key_key", value: "" },
],
@@ -484,6 +486,7 @@ describe("importer-openapi", () => {
clientId: "${[oauth_implicitOauth_client_id]}",
headerPrefix: "Bearer",
authorizationUrl: "https://example.com/authorize",
redirectUri: "${[oauth_implicitOauth_redirect_uri]}",
},
}),
);
@@ -497,6 +500,7 @@ describe("importer-openapi", () => {
{ name: "oauth_oauth_client_secret", value: "" },
{ name: "oauth_implicitOauth_client_id", value: "" },
{ name: "oauth_implicitOauth_client_secret", value: "" },
{ name: "oauth_implicitOauth_redirect_uri", value: "" },
],
}),
);
@@ -539,6 +543,7 @@ describe("importer-openapi", () => {
{ name: "baseUrl", value: "https://api.example.com" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "oauth_redirect_uri", value: "" },
],
}),
]);
@@ -596,6 +601,7 @@ describe("importer-openapi", () => {
{ name: "baseUrl", value: "/api/v1" },
{ name: "oauth_client_id", value: "" },
{ name: "oauth_client_secret", value: "" },
{ name: "oauth_redirect_uri", value: "" },
{ name: "baseUrlOrigin", value: "" },
],
}),
@@ -633,6 +639,7 @@ describe("importer-openapi", () => {
scope: "admin",
authorizationUrl: "https://example.com/authorize",
accessTokenUrl: "https://example.com/token",
redirectUri: "${[oauth_redirect_uri]}",
},
headers: [{ enabled: true, name: "Accept", value: "application/json" }],
}),
@@ -1376,9 +1383,7 @@ describe("importer-openapi", () => {
"application/json": {
schema: {
type: "object",
allOf: [
{ type: "object", properties: { fromAllOf: { example: "a" } } },
],
allOf: [{ type: "object", properties: { fromAllOf: { example: "a" } } }],
properties: { sibling: { example: "b" } },
},
},
@@ -1398,7 +1403,17 @@ describe("importer-openapi", () => {
test("Accepts unquoted YAML version numbers", async () => {
const imported = await convertOpenApi(
["swagger: 2.0", "info:", " title: Unquoted Test", ' version: "1"', "host: example.com", "paths:", " /a:", " get:", " responses: {}"].join("\n"),
[
"swagger: 2.0",
"info:",
" title: Unquoted Test",
' version: "1"',
"host: example.com",
"paths:",
" /a:",
" get:",
" responses: {}",
].join("\n"),
);
expect(imported?.resources.httpRequests[0]?.url).toBe("${[baseUrl]}/a");
@@ -2367,4 +2382,42 @@ describe("importer-openapi", () => {
expect(imported).toMatchSnapshot();
});
}
// A source key has to survive edits to the document and stay put when the user renames the
// request in Yaak, otherwise a re-import cannot tell an edit apart from a new request.
test("Keys operations by operationId, unchanged by a rename", async () => {
const spec = (summary: string) =>
JSON.stringify({
openapi: "3.0.0",
info: { title: "Keys", version: "1" },
paths: {
"/pets": {
get: { operationId: "listPets", summary, tags: ["pets"], responses: {} },
},
},
});
const before = await convertOpenApi(spec("List pets"));
const after = await convertOpenApi(spec("Fetch every pet"));
expect(before?.sourceKeys?.[before.resources.httpRequests[0]!.id]).toBe("op:listPets");
expect(after?.sourceKeys?.[after.resources.httpRequests[0]!.id]).toBe("op:listPets");
expect(before?.sourceKeys?.[before.resources.folders[0]!.id]).toBe("tag:pets");
});
// operationId is optional in OpenAPI, and the route is the only other part of the document
// that still points at the same endpoint.
test("Falls back to the route when an operation has no operationId", async () => {
const imported = await convertOpenApi(
JSON.stringify({
openapi: "3.0.0",
info: { title: "Keys", version: "1" },
paths: { "/pets/{id}": { delete: { summary: "Remove", responses: {} } } },
}),
);
expect(imported?.sourceKeys?.[imported.resources.httpRequests[0]!.id]).toBe(
"route:DELETE /pets/{id}",
);
});
});
+10 -1
View File
@@ -49,6 +49,12 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
const globalAuth = importAuth(root.auth);
const sourceKeys: Record<string, string> = {};
const trackSourceKey = (modelId: string, v: Record<string, unknown>, prefix: string) => {
const id = v.id ?? v._postman_id;
if (typeof id === "string" && id !== "") sourceKeys[modelId] = `${prefix}:${id}`;
};
const exportResources: ExportResources = {
workspaces: [],
environments: [],
@@ -63,6 +69,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
description: importDescription(info.description),
...globalAuth,
};
trackSourceKey(workspace.id, info, "collection");
exportResources.workspaces.push(workspace);
// Create the base environment
@@ -92,6 +99,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
name: v.name,
folderId,
};
trackSourceKey(folder.id, v, "item");
exportResources.folders.push(folder);
for (const child of v.item) {
importItem(child, folder.id);
@@ -142,6 +150,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
headers,
...requestAuth,
};
trackSourceKey(request.id, v, "item");
exportResources.httpRequests.push(request);
} else {
console.log("Unknown item", v, folderId);
@@ -156,7 +165,7 @@ export function convertPostman(contents: string): ImportPluginResponse | undefin
convertTemplateSyntax(exportResources),
) as PartialImportResources;
return { resources };
return { resources, sourceKeys };
}
function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters"> {
@@ -300,5 +300,8 @@
}
],
"folders": []
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_0": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
}
}
@@ -88,5 +88,8 @@
"folderId": "GENERATE_ID::FOLDER_0"
}
]
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_1": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
}
}
@@ -100,5 +100,8 @@
}
],
"folders": []
},
"sourceKeys": {
"GENERATE_ID::WORKSPACE_2": "collection:9e6dfada-256c-49ea-a38f-7d1b05b7ca2d"
}
}
@@ -87,4 +87,59 @@ describe("importer-postman", () => {
}),
]);
});
// A source key has to survive edits to the document and stay put when the user renames the
// request in Yaak, otherwise a re-import cannot tell an edit apart from a new request.
test("Keys items by their Postman ID, unchanged by a rename", () => {
const collection = (requestName: string) =>
JSON.stringify({
info: {
_postman_id: "collection-id",
name: "Keys",
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
item: [
{
id: "folder-id",
name: "Folder",
item: [
{
id: "request-id",
name: requestName,
request: { method: "GET", url: "https://yaak.app" },
},
],
},
],
});
const before = convertPostman(collection("Original"));
const after = convertPostman(collection("Renamed"));
const keyOf = (result: ReturnType<typeof convertPostman>, id: string | undefined) =>
id == null ? undefined : result?.sourceKeys?.[id];
expect(keyOf(before, before?.resources.httpRequests[0]?.id)).toBe("item:request-id");
expect(keyOf(after, after?.resources.httpRequests[0]?.id)).toBe("item:request-id");
expect(keyOf(before, before?.resources.folders[0]?.id)).toBe("item:folder-id");
expect(keyOf(before, before?.resources.workspaces[0]?.id)).toBe("collection:collection-id");
});
// Plenty of collections in the wild predate Postman writing item IDs, so the map just omits
// them and the host derives a key instead.
test("Omits keys for items the collection never identified", () => {
const result = convertPostman(
JSON.stringify({
info: {
name: "No IDs",
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
},
item: [{ name: "Request", request: { method: "GET", url: "https://yaak.app" } }],
}),
);
const requestId = result?.resources.httpRequests[0]?.id;
expect(requestId).toBeDefined();
expect(result?.sourceKeys).not.toHaveProperty(requestId as string);
});
});
+10 -1
View File
@@ -80,7 +80,16 @@ export function migrateImport(contents: string) {
}
}
return { resources: parsed.resources };
// Yaak's own model IDs are already stable identities for the exported document.
const sourceKeys: Record<string, string> = {};
for (const models of Object.values(parsed.resources)) {
if (!Array.isArray(models)) continue;
for (const model of models) {
if (typeof model?.id === "string") sourceKeys[model.id] = model.id;
}
}
return { resources: parsed.resources, sourceKeys };
}
function isJSObject(obj: unknown) {
+30
View File
@@ -148,4 +148,34 @@ describe("importer-yaak", () => {
}),
);
});
// A source key has to survive edits to the document and stay put when the user renames the
// request in Yaak, otherwise a re-import cannot tell an edit apart from a new request.
test("Keys models by their Yaak ID, unchanged by a rename", () => {
const exported = (requestName: string) =>
JSON.stringify({
yaakSchema: 5,
resources: {
workspaces: [{ id: "wk_1", model: "workspace", name: "Keys" }],
httpRequests: [
{
id: "rq_1",
model: "http_request",
workspaceId: "wk_1",
name: requestName,
url: "https://yaak.app",
},
],
},
});
expect(migrateImport(exported("Original"))?.sourceKeys).toEqual({
wk_1: "wk_1",
rq_1: "rq_1",
});
expect(migrateImport(exported("Renamed"))?.sourceKeys).toEqual({
wk_1: "wk_1",
rq_1: "rq_1",
});
});
});
+2 -1
View File
@@ -8,7 +8,8 @@
"types": "src/index.ts",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"jsonpath-plus": "^10.3.0"
+5 -1
View File
@@ -85,7 +85,11 @@ export const plugin: PluginDefinition = {
],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? "");
return input.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
// JSON.stringify produces a spec-correct string literal: it escapes
// the backslash and quote this used to handle, and also the control
// characters it did not. Slicing off the surrounding quotes leaves
// the escaped inner text this function is meant to emit.
return JSON.stringify(input).slice(1, -1);
},
},
{
@@ -0,0 +1,50 @@
import type { Context } from "@yaakapp/api";
import { describe, expect, it } from "vite-plus/test";
import { plugin } from "../src";
const LF = String.fromCharCode(10);
const TAB = String.fromCharCode(9);
const CR = String.fromCharCode(13);
describe("json.escape", () => {
const escapeFunction = plugin.templateFunctions?.find((f) => f.name === "json.escape");
const escape = async (input: string) =>
await escapeFunction!.onRender({} as Context, { values: { input } } as never);
// The point of the function is that the result can be dropped between two
// quotes in a JSON document, so that is what these assert.
const embeds = (escaped: string | null) => {
JSON.parse(`{"k":"${escaped}"}`);
return JSON.parse(`{"k":"${escaped}"}`).k;
};
it("should exist", () => {
expect(escapeFunction).toBeTruthy();
});
it("escapes a quote", async () => {
const input = `say "hi"`;
expect(embeds(await escape(input))).toBe(input);
});
it("escapes a backslash", async () => {
const input = `a${String.fromCharCode(92)}b`;
expect(embeds(await escape(input))).toBe(input);
});
it("escapes a newline", async () => {
const input = `line1${LF}line2`;
expect(embeds(await escape(input))).toBe(input);
});
it("escapes a tab and a carriage return", async () => {
const input = `a${TAB}b${CR}c`;
expect(embeds(await escape(input))).toBe(input);
});
it("round-trips a pretty-printed JSON document", async () => {
const input = JSON.stringify({ name: `he said "hi"`, items: [1, 2] }, null, 2);
expect(embeds(await escape(input))).toBe(input);
});
});