mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-21 20:18:43 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
195f89337f | ||
|
|
57fffe5a41 | ||
|
|
0c24d6562a | ||
|
|
b1ea3e3560 | ||
|
|
d72b7d7d30 | ||
|
|
b40e2cdc1b | ||
|
|
19cc6ee6d4 | ||
|
|
e59ecce886 | ||
|
|
c30f5b767b | ||
|
|
42b22d4c07 | ||
|
|
e05feba708 | ||
|
|
6b9b3660de |
@@ -630,6 +630,22 @@ async function checkPullRequest({
|
||||
const pr = response.data;
|
||||
const issueNumber = pr.number;
|
||||
|
||||
if (pr.user.type === "Bot") {
|
||||
core.notice(
|
||||
`Skipping contribution policy for bot PR #${pr.number} from @${pr.user.login}.`,
|
||||
);
|
||||
return {
|
||||
blocked: false,
|
||||
number: pr.number,
|
||||
summary: summarizeResult({
|
||||
pr,
|
||||
skipped: true,
|
||||
skipReason: `bot @${pr.user.login}`,
|
||||
}),
|
||||
skipped: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
minimumAutomaticPullNumber != null &&
|
||||
pr.number < minimumAutomaticPullNumber
|
||||
|
||||
Generated
+67
-11
@@ -862,6 +862,15 @@ dependencies = [
|
||||
"alloc-stdlib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
|
||||
dependencies = [
|
||||
"tinyvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -1752,8 +1761,18 @@ version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
"darling_core 0.20.11",
|
||||
"darling_macro 0.20.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"darling_macro 0.23.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1763,6 +1782,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
|
||||
dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1776,7 +1807,18 @@ version = "0.20.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_core 0.20.11",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
|
||||
dependencies = [
|
||||
"darling_core 0.23.0",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
]
|
||||
@@ -7316,6 +7358,18 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
@@ -7384,7 +7438,7 @@ version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.20.11",
|
||||
"heck 0.4.1",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -7653,17 +7707,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.12.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa"
|
||||
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bs58",
|
||||
"chrono",
|
||||
"hex",
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
"time",
|
||||
@@ -7671,11 +7727,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.12.0"
|
||||
version = "3.21.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d00caa5193a3c8362ac2b73be6b9e768aa5a4b2f721d8f4b339600c3cb51f8e"
|
||||
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"darling 0.23.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.101",
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <!-- sponsors-base -->
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||
</p>
|
||||
|
||||

|
||||
|
||||
@@ -39,6 +39,7 @@ import { BinaryFileEditor } from "./BinaryFileEditor";
|
||||
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
@@ -285,16 +286,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => ({
|
||||
minMatch: 3,
|
||||
options:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
}),
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||
import { CountBadge } from "./core/CountBadge";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
|
||||
import { Editor } from "./core/Editor/LazyEditor";
|
||||
import { IconButton } from "./core/IconButton";
|
||||
import type { Pair } from "./core/PairEditor";
|
||||
@@ -130,16 +131,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => ({
|
||||
minMatch: 3,
|
||||
options:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
}),
|
||||
() => getUrlCompletionConfig(autocompleteUrls),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import { defaultBoost } from "./twig/completion";
|
||||
|
||||
export type GenericCompletion = GenericCompletionOption & {
|
||||
apply?: Completion["apply"];
|
||||
};
|
||||
|
||||
export interface GenericCompletionConfig {
|
||||
minMatch?: number;
|
||||
options: GenericCompletionOption[];
|
||||
options: GenericCompletion[];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { Completion } from "@codemirror/autocomplete";
|
||||
import { EditorState, type TransactionSpec } from "@codemirror/state";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { applyUrlCompletion, getUrlCompletionConfig } from "./completion";
|
||||
|
||||
describe("applyUrlCompletion", () => {
|
||||
test("consumes an existing protocol suffix and preserves the rest of the URL", () => {
|
||||
expect(applyCompletion("http://rickandmortyapi.com/api/character", "http://", 4)).toBe(
|
||||
"http://rickandmortyapi.com/api/character",
|
||||
);
|
||||
});
|
||||
|
||||
test("inserts a protocol when there is no existing suffix", () => {
|
||||
expect(applyCompletion("htt", "http://", 3)).toBe("http://");
|
||||
});
|
||||
|
||||
test("replaces the full URL when accepting a saved URL", () => {
|
||||
expect(applyCompletion("htt://old.example/path", "https://new.example/api", 3)).toBe(
|
||||
"https://new.example/api",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUrlCompletionConfig", () => {
|
||||
test("always includes protocols alongside saved URL options", () => {
|
||||
const config = getUrlCompletionConfig([{ label: "https://example.com" }]);
|
||||
|
||||
expect(config.options.map((option) => option.label)).toEqual([
|
||||
"http://",
|
||||
"https://",
|
||||
"https://example.com",
|
||||
]);
|
||||
expect(config.options.every((option) => option.apply === applyUrlCompletion)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function applyCompletion(document: string, label: string, cursor: number) {
|
||||
let state = EditorState.create({ doc: document, selection: { anchor: cursor } });
|
||||
const view = {
|
||||
state,
|
||||
dispatch: (spec: TransactionSpec) => {
|
||||
state = state.update(spec).state;
|
||||
},
|
||||
} as unknown as EditorView;
|
||||
|
||||
applyUrlCompletion(view, { label } satisfies Completion, 0, cursor);
|
||||
return state.doc.toString();
|
||||
}
|
||||
@@ -1,9 +1,51 @@
|
||||
import { genericCompletion } from "../genericCompletion";
|
||||
import { insertCompletionText, pickedCompletion, type Completion } from "@codemirror/autocomplete";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
||||
import {
|
||||
genericCompletion,
|
||||
type GenericCompletion,
|
||||
type GenericCompletionConfig,
|
||||
} from "../genericCompletion";
|
||||
|
||||
export const completions = genericCompletion({
|
||||
options: [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
],
|
||||
minMatch: 1,
|
||||
});
|
||||
const protocolOptions: GenericCompletionOption[] = [
|
||||
{ label: "http://", type: "constant" },
|
||||
{ label: "https://", type: "constant" },
|
||||
];
|
||||
|
||||
export function getUrlCompletionConfig(
|
||||
options: GenericCompletionOption[],
|
||||
minMatch = 3,
|
||||
): GenericCompletionConfig {
|
||||
const urlOptions = [
|
||||
...protocolOptions,
|
||||
...options.filter(
|
||||
(option) => !protocolOptions.some((protocol) => protocol.label === option.label),
|
||||
),
|
||||
];
|
||||
return {
|
||||
minMatch,
|
||||
options: urlOptions.map<GenericCompletion>((option) => ({
|
||||
...option,
|
||||
apply: applyUrlCompletion,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyUrlCompletion(
|
||||
view: EditorView,
|
||||
completion: Completion,
|
||||
from: number,
|
||||
to: number,
|
||||
) {
|
||||
const isProtocol = /^https?:\/\/$/.test(completion.label);
|
||||
const replaceTo = isProtocol
|
||||
? to + (view.state.sliceDoc(to, to + 3) === "://" ? 3 : 0)
|
||||
: view.state.doc.length;
|
||||
|
||||
view.dispatch({
|
||||
...insertCompletionText(view.state, completion.label, from, replaceTo),
|
||||
annotations: pickedCompletion.of(completion),
|
||||
});
|
||||
}
|
||||
|
||||
export const completions = genericCompletion(getUrlCompletionConfig([], 1));
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, test, vi } from "vite-plus/test";
|
||||
import type { HotkeyAction } from "../../hooks/useHotKey";
|
||||
import { HotkeyList } from "./HotkeyList";
|
||||
|
||||
vi.mock("./Hotkey", () => ({
|
||||
Hotkey: ({ action }: { action: HotkeyAction }) =>
|
||||
action === "sidebar.selected.move" ? null : <span>{action}</span>,
|
||||
}));
|
||||
|
||||
vi.mock("./HotkeyLabel", () => ({
|
||||
HotkeyLabel: ({ action }: { action: HotkeyAction }) => <span>{action}</span>,
|
||||
}));
|
||||
|
||||
describe("HotkeyList", () => {
|
||||
test("keeps a grid cell for actions without a shortcut", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<HotkeyList hotkeys={["sidebar.selected.move", "request.send"]} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain(
|
||||
'<span>sidebar.selected.move</span><div class="ml-4"></div><span>request.send</span>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -18,7 +18,10 @@ export const HotkeyList = ({ hotkeys, bottomSlot, className }: Props) => {
|
||||
{hotkeys.map((hotkey) => (
|
||||
<Fragment key={hotkey}>
|
||||
<HotkeyLabel className="truncate" action={hotkey} />
|
||||
<Hotkey className="ml-4" action={hotkey} />
|
||||
{/* Keep this grid cell when Hotkey renders nothing so later rows stay aligned. */}
|
||||
<div className="ml-4">
|
||||
<Hotkey action={hotkey} />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
{bottomSlot}
|
||||
|
||||
@@ -219,7 +219,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
||||
<SseSummaryFooter
|
||||
style={style}
|
||||
error={summary.error ? String(summary.error) : null}
|
||||
isLoading={summary.isLoading}
|
||||
isLoading={summary.isLoading && summary.data == null}
|
||||
onRenderMarkdownChange={renderMarkdownSetting.set}
|
||||
renderMarkdown={renderMarkdown}
|
||||
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getResponseBodySseSummary } from "../lib/responseBody";
|
||||
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
|
||||
return useQuery<SseSummary>({
|
||||
enabled: resultKeyPath != null,
|
||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||
queryKey: [
|
||||
"response-body-sse-summary",
|
||||
response.id,
|
||||
|
||||
@@ -194,20 +194,31 @@ pub(crate) fn models_delete<R: Runtime>(
|
||||
#[tauri::command]
|
||||
pub(crate) fn models_duplicate<R: Runtime>(
|
||||
window: WebviewWindow<R>,
|
||||
model: AnyModel,
|
||||
model_type: String,
|
||||
model_id: String,
|
||||
) -> Result<String> {
|
||||
use yaak_models::error::Error::GenericError;
|
||||
|
||||
// Use transaction for duplications because it might recurse
|
||||
window.with_tx(|tx| {
|
||||
let source = &UpdateSource::from_window_label(window.label());
|
||||
let id = match model {
|
||||
AnyModel::Environment(m) => tx.duplicate_environment(&m, source)?.id,
|
||||
AnyModel::Folder(m) => tx.duplicate_folder(&m, source)?.id,
|
||||
AnyModel::GrpcRequest(m) => tx.duplicate_grpc_request(&m, source)?.id,
|
||||
AnyModel::HttpRequest(m) => tx.duplicate_http_request(&m, source)?.id,
|
||||
AnyModel::WebsocketRequest(m) => tx.duplicate_websocket_request(&m, source)?.id,
|
||||
a => return Err(GenericError(format!("Cannot duplicate AnyModel {a:?})"))),
|
||||
// Fetch the model fresh from the DB so the duplicate doesn't come from
|
||||
// a stale frontend snapshot
|
||||
let id = match model_type.as_str() {
|
||||
"environment" => {
|
||||
tx.duplicate_environment(&tx.get_environment(&model_id)?, source)?.id
|
||||
}
|
||||
"folder" => tx.duplicate_folder(&tx.get_folder(&model_id)?, source)?.id,
|
||||
"grpc_request" => {
|
||||
tx.duplicate_grpc_request(&tx.get_grpc_request(&model_id)?, source)?.id
|
||||
}
|
||||
"http_request" => {
|
||||
tx.duplicate_http_request(&tx.get_http_request(&model_id)?, source)?.id
|
||||
}
|
||||
"websocket_request" => {
|
||||
tx.duplicate_websocket_request(&tx.get_websocket_request(&model_id)?, source)?.id
|
||||
}
|
||||
t => return Err(GenericError(format!("Cannot duplicate model type {t}"))),
|
||||
};
|
||||
|
||||
Ok(id)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||
import { resolvedModelName } from "@yaakapp/yaak-client/lib/resolvedModelName";
|
||||
import { AnyModel, ModelPayload } from "../bindings/gen_models";
|
||||
import { modelStoreDataAtom } from "./atoms";
|
||||
import { ExtractModel, JotaiStore, ModelStoreData } from "./types";
|
||||
@@ -156,44 +155,22 @@ export async function deleteModel<M extends AnyModel["model"], T extends Extract
|
||||
await trackModelWrite(invoke<string>("models_delete", { model }));
|
||||
}
|
||||
|
||||
export function duplicateModel<M extends AnyModel["model"], T extends ExtractModel<AnyModel, M>>(
|
||||
model: T | null,
|
||||
) {
|
||||
export async function duplicateModel<
|
||||
M extends AnyModel["model"],
|
||||
T extends ExtractModel<AnyModel, M>,
|
||||
>(model: T | null): Promise<string> {
|
||||
if (model == null) {
|
||||
throw new Error("Failed to duplicate null model");
|
||||
}
|
||||
|
||||
// If the model has an explicit (non-empty) name, try to duplicate it with a name that doesn't conflict.
|
||||
// When the name is empty, keep it empty so the display falls back to the URL.
|
||||
let name = "name" in model ? model.name : undefined;
|
||||
if (name) {
|
||||
const existingModels = listModels(model.model);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const hasConflict = existingModels.some((m) => {
|
||||
if ("folderId" in m && "folderId" in model && model.folderId !== m.folderId) {
|
||||
return false;
|
||||
} else if (resolvedModelName(m) !== name) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!hasConflict) {
|
||||
break;
|
||||
}
|
||||
// Flush pending writes first, since the backend duplicates from the DB (the passed-in
|
||||
// model may be a stale snapshot, eg. from the memoized sidebar tree). Conflict-free
|
||||
// naming ("Foo Copy 2") is also handled by the backend.
|
||||
await flushAllModelWrites();
|
||||
|
||||
// Name conflict. Try another one
|
||||
const m: RegExpMatchArray | null = name.match(/ Copy( (?<n>\d+))?$/);
|
||||
if (m != null && m.groups?.n == null) {
|
||||
name = name.substring(0, m.index) + " Copy 2";
|
||||
} else if (m != null && m.groups?.n != null) {
|
||||
name = name.substring(0, m.index) + ` Copy ${parseInt(m.groups.n) + 1}`;
|
||||
} else {
|
||||
name = `${name} Copy`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return trackModelWrite(invoke<string>("models_duplicate", { model: { ...model, name } }));
|
||||
return trackModelWrite(
|
||||
invoke<string>("models_duplicate", { modelType: model.model, modelId: model.id }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createGlobalModel<T extends Exclude<AnyModel, { workspaceId: string }>>(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/// Compute a name for a duplicated model that doesn't conflict with any sibling
|
||||
/// name, following the " Copy N" convention. Empty names are kept empty so the
|
||||
/// display falls back to the URL.
|
||||
pub(crate) fn conflict_free_name(name: &str, sibling_names: &[String]) -> String {
|
||||
if name.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut name = name.to_string();
|
||||
for _ in 0..100 {
|
||||
if !sibling_names.contains(&name) {
|
||||
break;
|
||||
}
|
||||
name = next_copy_name(&name);
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn next_copy_name(name: &str) -> String {
|
||||
if let Some(base) = name.strip_suffix(" Copy") {
|
||||
return format!("{base} Copy 2");
|
||||
}
|
||||
|
||||
if let Some(idx) = name.rfind(" Copy ") {
|
||||
let n = &name[idx + " Copy ".len()..];
|
||||
if !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()) {
|
||||
if let Ok(n) = n.parse::<u64>() {
|
||||
return format!("{} Copy {}", &name[..idx], n + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
format!("{name} Copy")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_next_copy_name() {
|
||||
assert_eq!(next_copy_name("Foo"), "Foo Copy");
|
||||
assert_eq!(next_copy_name("Foo Copy"), "Foo Copy 2");
|
||||
assert_eq!(next_copy_name("Foo Copy 2"), "Foo Copy 3");
|
||||
assert_eq!(next_copy_name("Foo Copy 99"), "Foo Copy 100");
|
||||
assert_eq!(next_copy_name("Copy"), "Copy Copy");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_conflict_free_name() {
|
||||
let siblings = vec!["Foo".to_string(), "Foo Copy".to_string(), "".to_string()];
|
||||
assert_eq!(conflict_free_name("Foo", &siblings), "Foo Copy 2");
|
||||
assert_eq!(conflict_free_name("Bar", &siblings), "Bar");
|
||||
assert_eq!(conflict_free_name("", &siblings), "");
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::conflict_free_name;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Error::{MissingBaseEnvironment, MultipleBaseEnvironments};
|
||||
use crate::error::Result;
|
||||
@@ -88,6 +89,12 @@ impl<'a> ClientDb<'a> {
|
||||
) -> Result<Environment> {
|
||||
let mut environment = environment.clone();
|
||||
environment.id = "".to_string();
|
||||
let sibling_names = self
|
||||
.list_environments_dangerous(&environment.workspace_id)?
|
||||
.into_iter()
|
||||
.map(|e| e.name)
|
||||
.collect::<Vec<_>>();
|
||||
environment.name = conflict_free_name(&environment.name, &sibling_names);
|
||||
self.upsert_environment(&environment, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use super::conflict_free_name;
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::connection_or_tx::ConnectionOrTx;
|
||||
use crate::error::Result;
|
||||
@@ -62,14 +63,19 @@ impl<'a> ClientDb<'a> {
|
||||
pub fn duplicate_folder(&self, src_folder: &Folder, source: &UpdateSource) -> Result<Folder> {
|
||||
let fid = &src_folder.id;
|
||||
|
||||
let new_folder = self.upsert_folder(
|
||||
&Folder {
|
||||
id: "".into(),
|
||||
sort_priority: src_folder.sort_priority + 0.001,
|
||||
..src_folder.clone()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
let mut folder = Folder {
|
||||
id: "".into(),
|
||||
sort_priority: src_folder.sort_priority + 0.001,
|
||||
..src_folder.clone()
|
||||
};
|
||||
let sibling_names = self
|
||||
.list_folders(&folder.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|f| f.folder_id == folder.folder_id)
|
||||
.map(|f| f.name)
|
||||
.collect::<Vec<_>>();
|
||||
folder.name = conflict_free_name(&folder.name, &sibling_names);
|
||||
let new_folder = self.upsert_folder(&folder, source)?;
|
||||
|
||||
for m in self.find_many::<HttpRequest>(HttpRequestIden::FolderId, fid, None)? {
|
||||
self.upsert_http_request(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::dedupe_headers;
|
||||
use super::{conflict_free_name, dedupe_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
@@ -58,6 +58,13 @@ impl<'a> ClientDb<'a> {
|
||||
let mut request = grpc_request.clone();
|
||||
request.id = "".to_string();
|
||||
request.sort_priority = request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_grpc_requests(&request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
request.name = conflict_free_name(&request.name, &sibling_names);
|
||||
self.upsert(&request, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::dedupe_headers;
|
||||
use super::{conflict_free_name, dedupe_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
@@ -44,6 +44,13 @@ impl<'a> ClientDb<'a> {
|
||||
let mut http_request = http_request.clone();
|
||||
http_request.id = "".to_string();
|
||||
http_request.sort_priority = http_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_http_requests(&http_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == http_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
http_request.name = conflict_free_name(&http_request.name, &sibling_names);
|
||||
self.upsert(&http_request, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod any_request;
|
||||
mod batch;
|
||||
mod cookie_jars;
|
||||
mod duplicate_name;
|
||||
mod environments;
|
||||
mod folders;
|
||||
mod graphql_introspections;
|
||||
@@ -22,6 +23,7 @@ mod websocket_requests;
|
||||
mod workspace_metas;
|
||||
pub mod workspaces;
|
||||
pub use model_changes::PersistedModelChange;
|
||||
pub(crate) use duplicate_name::conflict_free_name;
|
||||
|
||||
const MAX_HISTORY_ITEMS: usize = 20;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::dedupe_headers;
|
||||
use super::{conflict_free_name, dedupe_headers};
|
||||
use crate::client_db::ClientDb;
|
||||
use crate::error::Result;
|
||||
use crate::models::{
|
||||
@@ -60,6 +60,13 @@ impl<'a> ClientDb<'a> {
|
||||
let mut websocket_request = websocket_request.clone();
|
||||
websocket_request.id = "".to_string();
|
||||
websocket_request.sort_priority = websocket_request.sort_priority + 0.001;
|
||||
let sibling_names = self
|
||||
.list_websocket_requests(&websocket_request.workspace_id)?
|
||||
.into_iter()
|
||||
.filter(|m| m.folder_id == websocket_request.folder_id)
|
||||
.map(|m| m.name)
|
||||
.collect::<Vec<_>>();
|
||||
websocket_request.name = conflict_free_name(&websocket_request.name, &sibling_names);
|
||||
self.upsert(&websocket_request, source)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { execSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
if (process.env.SKIP_WASM_BUILD === "1") {
|
||||
@@ -7,7 +8,18 @@ if (process.env.SKIP_WASM_BUILD === "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
execSync("wasm-pack build --target bundler", { stdio: "inherit" });
|
||||
// Remap machine-specific paths that rustc embeds into the binary (panic
|
||||
// location strings), so builds are reproducible across machines
|
||||
const sysroot = execSync("rustc --print sysroot").toString().trim();
|
||||
const cargoHome = process.env.CARGO_HOME ?? path.join(os.homedir(), ".cargo");
|
||||
|
||||
execSync("wasm-pack build --target bundler", {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...process.env,
|
||||
RUSTFLAGS: `--remap-path-prefix=${cargoHome}=/cargo --remap-path-prefix=${sysroot}=/rustc`,
|
||||
},
|
||||
});
|
||||
|
||||
// Rewrite the generated entry to use Vite's ?init import style instead of
|
||||
// the ES Module Integration style that wasm-pack generates, which Vite/rolldown
|
||||
|
||||
+4
-1
@@ -1,5 +1,8 @@
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
export function unescape_template(template: string): any;
|
||||
|
||||
export function escape_template(template: string): any;
|
||||
|
||||
export function parse_template(template: string): any;
|
||||
|
||||
export function unescape_template(template: string): any;
|
||||
|
||||
+118
-128
@@ -1,9 +1,82 @@
|
||||
let wasm;
|
||||
export function __wbg_set_wasm(val) {
|
||||
wasm = val;
|
||||
/**
|
||||
* @param {string} template
|
||||
* @returns {any}
|
||||
*/
|
||||
export function escape_template(template) {
|
||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.escape_template(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
* @returns {any}
|
||||
*/
|
||||
export function parse_template(template) {
|
||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_template(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
* @returns {any}
|
||||
*/
|
||||
export function unescape_template(template) {
|
||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.unescape_template(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
}
|
||||
export function __wbg___wbindgen_debug_string_edece8177ad01481(arg0, arg1) {
|
||||
const ret = debugString(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
}
|
||||
export function __wbg___wbindgen_throw_9c31b086c2b26051(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
}
|
||||
export function __wbg_new_02d162bc6cf02f60() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_new_310879b66b6e95e1() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
}
|
||||
export function __wbg_set_6be42768c690e380(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
}
|
||||
export function __wbg_set_78ea6a19f4818587(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
}
|
||||
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
|
||||
// Cast intrinsic for `Ref(String) -> Externref`.
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
}
|
||||
export function __wbindgen_init_externref_table() {
|
||||
const table = wasm.__wbindgen_externrefs;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
}
|
||||
function debugString(val) {
|
||||
// primitive types
|
||||
const type = typeof val;
|
||||
@@ -69,10 +142,19 @@ function debugString(val) {
|
||||
return className;
|
||||
}
|
||||
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
let cachedDataViewMemory0 = null;
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
return decodeText(ptr >>> 0, len);
|
||||
}
|
||||
|
||||
let cachedUint8ArrayMemory0 = null;
|
||||
|
||||
function getUint8ArrayMemory0() {
|
||||
if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
|
||||
cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
|
||||
@@ -80,25 +162,7 @@ function getUint8ArrayMemory0() {
|
||||
return cachedUint8ArrayMemory0;
|
||||
}
|
||||
|
||||
const lTextEncoder = typeof TextEncoder === 'undefined' ? (0, module.require)('util').TextEncoder : TextEncoder;
|
||||
|
||||
let cachedTextEncoder = new lTextEncoder('utf-8');
|
||||
|
||||
const encodeString = (typeof cachedTextEncoder.encodeInto === 'function'
|
||||
? function (arg, view) {
|
||||
return cachedTextEncoder.encodeInto(arg, view);
|
||||
}
|
||||
: function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
});
|
||||
|
||||
function passStringToWasm0(arg, malloc, realloc) {
|
||||
|
||||
if (realloc === undefined) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
const ptr = malloc(buf.length, 1) >>> 0;
|
||||
@@ -119,14 +183,13 @@ function passStringToWasm0(arg, malloc, realloc) {
|
||||
if (code > 0x7F) break;
|
||||
mem[ptr + offset] = code;
|
||||
}
|
||||
|
||||
if (offset !== len) {
|
||||
if (offset !== 0) {
|
||||
arg = arg.slice(offset);
|
||||
}
|
||||
ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
|
||||
const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
|
||||
const ret = encodeString(arg, view);
|
||||
const ret = cachedTextEncoder.encodeInto(arg, view);
|
||||
|
||||
offset += ret.written;
|
||||
ptr = realloc(ptr, len, offset, 1) >>> 0;
|
||||
@@ -136,116 +199,43 @@ function passStringToWasm0(arg, malloc, realloc) {
|
||||
return ptr;
|
||||
}
|
||||
|
||||
let cachedDataViewMemory0 = null;
|
||||
|
||||
function getDataViewMemory0() {
|
||||
if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
|
||||
cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
|
||||
}
|
||||
return cachedDataViewMemory0;
|
||||
}
|
||||
|
||||
const lTextDecoder = typeof TextDecoder === 'undefined' ? (0, module.require)('util').TextDecoder : TextDecoder;
|
||||
|
||||
let cachedTextDecoder = new lTextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
|
||||
cachedTextDecoder.decode();
|
||||
|
||||
function getStringFromWasm0(ptr, len) {
|
||||
ptr = ptr >>> 0;
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
function takeFromExternrefTable0(idx) {
|
||||
const value = wasm.__wbindgen_export_2.get(idx);
|
||||
const value = wasm.__wbindgen_externrefs.get(idx);
|
||||
wasm.__externref_table_dealloc(idx);
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* @param {string} template
|
||||
* @returns {any}
|
||||
*/
|
||||
export function unescape_template(template) {
|
||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.unescape_template(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
|
||||
let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
const MAX_SAFARI_DECODE_BYTES = 2146435072;
|
||||
let numBytesDecoded = 0;
|
||||
function decodeText(ptr, len) {
|
||||
numBytesDecoded += len;
|
||||
if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
|
||||
cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
|
||||
cachedTextDecoder.decode();
|
||||
numBytesDecoded = len;
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
* @returns {any}
|
||||
*/
|
||||
export function escape_template(template) {
|
||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.escape_template(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
const cachedTextEncoder = new TextEncoder();
|
||||
|
||||
if (!('encodeInto' in cachedTextEncoder)) {
|
||||
cachedTextEncoder.encodeInto = function (arg, view) {
|
||||
const buf = cachedTextEncoder.encode(arg);
|
||||
view.set(buf);
|
||||
return {
|
||||
read: arg.length,
|
||||
written: buf.length
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} template
|
||||
* @returns {any}
|
||||
*/
|
||||
export function parse_template(template) {
|
||||
const ptr0 = passStringToWasm0(template, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len0 = WASM_VECTOR_LEN;
|
||||
const ret = wasm.parse_template(ptr0, len0);
|
||||
if (ret[2]) {
|
||||
throw takeFromExternrefTable0(ret[1]);
|
||||
}
|
||||
return takeFromExternrefTable0(ret[0]);
|
||||
let WASM_VECTOR_LEN = 0;
|
||||
|
||||
|
||||
let wasm;
|
||||
export function __wbg_set_wasm(val) {
|
||||
wasm = val;
|
||||
}
|
||||
|
||||
export function __wbg_new_405e22f390576ce2() {
|
||||
const ret = new Object();
|
||||
return ret;
|
||||
};
|
||||
|
||||
export function __wbg_new_78feb108b6472713() {
|
||||
const ret = new Array();
|
||||
return ret;
|
||||
};
|
||||
|
||||
export function __wbg_set_37837023f3d740e8(arg0, arg1, arg2) {
|
||||
arg0[arg1 >>> 0] = arg2;
|
||||
};
|
||||
|
||||
export function __wbg_set_3f1d0b984ed272ed(arg0, arg1, arg2) {
|
||||
arg0[arg1] = arg2;
|
||||
};
|
||||
|
||||
export function __wbindgen_debug_string(arg0, arg1) {
|
||||
const ret = debugString(arg1);
|
||||
const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
|
||||
const len1 = WASM_VECTOR_LEN;
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
|
||||
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
|
||||
};
|
||||
|
||||
export function __wbindgen_init_externref_table() {
|
||||
const table = wasm.__wbindgen_export_2;
|
||||
const offset = table.grow(4);
|
||||
table.set(0, undefined);
|
||||
table.set(offset + 0, undefined);
|
||||
table.set(offset + 1, null);
|
||||
table.set(offset + 2, true);
|
||||
table.set(offset + 3, false);
|
||||
;
|
||||
};
|
||||
|
||||
export function __wbindgen_string_new(arg0, arg1) {
|
||||
const ret = getStringFromWasm0(arg0, arg1);
|
||||
return ret;
|
||||
};
|
||||
|
||||
export function __wbindgen_throw(arg0, arg1) {
|
||||
throw new Error(getStringFromWasm0(arg0, arg1));
|
||||
};
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -6,6 +6,6 @@ export const parse_template: (a: number, b: number) => [number, number, number];
|
||||
export const unescape_template: (a: number, b: number) => [number, number, number];
|
||||
export const __wbindgen_malloc: (a: number, b: number) => number;
|
||||
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
export const __wbindgen_export_2: WebAssembly.Table;
|
||||
export const __wbindgen_externrefs: WebAssembly.Table;
|
||||
export const __externref_table_dealloc: (a: number) => void;
|
||||
export const __wbindgen_start: () => void;
|
||||
|
||||
+71
-19
@@ -511,7 +511,10 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
.map_err(SendHttpRequestError::PrepareSendableRequest)?;
|
||||
}
|
||||
|
||||
let request_content_length = sendable_body_length(sendable_request.body.as_ref());
|
||||
let request_content_length = match sendable_request.body.as_ref() {
|
||||
Some(SendableBody::Bytes(_)) => sendable_body_length(sendable_request.body.as_ref()),
|
||||
Some(SendableBody::Stream { .. }) | None => None,
|
||||
};
|
||||
let mut response = params.existing_response.unwrap_or_default();
|
||||
response.request_id = params.request.id.clone();
|
||||
response.workspace_id = params.request.workspace_id.clone();
|
||||
@@ -811,16 +814,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
})?;
|
||||
drop(body_stream);
|
||||
|
||||
if let Some(task) = request_body_capture_task.take() {
|
||||
match task.await {
|
||||
Ok(Ok(total)) => {
|
||||
response.request_content_length = Some(usize_to_i32(total));
|
||||
}
|
||||
Ok(Err(err)) => request_body_capture_error = Some(err),
|
||||
Err(err) => request_body_capture_error = Some(err.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(err) = request_body_capture_error.take() {
|
||||
response.error = Some(append_error_message(
|
||||
response.error.take(),
|
||||
@@ -828,10 +821,6 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
));
|
||||
}
|
||||
|
||||
if let Err(join_err) = event_handle.await {
|
||||
warn!("Failed to join response event task: {}", join_err);
|
||||
}
|
||||
|
||||
if let Some(err) = body_read_error {
|
||||
if persist_response {
|
||||
let _ = persist_response_error(
|
||||
@@ -849,6 +838,16 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
cookie_jar.as_mut(),
|
||||
cookie_behavior.store.as_ref(),
|
||||
)?;
|
||||
if let Some(task) = request_body_capture_task.take() {
|
||||
match task.await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(err)) => warn!("Failed to store request body after response error: {err}"),
|
||||
Err(err) => warn!("Failed to join request body capture task: {err}"),
|
||||
}
|
||||
}
|
||||
if let Err(join_err) = event_handle.await {
|
||||
warn!("Failed to join response event task: {}", join_err);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
|
||||
@@ -875,6 +874,49 @@ pub async fn send_http_request<T: TemplateCallback>(
|
||||
|
||||
persist_cookie_jar(params.query_manager, cookie_jar.as_mut(), cookie_behavior.store.as_ref())?;
|
||||
|
||||
// Request-body history can be much larger than the response. It should not keep the
|
||||
// response in a loading state after the network/response-body work has completed.
|
||||
if let Some(task) = request_body_capture_task.take() {
|
||||
let mut update_response = false;
|
||||
match task.await {
|
||||
Ok(Ok(total)) => {
|
||||
let total = Some(usize_to_i32(total));
|
||||
if response.request_content_length != total {
|
||||
response.request_content_length = total;
|
||||
update_response = true;
|
||||
}
|
||||
}
|
||||
Ok(Err(err)) => {
|
||||
response.error = Some(append_error_message(
|
||||
response.error.take(),
|
||||
format!("Request succeeded but failed to store request body: {err}"),
|
||||
));
|
||||
update_response = true;
|
||||
}
|
||||
Err(err) => {
|
||||
response.error = Some(append_error_message(
|
||||
response.error.take(),
|
||||
format!("Request succeeded but failed to store request body: {err}"),
|
||||
));
|
||||
update_response = true;
|
||||
}
|
||||
}
|
||||
|
||||
if update_response && persist_response {
|
||||
response = params
|
||||
.query_manager
|
||||
.connect()
|
||||
.upsert_http_response(&response, ¶ms.update_source, params.blob_manager)
|
||||
.map_err(SendHttpRequestError::PersistResponse)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Timeline events are useful history, but they should not keep the response in a loading state
|
||||
// after the network/response-body work has completed.
|
||||
if let Err(join_err) = event_handle.await {
|
||||
warn!("Failed to join response event task: {}", join_err);
|
||||
}
|
||||
|
||||
Ok(SendHttpRequestResult { rendered_request, response, response_body })
|
||||
}
|
||||
|
||||
@@ -907,14 +949,24 @@ async fn persist_request_body_stream(
|
||||
) -> std::result::Result<usize, String> {
|
||||
let mut chunk_index: i32 = 0;
|
||||
let mut total_bytes = 0usize;
|
||||
|
||||
// Stream reads arrive in small (eg. 8-16 KiB) pieces, so accumulate them into
|
||||
// full-size chunks to avoid thousands of tiny inserts for large bodies
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(REQUEST_BODY_CHUNK_SIZE);
|
||||
while let Some(data) = rx.recv().await {
|
||||
total_bytes += data.len();
|
||||
if data.is_empty() {
|
||||
continue;
|
||||
buf.extend_from_slice(&data);
|
||||
while buf.len() >= REQUEST_BODY_CHUNK_SIZE {
|
||||
let data = buf.drain(..REQUEST_BODY_CHUNK_SIZE).collect();
|
||||
let chunk = BodyChunk::new(&body_id, chunk_index, data);
|
||||
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
|
||||
chunk_index += 1;
|
||||
}
|
||||
let chunk = BodyChunk::new(&body_id, chunk_index, data);
|
||||
}
|
||||
|
||||
if !buf.is_empty() {
|
||||
let chunk = BodyChunk::new(&body_id, chunk_index, buf);
|
||||
blob_manager.connect().insert_chunk(&chunk).map_err(|e| e.to_string())?;
|
||||
chunk_index += 1;
|
||||
}
|
||||
|
||||
Ok(total_bytes)
|
||||
|
||||
@@ -13,6 +13,7 @@ export async function getOrRefreshAccessToken(
|
||||
credentialsInBody,
|
||||
clientId,
|
||||
clientSecret,
|
||||
tokenName,
|
||||
forceRefresh,
|
||||
}: {
|
||||
scope: string | null;
|
||||
@@ -20,6 +21,7 @@ export async function getOrRefreshAccessToken(
|
||||
credentialsInBody: boolean;
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
tokenName?: "access_token" | "id_token";
|
||||
forceRefresh?: boolean;
|
||||
},
|
||||
): Promise<AccessToken | null> {
|
||||
@@ -28,7 +30,7 @@ export async function getOrRefreshAccessToken(
|
||||
return null;
|
||||
}
|
||||
|
||||
const isExpired = isTokenExpired(token);
|
||||
const isExpired = isTokenExpired(token, tokenName);
|
||||
|
||||
// Return the current access token if it's still valid
|
||||
if (!isExpired && !forceRefresh) {
|
||||
@@ -111,5 +113,5 @@ export async function getOrRefreshAccessToken(
|
||||
refresh_token: response.refresh_token ?? token.response.refresh_token,
|
||||
};
|
||||
|
||||
return storeToken(ctx, tokenArgs, newResponse);
|
||||
return storeToken(ctx, tokenArgs, newResponse, tokenName);
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export async function getAuthorizationCode(
|
||||
clientId,
|
||||
clientSecret,
|
||||
credentialsInBody,
|
||||
tokenName,
|
||||
});
|
||||
if (token != null) {
|
||||
return token;
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function getImplicit(
|
||||
authorizationUrl: authorizationUrlRaw,
|
||||
};
|
||||
const token = await getToken(ctx, tokenArgs);
|
||||
if (token != null && !isTokenExpired(token)) {
|
||||
if (token != null && !isTokenExpired(token, tokenName)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ async function getTokenViaEmbeddedBrowser(
|
||||
|
||||
const response = Object.fromEntries(params) as unknown as AccessTokenRawResponse;
|
||||
try {
|
||||
resolve(storeToken(ctx, tokenArgs, response));
|
||||
resolve(storeToken(ctx, tokenArgs, response, tokenName));
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
@@ -195,5 +195,5 @@ async function extractImplicitToken(
|
||||
response.id_token = idToken;
|
||||
}
|
||||
|
||||
return storeToken(ctx, tokenArgs, response);
|
||||
return storeToken(ctx, tokenArgs, response, tokenName);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Context } from "@yaakapp/api";
|
||||
import { jwtExpiresAt } from "./util";
|
||||
|
||||
export async function storeToken(
|
||||
ctx: Context,
|
||||
@@ -11,7 +12,10 @@ export async function storeToken(
|
||||
throw new Error(`${tokenName} not found in response ${Object.keys(response).join(", ")}`);
|
||||
}
|
||||
|
||||
const expiresAt = response.expires_in ? Date.now() + response.expires_in * 1000 : null;
|
||||
// Prefer expires_in from the response, falling back to the JWT's own exp claim
|
||||
const expiresAt = response.expires_in
|
||||
? Date.now() + response.expires_in * 1000
|
||||
: jwtExpiresAt(response[tokenName]);
|
||||
const token: AccessToken = {
|
||||
response,
|
||||
expiresAt,
|
||||
|
||||
@@ -1,7 +1,34 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { AccessToken } from "./store";
|
||||
|
||||
export function isTokenExpired(token: AccessToken) {
|
||||
return token.expiresAt && Date.now() > token.expiresAt;
|
||||
export function isTokenExpired(
|
||||
token: AccessToken,
|
||||
tokenName: "access_token" | "id_token" = "access_token",
|
||||
) {
|
||||
// Fall back to the JWT's own exp claim for tokens stored without an expiry
|
||||
// (eg. from a token response that had no expires_in). Decode the same token
|
||||
// that gets sent as the credential.
|
||||
const expiresAt = token.expiresAt ?? jwtExpiresAt(token.response[tokenName]);
|
||||
return expiresAt != null && Date.now() > expiresAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiry timestamp (ms) from a JWT's `exp` claim, or null if the token
|
||||
* is not a JWT or has no `exp`.
|
||||
*/
|
||||
export function jwtExpiresAt(token: string | undefined): number | null {
|
||||
if (!token) return null;
|
||||
|
||||
try {
|
||||
const payload = jwt.decode(token);
|
||||
if (payload != null && typeof payload === "object" && typeof payload.exp === "number") {
|
||||
return payload.exp * 1000;
|
||||
}
|
||||
} catch {
|
||||
// Opaque (non-JWT) token
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function extractCode(urlStr: string, redirectUri: string | null): string | null {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { extractCode } from "../src/util";
|
||||
import { extractCode, isTokenExpired, jwtExpiresAt } from "../src/util";
|
||||
|
||||
describe("extractCode", () => {
|
||||
test("extracts code from query when same origin + path", () => {
|
||||
@@ -107,3 +108,55 @@ describe("extractCode", () => {
|
||||
expect(extractCode(url, redirect)).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isTokenExpired", () => {
|
||||
const jwtWithExp = (expSecondsFromNow: number) =>
|
||||
jwt.sign({ exp: Math.floor(Date.now() / 1000) + expSecondsFromNow }, "test-secret");
|
||||
|
||||
test("uses stored expiresAt when present", () => {
|
||||
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() - 1000 })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(isTokenExpired({ response: { access_token: "x" }, expiresAt: Date.now() + 10000 })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("falls back to JWT exp claim when expiresAt is null", () => {
|
||||
expect(
|
||||
isTokenExpired({ response: { access_token: jwtWithExp(-60) }, expiresAt: null }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isTokenExpired({ response: { access_token: jwtWithExp(60) }, expiresAt: null }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("treats opaque tokens without expiresAt as non-expiring", () => {
|
||||
expect(isTokenExpired({ response: { access_token: "opaque-token" }, expiresAt: null })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("checks the token that is used as the credential", () => {
|
||||
// Expired id_token credential is not masked by an opaque access_token
|
||||
const token = {
|
||||
response: { access_token: "opaque-token", id_token: jwtWithExp(-60) },
|
||||
expiresAt: null,
|
||||
};
|
||||
expect(isTokenExpired(token, "id_token")).toBe(true);
|
||||
expect(isTokenExpired(token, "access_token")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jwtExpiresAt", () => {
|
||||
test("extracts exp claim in milliseconds", () => {
|
||||
const exp = Math.floor(Date.now() / 1000) + 300;
|
||||
expect(jwtExpiresAt(jwt.sign({ exp }, "test-secret"))).toBe(exp * 1000);
|
||||
});
|
||||
|
||||
test("returns null for non-JWT or missing tokens", () => {
|
||||
expect(jwtExpiresAt("not-a-jwt")).toBeNull();
|
||||
expect(jwtExpiresAt(undefined)).toBeNull();
|
||||
expect(jwtExpiresAt(jwt.sign({ foo: "bar" }, "test-secret"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* oxlint-disable no-explicit-any */
|
||||
|
||||
export function isJSObject(obj: unknown) {
|
||||
return Object.prototype.toString.call(obj) === "[object Object]";
|
||||
}
|
||||
@@ -13,6 +15,91 @@ export function convertId(id: string): string {
|
||||
return `GENERATE_ID::${id}`;
|
||||
}
|
||||
|
||||
export function importHttpBodyAndHeaders(obj: any) {
|
||||
const { headers } = importHeaders(obj);
|
||||
const { body, bodyType } = importHttpBody(obj.body);
|
||||
const mimeType = typeof obj.body?.mimeType === "string" ? obj.body.mimeType.trim() : "";
|
||||
|
||||
if (
|
||||
bodyType != null &&
|
||||
mimeType !== "" &&
|
||||
!headers.some((header: { name: string }) => header.name.toLowerCase() === "content-type")
|
||||
) {
|
||||
headers.push({ enabled: true, name: "Content-Type", value: mimeType });
|
||||
}
|
||||
|
||||
return { body, bodyType, headers };
|
||||
}
|
||||
|
||||
export function importHeaders(obj: any) {
|
||||
const headers = (obj.headers ?? [])
|
||||
.map((header: any) => ({
|
||||
enabled: !header.disabled,
|
||||
name: header.name ?? "",
|
||||
value: header.value ?? "",
|
||||
}))
|
||||
.filter(({ name, value }: any) => name !== "" || value !== "");
|
||||
return { headers } as const;
|
||||
}
|
||||
|
||||
function importHttpBody(rawBody: any) {
|
||||
const mimeType = typeof rawBody?.mimeType === "string" ? rawBody.mimeType.trim() : "";
|
||||
const normalizedMimeType = mimeType.split(";", 1)[0]?.toLowerCase() ?? "";
|
||||
|
||||
if (normalizedMimeType === "application/octet-stream") {
|
||||
return { bodyType: "binary", body: { filePath: rawBody.fileName ?? "" } };
|
||||
}
|
||||
|
||||
if (normalizedMimeType === "application/x-www-form-urlencoded") {
|
||||
return {
|
||||
bodyType: "application/x-www-form-urlencoded",
|
||||
body: {
|
||||
form: (rawBody.params ?? []).map((parameter: any) => ({
|
||||
enabled: !parameter.disabled,
|
||||
name: parameter.name ?? "",
|
||||
value: parameter.value ?? "",
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMimeType === "multipart/form-data") {
|
||||
return {
|
||||
bodyType: "multipart/form-data",
|
||||
body: {
|
||||
form: (rawBody.params ?? []).map((parameter: any) => ({
|
||||
enabled: !parameter.disabled,
|
||||
name: parameter.name ?? "",
|
||||
value: parameter.value ?? "",
|
||||
file: parameter.fileName ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (normalizedMimeType === "application/graphql") {
|
||||
return { bodyType: "graphql", body: { text: rawBody.text ?? "" } };
|
||||
}
|
||||
|
||||
if (normalizedMimeType === "application/json" || normalizedMimeType.endsWith("+json")) {
|
||||
return { bodyType: "application/json", body: { text: rawBody.text ?? "" } };
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedMimeType === "text/xml" ||
|
||||
normalizedMimeType === "application/xml" ||
|
||||
normalizedMimeType.endsWith("+xml")
|
||||
) {
|
||||
return { bodyType: "text/xml", body: { text: rawBody.text ?? "" } };
|
||||
}
|
||||
|
||||
if (typeof rawBody?.text === "string") {
|
||||
return { bodyType: "other", body: { text: rawBody.text } };
|
||||
}
|
||||
|
||||
return { bodyType: null, body: {} };
|
||||
}
|
||||
|
||||
export function deleteUndefinedAttrs<T>(obj: T): T {
|
||||
if (Array.isArray(obj) && obj != null) {
|
||||
return obj.map(deleteUndefinedAttrs) as T;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* oxlint-disable no-explicit-any */
|
||||
import type { PartialImportResources } from "@yaakapp/api";
|
||||
import { convertId, convertTemplateSyntax, isJSObject } from "./common";
|
||||
import { convertId, convertTemplateSyntax, importHttpBodyAndHeaders, isJSObject } from "./common";
|
||||
|
||||
export function convertInsomniaV4(parsed: any) {
|
||||
if (!Array.isArray(parsed.resources)) return null;
|
||||
@@ -64,38 +64,6 @@ export function convertInsomniaV4(parsed: any) {
|
||||
}
|
||||
|
||||
function importHttpRequest(r: any, workspaceId: string): PartialImportResources["httpRequests"][0] {
|
||||
let bodyType: string | null = null;
|
||||
let body = {};
|
||||
if (r.body.mimeType === "application/octet-stream") {
|
||||
bodyType = "binary";
|
||||
body = { filePath: r.body.fileName ?? "" };
|
||||
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
|
||||
bodyType = "application/x-www-form-urlencoded";
|
||||
body = {
|
||||
form: (r.body.params ?? []).map((p: any) => ({
|
||||
enabled: !p.disabled,
|
||||
name: p.name ?? "",
|
||||
value: p.value ?? "",
|
||||
})),
|
||||
};
|
||||
} else if (r.body?.mimeType === "multipart/form-data") {
|
||||
bodyType = "multipart/form-data";
|
||||
body = {
|
||||
form: (r.body.params ?? []).map((p: any) => ({
|
||||
enabled: !p.disabled,
|
||||
name: p.name ?? "",
|
||||
value: p.value ?? "",
|
||||
file: p.fileName ?? null,
|
||||
})),
|
||||
};
|
||||
} else if (r.body?.mimeType === "application/graphql") {
|
||||
bodyType = "graphql";
|
||||
body = { text: r.body.text ?? "" };
|
||||
} else if (r.body?.mimeType === "application/json") {
|
||||
bodyType = "application/json";
|
||||
body = { text: r.body.text ?? "" };
|
||||
}
|
||||
|
||||
let authenticationType: string | null = null;
|
||||
let authentication = {};
|
||||
if (r.authentication.type === "bearer") {
|
||||
@@ -127,18 +95,10 @@ function importHttpRequest(r: any, workspaceId: string): PartialImportResources[
|
||||
name: p.name ?? "",
|
||||
value: p.value ?? "",
|
||||
})),
|
||||
body,
|
||||
bodyType,
|
||||
...importHttpBodyAndHeaders(r),
|
||||
authentication,
|
||||
authenticationType,
|
||||
method: r.method,
|
||||
headers: (r.headers ?? [])
|
||||
.map((h: any) => ({
|
||||
enabled: !h.disabled,
|
||||
name: h.name ?? "",
|
||||
value: h.value ?? "",
|
||||
}))
|
||||
.filter(({ name, value }: any) => name !== "" || value !== ""),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* oxlint-disable no-explicit-any */
|
||||
import type { PartialImportResources } from "@yaakapp/api";
|
||||
import { convertId, convertTemplateSyntax, isJSObject } from "./common";
|
||||
import {
|
||||
convertId,
|
||||
convertTemplateSyntax,
|
||||
importHeaders,
|
||||
importHttpBodyAndHeaders,
|
||||
isJSObject,
|
||||
} from "./common";
|
||||
|
||||
export function convertInsomniaV5(parsed: any) {
|
||||
// Assert parsed is object
|
||||
@@ -82,38 +88,6 @@ function importHttpRequest(
|
||||
const updated = r.meta?.modified ?? r.updated;
|
||||
const sortKey = r.meta?.sortKey ?? r.sortKey;
|
||||
|
||||
let bodyType: string | null = null;
|
||||
let body = {};
|
||||
if (r.body?.mimeType === "application/octet-stream") {
|
||||
bodyType = "binary";
|
||||
body = { filePath: r.body.fileName ?? "" };
|
||||
} else if (r.body?.mimeType === "application/x-www-form-urlencoded") {
|
||||
bodyType = "application/x-www-form-urlencoded";
|
||||
body = {
|
||||
form: (r.body.params ?? []).map((p: any) => ({
|
||||
enabled: !p.disabled,
|
||||
name: p.name ?? "",
|
||||
value: p.value ?? "",
|
||||
})),
|
||||
};
|
||||
} else if (r.body?.mimeType === "multipart/form-data") {
|
||||
bodyType = "multipart/form-data";
|
||||
body = {
|
||||
form: (r.body.params ?? []).map((p: any) => ({
|
||||
enabled: !p.disabled,
|
||||
name: p.name ?? "",
|
||||
value: p.value ?? "",
|
||||
file: p.fileName ?? null,
|
||||
})),
|
||||
};
|
||||
} else if (r.body?.mimeType === "application/graphql") {
|
||||
bodyType = "graphql";
|
||||
body = { text: r.body.text ?? "" };
|
||||
} else if (r.body?.mimeType === "application/json") {
|
||||
bodyType = "application/json";
|
||||
body = { text: r.body.text ?? "" };
|
||||
}
|
||||
|
||||
return {
|
||||
id: convertId(id),
|
||||
workspaceId: convertId(workspaceId),
|
||||
@@ -130,10 +104,8 @@ function importHttpRequest(
|
||||
name: p.name ?? "",
|
||||
value: p.value ?? "",
|
||||
})),
|
||||
body,
|
||||
bodyType,
|
||||
...importHttpBodyAndHeaders(r),
|
||||
method: r.method,
|
||||
...importHeaders(r),
|
||||
...importAuthentication(r),
|
||||
};
|
||||
}
|
||||
@@ -203,17 +175,6 @@ function importWebsocketRequest(
|
||||
};
|
||||
}
|
||||
|
||||
function importHeaders(obj: any) {
|
||||
const headers = (obj.headers ?? [])
|
||||
.map((h: any) => ({
|
||||
enabled: !h.disabled,
|
||||
name: h.name ?? "",
|
||||
value: h.value ?? "",
|
||||
}))
|
||||
.filter(({ name, value }: any) => name !== "" || value !== "");
|
||||
return { headers } as const;
|
||||
}
|
||||
|
||||
function importAuthentication(obj: any) {
|
||||
let authenticationType: string | null = null;
|
||||
let authentication = {};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { importHttpBodyAndHeaders } from "../src/common";
|
||||
|
||||
describe("importHttpBodyAndHeaders", () => {
|
||||
test("imports XML text using the native XML body type", () => {
|
||||
const result = importHttpBodyAndHeaders({
|
||||
body: {
|
||||
mimeType: "application/soap+xml; charset=utf-8",
|
||||
text: "<soap:Envelope />",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bodyType: "text/xml",
|
||||
body: { text: "<soap:Envelope />" },
|
||||
headers: [
|
||||
{
|
||||
enabled: true,
|
||||
name: "Content-Type",
|
||||
value: "application/soap+xml; charset=utf-8",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("imports vendor JSON using the native JSON body type", () => {
|
||||
const result = importHttpBodyAndHeaders({
|
||||
body: {
|
||||
mimeType: "application/problem+json",
|
||||
text: '{"message":"Nope"}',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.bodyType).toBe("application/json");
|
||||
expect(result.body).toEqual({ text: '{"message":"Nope"}' });
|
||||
});
|
||||
|
||||
test("imports unknown text using the other body type", () => {
|
||||
const result = importHttpBodyAndHeaders({
|
||||
body: {
|
||||
mimeType: "application/yaml",
|
||||
text: "message: hello",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
bodyType: "other",
|
||||
body: { text: "message: hello" },
|
||||
headers: [
|
||||
{
|
||||
enabled: true,
|
||||
name: "Content-Type",
|
||||
value: "application/yaml",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("preserves an explicit content type instead of adding a duplicate", () => {
|
||||
const result = importHttpBodyAndHeaders({
|
||||
body: {
|
||||
mimeType: "application/yaml",
|
||||
text: "message: hello",
|
||||
},
|
||||
headers: [
|
||||
{
|
||||
name: "content-type",
|
||||
value: "application/x-yaml",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.headers).toEqual([
|
||||
{
|
||||
enabled: true,
|
||||
name: "content-type",
|
||||
value: "application/x-yaml",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("imports text without inventing a content type", () => {
|
||||
const result = importHttpBodyAndHeaders({ body: { text: "hello" } });
|
||||
|
||||
expect(result).toEqual({
|
||||
bodyType: "other",
|
||||
body: { text: "hello" },
|
||||
headers: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user