mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 20:23:40 +01:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbe8d1eef5 | ||
|
|
1e3dfff01e | ||
|
|
fccff5b7db | ||
|
|
6ed25b9f42 | ||
|
|
4a4d3b4301 | ||
|
|
25a464546a | ||
|
|
2c51fc92d6 | ||
|
|
f827aae46d | ||
|
|
caeebebc75 | ||
|
|
621f9c3b8f | ||
|
|
77b18d3477 | ||
|
|
a8c845b091 | ||
|
|
bcbf6d32a7 | ||
|
|
e30b289ed7 | ||
|
|
85904566e7 | ||
|
|
0b6803c6c0 | ||
|
|
edeb433c79 | ||
|
|
12e6e8a977 | ||
|
|
ab616fd915 | ||
|
|
4859aba142 | ||
|
|
cef569ee15 | ||
|
|
ae518dfb4c | ||
|
|
c9990d8492 | ||
|
|
309818200a | ||
|
|
06142d007a | ||
|
|
8a6e4810fd | ||
|
|
5a793e1d42 | ||
|
|
3d6c31440c | ||
|
|
31d55440c1 | ||
|
|
a822cc93d4 | ||
|
|
48889b6550 | ||
|
|
881cff472d | ||
|
|
d465c5679a | ||
|
|
0a68dea133 | ||
|
|
17a44f34a3 | ||
|
|
8c8be31aa2 | ||
|
|
ff5db718ce | ||
|
|
fd95b4cba3 |
No files matched your search
@@ -7,6 +7,23 @@ updates:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Build tooling majors are opted into, not queued. These never reach a
|
||||
# user, so the cost of being a release behind is low and the cost of a
|
||||
# surprise is a broken build. Note this also suppresses security PRs for
|
||||
# these, so Dependabot alerts are the backstop.
|
||||
- dependency-name: typescript
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: vitest
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@types/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "vite-plugin-*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
# 0.8 dropped stateExtensions and updateSchema from the package root and
|
||||
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
|
||||
# that editor moves to the jsonSchema helper.
|
||||
- dependency-name: codemirror-json-schema
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
@@ -25,6 +42,10 @@ updates:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
ignore:
|
||||
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
|
||||
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
|
||||
- dependency-name: oxc_resolver
|
||||
groups:
|
||||
cargo:
|
||||
patterns:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Retain only distributable bundle files, never the target tree or signing material.
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
createReadStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
const env = process.env;
|
||||
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
|
||||
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
|
||||
const extensions =
|
||||
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
|
||||
const files = [];
|
||||
|
||||
function snapshot(directory) {
|
||||
if (!existsSync(directory)) return;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const source = join(directory, entry.name);
|
||||
// Only descend through the bundler's immediate output folders. This excludes
|
||||
// .app trees and the extracted CEF deb contents (which contain executables).
|
||||
if (entry.isDirectory() && directory === bundle) {
|
||||
snapshot(source);
|
||||
} else if (
|
||||
entry.isFile() &&
|
||||
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
|
||||
) {
|
||||
// Match tauri-action's default macOS updater asset naming exactly.
|
||||
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
|
||||
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
|
||||
mkdirSync(destination, { recursive: true });
|
||||
const retained = join(destination, assetName);
|
||||
// The second snapshot must not overwrite the original user installer/signature.
|
||||
if (!existsSync(retained)) copyFileSync(source, retained);
|
||||
files.push({ source: relative(".", source), assetName });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snapshot(bundle);
|
||||
if (existsSync(destination)) {
|
||||
const assets = readdirSync(destination)
|
||||
.filter((name) => name !== "provenance.json")
|
||||
.sort();
|
||||
const checksums = [];
|
||||
for (const assetName of assets) {
|
||||
const hash = createHash("sha256");
|
||||
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
|
||||
checksums.push({ assetName, sha256: hash.digest("hex") });
|
||||
}
|
||||
writeFileSync(
|
||||
join(destination, "provenance.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
repository: env.GITHUB_REPOSITORY,
|
||||
tag: env.GITHUB_REF_NAME,
|
||||
sha: env.GITHUB_SHA,
|
||||
runId: env.GITHUB_RUN_ID,
|
||||
runAttempt: env.GITHUB_RUN_ATTEMPT,
|
||||
workflowRef: env.GITHUB_WORKFLOW_REF,
|
||||
workflowSha: env.GITHUB_WORKFLOW_SHA,
|
||||
tauriAction: "tauri-apps/tauri-action@v0",
|
||||
platform: env.RELEASE_PLATFORM,
|
||||
runtime: env.RELEASE_RUNTIME,
|
||||
arch: env.RELEASE_ARCH,
|
||||
target: env.RELEASE_TARGET,
|
||||
args: env.RELEASE_ARGS,
|
||||
assets: checksums,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n",
|
||||
);
|
||||
}
|
||||
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
|
||||
@@ -172,7 +172,8 @@ jobs:
|
||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
|
||||
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
|
||||
|
||||
- uses: tauri-apps/tauri-action@v0
|
||||
- name: Build and upload Tauri bundles
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
env:
|
||||
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
|
||||
|
||||
@@ -194,6 +195,8 @@ jobs:
|
||||
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
|
||||
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
|
||||
with:
|
||||
# Also retries builds; upload retries alone cannot clean up starter assets.
|
||||
retryAttempts: 3
|
||||
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
|
||||
tagName: "v__VERSION__"
|
||||
releaseName: "Release __VERSION__"
|
||||
@@ -203,6 +206,17 @@ jobs:
|
||||
projectPath: ./crates-tauri/yaak-app-client
|
||||
args: "${{ matrix.args }}"
|
||||
|
||||
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
|
||||
- name: Snapshot Tauri bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Build and upload CEF tarball from deb (Linux only)
|
||||
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
|
||||
env:
|
||||
@@ -217,7 +231,13 @@ jobs:
|
||||
mkdir -p "$extract_dir"
|
||||
dpkg-deb -x "$deb" "$extract_dir"
|
||||
tar -C "$extract_dir" -czf "$tarball" .
|
||||
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
|
||||
for attempt in 1 2 3 4; do
|
||||
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 4 ]; then exit 1; fi
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
|
||||
- name: Build and upload machine-wide installer (Windows only)
|
||||
@@ -235,6 +255,7 @@ jobs:
|
||||
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
|
||||
Push-Location crates-tauri/yaak-app-client
|
||||
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
|
||||
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
|
||||
Pop-Location
|
||||
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
|
||||
$setupSig = "$($setup.FullName).sig"
|
||||
@@ -242,5 +263,30 @@ jobs:
|
||||
$destSig = "$dest.sig"
|
||||
Copy-Item $setup.FullName $dest
|
||||
Copy-Item $setupSig $destSig
|
||||
gh release upload "${{ github.ref_name }}" "$dest" --clobber
|
||||
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
|
||||
foreach ($asset in @($dest, $destSig)) {
|
||||
for ($attempt = 1; $attempt -le 4; $attempt++) {
|
||||
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
|
||||
if ($LASTEXITCODE -eq 0) { break }
|
||||
if ($attempt -eq 4) { exit $LASTEXITCODE }
|
||||
Start-Sleep -Seconds 10
|
||||
}
|
||||
}
|
||||
|
||||
- name: Snapshot additional release bundles
|
||||
if: ${{ !cancelled() }}
|
||||
run: node .github/scripts/preserve-release-artifacts.mjs
|
||||
env:
|
||||
RELEASE_TARGET: ${{ matrix.targets }}
|
||||
RELEASE_PLATFORM: ${{ matrix.platform }}
|
||||
RELEASE_RUNTIME: ${{ matrix.runtime }}
|
||||
RELEASE_ARCH: ${{ matrix.yaak_arch }}
|
||||
RELEASE_ARGS: ${{ matrix.args }}
|
||||
|
||||
- name: Retain release bundles for recovery
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
|
||||
path: ${{ runner.temp }}/yaak-release-artifacts/
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
Generated
+884
-874
File diff suppressed because it is too large.
Load diff
+9
-1
@@ -29,6 +29,7 @@ members = [
|
||||
"crates-proxy/yaak-proxy-lib",
|
||||
# Server crates (the browser tier's hosted send executor)
|
||||
"crates-server/yaak-web",
|
||||
"crates-server/yaak-playground",
|
||||
# CLI crates
|
||||
"crates-cli/yaak-cli",
|
||||
# Tauri-specific crates
|
||||
@@ -47,7 +48,14 @@ chrono = "0.4.42"
|
||||
hex = "0.4.3"
|
||||
keyring = "3.6.3"
|
||||
log = "0.4.29"
|
||||
reqwest = "0.12.20"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = [
|
||||
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
|
||||
# rest of the old default set and stay on native-tls.
|
||||
"charset",
|
||||
"http2",
|
||||
"system-proxy",
|
||||
"native-tls",
|
||||
] }
|
||||
rustls = { version = "0.23.34", default-features = false }
|
||||
rustls-platform-verifier = "0.6.2"
|
||||
schemars = { version = "0.8.22", features = ["chrono"] }
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
|
||||
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
RUN cargo build --release -p yaak-playground
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
CMD ["yaak-playground"]
|
||||
+4
-1
@@ -38,7 +38,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
|
||||
COPY --from=web /app/dist/apps/yaak-client /srv
|
||||
ENV YAAK_WEB_BIND=0.0.0.0:8080
|
||||
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
|
||||
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8080
|
||||
EXPOSE 8080
|
||||
USER nobody
|
||||
# Overriding the command (dropping --serve) leaves the stateless send executor:
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import type {
|
||||
WorkspaceSettingsHighlight,
|
||||
WorkspaceSettingsTab,
|
||||
} from "../components/WorkspaceSettingsDialog";
|
||||
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
|
||||
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
|
||||
export function openWorkspaceSettings(
|
||||
tab?: WorkspaceSettingsTab,
|
||||
highlight?: WorkspaceSettingsHighlight,
|
||||
) {
|
||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
WorkspaceSettingsDialog.show(workspaceId, tab, highlight);
|
||||
WorkspaceSettingsDialog.show(workspaceId, tab);
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import type { Pair } from "./core/PairEditor";
|
||||
import { PairEditor } from "./core/PairEditor";
|
||||
import { PlainInput } from "./core/PlainInput";
|
||||
import { Select } from "./core/Select";
|
||||
import { SecretVariableHint } from "./hints/SecretVariableHint";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { SelectFile } from "./SelectFile";
|
||||
|
||||
@@ -47,8 +46,6 @@ interface Props<T> {
|
||||
stateKey: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
/** Offer to move literal secrets typed into password fields into a variable */
|
||||
suggestVariables?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
@@ -60,7 +57,6 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
stateKey,
|
||||
className,
|
||||
disabled,
|
||||
suggestVariables,
|
||||
}: Props<T>) {
|
||||
const setDataAttr = useCallback(
|
||||
(name: string, value: JsonPrimitive) => {
|
||||
@@ -77,7 +73,6 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
stateKey={stateKey}
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
suggestVariables={suggestVariables}
|
||||
data={data}
|
||||
className={classNames(className, "pb-4")} // Pad the bottom to look nice
|
||||
/>
|
||||
@@ -108,7 +103,6 @@ type FormInputsProps<T> = Pick<
|
||||
> & {
|
||||
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
||||
disabled?: boolean;
|
||||
suggestVariables?: boolean;
|
||||
};
|
||||
|
||||
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
@@ -119,7 +113,6 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
setDataAttr,
|
||||
data,
|
||||
disabled,
|
||||
suggestVariables,
|
||||
}: FormInputsProps<T>) {
|
||||
return (
|
||||
<>
|
||||
@@ -154,7 +147,6 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
arg={input}
|
||||
autocompleteFunctions={autocompleteFunctions || false}
|
||||
autocompleteVariables={autocompleteVariables || false}
|
||||
suggestVariables={suggestVariables || false}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
||||
@@ -295,7 +287,6 @@ function TextArg({
|
||||
value,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
suggestVariables,
|
||||
stateKey,
|
||||
}: {
|
||||
arg: FormInputText;
|
||||
@@ -303,7 +294,6 @@ function TextArg({
|
||||
onChange: (v: string) => void;
|
||||
autocompleteFunctions: boolean;
|
||||
autocompleteVariables: boolean;
|
||||
suggestVariables: boolean;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const props: InputProps = {
|
||||
@@ -326,19 +316,10 @@ function TextArg({
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
};
|
||||
const input =
|
||||
autocompleteVariables || autocompleteFunctions || arg.completionOptions ? (
|
||||
<Input {...props} />
|
||||
) : (
|
||||
<PlainInput {...props} />
|
||||
);
|
||||
if (!suggestVariables || !arg.password) return input;
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{input}
|
||||
<SecretVariableHint name={arg.name} value={value} onReplace={onChange} />
|
||||
</div>
|
||||
);
|
||||
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
||||
return <Input {...props} />;
|
||||
}
|
||||
return <PlainInput {...props} />;
|
||||
}
|
||||
|
||||
function EditorArg({
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
withEncryptionEnabled,
|
||||
} from "../lib/setupOrConfigureEncryption";
|
||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||
import { FeatureHint } from "./core/FeatureHint";
|
||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
|
||||
import { ensurePairId } from "./core/PairEditor.util";
|
||||
@@ -153,17 +152,6 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
This sharable environment contains plain-text secrets
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
{!environment.public && !isEncryptionEnabled && hasSecretLookingVariable(environment) && (
|
||||
<FeatureHint
|
||||
id="encrypt-secrets"
|
||||
className="mr-3"
|
||||
docsUrl="https://yaak.app/docs/collaboration/secrets-encryption"
|
||||
action={{ label: "Enable Encryption", onClick: setupOrConfigureEncryption }}
|
||||
>
|
||||
Some of these look like secrets. Encrypt them so the workspace is safe to sync to a
|
||||
folder or share with a team.
|
||||
</FeatureHint>
|
||||
)}
|
||||
</div>
|
||||
<PairOrBulkEditor
|
||||
setRef={setRef}
|
||||
@@ -185,9 +173,3 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SECRET_NAME = /token|secret|password|passwd|api[_-]?key|private[_-]?key|credential/i;
|
||||
|
||||
function hasSecretLookingVariable(environment: Environment) {
|
||||
return environment.variables.some((v) => v.value.trim() !== "" && SECRET_NAME.test(v.name));
|
||||
}
|
||||
@@ -65,21 +65,22 @@ function ExportDataDialogContent({
|
||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
|
||||
const exportPath = await platform.dialog.save({
|
||||
title: "Export Data",
|
||||
defaultPath: `yaak.${slug}.json`,
|
||||
});
|
||||
if (exportPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await rpc("cmd_export_data", {
|
||||
const document = await rpc<string>("cmd_export_data", {
|
||||
workspaceIds: ids,
|
||||
exportPath,
|
||||
includePrivateEnvironments: includePrivateEnvironments,
|
||||
});
|
||||
|
||||
const savedTo = await platform.files.save(
|
||||
`yaak.${slug}.json`,
|
||||
new TextEncoder().encode(document),
|
||||
[{ name: "JSON", extensions: ["json"] }],
|
||||
);
|
||||
if (savedTo == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
onHide();
|
||||
onSuccess(exportPath);
|
||||
onSuccess(savedTo);
|
||||
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
|
||||
|
||||
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
|
||||
|
||||
@@ -5,10 +5,13 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
|
||||
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
|
||||
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
|
||||
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
|
||||
import { usePlatformEvent } from "../hooks/usePlatformEvent";
|
||||
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
|
||||
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { jotaiStore } from "../lib/jotai";
|
||||
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
|
||||
import { router } from "../lib/router";
|
||||
|
||||
export function GlobalHooks() {
|
||||
useSyncZoomSetting();
|
||||
@@ -24,6 +27,10 @@ export function GlobalHooks() {
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
usePlatformEvent("show_home", () => {
|
||||
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
|
||||
});
|
||||
|
||||
useHotKey(
|
||||
"request.rename",
|
||||
async () => {
|
||||
|
||||
@@ -178,7 +178,6 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
||||
disabled={model.authentication.disabled === true}
|
||||
autocompleteVariables
|
||||
autocompleteFunctions
|
||||
suggestVariables
|
||||
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
||||
inputs={authConfig.data?.args ?? []}
|
||||
data={model.authentication}
|
||||
|
||||
@@ -52,7 +52,6 @@ import { JsonBodyEditor } from "./JsonBodyEditor";
|
||||
import { MarkdownEditor } from "./MarkdownEditor";
|
||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||
import { SharedOriginHint } from "./hints/SharedOriginHint";
|
||||
import { UrlBar } from "./UrlBar";
|
||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||
|
||||
@@ -345,27 +344,24 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
<div>
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
placeholder="https://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||
/>
|
||||
<SharedOriginHint />
|
||||
</div>
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
placeholder="https://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||
/>
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
|
||||
@@ -28,7 +28,6 @@ import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
||||
import { Tooltip } from "./core/Tooltip";
|
||||
import { EmptyStateText } from "./EmptyStateText";
|
||||
import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { ChainingHint } from "./hints/ChainingHint";
|
||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||
@@ -281,7 +280,6 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
{activeResponse && <ChainingHint response={activeResponse} mimeType={mimeType} />}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
|
||||
@@ -447,7 +447,7 @@ function LoadedImportDataDialog({
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text-subtlest">
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
|
||||
Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -460,7 +460,7 @@ function LoadedImportDataDialog({
|
||||
onChange={setSource}
|
||||
/>
|
||||
|
||||
<VStack space={2}>
|
||||
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
|
||||
<Select
|
||||
name="import-destination-kind"
|
||||
label="Import location"
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { Color } from "@yaakapp-internal/plugins";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { settingsAtom } from "@yaakapp-internal/models";
|
||||
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useState } from "react";
|
||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||
import { showDialog } from "../lib/dialog";
|
||||
import { importData } from "../lib/importData";
|
||||
import {
|
||||
createExampleWorkspace,
|
||||
createFreshWorkspace,
|
||||
type OnboardingChoice,
|
||||
recordOnboardingChoice,
|
||||
} from "../lib/onboarding";
|
||||
import { showErrorToast } from "../lib/toast";
|
||||
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
|
||||
import { Button } from "./core/Button";
|
||||
|
||||
/** Shown instead of a workspace when there are none. */
|
||||
export function Onboarding() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const { capabilities } = platform;
|
||||
const canImport = capabilities.plugins;
|
||||
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
|
||||
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
|
||||
|
||||
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
|
||||
recordOnboardingChoice(choice);
|
||||
setBusy(choice);
|
||||
try {
|
||||
await run();
|
||||
} catch (err) {
|
||||
showErrorToast({
|
||||
id: "onboarding-failed",
|
||||
title: "Something went wrong",
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
size="lg"
|
||||
className="x-theme-appHeader bg-surface"
|
||||
osType={platform.osType()}
|
||||
hideWindowControls={settings.hideWindowControls}
|
||||
useNativeTitlebar={settings.useNativeTitlebar}
|
||||
interfaceScale={settings.interfaceScale}
|
||||
/>
|
||||
<div className="overflow-auto px-6 py-10 grid">
|
||||
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Heading>How would you like to get started?</Heading>
|
||||
<p className="text-text-subtle">
|
||||
{canImport
|
||||
? "Bring over existing work, try a real API, or start fresh."
|
||||
: "Try a real API, or start fresh."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{canImport && (
|
||||
<StartOption
|
||||
color="primary"
|
||||
icon="folder_input"
|
||||
title="Migrate from another tool"
|
||||
description="Postman, Insomnia, OpenAPI, or curl"
|
||||
busy={busy === "import"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import", () => importData.mutateAsync())}
|
||||
/>
|
||||
)}
|
||||
<StartOption
|
||||
color="info"
|
||||
icon="flask"
|
||||
title="Try Yaak with a real API"
|
||||
description="Ready-made requests you can send right away"
|
||||
busy={busy === "example"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("example", createExampleWorkspace)}
|
||||
/>
|
||||
<StartOption
|
||||
color="success"
|
||||
icon="plus"
|
||||
title="Start fresh"
|
||||
description="An empty workspace for your first request"
|
||||
busy={busy === "fresh"}
|
||||
disabled={busy != null}
|
||||
onClick={choose("fresh", createFreshWorkspace)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showAlreadyUsing && (
|
||||
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
|
||||
<span className="text-sm text-text-subtle">Already using Yaak?</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{capabilities.localFiles && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="folder_open" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("open_folder", async () => {
|
||||
const dir = await platform.dialog.open({
|
||||
title: "Select Workspace Directory",
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (dir == null) return;
|
||||
await openWorkspaceFromSyncDir.mutateAsync(dir);
|
||||
})}
|
||||
>
|
||||
Open folder
|
||||
</Button>
|
||||
)}
|
||||
{capabilities.git && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="git_branch" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("clone_git", () => {
|
||||
showDialog({
|
||||
id: "clone-git-repository",
|
||||
size: "md",
|
||||
title: "Clone Git Repository",
|
||||
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
|
||||
});
|
||||
})}
|
||||
>
|
||||
Clone repository
|
||||
</Button>
|
||||
)}
|
||||
{canImport && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="border"
|
||||
color="secondary"
|
||||
leftSlot={<Icon icon="import" size="sm" />}
|
||||
disabled={busy != null}
|
||||
onClick={choose("import_yaak", () => importData.mutateAsync())}
|
||||
>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartOption({
|
||||
color,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
busy,
|
||||
disabled,
|
||||
}: {
|
||||
color: Color;
|
||||
icon: IconProps["icon"];
|
||||
title: string;
|
||||
description: string;
|
||||
onClick: () => void;
|
||||
busy: boolean;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className={classNames(
|
||||
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
|
||||
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
|
||||
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
|
||||
"outline-border-focus focus-visible:outline-2",
|
||||
disabled && !busy && "opacity-disabled",
|
||||
)}
|
||||
>
|
||||
{/* The banner theme tints this tile from the theme's own color for each option */}
|
||||
<div
|
||||
className={classNames(
|
||||
`x-theme-banner--${color}`,
|
||||
"size-10 rounded-md bg-surface-highlight grid place-items-center",
|
||||
)}
|
||||
>
|
||||
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-text">{title}</div>
|
||||
<div className="text-sm text-text-subtle">{description}</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
|
||||
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
|
||||
import { fireAndForget } from "../lib/fireAndForget";
|
||||
import { router } from "../lib/router";
|
||||
import { Onboarding } from "./Onboarding";
|
||||
|
||||
export function RedirectToLatestWorkspace() {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
@@ -14,10 +15,6 @@ export function RedirectToLatestWorkspace() {
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||
console.log("No workspaces found to redirect to. Skipping.", {
|
||||
workspaces,
|
||||
recentWorkspaces,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -34,11 +31,21 @@ export function RedirectToLatestWorkspace() {
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
// Whatever created the first workspace may have already navigated into it
|
||||
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
|
||||
if (workspaces.some((w) => w.id === currentId)) return;
|
||||
|
||||
console.log("Redirecting to workspace", params, search);
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
|
||||
})(),
|
||||
);
|
||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||
|
||||
// The global models are loaded before the router mounts, so an empty list is a real
|
||||
// first launch (or the last workspace was just deleted), not a store still loading
|
||||
if (workspaces.length === 0) {
|
||||
return <Onboarding />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -59,7 +59,6 @@ export function SyncToFilesystemSetting({
|
||||
)}
|
||||
|
||||
<SettingRowDirectory
|
||||
highlightKey="sync_dir"
|
||||
title="Local directory sync"
|
||||
description="Sync data to a folder for backup and Git integration."
|
||||
filePath={value.filePath}
|
||||
|
||||
@@ -40,6 +40,7 @@ import { ErrorBoundary } from "./ErrorBoundary";
|
||||
import { FolderLayout } from "./FolderLayout";
|
||||
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
|
||||
import { HttpRequestLayout } from "./HttpRequestLayout";
|
||||
import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
|
||||
import Sidebar from "./Sidebar";
|
||||
import { SidebarActions } from "./SidebarActions";
|
||||
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
|
||||
@@ -66,9 +67,8 @@ export function Workspace() {
|
||||
return { background };
|
||||
}, [activeEnvironment?.color]);
|
||||
|
||||
// We're loading still
|
||||
if (workspaces.length === 0) {
|
||||
return null;
|
||||
return <RedirectToLatestWorkspace />;
|
||||
}
|
||||
|
||||
const header = (
|
||||
|
||||
@@ -25,12 +25,8 @@ interface Props {
|
||||
workspaceId: string;
|
||||
hide: () => void;
|
||||
tab?: WorkspaceSettingsTab;
|
||||
highlight?: WorkspaceSettingsHighlight;
|
||||
}
|
||||
|
||||
/** Settings rows that can be pointed out when the dialog opens */
|
||||
export type WorkspaceSettingsHighlight = "sync_dir";
|
||||
|
||||
const TAB_AUTH = "auth";
|
||||
const TAB_DNS = "dns";
|
||||
const TAB_HEADERS = "headers";
|
||||
@@ -46,7 +42,7 @@ export type WorkspaceSettingsTab =
|
||||
|
||||
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
||||
|
||||
export function WorkspaceSettingsDialog({ workspaceId, hide, tab, highlight }: Props) {
|
||||
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
||||
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
|
||||
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
|
||||
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
|
||||
@@ -108,7 +104,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab, highlight }: P
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
|
||||
<SettingsList className="space-y-8 pb-3" highlight={highlight}>
|
||||
<SettingsList className="space-y-8 pb-3">
|
||||
<SettingsSection title={null}>
|
||||
<SyncToFilesystemSetting
|
||||
layout="settings"
|
||||
@@ -182,23 +178,14 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab, highlight }: P
|
||||
);
|
||||
}
|
||||
|
||||
WorkspaceSettingsDialog.show = (
|
||||
workspaceId: string,
|
||||
tab?: WorkspaceSettingsTab,
|
||||
highlight?: WorkspaceSettingsHighlight,
|
||||
) => {
|
||||
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
|
||||
showDialog({
|
||||
id: "workspace-settings",
|
||||
size: "lg",
|
||||
className: "h-[calc(100vh-5rem)] max-h-200!",
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<WorkspaceSettingsDialog
|
||||
workspaceId={workspaceId}
|
||||
hide={hide}
|
||||
tab={tab}
|
||||
highlight={highlight}
|
||||
/>
|
||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -62,7 +62,7 @@ export function Confirm({
|
||||
/>
|
||||
)}
|
||||
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
|
||||
<Button type="submit" color={color} disabled={!didConfirm}>
|
||||
<Button autoFocus={!requireTyping} type="submit" color={color} disabled={!didConfirm}>
|
||||
{confirmText ?? "Confirm"}
|
||||
</Button>
|
||||
<Button onClick={handleHide} variant="border">
|
||||
|
||||
@@ -2,12 +2,11 @@ import type { Color } from "@yaakapp-internal/plugins";
|
||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||
import { Banner } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { MouseEvent, ReactNode } from "react";
|
||||
import type { MouseEvent } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import type { ButtonProps } from "./Button";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
type DismissibleBannerSize = "sm" | "xs";
|
||||
|
||||
@@ -19,21 +18,17 @@ export function DismissibleBanner({
|
||||
onDismiss,
|
||||
onShow,
|
||||
actions,
|
||||
variant = "banner",
|
||||
...props
|
||||
}: BannerProps & {
|
||||
id: string;
|
||||
size?: DismissibleBannerSize;
|
||||
onDismiss?: () => void | Promise<void>;
|
||||
onShow?: () => void | Promise<void>;
|
||||
/** A tip dismisses with an X in the corner instead of a text button */
|
||||
variant?: "banner" | "tip";
|
||||
actions?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
color?: Color;
|
||||
variant?: ButtonProps["variant"];
|
||||
rightSlot?: ReactNode;
|
||||
}[];
|
||||
}) {
|
||||
const {
|
||||
@@ -61,69 +56,54 @@ export function DismissibleBanner({
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
const dismiss = (event: MouseEvent) => {
|
||||
stopParentClick(event);
|
||||
setDismissed(true).catch(console.error);
|
||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
||||
};
|
||||
const isTip = variant === "tip";
|
||||
const hasActionRow = !isTip || (actions?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<Banner
|
||||
className={classNames(className, "relative", size === "xs" && "!px-2 !py-2 text-xs")}
|
||||
className={classNames(
|
||||
className,
|
||||
"relative",
|
||||
size === "xs" && "!px-2 !py-2 text-xs",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className={classNames("@container", isTip && "relative")}>
|
||||
{isTip && (
|
||||
<IconButton
|
||||
icon="x"
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
title="Dismiss message"
|
||||
className="absolute! -top-0.5 -right-1 @[34rem]:top-1/2 @[34rem]:-translate-y-1/2 opacity-50 hover:opacity-100"
|
||||
onClick={dismiss}
|
||||
/>
|
||||
)}
|
||||
<div className="@container">
|
||||
<div
|
||||
className={classNames(
|
||||
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
|
||||
isTip && "@[34rem]:pr-5",
|
||||
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
|
||||
)}
|
||||
>
|
||||
{isTip ? <div className="pr-5 @[34rem]:pr-0">{children}</div> : children}
|
||||
{hasActionRow && (
|
||||
<div className="flex flex-col gap-1.5 @[16rem]:flex-row @[16rem]:flex-wrap @[16rem]:justify-end">
|
||||
{!isTip && (
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size={actionSize}
|
||||
onClick={dismiss}
|
||||
title="Dismiss message"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
rightSlot={a.rightSlot}
|
||||
variant={a.variant ?? "border"}
|
||||
color={a.color ?? props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
a.onClick();
|
||||
}}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
||||
<Button
|
||||
variant="border"
|
||||
color={props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
setDismissed(true).catch(console.error);
|
||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
||||
}}
|
||||
title="Dismiss message"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
{actions?.map((a) => (
|
||||
<Button
|
||||
key={a.label}
|
||||
variant={a.variant ?? "border"}
|
||||
color={a.color ?? props.color}
|
||||
size={actionSize}
|
||||
onClick={(event) => {
|
||||
stopParentClick(event);
|
||||
a.onClick();
|
||||
}}
|
||||
title={a.label}
|
||||
>
|
||||
{a.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
@@ -32,6 +32,8 @@ interface JsonLintOptions {
|
||||
export function jsonParseLinter(options?: JsonLintOptions) {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const doc = view.state.doc.toString();
|
||||
if (doc.trim() === "") return [];
|
||||
|
||||
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
|
||||
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
|
||||
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { ViewPlugin } from "@codemirror/view";
|
||||
import type { JsonPathSegment } from "./jsonPath";
|
||||
import { jsonPathSegmentsAt } from "./jsonPath";
|
||||
|
||||
export interface BreadcrumbUpdate {
|
||||
/** Path to the cursor, or `null` when the document isn't JSON. */
|
||||
segments: JsonPathSegment[] | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the JSON path under the cursor when the editor is created (it may restore a
|
||||
* cached selection), and whenever the selection, document, or parsed tree changes. The
|
||||
* tree matters because large documents parse in the background, so a path read early
|
||||
* can be wrong until the parser reaches the cursor.
|
||||
*
|
||||
* The callback is not fired on blur: moving focus to the filter box (by clicking
|
||||
* a crumb) must not erase the breadcrumb that was just acted on.
|
||||
*/
|
||||
export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
|
||||
const report = (view: EditorView) => {
|
||||
onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
|
||||
};
|
||||
|
||||
return ViewPlugin.define((view) => {
|
||||
report(view);
|
||||
return {
|
||||
update(update: ViewUpdate) {
|
||||
if (
|
||||
update.selectionSet ||
|
||||
update.docChanged ||
|
||||
syntaxTree(update.startState) !== syntaxTree(update.state)
|
||||
) {
|
||||
report(update.view);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { jsonc } from "@shopify/lang-jsonc";
|
||||
import { forceParsing } from "@codemirror/language";
|
||||
import { describe, expect, test } from "vite-plus/test";
|
||||
import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
|
||||
|
||||
function stateFor(doc: string): EditorState {
|
||||
const state = EditorState.create({ doc, extensions: [jsonc()] });
|
||||
// Ensure the whole doc is parsed so resolveInner sees a complete tree.
|
||||
forceParsing({ state } as never, doc.length, 5000);
|
||||
return state;
|
||||
}
|
||||
|
||||
/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
|
||||
function pathAt(doc: string, needle: string): string {
|
||||
const pos = doc.indexOf(needle) + needle.length;
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
if (segs == null) throw new Error("no JSON tree");
|
||||
return segmentsToJsonPath(segs);
|
||||
}
|
||||
|
||||
/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
|
||||
function pathBefore(doc: string, needle: string): string {
|
||||
const pos = doc.indexOf(needle);
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
if (segs == null) throw new Error("no JSON tree");
|
||||
return segmentsToJsonPath(segs);
|
||||
}
|
||||
|
||||
describe("jsonPathSegmentsAt", () => {
|
||||
test("nested object keys", () => {
|
||||
const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
|
||||
expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
|
||||
});
|
||||
|
||||
test("array element index", () => {
|
||||
const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
|
||||
expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
|
||||
});
|
||||
|
||||
test("root array element", () => {
|
||||
const doc = `[ { "id": 1 }, { "id": 2 } ]`;
|
||||
expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
|
||||
});
|
||||
|
||||
test("key containing a dot uses bracket-quote notation", () => {
|
||||
const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
|
||||
expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
|
||||
});
|
||||
|
||||
test("cursor on a property name resolves that key", () => {
|
||||
const doc = `{ "outer": { "inner": 5 } }`;
|
||||
expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
|
||||
});
|
||||
|
||||
test("deeply nested arrays and objects", () => {
|
||||
const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
|
||||
expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
|
||||
});
|
||||
|
||||
test("root scalar yields the root path", () => {
|
||||
expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
|
||||
});
|
||||
|
||||
test("caret just before a key still resolves that key", () => {
|
||||
// The character to the left is the object's whitespace, not the property.
|
||||
const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
|
||||
expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
|
||||
});
|
||||
|
||||
test("caret in a line's indentation resolves that line's member", () => {
|
||||
// Caret in the blank indentation of the `title` line (structurally the
|
||||
// object's whitespace) reads as being on `title`, not a neighbour.
|
||||
const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
|
||||
const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
|
||||
});
|
||||
|
||||
test("caret at the end of an array element keeps the element index", () => {
|
||||
// The caret sits after `}` where the left token is the object's close brace.
|
||||
const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
|
||||
const pos = doc.indexOf(`},`) + 1;
|
||||
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
|
||||
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
|
||||
});
|
||||
|
||||
test("non-JSON documents return null", () => {
|
||||
const state = EditorState.create({ doc: `<xml/>` });
|
||||
expect(jsonPathSegmentsAt(state, 3)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("segmentsToJsonPath", () => {
|
||||
test("empty segment list is the root", () => {
|
||||
expect(segmentsToJsonPath([])).toBe(`$`);
|
||||
});
|
||||
|
||||
test("partial path via count", () => {
|
||||
const segs = [
|
||||
{ kind: "key", key: "a" },
|
||||
{ kind: "index", index: 2 },
|
||||
{ kind: "key", key: "b" },
|
||||
] as const;
|
||||
expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonPathToSegments", () => {
|
||||
test("round-trips paths built by segmentsToJsonPath", () => {
|
||||
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
|
||||
const segments = jsonPathToSegments(path);
|
||||
expect(segments).not.toBeNull();
|
||||
expect(segmentsToJsonPath(segments ?? [])).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns null for paths that don't name a single location", () => {
|
||||
for (const path of [
|
||||
"$..id",
|
||||
"$[*]",
|
||||
"$.items[?(@.id)]",
|
||||
"$[0:2]",
|
||||
"items[0]",
|
||||
"$.a b",
|
||||
'$["\\q"]',
|
||||
'$["unterminated]',
|
||||
'$["line\nbreak"]',
|
||||
"$[01]",
|
||||
"$[9007199254740993]",
|
||||
"$.$",
|
||||
]) {
|
||||
expect(jsonPathToSegments(path)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { syntaxTree } from "@codemirror/language";
|
||||
import type { EditorState } from "@codemirror/state";
|
||||
import type { SyntaxNode } from "@lezer/common";
|
||||
import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
|
||||
|
||||
export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
|
||||
export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
|
||||
|
||||
// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
|
||||
// exactly one of these; everything else in the tree is punctuation or a comment.
|
||||
const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
|
||||
// The nodes with children that a caret can land "between" — its whitespace and
|
||||
// punctuation belong to the container, not to any value inside it.
|
||||
const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
|
||||
|
||||
/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
|
||||
function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
|
||||
const raw = state.doc.sliceString(node.from, node.to);
|
||||
try {
|
||||
return JSON.parse(raw) as string;
|
||||
} catch {
|
||||
// A half-typed or malformed key still deserves a best-effort label.
|
||||
return raw.replace(/^"|"$/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
/** Index of `child` among the value elements of its parent `Array` node. */
|
||||
function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
|
||||
let idx = 0;
|
||||
for (let c = array.firstChild; c != null; c = c.nextSibling) {
|
||||
if (c.from === child.from && c.to === child.to) return idx;
|
||||
if (VALUE_NODES.has(c.name)) idx++;
|
||||
}
|
||||
return idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* The path from the document root to the member on the caret's line, or `null`
|
||||
* when the document has no JSON tree (a different language, or empty). An empty
|
||||
* array means the caret sits at the root value itself.
|
||||
*
|
||||
* A caret exactly on a token uses that token. A caret in a line's whitespace
|
||||
* resolves to the enclosing object or array, so it's re-anchored to the first
|
||||
* non-whitespace character on that line — in pretty-printed JSON each member
|
||||
* starts its own line, so that's the member's own token. This tracks "the line
|
||||
* I'm on" without guessing between neighbouring keys.
|
||||
*
|
||||
* From the anchor, each ancestor contributes a segment: a `Property` its key, a
|
||||
* value directly inside an `Array` its index. Collected deepest-first, reversed.
|
||||
*/
|
||||
export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
|
||||
const tree = syntaxTree(state);
|
||||
if (tree.topNode.name !== "JsoncText") return null;
|
||||
|
||||
let node: SyntaxNode = tree.resolveInner(pos, -1);
|
||||
if (CONTAINER_NODES.has(node.name)) {
|
||||
const line = state.doc.lineAt(pos);
|
||||
const indent = line.text.length - line.text.trimStart().length;
|
||||
if (indent < line.text.length) {
|
||||
node = tree.resolveInner(line.from + indent, 1);
|
||||
}
|
||||
}
|
||||
|
||||
const segments: JsonPathSegment[] = [];
|
||||
let cur: SyntaxNode | null = node;
|
||||
while (cur != null) {
|
||||
const parent: SyntaxNode | null = cur.parent;
|
||||
if (cur.name === "Property") {
|
||||
const nameNode = cur.getChild("PropertyName");
|
||||
if (nameNode != null) {
|
||||
segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
|
||||
}
|
||||
} else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
|
||||
segments.push({ kind: "index", index: indexInArray(parent, cur) });
|
||||
}
|
||||
cur = parent;
|
||||
}
|
||||
|
||||
segments.reverse();
|
||||
return segments;
|
||||
}
|
||||
|
||||
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
|
||||
/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
|
||||
function segmentToJsonPath(segment: JsonPathSegment): string {
|
||||
if (segment.kind === "index") return `[${segment.index}]`;
|
||||
// jsonpath-plus interprets a bare `$` as its root operator.
|
||||
if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
|
||||
// Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
|
||||
// JSON.stringify gives correct double-quoting and escaping, matching the
|
||||
// convention the JSONPath filter box already displays.
|
||||
return `[${JSON.stringify(segment.key)}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a JSONPath expression for the first `count` segments (all of them by
|
||||
* default). `segmentsToJsonPath([])` is the root, `$`.
|
||||
*/
|
||||
export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
|
||||
return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import type { ReactNode } from "react";
|
||||
import { setKeyValue } from "../../lib/keyValueStore";
|
||||
import { DismissibleBanner } from "./DismissibleBanner";
|
||||
|
||||
interface Props {
|
||||
/** Stable id; the dismissal is remembered under it. */
|
||||
id: string;
|
||||
children: ReactNode;
|
||||
docsUrl?: string;
|
||||
action?: { label: string; onClick: () => void | Promise<void> };
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-time nudge toward a feature the user hasn't found yet. Dismissed once, gone for good.
|
||||
*/
|
||||
export function FeatureHint({ id, children, docsUrl, action, onDismiss, className }: Props) {
|
||||
const actions = [
|
||||
...(action ? [{ ...action, variant: "solid" as const, color: "info" as const }] : []),
|
||||
...(action != null && docsUrl != null
|
||||
? [
|
||||
{
|
||||
label: "Docs",
|
||||
rightSlot: <Icon icon="external_link" size="xs" className="opacity-60" />,
|
||||
onClick: () => platform.openUrl(docsUrl).catch(console.error),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
return (
|
||||
<DismissibleBanner
|
||||
id={`feature-hint:${id}`}
|
||||
variant="tip"
|
||||
color="info"
|
||||
size="xs"
|
||||
onDismiss={onDismiss}
|
||||
className={className}
|
||||
actions={actions}
|
||||
>
|
||||
<div className="flex items-start gap-2 text-xs">
|
||||
<Icon icon="sparkles" size="xs" className="hidden @[16rem]:block mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
{children}
|
||||
{action == null && docsUrl != null && (
|
||||
<>
|
||||
{" "}
|
||||
(
|
||||
<button
|
||||
type="button"
|
||||
className="group inline-flex items-center gap-0.5 underline hover:text-info cursor-pointer! [&_*]:cursor-pointer!"
|
||||
onClick={() => platform.openUrl(docsUrl).catch(console.error)}
|
||||
>
|
||||
docs
|
||||
<Icon
|
||||
icon="external_link"
|
||||
size="xs"
|
||||
className="opacity-80 group-hover:opacity-100"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export function dismissFeatureHint(id: string) {
|
||||
return setKeyValue({
|
||||
namespace: "global",
|
||||
key: ["dismiss-banner", `feature-hint:${id}`],
|
||||
value: true,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Link as RouterLink } from "@tanstack/react-router";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { appInfo } from "../../lib/appInfo";
|
||||
@@ -33,7 +34,10 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
|
||||
href={finalHref}
|
||||
target="_blank"
|
||||
rel={isYaakLink ? undefined : "noopener noreferrer"}
|
||||
onClick={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void platform.openUrl(finalHref);
|
||||
}}
|
||||
className={className}
|
||||
{...other}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { AnyModel } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
import { CopyIconButton } from "../CopyIconButton";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
@@ -20,31 +19,11 @@ type SettingRowBaseProps = {
|
||||
controlClassName?: string;
|
||||
description?: ReactNode;
|
||||
disabled?: boolean;
|
||||
/** Matched against the surrounding `SettingsList` highlight to point this row out when it opens */
|
||||
highlightKey?: string;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
const SettingsHighlightContext = createContext<string | null>(null);
|
||||
|
||||
const FOCUSABLE =
|
||||
"button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
|
||||
|
||||
export function SettingsList({
|
||||
children,
|
||||
className,
|
||||
highlight = null,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** The `highlightKey` of a row to scroll to, focus, and briefly highlight */
|
||||
highlight?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<SettingsHighlightContext.Provider value={highlight}>
|
||||
<div className={classNames("w-full", className)}>{children}</div>
|
||||
</SettingsHighlightContext.Provider>
|
||||
);
|
||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function SettingsSection({
|
||||
@@ -79,37 +58,17 @@ export function SettingRow({
|
||||
controlClassName,
|
||||
description,
|
||||
disabled,
|
||||
highlightKey,
|
||||
title,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
} & SettingRowBaseProps) {
|
||||
const highlight = useContext(SettingsHighlightContext);
|
||||
const isHighlighted = highlightKey != null && highlightKey === highlight;
|
||||
const [flashing, setFlashing] = useState(isHighlighted);
|
||||
const rowRef = useRef<HTMLDivElement>(null);
|
||||
const controlRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isHighlighted) return;
|
||||
rowRef.current?.scrollIntoView({ block: "nearest" });
|
||||
controlRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
|
||||
}, [isHighlighted]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowRef}
|
||||
aria-disabled={disabled || undefined}
|
||||
onAnimationEnd={(event) => {
|
||||
if (event.target === event.currentTarget) setFlashing(false);
|
||||
}}
|
||||
className={classNames(
|
||||
className,
|
||||
"@container border-b border-border-subtle py-4",
|
||||
disabled && "opacity-disabled",
|
||||
// Drawn on a layer so the tint gets even padding and stops short of the row's border
|
||||
flashing &&
|
||||
"relative isolate before:absolute before:-inset-x-3 before:inset-y-1 before:-z-10 before:rounded-lg before:animate-settingHighlight",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -125,7 +84,6 @@ export function SettingRow({
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={controlRef}
|
||||
className={classNames(
|
||||
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
||||
controlClassName,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
||||
import classNames from "classnames";
|
||||
import { useAtomValue } from "jotai";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { forwardRef, useCallback, useMemo } from "react";
|
||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||
@@ -16,7 +18,6 @@ import { showErrorToast, showToast } from "../../lib/toast";
|
||||
import type { DropdownItem } from "../core/Dropdown";
|
||||
import { Dropdown } from "../core/Dropdown";
|
||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { SyncWorkspaceHint } from "../hints/SyncWorkspaceHint";
|
||||
import { useGitCallbacks } from "./callbacks";
|
||||
import { GitCommitDialog } from "./GitCommitDialog";
|
||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
||||
@@ -30,7 +31,7 @@ export function GitDropdown() {
|
||||
if (workspaceMeta == null) return null;
|
||||
|
||||
if (workspaceMeta.settingSyncDir == null) {
|
||||
return <SyncWorkspaceHint workspaceId={workspaceMeta.workspaceId} />;
|
||||
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
|
||||
}
|
||||
|
||||
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
|
||||
@@ -566,6 +567,64 @@ const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonEle
|
||||
},
|
||||
);
|
||||
|
||||
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
|
||||
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
|
||||
key: "setup_sync",
|
||||
fallback: {},
|
||||
});
|
||||
|
||||
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const banner = (
|
||||
<Banner color="info">
|
||||
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
|
||||
Git collaboration.
|
||||
</Banner>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
fullWidth
|
||||
items={[
|
||||
{
|
||||
type: "content",
|
||||
label: banner,
|
||||
},
|
||||
{
|
||||
color: "success",
|
||||
label: "Open Workspace Settings",
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openWorkspaceSettings("settings"),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Hide This Message",
|
||||
leftSlot: <Icon icon="eye_closed" />,
|
||||
async onSelect() {
|
||||
const confirmed = await showConfirm({
|
||||
id: "hide-sync-menu-prompt",
|
||||
title: "Hide Setup Message",
|
||||
description: "You can configure filesystem sync or Git it in the workspace settings",
|
||||
});
|
||||
if (confirmed) {
|
||||
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<GitMenuButton>
|
||||
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
|
||||
<Icon icon="wrench" />
|
||||
<div className="truncate">Setup FS Sync or Git</div>
|
||||
</div>
|
||||
</GitMenuButton>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
function SetupGitDropdown({
|
||||
workspaceId,
|
||||
initRepo,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { httpRequestsAtom } from "@yaakapp-internal/models";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const MIN_REQUESTS = 2;
|
||||
const DOCS_URL = "https://yaak.app/docs/templating/request-chaining-scripting";
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
/** A successful JSON response, in a workspace with somewhere to send its values. */
|
||||
export function ChainingHint({ response, mimeType }: Props) {
|
||||
const requestCount = useAtomValue(httpRequestsAtom).length;
|
||||
const status = response.status ?? 0;
|
||||
const isJson = mimeType != null && /json/i.test(mimeType);
|
||||
if (response.state !== "closed" || status < 200 || status >= 300) return null;
|
||||
if (!isJson || requestCount < MIN_REQUESTS) return null;
|
||||
|
||||
return (
|
||||
<FeatureHint id="chaining" className="mx-3 mt-1 shrink-0" docsUrl={DOCS_URL}>
|
||||
Need a value from here in another request? Press <InlineCode>Ctrl+Space</InlineCode> in any
|
||||
field and pick <InlineCode>response()</InlineCode> to pull it out with a JSONPath
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { useEnvironmentsBreakdown } from "../../hooks/useEnvironmentsBreakdown";
|
||||
import {
|
||||
addVariableToBaseEnvironment,
|
||||
isTemplate,
|
||||
variableTemplate,
|
||||
} from "../../lib/addVariableToBaseEnvironment";
|
||||
import { dismissFeatureHint, FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const HINT_ID = "secret-variable";
|
||||
const DOCS_URL = "https://yaak.app/docs/templating/environments-and-variables";
|
||||
|
||||
interface Props {
|
||||
/** Becomes the variable name, so it should describe the value (eg. "password", "token") */
|
||||
name: string;
|
||||
value: string;
|
||||
onReplace: (template: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A literal secret typed straight into an auth field. Offer to move it to a variable so the
|
||||
* request references it instead of holding it.
|
||||
*/
|
||||
export function SecretVariableHint({ name, value, onReplace }: Props) {
|
||||
const { baseEnvironment } = useEnvironmentsBreakdown();
|
||||
if (value.trim() === "" || isTemplate(value) || baseEnvironment == null) return null;
|
||||
|
||||
return (
|
||||
<FeatureHint
|
||||
id={HINT_ID}
|
||||
docsUrl={DOCS_URL}
|
||||
action={{
|
||||
label: "Move to Variable",
|
||||
onClick: async () => {
|
||||
const finalName = await addVariableToBaseEnvironment(baseEnvironment, name, value);
|
||||
onReplace(variableTemplate(finalName));
|
||||
await dismissFeatureHint(HINT_ID);
|
||||
},
|
||||
}}
|
||||
>
|
||||
Typing this in by hand? Store it in a variable to reuse it across requests and swap it per
|
||||
environment.
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { httpRequestsAtom, patchModel } from "@yaakapp-internal/models";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useMemo } from "react";
|
||||
import { useEnvironmentsBreakdown } from "../../hooks/useEnvironmentsBreakdown";
|
||||
import {
|
||||
addVariableToBaseEnvironment,
|
||||
isTemplate,
|
||||
variableTemplate,
|
||||
} from "../../lib/addVariableToBaseEnvironment";
|
||||
import { FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const MIN_SHARED_REQUESTS = 3;
|
||||
const DOCS_URL = "https://yaak.app/docs/templating/environments-and-variables";
|
||||
|
||||
/**
|
||||
* Once a few requests spell out the same literal origin, offer to hoist it into a variable and
|
||||
* rewrite them all at once.
|
||||
*/
|
||||
export function SharedOriginHint() {
|
||||
const requests = useAtomValue(httpRequestsAtom);
|
||||
const { baseEnvironment } = useEnvironmentsBreakdown();
|
||||
|
||||
const shared = useMemo(() => {
|
||||
const byOrigin = new Map<string, typeof requests>();
|
||||
for (const r of requests) {
|
||||
if (isTemplate(r.url)) continue;
|
||||
const origin = literalOrigin(r.url);
|
||||
if (origin == null) continue;
|
||||
byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), r]);
|
||||
}
|
||||
let best: { origin: string; requests: typeof requests } | null = null;
|
||||
for (const [origin, group] of byOrigin) {
|
||||
if (group.length >= MIN_SHARED_REQUESTS && group.length > (best?.requests.length ?? 0)) {
|
||||
best = { origin, requests: group };
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}, [requests]);
|
||||
|
||||
if (shared == null || baseEnvironment == null) return null;
|
||||
|
||||
const { origin } = shared;
|
||||
const count = shared.requests.length;
|
||||
|
||||
return (
|
||||
<FeatureHint
|
||||
id={`shared-origin:${origin}`}
|
||||
className="mt-1"
|
||||
docsUrl={DOCS_URL}
|
||||
action={{
|
||||
label: "Use a Variable",
|
||||
onClick: async () => {
|
||||
const name = await addVariableToBaseEnvironment(baseEnvironment, "base_url", origin);
|
||||
const template = variableTemplate(name);
|
||||
await Promise.all(
|
||||
shared.requests.map((r) =>
|
||||
patchModel(r, { url: template + r.url.slice(origin.length) }),
|
||||
),
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{count} requests start with <InlineCode>{origin}</InlineCode>. Put it in a variable and switch
|
||||
hosts by switching environments.
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
|
||||
function literalOrigin(url: string): string | null {
|
||||
const m = /^(https?:\/\/[^/?#\s]+)/i.exec(url.trim());
|
||||
return m?.[1] ?? null;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { VStack } from "@yaakapp-internal/ui";
|
||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||
import { showToast } from "../../lib/toast";
|
||||
import { FeatureHint } from "../core/FeatureHint";
|
||||
|
||||
const DOCS_URL = "https://yaak.app/docs/collaboration/local-directory-git-sync";
|
||||
|
||||
/** What the sidebar's Git slot shows while the workspace only lives in the app's database. */
|
||||
export function SyncWorkspaceHint({ workspaceId }: { workspaceId: string }) {
|
||||
// Hidden from the menu this replaced, so honor it
|
||||
const { value: legacyHidden } = useKeyValue<Record<string, boolean>>({
|
||||
key: "setup_sync",
|
||||
fallback: {},
|
||||
});
|
||||
|
||||
if (legacyHidden == null || legacyHidden[workspaceId]) return null;
|
||||
|
||||
return (
|
||||
<FeatureHint
|
||||
id={`sync-workspace:${workspaceId}`}
|
||||
className="mx-2 mb-2!"
|
||||
docsUrl={DOCS_URL}
|
||||
action={{ label: "Configure", onClick: () => openWorkspaceSettings("settings", "sync_dir") }}
|
||||
onDismiss={showSyncLocationToast}
|
||||
>
|
||||
Sync this workspace to a folder of plain files to back it up or track it with Git.
|
||||
</FeatureHint>
|
||||
);
|
||||
}
|
||||
|
||||
function showSyncLocationToast() {
|
||||
showToast({
|
||||
id: "sync-hint-dismissed",
|
||||
color: "info",
|
||||
timeout: 8000,
|
||||
message: (
|
||||
<VStack>
|
||||
<h2 className="font-semibold">Sync whenever you're ready</h2>
|
||||
<p className="text-text-subtle text-sm">Directory sync and Git are in Workspace Settings</p>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Tooltip } from "../core/Tooltip";
|
||||
|
||||
interface Props {
|
||||
segments: JsonPathSegment[];
|
||||
appliedFilter: string | null;
|
||||
/**
|
||||
* How many leading segments make up the applied filter. `null` when unfiltered, or
|
||||
* when the filter isn't a plain path and is shown as raw text instead.
|
||||
*/
|
||||
appliedDepth: number | null;
|
||||
filterError: boolean;
|
||||
/** Called with the number of leading segments to filter to. `0` clears the filter. */
|
||||
onSelect: (count: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows where the cursor sits inside a response (`$ > sort > [0] > id`). An applied
|
||||
* plain-path filter is the start of the trail, so any crumb moves it. While filtered,
|
||||
* the bar is tinted so a lingering filter is hard to miss.
|
||||
*/
|
||||
export function ResponseBreadcrumbBar({
|
||||
segments,
|
||||
appliedFilter,
|
||||
appliedDepth,
|
||||
filterError,
|
||||
onSelect,
|
||||
}: Props) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [overflow, setOverflow] = useState({ left: false, right: false });
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const maxScroll = el.scrollWidth - el.clientWidth;
|
||||
setOverflow({
|
||||
left: el.scrollLeft > 1,
|
||||
right: el.scrollLeft < maxScroll - 1,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Keep the deepest crumb (where the cursor is) in view as the path changes.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
measure();
|
||||
}, [segments, measure]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [measure]);
|
||||
|
||||
const scrollBy = useCallback((direction: -1 | 1) => {
|
||||
const el = scrollRef.current;
|
||||
if (el == null) return;
|
||||
el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.6), behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
const isFiltered = appliedFilter != null;
|
||||
|
||||
return (
|
||||
<div className="rounded bg-surface shadow pointer-events-auto">
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-stretch h-6 rounded border overflow-hidden font-mono text-xs text-text-subtle select-none",
|
||||
!isFiltered && "border-border-subtle",
|
||||
isFiltered && !filterError && "border-primary/40 bg-primary/10",
|
||||
filterError && "border-danger/40 bg-danger/10",
|
||||
)}
|
||||
>
|
||||
{isFiltered && appliedDepth == null ? (
|
||||
<span
|
||||
className={classNames(
|
||||
"flex items-center min-w-0 truncate px-2",
|
||||
filterError ? "text-danger" : "text-primary",
|
||||
)}
|
||||
>
|
||||
{appliedFilter}
|
||||
</span>
|
||||
) : (
|
||||
<Crumb
|
||||
label="$"
|
||||
tooltip="Clear filter"
|
||||
isApplied={isFiltered}
|
||||
onClick={appliedDepth != null && appliedDepth > 0 ? () => onSelect(0) : null}
|
||||
/>
|
||||
)}
|
||||
{overflow.left && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="chevron_left"
|
||||
title="Scroll breadcrumbs left"
|
||||
iconColor="secondary"
|
||||
onClick={() => scrollBy(-1)}
|
||||
className="shrink-0 h-auto!"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={measure}
|
||||
className="flex items-stretch min-w-0 overflow-x-auto hide-scrollbars whitespace-nowrap"
|
||||
>
|
||||
{segments.map((segment, i) => {
|
||||
const count = i + 1;
|
||||
const isApplied = appliedDepth != null && count <= appliedDepth;
|
||||
const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth;
|
||||
return (
|
||||
<div key={i} className="flex items-stretch shrink-0">
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
size="xs"
|
||||
className="text-text-subtlest shrink-0 self-center"
|
||||
/>
|
||||
<Crumb
|
||||
label={segment.kind === "index" ? `[${segment.index}]` : segment.key}
|
||||
tooltip={
|
||||
segment.kind === "index"
|
||||
? `Filter to element ${segment.index}`
|
||||
: `Filter to ${segment.key}`
|
||||
}
|
||||
isApplied={isApplied}
|
||||
onClick={isClickable ? () => onSelect(count) : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{overflow.right && (
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="chevron_right"
|
||||
title="Scroll breadcrumbs right"
|
||||
iconColor="secondary"
|
||||
onClick={() => scrollBy(1)}
|
||||
className="shrink-0 h-auto!"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Crumb({
|
||||
label,
|
||||
tooltip,
|
||||
isApplied,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
tooltip: string;
|
||||
isApplied: boolean;
|
||||
onClick: (() => void) | null;
|
||||
}) {
|
||||
const outerClassName = classNames("flex items-center px-0.5 py-0.5", isApplied && "text-primary");
|
||||
const innerClassName = "flex items-center h-full px-1 rounded-sm";
|
||||
if (onClick == null) {
|
||||
return (
|
||||
<span className={classNames(outerClassName, "shrink-0")}>
|
||||
<span className={innerClassName}>{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip content={tooltip} className="shrink-0 items-stretch!">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={classNames(outerClassName, "group/crumb focus-visible:outline-none")}
|
||||
>
|
||||
<span
|
||||
className={classNames(
|
||||
innerClassName,
|
||||
"transition-colors group-hover/crumb:text-text group-hover/crumb:bg-surface-highlight",
|
||||
"group-focus-visible/crumb:ring-1 group-focus-visible/crumb:ring-border-focus",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Children, useCallback, useMemo } from "react";
|
||||
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Children, useCallback, useMemo, useState } from "react";
|
||||
import { useFormatText } from "../../hooks/useFormatText";
|
||||
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
|
||||
import { Button } from "../core/Button";
|
||||
import type { EditorProps } from "../core/Editor/Editor";
|
||||
import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension";
|
||||
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
|
||||
import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath";
|
||||
import { hyperlink } from "../core/Editor/hyperlink/extension";
|
||||
import { Editor } from "../core/Editor/LazyEditor";
|
||||
import { IconButton } from "../core/IconButton";
|
||||
import { Input } from "../core/Input";
|
||||
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
|
||||
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
|
||||
|
||||
const extraExtensions = [hyperlink];
|
||||
|
||||
interface Props {
|
||||
text: string;
|
||||
language: EditorProps["language"];
|
||||
@@ -38,12 +38,43 @@ export function TextViewer({
|
||||
filter,
|
||||
filterResult,
|
||||
}: Props) {
|
||||
// Track the JSON path under the cursor to drive the breadcrumb bar. Selection
|
||||
// works even in this read-only editor, so it updates as the user clicks around.
|
||||
const [breadcrumbSegments, setBreadcrumbSegments] = useState<JsonPathSegment[]>([]);
|
||||
const handleBreadcrumbUpdate = useCallback(
|
||||
({ segments }: { segments: JsonPathSegment[] | null }) =>
|
||||
setBreadcrumbSegments((prev) => {
|
||||
const next = segments ?? [];
|
||||
return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next;
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const extraExtensions = useMemo(
|
||||
() =>
|
||||
language === "json"
|
||||
? [hyperlink, jsonBreadcrumbExtension(handleBreadcrumbUpdate)]
|
||||
: [hyperlink],
|
||||
[language, handleBreadcrumbUpdate],
|
||||
);
|
||||
|
||||
const canFilter =
|
||||
filter != null && (language === "json" || language === "xml" || language === "html");
|
||||
const isSearching = filter?.isSearching ?? false;
|
||||
const appliedFilter = filter?.appliedFilter ?? null;
|
||||
const resultError = filterResult?.error ?? false;
|
||||
|
||||
// Filter output is always an array of matches, so under a plain-path filter the
|
||||
// cursor's leading index is that single match and the rest extends the filter.
|
||||
const appliedSegments =
|
||||
appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null;
|
||||
const pathSegments =
|
||||
appliedSegments != null
|
||||
? [...appliedSegments, ...breadcrumbSegments.slice(1)]
|
||||
: breadcrumbSegments;
|
||||
const showBreadcrumbs =
|
||||
filter != null &&
|
||||
(appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0));
|
||||
|
||||
const handleFilterKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (filter == null) return;
|
||||
@@ -155,16 +186,7 @@ export function TextViewer({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
|
||||
{appliedFilter && filter != null ? (
|
||||
<AppliedFilterBar
|
||||
filter={appliedFilter}
|
||||
error={resultError}
|
||||
onClear={() => filter.replaceFilter("")}
|
||||
/>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="relative h-full w-full">
|
||||
<Editor
|
||||
readOnly
|
||||
className={className}
|
||||
@@ -174,45 +196,23 @@ export function TextViewer({
|
||||
extraExtensions={extraExtensions}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
{showBreadcrumbs && (
|
||||
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
|
||||
<ResponseBreadcrumbBar
|
||||
segments={language === "json" ? pathSegments : []}
|
||||
appliedFilter={appliedFilter}
|
||||
appliedDepth={appliedSegments?.length ?? null}
|
||||
filterError={resultError}
|
||||
onSelect={(count) =>
|
||||
filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows what's actually filtering the body, which the filter box below can't convey
|
||||
* once it holds an edited expression that hasn't been applied yet.
|
||||
*/
|
||||
function AppliedFilterBar({
|
||||
filter,
|
||||
error,
|
||||
onClear,
|
||||
}: {
|
||||
filter: string;
|
||||
error: boolean;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
|
||||
<HStack space={2} className="min-w-0">
|
||||
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
|
||||
<span className="truncate min-w-0" title={filter}>
|
||||
Response filtered by <InlineCode>{filter}</InlineCode>
|
||||
{error && " (invalid expression)"}
|
||||
</span>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color={error ? "danger" : "info"}
|
||||
className="ml-auto shrink-0"
|
||||
onClick={onClear}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
/** Convert \uXXXX to actual Unicode characters */
|
||||
function decodeUnicodeLiterals(text: string): string {
|
||||
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
platform: { httpProxyUrl: "https://web.yaak.test" as string | null },
|
||||
showConfirm: vi.fn<() => Promise<boolean>>(),
|
||||
flushAllModelWrites: vi.fn(),
|
||||
rpc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
|
||||
vi.mock("@yaakapp-internal/models", () => ({ flushAllModelWrites: mocks.flushAllModelWrites }));
|
||||
vi.mock("../lib/confirm", () => ({ showConfirm: mocks.showConfirm }));
|
||||
vi.mock("../lib/appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
|
||||
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
|
||||
vi.mock("../lib/toast", () => ({ showToast: vi.fn() }));
|
||||
vi.mock("./useActiveCookieJar", () => ({ getActiveCookieJar: () => ({ id: "cj_test" }) }));
|
||||
vi.mock("./useActiveEnvironment", () => ({ getActiveEnvironment: () => ({ id: "ev_test" }) }));
|
||||
|
||||
async function sender() {
|
||||
return (await import("./useSendAnyHttpRequest")).sendAnyHttpRequest;
|
||||
}
|
||||
|
||||
describe("web proxy confirmation before sending", () => {
|
||||
let stored: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.resetAllMocks();
|
||||
mocks.platform.httpProxyUrl = "https://web.yaak.test";
|
||||
mocks.rpc.mockResolvedValue({ id: "rs_test" });
|
||||
stored = new Map();
|
||||
vi.stubGlobal("localStorage", {
|
||||
getItem: (key: string) => stored.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => stored.set(key, value),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test("blocks all pending sends until one shared confirmation is accepted", async () => {
|
||||
let decide!: (accepted: boolean) => void;
|
||||
mocks.showConfirm.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
decide = resolve;
|
||||
}),
|
||||
);
|
||||
const send = await sender();
|
||||
const first = send.mutateAsync("rq_one");
|
||||
const second = send.mutateAsync("rq_two");
|
||||
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
decide(true);
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.rpc).toHaveBeenCalledWith("cmd_send_http_request", {
|
||||
requestId: "rq_one",
|
||||
environmentId: "ev_test",
|
||||
cookieJarId: "cj_test",
|
||||
});
|
||||
expect(stored.size).toBe(1);
|
||||
});
|
||||
|
||||
test("cancel sends nothing, remembers nothing, and asks again next time", async () => {
|
||||
mocks.showConfirm.mockResolvedValue(false);
|
||||
const send = await sender();
|
||||
expect(await send.mutateAsync("rq_one")).toBeNull();
|
||||
expect(await send.mutateAsync("rq_one")).toBeNull();
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
expect(stored.size).toBe(0);
|
||||
});
|
||||
|
||||
test("cancel applies to every send waiting on the dialog", async () => {
|
||||
mocks.showConfirm.mockResolvedValue(false);
|
||||
const send = await sender();
|
||||
expect(await Promise.all([send.mutateAsync("rq_one"), send.mutateAsync("rq_two")])).toEqual([
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("remembers acceptance across module reloads for the same proxy", async () => {
|
||||
mocks.showConfirm.mockResolvedValue(true);
|
||||
await (await sender()).mutateAsync("rq_one");
|
||||
vi.resetModules();
|
||||
await (await sender()).mutateAsync("rq_two");
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("requires a new decision when the proxy changes", async () => {
|
||||
mocks.showConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
|
||||
const send = await sender();
|
||||
await send.mutateAsync("rq_one");
|
||||
mocks.platform.httpProxyUrl = "https://another-proxy.test";
|
||||
expect(await send.mutateAsync("rq_two")).toBeNull();
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("still asks and remembers in this tab if browser storage is unavailable", async () => {
|
||||
const unavailable = () => {
|
||||
throw new Error("Storage unavailable");
|
||||
};
|
||||
vi.stubGlobal("localStorage", { getItem: unavailable, setItem: unavailable });
|
||||
mocks.showConfirm.mockResolvedValue(true);
|
||||
const send = await sender();
|
||||
await send.mutateAsync("rq_one");
|
||||
await send.mutateAsync("rq_two");
|
||||
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("desktop sends directly without confirmation", async () => {
|
||||
mocks.platform.httpProxyUrl = null;
|
||||
await (await sender()).mutateAsync("rq_one");
|
||||
expect(mocks.showConfirm).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).toHaveBeenCalledTimes(1);
|
||||
expect(stored.size).toBe(0);
|
||||
});
|
||||
|
||||
test("an empty selection does not prompt or send", async () => {
|
||||
expect(await (await sender()).mutateAsync(null)).toBeNull();
|
||||
expect(mocks.showConfirm).not.toHaveBeenCalled();
|
||||
expect(mocks.rpc).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||
import { flushAllModelWrites } from "@yaakapp-internal/models";
|
||||
import { confirmWebProxy } from "../lib/confirmWebProxy";
|
||||
import { rpc } from "../lib/rpc";
|
||||
import { getActiveCookieJar } from "./useActiveCookieJar";
|
||||
import { getActiveEnvironment } from "./useActiveEnvironment";
|
||||
@@ -10,6 +11,10 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!(await confirmWebProxy())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await flushAllModelWrites();
|
||||
|
||||
return rpc("cmd_send_http_request", {
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="icon" type="image/png" href="/icon-128.png" sizes="128x128" />
|
||||
<link rel="apple-touch-icon" href="/icon-128.png" />
|
||||
<title>Yaak App</title>
|
||||
<!-- <script src="http://localhost:8097"></script>-->
|
||||
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import type { Environment } from "@yaakapp-internal/models";
|
||||
import { patchModel } from "@yaakapp-internal/models";
|
||||
import { generateId } from "./generateId";
|
||||
|
||||
/** Adds a variable, suffixing the name if it's taken, and returns the name that was used. */
|
||||
export async function addVariableToBaseEnvironment(
|
||||
environment: Environment,
|
||||
name: string,
|
||||
value: string,
|
||||
): Promise<string> {
|
||||
const taken = new Set(environment.variables.map((v) => v.name));
|
||||
let finalName = name;
|
||||
for (let i = 2; taken.has(finalName); i++) {
|
||||
finalName = `${name}_${i}`;
|
||||
}
|
||||
await patchModel(environment, {
|
||||
variables: [
|
||||
...environment.variables,
|
||||
{ id: generateId(), name: finalName, value, enabled: true },
|
||||
],
|
||||
});
|
||||
return finalName;
|
||||
}
|
||||
|
||||
export function variableTemplate(name: string) {
|
||||
return `\${[ ${name} ]}`;
|
||||
}
|
||||
|
||||
export function isTemplate(value: string) {
|
||||
return value.includes("${[");
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
|
||||
import type { DialogInstance } from "../components/Dialogs";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
platform: { httpProxyUrl: "https://web.yaak.app" },
|
||||
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
|
||||
vi.mock("./appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
|
||||
vi.mock("./dialog", () => ({ showDialog: mocks.showDialog }));
|
||||
vi.mock("../hooks/useHotKey", () => ({
|
||||
useHotKey: vi.fn(),
|
||||
useFormattedHotkey: () => null,
|
||||
}));
|
||||
|
||||
describe("proxy consent disclosure", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal("localStorage", { getItem: () => null });
|
||||
});
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
test.each([
|
||||
["Yaak-hosted", "https://web.yaak.app"],
|
||||
["self-hosted", "https://yaak.example.com"],
|
||||
["local", "http://localhost:8080"],
|
||||
["split deployment", "https://send.example.com/relay"],
|
||||
])("identifies the %s proxy and requires an explicit decision", async (_, proxyUrl) => {
|
||||
mocks.platform.httpProxyUrl = proxyUrl;
|
||||
const { confirmWebProxy } = await import("./confirmWebProxy");
|
||||
void confirmWebProxy();
|
||||
|
||||
const dialog = mocks.showDialog.mock.calls[0]?.[0];
|
||||
expect(dialog).toMatchObject({
|
||||
id: "web-proxy-consent",
|
||||
title: "Requests in Yaak Web use a proxy",
|
||||
size: "sm",
|
||||
disableClose: true,
|
||||
});
|
||||
if (dialog == null) throw new Error("Expected the proxy consent dialog");
|
||||
|
||||
const disclosure = renderToStaticMarkup(<>{dialog.description}</>);
|
||||
expect(disclosure).toContain(`>${proxyUrl}</code>`);
|
||||
expect(disclosure).toMatch(/request and response data/i);
|
||||
expect(disclosure).toContain("credentials");
|
||||
expect(disclosure).toContain("server shown below");
|
||||
expect(disclosure).not.toContain("Yaak’s servers");
|
||||
expect(disclosure).toContain(
|
||||
'href="https://yaak.app/docs/getting-started/web-proxy?ref=app.yaak.web"',
|
||||
);
|
||||
expect(disclosure).toContain('target="_blank"');
|
||||
expect(disclosure).toContain("how the proxy works");
|
||||
|
||||
const Actions = dialog.render;
|
||||
const actions = renderToStaticMarkup(<Actions hide={vi.fn()} />);
|
||||
expect(actions).toMatch(/<button[^>]*type="submit"/);
|
||||
expect(actions).toContain(">Send via Proxy<");
|
||||
expect(actions).toContain(">Cancel<");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import { InlineCode } from "@yaakapp-internal/ui";
|
||||
import { Link } from "../components/core/Link";
|
||||
import { showConfirm } from "./confirm";
|
||||
|
||||
const acceptedProxies = new Set<string>();
|
||||
const pendingConfirmations = new Map<string, Promise<boolean>>();
|
||||
|
||||
/** One decision for all pending sends; only acceptance is remembered. */
|
||||
export function confirmWebProxy(): Promise<boolean> {
|
||||
const proxyUrl = platform.httpProxyUrl;
|
||||
if (proxyUrl == null) return Promise.resolve(true);
|
||||
|
||||
const key = `yaak.webProxyConsent.v1:${proxyUrl}`;
|
||||
try {
|
||||
if (localStorage.getItem(key) === "accepted") return Promise.resolve(true);
|
||||
} catch {
|
||||
// Storage can be unavailable in private or restricted browsing.
|
||||
}
|
||||
if (acceptedProxies.has(proxyUrl)) return Promise.resolve(true);
|
||||
|
||||
const pending = pendingConfirmations.get(proxyUrl);
|
||||
if (pending != null) return pending;
|
||||
|
||||
const confirmation = showConfirm({
|
||||
id: "web-proxy-consent",
|
||||
title: "Requests in Yaak Web use a proxy",
|
||||
confirmText: "Send via Proxy",
|
||||
description: (
|
||||
<div className="space-y-3">
|
||||
<p>
|
||||
Yaak Web sends requests through a hosted proxy. Request and response data, including
|
||||
credentials, pass through the server shown below.
|
||||
</p>
|
||||
<p>
|
||||
<InlineCode className="break-all">{proxyUrl}</InlineCode>
|
||||
</p>
|
||||
<p>
|
||||
Learn more about{" "}
|
||||
<Link href="https://yaak.app/docs/getting-started/web-proxy">how the proxy works</Link>.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
})
|
||||
.then((accepted) => {
|
||||
if (accepted) {
|
||||
acceptedProxies.add(proxyUrl);
|
||||
try {
|
||||
localStorage.setItem(key, "accepted");
|
||||
} catch {
|
||||
// Keep acceptance for this tab if it cannot be persisted.
|
||||
}
|
||||
}
|
||||
return accepted;
|
||||
})
|
||||
.finally(() => pendingConfirmations.delete(proxyUrl));
|
||||
|
||||
pendingConfirmations.set(proxyUrl, confirmation);
|
||||
return confirmation;
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import type { SniffedValue } from "../components/core/Editor/sniffValue";
|
||||
import { isEncodedRun } from "../components/core/Editor/sniffValue";
|
||||
import { copyToClipboard } from "./copy";
|
||||
import { fireAndForget } from "./fireAndForget";
|
||||
import { rpc } from "./rpc";
|
||||
import { showToast } from "./toast";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
|
||||
@@ -212,35 +211,26 @@ export function copyImage(text: string, sniffed: SniffedValue, fallback: string)
|
||||
});
|
||||
}
|
||||
|
||||
/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a collapsed value to a file the user picks.
|
||||
*
|
||||
* A value that is already base64 goes straight to the backend as-is — decoding it here only to
|
||||
* A value that is already base64 is handed over as base64. These are the values
|
||||
* the editor collapsed for being large, so decoding one here only for a host to
|
||||
* encode it again would walk megabytes twice for nothing.
|
||||
*/
|
||||
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
|
||||
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
|
||||
const filepath = await platform.dialog.save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
|
||||
if (filepath == null) {
|
||||
const content =
|
||||
sniffed == null
|
||||
? new TextEncoder().encode(text)
|
||||
: sniffed.encoding === "base64"
|
||||
? { base64: normalizeBase64(payloadOf(text, sniffed)) }
|
||||
: decodeValue(text, sniffed);
|
||||
|
||||
const savedTo = await platform.files.save(`${name}.${ext}`, content);
|
||||
if (savedTo == null) {
|
||||
return; // Cancelled
|
||||
}
|
||||
|
||||
const data =
|
||||
sniffed == null
|
||||
? toBase64(new TextEncoder().encode(text))
|
||||
: sniffed.encoding === "base64"
|
||||
? normalizeBase64(payloadOf(text, sniffed))
|
||||
: toBase64(decodeValue(text, sniffed));
|
||||
|
||||
await rpc("cmd_save_base64_to_binary", { filepath, data });
|
||||
showToast({ message: `Saved to ${filepath}` });
|
||||
showToast({ message: `Saved to ${savedTo}` });
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { BatchUpsertResult } from "@yaakapp-internal/models";
|
||||
import { createGlobalModel } from "@yaakapp-internal/models";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
import { setKeyValue } from "./keyValueStore";
|
||||
|
||||
export type OnboardingChoice =
|
||||
| "import"
|
||||
| "import_yaak"
|
||||
| "example"
|
||||
| "fresh"
|
||||
| "open_folder"
|
||||
| "clone_git";
|
||||
|
||||
const NEW_WORKSPACE_NAME = "My Workspace";
|
||||
|
||||
/**
|
||||
* Remembered so later surfaces can lean toward what the user came for (an importer, the
|
||||
* example, or a blank slate). Nothing reads it yet.
|
||||
*/
|
||||
export function recordOnboardingChoice(choice: OnboardingChoice) {
|
||||
setKeyValue({
|
||||
namespace: "global",
|
||||
key: "onboarding",
|
||||
value: { choice, at: new Date().toISOString() },
|
||||
}).catch(console.error);
|
||||
}
|
||||
|
||||
export async function createFreshWorkspace() {
|
||||
const workspaceId = await createGlobalModel({ model: "workspace", name: NEW_WORKSPACE_NAME });
|
||||
await router.navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } });
|
||||
}
|
||||
|
||||
export async function createExampleWorkspace() {
|
||||
const created = await rpc<BatchUpsertResult>("cmd_create_example_workspace", {});
|
||||
const workspace = created.workspaces[0];
|
||||
if (workspace == null) throw new Error("Example workspace was not created");
|
||||
const firstRequest = created.httpRequests.find((r) => r.name === "List posts");
|
||||
await router.navigate({
|
||||
to: "/workspaces/$workspaceId",
|
||||
params: { workspaceId: workspace.id },
|
||||
search: firstRequest ? { request_id: firstRequest.id } : {},
|
||||
});
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"@codemirror/merge": "^6.11.2",
|
||||
"@codemirror/search": "^6.5.11",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.3.1",
|
||||
"@gilbarbara/deep-equal": "^0.4.1",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.3",
|
||||
"@mjackson/multipart-parser": "^0.10.1",
|
||||
@@ -47,24 +47,24 @@
|
||||
"eventemitter3": "^5.0.1",
|
||||
"focus-trap-react": "^11.0.4",
|
||||
"fuzzbunny": "^1.0.1",
|
||||
"graphql": "^16.13.1",
|
||||
"hexy": "^0.3.5",
|
||||
"graphql": "^17.0.2",
|
||||
"hexy": "^0.4.0",
|
||||
"history": "^5.3.0",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"js-md5": "^0.8.3",
|
||||
"js-md5": "^0.9.2",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"lucide-react": "^1.45.0",
|
||||
"mime": "^4.0.4",
|
||||
"motion": "^12.4.7",
|
||||
"nanoid": "^5.1.16",
|
||||
"motion": "^13.2.0",
|
||||
"nanoid": "^6.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"parse-color": "^1.0.0",
|
||||
"react": "^19.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.0.1",
|
||||
"react-pdf": "^11.0.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-use": "^17.6.1",
|
||||
"rehype-stringify": "^10.0.1",
|
||||
@@ -93,11 +93,9 @@
|
||||
"@yaakapp-internal/theme": "^1.0.0",
|
||||
"@yaakapp-internal/ui": "^1.0.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"internal-ip": "^8.0.0",
|
||||
"rollup": "^4.60.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite-plugin-static-copy": "^3.3.0",
|
||||
"vite-plugin-svgr": "^4.5.0",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.2",
|
||||
"vite-plugin-static-copy": "^4.1.1",
|
||||
"vite-plugin-wasm": "^3.5.0",
|
||||
"vite-plus": "^0.3.0"
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { Onboarding } from "../components/Onboarding";
|
||||
import { RedirectToLatestWorkspace } from "../components/RedirectToLatestWorkspace";
|
||||
|
||||
type IndexSearchSchema = {
|
||||
/** Show the home screen even when workspaces exist. Set by the dev menu. */
|
||||
home?: true;
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: RouteComponent,
|
||||
validateSearch: (search: Record<string, unknown>): IndexSearchSchema =>
|
||||
search.home === true ? { home: true } : {},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return <RedirectToLatestWorkspace />;
|
||||
const { home } = Route.useSearch();
|
||||
return home ? <Onboarding /> : <RedirectToLatestWorkspace />;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// @ts-ignore
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { defineConfig, normalizePath } from "vite-plus";
|
||||
import { viteStaticCopy } from "vite-plugin-static-copy";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import wasm from "vite-plugin-wasm";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -16,6 +16,12 @@ const standardFontsDir = normalizePath(
|
||||
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
|
||||
);
|
||||
|
||||
// The app's own icons, served as the page icon rather than copied into the client:
|
||||
// the bundled app and the tab should not be able to disagree about what Yaak looks like.
|
||||
const iconsDir = normalizePath(
|
||||
path.join(import.meta.dirname, "../../crates-tauri/yaak-app-client/icons/release"),
|
||||
);
|
||||
|
||||
/**
|
||||
* Which host the platform package installs. `web` builds Yaak to run in a plain
|
||||
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
|
||||
@@ -23,6 +29,84 @@ const standardFontsDir = normalizePath(
|
||||
*/
|
||||
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
|
||||
|
||||
const webUrl = "https://web.yaak.app";
|
||||
const webDir = normalizePath(path.join(import.meta.dirname, "web"));
|
||||
|
||||
/** Link previews for the hosted web app. Meaningless inside the desktop window. */
|
||||
function webHead() {
|
||||
return {
|
||||
name: "web-head",
|
||||
transformIndexHtml() {
|
||||
const title = "Yaak in your browser";
|
||||
const description = "No download, no setup. Open a tab and send your first request.";
|
||||
return [
|
||||
{ tag: "meta", attrs: { name: "description", content: description } },
|
||||
{ tag: "meta", attrs: { property: "og:type", content: "website" } },
|
||||
{ tag: "meta", attrs: { property: "og:url", content: webUrl } },
|
||||
{ tag: "meta", attrs: { property: "og:title", content: title } },
|
||||
{ tag: "meta", attrs: { property: "og:description", content: description } },
|
||||
{ tag: "meta", attrs: { property: "og:image", content: `${webUrl}/og.png` } },
|
||||
{ tag: "meta", attrs: { property: "og:image:width", content: "1200" } },
|
||||
{ tag: "meta", attrs: { property: "og:image:height", content: "630" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:card", content: "summary_large_image" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:site", content: "@yaakapp" } },
|
||||
{ tag: "meta", attrs: { name: "twitter:title", content: title } },
|
||||
{ tag: "meta", attrs: { name: "twitter:description", content: description } },
|
||||
{ tag: "meta", attrs: { name: "twitter:image", content: `${webUrl}/og.png` } },
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `yaak-web` is listening, taken from the same variables that put it there.
|
||||
*
|
||||
* `HOST` is an instruction about what the server accepts rather than an address to
|
||||
* dial, so a wildcard becomes loopback: the dev server and the send server share a
|
||||
* machine.
|
||||
*/
|
||||
function sendServerUrl(): string {
|
||||
const host = process.env.HOST?.trim();
|
||||
const port = process.env.PORT?.trim() || "9227";
|
||||
const wildcard = !host || host === "0.0.0.0" || host === "::" || host === "[::]";
|
||||
return `http://${wildcard ? "127.0.0.1" : host}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fails the build when a copied asset is not where the app fetches it from.
|
||||
*
|
||||
* `PdfViewer` and the page head address these by URL, but nothing ties those
|
||||
* URLs to the copy targets below, and a target that mirrors its source path
|
||||
* instead of landing at the root still builds cleanly — the miss only shows up
|
||||
* as a 404 once the app runs.
|
||||
*/
|
||||
function verifyServedAssets(expected: string[]) {
|
||||
let outDir = "";
|
||||
return {
|
||||
name: "verify-served-assets",
|
||||
apply: "build" as const,
|
||||
configResolved(config: { root: string; build: { outDir: string } }) {
|
||||
outDir = path.resolve(config.root, config.build.outDir);
|
||||
},
|
||||
// After viteStaticCopy, which writes on `writeBundle`.
|
||||
closeBundle() {
|
||||
const missing = expected.filter((asset) => {
|
||||
const full = path.join(outDir, asset);
|
||||
if (!fs.existsSync(full)) return true;
|
||||
const stat = fs.statSync(full);
|
||||
return stat.isDirectory() && fs.readdirSync(full).length === 0;
|
||||
});
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Copied assets missing from ${outDir}: ${missing.join(", ")}. ` +
|
||||
`Every viteStaticCopy target needs to strip its base path, or it lands ` +
|
||||
`under a copy of the directories it came from instead.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => {
|
||||
return {
|
||||
@@ -56,14 +140,44 @@ export default defineConfig(async () => {
|
||||
generatedRouteTree: "./routeTree.gen.ts",
|
||||
autoCodeSplitting: true,
|
||||
}),
|
||||
svgr(),
|
||||
react(),
|
||||
viteStaticCopy({
|
||||
// v4 matches only files and always mirrors the source tree into the
|
||||
// output, so every target here needs stripBase to land where it is
|
||||
// actually served from — without it these end up under a copy of the
|
||||
// path they came from, and nothing fails until the request 404s.
|
||||
targets: [
|
||||
{ src: cMapsDir, dest: "" },
|
||||
{ src: standardFontsDir, dest: "" },
|
||||
{ src: `${cMapsDir}/*`, dest: "cmaps", rename: { stripBase: true } },
|
||||
{
|
||||
src: `${standardFontsDir}/*`,
|
||||
dest: "standard_fonts",
|
||||
rename: { stripBase: true },
|
||||
},
|
||||
// `/favicon.ico` is requested by browsers whether or not anything links to it,
|
||||
// so it is served under that name to keep a 404 out of every console.
|
||||
{
|
||||
src: `${iconsDir}/icon.ico`,
|
||||
dest: "",
|
||||
rename: { name: "favicon.ico", stripBase: true },
|
||||
},
|
||||
{
|
||||
src: `${iconsDir}/128x128.png`,
|
||||
dest: "",
|
||||
rename: { name: "icon-128.png", stripBase: true },
|
||||
},
|
||||
...(yaakTarget === "web"
|
||||
? [{ src: `${webDir}/og.png`, dest: "", rename: { stripBase: true } }]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
...(yaakTarget === "web" ? [webHead()] : []),
|
||||
verifyServedAssets([
|
||||
"cmaps",
|
||||
"standard_fonts",
|
||||
"favicon.ico",
|
||||
"icon-128.png",
|
||||
...(yaakTarget === "web" ? ["og.png"] : []),
|
||||
]),
|
||||
],
|
||||
build: {
|
||||
target: "esnext",
|
||||
@@ -84,8 +198,28 @@ export default defineConfig(async () => {
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
// `HOST` names the interface, as it does most places: unset leaves Vite on
|
||||
// loopback, `0.0.0.0` exposes it for reaching the dev server from another
|
||||
// device.
|
||||
host: process.env.HOST,
|
||||
// Vite refuses a `Host` it does not recognise, which stops a page on a name the
|
||||
// attacker controls from rebinding that name here and driving `/v1` as its own
|
||||
// origin. Addresses are allowed already; only names need listing, so reaching
|
||||
// this as `dev-box.example` means naming it. Deliberately not widened to "any
|
||||
// host when exposed": the proxy below leads to an unauthenticated sender.
|
||||
allowedHosts: process.env.ALLOWED_HOSTS?.split(",")
|
||||
.map((h) => h.trim())
|
||||
.filter(Boolean),
|
||||
port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10),
|
||||
strictPort: true,
|
||||
// A web dev server is one origin, the way the built app is: `/v1` is passed
|
||||
// through to `yaak-web` rather than the tab being told to call it directly.
|
||||
// That is one address to open instead of two, no CORS in the loop, and a
|
||||
// dev build that sends exactly the way a production build does.
|
||||
proxy:
|
||||
yaakTarget === "web"
|
||||
? { "/v1": { target: sendServerUrl(), changeOrigin: true } }
|
||||
: undefined,
|
||||
},
|
||||
envPrefix: ["VITE_", "TAURI_"],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<!doctype html>
|
||||
<!--
|
||||
Source for og.png (1200x630). Re-render after editing:
|
||||
cd apps/yaak-client/web && "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --window-size=1200,630 --force-device-scale-factor=1 --virtual-time-budget=10000 --screenshot=og.png "file://$PWD/og.html"
|
||||
-->
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&family=JetBrains+Mono:wght@400;500&display=block"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
width: 1200px;
|
||||
height: 630px;
|
||||
overflow: hidden;
|
||||
}
|
||||
body {
|
||||
font-family: Inter, sans-serif;
|
||||
color: hsl(245, 23%, 85%);
|
||||
background:
|
||||
radial-gradient(900px 600px at 0% 100%, hsl(160, 40%, 16%) 0%, transparent 60%),
|
||||
radial-gradient(900px 600px at 100% 0%, hsl(275, 40%, 20%) 0%, transparent 60%),
|
||||
linear-gradient(135deg, hsl(244, 23%, 12%) 0%, hsl(244, 23%, 15%) 100%);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.dots {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(hsla(245, 23%, 85%, 0.07) 1.2px, transparent 1.2px);
|
||||
background-size: 28px 28px;
|
||||
mask-image: radial-gradient(ellipse at 50% 40%, black 30%, transparent 75%);
|
||||
}
|
||||
.page {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: 56px 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
font-weight: 600;
|
||||
font-size: 26px;
|
||||
}
|
||||
.brand img {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin-top: 52px;
|
||||
font-size: 84px;
|
||||
line-height: 1.05;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
color: hsl(245, 23%, 92%);
|
||||
}
|
||||
h1 em {
|
||||
font-style: normal;
|
||||
color: hsl(48, 80%, 63%);
|
||||
}
|
||||
p {
|
||||
margin-top: 28px;
|
||||
font-size: 34px;
|
||||
line-height: 1.4;
|
||||
font-weight: 500;
|
||||
color: hsl(245, 18%, 68%);
|
||||
max-width: 1040px;
|
||||
}
|
||||
.bar {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
height: 72px;
|
||||
padding: 0 28px;
|
||||
border-radius: 12px;
|
||||
background: hsla(244, 23%, 22%, 0.6);
|
||||
border: 1px solid hsl(244, 23%, 28%);
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
font-size: 28px;
|
||||
box-shadow: 0 10px 40px hsla(244, 40%, 5%, 0.4);
|
||||
}
|
||||
.method {
|
||||
color: hsl(245, 18%, 62%);
|
||||
font-weight: 500;
|
||||
}
|
||||
.url .scheme {
|
||||
color: hsl(245, 18%, 55%);
|
||||
}
|
||||
.url .host {
|
||||
color: hsl(245, 23%, 92%);
|
||||
}
|
||||
.status {
|
||||
margin-left: auto;
|
||||
color: hsl(150, 99%, 44%);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="dots"></div>
|
||||
<div class="page">
|
||||
<div class="brand">
|
||||
<img src="../../../crates-tauri/yaak-app-client/icons/release/128x128@2x.png" alt="" />
|
||||
<span>Yaak</span>
|
||||
</div>
|
||||
<h1>Your favorite API client<br /><em>in your browser</em></h1>
|
||||
<p>No download, no setup. Open a tab and send your first request.</p>
|
||||
<div class="bar">
|
||||
<span class="method">GET</span>
|
||||
<span class="url"><span class="scheme">https://</span><span class="host">web.yaak.app</span></span>
|
||||
<span class="status">200 OK</span>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 295 KiB |
@@ -19,7 +19,7 @@
|
||||
"classnames": "^2.5.1",
|
||||
"jotai": "^2.18.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"motion": "^12.4.7",
|
||||
"motion": "^13.2.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.2",
|
||||
"vite-plus": "^0.3.0"
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,11 @@ include_dir = "0.7"
|
||||
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
log = { workspace = true }
|
||||
rand = "0.8"
|
||||
reqwest = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["form"] }
|
||||
rolldown = "0.1.0"
|
||||
# Pinned exactly: rolldown_resolver takes `oxc_resolver = "^11"`, so cargo
|
||||
# drifts it forward and breaks on 11.11 (TsconfigOptions became
|
||||
# TsconfigDiscovery). This dep exists only to hold that resolution.
|
||||
oxc_resolver = "=11.10.0"
|
||||
schemars = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::context::CliContext;
|
||||
use crate::utils::workspace::resolve_workspace_id;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak_models::export::{self, ExportDataParams};
|
||||
use yaak::import;
|
||||
use yaak_models::util::{
|
||||
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
|
||||
@@ -127,17 +127,61 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
if parts.is_empty() { None } else { Some(parts.join(", ")) }
|
||||
}
|
||||
|
||||
/// Write a file so that afterwards it is either the old one or the whole new one.
|
||||
///
|
||||
/// Writing in place would truncate the previous export before the new one existed, so an
|
||||
/// interrupted run loses both. Instead the bytes go to a neighbouring file, are flushed, and
|
||||
/// take the target's name in one step. The directory is flushed too: `sync_all` on a file
|
||||
/// promises its contents, not that the name it just gained will survive a power cut.
|
||||
fn write_durably(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
|
||||
let dir = match path.parent() {
|
||||
Some(p) if !p.as_os_str().is_empty() => p,
|
||||
_ => std::path::Path::new("."),
|
||||
};
|
||||
// Alongside the target, because a rename is only atomic within one filesystem.
|
||||
let mut name = path.file_name().unwrap_or_default().to_os_string();
|
||||
name.push(format!(".tmp{}", std::process::id()));
|
||||
let tmp = dir.join(name);
|
||||
|
||||
let write = |tmp: &std::path::Path| -> std::io::Result<()> {
|
||||
let mut file = std::fs::File::create(tmp)?;
|
||||
file.write_all(bytes)?;
|
||||
file.sync_all()
|
||||
};
|
||||
if let Err(e) = write(&tmp) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Only Unix lets a directory be opened for this. Elsewhere the rename is as much as
|
||||
// the platform offers, and the file itself is already flushed.
|
||||
#[cfg(unix)]
|
||||
std::fs::File::open(dir)?.sync_all()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
|
||||
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
|
||||
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
|
||||
export::export_data(ExportDataParams {
|
||||
let document = export::export_data(ExportDataParams {
|
||||
query_manager: ctx.query_manager(),
|
||||
yaak_version: env!("CARGO_PKG_VERSION"),
|
||||
export_path: &args.file,
|
||||
workspace_ids: workspace_id_refs,
|
||||
include_private_environments: args.include_private_environments,
|
||||
})
|
||||
.map_err(|e| format!("Failed to export data: {e}"))?;
|
||||
// Flushed before reporting success: an export is a backup, and a backup that the
|
||||
// command called done while it was still only in the page cache is the one case
|
||||
// where saying nothing went wrong is worst.
|
||||
write_durably(&args.file, document.as_bytes())
|
||||
.map_err(|e| format!("Failed to write {}: {e}", args.file.display()))?;
|
||||
|
||||
Ok(workspace_ids.len())
|
||||
}
|
||||
|
||||
@@ -182,6 +182,31 @@ async fn build_plugin_reply(
|
||||
http_request.workspace_id = workspace_id;
|
||||
}
|
||||
|
||||
let environment_id = if let Some(environment_id) =
|
||||
send_http_request_request.environment_id.as_deref()
|
||||
{
|
||||
if shared_workspace_id.is_some_and(|id| id != http_request.workspace_id) {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: "HTTP request does not belong to the selected workspace"
|
||||
.to_string(),
|
||||
}));
|
||||
}
|
||||
match host_context
|
||||
.query_manager
|
||||
.connect()
|
||||
.get_environment_for_workspace(&http_request.workspace_id, environment_id)
|
||||
{
|
||||
Ok(environment) => Some(environment.id),
|
||||
Err(err) => {
|
||||
return Some(InternalEventPayload::ErrorResponse(ErrorResponse {
|
||||
error: err.to_string(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
execution_context.environment_id.clone()
|
||||
};
|
||||
|
||||
let cookie_jar_id =
|
||||
if let Some(cookie_jar_id) = execution_context.cookie_jar_id.clone() {
|
||||
Some(cookie_jar_id)
|
||||
@@ -211,7 +236,7 @@ async fn build_plugin_reply(
|
||||
query_manager: &host_context.query_manager,
|
||||
blob_manager: &host_context.blob_manager,
|
||||
request: http_request,
|
||||
environment_id: execution_context.environment_id.as_deref(),
|
||||
environment_id: environment_id.as_deref(),
|
||||
update_source: UpdateSource::Plugin,
|
||||
cookie_jar_id,
|
||||
response_dir: &host_context.response_dir,
|
||||
@@ -1003,3 +1028,184 @@ fn prompt_label_for_base(base: &yaak_plugins::events::FormInputBase) -> String {
|
||||
}
|
||||
base.name.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod environment_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::time::{Duration, timeout};
|
||||
use yaak_models::models::{EnvironmentVariable, HttpRequest, HttpRequestHeader, Workspace};
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_send_uses_override_and_fallback_and_rejects_invalid_ids_before_sending() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (received_tx, mut received_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
loop {
|
||||
let mut chunk = [0u8; 1024];
|
||||
let n = socket.read(&mut chunk).await.unwrap();
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
request.extend_from_slice(&chunk[..n]);
|
||||
if request.windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let request = String::from_utf8(request).unwrap();
|
||||
eprintln!("Local HTTP test received on {address}:\n{request}");
|
||||
received_tx.send(request).unwrap();
|
||||
socket
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
let (query_manager, blob_manager, _rx) = yaak_models::init_standalone(
|
||||
&dir.path().join("db.sqlite"),
|
||||
&dir.path().join("blobs.sqlite"),
|
||||
)
|
||||
.unwrap();
|
||||
let (base, a, b, request) = query_manager
|
||||
.with_tx(|db| {
|
||||
let source = &UpdateSource::Background;
|
||||
// Fresh databases no longer come with a workspace, so make one
|
||||
let workspace = db.upsert_workspace(
|
||||
&Workspace { name: "Test".into(), ..Default::default() },
|
||||
source,
|
||||
)?;
|
||||
let vars = |value: &str| {
|
||||
vec![EnvironmentVariable {
|
||||
enabled: true,
|
||||
name: "marker".into(),
|
||||
value: value.into(),
|
||||
..Default::default()
|
||||
}]
|
||||
};
|
||||
let base = db.ensure_base_environment(&workspace.id)?;
|
||||
let base = db.upsert_environment(
|
||||
&Environment { variables: vars("global"), ..base },
|
||||
source,
|
||||
)?;
|
||||
let sub = |name: &str| {
|
||||
db.upsert_environment(
|
||||
&Environment {
|
||||
name: name.into(),
|
||||
workspace_id: workspace.id.clone(),
|
||||
parent_model: "environment".into(),
|
||||
parent_id: Some(base.id.clone()),
|
||||
variables: vars(name),
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)
|
||||
};
|
||||
let a = sub("a")?;
|
||||
let b = sub("b")?;
|
||||
let request = db.upsert_http_request(
|
||||
&HttpRequest {
|
||||
workspace_id: workspace.id,
|
||||
url: format!("http://{address}/echo"),
|
||||
method: "GET".into(),
|
||||
headers: vec![HttpRequestHeader {
|
||||
enabled: true,
|
||||
name: "X-Environment".into(),
|
||||
value: "${[ marker ]}".into(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
source,
|
||||
)?;
|
||||
Ok::<_, yaak_models::error::Error>((base, a, b, request))
|
||||
})
|
||||
.unwrap();
|
||||
let plugin_dir = dir.path().join("plugins");
|
||||
std::fs::create_dir_all(&plugin_dir).unwrap();
|
||||
let plugin_manager = Arc::new(
|
||||
PluginManager::new(
|
||||
plugin_dir.clone(),
|
||||
plugin_dir,
|
||||
PathBuf::from("node"),
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"),
|
||||
&query_manager,
|
||||
&PluginContext::new_empty(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let host = CliHostContext {
|
||||
encryption_manager: Arc::new(EncryptionManager::new(
|
||||
query_manager.clone(),
|
||||
"yaak-test",
|
||||
)),
|
||||
query_manager,
|
||||
blob_manager,
|
||||
plugin_manager: plugin_manager.clone(),
|
||||
connection_manager: Arc::new(HttpConnectionManager::new()),
|
||||
response_dir: dir.path().join("responses"),
|
||||
execution_context: CliExecutionContext {
|
||||
workspace_id: Some(base.workspace_id.clone()),
|
||||
environment_id: Some(a.id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
std::fs::create_dir_all(&host.response_dir).unwrap();
|
||||
let event = |environment_id: Option<&str>| -> InternalEvent {
|
||||
let mut payload =
|
||||
json!({ "type": "send_http_request_request", "httpRequest": request });
|
||||
if let Some(id) = environment_id {
|
||||
payload["environmentId"] = json!(id);
|
||||
}
|
||||
serde_json::from_value(json!({
|
||||
"id": "test", "pluginRefId": "test", "pluginName": "test", "replyId": null,
|
||||
"context": PluginContext::new_empty(), "payload": payload
|
||||
}))
|
||||
.unwrap()
|
||||
};
|
||||
for (id, marker) in [
|
||||
(Some(b.id.as_str()), "b"),
|
||||
(None, "a"),
|
||||
(Some(base.id.as_str()), "global"),
|
||||
] {
|
||||
let reply =
|
||||
timeout(Duration::from_secs(10), build_plugin_reply(&host, &event(id), "test"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(reply, Some(InternalEventPayload::SendHttpRequestResponse(ref r)) if r.http_response.status == 200),
|
||||
"{reply:?}"
|
||||
);
|
||||
let received = received_rx.recv().await.unwrap().to_lowercase();
|
||||
assert!(received.contains(&format!("x-environment: {marker}\r\n")), "{received}");
|
||||
assert_eq!(host.execution_context.environment_id.as_deref(), Some(a.id.as_str()));
|
||||
}
|
||||
for id in ["", "ev_missing"] {
|
||||
let reply = build_plugin_reply(&host, &event(Some(id)), "test").await;
|
||||
assert!(matches!(reply, Some(InternalEventPayload::ErrorResponse(_))), "{reply:?}");
|
||||
}
|
||||
assert!(received_rx.try_recv().is_err());
|
||||
assert_eq!(
|
||||
host.query_manager
|
||||
.connect()
|
||||
.list_http_responses_for_request(&request.id, None)
|
||||
.unwrap()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
plugin_manager.terminate().await;
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ impl TestHttpServer {
|
||||
while !shutdown_signal.load(Ordering::Relaxed) {
|
||||
match listener.accept() {
|
||||
Ok((mut stream, _)) => {
|
||||
// macOS and the BSDs hand back accepted sockets carrying the
|
||||
// listener's non-blocking flag, which leaves the read timeout
|
||||
// below with nothing to govern.
|
||||
let _ = stream.set_nonblocking(false);
|
||||
let _ = stream.set_read_timeout(Some(Duration::from_secs(1)));
|
||||
let mut request = Vec::new();
|
||||
let mut request_buf = [0u8; 4096];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "yaak-playground"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
description = "A small, disposable API for trying Yaak against (yaak.run)"
|
||||
|
||||
[[bin]]
|
||||
name = "yaak-playground"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
axum = "0.7"
|
||||
bytes = "1.11.1"
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
env_logger = "0.11"
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "signal", "net"] }
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -0,0 +1,21 @@
|
||||
# yaak-playground
|
||||
|
||||
The API behind [yaak.run](https://yaak.run), for trying Yaak without bringing your own.
|
||||
|
||||
- Users and todos are fixed sample data.
|
||||
- Posts can be created, changed, and deleted. Each client IP gets its own copy, which goes back
|
||||
to the sample data `YAAK_PLAYGROUND_RESET_AFTER_SECS` after its first write.
|
||||
- `POST /auth/token` returns a bearer token for `GET /auth/me`. `POST /auth/session` sets a
|
||||
session cookie for `GET /auth/session`. Any username with the password `yaak` logs in.
|
||||
- `GET /` lists every endpoint and `GET /openapi.json` describes them.
|
||||
|
||||
Nothing is written to disk, so a restart resets everything.
|
||||
|
||||
```sh
|
||||
cargo run -p yaak-playground # http://127.0.0.1:9228
|
||||
```
|
||||
|
||||
Run `yaak-playground --help` for the limits. Behind a load balancer that sets
|
||||
`X-Forwarded-For`, set `YAAK_PLAYGROUND_TRUST_FORWARDED_FOR=true`.
|
||||
|
||||
The image is `Dockerfile.playground` at the repo root.
|
||||
@@ -0,0 +1,185 @@
|
||||
use crate::AppState;
|
||||
use crate::data::{self, User};
|
||||
use crate::error::{ApiError, ApiResult, parse_json};
|
||||
use axum::extract::State;
|
||||
use axum::extract::rejection::BytesRejection;
|
||||
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const PASSWORD: &str = "yaak";
|
||||
const SESSION_COOKIE: &str = "session";
|
||||
const TTL: Duration = Duration::from_secs(60 * 60);
|
||||
const MAX_CREDENTIALS: usize = 10_000;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Bearer,
|
||||
Session,
|
||||
}
|
||||
|
||||
/// Issued tokens and session IDs, kept apart so a token can't be sent as a cookie or the
|
||||
/// other way around.
|
||||
pub struct Credentials {
|
||||
entries: Mutex<HashMap<String, Entry>>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
user_id: u64,
|
||||
kind: Kind,
|
||||
expires: Instant,
|
||||
}
|
||||
|
||||
impl Credentials {
|
||||
pub fn new() -> Self {
|
||||
Self { entries: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
fn issue(&self, user_id: u64, kind: Kind) -> String {
|
||||
let value = uuid::Uuid::new_v4().simple().to_string();
|
||||
let now = Instant::now();
|
||||
let mut entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if entries.len() >= MAX_CREDENTIALS {
|
||||
entries.retain(|_, e| e.expires > now);
|
||||
}
|
||||
if entries.len() >= MAX_CREDENTIALS
|
||||
&& let Some(oldest) =
|
||||
entries.iter().min_by_key(|(_, e)| e.expires).map(|(k, _)| k.clone())
|
||||
{
|
||||
entries.remove(&oldest);
|
||||
}
|
||||
entries.insert(value.clone(), Entry { user_id, kind, expires: now + TTL });
|
||||
value
|
||||
}
|
||||
|
||||
fn lookup(&self, value: &str, kind: Kind) -> Option<&'static User> {
|
||||
let entries = self.entries.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = entries.get(value)?;
|
||||
if entry.kind != kind || entry.expires <= Instant::now() {
|
||||
return None;
|
||||
}
|
||||
data::user(entry.user_id)
|
||||
}
|
||||
|
||||
fn revoke(&self, value: &str) {
|
||||
self.entries.lock().unwrap_or_else(|e| e.into_inner()).remove(value);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Login {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
fn log_in(body: Result<Bytes, BytesRejection>) -> ApiResult<&'static User> {
|
||||
let login: Login = parse_json(body)?;
|
||||
data::user_by_username(&login.username)
|
||||
.filter(|_| login.password == PASSWORD)
|
||||
.ok_or_else(|| ApiError::unauthorized("Invalid username or password", None))
|
||||
}
|
||||
|
||||
pub async fn create_token(
|
||||
State(state): State<AppState>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Json<Value>> {
|
||||
let user = log_in(body)?;
|
||||
let token = state.credentials.issue(user.id, Kind::Bearer);
|
||||
Ok(Json(json!({
|
||||
"accessToken": token,
|
||||
"tokenType": "Bearer",
|
||||
"expiresIn": TTL.as_secs(),
|
||||
})))
|
||||
}
|
||||
|
||||
pub async fn me(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<&'static User>> {
|
||||
let Some(authorization) = headers.get(header::AUTHORIZATION).and_then(|v| v.to_str().ok())
|
||||
else {
|
||||
return Err(ApiError::unauthorized("Missing Authorization header", Some("Bearer")));
|
||||
};
|
||||
let token = match authorization.split_once(' ') {
|
||||
Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => token.trim(),
|
||||
_ => {
|
||||
return Err(ApiError::unauthorized(
|
||||
"Authorization header must be \"Bearer <token>\"",
|
||||
Some("Bearer"),
|
||||
));
|
||||
}
|
||||
};
|
||||
state.credentials.lookup(token, Kind::Bearer).map(Json).ok_or_else(|| {
|
||||
ApiError::unauthorized("Invalid or expired token", Some("Bearer error=\"invalid_token\""))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Response> {
|
||||
let user = log_in(body)?;
|
||||
let id = state.credentials.issue(user.id, Kind::Session);
|
||||
let cookie = format!(
|
||||
"{SESSION_COOKIE}={id}; Path=/; Max-Age={}; HttpOnly; SameSite=Lax{}",
|
||||
TTL.as_secs(),
|
||||
secure_attribute(&headers)
|
||||
);
|
||||
let mut res = Json(user).into_response();
|
||||
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub async fn session(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
) -> ApiResult<Json<&'static User>> {
|
||||
let Some(id) = session_cookie(&headers) else {
|
||||
return Err(ApiError::unauthorized("Missing session cookie", None));
|
||||
};
|
||||
state
|
||||
.credentials
|
||||
.lookup(id, Kind::Session)
|
||||
.map(Json)
|
||||
.ok_or_else(|| ApiError::unauthorized("Invalid or expired session", None))
|
||||
}
|
||||
|
||||
pub async fn delete_session(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||
if let Some(id) = session_cookie(&headers) {
|
||||
state.credentials.revoke(id);
|
||||
}
|
||||
let cookie = format!(
|
||||
"{SESSION_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax{}",
|
||||
secure_attribute(&headers)
|
||||
);
|
||||
let mut res = StatusCode::NO_CONTENT.into_response();
|
||||
res.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).expect("ascii"));
|
||||
res
|
||||
}
|
||||
|
||||
fn session_cookie(headers: &HeaderMap) -> Option<&str> {
|
||||
headers
|
||||
.get_all(header::COOKIE)
|
||||
.iter()
|
||||
.filter_map(|v| v.to_str().ok())
|
||||
.flat_map(|v| v.split(';'))
|
||||
.filter_map(|pair| pair.trim().split_once('='))
|
||||
.find(|(name, _)| *name == SESSION_COOKIE)
|
||||
.map(|(_, value)| value)
|
||||
}
|
||||
|
||||
/// `Secure` only when the request reached the proxy over HTTPS, so the cookie still works
|
||||
/// against a local instance on plain HTTP.
|
||||
fn secure_attribute(headers: &HeaderMap) -> &'static str {
|
||||
let https = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.eq_ignore_ascii_case("https"));
|
||||
if https { "; Secure" } else { "" }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use clap::Parser;
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
|
||||
/// A small, disposable API for trying Yaak against.
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "yaak-playground", version, about, long_about = None)]
|
||||
pub struct Config {
|
||||
/// Interface to listen on. 0.0.0.0 inside a container.
|
||||
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
|
||||
pub host: String,
|
||||
|
||||
#[arg(long, env = "PORT", default_value_t = 9228)]
|
||||
pub port: u16,
|
||||
|
||||
/// Requests allowed per client IP per minute. 0 disables the limit.
|
||||
#[arg(
|
||||
long,
|
||||
env = "YAAK_PLAYGROUND_RATE_LIMIT_PER_MINUTE",
|
||||
default_value_t = 300
|
||||
)]
|
||||
pub rate_limit_per_minute: u32,
|
||||
|
||||
/// Take the client IP from `X-Forwarded-For` (first hop) instead of the socket. Only behind
|
||||
/// a load balancer that sets the header.
|
||||
#[arg(
|
||||
long,
|
||||
env = "YAAK_PLAYGROUND_TRUST_FORWARDED_FOR",
|
||||
default_value_t = false
|
||||
)]
|
||||
pub trust_forwarded_for: bool,
|
||||
|
||||
/// Seconds after a client's first write before its posts go back to the sample data.
|
||||
#[arg(long, env = "YAAK_PLAYGROUND_RESET_AFTER_SECS", default_value_t = 3600)]
|
||||
pub reset_after_secs: u64,
|
||||
|
||||
/// Clients whose writes are kept at once. The oldest is reset early to make room.
|
||||
#[arg(long, env = "YAAK_PLAYGROUND_MAX_CLIENTS", default_value_t = 2000)]
|
||||
pub max_clients: usize,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "YAAK_PLAYGROUND_MAX_POSTS_PER_CLIENT",
|
||||
default_value_t = 100
|
||||
)]
|
||||
pub max_posts_per_client: usize,
|
||||
|
||||
#[arg(long, env = "YAAK_PLAYGROUND_MAX_REQUEST_BYTES", default_value_t = 64 * 1024)]
|
||||
pub max_request_bytes: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn listen_addr(&self) -> Result<SocketAddr, String> {
|
||||
(self.host.as_str(), self.port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("HOST {:?} resolved to no address", self.host))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::LazyLock;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct User {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Post {
|
||||
pub id: u64,
|
||||
pub user_id: u64,
|
||||
pub title: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Todo {
|
||||
pub id: u64,
|
||||
pub user_id: u64,
|
||||
pub title: String,
|
||||
pub completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Seed {
|
||||
users: Vec<User>,
|
||||
posts: Vec<Post>,
|
||||
todos: Vec<Todo>,
|
||||
}
|
||||
|
||||
static SEED: LazyLock<Seed> =
|
||||
LazyLock::new(|| serde_json::from_str(include_str!("seed.json")).expect("valid seed.json"));
|
||||
|
||||
pub fn users() -> &'static [User] {
|
||||
&SEED.users
|
||||
}
|
||||
|
||||
pub fn posts() -> &'static [Post] {
|
||||
&SEED.posts
|
||||
}
|
||||
|
||||
pub fn todos() -> &'static [Todo] {
|
||||
&SEED.todos
|
||||
}
|
||||
|
||||
pub fn user(id: u64) -> Option<&'static User> {
|
||||
users().iter().find(|u| u.id == id)
|
||||
}
|
||||
|
||||
pub fn user_by_username(username: &str) -> Option<&'static User> {
|
||||
users().iter().find(|u| u.username == username)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use axum::extract::rejection::BytesRejection;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use bytes::Bytes;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
|
||||
pub type ApiResult<T> = Result<T, ApiError>;
|
||||
|
||||
pub struct ApiError {
|
||||
status: StatusCode,
|
||||
message: String,
|
||||
challenge: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
|
||||
Self { status, message: message.into(), challenge: None }
|
||||
}
|
||||
|
||||
pub fn bad_request(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::BAD_REQUEST, message)
|
||||
}
|
||||
|
||||
pub fn not_found(message: impl Into<String>) -> Self {
|
||||
Self::new(StatusCode::NOT_FOUND, message)
|
||||
}
|
||||
|
||||
/// `challenge` becomes the `WWW-Authenticate` header.
|
||||
pub fn unauthorized(message: impl Into<String>, challenge: Option<&'static str>) -> Self {
|
||||
Self { status: StatusCode::UNAUTHORIZED, message: message.into(), challenge }
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiError {
|
||||
fn into_response(self) -> Response {
|
||||
let mut res = (self.status, Json(json!({ "error": self.message }))).into_response();
|
||||
if let Some(challenge) = self.challenge {
|
||||
res.headers_mut().insert(header::WWW_AUTHENTICATE, HeaderValue::from_static(challenge));
|
||||
}
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_id(raw: &str, noun: &str) -> ApiResult<u64> {
|
||||
raw.parse().map_err(|_| ApiError::not_found(format!("No {noun} with ID {raw}")))
|
||||
}
|
||||
|
||||
/// Taken as a `Result` so an oversized body gets a JSON error like everything else.
|
||||
pub fn parse_json<T: DeserializeOwned>(body: Result<Bytes, BytesRejection>) -> ApiResult<T> {
|
||||
let body = body.map_err(|e| ApiError::new(e.status(), e.body_text()))?;
|
||||
if body.is_empty() {
|
||||
return Err(ApiError::bad_request("Request body must be JSON"));
|
||||
}
|
||||
serde_json::from_slice(&body)
|
||||
.map_err(|e| ApiError::bad_request(format!("Request body is not valid: {e}")))
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! yaak-playground: the API behind yaak.run, for trying Yaak without bringing your own.
|
||||
//!
|
||||
//! Reads come from fixed sample data. Writes are kept per client IP and reset after a while,
|
||||
//! so nothing one caller sends is ever served to another. Nothing touches disk.
|
||||
|
||||
mod auth;
|
||||
mod config;
|
||||
mod data;
|
||||
mod error;
|
||||
mod limits;
|
||||
mod resources;
|
||||
mod store;
|
||||
|
||||
use axum::Router;
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
pub use config::Config;
|
||||
use error::ApiError;
|
||||
use limits::RateLimiter;
|
||||
use serde_json::{Value, json};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use store::PostStore;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
|
||||
const OPENAPI: &str = include_str!("openapi.json");
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
posts: Arc<PostStore>,
|
||||
credentials: Arc<auth::Credentials>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ClientIp(IpAddr);
|
||||
|
||||
/// Serve with `into_make_service_with_connect_info::<SocketAddr>()`; the client's address
|
||||
/// keys both the rate limit and its private copy of the posts.
|
||||
pub fn router(config: Config) -> Router {
|
||||
let state = AppState {
|
||||
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
|
||||
posts: Arc::new(PostStore::new(
|
||||
Duration::from_secs(config.reset_after_secs),
|
||||
config.max_clients,
|
||||
config.max_posts_per_client,
|
||||
)),
|
||||
credentials: Arc::new(auth::Credentials::new()),
|
||||
config: Arc::new(config),
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(AllowOrigin::any())
|
||||
.allow_methods([
|
||||
Method::GET,
|
||||
Method::POST,
|
||||
Method::PUT,
|
||||
Method::PATCH,
|
||||
Method::DELETE,
|
||||
Method::OPTIONS,
|
||||
])
|
||||
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||
.expose_headers([
|
||||
header::LOCATION,
|
||||
header::RETRY_AFTER,
|
||||
header::WWW_AUTHENTICATE,
|
||||
]);
|
||||
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/health", get(health))
|
||||
.route("/openapi.json", get(openapi))
|
||||
.route("/users", get(resources::list_users))
|
||||
.route("/users/:id", get(resources::get_user))
|
||||
.route("/users/:id/todos", get(resources::list_user_todos))
|
||||
.route("/todos", get(resources::list_todos))
|
||||
.route("/posts", get(resources::list_posts).post(resources::create_post))
|
||||
.route(
|
||||
"/posts/:id",
|
||||
get(resources::get_post)
|
||||
.put(resources::replace_post)
|
||||
.patch(resources::update_post)
|
||||
.delete(resources::delete_post),
|
||||
)
|
||||
.route("/auth/token", post(auth::create_token))
|
||||
.route("/auth/me", get(auth::me))
|
||||
.route(
|
||||
"/auth/session",
|
||||
post(auth::create_session).get(auth::session).delete(auth::delete_session),
|
||||
)
|
||||
.fallback(not_found)
|
||||
.layer(middleware::from_fn_with_state(state.clone(), identify_client))
|
||||
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
|
||||
.layer(cors)
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
async fn identify_client(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let ip = client_ip(&state.config, req.headers(), peer);
|
||||
if let Err(wait) = state.rate_limiter.check(ip) {
|
||||
let secs = wait.as_secs().max(1);
|
||||
let mut res = ApiError::new(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("Rate limit reached. Try again in {secs}s"),
|
||||
)
|
||||
.into_response();
|
||||
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(secs));
|
||||
return res;
|
||||
}
|
||||
req.extensions_mut().insert(ClientIp(ip));
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
if config.trust_forwarded_for
|
||||
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(first) = forwarded.split(',').next()
|
||||
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
peer.ip()
|
||||
}
|
||||
|
||||
async fn index(State(state): State<AppState>) -> Json<Value> {
|
||||
let spec: Value = serde_json::from_str(OPENAPI).expect("valid openapi.json");
|
||||
Json(json!({
|
||||
"name": "Yaak Playground",
|
||||
"description": "A small API for trying out Yaak. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.",
|
||||
"resetAfterSeconds": state.config.reset_after_secs,
|
||||
"login": { "username": "ada", "password": auth::PASSWORD },
|
||||
"openapi": "/openapi.json",
|
||||
"endpoints": operations(&spec),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Every `METHOD /path` the spec documents.
|
||||
fn operations(spec: &Value) -> Vec<String> {
|
||||
let mut ops = Vec::new();
|
||||
for (path, item) in spec["paths"].as_object().into_iter().flatten() {
|
||||
for method in ["get", "post", "put", "patch", "delete"] {
|
||||
if item.get(method).is_some() {
|
||||
ops.push(format!("{} {path}", method.to_uppercase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
ops
|
||||
}
|
||||
|
||||
async fn health() -> Json<Value> {
|
||||
Json(json!({ "ok": true, "version": env!("CARGO_PKG_VERSION") }))
|
||||
}
|
||||
|
||||
async fn openapi() -> Response {
|
||||
([(header::CONTENT_TYPE, "application/json")], OPENAPI).into_response()
|
||||
}
|
||||
|
||||
async fn not_found() -> ApiError {
|
||||
ApiError::not_found("Not found. GET / lists every endpoint")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One token bucket per client IP, refilled continuously.
|
||||
pub struct RateLimiter {
|
||||
per_minute: u32,
|
||||
buckets: Mutex<HashMap<IpAddr, Bucket>>,
|
||||
}
|
||||
|
||||
struct Bucket {
|
||||
tokens: f64,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// `per_minute == 0` disables limiting.
|
||||
pub fn new(per_minute: u32) -> Self {
|
||||
Self { per_minute, buckets: Mutex::new(HashMap::new()) }
|
||||
}
|
||||
|
||||
/// Take one token for `client`, or say how long until one is available.
|
||||
pub fn check(&self, client: IpAddr) -> Result<(), Duration> {
|
||||
if self.per_minute == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
let capacity = self.per_minute as f64;
|
||||
let per_second = capacity / 60.0;
|
||||
let now = Instant::now();
|
||||
|
||||
let mut buckets = self.buckets.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if buckets.len() > 1024 {
|
||||
buckets.retain(|_, b| now.duration_since(b.last).as_secs_f64() * per_second < capacity);
|
||||
}
|
||||
|
||||
let bucket = buckets.entry(client).or_insert(Bucket { tokens: capacity, last: now });
|
||||
let elapsed = now.duration_since(bucket.last).as_secs_f64();
|
||||
bucket.tokens = (bucket.tokens + elapsed * per_second).min(capacity);
|
||||
bucket.last = now;
|
||||
|
||||
if bucket.tokens >= 1.0 {
|
||||
bucket.tokens -= 1.0;
|
||||
Ok(())
|
||||
} else {
|
||||
let wait = (1.0 - bucket.tokens) / per_second;
|
||||
Err(Duration::from_secs_f64(wait.max(0.001)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
use clap::Parser;
|
||||
use log::info;
|
||||
use std::net::SocketAddr;
|
||||
use yaak_playground::{Config, router};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
let config = Config::parse();
|
||||
let bind = config.listen_addr().unwrap_or_else(|e| {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let app = router(config);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to bind {bind}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
info!("yaak-playground listening on http://{bind}");
|
||||
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
info!("Shutting down");
|
||||
})
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "Yaak Playground",
|
||||
"version": "1.0.0",
|
||||
"description": "A small API for trying out Yaak. Users and todos are fixed sample data. Posts you create, change, or delete are only visible to you, and go back to the sample data after a while.\n\nLog in with any username (like `ada`) and the password `yaak`."
|
||||
},
|
||||
"servers": [{ "url": "https://yaak.run" }],
|
||||
"tags": [
|
||||
{ "name": "Users" },
|
||||
{ "name": "Posts" },
|
||||
{ "name": "Todos" },
|
||||
{ "name": "Auth", "description": "Two ways to stay logged in: a bearer token you send yourself, or a session cookie the cookie jar sends for you." }
|
||||
],
|
||||
"paths": {
|
||||
"/users": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"operationId": "listUsers",
|
||||
"summary": "List users",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Every user",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/User" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"tags": ["Users"],
|
||||
"operationId": "getUser",
|
||||
"summary": "Get a user",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/{id}/todos": {
|
||||
"get": {
|
||||
"tags": ["Users", "Todos"],
|
||||
"operationId": "listUserTodos",
|
||||
"summary": "List a user's todos",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/Id" },
|
||||
{ "$ref": "#/components/parameters/Completed" },
|
||||
{ "$ref": "#/components/parameters/Limit" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The user's todos",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/todos": {
|
||||
"get": {
|
||||
"tags": ["Todos"],
|
||||
"operationId": "listTodos",
|
||||
"summary": "List todos",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/UserId" },
|
||||
{ "$ref": "#/components/parameters/Completed" },
|
||||
{ "$ref": "#/components/parameters/Limit" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Matching todos",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Todo" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/posts": {
|
||||
"get": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "listPosts",
|
||||
"summary": "List posts",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/UserId" },
|
||||
{ "$ref": "#/components/parameters/Limit" }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Matching posts",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": { "type": "array", "items": { "$ref": "#/components/schemas/Post" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" }
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "createPost",
|
||||
"summary": "Create a post",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "The new post",
|
||||
"headers": {
|
||||
"Location": { "description": "Where to fetch the new post", "schema": { "type": "string" } }
|
||||
},
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"409": {
|
||||
"description": "You already have the most posts allowed",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/posts/{id}": {
|
||||
"get": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "getPost",
|
||||
"summary": "Get a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The post",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "replacePost",
|
||||
"summary": "Replace a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostInput" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The updated post",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "updatePost",
|
||||
"summary": "Update part of a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/PostPatch" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The updated post",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Post" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Posts"],
|
||||
"operationId": "deletePost",
|
||||
"summary": "Delete a post",
|
||||
"parameters": [{ "$ref": "#/components/parameters/Id" }],
|
||||
"responses": {
|
||||
"204": { "description": "Deleted" },
|
||||
"404": { "$ref": "#/components/responses/NotFound" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/token": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "createToken",
|
||||
"summary": "Log in for a bearer token",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "A token to send as `Authorization: Bearer <token>`",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Token" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/me": {
|
||||
"get": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "getMe",
|
||||
"summary": "Get the user a bearer token belongs to",
|
||||
"security": [{ "bearerAuth": [] }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The logged-in user",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/session": {
|
||||
"post": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "createSession",
|
||||
"summary": "Log in with a session cookie",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Login" } } }
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The logged-in user. The session cookie comes back in `Set-Cookie`.",
|
||||
"headers": {
|
||||
"Set-Cookie": { "description": "The `session` cookie", "schema": { "type": "string" } }
|
||||
},
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"400": { "$ref": "#/components/responses/BadRequest" },
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
},
|
||||
"get": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "getSession",
|
||||
"summary": "Get the user a session cookie belongs to",
|
||||
"security": [{ "cookieAuth": [] }],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The logged-in user",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/User" } } }
|
||||
},
|
||||
"401": { "$ref": "#/components/responses/Unauthorized" }
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["Auth"],
|
||||
"operationId": "deleteSession",
|
||||
"summary": "Log out",
|
||||
"responses": {
|
||||
"204": { "description": "Logged out, and the cookie is cleared" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"parameters": {
|
||||
"Id": {
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": { "type": "integer", "minimum": 1 },
|
||||
"example": 1
|
||||
},
|
||||
"UserId": {
|
||||
"name": "userId",
|
||||
"in": "query",
|
||||
"description": "Only return items belonging to this user",
|
||||
"schema": { "type": "integer", "minimum": 1 }
|
||||
},
|
||||
"Completed": {
|
||||
"name": "completed",
|
||||
"in": "query",
|
||||
"schema": { "type": "boolean" }
|
||||
},
|
||||
"Limit": {
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"description": "Return at most this many items",
|
||||
"schema": { "type": "integer", "minimum": 0 }
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"BadRequest": {
|
||||
"description": "Something in the request is invalid",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"NotFound": {
|
||||
"description": "Nothing has that ID",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
},
|
||||
"Unauthorized": {
|
||||
"description": "Missing or invalid credentials",
|
||||
"content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
|
||||
}
|
||||
},
|
||||
"schemas": {
|
||||
"User": {
|
||||
"type": "object",
|
||||
"required": ["id", "name", "username", "email"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "example": 1 },
|
||||
"name": { "type": "string", "example": "Ada Lovelace" },
|
||||
"username": { "type": "string", "example": "ada" },
|
||||
"email": { "type": "string", "format": "email", "example": "ada@example.com" }
|
||||
}
|
||||
},
|
||||
"Post": {
|
||||
"type": "object",
|
||||
"required": ["id", "userId", "title", "body"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "example": 1 },
|
||||
"userId": { "type": "integer", "example": 1 },
|
||||
"title": { "type": "string" },
|
||||
"body": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"PostInput": {
|
||||
"type": "object",
|
||||
"required": ["userId", "title", "body"],
|
||||
"properties": {
|
||||
"userId": { "type": "integer", "example": 1 },
|
||||
"title": { "type": "string", "maxLength": 200, "example": "Hello from Yaak" },
|
||||
"body": { "type": "string", "maxLength": 2000, "example": "My first post" }
|
||||
}
|
||||
},
|
||||
"PostPatch": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"userId": { "type": "integer" },
|
||||
"title": { "type": "string", "maxLength": 200 },
|
||||
"body": { "type": "string", "maxLength": 2000 }
|
||||
}
|
||||
},
|
||||
"Todo": {
|
||||
"type": "object",
|
||||
"required": ["id", "userId", "title", "completed"],
|
||||
"properties": {
|
||||
"id": { "type": "integer", "example": 1 },
|
||||
"userId": { "type": "integer", "example": 1 },
|
||||
"title": { "type": "string" },
|
||||
"completed": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"Login": {
|
||||
"type": "object",
|
||||
"required": ["username", "password"],
|
||||
"properties": {
|
||||
"username": { "type": "string", "example": "ada" },
|
||||
"password": { "type": "string", "example": "yaak" }
|
||||
}
|
||||
},
|
||||
"Token": {
|
||||
"type": "object",
|
||||
"required": ["accessToken", "tokenType", "expiresIn"],
|
||||
"properties": {
|
||||
"accessToken": { "type": "string" },
|
||||
"tokenType": { "type": "string", "const": "Bearer" },
|
||||
"expiresIn": { "type": "integer", "description": "Seconds until the token expires", "example": 3600 }
|
||||
}
|
||||
},
|
||||
"Error": {
|
||||
"type": "object",
|
||||
"required": ["error"],
|
||||
"properties": { "error": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"securitySchemes": {
|
||||
"bearerAuth": { "type": "http", "scheme": "bearer" },
|
||||
"cookieAuth": { "type": "apiKey", "in": "cookie", "name": "session" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use crate::data::{self, Post, Todo, User};
|
||||
use crate::error::{ApiError, ApiResult, parse_id, parse_json};
|
||||
use crate::{AppState, ClientIp};
|
||||
use axum::extract::rejection::BytesRejection;
|
||||
use axum::extract::{Extension, Path, Query, State};
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const MAX_TITLE_CHARS: usize = 200;
|
||||
const MAX_BODY_CHARS: usize = 2000;
|
||||
|
||||
type Params = Query<HashMap<String, String>>;
|
||||
|
||||
struct Filters {
|
||||
user_id: Option<u64>,
|
||||
completed: Option<bool>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
fn filters(params: &HashMap<String, String>) -> ApiResult<Filters> {
|
||||
fn parse<T: std::str::FromStr>(
|
||||
params: &HashMap<String, String>,
|
||||
name: &str,
|
||||
expected: &str,
|
||||
) -> ApiResult<Option<T>> {
|
||||
params
|
||||
.get(name)
|
||||
.map(|v| {
|
||||
v.parse().map_err(|_| {
|
||||
ApiError::bad_request(format!("Query parameter `{name}` must be {expected}"))
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
Ok(Filters {
|
||||
user_id: parse(params, "userId", "a user ID")?,
|
||||
completed: parse(params, "completed", "true or false")?,
|
||||
limit: parse(params, "limit", "a number")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_users() -> Json<&'static [User]> {
|
||||
Json(data::users())
|
||||
}
|
||||
|
||||
pub async fn get_user(Path(id): Path<String>) -> ApiResult<Json<&'static User>> {
|
||||
let user_id = parse_id(&id, "user")?;
|
||||
data::user(user_id)
|
||||
.map(Json)
|
||||
.ok_or_else(|| ApiError::not_found(format!("No user with ID {id}")))
|
||||
}
|
||||
|
||||
pub async fn list_todos(Query(params): Params) -> ApiResult<Json<Vec<&'static Todo>>> {
|
||||
let f = filters(¶ms)?;
|
||||
Ok(Json(select_todos(f.user_id, f.completed, f.limit)))
|
||||
}
|
||||
|
||||
pub async fn list_user_todos(
|
||||
Path(id): Path<String>,
|
||||
Query(params): Params,
|
||||
) -> ApiResult<Json<Vec<&'static Todo>>> {
|
||||
let user = get_user(Path(id)).await?;
|
||||
let f = filters(¶ms)?;
|
||||
Ok(Json(select_todos(Some(user.id), f.completed, f.limit)))
|
||||
}
|
||||
|
||||
fn select_todos(
|
||||
user_id: Option<u64>,
|
||||
completed: Option<bool>,
|
||||
limit: Option<usize>,
|
||||
) -> Vec<&'static Todo> {
|
||||
data::todos()
|
||||
.iter()
|
||||
.filter(|t| user_id.is_none_or(|id| t.user_id == id))
|
||||
.filter(|t| completed.is_none_or(|c| t.completed == c))
|
||||
.take(limit.unwrap_or(usize::MAX))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn list_posts(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Query(params): Params,
|
||||
) -> ApiResult<Json<Vec<Post>>> {
|
||||
let f = filters(¶ms)?;
|
||||
let posts = state.posts.read(ip, |posts| {
|
||||
posts
|
||||
.iter()
|
||||
.filter(|p| f.user_id.is_none_or(|id| p.user_id == id))
|
||||
.take(f.limit.unwrap_or(usize::MAX))
|
||||
.cloned()
|
||||
.collect()
|
||||
});
|
||||
Ok(Json(posts))
|
||||
}
|
||||
|
||||
pub async fn get_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<Json<Post>> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
state
|
||||
.posts
|
||||
.read(ip, |posts| posts.iter().find(|p| p.id == post_id).cloned())
|
||||
.map(Json)
|
||||
.ok_or_else(|| post_not_found(&id))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct NewPost {
|
||||
user_id: u64,
|
||||
title: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct PostPatch {
|
||||
user_id: Option<u64>,
|
||||
title: Option<String>,
|
||||
body: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn create_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Response> {
|
||||
let input: NewPost = parse_json(body)?;
|
||||
validate(input.user_id, &input.title, &input.body)?;
|
||||
let max = state.posts.max_posts;
|
||||
let post = state.posts.write(ip, |client| {
|
||||
if client.posts.len() >= max {
|
||||
return Err(ApiError::new(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"You can have at most {max} posts. Delete one first, or wait for the reset"
|
||||
),
|
||||
));
|
||||
}
|
||||
Ok(client.insert(input.user_id, input.title, input.body))
|
||||
})?;
|
||||
let location = HeaderValue::from_str(&format!("/posts/{}", post.id)).expect("ascii");
|
||||
Ok((StatusCode::CREATED, [(header::LOCATION, location)], Json(post)).into_response())
|
||||
}
|
||||
|
||||
pub async fn replace_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Json<Post>> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
let input: NewPost = parse_json(body)?;
|
||||
validate(input.user_id, &input.title, &input.body)?;
|
||||
state.posts.write(ip, |client| {
|
||||
let post =
|
||||
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
|
||||
post.user_id = input.user_id;
|
||||
post.title = input.title;
|
||||
post.body = input.body;
|
||||
Ok(Json(post.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn update_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
body: Result<Bytes, BytesRejection>,
|
||||
) -> ApiResult<Json<Post>> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
let patch: PostPatch = parse_json(body)?;
|
||||
state.posts.write(ip, |client| {
|
||||
let post =
|
||||
client.posts.iter_mut().find(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
|
||||
let user_id = patch.user_id.unwrap_or(post.user_id);
|
||||
let title = patch.title.unwrap_or_else(|| post.title.clone());
|
||||
let body = patch.body.unwrap_or_else(|| post.body.clone());
|
||||
validate(user_id, &title, &body)?;
|
||||
post.user_id = user_id;
|
||||
post.title = title;
|
||||
post.body = body;
|
||||
Ok(Json(post.clone()))
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn delete_post(
|
||||
State(state): State<AppState>,
|
||||
Extension(ClientIp(ip)): Extension<ClientIp>,
|
||||
Path(id): Path<String>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
let post_id = parse_id(&id, "post")?;
|
||||
state.posts.write(ip, |client| {
|
||||
let index =
|
||||
client.posts.iter().position(|p| p.id == post_id).ok_or_else(|| post_not_found(&id))?;
|
||||
client.posts.remove(index);
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
})
|
||||
}
|
||||
|
||||
fn post_not_found(id: &str) -> ApiError {
|
||||
ApiError::not_found(format!("No post with ID {id}"))
|
||||
}
|
||||
|
||||
fn validate(user_id: u64, title: &str, body: &str) -> ApiResult<()> {
|
||||
if data::user(user_id).is_none() {
|
||||
return Err(ApiError::bad_request(format!("No user with ID {user_id}")));
|
||||
}
|
||||
if title.trim().is_empty() {
|
||||
return Err(ApiError::bad_request("`title` can't be empty"));
|
||||
}
|
||||
if title.chars().count() > MAX_TITLE_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"`title` must be {MAX_TITLE_CHARS} characters or fewer"
|
||||
)));
|
||||
}
|
||||
if body.chars().count() > MAX_BODY_CHARS {
|
||||
return Err(ApiError::bad_request(format!(
|
||||
"`body` must be {MAX_BODY_CHARS} characters or fewer"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"users": [
|
||||
{ "id": 1, "name": "Ada Lovelace", "username": "ada", "email": "ada@example.com" },
|
||||
{ "id": 2, "name": "Grace Hopper", "username": "grace", "email": "grace@example.com" },
|
||||
{ "id": 3, "name": "Alan Turing", "username": "alan", "email": "alan@example.com" },
|
||||
{ "id": 4, "name": "Katherine Johnson", "username": "katherine", "email": "katherine@example.com" },
|
||||
{ "id": 5, "name": "Margaret Hamilton", "username": "margaret", "email": "margaret@example.com" }
|
||||
],
|
||||
"posts": [
|
||||
{
|
||||
"id": 1,
|
||||
"userId": 1,
|
||||
"title": "Notes on the Analytical Engine",
|
||||
"body": "Requests are just messages. The trick is knowing exactly what you sent."
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"userId": 1,
|
||||
"title": "Why I keep my variables in environments",
|
||||
"body": "Switching from staging to production should be one click, not a find and replace."
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"userId": 2,
|
||||
"title": "Easier to ask forgiveness than permission",
|
||||
"body": "Unless the API returns a 403. Then it's time to check your token."
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"userId": 2,
|
||||
"title": "Debugging the first bug",
|
||||
"body": "The Timeline tab shows every redirect, header, and byte. No moths required."
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"userId": 3,
|
||||
"title": "Can machines send requests?",
|
||||
"body": "This one just did. Look through the response headers to see what came back."
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"userId": 3,
|
||||
"title": "On computable status codes",
|
||||
"body": "2xx is good news, 4xx usually means the request needs fixing, and 5xx usually means the server does."
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"userId": 4,
|
||||
"title": "Checking the math on pagination",
|
||||
"body": "Add a limit query parameter to get fewer results back."
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"userId": 4,
|
||||
"title": "Plotting a course between requests",
|
||||
"body": "Pull a value out of one response and send it in the next. That's all chaining is."
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"userId": 5,
|
||||
"title": "Error handling saved the landing",
|
||||
"body": "An API that returns clear errors is an API you can build on."
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"userId": 5,
|
||||
"title": "Logging in, then everything else",
|
||||
"body": "Get a token from one request and use it for the rest. Set it once on a folder and every request inside inherits it."
|
||||
}
|
||||
],
|
||||
"todos": [
|
||||
{ "id": 1, "userId": 1, "title": "Send your first request", "completed": true },
|
||||
{ "id": 2, "userId": 1, "title": "Create an environment", "completed": false },
|
||||
{ "id": 3, "userId": 1, "title": "Switch to a sub-environment", "completed": false },
|
||||
{ "id": 4, "userId": 2, "title": "Import an OpenAPI spec", "completed": true },
|
||||
{ "id": 5, "userId": 2, "title": "Add a header to a folder", "completed": false },
|
||||
{ "id": 6, "userId": 2, "title": "Read the response timeline", "completed": true },
|
||||
{ "id": 7, "userId": 3, "title": "Chain a value from another response", "completed": false },
|
||||
{ "id": 8, "userId": 3, "title": "Log in and use the token", "completed": false },
|
||||
{ "id": 9, "userId": 3, "title": "Copy a request as curl", "completed": true },
|
||||
{ "id": 10, "userId": 4, "title": "Sync a workspace to a folder", "completed": false },
|
||||
{ "id": 11, "userId": 4, "title": "Commit the workspace to Git", "completed": false },
|
||||
{ "id": 12, "userId": 4, "title": "Encrypt a secret", "completed": false },
|
||||
{ "id": 13, "userId": 5, "title": "Create a post", "completed": false },
|
||||
{ "id": 14, "userId": 5, "title": "Delete the post you created", "completed": false },
|
||||
{ "id": 15, "userId": 5, "title": "Delete the example workspace", "completed": false }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use crate::data::{self, Post};
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Mutex, MutexGuard};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Posts as each client sees them. A client that has never written reads the sample data; its
|
||||
/// first write takes a private copy, which is thrown away `reset_after` later. Nothing one
|
||||
/// client writes is ever served to another.
|
||||
pub struct PostStore {
|
||||
clients: Mutex<HashMap<IpAddr, ClientPosts>>,
|
||||
reset_after: Duration,
|
||||
max_clients: usize,
|
||||
pub max_posts: usize,
|
||||
}
|
||||
|
||||
pub struct ClientPosts {
|
||||
pub posts: Vec<Post>,
|
||||
next_id: u64,
|
||||
created: Instant,
|
||||
}
|
||||
|
||||
impl ClientPosts {
|
||||
pub fn insert(&mut self, user_id: u64, title: String, body: String) -> Post {
|
||||
let post = Post { id: self.next_id, user_id, title, body };
|
||||
self.next_id += 1;
|
||||
self.posts.push(post.clone());
|
||||
post
|
||||
}
|
||||
}
|
||||
|
||||
impl PostStore {
|
||||
pub fn new(reset_after: Duration, max_clients: usize, max_posts: usize) -> Self {
|
||||
Self { clients: Mutex::new(HashMap::new()), reset_after, max_clients, max_posts }
|
||||
}
|
||||
|
||||
pub fn read<R>(&self, client: IpAddr, f: impl FnOnce(&[Post]) -> R) -> R {
|
||||
let mut clients = self.lock();
|
||||
self.drop_if_expired(&mut clients, client, Instant::now());
|
||||
match clients.get(&client) {
|
||||
Some(c) => f(&c.posts),
|
||||
None => f(data::posts()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write<R>(&self, client: IpAddr, f: impl FnOnce(&mut ClientPosts) -> R) -> R {
|
||||
let mut clients = self.lock();
|
||||
let now = Instant::now();
|
||||
self.drop_if_expired(&mut clients, client, now);
|
||||
|
||||
if !clients.contains_key(&client) {
|
||||
if clients.len() >= self.max_clients {
|
||||
clients.retain(|_, c| now.duration_since(c.created) < self.reset_after);
|
||||
}
|
||||
if clients.len() >= self.max_clients
|
||||
&& let Some(oldest) =
|
||||
clients.iter().min_by_key(|(_, c)| c.created).map(|(ip, _)| *ip)
|
||||
{
|
||||
clients.remove(&oldest);
|
||||
}
|
||||
let posts = data::posts().to_vec();
|
||||
let next_id = posts.iter().map(|p| p.id).max().unwrap_or(0) + 1;
|
||||
clients.insert(client, ClientPosts { posts, next_id, created: now });
|
||||
}
|
||||
|
||||
f(clients.get_mut(&client).expect("inserted above"))
|
||||
}
|
||||
|
||||
fn lock(&self) -> MutexGuard<'_, HashMap<IpAddr, ClientPosts>> {
|
||||
self.clients.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn drop_if_expired(
|
||||
&self,
|
||||
clients: &mut HashMap<IpAddr, ClientPosts>,
|
||||
client: IpAddr,
|
||||
now: Instant,
|
||||
) {
|
||||
if clients.get(&client).is_some_and(|c| now.duration_since(c.created) >= self.reset_after) {
|
||||
clients.remove(&client);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use axum::Router;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::http::{HeaderMap, Request, StatusCode, header};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeSet;
|
||||
use std::net::SocketAddr;
|
||||
use tower::ServiceExt;
|
||||
use yaak_playground::{Config, router};
|
||||
|
||||
const ALICE: [u8; 4] = [203, 0, 113, 1];
|
||||
const BOB: [u8; 4] = [203, 0, 113, 2];
|
||||
|
||||
fn config() -> Config {
|
||||
Config {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 0,
|
||||
rate_limit_per_minute: 1000,
|
||||
trust_forwarded_for: false,
|
||||
reset_after_secs: 3600,
|
||||
max_clients: 10,
|
||||
max_posts_per_client: 20,
|
||||
max_request_bytes: 4096,
|
||||
}
|
||||
}
|
||||
|
||||
struct Res {
|
||||
status: StatusCode,
|
||||
headers: HeaderMap,
|
||||
json: Value,
|
||||
}
|
||||
|
||||
async fn call(
|
||||
app: &Router,
|
||||
client: [u8; 4],
|
||||
method: &str,
|
||||
uri: &str,
|
||||
headers: &[(&str, &str)],
|
||||
body: Option<Value>,
|
||||
) -> Res {
|
||||
let mut req = Request::builder().method(method).uri(uri);
|
||||
for (name, value) in headers {
|
||||
req = req.header(*name, *value);
|
||||
}
|
||||
let body = match body {
|
||||
Some(v) => Body::from(v.to_string()),
|
||||
None => Body::empty(),
|
||||
};
|
||||
let mut req = req.body(body).unwrap();
|
||||
req.extensions_mut().insert(ConnectInfo(SocketAddr::from((client, 40000))));
|
||||
let res = app.clone().oneshot(req).await.unwrap();
|
||||
let status = res.status();
|
||||
let headers = res.headers().clone();
|
||||
let bytes = to_bytes(res.into_body(), 1024 * 1024).await.unwrap();
|
||||
let json = if bytes.is_empty() { Value::Null } else { serde_json::from_slice(&bytes).unwrap() };
|
||||
Res { status, headers, json }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn users_and_todos() {
|
||||
let app = router(config());
|
||||
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
assert_eq!(res.json[0]["username"], "ada");
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/users/2", &[], None).await;
|
||||
assert_eq!(res.json["name"], "Grace Hopper");
|
||||
|
||||
for uri in ["/users/99", "/users/abc", "/users/99/todos"] {
|
||||
let res = call(&app, ALICE, "GET", uri, &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::NOT_FOUND, "{uri}");
|
||||
assert!(res.json["error"].is_string());
|
||||
}
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/users/1/todos?completed=false&limit=1", &[], None).await;
|
||||
assert_eq!(res.json.as_array().unwrap().len(), 1);
|
||||
assert_eq!(res.json[0]["userId"], 1);
|
||||
assert_eq!(res.json[0]["completed"], false);
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/todos?userId=3", &[], None).await;
|
||||
assert!(res.json.as_array().unwrap().iter().all(|t| t["userId"] == 3));
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/todos?completed=maybe", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn writes_are_private_to_the_client() {
|
||||
let app = router(config());
|
||||
let new_post = json!({ "userId": 1, "title": "Hello", "body": "From Yaak" });
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], Some(new_post)).await;
|
||||
assert_eq!(res.status, StatusCode::CREATED);
|
||||
let id = res.json["id"].as_u64().unwrap();
|
||||
assert_eq!(res.headers[header::LOCATION], format!("/posts/{id}"));
|
||||
|
||||
let uri = format!("/posts/{id}");
|
||||
assert_eq!(call(&app, ALICE, "GET", &uri, &[], None).await.status, StatusCode::OK);
|
||||
assert_eq!(call(&app, BOB, "GET", &uri, &[], None).await.status, StatusCode::NOT_FOUND);
|
||||
|
||||
let res = call(&app, ALICE, "PATCH", &uri, &[], Some(json!({ "title": "Edited" }))).await;
|
||||
assert_eq!(res.json["title"], "Edited");
|
||||
assert_eq!(res.json["body"], "From Yaak");
|
||||
|
||||
let replacement = json!({ "userId": 2, "title": "Replaced", "body": "" });
|
||||
let res = call(&app, ALICE, "PUT", &uri, &[], Some(replacement)).await;
|
||||
assert_eq!(res.json["userId"], 2);
|
||||
|
||||
let res = call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::NO_CONTENT);
|
||||
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::NOT_FOUND);
|
||||
assert_eq!(call(&app, BOB, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
|
||||
|
||||
let res = call(&app, ALICE, "GET", "/posts?userId=2", &[], None).await;
|
||||
assert!(res.json.as_array().unwrap().iter().any(|p| p["title"] == "Replaced"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_posts_are_rejected() {
|
||||
let app = router(config());
|
||||
let cases = [
|
||||
json!({ "title": "No user", "body": "" }),
|
||||
json!({ "userId": 99, "title": "Unknown user", "body": "" }),
|
||||
json!({ "userId": 1, "title": " ", "body": "" }),
|
||||
json!({ "userId": 1, "title": "x".repeat(201), "body": "" }),
|
||||
];
|
||||
for body in cases {
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], Some(body.clone())).await;
|
||||
assert_eq!(res.status, StatusCode::BAD_REQUEST, "{body}");
|
||||
assert!(res.json["error"].is_string());
|
||||
}
|
||||
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::BAD_REQUEST);
|
||||
|
||||
let huge = json!({ "userId": 1, "title": "Big", "body": "x".repeat(5000) });
|
||||
let res = call(&app, ALICE, "POST", "/posts", &[], Some(huge)).await;
|
||||
assert_eq!(res.status, StatusCode::PAYLOAD_TOO_LARGE);
|
||||
assert!(res.json["error"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn post_cap_and_reset() {
|
||||
let app = router(Config { max_posts_per_client: 11, ..config() });
|
||||
let post = json!({ "userId": 1, "title": "One more", "body": "" });
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "POST", "/posts", &[], Some(post.clone())).await.status,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "POST", "/posts", &[], Some(post)).await.status,
|
||||
StatusCode::CONFLICT
|
||||
);
|
||||
|
||||
let app = router(Config { reset_after_secs: 0, ..config() });
|
||||
call(&app, ALICE, "DELETE", "/posts/1", &[], None).await;
|
||||
assert_eq!(call(&app, ALICE, "GET", "/posts/1", &[], None).await.status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn bearer_token_flow() {
|
||||
let app = router(config());
|
||||
let res = call(&app, ALICE, "GET", "/auth/me", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(res.headers[header::WWW_AUTHENTICATE], "Bearer");
|
||||
|
||||
let bad = json!({ "username": "ada", "password": "nope" });
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "POST", "/auth/token", &[], Some(bad)).await.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
|
||||
let good = json!({ "username": "grace", "password": "yaak" });
|
||||
let res = call(&app, ALICE, "POST", "/auth/token", &[], Some(good)).await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
let token = res.json["accessToken"].as_str().unwrap().to_string();
|
||||
|
||||
let auth = format!("Bearer {token}");
|
||||
let res = call(&app, BOB, "GET", "/auth/me", &[("authorization", &auth)], None).await;
|
||||
assert_eq!(res.json["username"], "grace");
|
||||
|
||||
let res =
|
||||
call(&app, ALICE, "GET", "/auth/me", &[("authorization", "Bearer wrong")], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let cookie = format!("session={token}");
|
||||
let res = call(&app, ALICE, "GET", "/auth/session", &[("cookie", &cookie)], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_cookie_flow() {
|
||||
let app = router(config());
|
||||
let login = json!({ "username": "alan", "password": "yaak" });
|
||||
let res =
|
||||
call(&app, ALICE, "POST", "/auth/session", &[("x-forwarded-proto", "https")], Some(login))
|
||||
.await;
|
||||
assert_eq!(res.status, StatusCode::OK);
|
||||
let set_cookie = res.headers[header::SET_COOKIE].to_str().unwrap();
|
||||
assert!(set_cookie.contains("HttpOnly") && set_cookie.contains("; Secure"), "{set_cookie}");
|
||||
let cookie = set_cookie.split(';').next().unwrap().to_string();
|
||||
|
||||
let headers = [("cookie", cookie.as_str())];
|
||||
let res = call(&app, ALICE, "GET", "/auth/session", &headers, None).await;
|
||||
assert_eq!(res.json["username"], "alan");
|
||||
|
||||
let id = cookie.trim_start_matches("session=");
|
||||
let auth = format!("Bearer {id}");
|
||||
let res = call(&app, ALICE, "GET", "/auth/me", &[("authorization", &auth)], None).await;
|
||||
assert_eq!(res.status, StatusCode::UNAUTHORIZED);
|
||||
|
||||
let res = call(&app, ALICE, "DELETE", "/auth/session", &headers, None).await;
|
||||
assert_eq!(res.status, StatusCode::NO_CONTENT);
|
||||
assert!(res.headers[header::SET_COOKIE].to_str().unwrap().contains("Max-Age=0"));
|
||||
assert_eq!(
|
||||
call(&app, ALICE, "GET", "/auth/session", &headers, None).await.status,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rate_limit() {
|
||||
let app = router(Config { rate_limit_per_minute: 2, ..config() });
|
||||
call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
let res = call(&app, ALICE, "GET", "/users", &[], None).await;
|
||||
assert_eq!(res.status, StatusCode::TOO_MANY_REQUESTS);
|
||||
assert!(res.headers.contains_key(header::RETRY_AFTER));
|
||||
assert_eq!(call(&app, BOB, "GET", "/users", &[], None).await.status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openapi_matches_the_routes() {
|
||||
let app = router(config());
|
||||
let spec = call(&app, ALICE, "GET", "/openapi.json", &[], None).await.json;
|
||||
let mut documented = BTreeSet::new();
|
||||
for (path, item) in spec["paths"].as_object().unwrap() {
|
||||
for (method, _) in item.as_object().unwrap() {
|
||||
let method = method.to_uppercase();
|
||||
let uri = path.replace("{id}", "1");
|
||||
// A fresh client per call, so a DELETE can't hide the GET that follows it
|
||||
let client = [198, 51, 100, documented.len() as u8];
|
||||
let res = call(&app, client, &method, &uri, &[], None).await;
|
||||
assert!(
|
||||
res.status != StatusCode::NOT_FOUND && res.status != StatusCode::METHOD_NOT_ALLOWED,
|
||||
"{method} {path} is documented but not routed ({})",
|
||||
res.status
|
||||
);
|
||||
documented.insert(format!("{method} {path}"));
|
||||
}
|
||||
}
|
||||
|
||||
let index = call(&app, ALICE, "GET", "/", &[], None).await.json;
|
||||
let listed: BTreeSet<String> = index["endpoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e.as_str().unwrap().to_string())
|
||||
.collect();
|
||||
assert_eq!(listed, documented);
|
||||
|
||||
assert_eq!(call(&app, ALICE, "GET", "/nope", &[], None).await.status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ description = "The server behind Yaak in the browser: executes sends, and can se
|
||||
|
||||
# The send engine (yaak-http) and the model types it speaks (yaak-models, for
|
||||
# HttpRequest / Cookie / HttpResponseEventData). Deliberately NOT yaak (the
|
||||
# render + storage orchestration), yaak-plugins, or the RPC router: this binary
|
||||
# render + storage orchestration), yaak-plugins, or the RPC router: this crate
|
||||
# opens no database, runs no plugins, and renders nothing. yaak-models comes
|
||||
# along only because yaak-http's types are its types; nothing here calls into
|
||||
# its query layer.
|
||||
@@ -34,3 +34,7 @@ url = "2"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
yaak-http = { workspace = true }
|
||||
yaak-models = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
@@ -46,7 +46,7 @@ docker run -p 8080:8080 \
|
||||
anything strangers can reach — see [What it refuses](#what-it-refuses-and-why).
|
||||
Turn it on for an instance on your own network, where calling the API on the
|
||||
next machine is the whole point. Note that "private" is relative to the
|
||||
*container*: `127.0.0.1` is the container itself, and reaching the Docker
|
||||
_container_: `127.0.0.1` is the container itself, and reaching the Docker
|
||||
host means `host.docker.internal` (or `--network host`).
|
||||
- **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP,
|
||||
which suits a public instance and not a team of your own; `0` disables it.
|
||||
@@ -72,27 +72,44 @@ cargo run -p yaak-web
|
||||
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
|
||||
```
|
||||
|
||||
A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a
|
||||
different origin and serves no `/v1`); a production build sends to its own
|
||||
origin unless `VITE_YAAK_WEB_URL` was set when it was built.
|
||||
The dev server passes `/v1` through to this binary, so a dev build sends to its
|
||||
own origin exactly like a production build does — one address to open, and no
|
||||
CORS in the loop. `PORT` moves this server and the dev server follows it. A
|
||||
production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` was
|
||||
set when it was built.
|
||||
|
||||
Reaching the dev server from another machine is `HOST=0.0.0.0`. Vite allows
|
||||
addresses but not names, so opening it as a hostname also needs
|
||||
`ALLOWED_HOSTS=that-name`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Every flag has a `YAAK_WEB_*` environment variable, so a container needs no
|
||||
arguments; `--help` lists them all.
|
||||
Every flag has an environment variable, so a container needs no arguments;
|
||||
`--help` lists them all. The listen address uses the platform conventions —
|
||||
`HOST` and `PORT` — so the published image runs unchanged on a
|
||||
platform-as-a-service that assigns a port. Everything else is `YAAK_WEB_*`.
|
||||
|
||||
| Flag | Default | What |
|
||||
| --- | --- | --- |
|
||||
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
|
||||
| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. |
|
||||
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
|
||||
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
|
||||
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
||||
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
||||
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
||||
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
|
||||
| `--max-concurrent` | 256 | Sends in flight at once. |
|
||||
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
|
||||
| Flag | Default | What |
|
||||
| -------------------------- | ----------- | --------------------------------------------------------------------------------------------- |
|
||||
| `--serve` | off | Also serve a built web client from this directory, on the same origin. |
|
||||
| `--host` | `127.0.0.1` | Interface to listen on. The image sets `0.0.0.0`. May be a name; `localhost` resolves. |
|
||||
| `--port` | `9227` | Port to listen on. The image sets `8080`. |
|
||||
| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. |
|
||||
| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. |
|
||||
| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. |
|
||||
| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. |
|
||||
| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. |
|
||||
| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. |
|
||||
| `--max-concurrent` | 256 | Sends in flight at once. |
|
||||
| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. |
|
||||
|
||||
## Logging
|
||||
|
||||
Default logs contain a startup message and operational warnings and errors, not request URLs, client IPs,
|
||||
DNS lookups, or per-request timing. To diagnose a self-hosted instance, opt in with
|
||||
`RUST_LOG=warn,yaak_http=error,yaak_web=debug`. This includes request URLs and IPs,
|
||||
so only enable it while debugging. Hosting infrastructure may maintain its own
|
||||
access logs independently.
|
||||
|
||||
## Serving the app
|
||||
|
||||
@@ -150,8 +167,9 @@ caught, and so is a `Location:` header that points at one. It also refuses body
|
||||
types that would read files on its own disk (`binary`, multipart file
|
||||
fields), since no browser tab could legitimately mean those.
|
||||
|
||||
Refusals are logged with the reason. On a public instance (`web.yaak.app`, or
|
||||
anything else strangers can reach) this must stay on: the machine's private
|
||||
Refusal reasons are included in debug logs when enabled. On a public instance
|
||||
(`web.yaak.app`, or anything else strangers can reach), private-network protection
|
||||
must stay on: the machine's private
|
||||
network is the host's, not the user's, so a `localhost` or LAN API is not the
|
||||
user's to reach through it — the desktop app is what reaches those. On an
|
||||
instance you run for yourself, that reasoning is inverted, and
|
||||
@@ -162,7 +180,7 @@ other side is one the users are entitled to.
|
||||
There is no authentication either way: an instance is anonymous, protected by
|
||||
the per-client rate limit and the destination policy. Anything more (a shared
|
||||
token, per-user quotas) is a later slice and would sit in front of `send_http`
|
||||
in `main.rs`. Put TLS in front of a public instance.
|
||||
in `lib.rs`, or in middleware on the exported router. Put TLS in front of a public instance.
|
||||
|
||||
## The wire
|
||||
|
||||
@@ -172,7 +190,7 @@ in `main.rs`. Put TLS in front of a public instance.
|
||||
{
|
||||
"request": { "url": "https://…", "method": "GET", "headers": […], "body": {…}, "bodyType": null, "urlParameters": […] },
|
||||
"settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true },
|
||||
"cookies": [ … ]
|
||||
"cookies": [ … ]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -184,13 +202,13 @@ jar's contents (or `null` for no jar).
|
||||
The reply is `application/x-ndjson`, one JSON frame per line, in the order things
|
||||
happened:
|
||||
|
||||
| `type` | When | Carries |
|
||||
| --- | --- | --- |
|
||||
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
|
||||
| `type` | When | Carries |
|
||||
| ---------- | ----------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape |
|
||||
| `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing |
|
||||
| `body` | as the body is read | a decompressed chunk, base64 |
|
||||
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
|
||||
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
||||
| `body` | as the body is read | a decompressed chunk, base64 |
|
||||
| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it |
|
||||
| `error` | last, on failure | the reason, and any cookies collected before the failure |
|
||||
|
||||
Refusals that happen before anything is sent (a blocked destination, a bad body,
|
||||
rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use clap::Parser;
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{SocketAddr, ToSocketAddrs};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The server behind Yaak running in a browser.
|
||||
@@ -10,9 +10,25 @@ use std::path::PathBuf;
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
#[command(name = "yaak-web", version, about, long_about = None)]
|
||||
pub struct Config {
|
||||
/// Address to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container.
|
||||
#[arg(long, env = "YAAK_WEB_BIND", default_value = "127.0.0.1:9227")]
|
||||
pub bind: SocketAddr,
|
||||
/// Interface to listen on. 127.0.0.1 for a local instance; 0.0.0.0 inside a container,
|
||||
/// which is what makes the port reachable from outside it.
|
||||
#[arg(long, env = "HOST", default_value = "127.0.0.1")]
|
||||
pub host: String,
|
||||
|
||||
/// Port to listen on. Named `PORT` because that is the variable a platform-as-a-service
|
||||
/// assigns and expects to be obeyed, so the same image runs there and under `docker run`
|
||||
/// with nothing changed.
|
||||
#[arg(long, env = "PORT", default_value_t = 9227)]
|
||||
pub port: u16,
|
||||
|
||||
/// Port the app is served on elsewhere. A browser that opens this server directly is
|
||||
/// redirected there, keeping whatever hostname it used — so `home:9227` becomes
|
||||
/// `home:1424` without this server knowing what "home" is.
|
||||
///
|
||||
/// Set by `vp run web:dev`, where the app is on the dev server and this is only the
|
||||
/// executor behind it. Irrelevant with `--serve`, which puts both on one port.
|
||||
#[arg(long, env = "YAAK_WEB_APP_PORT")]
|
||||
pub app_port: Option<u16>,
|
||||
|
||||
/// Also serve a built web client from this directory, on the same origin as the API.
|
||||
/// Unknown paths fall back to `index.html` so the app's own routes work on a refresh.
|
||||
@@ -65,3 +81,18 @@ pub struct Config {
|
||||
#[arg(long, env = "YAAK_WEB_TRUST_FORWARDED_FOR", default_value_t = false)]
|
||||
pub trust_forwarded_for: bool,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// The address to listen on.
|
||||
///
|
||||
/// Resolved rather than parsed, so `HOST` may be a name — `localhost` is the one people
|
||||
/// actually type, and it is not an `IpAddr`. The first address wins; a host resolving to
|
||||
/// several is a machine with several interfaces, and any of them is a listen address.
|
||||
pub fn listen_addr(&self) -> Result<SocketAddr, String> {
|
||||
(self.host.as_str(), self.port)
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| format!("Invalid HOST {:?}: {e}", self.host))?
|
||||
.next()
|
||||
.ok_or_else(|| format!("HOST {:?} resolved to no address", self.host))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
//! resolves every hop.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::warn;
|
||||
use log::debug;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
@@ -186,7 +186,7 @@ impl<S: HttpSender> HttpSender for GuardedSender<S> {
|
||||
event_tx: mpsc::Sender<HttpResponseEvent>,
|
||||
) -> yaak_http::error::Result<HttpResponse> {
|
||||
if let Err(reason) = self.policy.check_url(&request.url) {
|
||||
warn!("Refused {} {}: {reason}", request.method, request.url);
|
||||
debug!("Refused {} {}: {reason}", request.method, request.url);
|
||||
return Err(yaak_http::error::Error::RequestError(reason));
|
||||
}
|
||||
self.inner.send(request, event_tx).await
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
//! yaak-web: the network half of Yaak in a browser.
|
||||
//!
|
||||
//! A tab can't see a response the way a desktop app can — CORS hides most
|
||||
//! headers, redirects are followed silently, there is no timeline. So the tab
|
||||
//! renders the request and hands it here; this process puts it on the network
|
||||
//! with the desktop's own engine and streams back everything that happened,
|
||||
//! for the tab to store. It keeps nothing: no database, no files, no session.
|
||||
//!
|
||||
//! Embed [`router`] in your own Axum server, or run the `yaak-web` binary
|
||||
//! configured by flags or `YAAK_WEB_*` environment variables.
|
||||
//! See README.md for running and deploying it, and `guard.rs` for what it
|
||||
//! refuses to talk to.
|
||||
|
||||
mod config;
|
||||
mod guard;
|
||||
mod limits;
|
||||
mod send;
|
||||
mod wire;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Json, Redirect, Response};
|
||||
use axum::routing::{get, post};
|
||||
pub use config::Config;
|
||||
use guard::DestinationPolicy;
|
||||
use limits::RateLimiter;
|
||||
use log::{debug, info, warn};
|
||||
use send::{Refusal, SendLimits};
|
||||
use serde_json::json;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use wire::SendRequest;
|
||||
|
||||
/// Minimal operational logging; request diagnostics require an explicit `RUST_LOG` setting.
|
||||
pub const DEFAULT_LOG_FILTER: &str = "warn,yaak_http=error,yaak_web::startup=info";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config: Arc<Config>,
|
||||
limits: Arc<SendLimits>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
in_flight: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
/// Build the API router and, when configured, the web client's static-file fallback.
|
||||
///
|
||||
/// The returned router owns its state and accepts normal Axum middleware via `.layer()`.
|
||||
/// Construction does not parse arguments, initialize logging, bind a socket, or install
|
||||
/// shutdown handlers. `config.host` and `config.port` are used only by the standalone binary.
|
||||
///
|
||||
/// Serve with `into_make_service_with_connect_info::<SocketAddr>()` so the send endpoint
|
||||
/// can extract the peer address for rate limiting.
|
||||
pub fn router(config: Config) -> Router {
|
||||
let policy = DestinationPolicy::new(config.allow_private_networks);
|
||||
if config.allow_private_networks {
|
||||
warn!(
|
||||
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
|
||||
on an instance strangers cannot reach"
|
||||
);
|
||||
}
|
||||
let state = AppState {
|
||||
limits: Arc::new(SendLimits {
|
||||
policy,
|
||||
max_response_bytes: config.max_response_bytes,
|
||||
max_timeout: Duration::from_secs(config.max_timeout_secs),
|
||||
}),
|
||||
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
|
||||
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
|
||||
config: Arc::new(config),
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([header::CONTENT_TYPE])
|
||||
.allow_origin(allowed_origins(&state.config.allowed_origins));
|
||||
|
||||
let api = Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
|
||||
// on the same router, behind the same policy, limits and auth. Not built; see README.
|
||||
.route("/v1/http/send", post(send_http))
|
||||
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
|
||||
.layer(cors)
|
||||
.with_state(state.clone());
|
||||
|
||||
match &state.config.serve {
|
||||
Some(dir) => {
|
||||
info!("Serving the web client from {}", dir.display());
|
||||
api.merge(web_router(dir))
|
||||
}
|
||||
// Without `--serve` there is no app here, only `/v1`. Someone who opens this
|
||||
// port in a browser guessed wrong about which of the two dev servers hosts the
|
||||
// app, so send them to the right one when we have been told where it is.
|
||||
None => {
|
||||
let app_port = state.config.app_port;
|
||||
api.fallback(move |headers: HeaderMap| async move { no_app_here(app_port, headers) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The built web client, served on the same origin as the API.
|
||||
///
|
||||
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
|
||||
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
|
||||
/// only a file server — a send behaves exactly as it does without this flag.
|
||||
///
|
||||
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
|
||||
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
|
||||
/// refresh).
|
||||
fn web_router(dir: &Path) -> Router {
|
||||
let index = ServeFile::new(dir.join("index.html"));
|
||||
Router::new()
|
||||
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
|
||||
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
|
||||
.fallback_service(ServeDir::new(dir).fallback(index))
|
||||
.layer(middleware::from_fn(cache_control))
|
||||
.layer(CompressionLayer::new())
|
||||
}
|
||||
|
||||
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
|
||||
/// Everything else — `index.html` above all, including the copy served for an unknown path —
|
||||
/// must be revalidated, or a browser keeps serving the deploy before last.
|
||||
async fn cache_control(req: Request, next: Next) -> Response {
|
||||
let hashed_name = req.uri().path().starts_with("/assets/");
|
||||
let mut res = next.run(req).await;
|
||||
if !res.status().is_success() {
|
||||
return res;
|
||||
}
|
||||
let is_html = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/html"));
|
||||
let value =
|
||||
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
|
||||
res
|
||||
}
|
||||
|
||||
/// The name in a `Host` header, without the port this server is reached on.
|
||||
///
|
||||
/// An IPv6 literal keeps its brackets and its own colons: the last colon only separates a
|
||||
/// port when what follows it isn't part of the address, which is what the `]` test decides.
|
||||
fn host_without_port(host: &str) -> &str {
|
||||
match host.rfind(':') {
|
||||
Some(i) if !host[i..].contains(']') => &host[..i],
|
||||
_ => host,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the send executor does with a browser that came looking for the app.
|
||||
///
|
||||
/// With an app port configured this is a redirect, built from the `Host` header so the
|
||||
/// hostname the browser already used is the one it keeps — this server has no idea which of
|
||||
/// its addresses someone typed, and does not need to.
|
||||
fn no_app_here(app_port: Option<u16>, headers: HeaderMap) -> Response {
|
||||
if let Some(port) = app_port
|
||||
&& let Some(host) = headers.get(header::HOST).and_then(|v| v.to_str().ok())
|
||||
{
|
||||
return Redirect::temporary(&format!("http://{}:{port}/", host_without_port(host)))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(
|
||||
StatusCode::NOT_FOUND,
|
||||
[(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
|
||||
"This is the Yaak send executor. It serves the API under /v1 and no app.\n\n\
|
||||
To serve the app from here too, restart with --serve <DIR> pointing at a built\n\
|
||||
web client.\n",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn allowed_origins(origins: &[String]) -> AllowOrigin {
|
||||
if origins.iter().any(|o| o.trim() == "*") {
|
||||
return AllowOrigin::any();
|
||||
}
|
||||
let parsed: Vec<HeaderValue> =
|
||||
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
|
||||
AllowOrigin::list(parsed)
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"maxResponseBytes": state.config.max_response_bytes,
|
||||
"maxTimeoutSecs": state.config.max_timeout_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
||||
let message = message.into();
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
|
||||
/// hop when the operator has said the header can be trusted.
|
||||
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
if config.trust_forwarded_for
|
||||
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(first) = forwarded.split(',').next()
|
||||
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
peer.ip()
|
||||
}
|
||||
|
||||
async fn send_http(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SendRequest>,
|
||||
) -> Response {
|
||||
let ip = client_ip(&state.config, &headers, peer);
|
||||
if let Err(wait) = state.rate_limiter.check(ip) {
|
||||
debug!("Rate limited {ip}");
|
||||
let mut res = error_response(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
|
||||
);
|
||||
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
|
||||
return res;
|
||||
}
|
||||
|
||||
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
|
||||
debug!("At capacity; refusing {ip}");
|
||||
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
|
||||
};
|
||||
|
||||
let prepared = match send::prepare(state.limits.clone(), body).await {
|
||||
Ok(p) => p,
|
||||
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||
Err(Refusal::Destination(m)) => {
|
||||
debug!("Refused send from {ip}: {m}");
|
||||
return error_response(StatusCode::FORBIDDEN, m);
|
||||
}
|
||||
};
|
||||
|
||||
let description = prepared.describe();
|
||||
debug!("{ip} -> {description}");
|
||||
let started = Instant::now();
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
|
||||
tokio::spawn(async move {
|
||||
prepared.run(tx).await;
|
||||
send::log_outcome(&description, started, "finished");
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
let stream = tokio_stream_from(rx);
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/x-ndjson")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
// Some reverse proxies buffer streamed responses unless told not to
|
||||
.header("x-accel-buffering", "no")
|
||||
.body(Body::from_stream(stream))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn tokio_stream_from<T: Send + 'static>(
|
||||
mut rx: tokio::sync::mpsc::Receiver<T>,
|
||||
) -> impl futures_util::Stream<Item = T> + Send + 'static {
|
||||
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::host_without_port;
|
||||
|
||||
#[test]
|
||||
fn strips_the_port_and_keeps_the_name() {
|
||||
assert_eq!(host_without_port("home:9227"), "home");
|
||||
assert_eq!(host_without_port("home"), "home");
|
||||
assert_eq!(host_without_port("192.168.1.5:9227"), "192.168.1.5");
|
||||
assert_eq!(host_without_port("192.168.1.5"), "192.168.1.5");
|
||||
// An IPv6 literal is full of colons, and only the one outside the brackets is a port.
|
||||
assert_eq!(host_without_port("[::1]:9227"), "[::1]");
|
||||
assert_eq!(host_without_port("[::1]"), "[::1]");
|
||||
assert_eq!(host_without_port("[2606:4700::1111]:8080"), "[2606:4700::1111]");
|
||||
assert_eq!(host_without_port("[2606:4700::1111]"), "[2606:4700::1111]");
|
||||
}
|
||||
}
|
||||
@@ -1,107 +1,29 @@
|
||||
//! yaak-web: the network half of Yaak in a browser.
|
||||
//!
|
||||
//! A tab can't see a response the way a desktop app can — CORS hides most
|
||||
//! headers, redirects are followed silently, there is no timeline. So the tab
|
||||
//! renders the request and hands it here; this process puts it on the network
|
||||
//! with the desktop's own engine and streams back everything that happened,
|
||||
//! for the tab to store. It keeps nothing: no database, no files, no session.
|
||||
//!
|
||||
//! One binary, configured by flags or `YAAK_WEB_*` environment variables.
|
||||
//! See README.md for running and deploying it, and `guard.rs` for what it
|
||||
//! refuses to talk to.
|
||||
//! Standalone server for Yaak in a browser.
|
||||
|
||||
mod config;
|
||||
mod guard;
|
||||
mod limits;
|
||||
mod send;
|
||||
mod wire;
|
||||
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{ConnectInfo, DefaultBodyLimit, Request, State};
|
||||
use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Json, Response};
|
||||
use axum::routing::{get, post};
|
||||
use clap::Parser;
|
||||
use config::Config;
|
||||
use guard::DestinationPolicy;
|
||||
use limits::RateLimiter;
|
||||
use log::{info, warn};
|
||||
use send::{Refusal, SendLimits};
|
||||
use serde_json::json;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Semaphore;
|
||||
use tower_http::compression::CompressionLayer;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use wire::SendRequest;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
config: Arc<Config>,
|
||||
limits: Arc<SendLimits>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
in_flight: Arc<Semaphore>,
|
||||
}
|
||||
use log::info;
|
||||
use std::net::SocketAddr;
|
||||
use yaak_web::{Config, router};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
env_logger::Builder::from_env(
|
||||
env_logger::Env::default().default_filter_or(yaak_web::DEFAULT_LOG_FILTER),
|
||||
)
|
||||
.init();
|
||||
let config = Config::parse();
|
||||
let bind = config.listen_addr().unwrap_or_else(|e| {
|
||||
eprintln!("{e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
let rate_limit_per_minute = config.rate_limit_per_minute;
|
||||
let app = router(config);
|
||||
|
||||
let policy = DestinationPolicy::new(config.allow_private_networks);
|
||||
if config.allow_private_networks {
|
||||
warn!(
|
||||
"Sends to loopback, private and link-local addresses are ALLOWED. Only run this way \
|
||||
on an instance strangers cannot reach"
|
||||
);
|
||||
}
|
||||
let state = AppState {
|
||||
limits: Arc::new(SendLimits {
|
||||
policy,
|
||||
max_response_bytes: config.max_response_bytes,
|
||||
max_timeout: Duration::from_secs(config.max_timeout_secs),
|
||||
}),
|
||||
rate_limiter: Arc::new(RateLimiter::new(config.rate_limit_per_minute)),
|
||||
in_flight: Arc::new(Semaphore::new(config.max_concurrent)),
|
||||
config: Arc::new(config),
|
||||
};
|
||||
|
||||
let cors = CorsLayer::new()
|
||||
.allow_methods([Method::GET, Method::POST, Method::OPTIONS])
|
||||
.allow_headers([header::CONTENT_TYPE])
|
||||
.allow_origin(allowed_origins(&state.config.allowed_origins));
|
||||
|
||||
let api = Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
// A WebSocket or gRPC relay would sit beside this as `/v1/ws/relay` and `/v1/grpc/relay`
|
||||
// on the same router, behind the same policy, limits and auth. Not built; see README.
|
||||
.route("/v1/http/send", post(send_http))
|
||||
.layer(DefaultBodyLimit::max(state.config.max_request_bytes))
|
||||
.layer(cors)
|
||||
.with_state(state.clone());
|
||||
|
||||
let app = match &state.config.serve {
|
||||
Some(dir) => {
|
||||
info!("Serving the web client from {}", dir.display());
|
||||
api.merge(web_router(dir))
|
||||
}
|
||||
None => api,
|
||||
};
|
||||
|
||||
let bind = state.config.bind;
|
||||
let listener = tokio::net::TcpListener::bind(bind).await.unwrap_or_else(|e| {
|
||||
eprintln!("Failed to bind {bind}: {e}");
|
||||
std::process::exit(1);
|
||||
});
|
||||
info!(
|
||||
"yaak-web listening on http://{bind} (rate limit: {}/min)",
|
||||
state.config.rate_limit_per_minute,
|
||||
);
|
||||
info!(target: "yaak_web::startup", "yaak-web listening on http://{bind} (rate limit: {}/min)", rate_limit_per_minute,);
|
||||
|
||||
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(async {
|
||||
@@ -111,138 +33,3 @@ async fn main() {
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
|
||||
/// The built web client, served on the same origin as the API.
|
||||
///
|
||||
/// This is what makes a single container zero-configuration: the tab's send URL is a path on
|
||||
/// the page's own origin, so there is no CORS, no second service and no URL to bake in. It is
|
||||
/// only a file server — a send behaves exactly as it does without this flag.
|
||||
///
|
||||
/// Merged as a fallback, so the `/v1` routes are matched first and a request that matches no
|
||||
/// file at all gets `index.html` (the app routes client-side; a deep link must survive a
|
||||
/// refresh).
|
||||
fn web_router(dir: &Path) -> Router {
|
||||
let index = ServeFile::new(dir.join("index.html"));
|
||||
Router::new()
|
||||
// `fallback`, not `not_found_service`: the app's own routes are real pages, so
|
||||
// index.html is served with the 200 the browser expects, not a 404 carrying HTML.
|
||||
.fallback_service(ServeDir::new(dir).fallback(index))
|
||||
.layer(middleware::from_fn(cache_control))
|
||||
.layer(CompressionLayer::new())
|
||||
}
|
||||
|
||||
/// Vite gives everything in `/assets` a content-hashed name, so those can be cached forever.
|
||||
/// Everything else — `index.html` above all, including the copy served for an unknown path —
|
||||
/// must be revalidated, or a browser keeps serving the deploy before last.
|
||||
async fn cache_control(req: Request, next: Next) -> Response {
|
||||
let hashed_name = req.uri().path().starts_with("/assets/");
|
||||
let mut res = next.run(req).await;
|
||||
if !res.status().is_success() {
|
||||
return res;
|
||||
}
|
||||
let is_html = res
|
||||
.headers()
|
||||
.get(header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.starts_with("text/html"));
|
||||
let value =
|
||||
if hashed_name && !is_html { "public, max-age=31536000, immutable" } else { "no-cache" };
|
||||
res.headers_mut().insert(header::CACHE_CONTROL, HeaderValue::from_static(value));
|
||||
res
|
||||
}
|
||||
|
||||
fn allowed_origins(origins: &[String]) -> AllowOrigin {
|
||||
if origins.iter().any(|o| o.trim() == "*") {
|
||||
return AllowOrigin::any();
|
||||
}
|
||||
let parsed: Vec<HeaderValue> =
|
||||
origins.iter().filter_map(|o| HeaderValue::from_str(o.trim()).ok()).collect();
|
||||
AllowOrigin::list(parsed)
|
||||
}
|
||||
|
||||
async fn health(State(state): State<AppState>) -> impl IntoResponse {
|
||||
Json(json!({
|
||||
"ok": true,
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"maxResponseBytes": state.config.max_response_bytes,
|
||||
"maxTimeoutSecs": state.config.max_timeout_secs,
|
||||
}))
|
||||
}
|
||||
|
||||
fn error_response(status: StatusCode, message: impl Into<String>) -> Response {
|
||||
let message = message.into();
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
/// The client's address for rate limiting: the socket peer, or the first `X-Forwarded-For`
|
||||
/// hop when the operator has said the header can be trusted.
|
||||
fn client_ip(config: &Config, headers: &HeaderMap, peer: SocketAddr) -> IpAddr {
|
||||
if config.trust_forwarded_for
|
||||
&& let Some(forwarded) = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok())
|
||||
&& let Some(first) = forwarded.split(',').next()
|
||||
&& let Ok(ip) = first.trim().parse::<IpAddr>()
|
||||
{
|
||||
return ip;
|
||||
}
|
||||
peer.ip()
|
||||
}
|
||||
|
||||
async fn send_http(
|
||||
State(state): State<AppState>,
|
||||
ConnectInfo(peer): ConnectInfo<SocketAddr>,
|
||||
headers: HeaderMap,
|
||||
Json(body): Json<SendRequest>,
|
||||
) -> Response {
|
||||
let ip = client_ip(&state.config, &headers, peer);
|
||||
if let Err(wait) = state.rate_limiter.check(ip) {
|
||||
warn!("Rate limited {ip}");
|
||||
let mut res = error_response(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("Rate limit reached; try again in {}s", wait.as_secs().max(1)),
|
||||
);
|
||||
res.headers_mut().insert(header::RETRY_AFTER, HeaderValue::from(wait.as_secs().max(1)));
|
||||
return res;
|
||||
}
|
||||
|
||||
let Ok(permit) = state.in_flight.clone().try_acquire_owned() else {
|
||||
warn!("At capacity; refusing {ip}");
|
||||
return error_response(StatusCode::SERVICE_UNAVAILABLE, "This server is at capacity");
|
||||
};
|
||||
|
||||
let prepared = match send::prepare(state.limits.clone(), body).await {
|
||||
Ok(p) => p,
|
||||
Err(Refusal::Unsupported(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||
Err(Refusal::Invalid(m)) => return error_response(StatusCode::BAD_REQUEST, m),
|
||||
Err(Refusal::Destination(m)) => {
|
||||
warn!("Refused send from {ip}: {m}");
|
||||
return error_response(StatusCode::FORBIDDEN, m);
|
||||
}
|
||||
};
|
||||
|
||||
let description = prepared.describe();
|
||||
info!("{ip} -> {description}");
|
||||
let started = Instant::now();
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(send::FRAME_CHANNEL_CAPACITY);
|
||||
tokio::spawn(async move {
|
||||
prepared.run(tx).await;
|
||||
send::log_outcome(&description, started, "finished");
|
||||
drop(permit);
|
||||
});
|
||||
|
||||
let stream = tokio_stream_from(rx);
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/x-ndjson")
|
||||
.header(header::CACHE_CONTROL, "no-store")
|
||||
// Some reverse proxies buffer streamed responses unless told not to
|
||||
.header("x-accel-buffering", "no")
|
||||
.body(Body::from_stream(stream))
|
||||
.expect("valid response")
|
||||
}
|
||||
|
||||
fn tokio_stream_from<T: Send + 'static>(
|
||||
mut rx: tokio::sync::mpsc::Receiver<T>,
|
||||
) -> impl futures_util::Stream<Item = T> + Send + 'static {
|
||||
futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx))
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use crate::guard::{DestinationPolicy, GuardedSender};
|
||||
use crate::wire::{Frame, SendRequest};
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use log::{info, warn};
|
||||
use log::{debug, warn};
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
@@ -344,7 +344,8 @@ async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> {
|
||||
let mut line = match serde_json::to_vec(frame) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
warn!("Failed to serialize frame: {e}");
|
||||
warn!("Failed to serialize response frame");
|
||||
debug!("Frame serialization error: {e}");
|
||||
return Err(());
|
||||
}
|
||||
};
|
||||
@@ -352,7 +353,7 @@ async fn write_frame(frames: &FrameSender, frame: &Frame) -> Result<(), ()> {
|
||||
frames.send(Ok(Bytes::from(line))).await.map_err(|_| ())
|
||||
}
|
||||
|
||||
/// Log a finished send at info: destination, outcome, and how long, never the content.
|
||||
/// Request diagnostics are opt-in, including destinations and completion timing.
|
||||
pub fn log_outcome(description: &str, started: Instant, outcome: &str) {
|
||||
info!("{description} -> {outcome} in {:?}", started.elapsed());
|
||||
debug!("{description} -> {outcome} in {:?}", started.elapsed());
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
use axum::Router;
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::extract::{ConnectInfo, Request};
|
||||
use axum::http::StatusCode;
|
||||
use axum::routing::get;
|
||||
use clap::Parser;
|
||||
use std::io::{self, Write};
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tower::ServiceExt;
|
||||
use yaak_web::{Config, DEFAULT_LOG_FILTER, router};
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LogCapture(Arc<Mutex<Vec<u8>>>);
|
||||
|
||||
impl Write for LogCapture {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
self.0.lock().unwrap().extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn send_request(url: &str) -> Request {
|
||||
let payload = serde_json::json!({
|
||||
"request": { "url": url, "method": "GET" },
|
||||
"settings": {
|
||||
"validateCertificates": true,
|
||||
"followRedirects": true,
|
||||
"timeoutMs": 2000,
|
||||
"sendCookies": true,
|
||||
"storeCookies": true
|
||||
}
|
||||
});
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/v1/http/send")
|
||||
.header("content-type", "application/json")
|
||||
.extension(ConnectInfo("192.0.2.42:1234".parse::<SocketAddr>().unwrap()))
|
||||
.body(Body::from(payload.to_string()))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_logging_keeps_operational_warnings_without_request_details() {
|
||||
let capture = LogCapture(Arc::new(Mutex::new(Vec::new())));
|
||||
env_logger::Builder::new()
|
||||
.parse_filters(DEFAULT_LOG_FILTER)
|
||||
.target(env_logger::Target::Pipe(Box::new(capture.clone())))
|
||||
.init();
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let upstream = tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
Router::new().route("/private-path", get(|| async { "secret body" })),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
let mut config = Config::parse_from(["yaak-web"]);
|
||||
config.allow_private_networks = true;
|
||||
config.rate_limit_per_minute = 1;
|
||||
let app = router(config.clone());
|
||||
assert!(String::from_utf8_lossy(&capture.0.lock().unwrap()).contains("ALLOWED"));
|
||||
capture.0.lock().unwrap().clear();
|
||||
|
||||
// Exercise the HTTP engine and DNS lookup as well as the proxy's own logs.
|
||||
let url = format!("http://localhost:{port}/private-path?token=secret");
|
||||
let response = app.clone().oneshot(send_request(&url)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
|
||||
assert!(String::from_utf8_lossy(&body).contains("\"type\":\"done\""));
|
||||
assert_eq!(
|
||||
app.oneshot(send_request(&url)).await.unwrap().status(),
|
||||
StatusCode::TOO_MANY_REQUESTS
|
||||
);
|
||||
|
||||
config.allow_private_networks = false;
|
||||
let app = router(config.clone());
|
||||
assert_eq!(
|
||||
app.oneshot(send_request("http://127.0.0.1/private-path")).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
config.max_concurrent = 0;
|
||||
assert_eq!(
|
||||
router(config).oneshot(send_request(&url)).await.unwrap().status(),
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
);
|
||||
|
||||
upstream.abort();
|
||||
let logs = capture.0.lock().unwrap();
|
||||
assert!(logs.is_empty(), "Unexpected request logs: {}", String::from_utf8_lossy(&logs));
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use axum::body::{Body, to_bytes};
|
||||
use axum::extract::Request;
|
||||
use axum::http::{HeaderValue, StatusCode, header};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::Response;
|
||||
use std::path::PathBuf;
|
||||
use tower::ServiceExt;
|
||||
use yaak_web::{Config, router};
|
||||
|
||||
fn config(serve: Option<PathBuf>) -> Config {
|
||||
Config {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 0,
|
||||
app_port: None,
|
||||
serve,
|
||||
allow_private_networks: false,
|
||||
allowed_origins: vec!["*".into()],
|
||||
max_request_bytes: 1024,
|
||||
max_response_bytes: 2048,
|
||||
max_timeout_secs: 5,
|
||||
rate_limit_per_minute: 10,
|
||||
max_concurrent: 2,
|
||||
trust_forwarded_for: false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn mark_response(request: Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
response.headers_mut().insert("x-hosted-middleware", HeaderValue::from_static("yes"));
|
||||
response
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn embedded_router_serves_api_without_static_files() {
|
||||
let app = router(config(None)).layer(middleware::from_fn(mark_response));
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/v1/health").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["x-hosted-middleware"], "yes");
|
||||
let body = to_bytes(response.into_body(), 4096).await.unwrap();
|
||||
let health: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert_eq!(health["ok"], true);
|
||||
assert_eq!(health["maxResponseBytes"], 2048);
|
||||
assert_eq!(health["maxTimeoutSecs"], 5);
|
||||
|
||||
let response = app.oneshot(Request::new(Body::empty())).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_wraps_html_fallback_and_assets_alongside_api() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("index.html"), "<html>Yaak</html>").unwrap();
|
||||
std::fs::create_dir(dir.path().join("assets")).unwrap();
|
||||
std::fs::write(dir.path().join("assets/app-abc.js"), "// app").unwrap();
|
||||
let app = router(config(Some(dir.path().into()))).layer(middleware::from_fn(mark_response));
|
||||
|
||||
for path in [
|
||||
"/",
|
||||
"/workspace/example",
|
||||
"/assets/app-abc.js",
|
||||
"/v1/health",
|
||||
] {
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{path}");
|
||||
assert_eq!(response.headers()["x-hosted-middleware"], "yes", "{path}");
|
||||
if path.starts_with("/assets/") {
|
||||
assert_eq!(
|
||||
response.headers()[header::CACHE_CONTROL],
|
||||
"public, max-age=31536000, immutable"
|
||||
);
|
||||
} else if path == "/v1/health" {
|
||||
assert_eq!(response.headers()[header::CONTENT_TYPE], "application/json");
|
||||
} else {
|
||||
assert_eq!(response.headers()[header::CACHE_CONTROL], "no-cache");
|
||||
assert!(
|
||||
response.headers()[header::CONTENT_TYPE].to_str().unwrap().starts_with("text/html")
|
||||
);
|
||||
assert_eq!(to_bytes(response.into_body(), 4096).await.unwrap(), "<html>Yaak</html>");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,8 @@ reqwest = { workspace = true, features = [
|
||||
"brotli",
|
||||
"deflate",
|
||||
"json",
|
||||
"rustls-tls-manual-roots-no-provider",
|
||||
"query",
|
||||
"rustls-no-provider",
|
||||
"socks",
|
||||
"http2",
|
||||
] }
|
||||
|
||||
@@ -137,6 +137,7 @@ fn setup_window_menu<R: Runtime>(win: &WebviewWindow<R>) -> Result<()> {
|
||||
"dev.generate_theme_css" => {
|
||||
w.emit("generate_theme_css", true).unwrap();
|
||||
}
|
||||
"dev.show_home" => w.emit("show_home", true).unwrap(),
|
||||
"dev.toggle_devtools" => {
|
||||
if webview_window.is_devtools_open() {
|
||||
webview_window.close_devtools();
|
||||
|
||||
@@ -284,12 +284,26 @@ async fn handle_host_plugin_request<R: Runtime>(
|
||||
let workspace =
|
||||
workspace_from_window(&window).expect("Failed to get workspace_id from window URL");
|
||||
let cookie_jar = cookie_jar_from_window(&window);
|
||||
let environment = environment_from_window(&window);
|
||||
|
||||
if http_request.workspace_id.is_empty() {
|
||||
http_request.workspace_id = workspace.id;
|
||||
http_request.workspace_id = workspace.id.clone();
|
||||
}
|
||||
|
||||
let environment =
|
||||
if let Some(environment_id) = req.environment_id.as_deref() {
|
||||
if http_request.workspace_id != workspace.id {
|
||||
return Err(crate::error::Error::GenericError(
|
||||
"HTTP request does not belong to the selected workspace".to_string(),
|
||||
));
|
||||
}
|
||||
Some(window.db().get_environment_for_workspace(
|
||||
&http_request.workspace_id,
|
||||
environment_id,
|
||||
)?)
|
||||
} else {
|
||||
environment_from_window(&window)
|
||||
};
|
||||
|
||||
let http_response = if http_request.id.is_empty() {
|
||||
HttpResponse::default()
|
||||
} else {
|
||||
|
||||
@@ -667,10 +667,17 @@ async fn cmd_curl_to_request<R: Runtime>(
|
||||
Ok(yaak_commands::actions::cmd_curl_to_request(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<()> {
|
||||
async fn cmd_export_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdExportDataReq) -> Result<String> {
|
||||
Ok(yaak_commands::data::cmd_export_data(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_create_example_workspace<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdCreateExampleWorkspaceReq,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(yaak_commands::data::cmd_create_example_workspace(ctx, req).await?)
|
||||
}
|
||||
|
||||
async fn cmd_save_base64_to_binary<R: Runtime>(
|
||||
ctx: ClientCtx<R>,
|
||||
req: CmdSaveBase64ToBinaryReq,
|
||||
|
||||
@@ -165,6 +165,8 @@ pub fn app_menu<R: Runtime>(app_handle: &AppHandle<R>) -> tauri::Result<Menu<R>>
|
||||
"Generate Theme CSS",
|
||||
)
|
||||
.build(app_handle)?,
|
||||
&MenuItemBuilder::with_id("dev.show_home".to_string(), "Show Home Screen")
|
||||
.build(app_handle)?,
|
||||
],
|
||||
)?,
|
||||
],
|
||||
|
||||
+4
-2
@@ -31,6 +31,8 @@ export type CmdCheckForUpdatesReq = Record<string, never>;
|
||||
|
||||
export type CmdCommitImportReq = { plan: ImportPlan, };
|
||||
|
||||
export type CmdCreateExampleWorkspaceReq = Record<string, never>;
|
||||
|
||||
export type CmdCurlToRequestReq = { command: string, workspaceId: string, };
|
||||
|
||||
export type CmdDecryptTemplateReq = { template: string, };
|
||||
@@ -49,7 +51,7 @@ export type CmdDismissNotificationReq = { notificationId: string, };
|
||||
|
||||
export type CmdEnableEncryptionReq = { workspaceId: string, };
|
||||
|
||||
export type CmdExportDataReq = { exportPath: string, workspaceIds: Array<string>, includePrivateEnvironments: boolean, };
|
||||
export type CmdExportDataReq = { workspaceIds: Array<string>, includePrivateEnvironments: boolean, };
|
||||
|
||||
export type CmdFolderActionsReq = Record<string, never>;
|
||||
|
||||
@@ -254,6 +256,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
|
||||
|
||||
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
|
||||
|
||||
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, null], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_worktree_status: [CmdGitWorktreeStatusReq, GitWorktreeStatus], cmd_git_log: [CmdGitLogRLine truncated
|
||||
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_workLine truncated
|
||||
|
||||
export type WatchResult = { unlistenEvent: string, };
|
||||
@@ -366,11 +366,14 @@ pub struct CmdCurlToRequestReq {
|
||||
pub workspace_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdCreateExampleWorkspaceReq {}
|
||||
|
||||
#[derive(Debug, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = "gen_rpc.ts")]
|
||||
pub struct CmdExportDataReq {
|
||||
pub export_path: String,
|
||||
pub workspace_ids: Vec<String>,
|
||||
pub include_private_environments: bool,
|
||||
}
|
||||
@@ -956,7 +959,8 @@ macro_rules! with_commands {
|
||||
cmd_call_grpc_request_action(CmdCallGrpcRequestActionReq) -> (),
|
||||
cmd_call_http_authentication_action(CmdCallHttpAuthenticationActionReq) -> (),
|
||||
cmd_curl_to_request(CmdCurlToRequestReq) -> HttpRequest,
|
||||
cmd_export_data(CmdExportDataReq) -> (),
|
||||
cmd_export_data(CmdExportDataReq) -> String,
|
||||
cmd_create_example_workspace(CmdCreateExampleWorkspaceReq) -> BatchUpsertResult,
|
||||
cmd_save_base64_to_binary(CmdSaveBase64ToBinaryReq) -> (),
|
||||
cmd_save_response(CmdSaveResponseReq) -> (),
|
||||
cmd_send_http_request(CmdSendHttpRequestReq) -> HttpResponse,
|
||||
|
||||
@@ -2,22 +2,29 @@
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::host::Host;
|
||||
use std::path::Path;
|
||||
use yaak::export::{self, ExportDataParams};
|
||||
use yaak_models::example::create_example_workspace;
|
||||
use yaak_models::export::{self, ExportDataParams};
|
||||
use yaak_models::util::BatchUpsertResult;
|
||||
use yaak_rpc_schema::*;
|
||||
use yaak_templates::format_json::format_json;
|
||||
|
||||
pub async fn cmd_export_data<H: Host>(host: H, req: CmdExportDataReq) -> Result<()> {
|
||||
pub async fn cmd_export_data<H: Host>(host: H, req: CmdExportDataReq) -> Result<String> {
|
||||
let version = host.app_version();
|
||||
Ok(export::export_data(ExportDataParams {
|
||||
query_manager: host.query_manager(),
|
||||
yaak_version: &version,
|
||||
export_path: Path::new(&req.export_path),
|
||||
workspace_ids: req.workspace_ids.iter().map(|s| s.as_str()).collect(),
|
||||
include_private_environments: req.include_private_environments,
|
||||
})?)
|
||||
}
|
||||
|
||||
pub async fn cmd_create_example_workspace<H: Host>(
|
||||
host: H,
|
||||
_req: CmdCreateExampleWorkspaceReq,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
Ok(create_example_workspace(host.query_manager(), &host.update_source())?)
|
||||
}
|
||||
|
||||
pub async fn cmd_format_json<H: Host>(_host: H, req: CmdFormatJsonReq) -> Result<String> {
|
||||
Ok(format_json(&req.text, " "))
|
||||
}
|
||||
@@ -7,7 +7,7 @@ publish = false
|
||||
[dependencies]
|
||||
base32 = "0.5.1" # For encoding human-readable key
|
||||
base64 = "0.22.1" # For encoding in the database
|
||||
chacha20poly1305 = "0.10.1"
|
||||
chacha20poly1305 = "0.11.0"
|
||||
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
|
||||
log = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
use crate::error::Error::{DecryptionError, EncryptionError, InvalidEncryptedData};
|
||||
use crate::error::Result;
|
||||
use chacha20poly1305::aead::generic_array::typenum::Unsigned;
|
||||
use chacha20poly1305::aead::{Aead, AeadCore, Key, KeyInit, OsRng};
|
||||
use chacha20poly1305::XChaCha20Poly1305;
|
||||
use chacha20poly1305::aead::array::typenum::Unsigned;
|
||||
use chacha20poly1305::aead::{Aead, AeadCore, Generate, Key, KeyInit};
|
||||
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
|
||||
|
||||
const ENCRYPTION_TAG: &str = "yA4k3nC";
|
||||
const ENCRYPTION_VERSION: u8 = 1;
|
||||
|
||||
pub(crate) fn encrypt_data(data: &[u8], key: &Key<XChaCha20Poly1305>) -> Result<Vec<u8>> {
|
||||
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
|
||||
let nonce = XNonce::generate();
|
||||
let cipher = XChaCha20Poly1305::new(&key);
|
||||
let ciphered_data = cipher.encrypt(&nonce, data).map_err(|_| EncryptionError)?;
|
||||
|
||||
@@ -34,11 +34,12 @@ pub(crate) fn decrypt_data(cipher_data: &[u8], key: &Key<XChaCha20Poly1305>) ->
|
||||
return Err(InvalidEncryptedData);
|
||||
}
|
||||
|
||||
let nonce_bytes = <XChaCha20Poly1305 as AeadCore>::NonceSize::to_usize();
|
||||
let nonce_bytes = <XChaCha20Poly1305 as AeadCore>::NonceSize::USIZE;
|
||||
let (nonce, ciphered_data) = rest.split_at_checked(nonce_bytes).ok_or(InvalidEncryptedData)?;
|
||||
|
||||
let nonce: &XNonce = nonce.try_into().map_err(|_| InvalidEncryptedData)?;
|
||||
let cipher = XChaCha20Poly1305::new(&key);
|
||||
cipher.decrypt(nonce.into(), ciphered_data).map_err(|_e| DecryptionError)
|
||||
cipher.decrypt(nonce, ciphered_data).map_err(|_e| DecryptionError)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -46,12 +47,12 @@ mod test {
|
||||
use crate::encryption::{decrypt_data, encrypt_data};
|
||||
use crate::error::Error::InvalidEncryptedData;
|
||||
use crate::error::Result;
|
||||
use chacha20poly1305::aead::OsRng;
|
||||
use chacha20poly1305::{KeyInit, XChaCha20Poly1305};
|
||||
use chacha20poly1305::aead::{Generate, Key};
|
||||
use chacha20poly1305::XChaCha20Poly1305;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt() -> Result<()> {
|
||||
let key = XChaCha20Poly1305::generate_key(OsRng);
|
||||
let key = Key::<XChaCha20Poly1305>::generate();
|
||||
let encrypted = encrypt_data("hello world".as_bytes(), &key)?;
|
||||
let decrypted = decrypt_data(encrypted.as_slice(), &key)?;
|
||||
assert_eq!(String::from_utf8(decrypted).unwrap(), "hello world");
|
||||
@@ -60,7 +61,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_empty() -> Result<()> {
|
||||
let key = XChaCha20Poly1305::generate_key(OsRng);
|
||||
let key = Key::<XChaCha20Poly1305>::generate();
|
||||
let encrypted = encrypt_data(&[], &key)?;
|
||||
assert_eq!(encrypted.len(), 48);
|
||||
let decrypted = decrypt_data(encrypted.as_slice(), &key)?;
|
||||
@@ -70,7 +71,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_bad_version() -> Result<()> {
|
||||
let key = XChaCha20Poly1305::generate_key(OsRng);
|
||||
let key = Key::<XChaCha20Poly1305>::generate();
|
||||
let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?;
|
||||
encrypted[7] = 0;
|
||||
let decrypted = decrypt_data(encrypted.as_slice(), &key);
|
||||
@@ -80,7 +81,7 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_bad_tag() -> Result<()> {
|
||||
let key = XChaCha20Poly1305::generate_key(OsRng);
|
||||
let key = Key::<XChaCha20Poly1305>::generate();
|
||||
let mut encrypted = encrypt_data("hello world".as_bytes(), &key)?;
|
||||
encrypted[0] = 2;
|
||||
let decrypted = decrypt_data(encrypted.as_slice(), &key);
|
||||
@@ -90,9 +91,29 @@ mod test {
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_unencrypted_data() -> Result<()> {
|
||||
let key = XChaCha20Poly1305::generate_key(OsRng);
|
||||
let key = Key::<XChaCha20Poly1305>::generate();
|
||||
let decrypted = decrypt_data("123".as_bytes(), &key);
|
||||
assert!(matches!(decrypted, Err(InvalidEncryptedData)));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod compatibility {
|
||||
use super::*;
|
||||
|
||||
/// Produced by chacha20poly1305 0.10.1. Decrypting it here proves a crate
|
||||
/// upgrade hasn't changed the on-disk format, which users already have
|
||||
/// rows of in their databases.
|
||||
const V1_FROM_0_10: &str = "7941346b336e43017c7cb13467eecaa963b11734be636f9cc4152de348584fb27e95d1a70973e557cd335cf29e12d0d305d63ca0aa168f1b17003b1690a9d49140f0";
|
||||
|
||||
#[test]
|
||||
fn decrypts_data_written_by_the_previous_release() {
|
||||
let bytes: Vec<u8> = (0..V1_FROM_0_10.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&V1_FROM_0_10[i..i + 2], 16).unwrap())
|
||||
.collect();
|
||||
let key = Key::<XChaCha20Poly1305>::from([7u8; 32]);
|
||||
assert_eq!(decrypt_data(&bytes, &key).unwrap(), b"yaak golden vector");
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ use crate::encryption::{decrypt_data, encrypt_data};
|
||||
use crate::error::Error::GenericError;
|
||||
use crate::error::Result;
|
||||
use base32::Alphabet;
|
||||
use chacha20poly1305::aead::{Key, KeyInit, OsRng};
|
||||
use chacha20poly1305::aead::{Generate, Key};
|
||||
use chacha20poly1305::XChaCha20Poly1305;
|
||||
use keyring::{Entry, Error};
|
||||
use log::info;
|
||||
@@ -24,11 +24,12 @@ impl MasterKey {
|
||||
let without_prefix = encoded.strip_prefix(HUMAN_PREFIX).unwrap_or(&encoded);
|
||||
let key_bytes = base32::decode(Alphabet::Crockford {}, &without_prefix)
|
||||
.ok_or(GenericError("Failed to decode master key".to_string()))?;
|
||||
Key::<XChaCha20Poly1305>::clone_from_slice(key_bytes.as_slice())
|
||||
Key::<XChaCha20Poly1305>::try_from(key_bytes.as_slice())
|
||||
.map_err(|_| GenericError("Master key is the wrong length".to_string()))?
|
||||
}
|
||||
Err(Error::NoEntry) => {
|
||||
info!("Creating new master key");
|
||||
let key = XChaCha20Poly1305::generate_key(OsRng);
|
||||
let key = Key::<XChaCha20Poly1305>::generate();
|
||||
let encoded = base32::encode(Alphabet::Crockford {}, key.as_slice());
|
||||
let with_prefix = format!("{HUMAN_PREFIX}{encoded}");
|
||||
entry.set_password(&with_prefix)?;
|
||||
@@ -50,9 +51,7 @@ impl MasterKey {
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_key() -> Self {
|
||||
let key: Key<XChaCha20Poly1305> = Key::<XChaCha20Poly1305>::clone_from_slice(
|
||||
"00000000000000000000000000000000".as_bytes(),
|
||||
);
|
||||
let key = Key::<XChaCha20Poly1305>::from(*b"00000000000000000000000000000000");
|
||||
Self { key }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ use crate::encryption::{decrypt_data, encrypt_data};
|
||||
use crate::error::Error::InvalidHumanKey;
|
||||
use crate::error::Result;
|
||||
use base32::Alphabet;
|
||||
use chacha20poly1305::aead::{Key, KeyInit, OsRng};
|
||||
use chacha20poly1305::aead::{Generate, Key};
|
||||
use chacha20poly1305::{KeySizeUser, XChaCha20Poly1305};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -39,7 +39,9 @@ impl WorkspaceKey {
|
||||
}
|
||||
|
||||
pub(crate) fn from_raw_key(key: &[u8]) -> Self {
|
||||
Self { key: Key::<XChaCha20Poly1305>::clone_from_slice(key) }
|
||||
Self {
|
||||
key: Key::<XChaCha20Poly1305>::try_from(key).expect("workspace key must be 32 bytes"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn raw_key(&self) -> &[u8] {
|
||||
@@ -47,7 +49,7 @@ impl WorkspaceKey {
|
||||
}
|
||||
|
||||
pub(crate) fn create() -> Result<Self> {
|
||||
let key = XChaCha20Poly1305::generate_key(OsRng);
|
||||
let key = Key::<XChaCha20Poly1305>::generate();
|
||||
Ok(Self::from_raw_key(key.as_slice()))
|
||||
}
|
||||
|
||||
|
||||
@@ -13,15 +13,15 @@ hyper-rustls = { version = "0.27.7", default-features = false, features = ["http
|
||||
hyper-util = { version = "0.1.13", default-features = false, features = ["client-legacy"] }
|
||||
log = { workspace = true }
|
||||
md5 = "0.7.0"
|
||||
prost = "0.13.4"
|
||||
prost-reflect = { version = "0.14.4", default-features = false, features = ["serde", "derive"] }
|
||||
prost-types = "0.13.4"
|
||||
prost = "0.14.4"
|
||||
prost-reflect = { version = "0.16.5", default-features = false, features = ["serde", "derive"] }
|
||||
prost-types = "0.14.4"
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs", "process"] }
|
||||
tokio-stream = "0.1.14"
|
||||
tonic = { version = "0.12.3", default-features = false, features = ["transport"] }
|
||||
tonic-reflection = "0.12.3"
|
||||
tonic = { version = "0.14.6", default-features = false, features = ["transport"] }
|
||||
tonic-reflection = "0.14.6"
|
||||
uuid = { version = "1.7.0", features = ["v4"] }
|
||||
yaak-common = { workspace = true }
|
||||
yaak-tls = { workspace = true }
|
||||
|
||||
@@ -10,7 +10,7 @@ use log::debug;
|
||||
use std::collections::BTreeMap;
|
||||
use tokio_stream::StreamExt;
|
||||
use tonic::Request;
|
||||
use tonic::body::BoxBody;
|
||||
use tonic::body::Body;
|
||||
use tonic::transport::Uri;
|
||||
use tonic_reflection::pb::v1::server_reflection_request::MessageRequest;
|
||||
use tonic_reflection::pb::v1::server_reflection_response::MessageResponse;
|
||||
@@ -22,7 +22,7 @@ use tonic_reflection::pb::v1::{ExtensionRequest, FileDescriptorResponse};
|
||||
use tonic_reflection::pb::{v1, v1alpha};
|
||||
use yaak_tls::ClientCertificateConfig;
|
||||
|
||||
pub struct AutoReflectionClient<T = Client<HttpsConnector<HttpConnector>, BoxBody>> {
|
||||
pub struct AutoReflectionClient<T = Client<HttpsConnector<HttpConnector>, Body>> {
|
||||
use_v1alpha: bool,
|
||||
client_v1: v1::server_reflection_client::ServerReflectionClient<T>,
|
||||
client_v1alpha: v1alpha::server_reflection_client::ServerReflectionClient<T>,
|
||||
|
||||
@@ -27,7 +27,7 @@ use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::ReceiverStream;
|
||||
use tonic::body::BoxBody;
|
||||
use tonic::body::Body;
|
||||
use tonic::metadata::{MetadataKey, MetadataValue};
|
||||
use tonic::transport::Uri;
|
||||
use tonic::{IntoRequest, IntoStreamingRequest, Request, Response, Status, Streaming};
|
||||
@@ -36,7 +36,7 @@ use yaak_tls::ClientCertificateConfig;
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcConnection {
|
||||
pool: Arc<RwLock<DescriptorPool>>,
|
||||
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
||||
conn: Client<HttpsConnector<HttpConnector>, Body>,
|
||||
pub uri: Uri,
|
||||
use_reflection: bool,
|
||||
max_message_size: usize,
|
||||
@@ -338,10 +338,10 @@ impl GrpcConnection {
|
||||
}
|
||||
|
||||
fn grpc_client(
|
||||
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
||||
conn: Client<HttpsConnector<HttpConnector>, Body>,
|
||||
uri: Uri,
|
||||
max_message_size: usize,
|
||||
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
|
||||
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, Body>> {
|
||||
tonic::client::Grpc::with_origin(conn, uri)
|
||||
.max_decoding_message_size(max_message_size)
|
||||
.max_encoding_message_size(max_message_size)
|
||||
|
||||
@@ -4,7 +4,7 @@ use hyper_util::client::legacy::Client;
|
||||
use hyper_util::client::legacy::connect::HttpConnector;
|
||||
use hyper_util::rt::TokioExecutor;
|
||||
use log::info;
|
||||
use tonic::body::BoxBody;
|
||||
use tonic::body::Body;
|
||||
use yaak_tls::{ClientCertificateConfig, get_tls_config};
|
||||
|
||||
// I think ALPN breaks this because we're specifying http2_only
|
||||
@@ -13,7 +13,7 @@ const WITH_ALPN: bool = false;
|
||||
pub(crate) fn get_transport(
|
||||
validate_certificates: bool,
|
||||
client_cert: Option<ClientCertificateConfig>,
|
||||
) -> Result<Client<HttpsConnector<HttpConnector>, BoxBody>> {
|
||||
) -> Result<Client<HttpsConnector<HttpConnector>, Body>> {
|
||||
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
|
||||
|
||||
let mut http = HttpConnector::new();
|
||||
|
||||
@@ -20,9 +20,8 @@ log = { workspace = true }
|
||||
mime_guess = "2.0.5"
|
||||
native-tls = { version = "0.2", features = ["alpn"] }
|
||||
reqwest = { workspace = true, features = [
|
||||
"rustls-tls-manual-roots-no-provider",
|
||||
"rustls-no-provider",
|
||||
"native-tls",
|
||||
"native-tls-alpn",
|
||||
"socks",
|
||||
"http2",
|
||||
"stream",
|
||||
@@ -41,3 +40,4 @@ yaak-tls = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
@@ -10,8 +10,14 @@ use yaak_tls::{
|
||||
|
||||
pub const HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE: u32 = 1024 * 1024;
|
||||
|
||||
/// hyper caps HTTP/1 responses at 100 headers, which real servers exceed.
|
||||
/// https://yaak.app/feedback/posts/when-response-headers-exceed-a-certain-count-hyper-throws-error
|
||||
pub const HTTP1_MAX_RESPONSE_HEADERS: usize = 1024;
|
||||
|
||||
fn client_builder() -> ClientBuilder {
|
||||
Client::builder().http2_max_header_list_size(HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE)
|
||||
Client::builder()
|
||||
.http2_max_header_list_size(HTTP2_MAX_RESPONSE_HEADER_LIST_SIZE)
|
||||
.http1_max_headers(HTTP1_MAX_RESPONSE_HEADERS)
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -399,3 +405,100 @@ UFNAXPsoutUompC1Z57bKA6OKM2hRANCAATCYYKhzgHEaRaGsYVjJSoXvoroL8qe
|
||||
assert!(build_native_tls_identity(Some(empty)).unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_limit_tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
/// Accepts one connection and replies with a response head of exactly
|
||||
/// `total_headers` fields. The trailing `Content-Length` is one of them,
|
||||
/// because hyper counts every field against its limit.
|
||||
async fn serve_response_with_header_count(total_headers: usize) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
|
||||
// Read the request head so the client is not writing into a closed socket
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
let n = socket.read(&mut buf).await.unwrap();
|
||||
if n == 0 || buf[..n].windows(4).any(|w| w == b"\r\n\r\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let mut response = String::from("HTTP/1.1 200 OK\r\n");
|
||||
for i in 0..total_headers - 1 {
|
||||
response.push_str(&format!("X-Test-{i}: value-{i}\r\n"));
|
||||
}
|
||||
response.push_str("Content-Length: 0\r\n\r\n");
|
||||
|
||||
// A response the client rejects mid-parse closes the socket under
|
||||
// us, so a failed write here is an expected outcome, not a fault
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
let _ = socket.flush().await;
|
||||
});
|
||||
|
||||
format!("http://{addr}/")
|
||||
}
|
||||
|
||||
fn options() -> HttpConnectionOptions {
|
||||
HttpConnectionOptions {
|
||||
id: "test".to_string(),
|
||||
validate_certificates: true,
|
||||
http_version: HttpVersion::Http1,
|
||||
proxy: HttpConnectionProxySetting::Disabled,
|
||||
client_certificate: None,
|
||||
dns_overrides: Vec::new(),
|
||||
address_filter: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// hyper defaults to 100 header fields per HTTP/1 response and fails the
|
||||
/// whole request past that, which is what users hit in the field.
|
||||
#[tokio::test]
|
||||
async fn responses_with_more_than_100_headers_are_accepted() {
|
||||
let url = serve_response_with_header_count(150).await;
|
||||
let (client, _resolver) = options().build_client().unwrap();
|
||||
|
||||
let response = client
|
||||
.inner()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.expect("request with 150 response headers should succeed");
|
||||
|
||||
assert_eq!(response.status(), 200);
|
||||
assert_eq!(response.headers().len(), 150);
|
||||
for i in 0..149 {
|
||||
assert_eq!(
|
||||
response.headers().get(format!("x-test-{i}")).unwrap(),
|
||||
format!("value-{i}").as_str(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pins both sides of the boundary. The sizes are written out rather than
|
||||
/// derived from `HTTP1_MAX_RESPONSE_HEADERS`, so retuning the limit trips
|
||||
/// this test instead of silently moving with it.
|
||||
#[tokio::test]
|
||||
async fn the_limit_is_exactly_1024_header_fields() {
|
||||
let (client, _resolver) = options().build_client().unwrap();
|
||||
|
||||
let at_limit = serve_response_with_header_count(1024).await;
|
||||
let response = client
|
||||
.inner()
|
||||
.get(&at_limit)
|
||||
.send()
|
||||
.await
|
||||
.expect("a response with 1024 header fields should succeed");
|
||||
assert_eq!(response.headers().len(), 1024);
|
||||
|
||||
let over_limit = serve_response_with_header_count(1025).await;
|
||||
assert!(client.inner().get(&over_limit).send().await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ use sea_query::{IntoColumnRef, IntoIden, SimpleExpr};
|
||||
use std::cell::RefCell;
|
||||
use std::fmt::Debug;
|
||||
use std::ops::Deref;
|
||||
use std::sync::mpsc;
|
||||
use yaak_database::DbContext;
|
||||
|
||||
/// A read handle. Comes from the reader pool and can only query.
|
||||
@@ -80,7 +79,6 @@ impl<'a> ClientDb<'a> {
|
||||
/// discards them along with the rows.
|
||||
pub struct WriteDb<'a> {
|
||||
db: ClientDb<'a>,
|
||||
events_tx: mpsc::Sender<ModelPayload>,
|
||||
pending_events: RefCell<Vec<ModelPayload>>,
|
||||
}
|
||||
|
||||
@@ -93,8 +91,8 @@ impl<'a> Deref for WriteDb<'a> {
|
||||
}
|
||||
|
||||
impl<'a> WriteDb<'a> {
|
||||
pub fn new(ctx: DbContext<'a>, events_tx: mpsc::Sender<ModelPayload>) -> Self {
|
||||
Self { db: ClientDb::new(ctx), events_tx, pending_events: RefCell::new(Vec::new()) }
|
||||
pub fn new(ctx: DbContext<'a>) -> Self {
|
||||
Self { db: ClientDb::new(ctx), pending_events: RefCell::new(Vec::new()) }
|
||||
}
|
||||
|
||||
/// The events for everything written so far, to send once the
|
||||
|
||||
@@ -33,6 +33,9 @@ pub enum Error {
|
||||
#[error("No base environment for {0}")]
|
||||
MissingBaseEnvironment(String),
|
||||
|
||||
#[error("Invalid environment selection: {0}")]
|
||||
InvalidEnvironment(String),
|
||||
|
||||
#[error("Multiple base environments for {0}. Delete duplicates before continuing.")]
|
||||
MultipleBaseEnvironments(String),
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
//! The example workspace offered during onboarding.
|
||||
//!
|
||||
//! Authored as JSON with `{{PLACEHOLDER}}` ids so requests can reference each
|
||||
//! other (the chaining example names another request by id). Every placeholder
|
||||
//! gets a fresh id per creation, so the example can be created more than once.
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::models::{Environment, Folder, HttpRequest, UpsertModelInfo, Workspace};
|
||||
use crate::query_manager::QueryManager;
|
||||
use crate::util::{BatchUpsertResult, UpdateSource};
|
||||
|
||||
const EXAMPLE_JSON: &str = include_str!("example_workspace.json");
|
||||
|
||||
const WORKSPACE_IDS: &[&str] = &["WORKSPACE"];
|
||||
const ENVIRONMENT_IDS: &[&str] = &["ENV_BASE", "ENV_USER_2"];
|
||||
const FOLDER_IDS: &[&str] = &[
|
||||
"FOLDER_BASICS",
|
||||
"FOLDER_VARIABLES",
|
||||
"FOLDER_CHAINING",
|
||||
"FOLDER_AUTH",
|
||||
];
|
||||
const REQUEST_IDS: &[&str] = &[
|
||||
"RQ_LIST_POSTS",
|
||||
"RQ_GET_POST",
|
||||
"RQ_CREATE_POST",
|
||||
"RQ_CURRENT_USER",
|
||||
"RQ_LIST_USERS",
|
||||
"RQ_POSTS_BY_FIRST_USER",
|
||||
"RQ_TODOS",
|
||||
"RQ_LOG_IN",
|
||||
"RQ_ME",
|
||||
];
|
||||
|
||||
pub fn create_example_workspace(
|
||||
query_manager: &QueryManager,
|
||||
source: &UpdateSource,
|
||||
) -> Result<BatchUpsertResult> {
|
||||
let resources = example_resources()?;
|
||||
Ok(query_manager.with_tx(|tx| {
|
||||
tx.batch_upsert(
|
||||
resources.workspaces,
|
||||
resources.environments,
|
||||
resources.folders,
|
||||
resources.http_requests,
|
||||
resources.grpc_requests,
|
||||
resources.websocket_requests,
|
||||
source,
|
||||
)
|
||||
})?)
|
||||
}
|
||||
|
||||
fn example_resources() -> Result<BatchUpsertResult> {
|
||||
let mut json = EXAMPLE_JSON.to_string();
|
||||
let placeholders = WORKSPACE_IDS
|
||||
.iter()
|
||||
.map(|p| (*p, Workspace::generate_id()))
|
||||
.chain(ENVIRONMENT_IDS.iter().map(|p| (*p, Environment::generate_id())))
|
||||
.chain(FOLDER_IDS.iter().map(|p| (*p, Folder::generate_id())))
|
||||
.chain(REQUEST_IDS.iter().map(|p| (*p, HttpRequest::generate_id())));
|
||||
for (placeholder, id) in placeholders {
|
||||
json = json.replace(&format!("{{{{{placeholder}}}}}"), &id);
|
||||
}
|
||||
debug_assert!(!json.contains("{{"), "example workspace has an unmapped placeholder");
|
||||
Ok(serde_json::from_str(&json)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::init_in_memory;
|
||||
|
||||
#[test]
|
||||
fn every_placeholder_is_mapped() {
|
||||
let json = serde_json::to_string(&example_resources().unwrap()).unwrap();
|
||||
assert!(!json.contains("{{"), "unmapped placeholder in {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_request_references_a_created_request() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
|
||||
let created = create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||
assert_eq!(created.workspaces.len(), 1);
|
||||
let workspace_id = &created.workspaces[0].id;
|
||||
|
||||
let db = query_manager.connect();
|
||||
let requests = db.list_http_requests(workspace_id).unwrap();
|
||||
assert_eq!(requests.len(), REQUEST_IDS.len());
|
||||
assert_eq!(db.list_folders(workspace_id).unwrap().len(), FOLDER_IDS.len());
|
||||
assert_eq!(db.list_environments(workspace_id).unwrap().len(), ENVIRONMENT_IDS.len());
|
||||
|
||||
let list_users = requests.iter().find(|r| r.name == "List users").unwrap();
|
||||
let chained = requests.iter().find(|r| r.name == "Posts by the first user").unwrap();
|
||||
let param = &chained.url_parameters[0].value;
|
||||
assert!(param.contains(&format!("request='{}'", list_users.id)), "{param}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creating_twice_makes_two_workspaces() {
|
||||
let (query_manager, _blobs, _rx) = init_in_memory().unwrap();
|
||||
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||
create_example_workspace(&query_manager, &UpdateSource::Background).unwrap();
|
||||
assert_eq!(query_manager.connect().list_workspaces().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
{
|
||||
"workspaces": [
|
||||
{
|
||||
"id": "{{WORKSPACE}}",
|
||||
"name": "Example Workspace",
|
||||
"description": "A small tour of Yaak using the [Yaak Playground](https://yaak.run) API. Nothing here needs an account.\n\n1. Open **Getting Started / List posts** and press Send\n2. Open **Variables / Get the current user**, then switch the environment in the top-left to change which user it fetches\n3. Open **Chaining / Posts by the first user** to see how one request reads a value out of another request's response\n4. Open **Authentication / Get my profile** to see a login token used for you\n\nDelete this workspace whenever you're done with it."
|
||||
}
|
||||
],
|
||||
"environments": [
|
||||
{
|
||||
"id": "{{ENV_BASE}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Global Variables",
|
||||
"parentModel": "workspace",
|
||||
"public": true,
|
||||
"variables": [
|
||||
{ "name": "base_url", "value": "https://yaak.run", "enabled": true },
|
||||
{ "name": "user_id", "value": "1", "enabled": true },
|
||||
{ "name": "username", "value": "ada", "enabled": true },
|
||||
{ "name": "password", "value": "yaak", "enabled": true }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "{{ENV_USER_2}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Another User",
|
||||
"parentModel": "environment",
|
||||
"public": true,
|
||||
"color": "#c084fc",
|
||||
"variables": [
|
||||
{ "name": "user_id", "value": "2", "enabled": true },
|
||||
{ "name": "username", "value": "grace", "enabled": true }
|
||||
]
|
||||
}
|
||||
],
|
||||
"folders": [
|
||||
{
|
||||
"id": "{{FOLDER_BASICS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Getting Started",
|
||||
"sortPriority": 1,
|
||||
"description": "Plain requests. Send one, then look through the response tabs: Body, Headers, Cookies, and Timeline."
|
||||
},
|
||||
{
|
||||
"id": "{{FOLDER_VARIABLES}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Variables",
|
||||
"sortPriority": 2,
|
||||
"description": "Anything wrapped in `${[ ... ]}` is a template. Variables come from the environment selected in the top-left, and sub-environments override the base values."
|
||||
},
|
||||
{
|
||||
"id": "{{FOLDER_CHAINING}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Chaining",
|
||||
"sortPriority": 3,
|
||||
"description": "The `response()` template function reads a value out of another request's response, sending that request first if it has to. Put the cursor in a URL and press Ctrl+Space to insert one."
|
||||
},
|
||||
{
|
||||
"id": "{{FOLDER_AUTH}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"name": "Authentication",
|
||||
"sortPriority": 4,
|
||||
"description": "This folder sets Bearer auth to the token from the **Log in** response. Every request inside inherits it, so the token lives in one place. When the last login is more than 50 minutes old, Yaak logs in again before sending.",
|
||||
"authenticationType": "bearer",
|
||||
"authentication": {
|
||||
"token": "${[ response.body.path(request='{{RQ_LOG_IN}}', path='$.accessToken', behavior='ttl', ttl='3000') ]}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"httpRequests": [
|
||||
{
|
||||
"id": "{{RQ_LIST_POSTS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_BASICS}}",
|
||||
"name": "List posts",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/posts",
|
||||
"sortPriority": 1,
|
||||
"urlParameters": [{ "name": "limit", "value": "5", "enabled": true }],
|
||||
"description": "Query parameters live in the Params tab. Try raising `limit`."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_GET_POST}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_BASICS}}",
|
||||
"name": "Get a post",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/posts/1",
|
||||
"sortPriority": 2,
|
||||
"description": "Change the ID at the end of the URL and send again."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_CREATE_POST}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_BASICS}}",
|
||||
"name": "Create a post",
|
||||
"method": "POST",
|
||||
"url": "${[ base_url ]}/posts",
|
||||
"sortPriority": 3,
|
||||
"bodyType": "application/json",
|
||||
"body": {
|
||||
"text": "{\n \"title\": \"Hello from Yaak\",\n \"body\": \"Templates work inside bodies too\",\n \"userId\": ${[ user_id ]}\n}"
|
||||
},
|
||||
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
|
||||
"description": "Posts you create are only visible to you, and go back to the sample data after an hour. Put the new ID into **Get a post** to read it back."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_CURRENT_USER}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_VARIABLES}}",
|
||||
"name": "Get the current user",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/users/${[ user_id ]}",
|
||||
"sortPriority": 1,
|
||||
"description": "Switch to the **Another User** environment in the top-left and send again. Hover a template in the URL to see what it resolves to."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_TODOS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_VARIABLES}}",
|
||||
"name": "List the user's todos",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/users/${[ user_id ]}/todos",
|
||||
"sortPriority": 2,
|
||||
"urlParameters": [{ "name": "completed", "value": "false", "enabled": true }],
|
||||
"description": "The same `user_id` variable, used in a different request. Disable the `completed` parameter to see finished todos too."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_LIST_USERS}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_CHAINING}}",
|
||||
"name": "List users",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/users",
|
||||
"sortPriority": 1
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_POSTS_BY_FIRST_USER}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_CHAINING}}",
|
||||
"name": "Posts by the first user",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/posts",
|
||||
"sortPriority": 2,
|
||||
"urlParameters": [
|
||||
{
|
||||
"name": "userId",
|
||||
"value": "${[ response.body.path(request='{{RQ_LIST_USERS}}', path='$[0].id') ]}",
|
||||
"enabled": true
|
||||
}
|
||||
],
|
||||
"description": "The `userId` parameter is pulled from the **List users** response with the JSONPath `$[0].id`. If that request hasn't been sent yet, Yaak sends it first."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_LOG_IN}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_AUTH}}",
|
||||
"name": "Log in",
|
||||
"method": "POST",
|
||||
"url": "${[ base_url ]}/auth/token",
|
||||
"sortPriority": 1,
|
||||
"authenticationType": "none",
|
||||
"bodyType": "application/json",
|
||||
"body": {
|
||||
"text": "{\n \"username\": \"${[ username ]}\",\n \"password\": \"${[ password ]}\"\n}"
|
||||
},
|
||||
"headers": [{ "name": "Content-Type", "value": "application/json", "enabled": true }],
|
||||
"description": "Sends the username and password from the environment and returns a token. Its Auth tab is set to No Auth, so it doesn't try to use the token it's fetching."
|
||||
},
|
||||
{
|
||||
"id": "{{RQ_ME}}",
|
||||
"workspaceId": "{{WORKSPACE}}",
|
||||
"folderId": "{{FOLDER_AUTH}}",
|
||||
"name": "Get my profile",
|
||||
"method": "GET",
|
||||
"url": "${[ base_url ]}/auth/me",
|
||||
"sortPriority": 2,
|
||||
"description": "Open the Auth tab: it inherits Bearer auth from the folder, and there's nothing to copy and paste. Check the Timeline tab after sending to see the Authorization header that was sent. To try it as someone else, switch to **Another User** and send **Log in** again."
|
||||
}
|
||||
]
|
||||
}
|
||||
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user