mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-07-02 19:11:39 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 580302cbd2 | |||
| 3b9c311dc5 | |||
| 016fcba1c6 |
@@ -4,14 +4,13 @@
|
|||||||
|
|
||||||
## Submission
|
## Submission
|
||||||
|
|
||||||
- [ ] This PR is a bug fix.
|
- [ ] This PR is a bug fix or small-scope improvement.
|
||||||
- [ ] If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.
|
- [ ] If this PR is not a bug fix or small-scope improvement, I linked an approved feedback item below.
|
||||||
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
- [ ] I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||||
- [ ] I tested this change locally.
|
- [ ] I tested this change locally.
|
||||||
- [ ] I added or updated tests when reasonable.
|
- [ ] I added or updated tests when reasonable.
|
||||||
- [ ] I added screenshots or recordings for UI changes when reasonable.
|
|
||||||
|
|
||||||
Explicit permission feedback item (required if not a bug fix):
|
Approved feedback item (required if not a bug fix or small-scope improvement):
|
||||||
|
|
||||||
<!-- https://yaak.app/feedback/... -->
|
<!-- https://yaak.app/feedback/... -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,848 +0,0 @@
|
|||||||
const fs = require("node:fs");
|
|
||||||
|
|
||||||
const COMMENT_MARKER = "<!-- yaak-contribution-policy -->";
|
|
||||||
|
|
||||||
const MAINTAINER_LOGINS = new Set(["gschier"]);
|
|
||||||
const MAINTAINER_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]);
|
|
||||||
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain", "write"]);
|
|
||||||
const REVIEWER_LOGIN = "gschier";
|
|
||||||
|
|
||||||
const LARGE_DIFF_CHANGED_FILES = 20;
|
|
||||||
const LARGE_DIFF_CHANGED_LINES = 800;
|
|
||||||
const SUMMARY_TITLE_MAX_LENGTH = 80;
|
|
||||||
const AUTOMATIC_PR_CREATED_AFTER = "2026-06-30T07:00:00.000Z";
|
|
||||||
const AUTOMATIC_PR_CREATED_AFTER_LABEL = "June 30, 2026";
|
|
||||||
|
|
||||||
const LABELS = {
|
|
||||||
inScope: {
|
|
||||||
name: "contribution: in scope",
|
|
||||||
color: "0E8A16",
|
|
||||||
description: "Community PR appears to be in scope for maintainer review.",
|
|
||||||
},
|
|
||||||
outOfScope: {
|
|
||||||
name: "contribution: out of scope",
|
|
||||||
color: "B60205",
|
|
||||||
description: "Community PR does not match Yaak's contribution policy.",
|
|
||||||
},
|
|
||||||
explicitPermission: {
|
|
||||||
name: "contribution: explicit permission",
|
|
||||||
color: "5319E7",
|
|
||||||
description:
|
|
||||||
"Community PR links feedback where @gschier explicitly allowed the work.",
|
|
||||||
},
|
|
||||||
missingTemplate: {
|
|
||||||
name: "contribution: missing template",
|
|
||||||
color: "D93F0B",
|
|
||||||
description:
|
|
||||||
"Community PR is missing enough of the pull request template to review.",
|
|
||||||
},
|
|
||||||
policyUnmet: {
|
|
||||||
name: "contribution: policy unmet",
|
|
||||||
color: "B60205",
|
|
||||||
description:
|
|
||||||
"Community PR does not currently satisfy the contribution policy.",
|
|
||||||
},
|
|
||||||
needsScopeReview: {
|
|
||||||
name: "contribution: needs scope review",
|
|
||||||
color: "FBCA04",
|
|
||||||
description:
|
|
||||||
"Community PR may be broader than Yaak's bug-fix contribution policy.",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const MANAGED_LABEL_NAMES = [
|
|
||||||
...new Set(Object.values(LABELS).map((label) => label.name)),
|
|
||||||
];
|
|
||||||
|
|
||||||
const CHECKBOXES = {
|
|
||||||
bugFix: "This PR is a bug fix.",
|
|
||||||
explicitPermission:
|
|
||||||
"If this PR is not a bug fix, I linked the feedback item where @gschier explicitly gave me permission to work on it.",
|
|
||||||
readContributing:
|
|
||||||
"I have read and followed [`CONTRIBUTING.md`](CONTRIBUTING.md).",
|
|
||||||
testedLocally: "I tested this change locally.",
|
|
||||||
testsUpdated: "I added or updated tests when reasonable.",
|
|
||||||
screenshotsAdded:
|
|
||||||
"I added screenshots or recordings for UI changes when reasonable.",
|
|
||||||
};
|
|
||||||
|
|
||||||
function escapeRegExp(value) {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeBody(body) {
|
|
||||||
return (body || "").replace(/\r\n/g, "\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripComments(value) {
|
|
||||||
return value.replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSection(body, heading) {
|
|
||||||
const pattern = new RegExp(`^##\\s+${escapeRegExp(heading)}\\s*$`, "gim");
|
|
||||||
const match = pattern.exec(body);
|
|
||||||
|
|
||||||
if (match == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rest = body.slice(match.index + match[0].length);
|
|
||||||
const nextHeadingIndex = rest.search(/^##\s+/m);
|
|
||||||
return nextHeadingIndex === -1 ? rest : rest.slice(0, nextHeadingIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasMeaningfulText(value) {
|
|
||||||
return stripComments(value || "").length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCheckboxLabel(label) {
|
|
||||||
return label
|
|
||||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
||||||
.replace(/`/g, "")
|
|
||||||
.replace(/\s+/g, " ")
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkboxState(body, label) {
|
|
||||||
const expectedLabel = normalizeCheckboxLabel(label);
|
|
||||||
|
|
||||||
for (const line of body.split("\n")) {
|
|
||||||
const match = line.match(/^\s*[-*]\s*\[([ xX])\]\s*(.*?)\s*$/i);
|
|
||||||
|
|
||||||
if (match == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizeCheckboxLabel(match[2]) === expectedLabel) {
|
|
||||||
return match[1].toLowerCase() === "x";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findFeedbackUrl(body) {
|
|
||||||
return (
|
|
||||||
body.match(
|
|
||||||
/https?:\/\/(?:www\.)?(?:yaak\.app\/feedback|feedback\.yaak\.app)\/[^\s)>\]]+/i,
|
|
||||||
)?.[0] ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLabelNames(pr) {
|
|
||||||
return new Set((pr.labels || []).map((label) => label.name));
|
|
||||||
}
|
|
||||||
|
|
||||||
function analyzePullRequest(pr) {
|
|
||||||
const body = normalizeBody(pr.body);
|
|
||||||
const labelNames = getLabelNames(pr);
|
|
||||||
const states = Object.fromEntries(
|
|
||||||
Object.entries(CHECKBOXES).map(([key, label]) => [
|
|
||||||
key,
|
|
||||||
checkboxState(body, label),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
const sectionCount = ["Summary", "Submission", "Related"].filter(
|
|
||||||
(heading) => getSection(body, heading) != null,
|
|
||||||
).length;
|
|
||||||
const checkboxCount = Object.values(states).filter(
|
|
||||||
(state) => state != null,
|
|
||||||
).length;
|
|
||||||
const templateUsed = sectionCount >= 2 && checkboxCount >= 3;
|
|
||||||
const blockers = [];
|
|
||||||
const totalChangedLines =
|
|
||||||
Number(pr.additions || 0) + Number(pr.deletions || 0);
|
|
||||||
const changedFiles = Number(pr.changed_files || 0);
|
|
||||||
const largeDiff =
|
|
||||||
changedFiles > LARGE_DIFF_CHANGED_FILES ||
|
|
||||||
totalChangedLines > LARGE_DIFF_CHANGED_LINES;
|
|
||||||
|
|
||||||
if (labelNames.has(LABELS.outOfScope.name)) {
|
|
||||||
return {
|
|
||||||
blockers: [
|
|
||||||
{
|
|
||||||
label: LABELS.outOfScope.name,
|
|
||||||
message: "Marked out of scope by maintainer label.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
changedFiles,
|
|
||||||
desiredLabels: [LABELS.outOfScope.name],
|
|
||||||
largeDiff,
|
|
||||||
status: "out_of_scope",
|
|
||||||
templateUsed,
|
|
||||||
totalChangedLines,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (labelNames.has(LABELS.inScope.name)) {
|
|
||||||
return {
|
|
||||||
blockers: [],
|
|
||||||
changedFiles,
|
|
||||||
desiredLabels: [LABELS.inScope.name],
|
|
||||||
largeDiff,
|
|
||||||
status: "in_scope",
|
|
||||||
templateUsed,
|
|
||||||
totalChangedLines,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!templateUsed) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.missingTemplate.name,
|
|
||||||
message:
|
|
||||||
"Update the PR description with the repository pull request template.",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const summary = getSection(body, "Summary");
|
|
||||||
const hasSummary = hasMeaningfulText(summary);
|
|
||||||
const feedbackUrl = findFeedbackUrl(body);
|
|
||||||
const bugFix = states.bugFix === true;
|
|
||||||
const explicitPermission = states.explicitPermission === true;
|
|
||||||
|
|
||||||
if (!hasSummary) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message:
|
|
||||||
"Add a short summary describing the bug fix or permitted change.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bugFix && explicitPermission) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message:
|
|
||||||
"Choose either the bug-fix checkbox or the explicit-permission checkbox, not both.",
|
|
||||||
});
|
|
||||||
} else if (!bugFix && !explicitPermission) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message:
|
|
||||||
"Check whether this is a bug fix, or confirm that explicit permission from @gschier is linked.",
|
|
||||||
});
|
|
||||||
} else if (explicitPermission && feedbackUrl == null) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message:
|
|
||||||
"Link the feedback item where @gschier explicitly gave you permission to work on this.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (states.readContributing !== true) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message: "Confirm that `CONTRIBUTING.md` was read and followed.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (states.testedLocally !== true) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message: "Confirm that the change was tested locally.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (states.testsUpdated !== true) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message: "Confirm that tests were added or updated when reasonable.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (states.screenshotsAdded !== true) {
|
|
||||||
blockers.push({
|
|
||||||
label: LABELS.policyUnmet.name,
|
|
||||||
message:
|
|
||||||
"Confirm that screenshots or recordings were added for UI changes when reasonable.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const desiredLabels = new Set();
|
|
||||||
|
|
||||||
if (blockers.length === 0) {
|
|
||||||
desiredLabels.add(
|
|
||||||
largeDiff
|
|
||||||
? LABELS.needsScopeReview.name
|
|
||||||
: states.explicitPermission
|
|
||||||
? LABELS.explicitPermission.name
|
|
||||||
: LABELS.inScope.name,
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
blockers.some((blocker) => blocker.label === LABELS.missingTemplate.name)
|
|
||||||
) {
|
|
||||||
desiredLabels.add(LABELS.missingTemplate.name);
|
|
||||||
} else {
|
|
||||||
desiredLabels.add(LABELS.policyUnmet.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
blockers,
|
|
||||||
changedFiles,
|
|
||||||
desiredLabels: [...desiredLabels],
|
|
||||||
largeDiff,
|
|
||||||
status: blockers.length === 0 ? "in_scope" : "blocked",
|
|
||||||
templateUsed,
|
|
||||||
totalChangedLines,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildBlockingComment(analysis) {
|
|
||||||
const lines = [
|
|
||||||
COMMENT_MARKER,
|
|
||||||
"Thanks for the PR. Yaak currently accepts community PRs for bug fixes, plus larger changes that link a feedback item where @gschier explicitly gave permission to work on it.",
|
|
||||||
"",
|
|
||||||
"This PR cannot be accepted yet because the following contribution policy requirements were unmet:",
|
|
||||||
"",
|
|
||||||
...analysis.blockers.map((blocker) => `- ${blocker.message}`),
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!analysis.templateUsed) {
|
|
||||||
lines.push(
|
|
||||||
"",
|
|
||||||
"You can copy this template into the PR description and keep any existing context that is still useful.",
|
|
||||||
"",
|
|
||||||
"<details>",
|
|
||||||
"<summary>PR description template</summary>",
|
|
||||||
"",
|
|
||||||
"```md",
|
|
||||||
getPullRequestTemplate(),
|
|
||||||
"```",
|
|
||||||
"",
|
|
||||||
"</details>",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (analysis.largeDiff) {
|
|
||||||
lines.push(
|
|
||||||
"",
|
|
||||||
`This PR also changes ${analysis.changedFiles} files and ${analysis.totalChangedLines} lines, so it has been labeled as needing scope review. That label is advisory, but maintainers may ask for the scope to be reduced.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPullRequestTemplate() {
|
|
||||||
return fs.readFileSync(".github/pull_request_template.md", "utf8").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildInScopeComment() {
|
|
||||||
return [
|
|
||||||
COMMENT_MARKER,
|
|
||||||
"Thanks for the PR. This appears to match Yaak's contribution policy and is awaiting review by @gschier.",
|
|
||||||
"",
|
|
||||||
"This only means the PR is in scope for review. It does not mean the change has been reviewed or accepted for merge.",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOutOfScopeComment() {
|
|
||||||
return [
|
|
||||||
COMMENT_MARKER,
|
|
||||||
"Thanks for the PR. This does not appear to match Yaak's current contribution policy.",
|
|
||||||
"",
|
|
||||||
"Yaak currently accepts community PRs for bug fixes, or changes tied to a feedback item where @gschier explicitly gave permission to work on it.",
|
|
||||||
"",
|
|
||||||
"If this PR is tied to a feedback item where @gschier explicitly gave permission, please link it in the PR description.",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPolicyComment(analysis) {
|
|
||||||
if (analysis.status === "out_of_scope") {
|
|
||||||
return buildOutOfScopeComment();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (analysis.blockers.length > 0) {
|
|
||||||
return buildBlockingComment(analysis);
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildInScopeComment();
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value)
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateTitle(title) {
|
|
||||||
if (title.length <= SUMMARY_TITLE_MAX_LENGTH) {
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${title.slice(0, SUMMARY_TITLE_MAX_LENGTH - 3).trimEnd()}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeTableText(value) {
|
|
||||||
return escapeHtml(value).replace(/\n/g, "<br>");
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarizeResult({ pr, analysis, skipped, skipReason }) {
|
|
||||||
const comment =
|
|
||||||
analysis == null
|
|
||||||
? "None"
|
|
||||||
: buildPolicyComment(analysis).replace(COMMENT_MARKER, "").trim();
|
|
||||||
const summary = {
|
|
||||||
blocked: analysis?.blockers.length > 0,
|
|
||||||
comment,
|
|
||||||
details: "None",
|
|
||||||
labels:
|
|
||||||
analysis?.desiredLabels.length > 0
|
|
||||||
? analysis.desiredLabels.join(", ")
|
|
||||||
: "None",
|
|
||||||
number: pr.number,
|
|
||||||
prLink: `<a href="${escapeHtml(pr.html_url)}">#${pr.number}</a>`,
|
|
||||||
status: "In scope",
|
|
||||||
title: escapeHtml(truncateTitle(pr.title)),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (skipped) {
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
blocked: false,
|
|
||||||
comment: "None",
|
|
||||||
details: escapeHtml(skipReason),
|
|
||||||
labels: "None",
|
|
||||||
status: "Skipped",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (summary.blocked) {
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
comment: escapeTableText(summary.comment),
|
|
||||||
details: escapeHtml(
|
|
||||||
analysis.blockers.map((blocker) => blocker.message).join("; "),
|
|
||||||
),
|
|
||||||
labels: escapeHtml(summary.labels),
|
|
||||||
status: analysis.status === "out_of_scope" ? "Out of scope" : "Blocked",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...summary,
|
|
||||||
comment: escapeTableText(summary.comment),
|
|
||||||
labels: escapeHtml(summary.labels),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function wasCreatedBefore(value, cutoff) {
|
|
||||||
return Date.parse(value) < Date.parse(cutoff);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function isOfficialMaintainer({ github, owner, repo, pr }) {
|
|
||||||
if (MAINTAINER_LOGINS.has(pr.user.login)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (MAINTAINER_ASSOCIATIONS.has(pr.author_association)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await github.rest.repos.getCollaboratorPermissionLevel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
username: pr.user.login,
|
|
||||||
});
|
|
||||||
|
|
||||||
return MAINTAINER_PERMISSIONS.has(response.data.permission);
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 404) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureManagedLabels({ github, owner, repo }) {
|
|
||||||
for (const label of Object.values(LABELS)) {
|
|
||||||
try {
|
|
||||||
await github.rest.issues.getLabel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
name: label.name,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status !== 404) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
await github.rest.issues.createLabel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
name: label.name,
|
|
||||||
color: label.color,
|
|
||||||
description: label.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncLabels({ github, owner, repo, issueNumber, desiredLabels }) {
|
|
||||||
const desired = new Set(desiredLabels);
|
|
||||||
|
|
||||||
await ensureManagedLabels({ github, owner, repo });
|
|
||||||
|
|
||||||
for (const labelName of MANAGED_LABEL_NAMES) {
|
|
||||||
if (desired.has(labelName)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await github.rest.issues.removeLabel({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
name: labelName,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status !== 404) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (desired.size > 0) {
|
|
||||||
await github.rest.issues.addLabels({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
labels: [...desired],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findPolicyComment({ github, owner, repo, issueNumber }) {
|
|
||||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
|
|
||||||
return comments.find(
|
|
||||||
(comment) =>
|
|
||||||
comment.user.type === "Bot" && comment.body?.includes(COMMENT_MARKER),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function upsertPolicyComment({ github, owner, repo, issueNumber, body }) {
|
|
||||||
const existingComment = await findPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingComment == null) {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issue_number: issueNumber,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await github.rest.issues.updateComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: existingComment.id,
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deletePolicyComment({ github, owner, repo, issueNumber }) {
|
|
||||||
const existingComment = await findPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingComment == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await github.rest.issues.deleteComment({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
comment_id: existingComment.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function requestMaintainerReview({ github, owner, repo, pr }) {
|
|
||||||
if (pr.user.login === REVIEWER_LOGIN) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await github.rest.pulls.requestReviewers({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: pr.number,
|
|
||||||
reviewers: [REVIEWER_LOGIN],
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error.status === 422) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkPullRequest({
|
|
||||||
github,
|
|
||||||
core,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pullNumber,
|
|
||||||
dryRun,
|
|
||||||
skipCreatedBefore,
|
|
||||||
}) {
|
|
||||||
const response = await github.rest.pulls.get({
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pull_number: pullNumber,
|
|
||||||
});
|
|
||||||
const pr = response.data;
|
|
||||||
const issueNumber = pr.number;
|
|
||||||
|
|
||||||
if (
|
|
||||||
skipCreatedBefore != null &&
|
|
||||||
wasCreatedBefore(pr.created_at, skipCreatedBefore)
|
|
||||||
) {
|
|
||||||
core.notice(
|
|
||||||
`Skipping contribution policy for PR #${pr.number} because it was created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}.`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: `created before ${AUTOMATIC_PR_CREATED_AFTER_LABEL}`,
|
|
||||||
}),
|
|
||||||
skipped: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pr.draft) {
|
|
||||||
core.notice(`Skipping contribution policy for draft PR #${pr.number}.`);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: "draft",
|
|
||||||
}),
|
|
||||||
skipped: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (await isOfficialMaintainer({ github, owner, repo, pr })) {
|
|
||||||
core.notice(
|
|
||||||
`Skipping contribution policy for maintainer PR #${pr.number} from @${pr.user.login}.`,
|
|
||||||
);
|
|
||||||
if (!dryRun) {
|
|
||||||
await syncLabels({ github, owner, repo, issueNumber, desiredLabels: [] });
|
|
||||||
await deletePolicyComment({ github, owner, repo, issueNumber });
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({
|
|
||||||
pr,
|
|
||||||
skipped: true,
|
|
||||||
skipReason: `maintainer @${pr.user.login}`,
|
|
||||||
}),
|
|
||||||
skipped: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const analysis = analyzePullRequest(pr);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
const summary = summarizeResult({ pr, analysis });
|
|
||||||
core.notice(
|
|
||||||
`[dry-run] PR #${summary.number}: ${summary.status}; labels: ${summary.labels}; details: ${summary.details}`,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
blocked: analysis.blockers.length > 0,
|
|
||||||
number: pr.number,
|
|
||||||
summary,
|
|
||||||
skipped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await syncLabels({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
desiredLabels: analysis.desiredLabels,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (analysis.blockers.length > 0) {
|
|
||||||
await upsertPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
body: buildPolicyComment(analysis),
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
blocked: true,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({ pr, analysis }),
|
|
||||||
skipped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
await upsertPolicyComment({
|
|
||||||
github,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
issueNumber,
|
|
||||||
body: buildPolicyComment(analysis),
|
|
||||||
});
|
|
||||||
await requestMaintainerReview({ github, owner, repo, pr });
|
|
||||||
core.notice(`Contribution policy check passed for PR #${pr.number}.`);
|
|
||||||
return {
|
|
||||||
blocked: false,
|
|
||||||
number: pr.number,
|
|
||||||
summary: summarizeResult({ pr, analysis }),
|
|
||||||
skipped: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function listOpenPullRequests({ github, owner, repo }) {
|
|
||||||
return github.paginate(github.rest.pulls.list, {
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
state: "open",
|
|
||||||
per_page: 100,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getManualPullRequestNumbers({ context, core }) {
|
|
||||||
const value = String(context.payload.inputs?.pr || "all").trim();
|
|
||||||
|
|
||||||
if (value.toLowerCase() === "all") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pullNumber = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
|
|
||||||
core.setFailed('The "pr" input must be "all" or a positive PR number.');
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [pullNumber];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run({ github, context, core }) {
|
|
||||||
const { owner, repo } = context.repo;
|
|
||||||
const payloadPr = context.payload.pull_request;
|
|
||||||
const dryRunInput = context.payload.inputs?.dry_run;
|
|
||||||
const dryRun =
|
|
||||||
context.eventName === "workflow_dispatch" &&
|
|
||||||
dryRunInput !== false &&
|
|
||||||
dryRunInput !== "false";
|
|
||||||
const skipCreatedBefore =
|
|
||||||
payloadPr == null ? null : AUTOMATIC_PR_CREATED_AFTER;
|
|
||||||
let pullNumbers;
|
|
||||||
|
|
||||||
if (payloadPr != null) {
|
|
||||||
pullNumbers = [payloadPr.number];
|
|
||||||
} else {
|
|
||||||
pullNumbers = getManualPullRequestNumbers({ context, core });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pullNumbers?.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pullRequests =
|
|
||||||
pullNumbers == null
|
|
||||||
? await listOpenPullRequests({ github, owner, repo })
|
|
||||||
: pullNumbers.map((number) => ({ number }));
|
|
||||||
const results = [];
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
core.notice(
|
|
||||||
`Running contribution policy in dry-run mode for ${
|
|
||||||
pullNumbers == null
|
|
||||||
? "all open PRs"
|
|
||||||
: pullNumbers.map((number) => `#${number}`).join(", ")
|
|
||||||
}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const pr of pullRequests) {
|
|
||||||
results.push(
|
|
||||||
await checkPullRequest({
|
|
||||||
github,
|
|
||||||
core,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
pullNumber: pr.number,
|
|
||||||
dryRun,
|
|
||||||
skipCreatedBefore,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await core.summary
|
|
||||||
.addHeading(`Contribution Policy ${dryRun ? "Dry Run" : "Results"}`)
|
|
||||||
.addTable([
|
|
||||||
[
|
|
||||||
{ data: "PR", header: true },
|
|
||||||
{ data: "Title", header: true },
|
|
||||||
{ data: "Status", header: true },
|
|
||||||
{ data: "Labels", header: true },
|
|
||||||
{ data: "Details", header: true },
|
|
||||||
{ data: "Comment", header: true },
|
|
||||||
],
|
|
||||||
...results.map((result) => [
|
|
||||||
result.summary.prLink,
|
|
||||||
result.summary.title,
|
|
||||||
result.summary.status,
|
|
||||||
result.summary.labels,
|
|
||||||
result.summary.details,
|
|
||||||
result.summary.comment,
|
|
||||||
]),
|
|
||||||
])
|
|
||||||
.write();
|
|
||||||
|
|
||||||
const blockedPullRequests = results.filter((result) => result.blocked);
|
|
||||||
|
|
||||||
if (blockedPullRequests.length > 0) {
|
|
||||||
if (dryRun) {
|
|
||||||
core.warning(
|
|
||||||
`Dry run found contribution policy failures for PR(s): ${blockedPullRequests
|
|
||||||
.map((result) => `#${result.number}`)
|
|
||||||
.join(", ")}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
core.setFailed(
|
|
||||||
`Contribution policy failed for PR(s): ${blockedPullRequests
|
|
||||||
.map((result) => `#${result.number}`)
|
|
||||||
.join(", ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
analyzePullRequest,
|
|
||||||
run,
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
name: Contribution Policy
|
|
||||||
|
|
||||||
on:
|
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
pr:
|
|
||||||
description: PR number or all
|
|
||||||
required: true
|
|
||||||
default: all
|
|
||||||
type: string
|
|
||||||
dry_run:
|
|
||||||
description: Dry run
|
|
||||||
required: true
|
|
||||||
default: true
|
|
||||||
type: boolean
|
|
||||||
pull_request_target:
|
|
||||||
types:
|
|
||||||
- opened
|
|
||||||
- edited
|
|
||||||
- reopened
|
|
||||||
- synchronize
|
|
||||||
- ready_for_review
|
|
||||||
- labeled
|
|
||||||
- unlabeled
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
issues: write
|
|
||||||
pull-requests: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
check:
|
|
||||||
name: Check contribution policy
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout policy script
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.base.sha || github.ref }}
|
|
||||||
fetch-depth: 1
|
|
||||||
|
|
||||||
- name: Check contribution policy
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { run } = require("./.github/scripts/check-contribution-policy.js");
|
|
||||||
await run({ github, context, core });
|
|
||||||
+2
-1
@@ -3,12 +3,13 @@
|
|||||||
Yaak accepts community pull requests for:
|
Yaak accepts community pull requests for:
|
||||||
|
|
||||||
- Bug fixes
|
- Bug fixes
|
||||||
|
- Small-scope improvements directly tied to existing behavior
|
||||||
|
|
||||||
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
|
Pull requests that introduce broad new features, major redesigns, or large refactors are out of scope unless explicitly approved first.
|
||||||
|
|
||||||
## Approval for Non-Bugfix Changes
|
## Approval for Non-Bugfix Changes
|
||||||
|
|
||||||
If your PR is not a bug fix, include a link to the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
|
If your PR is not a bug fix or small-scope improvement, include a link to the approved [feedback item](https://yaak.app/feedback) where contribution approval was explicitly stated.
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
|
|||||||
Generated
+9
-9
@@ -215,7 +215,7 @@ dependencies = [
|
|||||||
"objc2-foundation 0.3.1",
|
"objc2-foundation 0.3.1",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
"wl-clipboard-rs",
|
"wl-clipboard-rs",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
]
|
]
|
||||||
@@ -1151,7 +1151,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static 1.5.0",
|
"lazy_static 1.5.0",
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1970,7 +1970,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6534,7 +6534,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6547,7 +6547,7 @@ dependencies = [
|
|||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.9.4",
|
"linux-raw-sys 0.9.4",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7508,9 +7508,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tar"
|
name = "tar"
|
||||||
version = "0.4.46"
|
version = "0.4.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"filetime",
|
"filetime",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -7988,7 +7988,7 @@ dependencies = [
|
|||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.0.7",
|
"rustix 1.0.7",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -9317,7 +9317,7 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -57,8 +57,8 @@ Built with [Tauri](https://tauri.app), Rust, and React, it’s fast, lightweight
|
|||||||
## Contribution Policy
|
## Contribution Policy
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Community PRs are currently limited to bug fixes.
|
> Community PRs are currently limited to bug fixes and small-scope improvements.
|
||||||
> If your PR is not a bug fix, link the [feedback item](https://yaak.app/feedback) where @gschier explicitly gave you permission to work on it.
|
> If your PR is out of scope, link an approved feedback item from [yaak.app/feedback](https://yaak.app/feedback).
|
||||||
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
> See [`CONTRIBUTING.md`](CONTRIBUTING.md) for policy details and [`DEVELOPMENT.md`](DEVELOPMENT.md) for local setup.
|
||||||
|
|
||||||
## Useful Resources
|
## Useful Resources
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
@@ -90,8 +89,6 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<CommercialUseBanner source="git-clone" title="Using Git for work?" />
|
|
||||||
|
|
||||||
<PlainInput
|
<PlainInput
|
||||||
required
|
required
|
||||||
label="Repository URL"
|
label="Repository URL"
|
||||||
|
|||||||
@@ -1,130 +0,0 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
|
||||||
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
|
||||||
import { useKeyValue } from "../hooks/useKeyValue";
|
|
||||||
import { appInfo } from "../lib/appInfo";
|
|
||||||
import { pricingUrl } from "../lib/pricingUrl";
|
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
|
||||||
|
|
||||||
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
||||||
const COMMERCIAL_USE_BANNER_MESSAGE =
|
|
||||||
"Personal use of Yaak is free. If you’re using Yaak at work, please purchase a license.";
|
|
||||||
|
|
||||||
export function CommercialUseBanner({
|
|
||||||
source,
|
|
||||||
title,
|
|
||||||
}: {
|
|
||||||
source: string;
|
|
||||||
title: string;
|
|
||||||
}) {
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const snoozeStartedRef = useRef(false);
|
|
||||||
const {
|
|
||||||
isLoading: isSnoozeLoading,
|
|
||||||
set: setSnoozedAt,
|
|
||||||
value: snoozedAt,
|
|
||||||
} = useKeyValue<string | null>({
|
|
||||||
namespace: "global",
|
|
||||||
key: "commercial-use-banner-snoozed-at",
|
|
||||||
fallback: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let canceled = false;
|
|
||||||
|
|
||||||
shouldShowCommercialUsePrompt()
|
|
||||||
.then((shouldShow) => {
|
|
||||||
if (!canceled) setVisible(shouldShow);
|
|
||||||
})
|
|
||||||
.catch(console.error);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
canceled = true;
|
|
||||||
};
|
|
||||||
}, [source]);
|
|
||||||
|
|
||||||
const snoozed = isSnoozed(snoozedAt, COMMERCIAL_USE_SNOOZE_MS);
|
|
||||||
const handleShow = useCallback(() => {
|
|
||||||
if (snoozeStartedRef.current || snoozed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
snoozeStartedRef.current = true;
|
|
||||||
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() })).catch(console.error);
|
|
||||||
}, [setSnoozedAt, snoozed, source]);
|
|
||||||
|
|
||||||
if (!visible || isSnoozeLoading || (snoozed && !snoozeStartedRef.current)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full">
|
|
||||||
<DismissibleBanner
|
|
||||||
id={`commercial-use:${source}`}
|
|
||||||
color="info"
|
|
||||||
className="w-full"
|
|
||||||
onDismiss={() =>
|
|
||||||
setSnoozedAt(JSON.stringify({ source, at: new Date().toISOString() }))
|
|
||||||
}
|
|
||||||
onShow={handleShow}
|
|
||||||
actions={[
|
|
||||||
{
|
|
||||||
label: "Purchase License",
|
|
||||||
color: "info",
|
|
||||||
variant: "solid",
|
|
||||||
onClick: () => {
|
|
||||||
openCommercialUsePricing(source).catch(console.error);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<div className="text-sm">
|
|
||||||
<p className="font-semibold text-text">{title}</p>
|
|
||||||
<p className="mt-0.5 text-text-subtle">{COMMERCIAL_USE_BANNER_MESSAGE}</p>
|
|
||||||
</div>
|
|
||||||
</DismissibleBanner>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
|
||||||
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
|
|
||||||
if (appInfo.featureLicense !== true) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
|
|
||||||
return license.status === "personal_use";
|
|
||||||
} catch (err) {
|
|
||||||
console.log("Failed to check license before commercial-use prompt", err);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openCommercialUsePricing(source: string): Promise<void> {
|
|
||||||
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSnoozed(value: string | null, ms: number): boolean {
|
|
||||||
if (value == null) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const snooze = JSON.parse(value) as { at?: unknown };
|
|
||||||
const at = typeof snooze.at === "string" ? snooze.at : null;
|
|
||||||
return isWithinMs(at, ms);
|
|
||||||
} catch {
|
|
||||||
// Older builds stored only the timestamp, so keep respecting that as a global snooze.
|
|
||||||
return isWithinMs(value, ms);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWithinMs(date: string | null, ms: number): boolean {
|
|
||||||
if (date == null) return false;
|
|
||||||
|
|
||||||
const time = new Date(date).getTime();
|
|
||||||
if (Number.isNaN(time)) return false;
|
|
||||||
|
|
||||||
return Date.now() - time < ms;
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,6 @@ import slugify from "slugify";
|
|||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
@@ -86,10 +85,8 @@ function ExportDataDialogContent({
|
|||||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||||
const noneSelected = numSelected === 0;
|
const noneSelected = numSelected === 0;
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
|
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
<VStack space={3} className="overflow-auto px-5 pb-6">
|
||||||
<CommercialUseBanner source="data-export" title="Exporting work data?" />
|
|
||||||
|
|
||||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -140,9 +137,9 @@ function ExportDataDialogContent({
|
|||||||
/>
|
/>
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
</VStack>
|
</VStack>
|
||||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
|
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
|
||||||
<div>
|
<div>
|
||||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
|
||||||
Create Run Button
|
Create Run Button
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,17 +10,14 @@ import { HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||||
import { useAuthDropdownOptions } from "../hooks/useAuthTab";
|
|
||||||
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
import { useHttpAuthenticationConfig } from "../hooks/useHttpAuthenticationConfig";
|
||||||
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
import { useInheritedAuthentication } from "../hooks/useInheritedAuthentication";
|
||||||
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
import { useRenderTemplate } from "../hooks/useRenderTemplate";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { Button } from "./core/Button";
|
|
||||||
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
import { Dropdown, type DropdownItem } from "./core/Dropdown";
|
||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import { Input, type InputProps } from "./core/Input";
|
import { Input, type InputProps } from "./core/Input";
|
||||||
import { Link } from "./core/Link";
|
import { Link } from "./core/Link";
|
||||||
import { RadioDropdown } from "./core/RadioDropdown";
|
|
||||||
import { SegmentedControl } from "./core/SegmentedControl";
|
import { SegmentedControl } from "./core/SegmentedControl";
|
||||||
import { DynamicForm } from "./DynamicForm";
|
import { DynamicForm } from "./DynamicForm";
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
@@ -38,8 +35,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
async (authentication: Record<string, unknown>) =>
|
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
|
||||||
await patchModel(model, { authentication }),
|
|
||||||
[model],
|
[model],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -51,8 +47,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
return (
|
return (
|
||||||
<EmptyStateText>
|
<EmptyStateText>
|
||||||
<p>
|
<p>
|
||||||
Auth plugin not found for{" "}
|
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
|
||||||
<InlineCode>{model.authenticationType}</InlineCode>
|
|
||||||
</p>
|
</p>
|
||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
);
|
);
|
||||||
@@ -61,20 +56,11 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
if (inheritedAuth == null) {
|
if (inheritedAuth == null) {
|
||||||
if (model.model === "workspace" || model.model === "folder") {
|
if (model.model === "workspace" || model.model === "folder") {
|
||||||
return (
|
return (
|
||||||
<EmptyStateText className="flex-col gap-3">
|
<EmptyStateText className="flex-col gap-1">
|
||||||
<div className="not-italic flex flex-col items-center gap-3 text-center">
|
<p>
|
||||||
<p className="max-w-md text-sm text-text-subtle">
|
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
||||||
Choose an auth method to apply it to all requests in{" "}
|
|
||||||
<strong className="font-semibold text-text-subtle">
|
|
||||||
{resolvedModelName(model)}
|
|
||||||
</strong>
|
|
||||||
.
|
|
||||||
</p>
|
</p>
|
||||||
<AuthenticationTypeDropdown model={model} />
|
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
||||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">
|
|
||||||
Documentation
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</EmptyStateText>
|
</EmptyStateText>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,8 +83,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
type="submit"
|
type="submit"
|
||||||
className="underline hover:text-text"
|
className="underline hover:text-text"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (inheritedAuth.model === "folder")
|
if (inheritedAuth.model === "folder") openFolderSettings(inheritedAuth.id, "auth");
|
||||||
openFolderSettings(inheritedAuth.id, "auth");
|
|
||||||
else openWorkspaceSettings("auth");
|
else openWorkspaceSettings("auth");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -118,8 +103,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
hideLabel
|
hideLabel
|
||||||
name="enabled"
|
name="enabled"
|
||||||
value={
|
value={
|
||||||
model.authentication.disabled === false ||
|
model.authentication.disabled === false || model.authentication.disabled == null
|
||||||
model.authentication.disabled == null
|
|
||||||
? "__TRUE__"
|
? "__TRUE__"
|
||||||
: model.authentication.disabled === true
|
: model.authentication.disabled === true
|
||||||
? "__FALSE__"
|
? "__FALSE__"
|
||||||
@@ -167,9 +151,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
stateKey={`auth.${model.id}.dynamic`}
|
stateKey={`auth.${model.id}.dynamic`}
|
||||||
value={model.authentication.disabled}
|
value={model.authentication.disabled}
|
||||||
onChange={(v) =>
|
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
|
||||||
handleChange({ ...model.authentication, disabled: v })
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -187,33 +169,6 @@ export function HttpAuthenticationEditor({ model }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthenticationTypeDropdown({ model }: Props) {
|
|
||||||
const options = useAuthDropdownOptions(model);
|
|
||||||
|
|
||||||
if (options == null) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioDropdown
|
|
||||||
items={options.items}
|
|
||||||
itemsAfter={options.itemsAfter}
|
|
||||||
itemsBefore={options.itemsBefore}
|
|
||||||
value={options.value}
|
|
||||||
onChange={options.onChange}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
color="secondary"
|
|
||||||
variant="border"
|
|
||||||
size="sm"
|
|
||||||
rightSlot={
|
|
||||||
<Icon icon="chevron_down" size="sm" className="text-text-subtle" />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Select Auth
|
|
||||||
</Button>
|
|
||||||
</RadioDropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthenticationDisabledInput({
|
function AuthenticationDisabledInput({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -243,11 +198,7 @@ function AuthenticationDisabledInput({
|
|||||||
rightSlot={
|
rightSlot={
|
||||||
<div className="px-1 flex items-center">
|
<div className="px-1 flex items-center">
|
||||||
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
||||||
{rendered.isPending
|
{rendered.isPending ? "loading" : rendered.data ? "enabled" : "disabled"}
|
||||||
? "loading"
|
|
||||||
: rendered.data
|
|
||||||
? "enabled"
|
|
||||||
: "disabled"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
|
|||||||
import { deepEqualAtom } from "../lib/atoms";
|
import { deepEqualAtom } from "../lib/atoms";
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { generateId } from "../lib/generateId";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
|
||||||
import {
|
import {
|
||||||
BODY_TYPE_BINARY,
|
BODY_TYPE_BINARY,
|
||||||
BODY_TYPE_FORM_MULTIPART,
|
BODY_TYPE_FORM_MULTIPART,
|
||||||
@@ -132,7 +131,9 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||||
|
(m) => m[1] ?? "",
|
||||||
|
);
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
const items: Pair[] = [...nonEmptyParameters];
|
||||||
for (const name of placeholderNames) {
|
for (const name of placeholderNames) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
|
||||||
@@ -15,8 +14,6 @@ export function ImportDataDialog({ importData }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={5} className="pb-4">
|
<VStack space={5} className="pb-4">
|
||||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
|
||||||
|
|
||||||
<VStack space={1}>
|
<VStack space={1}>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>OpenAPI 3.0, 3.1</li>
|
<li>OpenAPI 3.0, 3.1</li>
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
modelSupportsSetting,
|
modelSupportsSetting,
|
||||||
type RequestSettingDefinition,
|
type RequestSettingDefinition,
|
||||||
SETTING_FOLLOW_REDIRECTS,
|
SETTING_FOLLOW_REDIRECTS,
|
||||||
SETTING_REQUEST_MESSAGE_SIZE,
|
|
||||||
SETTING_REQUEST_TIMEOUT,
|
SETTING_REQUEST_TIMEOUT,
|
||||||
SETTING_SEND_COOKIES,
|
SETTING_SEND_COOKIES,
|
||||||
SETTING_STORE_COOKIES,
|
SETTING_STORE_COOKIES,
|
||||||
@@ -23,44 +22,21 @@ import { Checkbox } from "./core/Checkbox";
|
|||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import {
|
import {
|
||||||
SettingOverrideRow,
|
SettingOverrideRow,
|
||||||
SettingRow,
|
|
||||||
SettingRowBoolean,
|
SettingRowBoolean,
|
||||||
|
SettingRowNumber,
|
||||||
SettingsList,
|
SettingsList,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from "./core/SettingRow";
|
} from "./core/SettingRow";
|
||||||
|
|
||||||
const BYTES_PER_MB = 1024 * 1024;
|
|
||||||
const MAX_REQUEST_MESSAGE_SIZE_BYTES = 2_147_483_647;
|
|
||||||
const MAX_MESSAGE_SIZE_MB = MAX_REQUEST_MESSAGE_SIZE_BYTES / BYTES_PER_MB;
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
showSectionTitles?: boolean;
|
showSectionTitles?: boolean;
|
||||||
model: ModelWithSettings;
|
model: ModelWithSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ModelWithSettings =
|
type ModelWithSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
type ModelWithHttpSettings = Workspace | Folder | HttpRequest;
|
||||||
type ModelWithTlsSettings =
|
type ModelWithTlsSettings = Workspace | Folder | HttpRequest | WebsocketRequest | GrpcRequest;
|
||||||
| Workspace
|
type ModelWithCookieSettings = Workspace | Folder | HttpRequest | WebsocketRequest;
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type ModelWithCookieSettings =
|
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| HttpRequest
|
|
||||||
| WebsocketRequest;
|
|
||||||
type ModelWithMessageSizeSettings =
|
|
||||||
| Workspace
|
|
||||||
| Folder
|
|
||||||
| WebsocketRequest
|
|
||||||
| GrpcRequest;
|
|
||||||
type BooleanSetting = boolean | InheritedBoolSetting;
|
type BooleanSetting = boolean | InheritedBoolSetting;
|
||||||
type IntegerSetting = number | InheritedIntSetting;
|
type IntegerSetting = number | InheritedIntSetting;
|
||||||
type CookieSettingsPatch = {
|
type CookieSettingsPatch = {
|
||||||
@@ -74,19 +50,12 @@ type HttpSettingsPatch = {
|
|||||||
type TlsSettingsPatch = {
|
type TlsSettingsPatch = {
|
||||||
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
|
settingValidateCertificates?: ModelWithTlsSettings["settingValidateCertificates"];
|
||||||
};
|
};
|
||||||
type MessageSizeSettingsPatch = {
|
|
||||||
settingRequestMessageSize?: ModelWithMessageSizeSettings["settingRequestMessageSize"];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ModelSettingsEditor({
|
export function ModelSettingsEditor({ model, showSectionTitles = false }: Props) {
|
||||||
model,
|
|
||||||
showSectionTitles = false,
|
|
||||||
}: Props) {
|
|
||||||
const ancestors = useModelAncestors(model);
|
const ancestors = useModelAncestors(model);
|
||||||
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
const supportsHttpSettings = modelSupportsHttpSettings(model);
|
||||||
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
const supportsCookieSettings = modelSupportsCookieSettings(model);
|
||||||
const supportsTlsSettings = modelSupportsTlsSettings(model);
|
const supportsTlsSettings = modelSupportsTlsSettings(model);
|
||||||
const supportsMessageSizeSettings = modelSupportsMessageSizeSettings(model);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsList className="space-y-8">
|
<SettingsList className="space-y-8">
|
||||||
@@ -108,22 +77,6 @@ export function ModelSettingsEditor({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{supportsMessageSizeSettings && (
|
|
||||||
<MessageSizeSettingRow
|
|
||||||
settingDefinition={SETTING_REQUEST_MESSAGE_SIZE}
|
|
||||||
setting={model.settingRequestMessageSize}
|
|
||||||
inheritedValue={resolveInheritedValue(
|
|
||||||
ancestors,
|
|
||||||
SETTING_REQUEST_MESSAGE_SIZE.modelKey,
|
|
||||||
model.settingRequestMessageSize,
|
|
||||||
)}
|
|
||||||
onChange={(settingRequestMessageSize) =>
|
|
||||||
patchMessageSizeSettings(model, {
|
|
||||||
settingRequestMessageSize,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<BooleanSettingRow
|
<BooleanSettingRow
|
||||||
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
|
settingDefinition={SETTING_VALIDATE_CERTIFICATES}
|
||||||
setting={model.settingValidateCertificates}
|
setting={model.settingValidateCertificates}
|
||||||
@@ -157,9 +110,7 @@ export function ModelSettingsEditor({
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
{supportsCookieSettings && (
|
{supportsCookieSettings && (
|
||||||
<SettingsSection
|
<SettingsSection title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}>
|
||||||
title={supportsTlsSettings || showSectionTitles ? "Cookies" : null}
|
|
||||||
>
|
|
||||||
<BooleanSettingRow
|
<BooleanSettingRow
|
||||||
settingDefinition={SETTING_SEND_COOKIES}
|
settingDefinition={SETTING_SEND_COOKIES}
|
||||||
setting={model.settingSendCookies}
|
setting={model.settingSendCookies}
|
||||||
@@ -207,103 +158,46 @@ export function countOverriddenSettings(model: ModelWithSettings) {
|
|||||||
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
settings.push(model.settingFollowRedirects, model.settingRequestTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (modelSupportsMessageSizeSettings(model)) {
|
return settings.filter((setting) => isInheritedSetting(setting) && setting.enabled === true)
|
||||||
settings.push(model.settingRequestMessageSize);
|
.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
return settings.filter(
|
function patchCookieSettings(model: ModelWithCookieSettings, patch: Partial<CookieSettingsPatch>) {
|
||||||
(setting) => isInheritedSetting(setting) && setting.enabled === true,
|
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||||
).length;
|
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||||
}
|
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
||||||
|
if (model.model === "websocket_request")
|
||||||
function patchCookieSettings(
|
|
||||||
model: ModelWithCookieSettings,
|
|
||||||
patch: Partial<CookieSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
|
||||||
case "workspace":
|
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
case "folder":
|
|
||||||
return patchModel(model, patch as Partial<Folder>);
|
|
||||||
case "http_request":
|
|
||||||
return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
case "websocket_request":
|
|
||||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||||
}
|
throw new Error("Unsupported cookie settings model");
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchHttpSettings(
|
function patchHttpSettings(model: ModelWithHttpSettings, patch: Partial<HttpSettingsPatch>) {
|
||||||
model: ModelWithHttpSettings,
|
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||||
patch: Partial<HttpSettingsPatch>,
|
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||||
) {
|
|
||||||
switch (model.model) {
|
|
||||||
case "workspace":
|
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
case "folder":
|
|
||||||
return patchModel(model, patch as Partial<Folder>);
|
|
||||||
case "http_request":
|
|
||||||
return patchModel(model, patch as Partial<HttpRequest>);
|
return patchModel(model, patch as Partial<HttpRequest>);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function patchTlsSettings(
|
function patchTlsSettings(model: ModelWithTlsSettings, patch: Partial<TlsSettingsPatch>) {
|
||||||
model: ModelWithTlsSettings,
|
if (model.model === "workspace") return patchModel(model, patch as Partial<Workspace>);
|
||||||
patch: Partial<TlsSettingsPatch>,
|
if (model.model === "folder") return patchModel(model, patch as Partial<Folder>);
|
||||||
) {
|
if (model.model === "http_request") return patchModel(model, patch as Partial<HttpRequest>);
|
||||||
switch (model.model) {
|
if (model.model === "websocket_request")
|
||||||
case "workspace":
|
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
case "folder":
|
|
||||||
return patchModel(model, patch as Partial<Folder>);
|
|
||||||
case "http_request":
|
|
||||||
return patchModel(model, patch as Partial<HttpRequest>);
|
|
||||||
case "websocket_request":
|
|
||||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
return patchModel(model, patch as Partial<WebsocketRequest>);
|
||||||
case "grpc_request":
|
|
||||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
return patchModel(model, patch as Partial<GrpcRequest>);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function patchMessageSizeSettings(
|
function modelSupportsHttpSettings(model: ModelWithSettings): model is ModelWithHttpSettings {
|
||||||
model: ModelWithMessageSizeSettings,
|
|
||||||
patch: Partial<MessageSizeSettingsPatch>,
|
|
||||||
) {
|
|
||||||
switch (model.model) {
|
|
||||||
case "workspace":
|
|
||||||
return patchModel(model, patch as Partial<Workspace>);
|
|
||||||
case "folder":
|
|
||||||
return patchModel(model, patch as Partial<Folder>);
|
|
||||||
case "websocket_request":
|
|
||||||
return patchModel(model, patch as Partial<WebsocketRequest>);
|
|
||||||
case "grpc_request":
|
|
||||||
return patchModel(model, patch as Partial<GrpcRequest>);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function modelSupportsHttpSettings(
|
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithHttpSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
return modelSupportsSetting(model, SETTING_REQUEST_TIMEOUT);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsCookieSettings(
|
function modelSupportsCookieSettings(model: ModelWithSettings): model is ModelWithCookieSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithCookieSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
return modelSupportsSetting(model, SETTING_SEND_COOKIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsTlsSettings(
|
function modelSupportsTlsSettings(model: ModelWithSettings): model is ModelWithTlsSettings {
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithTlsSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
return modelSupportsSetting(model, SETTING_VALIDATE_CERTIFICATES);
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelSupportsMessageSizeSettings(
|
|
||||||
model: ModelWithSettings,
|
|
||||||
): model is ModelWithMessageSizeSettings {
|
|
||||||
return modelSupportsSetting(model, SETTING_REQUEST_MESSAGE_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BooleanSettingRow({
|
function BooleanSettingRow({
|
||||||
inheritedValue,
|
inheritedValue,
|
||||||
setting,
|
setting,
|
||||||
@@ -317,11 +211,7 @@ function BooleanSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
@@ -365,189 +255,48 @@ function IntegerSettingRow({
|
|||||||
}) {
|
}) {
|
||||||
const inherited = isInheritedSetting(setting);
|
const inherited = isInheritedSetting(setting);
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
const overridden = inherited ? setting.enabled === true : false;
|
||||||
const value = inherited
|
const value = inherited ? (overridden ? setting.value : inheritedValue) : setting;
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
|
|
||||||
if (!inherited) {
|
if (!inherited) {
|
||||||
return (
|
return (
|
||||||
<SettingRow
|
<SettingRowNumber
|
||||||
|
name={settingDefinition.modelKey}
|
||||||
title={settingDefinition.title}
|
title={settingDefinition.title}
|
||||||
description={settingDefinition.description}
|
description={settingDefinition.description}
|
||||||
>
|
|
||||||
<NumberUnitInput
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
unit="ms"
|
|
||||||
value={`${value}`}
|
|
||||||
placeholder={`${settingDefinition.defaultValue}`}
|
|
||||||
validate={isValidInteger}
|
|
||||||
onChange={(value) => onChange(parseInteger(value))}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<NumberUnitInput
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
unit="ms"
|
|
||||||
value={`${value}`}
|
|
||||||
placeholder={`${settingDefinition.defaultValue}`}
|
|
||||||
validate={isValidInteger}
|
|
||||||
onChange={(value) =>
|
|
||||||
onChange({
|
|
||||||
...setting,
|
|
||||||
enabled: true,
|
|
||||||
value: parseInteger(value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageSizeSettingRow({
|
|
||||||
inheritedValue,
|
|
||||||
setting,
|
|
||||||
settingDefinition,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
inheritedValue: number;
|
|
||||||
setting: IntegerSetting;
|
|
||||||
settingDefinition: RequestSettingDefinition<"settingRequestMessageSize">;
|
|
||||||
onChange: (setting: IntegerSetting) => void;
|
|
||||||
}) {
|
|
||||||
const inherited = isInheritedSetting(setting);
|
|
||||||
const overridden = inherited ? setting.enabled === true : false;
|
|
||||||
const value = inherited
|
|
||||||
? overridden
|
|
||||||
? setting.value
|
|
||||||
: inheritedValue
|
|
||||||
: setting;
|
|
||||||
const displayValue = formatMegabytes(value);
|
|
||||||
const placeholder = "0";
|
|
||||||
|
|
||||||
if (!inherited) {
|
|
||||||
return (
|
|
||||||
<SettingRow
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
>
|
|
||||||
<MessageSizeInput
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
value={displayValue}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(value) => onChange(parseMegabytes(value))}
|
|
||||||
/>
|
|
||||||
</SettingRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingOverrideRow
|
|
||||||
title={settingDefinition.title}
|
|
||||||
description={settingDefinition.description}
|
|
||||||
overridden={overridden}
|
|
||||||
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
|
||||||
>
|
|
||||||
<MessageSizeInput
|
|
||||||
name={settingDefinition.modelKey}
|
|
||||||
label={settingDefinition.title}
|
|
||||||
value={displayValue}
|
|
||||||
placeholder={placeholder}
|
|
||||||
onChange={(value) =>
|
|
||||||
onChange({
|
|
||||||
...setting,
|
|
||||||
enabled: true,
|
|
||||||
value: parseMegabytes(value),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</SettingOverrideRow>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageSizeInput({
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<NumberUnitInput
|
|
||||||
name={name}
|
|
||||||
label={label}
|
|
||||||
unit="MB"
|
|
||||||
value={value}
|
value={value}
|
||||||
inputMode="decimal"
|
placeholder={`${settingDefinition.defaultValue}`}
|
||||||
step="any"
|
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
||||||
placeholder={placeholder}
|
onChange={(value) => onChange(value)}
|
||||||
validate={isValidMegabytes}
|
|
||||||
onChange={onChange}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberUnitInput({
|
|
||||||
inputMode,
|
|
||||||
label,
|
|
||||||
name,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
step,
|
|
||||||
unit,
|
|
||||||
validate,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
inputMode?: "decimal" | "numeric";
|
|
||||||
label: string;
|
|
||||||
name: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
placeholder: string;
|
|
||||||
step?: number | "any";
|
|
||||||
unit: string;
|
|
||||||
validate: (value: string) => boolean;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
|
<SettingOverrideRow
|
||||||
|
title={settingDefinition.title}
|
||||||
|
description={settingDefinition.description}
|
||||||
|
overridden={overridden}
|
||||||
|
onResetOverride={() => onChange({ ...setting, enabled: false })}
|
||||||
|
>
|
||||||
<PlainInput
|
<PlainInput
|
||||||
hideLabel
|
hideLabel
|
||||||
name={name}
|
name={settingDefinition.modelKey}
|
||||||
label={label}
|
label={settingDefinition.title}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="number"
|
type="number"
|
||||||
inputMode={inputMode}
|
placeholder={`${settingDefinition.defaultValue}`}
|
||||||
step={step}
|
defaultValue={`${value}`}
|
||||||
placeholder={placeholder}
|
|
||||||
defaultValue={value}
|
|
||||||
className="[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
|
||||||
containerClassName="!w-48"
|
containerClassName="!w-48"
|
||||||
validate={validate}
|
validate={(value) => value === "" || Number.parseInt(value, 10) >= 0}
|
||||||
rightSlot={
|
onChange={(value) =>
|
||||||
<span className="flex self-stretch items-center border-l border-border-subtle px-2 text-xs font-medium text-text-subtle">
|
onChange({
|
||||||
{unit}
|
...setting,
|
||||||
</span>
|
enabled: true,
|
||||||
|
value: Number.parseInt(value, 10) || 0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
onChange={onChange}
|
|
||||||
/>
|
/>
|
||||||
|
</SettingOverrideRow>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,7 +308,7 @@ function isInheritedSetting<T>(
|
|||||||
|
|
||||||
function resolveInheritedValue(
|
function resolveInheritedValue(
|
||||||
ancestors: (Folder | Workspace)[],
|
ancestors: (Folder | Workspace)[],
|
||||||
key: "settingRequestTimeout" | "settingRequestMessageSize",
|
key: "settingRequestTimeout",
|
||||||
fallback: IntegerSetting,
|
fallback: IntegerSetting,
|
||||||
): number;
|
): number;
|
||||||
function resolveInheritedValue(
|
function resolveInheritedValue(
|
||||||
@@ -589,46 +338,10 @@ function resolveInheritedValue(
|
|||||||
type WorkspaceSettings = Pick<
|
type WorkspaceSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
| "settingRequestMessageSize"
|
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
| "settingStoreCookies"
|
| "settingStoreCookies"
|
||||||
| "settingValidateCertificates"
|
| "settingValidateCertificates"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
type BooleanWorkspaceSettingKey = Exclude<
|
type BooleanWorkspaceSettingKey = Exclude<keyof WorkspaceSettings, "settingRequestTimeout">;
|
||||||
keyof WorkspaceSettings,
|
|
||||||
"settingRequestTimeout" | "settingRequestMessageSize"
|
|
||||||
>;
|
|
||||||
|
|
||||||
function formatMegabytes(bytes: number) {
|
|
||||||
const megabytes = bytes / BYTES_PER_MB;
|
|
||||||
return Number.isInteger(megabytes)
|
|
||||||
? `${megabytes}`
|
|
||||||
: megabytes.toFixed(3).replace(/\.?0+$/, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMegabytes(value: string) {
|
|
||||||
const megabytes = Number(value);
|
|
||||||
return Number.isFinite(megabytes) ? Math.round(megabytes * BYTES_PER_MB) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseInteger(value: string) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? Math.trunc(parsed) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidInteger(value: string) {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return value === "" || (Number.isInteger(parsed) && parsed >= 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidMegabytes(value: string) {
|
|
||||||
if (value === "") return true;
|
|
||||||
const megabytes = Number(value);
|
|
||||||
return (
|
|
||||||
Number.isFinite(megabytes) &&
|
|
||||||
megabytes >= 0 &&
|
|
||||||
megabytes <= MAX_MESSAGE_SIZE_MB
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { showConfirmDelete } from "../../lib/confirm";
|
import { showConfirmDelete } from "../../lib/confirm";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
import { DetailsBanner } from "../core/DetailsBanner";
|
import { DetailsBanner } from "../core/DetailsBanner";
|
||||||
@@ -233,8 +232,6 @@ export function SettingsCertificates() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CommercialUseBanner source="client-certificates" title="Using certificates for work?" />
|
|
||||||
|
|
||||||
{certificates.length > 0 && (
|
{certificates.length > 0 && (
|
||||||
<VStack space={3}>
|
<VStack space={3}>
|
||||||
{certificates.map((cert, index) => (
|
{certificates.map((cert, index) => (
|
||||||
|
|||||||
@@ -2,15 +2,22 @@ import { revealItemInDir } from "@tauri-apps/plugin-opener";
|
|||||||
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
||||||
import { Heading, VStack } from "@yaakapp-internal/ui";
|
import { Heading, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
import { useCheckForUpdates } from "../../hooks/useCheckForUpdates";
|
||||||
import { appInfo } from "../../lib/appInfo";
|
import { appInfo } from "../../lib/appInfo";
|
||||||
|
import {
|
||||||
|
SETTING_FOLLOW_REDIRECTS,
|
||||||
|
SETTING_REQUEST_TIMEOUT,
|
||||||
|
SETTING_SEND_COOKIES,
|
||||||
|
SETTING_STORE_COOKIES,
|
||||||
|
SETTING_VALIDATE_CERTIFICATES,
|
||||||
|
} from "../../lib/requestSettings";
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
import { revealInFinderText } from "../../lib/reveal";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import { DismissibleBanner } from "../core/DismissibleBanner";
|
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import {
|
import {
|
||||||
ModelSettingRowBoolean,
|
ModelSettingRowBoolean,
|
||||||
|
ModelSettingRowNumber,
|
||||||
ModelSettingSelectControl,
|
ModelSettingSelectControl,
|
||||||
SettingValue,
|
SettingValue,
|
||||||
SettingRow,
|
SettingRow,
|
||||||
@@ -20,29 +27,20 @@ import {
|
|||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from "../core/SettingRow";
|
} from "../core/SettingRow";
|
||||||
|
|
||||||
const WORKSPACE_SETTINGS_MOVED_AT = "2026-06-30";
|
|
||||||
|
|
||||||
export function SettingsGeneral() {
|
export function SettingsGeneral() {
|
||||||
|
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
const checkForUpdates = useCheckForUpdates();
|
const checkForUpdates = useCheckForUpdates();
|
||||||
|
|
||||||
if (settings == null) {
|
if (settings == null || workspace == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const showWorkspaceSettingsMovedBanner =
|
|
||||||
settings.createdAt.slice(0, 10) < WORKSPACE_SETTINGS_MOVED_AT;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
<div>
|
<div className="mb-4">
|
||||||
<Heading>General</Heading>
|
<Heading>General</Heading>
|
||||||
<p className="text-text-subtle">
|
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
||||||
Configure general settings for update behavior and more.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 mb-5">
|
|
||||||
<CommercialUseBanner source="settings-general" title="Using Yaak for work?" />
|
|
||||||
</div>
|
</div>
|
||||||
<SettingsList className="space-y-8">
|
<SettingsList className="space-y-8">
|
||||||
<CargoFeature feature="updater">
|
<CargoFeature feature="updater">
|
||||||
@@ -78,9 +76,7 @@ export function SettingsGeneral() {
|
|||||||
description="Choose whether updates are installed automatically or manually."
|
description="Choose whether updates are installed automatically or manually."
|
||||||
name="autoupdate"
|
name="autoupdate"
|
||||||
value={settings.autoupdate ? "auto" : "manual"}
|
value={settings.autoupdate ? "auto" : "manual"}
|
||||||
onChange={(v) =>
|
onChange={(v) => patchModel(settings, { autoupdate: v === "auto" })}
|
||||||
patchModel(settings, { autoupdate: v === "auto" })
|
|
||||||
}
|
|
||||||
options={[
|
options={[
|
||||||
{ label: "Automatic", value: "auto" },
|
{ label: "Automatic", value: "auto" },
|
||||||
{ label: "Manual", value: "manual" },
|
{ label: "Manual", value: "manual" },
|
||||||
@@ -112,19 +108,54 @@ export function SettingsGeneral() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</CargoFeature>
|
</CargoFeature>
|
||||||
|
|
||||||
{showWorkspaceSettingsMovedBanner && (
|
<SettingsSection
|
||||||
<DismissibleBanner
|
title={
|
||||||
id="workspace-settings-moved-2026-06-30"
|
<>
|
||||||
color="info"
|
Workspace{" "}
|
||||||
className="p-4 max-w-xl mx-auto"
|
<span className="inline-block bg-surface-highlight px-2 py-0.5 rounded text">
|
||||||
|
{workspace.name}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<p>
|
<ModelSettingRowNumber
|
||||||
Workspace specific settings have moved to{" "}
|
model={workspace}
|
||||||
<b>Workspace Settings</b>, accessible from the workspace switcher
|
modelKey={SETTING_REQUEST_TIMEOUT.modelKey}
|
||||||
menu.
|
title={SETTING_REQUEST_TIMEOUT.title}
|
||||||
</p>
|
description={SETTING_REQUEST_TIMEOUT.description}
|
||||||
</DismissibleBanner>
|
placeholder={`${SETTING_REQUEST_TIMEOUT.defaultValue}`}
|
||||||
)}
|
required
|
||||||
|
validate={(value) => Number.parseInt(value, 10) >= 0}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelSettingRowBoolean
|
||||||
|
model={workspace}
|
||||||
|
modelKey={SETTING_VALIDATE_CERTIFICATES.modelKey}
|
||||||
|
title={SETTING_VALIDATE_CERTIFICATES.title}
|
||||||
|
description={SETTING_VALIDATE_CERTIFICATES.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelSettingRowBoolean
|
||||||
|
model={workspace}
|
||||||
|
modelKey={SETTING_FOLLOW_REDIRECTS.modelKey}
|
||||||
|
title={SETTING_FOLLOW_REDIRECTS.title}
|
||||||
|
description={SETTING_FOLLOW_REDIRECTS.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelSettingRowBoolean
|
||||||
|
model={workspace}
|
||||||
|
modelKey={SETTING_SEND_COOKIES.modelKey}
|
||||||
|
title={SETTING_SEND_COOKIES.title}
|
||||||
|
description={SETTING_SEND_COOKIES.description}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ModelSettingRowBoolean
|
||||||
|
model={workspace}
|
||||||
|
modelKey={SETTING_STORE_COOKIES.modelKey}
|
||||||
|
title={SETTING_STORE_COOKIES.title}
|
||||||
|
description={SETTING_STORE_COOKIES.description}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<SettingsSection title="App Info">
|
<SettingsSection title="App Info">
|
||||||
<SettingRow title="Version" description="Current Yaak version.">
|
<SettingRow title="Version" description="Current Yaak version.">
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
|
||||||
import { invokeCmd } from "../../lib/tauri";
|
import { invokeCmd } from "../../lib/tauri";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
@@ -253,9 +252,7 @@ function LicenseSettings({ settings }: { settings: Settings }) {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
Licenses help keep Yaak independent and sustainable.{" "}
|
||||||
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
|
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||||
Purchase a License →
|
|
||||||
</Link>
|
|
||||||
</p>
|
</p>
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { formatDate } from "date-fns/format";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToggle } from "../../hooks/useToggle";
|
import { useToggle } from "../../hooks/useToggle";
|
||||||
import { pluralizeCount } from "../../lib/pluralize";
|
import { pluralizeCount } from "../../lib/pluralize";
|
||||||
import { pricingUrl } from "../../lib/pricingUrl";
|
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
@@ -49,7 +48,7 @@ function SettingsLicenseCmp() {
|
|||||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,7 +68,7 @@ function SettingsLicenseCmp() {
|
|||||||
</span>
|
</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +134,7 @@ function SettingsLicenseCmp() {
|
|||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")}
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
>
|
>
|
||||||
Direct Support
|
Direct Support
|
||||||
@@ -151,7 +150,9 @@ function SettingsLicenseCmp() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
openUrl(
|
||||||
|
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Purchase License
|
Purchase License
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|||||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import {
|
import {
|
||||||
SettingRowBoolean,
|
SettingRowBoolean,
|
||||||
SettingRowSelect,
|
SettingRowSelect,
|
||||||
@@ -34,7 +33,6 @@ export function SettingsProxy() {
|
|||||||
traffic, or routing through specific infrastructure.
|
traffic, or routing through specific infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?" />
|
|
||||||
<SettingsList className="space-y-8">
|
<SettingsList className="space-y-8">
|
||||||
<SettingsSection title="Proxy">
|
<SettingsSection title="Proxy">
|
||||||
<SettingRowSelect
|
<SettingRowSelect
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { useExportData } from "../hooks/useExportData";
|
|||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import { importData } from "../lib/importData";
|
import { importData } from "../lib/importData";
|
||||||
import { pricingUrl } from "../lib/pricingUrl";
|
|
||||||
import type { DropdownRef } from "./core/Dropdown";
|
import type { DropdownRef } from "./core/Dropdown";
|
||||||
import { Dropdown } from "./core/Dropdown";
|
import { Dropdown } from "./core/Dropdown";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -77,8 +76,7 @@ export function SettingsDropdown() {
|
|||||||
hidden: check.data == null || check.data.status === "active",
|
hidden: check.data == null || check.data.status === "active",
|
||||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||||
onSelect: () =>
|
onSelect: () => openUrl("https://yaak.app/pricing"),
|
||||||
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Install CLI",
|
label: "Install CLI",
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
|
|||||||
import { deepEqualAtom } from "../lib/atoms";
|
import { deepEqualAtom } from "../lib/atoms";
|
||||||
import { languageFromContentType } from "../lib/contentType";
|
import { languageFromContentType } from "../lib/contentType";
|
||||||
import { generateId } from "../lib/generateId";
|
import { generateId } from "../lib/generateId";
|
||||||
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
|
|
||||||
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
|
||||||
import { resolvedModelName } from "../lib/resolvedModelName";
|
import { resolvedModelName } from "../lib/resolvedModelName";
|
||||||
import { CountBadge } from "./core/CountBadge";
|
import { CountBadge } from "./core/CountBadge";
|
||||||
@@ -84,7 +83,9 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||||
const placeholderNames = extractPathPlaceholders(activeRequest.url);
|
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||||
|
(m) => m[1] ?? "",
|
||||||
|
);
|
||||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||||
const items: Pair[] = [...nonEmptyParameters];
|
const items: Pair[] = [...nonEmptyParameters];
|
||||||
for (const name of placeholderNames) {
|
for (const name of placeholderNames) {
|
||||||
|
|||||||
@@ -105,18 +105,10 @@ function WebsocketEventRow({
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
const iconColor =
|
const iconColor =
|
||||||
messageType === "error"
|
messageType === "close" || messageType === "open" ? "secondary" : isServer ? "info" : "primary";
|
||||||
? "warning"
|
|
||||||
: messageType === "close" || messageType === "open"
|
|
||||||
? "secondary"
|
|
||||||
: isServer
|
|
||||||
? "info"
|
|
||||||
: "primary";
|
|
||||||
|
|
||||||
const icon =
|
const icon =
|
||||||
messageType === "error"
|
messageType === "close" || messageType === "open"
|
||||||
? "alert_triangle"
|
|
||||||
: messageType === "close" || messageType === "open"
|
|
||||||
? "info"
|
? "info"
|
||||||
: isServer
|
: isServer
|
||||||
? "arrow_big_down_dash"
|
? "arrow_big_down_dash"
|
||||||
@@ -127,8 +119,6 @@ function WebsocketEventRow({
|
|||||||
"Disconnected from server"
|
"Disconnected from server"
|
||||||
) : messageType === "open" ? (
|
) : messageType === "open" ? (
|
||||||
"Connected to server"
|
"Connected to server"
|
||||||
) : messageType === "error" ? (
|
|
||||||
<span className="text-warning">{message}</span>
|
|
||||||
) : message === "" ? (
|
) : message === "" ? (
|
||||||
<em className="italic text-text-subtlest">No content</em>
|
<em className="italic text-text-subtlest">No content</em>
|
||||||
) : (
|
) : (
|
||||||
@@ -180,8 +170,6 @@ function WebsocketEventDetail({
|
|||||||
? "Connection Closed"
|
? "Connection Closed"
|
||||||
: event.messageType === "open"
|
: event.messageType === "open"
|
||||||
? "Connection Open"
|
? "Connection Open"
|
||||||
: event.messageType === "error"
|
|
||||||
? "WebSocket Error"
|
|
||||||
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
: `Message ${event.isServer ? "Received" : "Sent"}`;
|
||||||
|
|
||||||
const actions: EventDetailAction[] =
|
const actions: EventDetailAction[] =
|
||||||
|
|||||||
@@ -112,9 +112,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
onCreateNewWorkspace={hide}
|
onCreateNewWorkspace={hide}
|
||||||
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
|
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
|
||||||
/>
|
/>
|
||||||
<div className="mt-4">
|
|
||||||
<WorkspaceEncryptionSetting layout="settings" size="xs" />
|
<WorkspaceEncryptionSetting layout="settings" size="xs" />
|
||||||
</div>
|
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
<ModelSettingsEditor model={workspace} showSectionTitles />
|
<ModelSettingsEditor model={workspace} showSectionTitles />
|
||||||
</SettingsList>
|
</SettingsList>
|
||||||
|
|||||||
@@ -1,73 +1,39 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||||
import { Banner } from "@yaakapp-internal/ui";
|
import { Banner, HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useEffect } from "react";
|
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
import type { ButtonProps } from "./Button";
|
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
export function DismissibleBanner({
|
export function DismissibleBanner({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
id,
|
id,
|
||||||
onDismiss,
|
|
||||||
onShow,
|
|
||||||
actions,
|
actions,
|
||||||
...props
|
...props
|
||||||
}: BannerProps & {
|
}: BannerProps & {
|
||||||
id: string;
|
id: string;
|
||||||
onDismiss?: () => void | Promise<void>;
|
actions?: { label: string; onClick: () => void; color?: Color }[];
|
||||||
onShow?: () => void | Promise<void>;
|
|
||||||
actions?: {
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
color?: Color;
|
|
||||||
variant?: ButtonProps["variant"];
|
|
||||||
}[];
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
|
||||||
isLoading,
|
|
||||||
set: setDismissed,
|
|
||||||
value: dismissed,
|
|
||||||
} = useKeyValue<boolean>({
|
|
||||||
namespace: "global",
|
namespace: "global",
|
||||||
key: ["dismiss-banner", id],
|
key: ["dismiss-banner", id],
|
||||||
fallback: false,
|
fallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const shouldShow = !isLoading && !dismissed;
|
if (dismissed) return null;
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (shouldShow) {
|
|
||||||
Promise.resolve(onShow?.()).catch(console.error);
|
|
||||||
}
|
|
||||||
}, [onShow, shouldShow]);
|
|
||||||
|
|
||||||
if (!shouldShow) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Banner className={classNames(className, "relative")} {...props}>
|
<Banner
|
||||||
<div className="@container">
|
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
|
||||||
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
|
{...props}
|
||||||
{children}
|
|
||||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="border"
|
|
||||||
color={props.color}
|
|
||||||
size="xs"
|
|
||||||
onClick={() => {
|
|
||||||
setDismissed(true).catch(console.error);
|
|
||||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
|
||||||
}}
|
|
||||||
title="Dismiss message"
|
|
||||||
>
|
>
|
||||||
Dismiss
|
{children}
|
||||||
</Button>
|
<HStack space={1.5}>
|
||||||
{actions?.map((a) => (
|
{actions?.map((a) => (
|
||||||
<Button
|
<Button
|
||||||
key={a.label}
|
key={a.label}
|
||||||
variant={a.variant ?? "border"}
|
variant="border"
|
||||||
color={a.color ?? props.color}
|
color={a.color ?? props.color}
|
||||||
size="xs"
|
size="xs"
|
||||||
onClick={a.onClick}
|
onClick={a.onClick}
|
||||||
@@ -76,9 +42,16 @@ export function DismissibleBanner({
|
|||||||
{a.label}
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
<Button
|
||||||
</div>
|
variant="border"
|
||||||
</div>
|
color={props.color}
|
||||||
|
size="xs"
|
||||||
|
onClick={() => setDismissed((d) => !d)}
|
||||||
|
title="Dismiss message"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</HStack>
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,22 +282,6 @@ function EditorInner({
|
|||||||
[disableTabIndent],
|
[disableTabIndent],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update read-only
|
|
||||||
const readOnlyCompartment = useRef(new Compartment());
|
|
||||||
useEffect(
|
|
||||||
function configureReadOnly() {
|
|
||||||
if (cm.current === null) return;
|
|
||||||
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
|
|
||||||
const next = readOnly ? readonlyExtensions : emptyExtension;
|
|
||||||
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
|
|
||||||
if (current === next) return;
|
|
||||||
|
|
||||||
const effects = readOnlyCompartment.current.reconfigure(next);
|
|
||||||
cm.current?.view.dispatch({ effects });
|
|
||||||
},
|
|
||||||
[readOnly],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onClickFunction = useCallback(
|
const onClickFunction = useCallback(
|
||||||
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
|
||||||
const show = () => {
|
const show = () => {
|
||||||
@@ -410,9 +394,9 @@ function EditorInner({
|
|||||||
keymapCompartment.current.of(
|
keymapCompartment.current.of(
|
||||||
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
|
||||||
),
|
),
|
||||||
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
|
|
||||||
...getExtensions({
|
...getExtensions({
|
||||||
container,
|
container,
|
||||||
|
readOnly,
|
||||||
singleLine,
|
singleLine,
|
||||||
hideGutter,
|
hideGutter,
|
||||||
stateKey,
|
stateKey,
|
||||||
@@ -569,6 +553,7 @@ function EditorInner({
|
|||||||
function getExtensions({
|
function getExtensions({
|
||||||
stateKey,
|
stateKey,
|
||||||
container,
|
container,
|
||||||
|
readOnly,
|
||||||
singleLine,
|
singleLine,
|
||||||
hideGutter,
|
hideGutter,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -577,7 +562,7 @@ function getExtensions({
|
|||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
|
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & {
|
||||||
stateKey: EditorProps["stateKey"];
|
stateKey: EditorProps["stateKey"];
|
||||||
container: HTMLDivElement | null;
|
container: HTMLDivElement | null;
|
||||||
onChange: RefObject<EditorProps["onChange"]>;
|
onChange: RefObject<EditorProps["onChange"]>;
|
||||||
@@ -627,6 +612,7 @@ function getExtensions({
|
|||||||
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
|
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
|
||||||
...(singleLine ? [singleLineExtensions()] : []),
|
...(singleLine ? [singleLineExtensions()] : []),
|
||||||
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
|
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
|
||||||
|
...(readOnly ? readonlyExtensions : []),
|
||||||
|
|
||||||
// ------------------------ //
|
// ------------------------ //
|
||||||
// Things that must be last //
|
// Things that must be last //
|
||||||
|
|||||||
@@ -53,17 +53,19 @@ function pathParameters(
|
|||||||
if (node.name === "Text") {
|
if (node.name === "Text") {
|
||||||
// Find the `url` node and then jump into it to find the placeholders
|
// Find the `url` node and then jump into it to find the placeholders
|
||||||
for (let i = node.from; i < node.to; i++) {
|
for (let i = node.from; i < node.to; i++) {
|
||||||
const innerTree = tree.resolveInner(i);
|
const innerTree = syntaxTree(view.state).resolveInner(i);
|
||||||
if (innerTree.node.name === "url") {
|
if (innerTree.node.name === "url") {
|
||||||
innerTree.node.cursor().iterate((node) => {
|
innerTree.toTree().iterate({
|
||||||
|
enter(node) {
|
||||||
if (node.name !== "Placeholder") return;
|
if (node.name !== "Placeholder") return;
|
||||||
const globalFrom = node.from;
|
const globalFrom = innerTree.node.from + node.from;
|
||||||
const globalTo = node.to;
|
const globalTo = innerTree.node.from + node.to;
|
||||||
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
|
const rawText = view.state.doc.sliceString(globalFrom, globalTo);
|
||||||
const onClick = () => onClickPathParameter(rawText);
|
const onClick = () => onClickPathParameter(rawText);
|
||||||
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
|
const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick);
|
||||||
const deco = Decoration.replace({ widget, inclusive: false });
|
const deco = Decoration.replace({ widget, inclusive: false });
|
||||||
widgets.push(deco.range(globalFrom, globalTo));
|
widgets.push(deco.range(globalFrom, globalTo));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
// Host is optional so URLs starting with `/` go straight to Path. Without this,
|
@top url { Protocol? Host Path? Query? }
|
||||||
// the parser error-recovers past the leading `/` and consumes the first segment as
|
|
||||||
// Host (since Host's char class includes `:` for `host:port`), eating an initial
|
|
||||||
// `:name` placeholder like `/:foo/:bar`.
|
|
||||||
@top url { Protocol? Host? Path? Query? }
|
|
||||||
|
|
||||||
Path { ("/" PathSegment)+ }
|
Path { ("/" (Placeholder | PathSegment))+ }
|
||||||
|
|
||||||
Placeholder { ":" pathChars }
|
|
||||||
PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* }
|
|
||||||
|
|
||||||
Query { "?" queryPair ("&" queryPair)* }
|
Query { "?" queryPair ("&" queryPair)* }
|
||||||
|
|
||||||
@@ -16,7 +9,9 @@ Query { "?" queryPair ("&" queryPair)* }
|
|||||||
Host { $[a-zA-Z0-9-_.:\[\]]+ }
|
Host { $[a-zA-Z0-9-_.:\[\]]+ }
|
||||||
@precedence { Protocol, Host }
|
@precedence { Protocol, Host }
|
||||||
|
|
||||||
pathChars { ![/?#:]+ }
|
Placeholder { ":" ![/?#]+ }
|
||||||
|
PathSegment { ![?#/]+ }
|
||||||
|
@precedence { Placeholder, PathSegment }
|
||||||
|
|
||||||
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
|
queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||||
export const
|
export const url = 1,
|
||||||
url = 1,
|
|
||||||
Protocol = 2,
|
Protocol = 2,
|
||||||
Host = 3,
|
Host = 3,
|
||||||
Path = 4,
|
Port = 4,
|
||||||
PathSegment = 5,
|
Path = 5,
|
||||||
Placeholder = 6,
|
Placeholder = 6,
|
||||||
Query = 7
|
PathSegment = 7,
|
||||||
|
Query = 8;
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { parser } from "./url";
|
|
||||||
|
|
||||||
function expectValidParse(input: string) {
|
|
||||||
expect(parser.parse(input).toString()).not.toContain("⚠");
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeholderValues(input: string): string[] {
|
|
||||||
const values: string[] = [];
|
|
||||||
parser
|
|
||||||
.parse(input)
|
|
||||||
.cursor()
|
|
||||||
.iterate((node) => {
|
|
||||||
if (node.name === "Placeholder") values.push(input.slice(node.from, node.to));
|
|
||||||
});
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("URL grammar Placeholder", () => {
|
|
||||||
test("recognizes path placeholders", () => {
|
|
||||||
expectValidParse("https://x.com/users/:id");
|
|
||||||
expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats a colon suffix as literal path text", () => {
|
|
||||||
expectValidParse("https://yaak.app/x/echo/:foo:bar/baz");
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("treats repeated colon suffixes as literal path text", () => {
|
|
||||||
expectValidParse("https://yaak.app/x/echo/:foo:bar:baz");
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not recognize a colon in the middle of a plain path segment", () => {
|
|
||||||
expectValidParse("https://yaak.app/x/echo/foo:bar/baz");
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not recognize query parameters as path placeholders", () => {
|
|
||||||
expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("recognizes placeholders in a path fragment after a templated base URL", () => {
|
|
||||||
// Mixed Twig parsing can feed the URL parser only the text after a template tag,
|
|
||||||
// as in `${[ URL ]}/x/:foo/:hello`.
|
|
||||||
expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([
|
|
||||||
":foo",
|
|
||||||
":hello",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,18 +1,20 @@
|
|||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||||
import {LRParser} from "@lezer/lr"
|
import { LRParser } from "@lezer/lr";
|
||||||
import {highlight} from "./highlight"
|
import { highlight } from "./highlight";
|
||||||
export const parser = LRParser.deserialize({
|
export const parser = LRParser.deserialize({
|
||||||
version: 14,
|
version: 14,
|
||||||
states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d",
|
states:
|
||||||
stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~",
|
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c",
|
||||||
goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea",
|
stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~",
|
||||||
nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query",
|
goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[",
|
||||||
maxTerm: 17,
|
nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query",
|
||||||
|
maxTerm: 14,
|
||||||
propSources: [highlight],
|
propSources: [highlight],
|
||||||
skippedNodes: [0],
|
skippedNodes: [0],
|
||||||
repeatNodeCount: 3,
|
repeatNodeCount: 2,
|
||||||
tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!QZ!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP",
|
tokenData:
|
||||||
|
".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP",
|
||||||
tokenizers: [0, 1, 2],
|
tokenizers: [0, 1, 2],
|
||||||
topRules: {"url":[0,1]},
|
topRules: { url: [0, 1] },
|
||||||
tokenPrec: 99
|
tokenPrec: 63,
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ import { CopyIconButton } from "../CopyIconButton";
|
|||||||
import { AutoScroller } from "./AutoScroller";
|
import { AutoScroller } from "./AutoScroller";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
import type { SelectProps } from "./Select";
|
|
||||||
import { Select } from "./Select";
|
|
||||||
import { Separator } from "./Separator";
|
import { Separator } from "./Separator";
|
||||||
|
|
||||||
interface EventViewerProps<T> {
|
interface EventViewerProps<T> {
|
||||||
@@ -153,7 +151,7 @@ export function EventViewer<T>({
|
|||||||
layout="vertical"
|
layout="vertical"
|
||||||
storageKey={splitLayoutStorageKey}
|
storageKey={splitLayoutStorageKey}
|
||||||
defaultRatio={defaultRatio}
|
defaultRatio={defaultRatio}
|
||||||
minHeightPx={72}
|
minHeightPx={10}
|
||||||
firstSlot={({ style }) => (
|
firstSlot={({ style }) => (
|
||||||
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
<div style={style} className="w-full h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||||
{header ?? <span aria-hidden />}
|
{header ?? <span aria-hidden />}
|
||||||
@@ -204,9 +202,7 @@ export function EventViewer<T>({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventDetailAction =
|
export interface EventDetailAction {
|
||||||
| {
|
|
||||||
type?: "button";
|
|
||||||
/** Unique key for React */
|
/** Unique key for React */
|
||||||
key: string;
|
key: string;
|
||||||
/** Button label */
|
/** Button label */
|
||||||
@@ -216,26 +212,13 @@ export type EventDetailAction =
|
|||||||
/** Click handler */
|
/** Click handler */
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}
|
}
|
||||||
| {
|
|
||||||
type: "select";
|
|
||||||
/** Unique key for React */
|
|
||||||
key: string;
|
|
||||||
/** Select label */
|
|
||||||
label: string;
|
|
||||||
/** Selected value */
|
|
||||||
value: string;
|
|
||||||
/** Select options */
|
|
||||||
options: SelectProps<string>["options"];
|
|
||||||
/** Change handler */
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EventDetailHeaderProps {
|
interface EventDetailHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
prefix?: ReactNode;
|
prefix?: ReactNode;
|
||||||
timestamp?: string;
|
timestamp?: string;
|
||||||
actions?: EventDetailAction[];
|
actions?: EventDetailAction[];
|
||||||
copyText?: string | (() => Promise<string | null>);
|
copyText?: string;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,20 +239,7 @@ export function EventDetailHeader({
|
|||||||
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
|
<h3 className="font-semibold select-auto cursor-auto truncate">{title}</h3>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack space={2} className="items-center">
|
<HStack space={2} className="items-center">
|
||||||
{actions?.map((action) =>
|
{actions?.map((action) => (
|
||||||
action.type === "select" ? (
|
|
||||||
<div key={action.key} className="w-32">
|
|
||||||
<Select
|
|
||||||
name={action.key}
|
|
||||||
label={action.label}
|
|
||||||
hideLabel
|
|
||||||
size="xs"
|
|
||||||
value={action.value}
|
|
||||||
options={action.options}
|
|
||||||
onChange={action.onChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
key={action.key}
|
key={action.key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -280,15 +250,13 @@ export function EventDetailHeader({
|
|||||||
{action.icon}
|
{action.icon}
|
||||||
{action.label}
|
{action.label}
|
||||||
</Button>
|
</Button>
|
||||||
),
|
))}
|
||||||
)}
|
|
||||||
{copyText != null && (
|
{copyText != null && (
|
||||||
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
|
<CopyIconButton text={copyText} size="xs" title="Copy" variant="border" iconSize="sm" />
|
||||||
)}
|
)}
|
||||||
{formattedTime && (
|
{formattedTime && (
|
||||||
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
|
<span className="text-text-subtlest font-mono text-editor ml-2">{formattedTime}</span>
|
||||||
)}
|
)}
|
||||||
{onClose != null && (
|
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
copyText != null ||
|
copyText != null ||
|
||||||
@@ -305,7 +273,6 @@ export function EventDetailHeader({
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { HStack } from "@yaakapp-internal/ui";
|
import { HStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { FocusEvent, InputHTMLAttributes, ReactNode } from "react";
|
import type { FocusEvent, HTMLAttributes, ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
@@ -28,9 +28,10 @@ export type PlainInputProps = Omit<
|
|||||||
| "extraExtensions"
|
| "extraExtensions"
|
||||||
| "forcedEnvironmentId"
|
| "forcedEnvironmentId"
|
||||||
> &
|
> &
|
||||||
Pick<InputHTMLAttributes<HTMLInputElement>, "inputMode" | "onKeyDownCapture" | "step"> & {
|
Pick<HTMLAttributes<HTMLInputElement>, "onKeyDownCapture"> & {
|
||||||
onFocusRaw?: InputHTMLAttributes<HTMLInputElement>["onFocus"];
|
onFocusRaw?: HTMLAttributes<HTMLInputElement>["onFocus"];
|
||||||
type?: "text" | "password" | "number";
|
type?: "text" | "password" | "number";
|
||||||
|
step?: number;
|
||||||
hideObscureToggle?: boolean;
|
hideObscureToggle?: boolean;
|
||||||
labelRightSlot?: ReactNode;
|
labelRightSlot?: ReactNode;
|
||||||
};
|
};
|
||||||
@@ -51,7 +52,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
labelClassName,
|
labelClassName,
|
||||||
labelPosition = "top",
|
labelPosition = "top",
|
||||||
labelRightSlot,
|
labelRightSlot,
|
||||||
inputMode,
|
|
||||||
leftSlot,
|
leftSlot,
|
||||||
name,
|
name,
|
||||||
onBlur,
|
onBlur,
|
||||||
@@ -64,7 +64,6 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
required,
|
required,
|
||||||
rightSlot,
|
rightSlot,
|
||||||
size = "md",
|
size = "md",
|
||||||
step,
|
|
||||||
tint,
|
tint,
|
||||||
type = "text",
|
type = "text",
|
||||||
validate,
|
validate,
|
||||||
@@ -205,14 +204,12 @@ export const PlainInput = forwardRef<{ focus: () => void }, PlainInputProps>(fun
|
|||||||
autoComplete="off"
|
autoComplete="off"
|
||||||
autoCapitalize="off"
|
autoCapitalize="off"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
inputMode={inputMode}
|
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
onPaste={(e) => onPaste?.(e.clipboardData.getData("Text"))}
|
||||||
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
|
className={classNames(commonClassName, "h-full disabled:opacity-disabled")}
|
||||||
onFocus={handleFocus}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
required={required}
|
required={required}
|
||||||
step={step}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
onKeyDownCapture={onKeyDownCapture}
|
onKeyDownCapture={onKeyDownCapture}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
|
|||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { showErrorToast } from "../../lib/toast";
|
import { showErrorToast } from "../../lib/toast";
|
||||||
import { sync } from "../../init/sync";
|
import { sync } from "../../init/sync";
|
||||||
import { CommercialUseBanner } from "../CommercialUseBanner";
|
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
import type { CheckboxProps } from "../core/Checkbox";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
@@ -206,8 +205,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultRatio={0.6}
|
defaultRatio={0.6}
|
||||||
firstSlot={({ style }) => (
|
firstSlot={({ style }) => (
|
||||||
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
<div style={style} className="h-full px-4">
|
||||||
<CommercialUseBanner source="git-commit" title="Using Git for work?" />
|
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
storageKey="commit-vertical"
|
storageKey="commit-vertical"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
|
|||||||
title: "Add Remote",
|
title: "Add Remote",
|
||||||
inputs: [
|
inputs: [
|
||||||
{ type: "text", label: "Name", name: "name", defaultValue: defaultName },
|
{ type: "text", label: "Name", name: "name", defaultValue: defaultName },
|
||||||
{ type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" },
|
{ type: "text", label: "URL", name: "url" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (r == null) throw new Error("Cancelled remote prompt");
|
if (r == null) throw new Error("Cancelled remote prompt");
|
||||||
|
|||||||
@@ -1,38 +1,21 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import { extractSseValueAtPath, type ServerSentEvent } from "@yaakapp-internal/sse";
|
import type { ServerSentEvent } from "@yaakapp-internal/sse";
|
||||||
import { HStack, Icon, InlineCode, SplitLayout, VStack } from "@yaakapp-internal/ui";
|
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { CSSProperties, ReactNode } from "react";
|
|
||||||
import { Fragment, useMemo, useState } from "react";
|
import { Fragment, useMemo, useState } from "react";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
|
||||||
import { useFormatText } from "../../hooks/useFormatText";
|
import { useFormatText } from "../../hooks/useFormatText";
|
||||||
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
|
import { useResponseBodyEventSource } from "../../hooks/useResponseBodyEventSource";
|
||||||
import { useResponseBodySseSummary } from "../../hooks/useResponseBodySseSummary";
|
|
||||||
import {
|
|
||||||
sseSummaryResultKeyPathAutocomplete,
|
|
||||||
useSseSummaryResultKeyPath,
|
|
||||||
} from "../../hooks/useSseSummaryResultKeyPath";
|
|
||||||
import { isJSON } from "../../lib/contentType";
|
import { isJSON } from "../../lib/contentType";
|
||||||
import { EmptyStateText } from "../EmptyStateText";
|
|
||||||
import { Markdown } from "../Markdown";
|
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { DropdownItem } from "../core/Dropdown";
|
|
||||||
import { Dropdown } from "../core/Dropdown";
|
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
|
import { EventDetailHeader, EventViewer } from "../core/EventViewer";
|
||||||
import { EventViewerRow } from "../core/EventViewerRow";
|
import { EventViewerRow } from "../core/EventViewerRow";
|
||||||
import { IconButton } from "../core/IconButton";
|
|
||||||
import { IconTooltip } from "../core/IconTooltip";
|
|
||||||
import { Input } from "../core/Input";
|
|
||||||
import { Select } from "../core/Select";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
response: HttpResponse;
|
response: HttpResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_EXTRACTED_TEXT_RATIO = 0.28;
|
|
||||||
|
|
||||||
export function EventStreamViewer({ response }: Props) {
|
export function EventStreamViewer({ response }: Props) {
|
||||||
return (
|
return (
|
||||||
<Fragment
|
<Fragment
|
||||||
@@ -46,135 +29,9 @@ export function EventStreamViewer({ response }: Props) {
|
|||||||
function ActualEventStreamViewer({ response }: Props) {
|
function ActualEventStreamViewer({ response }: Props) {
|
||||||
const [showLarge, setShowLarge] = useState<boolean>(false);
|
const [showLarge, setShowLarge] = useState<boolean>(false);
|
||||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||||
const filterEventPreviewsSetting = useKeyValue<boolean>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_filter_event_previews", response.requestId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const applyToDetailsSetting = useKeyValue<boolean>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_apply_to_details", response.requestId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const renderMarkdownSetting = useKeyValue<boolean>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_render_markdown", response.requestId],
|
|
||||||
fallback: false,
|
|
||||||
});
|
|
||||||
const summarySettings = useSseSummaryResultKeyPath({ response });
|
|
||||||
const events = useResponseBodyEventSource(response);
|
const events = useResponseBodyEventSource(response);
|
||||||
const summary = useResponseBodySseSummary(response, summarySettings.resultKeyPath);
|
|
||||||
const showExtractedText = summarySettings.resultKeyPath != null;
|
|
||||||
const showResultKeyPathWarning =
|
|
||||||
showExtractedText &&
|
|
||||||
summary.data != null &&
|
|
||||||
summary.data.fragmentCount === 0 &&
|
|
||||||
!summary.isFetching &&
|
|
||||||
summary.error == null;
|
|
||||||
const filterEventPreviews = showExtractedText && filterEventPreviewsSetting.value === true;
|
|
||||||
const applyToDetails = showExtractedText && applyToDetailsSetting.value === true;
|
|
||||||
const renderMarkdown = showExtractedText && renderMarkdownSetting.value === true;
|
|
||||||
const settingsItems = useMemo<DropdownItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
label: "Apply to Previews",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => filterEventPreviewsSetting.set(filterEventPreviewsSetting.value !== true),
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
filterEventPreviewsSetting.value === true
|
|
||||||
? "check_square_checked"
|
|
||||||
: "check_square_unchecked"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Apply to Details",
|
|
||||||
keepOpenOnSelect: true,
|
|
||||||
onSelect: () => applyToDetailsSetting.set(applyToDetailsSetting.value !== true),
|
|
||||||
leftSlot: (
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
applyToDetailsSetting.value === true
|
|
||||||
? "check_square_checked"
|
|
||||||
: "check_square_unchecked"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[
|
|
||||||
applyToDetailsSetting,
|
|
||||||
filterEventPreviewsSetting,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-h-0 grid grid-rows-[auto_minmax(0,1fr)]">
|
|
||||||
<HStack space={2} alignItems="center" className="pt-1 pb-1 border-b border-border-subtle">
|
|
||||||
<div className={classNames(summarySettings.enabled ? "w-44 shrink-0" : "min-w-40 flex-1")}>
|
|
||||||
<Select
|
|
||||||
name={`sse-summary-result-key-path-enabled::${response.requestId}`}
|
|
||||||
label="Extracted text"
|
|
||||||
hideLabel
|
|
||||||
size="xs"
|
|
||||||
value={summarySettings.enabled ? "jsonpath" : "off"}
|
|
||||||
options={[
|
|
||||||
{ label: "Full events", value: "off" },
|
|
||||||
{ label: "JSONPath", value: "jsonpath" },
|
|
||||||
]}
|
|
||||||
onChange={(value) => summarySettings.setEnabled(value === "jsonpath")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{summarySettings.enabled && (
|
|
||||||
<>
|
|
||||||
<div className="min-w-40 flex-1">
|
|
||||||
<Input
|
|
||||||
label="Result JSON path"
|
|
||||||
hideLabel
|
|
||||||
size="xs"
|
|
||||||
autocomplete={sseSummaryResultKeyPathAutocomplete}
|
|
||||||
defaultValue={summarySettings.resultKeyPathInputValue}
|
|
||||||
forceUpdateKey={`${response.requestId}:${summarySettings.inferredResultKeyPath ?? ""}`}
|
|
||||||
placeholder="$.choices[0].delta.content"
|
|
||||||
rightSlot={
|
|
||||||
showResultKeyPathWarning ? (
|
|
||||||
<div className="flex items-center px-2">
|
|
||||||
<IconTooltip
|
|
||||||
tabIndex={-1}
|
|
||||||
icon="alert_triangle"
|
|
||||||
iconColor="notice"
|
|
||||||
content="No text fragments matched this JSONPath."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
stateKey={`sse-summary-result-key-path::${response.requestId}`}
|
|
||||||
tint={showResultKeyPathWarning ? "notice" : undefined}
|
|
||||||
onChange={summarySettings.setResultKeyPath}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Dropdown items={settingsItems}>
|
|
||||||
<IconButton
|
|
||||||
size="xs"
|
|
||||||
variant="border"
|
|
||||||
icon="settings"
|
|
||||||
title="Extracted text settings"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
<SplitLayout
|
|
||||||
layout="vertical"
|
|
||||||
storageKey={`sse_extracted_text::${response.requestId}`}
|
|
||||||
defaultRatio={DEFAULT_EXTRACTED_TEXT_RATIO}
|
|
||||||
minHeightPx={72}
|
|
||||||
resizeHandleClassName="hover:bg-surface-highlight active:bg-surface-highlight"
|
|
||||||
firstSlot={({ style }) => (
|
|
||||||
<div style={style} className="min-h-0">
|
|
||||||
<EventViewer
|
<EventViewer
|
||||||
events={events.data ?? []}
|
events={events.data ?? []}
|
||||||
getEventKey={(_, index) => String(index)}
|
getEventKey={(_, index) => String(index)}
|
||||||
@@ -189,9 +46,7 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
content={
|
content={
|
||||||
<HStack space={2} className="items-center">
|
<HStack space={2} className="items-center">
|
||||||
<EventLabels event={event} index={index} isActive={isActive} />
|
<EventLabels event={event} index={index} isActive={isActive} />
|
||||||
<span className="truncate text-xs">
|
<span className="truncate text-xs">{event.data.slice(0, 1000)}</span>
|
||||||
{getEventPreview(event, summarySettings.resultKeyPath, filterEventPreviews)}
|
|
||||||
</span>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -200,8 +55,6 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
<EventDetail
|
<EventDetail
|
||||||
event={event}
|
event={event}
|
||||||
index={index}
|
index={index}
|
||||||
applyJsonPath={applyToDetails}
|
|
||||||
resultKeyPath={summarySettings.resultKeyPath}
|
|
||||||
showLarge={showLarge}
|
showLarge={showLarge}
|
||||||
showingLarge={showingLarge}
|
showingLarge={showingLarge}
|
||||||
setShowLarge={setShowLarge}
|
setShowLarge={setShowLarge}
|
||||||
@@ -210,152 +63,30 @@ function ActualEventStreamViewer({ response }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
secondSlot={
|
|
||||||
showExtractedText
|
|
||||||
? ({ style }) => (
|
|
||||||
<SseSummaryFooter
|
|
||||||
style={style}
|
|
||||||
error={summary.error ? String(summary.error) : null}
|
|
||||||
isLoading={summary.isLoading}
|
|
||||||
onRenderMarkdownChange={renderMarkdownSetting.set}
|
|
||||||
renderMarkdown={renderMarkdown}
|
|
||||||
resultKeyPath={summarySettings.resultKeyPath ?? ""}
|
|
||||||
summary={summary.data?.summary ?? ""}
|
|
||||||
fragmentCount={summary.data?.fragmentCount ?? 0}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SseSummaryFooter({
|
|
||||||
error,
|
|
||||||
fragmentCount,
|
|
||||||
isLoading,
|
|
||||||
onRenderMarkdownChange,
|
|
||||||
renderMarkdown,
|
|
||||||
resultKeyPath,
|
|
||||||
style,
|
|
||||||
summary,
|
|
||||||
}: {
|
|
||||||
error: string | null;
|
|
||||||
fragmentCount: number;
|
|
||||||
isLoading: boolean;
|
|
||||||
onRenderMarkdownChange: (renderMarkdown: boolean) => void;
|
|
||||||
renderMarkdown: boolean;
|
|
||||||
resultKeyPath: string;
|
|
||||||
style: CSSProperties;
|
|
||||||
summary: string;
|
|
||||||
}) {
|
|
||||||
const hasSummary = fragmentCount > 0;
|
|
||||||
const actions = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
key: "sse-summary-format",
|
|
||||||
label: "Extracted text format",
|
|
||||||
type: "select" as const,
|
|
||||||
value: renderMarkdown ? "markdown" : "text",
|
|
||||||
options: [
|
|
||||||
{ label: "Text", value: "text" },
|
|
||||||
{ label: "Markdown", value: "markdown" },
|
|
||||||
],
|
|
||||||
onChange: (value: string) => onRenderMarkdownChange(value === "markdown"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[onRenderMarkdownChange, renderMarkdown],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={style}
|
|
||||||
className="min-h-0 overflow-hidden border-t border-border-subtle bg-surface grid grid-rows-[auto_minmax(0,1fr)]"
|
|
||||||
>
|
|
||||||
<div className="pt-2">
|
|
||||||
<EventDetailHeader
|
|
||||||
actions={actions}
|
|
||||||
title="Extracted Text"
|
|
||||||
copyText={hasSummary ? summary : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"min-h-0 py-2 overflow-auto",
|
|
||||||
(error != null || isLoading || (hasSummary && !renderMarkdown)) && "text-xs",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{error != null ? (
|
|
||||||
<span className="text-danger">{error}</span>
|
|
||||||
) : isLoading ? (
|
|
||||||
<span className="italic text-text-subtlest">Loading extracted text...</span>
|
|
||||||
) : hasSummary ? (
|
|
||||||
renderMarkdown ? (
|
|
||||||
<div className="min-h-0">
|
|
||||||
<Markdown className="select-auto cursor-auto">{summary}</Markdown>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<pre className="font-mono whitespace-pre-wrap break-words select-auto cursor-auto">
|
|
||||||
{summary}
|
|
||||||
</pre>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<EmptyStateText className="gap-1.5">
|
|
||||||
No fragments for <InlineCode className="py-0">{resultKeyPath}</InlineCode>
|
|
||||||
</EmptyStateText>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEventPreview(
|
|
||||||
event: ServerSentEvent,
|
|
||||||
resultKeyPath: string | null,
|
|
||||||
filterEventPreview: boolean,
|
|
||||||
): string {
|
|
||||||
if (filterEventPreview && resultKeyPath != null) {
|
|
||||||
return (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data).slice(0, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
return event.data.slice(0, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function EventDetail({
|
function EventDetail({
|
||||||
applyJsonPath,
|
|
||||||
event,
|
event,
|
||||||
index,
|
index,
|
||||||
resultKeyPath,
|
|
||||||
showLarge,
|
showLarge,
|
||||||
showingLarge,
|
showingLarge,
|
||||||
setShowLarge,
|
setShowLarge,
|
||||||
setShowingLarge,
|
setShowingLarge,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
applyJsonPath: boolean;
|
|
||||||
event: ServerSentEvent;
|
event: ServerSentEvent;
|
||||||
index: number;
|
index: number;
|
||||||
resultKeyPath: string | null;
|
|
||||||
showLarge: boolean;
|
showLarge: boolean;
|
||||||
showingLarge: boolean;
|
showingLarge: boolean;
|
||||||
setShowLarge: (v: boolean) => void;
|
setShowLarge: (v: boolean) => void;
|
||||||
setShowingLarge: (v: boolean) => void;
|
setShowingLarge: (v: boolean) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
const detailText = useMemo(
|
|
||||||
() =>
|
|
||||||
applyJsonPath && resultKeyPath != null
|
|
||||||
? (extractSseValueAtPath(event.data, resultKeyPath) ?? event.data)
|
|
||||||
: event.data,
|
|
||||||
[applyJsonPath, event.data, resultKeyPath],
|
|
||||||
);
|
|
||||||
const language = useMemo<"text" | "json">(() => {
|
const language = useMemo<"text" | "json">(() => {
|
||||||
if (!detailText) return "text";
|
if (!event?.data) return "text";
|
||||||
return isJSON(detailText) ? "json" : "text";
|
return isJSON(event?.data) ? "json" : "text";
|
||||||
}, [detailText]);
|
}, [event?.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
@@ -364,7 +95,7 @@ function EventDetail({
|
|||||||
prefix={<EventLabels event={event} index={index} />}
|
prefix={<EventLabels event={event} index={index} />}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
{!showLarge && detailText.length > 1000 * 1000 ? (
|
{!showLarge && event.data.length > 1000 * 1000 ? (
|
||||||
<VStack space={2} className="italic text-text-subtlest">
|
<VStack space={2} className="italic text-text-subtlest">
|
||||||
Message previews larger than 1MB are hidden
|
Message previews larger than 1MB are hidden
|
||||||
<div>
|
<div>
|
||||||
@@ -386,7 +117,7 @@ function EventDetail({
|
|||||||
</div>
|
</div>
|
||||||
</VStack>
|
</VStack>
|
||||||
) : (
|
) : (
|
||||||
<FormattedEditor language={language} text={detailText} />
|
<FormattedEditor language={language} text={event.data} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -411,17 +142,14 @@ function EventLabels({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<HStack space={1.5} alignItems="center" className={className}>
|
<HStack space={1.5} alignItems="center" className={className}>
|
||||||
<EventLabel isActive={isActive}>{event.id ?? index}</EventLabel>
|
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
|
||||||
{event.eventType && <EventLabel isActive={isActive}>{event.eventType}</EventLabel>}
|
{event.id ?? index}
|
||||||
|
</InlineCode>
|
||||||
|
{event.eventType && (
|
||||||
|
<InlineCode className={classNames("py-0", isActive && "bg-text-subtlest text-text")}>
|
||||||
|
{event.eventType}
|
||||||
|
</InlineCode>
|
||||||
|
)}
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventLabel({ children, isActive }: { children: ReactNode; isActive?: boolean }) {
|
|
||||||
return (
|
|
||||||
<InlineCode className={classNames("py-0", isActive && "relative overflow-hidden")}>
|
|
||||||
{isActive && <span className="absolute inset-0 bg-text opacity-5 pointer-events-none" />}
|
|
||||||
<span className="relative">{children}</span>
|
|
||||||
</InlineCode>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { useMemo } from "react";
|
|||||||
import { openFolderSettings } from "../commands/openFolderSettings";
|
import { openFolderSettings } from "../commands/openFolderSettings";
|
||||||
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
import { openWorkspaceSettings } from "../commands/openWorkspaceSettings";
|
||||||
import { IconTooltip } from "../components/core/IconTooltip";
|
import { IconTooltip } from "../components/core/IconTooltip";
|
||||||
import type { RadioDropdownProps } from "../components/core/RadioDropdown";
|
|
||||||
import type { TabItem } from "../components/core/Tabs/Tabs";
|
import type { TabItem } from "../components/core/Tabs/Tabs";
|
||||||
import { capitalize } from "../lib/capitalize";
|
import { capitalize } from "../lib/capitalize";
|
||||||
import { showConfirm } from "../lib/confirm";
|
import { showConfirm } from "../lib/confirm";
|
||||||
@@ -15,37 +14,19 @@ import type { AuthenticatedModel } from "./useInheritedAuthentication";
|
|||||||
import { useInheritedAuthentication } from "./useInheritedAuthentication";
|
import { useInheritedAuthentication } from "./useInheritedAuthentication";
|
||||||
import { useModelAncestors } from "./useModelAncestors";
|
import { useModelAncestors } from "./useModelAncestors";
|
||||||
|
|
||||||
export function useAuthTab<T extends string>(
|
export function useAuthTab<T extends string>(tabValue: T, model: AuthenticatedModel | null) {
|
||||||
tabValue: T,
|
|
||||||
model: AuthenticatedModel | null,
|
|
||||||
) {
|
|
||||||
const options = useAuthDropdownOptions(model);
|
|
||||||
|
|
||||||
return useMemo<TabItem[]>(() => {
|
|
||||||
if (model == null || options == null) return [];
|
|
||||||
|
|
||||||
const tab: TabItem = {
|
|
||||||
value: tabValue,
|
|
||||||
label: "Auth",
|
|
||||||
options,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [tab];
|
|
||||||
}, [model, options, tabValue]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuthDropdownOptions(
|
|
||||||
model: AuthenticatedModel | null,
|
|
||||||
): Omit<RadioDropdownProps, "children"> | null {
|
|
||||||
const authentication = useHttpAuthenticationSummaries();
|
const authentication = useHttpAuthenticationSummaries();
|
||||||
const inheritedAuth = useInheritedAuthentication(model);
|
const inheritedAuth = useInheritedAuthentication(model);
|
||||||
const ancestors = useModelAncestors(model);
|
const ancestors = useModelAncestors(model);
|
||||||
const parentModel = ancestors[0] ?? null;
|
const parentModel = ancestors[0] ?? null;
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo<TabItem[]>(() => {
|
||||||
if (model == null) return null;
|
if (model == null) return [];
|
||||||
|
|
||||||
return {
|
const tab: TabItem = {
|
||||||
|
value: tabValue,
|
||||||
|
label: "Auth",
|
||||||
|
options: {
|
||||||
value: model.authenticationType,
|
value: model.authenticationType,
|
||||||
items: [
|
items: [
|
||||||
...authentication.map((a) => ({
|
...authentication.map((a) => ({
|
||||||
@@ -57,12 +38,10 @@ export function useAuthDropdownOptions(
|
|||||||
{
|
{
|
||||||
label: "Inherit from Parent",
|
label: "Inherit from Parent",
|
||||||
shortLabel:
|
shortLabel:
|
||||||
inheritedAuth != null &&
|
inheritedAuth != null && inheritedAuth.authenticationType !== "none" ? (
|
||||||
inheritedAuth.authenticationType !== "none" ? (
|
|
||||||
<HStack space={1.5}>
|
<HStack space={1.5}>
|
||||||
{authentication.find(
|
{authentication.find((a) => a.name === inheritedAuth.authenticationType)
|
||||||
(a) => a.name === inheritedAuth.authenticationType,
|
?.shortLabel ?? "UNKNOWN"}
|
||||||
)?.shortLabel ?? "UNKNOWN"}
|
|
||||||
<IconTooltip
|
<IconTooltip
|
||||||
icon="zap_off"
|
icon="zap_off"
|
||||||
iconSize="xs"
|
iconSize="xs"
|
||||||
@@ -79,11 +58,7 @@ export function useAuthDropdownOptions(
|
|||||||
itemsAfter: (() => {
|
itemsAfter: (() => {
|
||||||
const actions: (
|
const actions: (
|
||||||
| { type: "separator"; label: string }
|
| { type: "separator"; label: string }
|
||||||
| {
|
| { label: string; leftSlot: React.ReactNode; onSelect: () => Promise<void> }
|
||||||
label: string;
|
|
||||||
leftSlot: React.ReactNode;
|
|
||||||
onSelect: () => Promise<void>;
|
|
||||||
}
|
|
||||||
)[] = [];
|
)[] = [];
|
||||||
|
|
||||||
// Promote: move auth from current model up to parent
|
// Promote: move auth from current model up to parent
|
||||||
@@ -91,8 +66,7 @@ export function useAuthDropdownOptions(
|
|||||||
parentModel &&
|
parentModel &&
|
||||||
model.authenticationType &&
|
model.authenticationType &&
|
||||||
model.authenticationType !== "none" &&
|
model.authenticationType !== "none" &&
|
||||||
(parentModel.authenticationType == null ||
|
(parentModel.authenticationType == null || parentModel.authenticationType === "none")
|
||||||
parentModel.authenticationType === "none")
|
|
||||||
) {
|
) {
|
||||||
actions.push(
|
actions.push(
|
||||||
{ type: "separator", label: "Actions" },
|
{ type: "separator", label: "Actions" },
|
||||||
@@ -100,11 +74,7 @@ export function useAuthDropdownOptions(
|
|||||||
label: `Promote to ${capitalize(parentModel.model)}`,
|
label: `Promote to ${capitalize(parentModel.model)}`,
|
||||||
leftSlot: (
|
leftSlot: (
|
||||||
<Icon
|
<Icon
|
||||||
icon={
|
icon={parentModel.model === "workspace" ? "corner_right_up" : "folder_up"}
|
||||||
parentModel.model === "workspace"
|
|
||||||
? "corner_right_up"
|
|
||||||
: "folder_up"
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
onSelect: async () => {
|
onSelect: async () => {
|
||||||
@@ -120,10 +90,7 @@ export function useAuthDropdownOptions(
|
|||||||
),
|
),
|
||||||
});
|
});
|
||||||
if (confirmed) {
|
if (confirmed) {
|
||||||
await patchModel(model, {
|
await patchModel(model, { authentication: {}, authenticationType: null });
|
||||||
authentication: {},
|
|
||||||
authenticationType: null,
|
|
||||||
});
|
|
||||||
await patchModel(parentModel, {
|
await patchModel(parentModel, {
|
||||||
authentication: model.authentication,
|
authentication: model.authentication,
|
||||||
authenticationType: model.authenticationType,
|
authenticationType: model.authenticationType,
|
||||||
@@ -142,8 +109,7 @@ export function useAuthDropdownOptions(
|
|||||||
|
|
||||||
// Copy from ancestor: copy auth config down to current model
|
// Copy from ancestor: copy auth config down to current model
|
||||||
const ancestorWithAuth = ancestors.find(
|
const ancestorWithAuth = ancestors.find(
|
||||||
(a) =>
|
(a) => a.authenticationType != null && a.authenticationType !== "none",
|
||||||
a.authenticationType != null && a.authenticationType !== "none",
|
|
||||||
);
|
);
|
||||||
if (ancestorWithAuth) {
|
if (ancestorWithAuth) {
|
||||||
if (actions.length === 0) {
|
if (actions.length === 0) {
|
||||||
@@ -154,9 +120,7 @@ export function useAuthDropdownOptions(
|
|||||||
leftSlot: (
|
leftSlot: (
|
||||||
<Icon
|
<Icon
|
||||||
icon={
|
icon={
|
||||||
ancestorWithAuth.model === "workspace"
|
ancestorWithAuth.model === "workspace" ? "corner_right_down" : "folder_down"
|
||||||
? "corner_right_down"
|
|
||||||
: "folder_down"
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -168,15 +132,11 @@ export function useAuthDropdownOptions(
|
|||||||
description: (
|
description: (
|
||||||
<>
|
<>
|
||||||
Copy{" "}
|
Copy{" "}
|
||||||
{authentication.find(
|
{authentication.find((a) => a.name === ancestorWithAuth.authenticationType)
|
||||||
(a) => a.name === ancestorWithAuth.authenticationType,
|
?.label ?? "authentication"}{" "}
|
||||||
)?.label ?? "authentication"}{" "}
|
config from <InlineCode>{resolvedModelName(ancestorWithAuth)}</InlineCode>?
|
||||||
config from{" "}
|
This will override the current authentication but will not affect the{" "}
|
||||||
<InlineCode>
|
{modelTypeLabel(ancestorWithAuth).toLowerCase()}.
|
||||||
{resolvedModelName(ancestorWithAuth)}
|
|
||||||
</InlineCode>
|
|
||||||
? This will override the current authentication but will not
|
|
||||||
affect the {modelTypeLabel(ancestorWithAuth).toLowerCase()}.
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -201,6 +161,9 @@ export function useAuthDropdownOptions(
|
|||||||
}
|
}
|
||||||
await patchModel(model, { authentication, authenticationType });
|
await patchModel(model, { authentication, authenticationType });
|
||||||
},
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}, [authentication, inheritedAuth, model, parentModel, ancestors]);
|
|
||||||
|
return [tab];
|
||||||
|
}, [authentication, inheritedAuth, model, parentModel, tabValue, ancestors]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,7 @@ import { getResponseBodyEventSource } from "../lib/responseBody";
|
|||||||
export function useResponseBodyEventSource(response: HttpResponse) {
|
export function useResponseBodyEventSource(response: HttpResponse) {
|
||||||
return useQuery<ServerSentEvent[]>({
|
return useQuery<ServerSentEvent[]>({
|
||||||
placeholderData: (prev) => prev, // Keep previous data on refetch
|
placeholderData: (prev) => prev, // Keep previous data on refetch
|
||||||
queryKey: [
|
queryKey: ["response-body-event-source", response.id, response.contentLength],
|
||||||
"response-body-event-source",
|
|
||||||
response.id,
|
|
||||||
response.updatedAt,
|
|
||||||
response.contentLength,
|
|
||||||
],
|
|
||||||
queryFn: () => getResponseBodyEventSource(response),
|
queryFn: () => getResponseBodyEventSource(response),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import type { SseSummary } from "@yaakapp-internal/sse";
|
|
||||||
import { getResponseBodySseSummary } from "../lib/responseBody";
|
|
||||||
|
|
||||||
export function useResponseBodySseSummary(response: HttpResponse, resultKeyPath: string | null) {
|
|
||||||
return useQuery<SseSummary>({
|
|
||||||
enabled: resultKeyPath != null,
|
|
||||||
queryKey: [
|
|
||||||
"response-body-sse-summary",
|
|
||||||
response.id,
|
|
||||||
response.updatedAt,
|
|
||||||
response.contentLength,
|
|
||||||
resultKeyPath,
|
|
||||||
],
|
|
||||||
queryFn: () => getResponseBodySseSummary(response, resultKeyPath ?? ""),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
|
||||||
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
import type { GenericCompletionConfig } from "../components/core/Editor/genericCompletion";
|
|
||||||
import { useKeyValue } from "./useKeyValue";
|
|
||||||
|
|
||||||
const OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH = "$.choices[0].delta.content";
|
|
||||||
const OPENAI_RESPONSES_RESULT_KEY_PATH = "$.delta";
|
|
||||||
const ANTHROPIC_RESULT_KEY_PATH = "$.delta.text";
|
|
||||||
const GOOGLE_RESULT_KEY_PATH = "$.candidates[0].content.parts[0].text";
|
|
||||||
|
|
||||||
const sseSummaryResultKeyPathOptions: GenericCompletionOption[] = [
|
|
||||||
{
|
|
||||||
label: OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH,
|
|
||||||
detail: "ChatGPT (OpenAI)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: OPENAI_RESPONSES_RESULT_KEY_PATH,
|
|
||||||
detail: "Responses (OpenAI)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: ANTHROPIC_RESULT_KEY_PATH,
|
|
||||||
detail: "Claude (Anthropic)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: GOOGLE_RESULT_KEY_PATH,
|
|
||||||
detail: "Gemini (Google)",
|
|
||||||
type: "constant",
|
|
||||||
boost: 1,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const sseSummaryResultKeyPathAutocomplete: GenericCompletionConfig = {
|
|
||||||
minMatch: 0,
|
|
||||||
options: sseSummaryResultKeyPathOptions,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useSseSummaryResultKeyPath({ response }: { response: HttpResponse }) {
|
|
||||||
const storedResultKeyPath = useKeyValue<string | null>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_summary_result_key_path", response.requestId],
|
|
||||||
fallback: null,
|
|
||||||
});
|
|
||||||
const enabled = useKeyValue<boolean | null>({
|
|
||||||
namespace: "no_sync",
|
|
||||||
key: ["sse_summary_result_key_path_enabled", response.requestId],
|
|
||||||
fallback: null,
|
|
||||||
});
|
|
||||||
const inferredResultKeyPath = useMemo(() => inferSseSummaryResultKeyPath(response), [response.url]);
|
|
||||||
const resultKeyPath = storedResultKeyPath.value ?? inferredResultKeyPath;
|
|
||||||
const trimmedResultKeyPath = resultKeyPath?.trim() ?? "";
|
|
||||||
const isEnabled = enabled.value ?? inferredResultKeyPath != null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
enabled: isEnabled,
|
|
||||||
inferredResultKeyPath,
|
|
||||||
resultKeyPath: isEnabled && trimmedResultKeyPath.length > 0 ? trimmedResultKeyPath : null,
|
|
||||||
resultKeyPathInputValue: resultKeyPath ?? "",
|
|
||||||
setEnabled: enabled.set,
|
|
||||||
setResultKeyPath: storedResultKeyPath.set,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function inferSseSummaryResultKeyPath(response: HttpResponse): string | null {
|
|
||||||
let url: URL;
|
|
||||||
try {
|
|
||||||
url = new URL(response.url);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
|
||||||
const pathname = url.pathname.toLowerCase();
|
|
||||||
|
|
||||||
if (hostname === "api.openai.com" && pathname === "/v1/chat/completions") {
|
|
||||||
return OPENAI_CHAT_COMPLETIONS_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
if (hostname === "api.openai.com" && pathname === "/v1/responses") {
|
|
||||||
return OPENAI_RESPONSES_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
if (hostname === "api.anthropic.com" && pathname === "/v1/messages") {
|
|
||||||
return ANTHROPIC_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
hostname === "generativelanguage.googleapis.com" &&
|
|
||||||
pathname.includes(":streamgeneratecontent")
|
|
||||||
) {
|
|
||||||
return GOOGLE_RESULT_KEY_PATH;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { describe, expect, test } from "vite-plus/test";
|
|
||||||
import { extractPathPlaceholders } from "./pathPlaceholders";
|
|
||||||
|
|
||||||
describe("extractPathPlaceholders", () => {
|
|
||||||
test("extracts a single placeholder", () => {
|
|
||||||
expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("extracts multiple placeholders", () => {
|
|
||||||
expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("stops at a literal `:` in the same segment", () => {
|
|
||||||
expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not match `:foo` mid-segment", () => {
|
|
||||||
expect(extractPathPlaceholders("/users/abc:def")).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not match `:` in a host port", () => {
|
|
||||||
expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("returns empty for a URL with no placeholders", () => {
|
|
||||||
expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
/**
|
|
||||||
* Extract `:name`-style path placeholders from a URL string.
|
|
||||||
*
|
|
||||||
* A placeholder is `:` followed by one-or-more characters that are not `/`, `?`,
|
|
||||||
* `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon
|
|
||||||
* starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one
|
|
||||||
* placeholder `:id` and `:increment-importance` is literal text.
|
|
||||||
*
|
|
||||||
* Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def`
|
|
||||||
* has no placeholders. Returned names include the leading colon.
|
|
||||||
*/
|
|
||||||
export function extractPathPlaceholders(url: string): string[] {
|
|
||||||
return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? "");
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export function pricingUrl(intent: string): string {
|
|
||||||
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ type ModelType = AnyModel["model"];
|
|||||||
type WorkspaceRequestSettings = Pick<
|
type WorkspaceRequestSettings = Pick<
|
||||||
Workspace,
|
Workspace,
|
||||||
| "settingFollowRedirects"
|
| "settingFollowRedirects"
|
||||||
| "settingRequestMessageSize"
|
|
||||||
| "settingRequestTimeout"
|
| "settingRequestTimeout"
|
||||||
| "settingSendCookies"
|
| "settingSendCookies"
|
||||||
| "settingStoreCookies"
|
| "settingStoreCookies"
|
||||||
@@ -18,9 +17,7 @@ type ModelTypeWithSetting<K extends RequestSettingKey> = {
|
|||||||
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
[M in ModelType]: K extends keyof ModelForType<M> ? M : never;
|
||||||
}[ModelType];
|
}[ModelType];
|
||||||
|
|
||||||
export type RequestSettingDefinition<
|
export type RequestSettingDefinition<K extends RequestSettingKey = RequestSettingKey> = {
|
||||||
K extends RequestSettingKey = RequestSettingKey,
|
|
||||||
> = {
|
|
||||||
defaultValue: WorkspaceRequestSettings[K];
|
defaultValue: WorkspaceRequestSettings[K];
|
||||||
description: string;
|
description: string;
|
||||||
modelKey: K;
|
modelKey: K;
|
||||||
@@ -44,26 +41,11 @@ export const SETTING_REQUEST_TIMEOUT = defineRequestSetting({
|
|||||||
title: "Request Timeout",
|
title: "Request Timeout",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SETTING_REQUEST_MESSAGE_SIZE = defineRequestSetting({
|
|
||||||
defaultValue: 64 * 1024 * 1024,
|
|
||||||
description:
|
|
||||||
"Maximum gRPC or WebSocket message size in MB. Set to 0 to disable.",
|
|
||||||
modelKey: "settingRequestMessageSize",
|
|
||||||
models: ["workspace", "folder", "websocket_request", "grpc_request"],
|
|
||||||
title: "Message Size Limit",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
export const SETTING_VALIDATE_CERTIFICATES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description: "When disabled, skip validation of server certificates.",
|
description: "When disabled, skip validation of server certificates.",
|
||||||
modelKey: "settingValidateCertificates",
|
modelKey: "settingValidateCertificates",
|
||||||
models: [
|
models: ["workspace", "folder", "http_request", "websocket_request", "grpc_request"],
|
||||||
"workspace",
|
|
||||||
"folder",
|
|
||||||
"http_request",
|
|
||||||
"websocket_request",
|
|
||||||
"grpc_request",
|
|
||||||
],
|
|
||||||
title: "Validate TLS certificates",
|
title: "Validate TLS certificates",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -77,8 +59,7 @@ export const SETTING_FOLLOW_REDIRECTS = defineRequestSetting({
|
|||||||
|
|
||||||
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
export const SETTING_SEND_COOKIES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description:
|
description: "Attach matching cookies from the active cookie jar to outgoing requests.",
|
||||||
"Attach matching cookies from the active cookie jar to outgoing requests.",
|
|
||||||
modelKey: "settingSendCookies",
|
modelKey: "settingSendCookies",
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
title: "Automatically send cookies",
|
title: "Automatically send cookies",
|
||||||
@@ -86,8 +67,7 @@ export const SETTING_SEND_COOKIES = defineRequestSetting({
|
|||||||
|
|
||||||
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
export const SETTING_STORE_COOKIES = defineRequestSetting({
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
description:
|
description: "Save cookies from Set-Cookie response headers to the active cookie jar.",
|
||||||
"Save cookies from Set-Cookie response headers to the active cookie jar.",
|
|
||||||
modelKey: "settingStoreCookies",
|
modelKey: "settingStoreCookies",
|
||||||
models: ["workspace", "folder", "http_request", "websocket_request"],
|
models: ["workspace", "folder", "http_request", "websocket_request"],
|
||||||
title: "Automatically store cookies",
|
title: "Automatically store cookies",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { readFile } from "@tauri-apps/plugin-fs";
|
import { readFile } from "@tauri-apps/plugin-fs";
|
||||||
import type { HttpResponse } from "@yaakapp-internal/models";
|
import type { HttpResponse } from "@yaakapp-internal/models";
|
||||||
import type { FilterResponse } from "@yaakapp-internal/plugins";
|
import type { FilterResponse } from "@yaakapp-internal/plugins";
|
||||||
import type { ServerSentEvent, SseSummary } from "@yaakapp-internal/sse";
|
import type { ServerSentEvent } from "@yaakapp-internal/sse";
|
||||||
import { candidateJsonPayloadsFromSseText, computeSseSummary } from "@yaakapp-internal/sse";
|
|
||||||
import { invokeCmd } from "./tauri";
|
import { invokeCmd } from "./tauri";
|
||||||
|
|
||||||
export async function getResponseBodyText({
|
export async function getResponseBodyText({
|
||||||
@@ -28,36 +27,9 @@ export async function getResponseBodyEventSource(
|
|||||||
response: HttpResponse,
|
response: HttpResponse,
|
||||||
): Promise<ServerSentEvent[]> {
|
): Promise<ServerSentEvent[]> {
|
||||||
if (!response.bodyPath) return [];
|
if (!response.bodyPath) return [];
|
||||||
try {
|
return invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
|
||||||
const events = await invokeCmd<ServerSentEvent[]>("cmd_get_sse_events", {
|
|
||||||
filePath: response.bodyPath,
|
filePath: response.bodyPath,
|
||||||
});
|
});
|
||||||
if (events.length > 0) {
|
|
||||||
return events;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Fall back to raw JSON frame parsing for non-standard SSE-like responses.
|
|
||||||
}
|
|
||||||
|
|
||||||
const bytes = await readFile(response.bodyPath);
|
|
||||||
const text = new TextDecoder("utf-8").decode(bytes);
|
|
||||||
return candidateJsonPayloadsFromSseText(text).map((data, index) => ({
|
|
||||||
data,
|
|
||||||
eventType: "",
|
|
||||||
id: String(index),
|
|
||||||
retry: null,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getResponseBodySseSummary(
|
|
||||||
response: HttpResponse,
|
|
||||||
resultKeyPath: string,
|
|
||||||
): Promise<SseSummary> {
|
|
||||||
if (!response.bodyPath) return { fragmentCount: 0, summary: "" };
|
|
||||||
|
|
||||||
const bytes = await readFile(response.bodyPath);
|
|
||||||
const text = new TextDecoder("utf-8").decode(bytes);
|
|
||||||
return computeSseSummary(text, resultKeyPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getResponseBodyBytes(
|
export async function getResponseBodyBytes(
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-pdf": "^10.0.1",
|
"react-pdf": "^10.0.1",
|
||||||
"react-syntax-highlighter": "^16.1.0",
|
"react-syntax-highlighter": "^16.1.0",
|
||||||
"react-use": "^17.6.1",
|
"react-use": "^17.6.0",
|
||||||
"rehype-stringify": "^10.0.1",
|
"rehype-stringify": "^10.0.1",
|
||||||
"remark-frontmatter": "^5.0.0",
|
"remark-frontmatter": "^5.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
@@ -102,11 +102,11 @@
|
|||||||
"postcss-nesting": "^13.0.2",
|
"postcss-nesting": "^13.0.2",
|
||||||
"rollup": "^4.60.3",
|
"rollup": "^4.60.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
|
||||||
"vite-plugin-static-copy": "^3.3.0",
|
"vite-plugin-static-copy": "^3.3.0",
|
||||||
"vite-plugin-svgr": "^4.5.0",
|
"vite-plugin-svgr": "^4.5.0",
|
||||||
"vite-plugin-top-level-await": "^1.5.0",
|
"vite-plugin-top-level-await": "^1.5.0",
|
||||||
"vite-plugin-wasm": "^3.5.0",
|
"vite-plugin-wasm": "^3.5.0",
|
||||||
"vite-plus": "^0.2.1"
|
"vite-plus": "^0.1.20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export default defineConfig(async () => {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
build: {
|
build: {
|
||||||
target: "esnext",
|
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
outDir: "../../dist/apps/yaak-client",
|
outDir: "../../dist/apps/yaak-client",
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
|
||||||
"vite-plus": "^0.2.1"
|
"vite-plus": "^0.1.20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use crate::models_ext::QueryManagerExt;
|
|||||||
use chrono::{NaiveDateTime, Utc};
|
use chrono::{NaiveDateTime, Utc};
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use tauri::{AppHandle, Runtime, is_dev};
|
use tauri::{AppHandle, Runtime};
|
||||||
use yaak_models::util::UpdateSource;
|
use yaak_models::util::UpdateSource;
|
||||||
|
|
||||||
const NAMESPACE: &str = "analytics";
|
const NAMESPACE: &str = "analytics";
|
||||||
@@ -36,10 +36,6 @@ pub fn get_or_upsert_launch_info<R: Runtime>(app_handle: &AppHandle<R>) -> &Laun
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
if is_dev() {
|
|
||||||
info.current_version = "0.0.1".to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
app_handle
|
app_handle
|
||||||
.with_tx(|tx| {
|
.with_tx(|tx| {
|
||||||
// Load the previously tracked version
|
// Load the previously tracked version
|
||||||
|
|||||||
@@ -295,8 +295,7 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
|||||||
unrendered_request.folder_id.as_deref(),
|
unrendered_request.folder_id.as_deref(),
|
||||||
environment_id,
|
environment_id,
|
||||||
)?;
|
)?;
|
||||||
let resolved_settings =
|
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||||
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
|
||||||
|
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
@@ -333,7 +332,6 @@ async fn cmd_grpc_reflect<R: Runtime>(
|
|||||||
&metadata,
|
&metadata,
|
||||||
resolved_settings.validate_certificates.value,
|
resolved_settings.validate_certificates.value,
|
||||||
client_certificate,
|
client_certificate,
|
||||||
resolved_settings.request_message_size.value,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| GenericError(e.to_string()))?)
|
.map_err(|e| GenericError(e.to_string()))?)
|
||||||
@@ -355,8 +353,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
unrendered_request.folder_id.as_deref(),
|
unrendered_request.folder_id.as_deref(),
|
||||||
environment_id,
|
environment_id,
|
||||||
)?;
|
)?;
|
||||||
let resolved_settings =
|
let resolved_settings = app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
||||||
app_handle.db().resolve_settings_for_grpc_request(&unrendered_request)?;
|
|
||||||
|
|
||||||
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
let plugin_manager = Arc::new((*app_handle.state::<PluginManager>()).clone());
|
||||||
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
let encryption_manager = Arc::new((*app_handle.state::<EncryptionManager>()).clone());
|
||||||
@@ -428,7 +425,6 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&metadata,
|
&metadata,
|
||||||
resolved_settings.validate_certificates.value,
|
resolved_settings.validate_certificates.value,
|
||||||
client_cert.clone(),
|
client_cert.clone(),
|
||||||
resolved_settings.request_message_size.value,
|
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@@ -718,7 +714,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
Some(s) => GrpcEvent {
|
Some(s) => GrpcEvent {
|
||||||
error: Some(s.message().to_string()),
|
error: Some(s.message().to_string()),
|
||||||
status: Some(s.code() as i32),
|
status: Some(s.code() as i32),
|
||||||
content: "Request failed".to_string(),
|
content: "Failed to connect".to_string(),
|
||||||
metadata: metadata_to_map(s.metadata().clone()),
|
metadata: metadata_to_map(s.metadata().clone()),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
@@ -726,7 +722,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
None => GrpcEvent {
|
None => GrpcEvent {
|
||||||
error: Some(e.message),
|
error: Some(e.message),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Request failed".to_string(),
|
content: "Failed to connect".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -742,7 +738,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Request failed".to_string(),
|
content: "Failed to connect".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -785,7 +781,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
Some(s) => GrpcEvent {
|
Some(s) => GrpcEvent {
|
||||||
error: Some(s.message().to_string()),
|
error: Some(s.message().to_string()),
|
||||||
status: Some(s.code() as i32),
|
status: Some(s.code() as i32),
|
||||||
content: "Stream failed".to_string(),
|
content: "Failed to connect".to_string(),
|
||||||
metadata: metadata_to_map(s.metadata().clone()),
|
metadata: metadata_to_map(s.metadata().clone()),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
@@ -793,7 +789,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
None => GrpcEvent {
|
None => GrpcEvent {
|
||||||
error: Some(e.message),
|
error: Some(e.message),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Stream failed".to_string(),
|
content: "Failed to connect".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -810,7 +806,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
error: Some(e.to_string()),
|
error: Some(e.to_string()),
|
||||||
status: Some(Code::Unknown as i32),
|
status: Some(Code::Unknown as i32),
|
||||||
content: "Stream failed".to_string(),
|
content: "Failed to connect".to_string(),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
..base_event.clone()
|
..base_event.clone()
|
||||||
},
|
},
|
||||||
@@ -882,8 +878,7 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
.db()
|
.db()
|
||||||
.upsert_grpc_event(
|
.upsert_grpc_event(
|
||||||
&GrpcEvent {
|
&GrpcEvent {
|
||||||
content: "Stream failed".to_string(),
|
content: status.to_string(),
|
||||||
error: Some(status.message().to_string()),
|
|
||||||
status: Some(status.code() as i32),
|
status: Some(status.code() as i32),
|
||||||
metadata: metadata_to_map(status.metadata().clone()),
|
metadata: metadata_to_map(status.metadata().clone()),
|
||||||
event_type: GrpcEventType::ConnectionEnd,
|
event_type: GrpcEventType::ConnectionEnd,
|
||||||
@@ -892,7 +887,6 @@ async fn cmd_grpc_go<R: Runtime>(
|
|||||||
&UpdateSource::from_window_label(window.label()),
|
&UpdateSource::from_window_label(window.label()),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl YaakNotifier {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Checking for notifications");
|
debug!("Checking for notifications");
|
||||||
|
|
||||||
#[cfg(feature = "license")]
|
#[cfg(feature = "license")]
|
||||||
let license_check = {
|
let license_check = {
|
||||||
@@ -115,20 +115,17 @@ impl YaakNotifier {
|
|||||||
]);
|
]);
|
||||||
let resp = req.send().await?;
|
let resp = req.send().await?;
|
||||||
if resp.status() != 200 {
|
if resp.status() != 200 {
|
||||||
info!("Skipping notification status code {}", resp.status());
|
debug!("Skipping notification status code {}", resp.status());
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let notifications = resp.json::<Vec<YaakNotification>>().await?;
|
for notification in resp.json::<Vec<YaakNotification>>().await? {
|
||||||
debug!("Received {} notifications", notifications.len());
|
|
||||||
|
|
||||||
for notification in notifications {
|
|
||||||
let seen = get_kv(app_handle).await?;
|
let seen = get_kv(app_handle).await?;
|
||||||
if seen.contains(¬ification.id) {
|
if seen.contains(¬ification.id) {
|
||||||
debug!("Already seen notification {}", notification.id);
|
debug!("Already seen notification {}", notification.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
info!("Got notification {:?}", notification);
|
debug!("Got notification {:?}", notification);
|
||||||
|
|
||||||
let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
|
let _ = app_handle.emit_to(window.label(), "notification", notification.clone());
|
||||||
break; // Only show one notification
|
break; // Only show one notification
|
||||||
|
|||||||
@@ -50,37 +50,6 @@ pub async fn cmd_ws_send<R: Runtime>(
|
|||||||
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
ws_manager: State<'_, Mutex<WebsocketManager>>,
|
||||||
) -> Result<WebsocketConnection> {
|
) -> Result<WebsocketConnection> {
|
||||||
let connection = app_handle.db().get_websocket_connection(connection_id)?;
|
let connection = app_handle.db().get_websocket_connection(connection_id)?;
|
||||||
|
|
||||||
match send_websocket_message(&connection, environment_id, &app_handle, &window, &ws_manager)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(connection) => Ok(connection),
|
|
||||||
Err(e) => {
|
|
||||||
app_handle.db().upsert_websocket_event(
|
|
||||||
&WebsocketEvent {
|
|
||||||
connection_id: connection.id.clone(),
|
|
||||||
request_id: connection.request_id.clone(),
|
|
||||||
workspace_id: connection.workspace_id.clone(),
|
|
||||||
is_server: false,
|
|
||||||
message_type: WebsocketEventType::Error,
|
|
||||||
message: e.to_string().into(),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
&UpdateSource::from_window_label(window.label()),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
Ok(connection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn send_websocket_message<R: Runtime>(
|
|
||||||
connection: &WebsocketConnection,
|
|
||||||
environment_id: Option<&str>,
|
|
||||||
app_handle: &AppHandle<R>,
|
|
||||||
window: &WebviewWindow<R>,
|
|
||||||
ws_manager: &Mutex<WebsocketManager>,
|
|
||||||
) -> Result<WebsocketConnection> {
|
|
||||||
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
|
let unrendered_request = app_handle.db().get_websocket_request(&connection.request_id)?;
|
||||||
let environment_chain = app_handle.db().resolve_environments(
|
let environment_chain = app_handle.db().resolve_environments(
|
||||||
&unrendered_request.workspace_id,
|
&unrendered_request.workspace_id,
|
||||||
@@ -122,7 +91,7 @@ async fn send_websocket_message<R: Runtime>(
|
|||||||
&UpdateSource::from_window_label(window.label()),
|
&UpdateSource::from_window_label(window.label()),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok(connection.clone())
|
Ok(connection)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[command]
|
#[command]
|
||||||
@@ -330,7 +299,6 @@ pub async fn cmd_ws_connect<R: Runtime>(
|
|||||||
receive_tx,
|
receive_tx,
|
||||||
resolved_settings.validate_certificates.value,
|
resolved_settings.validate_certificates.value,
|
||||||
client_cert,
|
client_cert,
|
||||||
resolved_settings.request_message_size.value,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
|
|||||||
Generated
-4
@@ -46,7 +46,6 @@ export type Folder = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GrpcRequest = {
|
export type GrpcRequest = {
|
||||||
@@ -70,7 +69,6 @@ export type GrpcRequest = {
|
|||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequest = {
|
export type HttpRequest = {
|
||||||
@@ -148,7 +146,6 @@ export type WebsocketRequest = {
|
|||||||
settingSendCookies: InheritedBoolSetting;
|
settingSendCookies: InheritedBoolSetting;
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Workspace = {
|
export type Workspace = {
|
||||||
@@ -165,7 +162,6 @@ export type Workspace = {
|
|||||||
settingValidateCertificates: boolean;
|
settingValidateCertificates: boolean;
|
||||||
settingFollowRedirects: boolean;
|
settingFollowRedirects: boolean;
|
||||||
settingRequestTimeout: number;
|
settingRequestTimeout: number;
|
||||||
settingRequestMessageSize: number;
|
|
||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
|||||||
@@ -33,21 +33,15 @@ impl AutoReflectionClient {
|
|||||||
uri: &Uri,
|
uri: &Uri,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
max_message_size: usize,
|
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
|
let client_v1 = v1::server_reflection_client::ServerReflectionClient::with_origin(
|
||||||
get_transport(validate_certificates, client_cert.clone())?,
|
get_transport(validate_certificates, client_cert.clone())?,
|
||||||
uri.clone(),
|
uri.clone(),
|
||||||
)
|
);
|
||||||
.max_decoding_message_size(max_message_size)
|
let client_v1alpha = v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
|
||||||
.max_encoding_message_size(max_message_size);
|
|
||||||
let client_v1alpha =
|
|
||||||
v1alpha::server_reflection_client::ServerReflectionClient::with_origin(
|
|
||||||
get_transport(validate_certificates, client_cert.clone())?,
|
get_transport(validate_certificates, client_cert.clone())?,
|
||||||
uri.clone(),
|
uri.clone(),
|
||||||
)
|
);
|
||||||
.max_decoding_message_size(max_message_size)
|
|
||||||
.max_encoding_message_size(max_message_size);
|
|
||||||
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
|
Ok(AutoReflectionClient { use_v1alpha: false, client_v1, client_v1alpha })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ pub struct GrpcConnection {
|
|||||||
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
||||||
pub uri: Uri,
|
pub uri: Uri,
|
||||||
use_reflection: bool,
|
use_reflection: bool,
|
||||||
max_message_size: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Debug)]
|
#[derive(Default, Debug)]
|
||||||
@@ -98,14 +97,7 @@ impl GrpcConnection {
|
|||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
) -> Result<Response<DynamicMessage>> {
|
) -> Result<Response<DynamicMessage>> {
|
||||||
if self.use_reflection {
|
if self.use_reflection {
|
||||||
reflect_types_for_message(
|
reflect_types_for_message(self.pool.clone(), &self.uri, message, metadata, client_cert)
|
||||||
self.pool.clone(),
|
|
||||||
&self.uri,
|
|
||||||
message,
|
|
||||||
metadata,
|
|
||||||
client_cert,
|
|
||||||
self.max_message_size,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
let method = &self.method(&service, &method).await?;
|
let method = &self.method(&service, &method).await?;
|
||||||
@@ -115,7 +107,7 @@ impl GrpcConnection {
|
|||||||
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
||||||
deserializer.end()?;
|
deserializer.end()?;
|
||||||
|
|
||||||
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
||||||
|
|
||||||
let mut req = req_message.into_request();
|
let mut req = req_message.into_request();
|
||||||
decorate_req(metadata, &mut req)?;
|
decorate_req(metadata, &mut req)?;
|
||||||
@@ -140,7 +132,6 @@ impl GrpcConnection {
|
|||||||
message,
|
message,
|
||||||
metadata,
|
metadata,
|
||||||
client_cert,
|
client_cert,
|
||||||
self.max_message_size,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -180,7 +171,6 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
let max_message_size = self.max_message_size;
|
|
||||||
stream
|
stream
|
||||||
.then(move |json| {
|
.then(move |json| {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
@@ -193,15 +183,8 @@ impl GrpcConnection {
|
|||||||
let json_clone = json.clone();
|
let json_clone = json.clone();
|
||||||
async move {
|
async move {
|
||||||
if use_reflection {
|
if use_reflection {
|
||||||
if let Err(e) = reflect_types_for_message(
|
if let Err(e) =
|
||||||
pool,
|
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||||
&uri,
|
|
||||||
&json,
|
|
||||||
&md,
|
|
||||||
client_cert,
|
|
||||||
max_message_size,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
warn!("Failed to resolve Any types: {e}");
|
warn!("Failed to resolve Any types: {e}");
|
||||||
}
|
}
|
||||||
@@ -223,7 +206,7 @@ impl GrpcConnection {
|
|||||||
.filter_map(|x| x)
|
.filter_map(|x| x)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
||||||
let path = method_desc_to_path(method);
|
let path = method_desc_to_path(method);
|
||||||
let codec = DynamicCodec::new(method.clone());
|
let codec = DynamicCodec::new(method.clone());
|
||||||
|
|
||||||
@@ -254,7 +237,6 @@ impl GrpcConnection {
|
|||||||
let md = metadata.clone();
|
let md = metadata.clone();
|
||||||
let use_reflection = self.use_reflection.clone();
|
let use_reflection = self.use_reflection.clone();
|
||||||
let client_cert = client_cert.clone();
|
let client_cert = client_cert.clone();
|
||||||
let max_message_size = self.max_message_size;
|
|
||||||
stream
|
stream
|
||||||
.then(move |json| {
|
.then(move |json| {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
@@ -267,15 +249,8 @@ impl GrpcConnection {
|
|||||||
let json_clone = json.clone();
|
let json_clone = json.clone();
|
||||||
async move {
|
async move {
|
||||||
if use_reflection {
|
if use_reflection {
|
||||||
if let Err(e) = reflect_types_for_message(
|
if let Err(e) =
|
||||||
pool,
|
reflect_types_for_message(pool, &uri, &json, &md, client_cert).await
|
||||||
&uri,
|
|
||||||
&json,
|
|
||||||
&md,
|
|
||||||
client_cert,
|
|
||||||
max_message_size,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
{
|
||||||
warn!("Failed to resolve Any types: {e}");
|
warn!("Failed to resolve Any types: {e}");
|
||||||
}
|
}
|
||||||
@@ -297,7 +272,7 @@ impl GrpcConnection {
|
|||||||
.filter_map(|x| x)
|
.filter_map(|x| x)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
||||||
let path = method_desc_to_path(method);
|
let path = method_desc_to_path(method);
|
||||||
let codec = DynamicCodec::new(method.clone());
|
let codec = DynamicCodec::new(method.clone());
|
||||||
|
|
||||||
@@ -325,7 +300,7 @@ impl GrpcConnection {
|
|||||||
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
let req_message = DynamicMessage::deserialize(input_message, &mut deserializer)?;
|
||||||
deserializer.end()?;
|
deserializer.end()?;
|
||||||
|
|
||||||
let mut client = grpc_client(self.conn.clone(), self.uri.clone(), self.max_message_size);
|
let mut client = tonic::client::Grpc::with_origin(self.conn.clone(), self.uri.clone());
|
||||||
|
|
||||||
let mut req = req_message.into_request();
|
let mut req = req_message.into_request();
|
||||||
decorate_req(metadata, &mut req)?;
|
decorate_req(metadata, &mut req)?;
|
||||||
@@ -337,23 +312,6 @@ impl GrpcConnection {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn grpc_client(
|
|
||||||
conn: Client<HttpsConnector<HttpConnector>, BoxBody>,
|
|
||||||
uri: Uri,
|
|
||||||
max_message_size: usize,
|
|
||||||
) -> tonic::client::Grpc<Client<HttpsConnector<HttpConnector>, BoxBody>> {
|
|
||||||
tonic::client::Grpc::with_origin(conn, uri)
|
|
||||||
.max_decoding_message_size(max_message_size)
|
|
||||||
.max_encoding_message_size(max_message_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn message_size_limit(setting: i32) -> usize {
|
|
||||||
match setting.try_into() {
|
|
||||||
Ok(0) | Err(_) => usize::MAX,
|
|
||||||
Ok(limit) => limit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Configuration for GrpcHandle to compile proto files
|
/// Configuration for GrpcHandle to compile proto files
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct GrpcConfig {
|
pub struct GrpcConfig {
|
||||||
@@ -390,7 +348,6 @@ impl GrpcHandle {
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
request_message_size: i32,
|
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let server_reflection = proto_files.is_empty();
|
let server_reflection = proto_files.is_empty();
|
||||||
let key = make_pool_key(id, uri, proto_files);
|
let key = make_pool_key(id, uri, proto_files);
|
||||||
@@ -402,14 +359,7 @@ impl GrpcHandle {
|
|||||||
|
|
||||||
let pool = if server_reflection {
|
let pool = if server_reflection {
|
||||||
let full_uri = uri_from_str(uri)?;
|
let full_uri = uri_from_str(uri)?;
|
||||||
fill_pool_from_reflection(
|
fill_pool_from_reflection(&full_uri, metadata, validate_certificates, client_cert).await
|
||||||
&full_uri,
|
|
||||||
metadata,
|
|
||||||
validate_certificates,
|
|
||||||
client_cert,
|
|
||||||
message_size_limit(request_message_size),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
} else {
|
} else {
|
||||||
fill_pool_from_files(&self.config, proto_files).await
|
fill_pool_from_files(&self.config, proto_files).await
|
||||||
}?;
|
}?;
|
||||||
@@ -426,20 +376,11 @@ impl GrpcHandle {
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
request_message_size: i32,
|
|
||||||
) -> Result<Vec<ServiceDefinition>> {
|
) -> Result<Vec<ServiceDefinition>> {
|
||||||
// Ensure we have a pool; reflect only if missing
|
// Ensure we have a pool; reflect only if missing
|
||||||
if self.get_pool(id, uri, proto_files).is_none() {
|
if self.get_pool(id, uri, proto_files).is_none() {
|
||||||
info!("Reflecting gRPC services for {} at {}", id, uri);
|
info!("Reflecting gRPC services for {} at {}", id, uri);
|
||||||
self.reflect(
|
self.reflect(id, uri, proto_files, metadata, validate_certificates, client_cert)
|
||||||
id,
|
|
||||||
uri,
|
|
||||||
proto_files,
|
|
||||||
metadata,
|
|
||||||
validate_certificates,
|
|
||||||
client_cert,
|
|
||||||
request_message_size,
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,10 +421,8 @@ impl GrpcHandle {
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
request_message_size: i32,
|
|
||||||
) -> Result<GrpcConnection> {
|
) -> Result<GrpcConnection> {
|
||||||
let use_reflection = proto_files.is_empty();
|
let use_reflection = proto_files.is_empty();
|
||||||
let max_message_size = message_size_limit(request_message_size);
|
|
||||||
if self.get_pool(id, uri, proto_files).is_none() {
|
if self.get_pool(id, uri, proto_files).is_none() {
|
||||||
self.reflect(
|
self.reflect(
|
||||||
id,
|
id,
|
||||||
@@ -492,7 +431,6 @@ impl GrpcHandle {
|
|||||||
metadata,
|
metadata,
|
||||||
validate_certificates,
|
validate_certificates,
|
||||||
client_cert.clone(),
|
client_cert.clone(),
|
||||||
request_message_size,
|
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
@@ -502,13 +440,7 @@ impl GrpcHandle {
|
|||||||
.clone();
|
.clone();
|
||||||
let uri = uri_from_str(uri)?;
|
let uri = uri_from_str(uri)?;
|
||||||
let conn = get_transport(validate_certificates, client_cert.clone())?;
|
let conn = get_transport(validate_certificates, client_cert.clone())?;
|
||||||
Ok(GrpcConnection {
|
Ok(GrpcConnection { pool: Arc::new(RwLock::new(pool)), use_reflection, conn, uri })
|
||||||
pool: Arc::new(RwLock::new(pool)),
|
|
||||||
use_reflection,
|
|
||||||
conn,
|
|
||||||
uri,
|
|
||||||
max_message_size,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
|
fn get_pool(&self, id: &str, uri: &str, proto_files: &Vec<PathBuf>) -> Option<&DescriptorPool> {
|
||||||
|
|||||||
@@ -119,11 +119,9 @@ pub async fn fill_pool_from_reflection(
|
|||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
max_message_size: usize,
|
|
||||||
) -> Result<DescriptorPool> {
|
) -> Result<DescriptorPool> {
|
||||||
let mut pool = DescriptorPool::new();
|
let mut pool = DescriptorPool::new();
|
||||||
let mut client =
|
let mut client = AutoReflectionClient::new(uri, validate_certificates, client_cert)?;
|
||||||
AutoReflectionClient::new(uri, validate_certificates, client_cert, max_message_size)?;
|
|
||||||
|
|
||||||
for service in list_services(&mut client, metadata).await? {
|
for service in list_services(&mut client, metadata).await? {
|
||||||
if service == "grpc.reflection.v1alpha.ServerReflection" {
|
if service == "grpc.reflection.v1alpha.ServerReflection" {
|
||||||
@@ -194,7 +192,6 @@ pub(crate) async fn reflect_types_for_message(
|
|||||||
json: &str,
|
json: &str,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
max_message_size: usize,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
// 1. Collect all Any types in the JSON
|
// 1. Collect all Any types in the JSON
|
||||||
let mut extra_types = Vec::new();
|
let mut extra_types = Vec::new();
|
||||||
@@ -204,7 +201,7 @@ pub(crate) async fn reflect_types_for_message(
|
|||||||
return Ok(()); // nothing to do
|
return Ok(()); // nothing to do
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
|
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
|
||||||
for extra_type in extra_types {
|
for extra_type in extra_types {
|
||||||
{
|
{
|
||||||
let guard = pool.read().await;
|
let guard = pool.read().await;
|
||||||
@@ -242,7 +239,6 @@ pub(crate) async fn reflect_types_for_dynamic_message(
|
|||||||
message: &DynamicMessage,
|
message: &DynamicMessage,
|
||||||
metadata: &BTreeMap<String, String>,
|
metadata: &BTreeMap<String, String>,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
max_message_size: usize,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut extra_types = HashSet::new();
|
let mut extra_types = HashSet::new();
|
||||||
collect_any_types_from_dynamic_message(message, &mut extra_types);
|
collect_any_types_from_dynamic_message(message, &mut extra_types);
|
||||||
@@ -251,7 +247,7 @@ pub(crate) async fn reflect_types_for_dynamic_message(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut client = AutoReflectionClient::new(uri, false, client_cert, max_message_size)?;
|
let mut client = AutoReflectionClient::new(uri, false, client_cert)?;
|
||||||
for extra_type in extra_types {
|
for extra_type in extra_types {
|
||||||
{
|
{
|
||||||
let guard = pool.read().await;
|
let guard = pool.read().await;
|
||||||
|
|||||||
@@ -34,10 +34,7 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String {
|
|||||||
return url.to_string();
|
return url.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`.
|
let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap();
|
||||||
// The `:` boundary is what lets `/:id:increment-importance` substitute the `:id`
|
|
||||||
// placeholder while leaving `:increment-importance` as literal text.
|
|
||||||
let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap();
|
|
||||||
let result = re
|
let result = re
|
||||||
.replace_all(url, |cap: ®ex::Captures| {
|
.replace_all(url, |cap: ®ex::Captures| {
|
||||||
format!(
|
format!(
|
||||||
@@ -86,18 +83,6 @@ mod placeholder_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn placeholder_followed_by_literal_colon() {
|
|
||||||
// AIP-136-style custom method: `:id` is the placeholder, `:increment-importance`
|
|
||||||
// is literal text in the same path segment.
|
|
||||||
let p =
|
|
||||||
HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None };
|
|
||||||
assert_eq!(
|
|
||||||
replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"),
|
|
||||||
"https://example.com/tasks/42:increment-importance",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn placeholder_missing() {
|
fn placeholder_missing() {
|
||||||
let p = HttpUrlParameter {
|
let p = HttpUrlParameter {
|
||||||
|
|||||||
+1
-6
@@ -109,7 +109,6 @@ export type Folder = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = {
|
||||||
@@ -185,7 +184,6 @@ export type GrpcRequest = {
|
|||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequest = {
|
export type HttpRequest = {
|
||||||
@@ -458,8 +456,7 @@ export type WebsocketEvent = {
|
|||||||
messageType: WebsocketEventType;
|
messageType: WebsocketEventType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WebsocketEventType =
|
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
|
||||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
|
||||||
|
|
||||||
export type WebsocketMessageType = "text" | "binary";
|
export type WebsocketMessageType = "text" | "binary";
|
||||||
|
|
||||||
@@ -485,7 +482,6 @@ export type WebsocketRequest = {
|
|||||||
settingSendCookies: InheritedBoolSetting;
|
settingSendCookies: InheritedBoolSetting;
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Workspace = {
|
export type Workspace = {
|
||||||
@@ -502,7 +498,6 @@ export type Workspace = {
|
|||||||
settingValidateCertificates: boolean;
|
settingValidateCertificates: boolean;
|
||||||
settingFollowRedirects: boolean;
|
settingFollowRedirects: boolean;
|
||||||
settingRequestTimeout: number;
|
settingRequestTimeout: number;
|
||||||
settingRequestMessageSize: number;
|
|
||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ function trackModelWrite<T>(write: Promise<T>): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function flushAllModelWrites(): Promise<void> {
|
export async function flushAllModelWrites(): Promise<void> {
|
||||||
const results = await Promise.allSettled(pendingModelWrites);
|
const results = await Promise.allSettled([...pendingModelWrites]);
|
||||||
const rejected = results.find((result) => result.status === "rejected");
|
const rejected = results.find((result) => result.status === "rejected");
|
||||||
if (rejected?.status === "rejected") {
|
if (rejected?.status === "rejected") {
|
||||||
throw rejected.reason;
|
throw rejected.reason;
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
ALTER TABLE workspaces ADD COLUMN setting_request_message_size INTEGER DEFAULT 67108864 NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE folders ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE websocket_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
|
||||||
|
|
||||||
ALTER TABLE grpc_requests ADD COLUMN setting_request_message_size TEXT DEFAULT '{"enabled":false,"value":67108864}' NOT NULL;
|
|
||||||
@@ -21,8 +21,6 @@ use ts_rs::TS;
|
|||||||
use yaak_database::{Result as DbResult, UpdateSource};
|
use yaak_database::{Result as DbResult, UpdateSource};
|
||||||
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
pub use yaak_database::{UpsertModelInfo, upsert_date};
|
||||||
|
|
||||||
pub const DEFAULT_REQUEST_MESSAGE_SIZE: i32 = 64 * 1024 * 1024;
|
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
macro_rules! impl_model {
|
macro_rules! impl_model {
|
||||||
($t:ty, $variant:ident) => {
|
($t:ty, $variant:ident) => {
|
||||||
@@ -122,7 +120,6 @@ pub struct ResolvedHttpRequestSettings {
|
|||||||
pub validate_certificates: ResolvedSetting<bool>,
|
pub validate_certificates: ResolvedSetting<bool>,
|
||||||
pub follow_redirects: ResolvedSetting<bool>,
|
pub follow_redirects: ResolvedSetting<bool>,
|
||||||
pub request_timeout: ResolvedSetting<i32>,
|
pub request_timeout: ResolvedSetting<i32>,
|
||||||
pub request_message_size: ResolvedSetting<i32>,
|
|
||||||
pub send_cookies: ResolvedSetting<bool>,
|
pub send_cookies: ResolvedSetting<bool>,
|
||||||
pub store_cookies: ResolvedSetting<bool>,
|
pub store_cookies: ResolvedSetting<bool>,
|
||||||
}
|
}
|
||||||
@@ -133,7 +130,6 @@ impl Default for ResolvedHttpRequestSettings {
|
|||||||
validate_certificates: ResolvedSetting::default_source(true),
|
validate_certificates: ResolvedSetting::default_source(true),
|
||||||
follow_redirects: ResolvedSetting::default_source(true),
|
follow_redirects: ResolvedSetting::default_source(true),
|
||||||
request_timeout: ResolvedSetting::default_source(0),
|
request_timeout: ResolvedSetting::default_source(0),
|
||||||
request_message_size: ResolvedSetting::default_source(DEFAULT_REQUEST_MESSAGE_SIZE),
|
|
||||||
send_cookies: ResolvedSetting::default_source(true),
|
send_cookies: ResolvedSetting::default_source(true),
|
||||||
store_cookies: ResolvedSetting::default_source(true),
|
store_cookies: ResolvedSetting::default_source(true),
|
||||||
}
|
}
|
||||||
@@ -404,8 +400,6 @@ pub struct Workspace {
|
|||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub setting_follow_redirects: bool,
|
pub setting_follow_redirects: bool,
|
||||||
pub setting_request_timeout: i32,
|
pub setting_request_timeout: i32,
|
||||||
#[serde(default = "default_request_message_size")]
|
|
||||||
pub setting_request_message_size: i32,
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub setting_dns_overrides: Vec<DnsOverride>,
|
pub setting_dns_overrides: Vec<DnsOverride>,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
@@ -451,7 +445,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
|
(EncryptionKeyChallenge, self.encryption_key_challenge.into()),
|
||||||
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
(SettingFollowRedirects, self.setting_follow_redirects.into()),
|
||||||
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
(SettingRequestTimeout, self.setting_request_timeout.into()),
|
||||||
(SettingRequestMessageSize, self.setting_request_message_size.into()),
|
|
||||||
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
(SettingValidateCertificates, self.setting_validate_certificates.into()),
|
||||||
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
(SettingDnsOverrides, serde_json::to_string(&self.setting_dns_overrides)?.into()),
|
||||||
(SettingSendCookies, self.setting_send_cookies.into()),
|
(SettingSendCookies, self.setting_send_cookies.into()),
|
||||||
@@ -470,7 +463,7 @@ impl UpsertModelInfo for Workspace {
|
|||||||
WorkspaceIden::EncryptionKeyChallenge,
|
WorkspaceIden::EncryptionKeyChallenge,
|
||||||
WorkspaceIden::SettingRequestTimeout,
|
WorkspaceIden::SettingRequestTimeout,
|
||||||
WorkspaceIden::SettingFollowRedirects,
|
WorkspaceIden::SettingFollowRedirects,
|
||||||
WorkspaceIden::SettingRequestMessageSize,
|
WorkspaceIden::SettingRequestTimeout,
|
||||||
WorkspaceIden::SettingValidateCertificates,
|
WorkspaceIden::SettingValidateCertificates,
|
||||||
WorkspaceIden::SettingDnsOverrides,
|
WorkspaceIden::SettingDnsOverrides,
|
||||||
WorkspaceIden::SettingSendCookies,
|
WorkspaceIden::SettingSendCookies,
|
||||||
@@ -498,7 +491,6 @@ impl UpsertModelInfo for Workspace {
|
|||||||
authentication_type: row.get("authentication_type")?,
|
authentication_type: row.get("authentication_type")?,
|
||||||
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
setting_follow_redirects: row.get("setting_follow_redirects")?,
|
||||||
setting_request_timeout: row.get("setting_request_timeout")?,
|
setting_request_timeout: row.get("setting_request_timeout")?,
|
||||||
setting_request_message_size: row.get("setting_request_message_size")?,
|
|
||||||
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
setting_validate_certificates: row.get("setting_validate_certificates")?,
|
||||||
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
setting_dns_overrides: serde_json::from_str(&setting_dns_overrides).unwrap_or_default(),
|
||||||
setting_send_cookies: row.get("setting_send_cookies")?,
|
setting_send_cookies: row.get("setting_send_cookies")?,
|
||||||
@@ -970,8 +962,6 @@ pub struct Folder {
|
|||||||
pub setting_validate_certificates: InheritedBoolSetting,
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
pub setting_follow_redirects: InheritedBoolSetting,
|
pub setting_follow_redirects: InheritedBoolSetting,
|
||||||
pub setting_request_timeout: InheritedIntSetting,
|
pub setting_request_timeout: InheritedIntSetting,
|
||||||
#[serde(default = "default_request_message_size_setting")]
|
|
||||||
pub setting_request_message_size: InheritedIntSetting,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for Folder {
|
impl UpsertModelInfo for Folder {
|
||||||
@@ -1019,10 +1009,6 @@ impl UpsertModelInfo for Folder {
|
|||||||
),
|
),
|
||||||
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
|
(SettingFollowRedirects, serde_json::to_string(&self.setting_follow_redirects)?.into()),
|
||||||
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
|
(SettingRequestTimeout, serde_json::to_string(&self.setting_request_timeout)?.into()),
|
||||||
(
|
|
||||||
SettingRequestMessageSize,
|
|
||||||
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1041,7 +1027,6 @@ impl UpsertModelInfo for Folder {
|
|||||||
FolderIden::SettingValidateCertificates,
|
FolderIden::SettingValidateCertificates,
|
||||||
FolderIden::SettingFollowRedirects,
|
FolderIden::SettingFollowRedirects,
|
||||||
FolderIden::SettingRequestTimeout,
|
FolderIden::SettingRequestTimeout,
|
||||||
FolderIden::SettingRequestMessageSize,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,7 +1041,6 @@ impl UpsertModelInfo for Folder {
|
|||||||
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
let setting_follow_redirects: String = row.get("setting_follow_redirects")?;
|
||||||
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
let setting_request_timeout: String = row.get("setting_request_timeout")?;
|
||||||
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1078,8 +1062,6 @@ impl UpsertModelInfo for Folder {
|
|||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
setting_request_timeout: serde_json::from_str(&setting_request_timeout)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
|
||||||
.unwrap_or_else(|_| default_request_message_size_setting()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1416,8 +1398,6 @@ pub struct WebsocketRequest {
|
|||||||
pub setting_send_cookies: InheritedBoolSetting,
|
pub setting_send_cookies: InheritedBoolSetting,
|
||||||
pub setting_store_cookies: InheritedBoolSetting,
|
pub setting_store_cookies: InheritedBoolSetting,
|
||||||
pub setting_validate_certificates: InheritedBoolSetting,
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
#[serde(default = "default_request_message_size_setting")]
|
|
||||||
pub setting_request_message_size: InheritedIntSetting,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for WebsocketRequest {
|
impl UpsertModelInfo for WebsocketRequest {
|
||||||
@@ -1466,10 +1446,6 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
SettingValidateCertificates,
|
SettingValidateCertificates,
|
||||||
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
||||||
),
|
),
|
||||||
(
|
|
||||||
SettingRequestMessageSize,
|
|
||||||
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1490,7 +1466,6 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
WebsocketRequestIden::SettingSendCookies,
|
WebsocketRequestIden::SettingSendCookies,
|
||||||
WebsocketRequestIden::SettingStoreCookies,
|
WebsocketRequestIden::SettingStoreCookies,
|
||||||
WebsocketRequestIden::SettingValidateCertificates,
|
WebsocketRequestIden::SettingValidateCertificates,
|
||||||
WebsocketRequestIden::SettingRequestMessageSize,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1504,7 +1479,6 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
let setting_send_cookies: String = row.get("setting_send_cookies")?;
|
let setting_send_cookies: String = row.get("setting_send_cookies")?;
|
||||||
let setting_store_cookies: String = row.get("setting_store_cookies")?;
|
let setting_store_cookies: String = row.get("setting_store_cookies")?;
|
||||||
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -1525,8 +1499,6 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
|
setting_store_cookies: serde_json::from_str(&setting_store_cookies).unwrap_or_default(),
|
||||||
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
|
||||||
.unwrap_or_else(|_| default_request_message_size_setting()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1537,7 +1509,6 @@ impl UpsertModelInfo for WebsocketRequest {
|
|||||||
pub enum WebsocketEventType {
|
pub enum WebsocketEventType {
|
||||||
Binary,
|
Binary,
|
||||||
Close,
|
Close,
|
||||||
Error,
|
|
||||||
Frame,
|
Frame,
|
||||||
Open,
|
Open,
|
||||||
Ping,
|
Ping,
|
||||||
@@ -2068,8 +2039,6 @@ pub struct GrpcRequest {
|
|||||||
/// Server URL (http for plaintext or https for secure)
|
/// Server URL (http for plaintext or https for secure)
|
||||||
pub url: String,
|
pub url: String,
|
||||||
pub setting_validate_certificates: InheritedBoolSetting,
|
pub setting_validate_certificates: InheritedBoolSetting,
|
||||||
#[serde(default = "default_request_message_size_setting")]
|
|
||||||
pub setting_request_message_size: InheritedIntSetting,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UpsertModelInfo for GrpcRequest {
|
impl UpsertModelInfo for GrpcRequest {
|
||||||
@@ -2117,10 +2086,6 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
SettingValidateCertificates,
|
SettingValidateCertificates,
|
||||||
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
serde_json::to_string(&self.setting_validate_certificates)?.into(),
|
||||||
),
|
),
|
||||||
(
|
|
||||||
SettingRequestMessageSize,
|
|
||||||
serde_json::to_string(&self.setting_request_message_size)?.into(),
|
|
||||||
),
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2140,7 +2105,6 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
GrpcRequestIden::Authentication,
|
GrpcRequestIden::Authentication,
|
||||||
GrpcRequestIden::Metadata,
|
GrpcRequestIden::Metadata,
|
||||||
GrpcRequestIden::SettingValidateCertificates,
|
GrpcRequestIden::SettingValidateCertificates,
|
||||||
GrpcRequestIden::SettingRequestMessageSize,
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2151,7 +2115,6 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
let authentication: String = row.get("authentication")?;
|
let authentication: String = row.get("authentication")?;
|
||||||
let metadata: String = row.get("metadata")?;
|
let metadata: String = row.get("metadata")?;
|
||||||
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
let setting_validate_certificates: String = row.get("setting_validate_certificates")?;
|
||||||
let setting_request_message_size: String = row.get("setting_request_message_size")?;
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id: row.get("id")?,
|
id: row.get("id")?,
|
||||||
model: row.get("model")?,
|
model: row.get("model")?,
|
||||||
@@ -2171,8 +2134,6 @@ impl UpsertModelInfo for GrpcRequest {
|
|||||||
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
|
metadata: serde_json::from_str(metadata.as_str()).unwrap_or_default(),
|
||||||
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
setting_validate_certificates: serde_json::from_str(&setting_validate_certificates)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
setting_request_message_size: serde_json::from_str(&setting_request_message_size)
|
|
||||||
.unwrap_or_else(|_| default_request_message_size_setting()),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2723,14 +2684,6 @@ fn default_true() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_request_message_size() -> i32 {
|
|
||||||
DEFAULT_REQUEST_MESSAGE_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_request_message_size_setting() -> InheritedIntSetting {
|
|
||||||
InheritedIntSetting { enabled: false, value: DEFAULT_REQUEST_MESSAGE_SIZE }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_http_method() -> String {
|
fn default_http_method() -> String {
|
||||||
"GET".to_string()
|
"GET".to_string()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,14 +180,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
} else {
|
} else {
|
||||||
parent.request_timeout
|
parent.request_timeout
|
||||||
},
|
},
|
||||||
request_message_size: if folder.setting_request_message_size.enabled {
|
|
||||||
ResolvedSetting::from_model(
|
|
||||||
folder.setting_request_message_size.value,
|
|
||||||
AnyModel::Folder(folder.clone()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
parent.request_message_size
|
|
||||||
},
|
|
||||||
send_cookies: if folder.setting_send_cookies.enabled {
|
send_cookies: if folder.setting_send_cookies.enabled {
|
||||||
ResolvedSetting::from_model(
|
ResolvedSetting::from_model(
|
||||||
folder.setting_send_cookies.value,
|
folder.setting_send_cookies.value,
|
||||||
|
|||||||
@@ -129,14 +129,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
} else {
|
} else {
|
||||||
parent.validate_certificates
|
parent.validate_certificates
|
||||||
},
|
},
|
||||||
request_message_size: if grpc_request.setting_request_message_size.enabled {
|
|
||||||
ResolvedSetting::from_model(
|
|
||||||
grpc_request.setting_request_message_size.value,
|
|
||||||
AnyModel::GrpcRequest(grpc_request.clone()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
parent.request_message_size
|
|
||||||
},
|
|
||||||
..parent
|
..parent
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
} else {
|
} else {
|
||||||
parent.request_timeout
|
parent.request_timeout
|
||||||
},
|
},
|
||||||
request_message_size: parent.request_message_size,
|
|
||||||
send_cookies: if http_request.setting_send_cookies.enabled {
|
send_cookies: if http_request.setting_send_cookies.enabled {
|
||||||
ResolvedSetting::from_model(
|
ResolvedSetting::from_model(
|
||||||
http_request.setting_send_cookies.value,
|
http_request.setting_send_cookies.value,
|
||||||
|
|||||||
@@ -139,14 +139,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
} else {
|
} else {
|
||||||
parent.validate_certificates
|
parent.validate_certificates
|
||||||
},
|
},
|
||||||
request_message_size: if websocket_request.setting_request_message_size.enabled {
|
|
||||||
ResolvedSetting::from_model(
|
|
||||||
websocket_request.setting_request_message_size.value,
|
|
||||||
AnyModel::WebsocketRequest(websocket_request.clone()),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
parent.request_message_size
|
|
||||||
},
|
|
||||||
send_cookies: if websocket_request.setting_send_cookies.enabled {
|
send_cookies: if websocket_request.setting_send_cookies.enabled {
|
||||||
ResolvedSetting::from_model(
|
ResolvedSetting::from_model(
|
||||||
websocket_request.setting_send_cookies.value,
|
websocket_request.setting_send_cookies.value,
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
&Workspace {
|
&Workspace {
|
||||||
name: "Yaak".to_string(),
|
name: "Yaak".to_string(),
|
||||||
setting_follow_redirects: true,
|
setting_follow_redirects: true,
|
||||||
setting_request_message_size: crate::models::DEFAULT_REQUEST_MESSAGE_SIZE,
|
|
||||||
setting_validate_certificates: true,
|
setting_validate_certificates: true,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
@@ -103,10 +102,6 @@ impl<'a> ClientDb<'a> {
|
|||||||
workspace.setting_request_timeout,
|
workspace.setting_request_timeout,
|
||||||
AnyModel::Workspace(workspace.clone()),
|
AnyModel::Workspace(workspace.clone()),
|
||||||
),
|
),
|
||||||
request_message_size: ResolvedSetting::from_model(
|
|
||||||
workspace.setting_request_message_size,
|
|
||||||
AnyModel::Workspace(workspace.clone()),
|
|
||||||
),
|
|
||||||
send_cookies: ResolvedSetting::from_model(
|
send_cookies: ResolvedSetting::from_model(
|
||||||
workspace.setting_send_cookies,
|
workspace.setting_send_cookies,
|
||||||
AnyModel::Workspace(workspace.clone()),
|
AnyModel::Workspace(workspace.clone()),
|
||||||
|
|||||||
+1
-6
@@ -108,7 +108,6 @@ export type Folder = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = {
|
||||||
@@ -184,7 +183,6 @@ export type GrpcRequest = {
|
|||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequest = {
|
export type HttpRequest = {
|
||||||
@@ -428,8 +426,7 @@ export type WebsocketEvent = {
|
|||||||
messageType: WebsocketEventType;
|
messageType: WebsocketEventType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WebsocketEventType =
|
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
|
||||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
|
||||||
|
|
||||||
export type WebsocketRequest = {
|
export type WebsocketRequest = {
|
||||||
model: "websocket_request";
|
model: "websocket_request";
|
||||||
@@ -453,7 +450,6 @@ export type WebsocketRequest = {
|
|||||||
settingSendCookies: InheritedBoolSetting;
|
settingSendCookies: InheritedBoolSetting;
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Workspace = {
|
export type Workspace = {
|
||||||
@@ -470,7 +466,6 @@ export type Workspace = {
|
|||||||
settingValidateCertificates: boolean;
|
settingValidateCertificates: boolean;
|
||||||
settingFollowRedirects: boolean;
|
settingFollowRedirects: boolean;
|
||||||
settingRequestTimeout: number;
|
settingRequestTimeout: number;
|
||||||
settingRequestMessageSize: number;
|
|
||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
|||||||
@@ -1070,7 +1070,7 @@ impl PluginManager {
|
|||||||
&InternalEventPayload::ImportRequest(ImportRequest {
|
&InternalEventPayload::ImportRequest(ImportRequest {
|
||||||
content: content.to_string(),
|
content: content.to_string(),
|
||||||
}),
|
}),
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(60),
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1 @@
|
|||||||
export * from "./bindings/sse";
|
export * from "./bindings/sse";
|
||||||
export * from "./summary";
|
|
||||||
|
|||||||
@@ -2,11 +2,5 @@
|
|||||||
"name": "@yaakapp-internal/sse",
|
"name": "@yaakapp-internal/sse",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"main": "index.ts"
|
||||||
"jsonpath-plus": "^10.3.0"
|
|
||||||
},
|
|
||||||
"main": "index.ts",
|
|
||||||
"scripts": {
|
|
||||||
"test": "vitest run"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import { computeSseSummary, extractSseValueAtPath } from "./summary";
|
|
||||||
|
|
||||||
describe("extractSseValueAtPath", () => {
|
|
||||||
it("supports simple paths", () => {
|
|
||||||
expect(
|
|
||||||
extractSseValueAtPath(
|
|
||||||
JSON.stringify({ choices: [{ delta: { content: "hello" } }] }),
|
|
||||||
"$.choices[0].delta.content",
|
|
||||||
),
|
|
||||||
).toBe("hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("supports full JSONPath expressions", () => {
|
|
||||||
expect(
|
|
||||||
extractSseValueAtPath(
|
|
||||||
JSON.stringify({
|
|
||||||
choices: [
|
|
||||||
{ delta: { role: "assistant" } },
|
|
||||||
{ delta: { content: "hello" } },
|
|
||||||
{ delta: { content: " world" } },
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
"$.choices[*].delta.content",
|
|
||||||
),
|
|
||||||
).toBe("hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns null when a JSONPath expression has no matches", () => {
|
|
||||||
expect(extractSseValueAtPath(JSON.stringify({ delta: {} }), "$.delta.text")).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("computeSseSummary", () => {
|
|
||||||
it("concatenates JSONPath matches across SSE messages", () => {
|
|
||||||
expect(
|
|
||||||
computeSseSummary(
|
|
||||||
[
|
|
||||||
`data: ${JSON.stringify({ choices: [{ delta: { content: "hello" } }] })}`,
|
|
||||||
"",
|
|
||||||
`data: ${JSON.stringify({ choices: [{ delta: { content: " world" } }] })}`,
|
|
||||||
"",
|
|
||||||
].join("\n"),
|
|
||||||
"$.choices[*].delta.content",
|
|
||||||
),
|
|
||||||
).toEqual({
|
|
||||||
fragmentCount: 2,
|
|
||||||
summary: "hello world",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
import { JSONPath } from "jsonpath-plus";
|
|
||||||
|
|
||||||
export interface SseSummary {
|
|
||||||
fragmentCount: number;
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type JSONPathJson = null | boolean | number | string | object | unknown[];
|
|
||||||
|
|
||||||
const STANDARD_SSE_FIELD = /^(event|id|retry):/i;
|
|
||||||
|
|
||||||
export function candidateJsonPayloadsFromSseText(text: string): string[] {
|
|
||||||
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
||||||
const blocks = normalized.split(/\n{2,}/);
|
|
||||||
const candidates: string[] = [];
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
const lines = block.split("\n");
|
|
||||||
const dataLines = lines
|
|
||||||
.map((line) => {
|
|
||||||
const match = /^data:(?: ?)(.*)$/.exec(line);
|
|
||||||
return match?.[1];
|
|
||||||
})
|
|
||||||
.filter((line): line is string => line != null);
|
|
||||||
|
|
||||||
if (dataLines.length > 0) {
|
|
||||||
const payload = dataLines.join("\n").trim();
|
|
||||||
if (payload) {
|
|
||||||
candidates.push(payload);
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedBlock = block.trim();
|
|
||||||
if (!trimmedBlock) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isParsableJson(trimmedBlock)) {
|
|
||||||
candidates.push(trimmedBlock);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmedLine = line.trim();
|
|
||||||
if (
|
|
||||||
!trimmedLine ||
|
|
||||||
trimmedLine.startsWith(":") ||
|
|
||||||
STANDARD_SSE_FIELD.test(trimmedLine) ||
|
|
||||||
!isParsableJson(trimmedLine)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
candidates.push(trimmedLine);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function computeSseSummary(text: string, keyPath: string): SseSummary {
|
|
||||||
const fragments: string[] = [];
|
|
||||||
|
|
||||||
for (const payload of candidateJsonPayloadsFromSseText(text)) {
|
|
||||||
const fragment = extractSseValueAtPath(payload, keyPath);
|
|
||||||
if (fragment != null) {
|
|
||||||
fragments.push(fragment);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
fragmentCount: fragments.length,
|
|
||||||
summary: fragments.join(""),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function extractSseValueAtPath(payload: string, keyPath: string): string | null {
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(payload);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = keyPath.trim();
|
|
||||||
if (!path) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: unknown;
|
|
||||||
try {
|
|
||||||
result = JSONPath({ path, json: parsed as JSONPathJson });
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(result)) {
|
|
||||||
const fragments = result
|
|
||||||
.map((item) => stringifySummaryValue(item))
|
|
||||||
.filter((item): item is string => item != null);
|
|
||||||
return fragments.length > 0 ? fragments.join("") : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return stringifySummaryValue(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringifySummaryValue(value: unknown): string | null {
|
|
||||||
if (value == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (typeof value === "string") {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isParsableJson(value: string): boolean {
|
|
||||||
try {
|
|
||||||
JSON.parse(value);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-4
@@ -46,7 +46,6 @@ export type Folder = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GrpcRequest = {
|
export type GrpcRequest = {
|
||||||
@@ -70,7 +69,6 @@ export type GrpcRequest = {
|
|||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequest = {
|
export type HttpRequest = {
|
||||||
@@ -161,7 +159,6 @@ export type WebsocketRequest = {
|
|||||||
settingSendCookies: InheritedBoolSetting;
|
settingSendCookies: InheritedBoolSetting;
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Workspace = {
|
export type Workspace = {
|
||||||
@@ -178,7 +175,6 @@ export type Workspace = {
|
|||||||
settingValidateCertificates: boolean;
|
settingValidateCertificates: boolean;
|
||||||
settingFollowRedirects: boolean;
|
settingFollowRedirects: boolean;
|
||||||
settingRequestTimeout: number;
|
settingRequestTimeout: number;
|
||||||
settingRequestMessageSize: number;
|
|
||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ pub async fn ws_connect(
|
|||||||
headers: HeaderMap<HeaderValue>,
|
headers: HeaderMap<HeaderValue>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
request_message_size: i32,
|
|
||||||
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
|
) -> Result<(WebSocketStream<MaybeTlsStream<TcpStream>>, Response)> {
|
||||||
info!("Connecting to WS {url}");
|
info!("Connecting to WS {url}");
|
||||||
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
|
let tls_config = get_tls_config(validate_certificates, WITH_ALPN, client_cert.clone())?;
|
||||||
@@ -35,7 +34,7 @@ pub async fn ws_connect(
|
|||||||
|
|
||||||
let (stream, response) = connect_async_tls_with_config(
|
let (stream, response) = connect_async_tls_with_config(
|
||||||
req,
|
req,
|
||||||
Some(websocket_config(request_message_size)),
|
Some(WebSocketConfig::default()),
|
||||||
false,
|
false,
|
||||||
Some(Connector::Rustls(Arc::new(tls_config))),
|
Some(Connector::Rustls(Arc::new(tls_config))),
|
||||||
)
|
)
|
||||||
@@ -49,12 +48,3 @@ pub async fn ws_connect(
|
|||||||
|
|
||||||
Ok((stream, response))
|
Ok((stream, response))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn websocket_config(request_message_size: i32) -> WebSocketConfig {
|
|
||||||
let max_message_size = message_size_limit(request_message_size);
|
|
||||||
WebSocketConfig::default().max_message_size(max_message_size).max_frame_size(max_message_size)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn message_size_limit(setting: i32) -> Option<usize> {
|
|
||||||
setting.try_into().ok().filter(|limit| *limit > 0)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use tokio_tungstenite::tungstenite;
|
|||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error("{0}")]
|
#[error("WebSocket error: {0}")]
|
||||||
WebSocketErr(#[from] tungstenite::Error),
|
WebSocketErr(#[from] tungstenite::Error),
|
||||||
|
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -16,7 +16,7 @@ pub enum Error {
|
|||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
TlsError(#[from] yaak_tls::error::Error),
|
TlsError(#[from] yaak_tls::error::Error),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("WebSocket error: {0}")]
|
||||||
GenericError(String),
|
GenericError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use crate::connect::{message_size_limit, ws_connect};
|
use crate::connect::ws_connect;
|
||||||
use crate::error::Error::GenericError;
|
|
||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use futures_util::stream::SplitSink;
|
use futures_util::stream::SplitSink;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
@@ -16,16 +15,10 @@ use tokio_tungstenite::tungstenite::http::HeaderValue;
|
|||||||
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
|
||||||
use yaak_tls::ClientCertificateConfig;
|
use yaak_tls::ClientCertificateConfig;
|
||||||
|
|
||||||
type WebsocketSink = SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>;
|
|
||||||
|
|
||||||
struct WebsocketConnection {
|
|
||||||
max_message_size: Option<usize>,
|
|
||||||
sink: WebsocketSink,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebsocketManager {
|
pub struct WebsocketManager {
|
||||||
connections: Arc<Mutex<HashMap<String, WebsocketConnection>>>,
|
connections:
|
||||||
|
Arc<Mutex<HashMap<String, SplitSink<WebSocketStream<MaybeTlsStream<TcpStream>>, Message>>>>,
|
||||||
read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,
|
read_tasks: Arc<Mutex<HashMap<String, tokio::task::JoinHandle<()>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,20 +35,14 @@ impl WebsocketManager {
|
|||||||
receive_tx: mpsc::Sender<Message>,
|
receive_tx: mpsc::Sender<Message>,
|
||||||
validate_certificates: bool,
|
validate_certificates: bool,
|
||||||
client_cert: Option<ClientCertificateConfig>,
|
client_cert: Option<ClientCertificateConfig>,
|
||||||
request_message_size: i32,
|
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let tx = receive_tx.clone();
|
let tx = receive_tx.clone();
|
||||||
let max_message_size = message_size_limit(request_message_size);
|
|
||||||
|
|
||||||
let (stream, response) =
|
let (stream, response) =
|
||||||
ws_connect(url, headers, validate_certificates, client_cert, request_message_size)
|
ws_connect(url, headers, validate_certificates, client_cert).await?;
|
||||||
.await?;
|
|
||||||
let (write, mut read) = stream.split();
|
let (write, mut read) = stream.split();
|
||||||
|
|
||||||
self.connections
|
self.connections.lock().await.insert(id.to_string(), write);
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.insert(id.to_string(), WebsocketConnection { max_message_size, sink: write });
|
|
||||||
|
|
||||||
let handle = {
|
let handle = {
|
||||||
let connection_id = id.to_string();
|
let connection_id = id.to_string();
|
||||||
@@ -83,20 +70,13 @@ impl WebsocketManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> {
|
pub async fn send(&mut self, id: &str, msg: Message) -> Result<()> {
|
||||||
|
debug!("Send websocket message {msg:?}");
|
||||||
let mut connections = self.connections.lock().await;
|
let mut connections = self.connections.lock().await;
|
||||||
let connection = match connections.get_mut(id) {
|
let connection = match connections.get_mut(id) {
|
||||||
None => return Ok(()),
|
None => return Ok(()),
|
||||||
Some(c) => c,
|
Some(c) => c,
|
||||||
};
|
};
|
||||||
if let Some(limit) = connection.max_message_size {
|
connection.send(msg).await?;
|
||||||
let message_size = msg.len();
|
|
||||||
if message_size > limit {
|
|
||||||
return Err(GenericError(format!(
|
|
||||||
"WebSocket message too large: found {message_size} bytes, the limit is {limit} bytes"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
connection.sink.send(msg).await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +84,7 @@ impl WebsocketManager {
|
|||||||
info!("Closing websocket");
|
info!("Closing websocket");
|
||||||
if let Some(mut connection) = self.connections.lock().await.remove(id) {
|
if let Some(mut connection) = self.connections.lock().await.remove(id) {
|
||||||
// Wait a maximum of 1 second for the connection to close
|
// Wait a maximum of 1 second for the connection to close
|
||||||
if let Err(e) = connection.sink.close().await {
|
if let Err(e) = connection.close().await {
|
||||||
warn!("Failed to close websocket connection {e:?}");
|
warn!("Failed to close websocket connection {e:?}");
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+838
-1323
File diff suppressed because it is too large
Load Diff
+5
-4
@@ -121,13 +121,14 @@
|
|||||||
"nodejs-file-downloader": "^4.13.0",
|
"nodejs-file-downloader": "^4.13.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1",
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
|
||||||
"vite-plus": "^0.2.1",
|
"vite-plus": "^0.1.20",
|
||||||
"vitest": "^4.1.9"
|
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"js-yaml": "^4.1.1",
|
"js-yaml": "^4.1.1",
|
||||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.1"
|
"vite": "npm:@voidzero-dev/vite-plus-core@^0.1.20",
|
||||||
|
"vitest": "npm:@voidzero-dev/vite-plus-test@^0.1.20"
|
||||||
},
|
},
|
||||||
"packageManager": "npm@11.11.1"
|
"packageManager": "npm@11.11.1"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-6
@@ -108,7 +108,6 @@ export type Folder = {
|
|||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingFollowRedirects: InheritedBoolSetting;
|
settingFollowRedirects: InheritedBoolSetting;
|
||||||
settingRequestTimeout: InheritedIntSetting;
|
settingRequestTimeout: InheritedIntSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GraphQlIntrospection = {
|
export type GraphQlIntrospection = {
|
||||||
@@ -184,7 +183,6 @@ export type GrpcRequest = {
|
|||||||
*/
|
*/
|
||||||
url: string;
|
url: string;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HttpRequest = {
|
export type HttpRequest = {
|
||||||
@@ -428,8 +426,7 @@ export type WebsocketEvent = {
|
|||||||
messageType: WebsocketEventType;
|
messageType: WebsocketEventType;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WebsocketEventType =
|
export type WebsocketEventType = "binary" | "close" | "frame" | "open" | "ping" | "pong" | "text";
|
||||||
"binary" | "close" | "error" | "frame" | "open" | "ping" | "pong" | "text";
|
|
||||||
|
|
||||||
export type WebsocketRequest = {
|
export type WebsocketRequest = {
|
||||||
model: "websocket_request";
|
model: "websocket_request";
|
||||||
@@ -453,7 +450,6 @@ export type WebsocketRequest = {
|
|||||||
settingSendCookies: InheritedBoolSetting;
|
settingSendCookies: InheritedBoolSetting;
|
||||||
settingStoreCookies: InheritedBoolSetting;
|
settingStoreCookies: InheritedBoolSetting;
|
||||||
settingValidateCertificates: InheritedBoolSetting;
|
settingValidateCertificates: InheritedBoolSetting;
|
||||||
settingRequestMessageSize: InheritedIntSetting;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Workspace = {
|
export type Workspace = {
|
||||||
@@ -470,7 +466,6 @@ export type Workspace = {
|
|||||||
settingValidateCertificates: boolean;
|
settingValidateCertificates: boolean;
|
||||||
settingFollowRedirects: boolean;
|
settingFollowRedirects: boolean;
|
||||||
settingRequestTimeout: number;
|
settingRequestTimeout: number;
|
||||||
settingRequestMessageSize: number;
|
|
||||||
settingDnsOverrides: Array<DnsOverride>;
|
settingDnsOverrides: Array<DnsOverride>;
|
||||||
settingSendCookies: boolean;
|
settingSendCookies: boolean;
|
||||||
settingStoreCookies: boolean;
|
settingStoreCookies: boolean;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^24.0.13",
|
"@types/node": "^24.0.13",
|
||||||
|
|||||||
@@ -166,9 +166,6 @@ function toastColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
|||||||
|
|
||||||
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||||
return completeFullColorVariables(theme, {
|
return completeFullColorVariables(theme, {
|
||||||
text: color.desaturate(0.5).lift(0.12).css(),
|
|
||||||
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
|
||||||
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
|
|
||||||
surface: color.translucify(0.95).css(),
|
surface: color.translucify(0.95).css(),
|
||||||
surfaceHighlight: color.translucify(0.85).css(),
|
surfaceHighlight: color.translucify(0.85).css(),
|
||||||
border: color.lift(0.3).translucify(0.8).css(),
|
border: color.lift(0.3).translucify(0.8).css(),
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ interface Props {
|
|||||||
resizeHandleClassName?: string;
|
resizeHandleClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseProperties = { minHeight: 0, minWidth: 0 };
|
const baseProperties = { minWidth: 0 };
|
||||||
const areaL = { ...baseProperties, gridArea: "left" };
|
const areaL = { ...baseProperties, gridArea: "left" };
|
||||||
const areaR = { ...baseProperties, gridArea: "right" };
|
const areaR = { ...baseProperties, gridArea: "right" };
|
||||||
const areaD = { ...baseProperties, gridArea: "drag" };
|
const areaD = { ...baseProperties, gridArea: "drag" };
|
||||||
@@ -60,25 +60,23 @@ export function SplitLayout({
|
|||||||
const size = useContainerSize(containerRef);
|
const size = useContainerSize(containerRef);
|
||||||
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
|
const verticalBasedOnSize = size.width !== 0 && size.width < STACK_VERTICAL_WIDTH;
|
||||||
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize);
|
const vertical = layout !== "horizontal" && (layout === "vertical" || verticalBasedOnSize);
|
||||||
const renderedWidth = clampSplitRatio(width, minWidthPx, size.width);
|
|
||||||
const renderedHeight = secondSlot ? clampSplitRatio(height, minHeightPx, size.height) : 0;
|
|
||||||
|
|
||||||
const styles = useMemo<CSSProperties>(() => {
|
const styles = useMemo<CSSProperties>(() => {
|
||||||
return {
|
return {
|
||||||
...style,
|
...style,
|
||||||
gridTemplate: vertical
|
gridTemplate: vertical
|
||||||
? `
|
? `
|
||||||
' ${areaL.gridArea}' minmax(0,${1 - renderedHeight}fr)
|
' ${areaL.gridArea}' minmax(0,${1 - height}fr)
|
||||||
' ${areaD.gridArea}' 0
|
' ${areaD.gridArea}' 0
|
||||||
' ${areaR.gridArea}' minmax(0,${renderedHeight}fr)
|
' ${areaR.gridArea}' minmax(${minHeightPx}px,${height}fr)
|
||||||
/ 1fr
|
/ 1fr
|
||||||
`
|
`
|
||||||
: `
|
: `
|
||||||
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
|
' ${areaL.gridArea} ${areaD.gridArea} ${areaR.gridArea}' minmax(0,1fr)
|
||||||
/ ${1 - renderedWidth}fr 0 ${renderedWidth}fr
|
/ ${1 - width}fr 0 ${width}fr
|
||||||
`,
|
`,
|
||||||
};
|
};
|
||||||
}, [style, vertical, renderedHeight, renderedWidth]);
|
}, [style, vertical, height, minHeightPx, width]);
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
const handleReset = useCallback(() => {
|
||||||
if (vertical) setHeight(defaultRatio);
|
if (vertical) setHeight(defaultRatio);
|
||||||
@@ -98,36 +96,22 @@ export function SplitLayout({
|
|||||||
const containerHeight =
|
const containerHeight =
|
||||||
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
|
$c.clientHeight - Number.parseFloat(paddingTop) - Number.parseFloat(paddingBottom);
|
||||||
|
|
||||||
if ((vertical && containerHeight <= 0) || (!vertical && containerWidth <= 0)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mouseStartX = e.xStart;
|
const mouseStartX = e.xStart;
|
||||||
const mouseStartY = e.yStart;
|
const mouseStartY = e.yStart;
|
||||||
const startWidth = containerWidth * renderedWidth;
|
const startWidth = containerWidth * width;
|
||||||
const startHeight = containerHeight * renderedHeight;
|
const startHeight = containerHeight * height;
|
||||||
|
|
||||||
if (vertical) {
|
if (vertical) {
|
||||||
const minHeight = Math.min(minHeightPx, containerHeight);
|
const maxHeightPx = containerHeight - minHeightPx;
|
||||||
const maxHeightPx = Math.max(minHeight, containerHeight - minHeightPx);
|
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeightPx, maxHeightPx);
|
||||||
const newHeightPx = clamp(startHeight - (e.y - mouseStartY), minHeight, maxHeightPx);
|
|
||||||
setHeight(newHeightPx / containerHeight);
|
setHeight(newHeightPx / containerHeight);
|
||||||
} else {
|
} else {
|
||||||
const minWidth = Math.min(minWidthPx, containerWidth);
|
const maxWidthPx = containerWidth - minWidthPx;
|
||||||
const maxWidthPx = Math.max(minWidth, containerWidth - minWidthPx);
|
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidthPx, maxWidthPx);
|
||||||
const newWidthPx = clamp(startWidth - (e.x - mouseStartX), minWidth, maxWidthPx);
|
|
||||||
setWidth(newWidthPx / containerWidth);
|
setWidth(newWidthPx / containerWidth);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[width, height, vertical, minHeightPx, setHeight, minWidthPx, setWidth],
|
||||||
renderedWidth,
|
|
||||||
renderedHeight,
|
|
||||||
vertical,
|
|
||||||
minHeightPx,
|
|
||||||
setHeight,
|
|
||||||
minWidthPx,
|
|
||||||
setWidth,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -156,13 +140,3 @@ export function SplitLayout({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampSplitRatio(ratio: number, minPx: number, containerPx: number): number {
|
|
||||||
if (containerPx <= 0 || minPx <= 0) {
|
|
||||||
return ratio;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minRatio = Math.min(1, minPx / containerPx);
|
|
||||||
const maxRatio = minRatio >= 0.5 ? minRatio : 1 - minRatio;
|
|
||||||
return clamp(ratio, minRatio, maxRatio);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"@hono/mcp": "^0.2.3",
|
"@hono/mcp": "^0.2.3",
|
||||||
"@hono/node-server": "^1.19.13",
|
"@hono/node-server": "^1.19.13",
|
||||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||||
"hono": "^4.12.25",
|
"hono": "^4.12.14",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
"test": "vp test --run tests"
|
"test": "vp test --run tests"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"openapi-to-postmanv2": "^5.8.0",
|
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/openapi-to-postmanv2": "^5.0.0"
|
"@types/openapi-to-postmanv2": "^5.0.0",
|
||||||
|
"openapi-to-postmanv2": "^5.8.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,37 @@
|
|||||||
import { convertPostman } from "@yaak/importer-postman/src";
|
import type {
|
||||||
import type { Context, PluginDefinition } from "@yaakapp/api";
|
Context,
|
||||||
|
Environment,
|
||||||
|
Folder,
|
||||||
|
HttpRequest,
|
||||||
|
HttpRequestHeader,
|
||||||
|
HttpUrlParameter,
|
||||||
|
PartialImportResources,
|
||||||
|
PluginDefinition,
|
||||||
|
Workspace,
|
||||||
|
} from "@yaakapp/api";
|
||||||
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
||||||
import { convert } from "openapi-to-postmanv2";
|
import YAML from "yaml";
|
||||||
|
|
||||||
|
type AtLeast<T, K extends keyof T> = Partial<T> & Pick<T, K>;
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
type ImportResources = {
|
||||||
|
workspaces: AtLeast<Workspace, "name" | "id" | "model">[];
|
||||||
|
environments: AtLeast<Environment, "name" | "id" | "model" | "workspaceId" | "variables">[];
|
||||||
|
folders: AtLeast<Folder, "name" | "id" | "model" | "workspaceId">[];
|
||||||
|
httpRequests: AtLeast<HttpRequest, "name" | "id" | "model" | "workspaceId">[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const HTTP_METHODS = ["delete", "get", "head", "options", "patch", "post", "put", "trace"];
|
||||||
|
const BODY_CONTENT_TYPE_PREFERENCE = [
|
||||||
|
"application/json",
|
||||||
|
"application/x-www-form-urlencoded",
|
||||||
|
"multipart/form-data",
|
||||||
|
"application/xml",
|
||||||
|
"text/plain",
|
||||||
|
];
|
||||||
|
const MAX_EXAMPLE_DEPTH = 8;
|
||||||
|
const MAX_EXAMPLE_PROPERTIES = 25;
|
||||||
|
const MAX_DESCRIPTION_ITEMS = 40;
|
||||||
|
|
||||||
export const plugin: PluginDefinition = {
|
export const plugin: PluginDefinition = {
|
||||||
importer: {
|
importer: {
|
||||||
@@ -14,23 +44,785 @@ export const plugin: PluginDefinition = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {
|
export async function convertOpenApi(contents: string): Promise<ImportPluginResponse | undefined> {
|
||||||
// oxlint-disable-next-line no-explicit-any
|
const spec = parseSpec(contents);
|
||||||
let postmanCollection: any;
|
if (!isOpenApiSpec(spec)) return undefined;
|
||||||
|
|
||||||
|
const importState = new ImportState(spec);
|
||||||
|
const workspace: ImportResources["workspaces"][0] = {
|
||||||
|
model: "workspace",
|
||||||
|
id: importState.generateId("workspace"),
|
||||||
|
name: stringAt(spec.info, "title") ?? "OpenAPI Import",
|
||||||
|
description: importInfoDescription(toRecord(spec.info)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const resources: ImportResources = {
|
||||||
|
workspaces: [workspace],
|
||||||
|
environments: [],
|
||||||
|
folders: [],
|
||||||
|
httpRequests: [],
|
||||||
|
};
|
||||||
|
const baseUrl = importBaseUrl(spec);
|
||||||
|
const requestBaseUrl = baseUrl.length > 0 ? "${[baseUrl]}" : "";
|
||||||
|
|
||||||
|
if (baseUrl.length > 0) {
|
||||||
|
resources.environments.push({
|
||||||
|
model: "environment",
|
||||||
|
id: importState.generateId("environment"),
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name: "Global Variables",
|
||||||
|
variables: [{ name: "baseUrl", value: baseUrl }],
|
||||||
|
parentModel: "workspace",
|
||||||
|
parentId: null,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const folderIdsByTag = new Map<string, string>();
|
||||||
|
for (const tag of toArray(spec.tags)) {
|
||||||
|
const tagRecord = toRecord(tag);
|
||||||
|
const name = stringAt(tagRecord, "name");
|
||||||
|
if (name == null || folderIdsByTag.has(name)) continue;
|
||||||
|
|
||||||
|
const folder: ImportResources["folders"][0] = {
|
||||||
|
model: "folder",
|
||||||
|
id: importState.generateId("folder"),
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
name,
|
||||||
|
description: importTagDescription(tagRecord),
|
||||||
|
folderId: null,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
|
};
|
||||||
|
resources.folders.push(folder);
|
||||||
|
folderIdsByTag.set(name, folder.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [rawPath, rawPathItem] of Object.entries(toRecord(spec.paths))) {
|
||||||
|
const pathItem = importState.resolve(rawPathItem);
|
||||||
|
if (!isRecord(pathItem)) continue;
|
||||||
|
|
||||||
|
const pathParameters = toArray(pathItem.parameters);
|
||||||
|
for (const method of HTTP_METHODS) {
|
||||||
|
const operation = importState.resolve(pathItem[method]);
|
||||||
|
if (!isRecord(operation)) continue;
|
||||||
|
|
||||||
|
const folderId = findOrCreateFolderId({
|
||||||
|
folderIdsByTag,
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
resources,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
resources.httpRequests.push(
|
||||||
|
importOperation({
|
||||||
|
importState,
|
||||||
|
method,
|
||||||
|
operation,
|
||||||
|
path: rawPath,
|
||||||
|
pathParameters,
|
||||||
|
requestBaseUrl,
|
||||||
|
spec,
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
folderId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resources.httpRequests.length === 0) return undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
resources: deleteUndefinedAttrs(
|
||||||
|
convertTemplateSyntax({
|
||||||
|
environments: resources.environments,
|
||||||
|
folders: resources.folders,
|
||||||
|
grpcRequests: [],
|
||||||
|
httpRequests: resources.httpRequests,
|
||||||
|
websocketRequests: [],
|
||||||
|
workspaces: resources.workspaces,
|
||||||
|
}),
|
||||||
|
) as PartialImportResources,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOperation({
|
||||||
|
importState,
|
||||||
|
method,
|
||||||
|
operation,
|
||||||
|
path,
|
||||||
|
pathParameters,
|
||||||
|
requestBaseUrl,
|
||||||
|
spec,
|
||||||
|
workspaceId,
|
||||||
|
folderId,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
method: string;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
path: string;
|
||||||
|
pathParameters: unknown[];
|
||||||
|
requestBaseUrl: string;
|
||||||
|
spec: UnknownRecord;
|
||||||
|
workspaceId: string;
|
||||||
|
folderId: string | null;
|
||||||
|
}): ImportResources["httpRequests"][0] {
|
||||||
|
const parameters = [...pathParameters, ...toArray(operation.parameters)].map((p) =>
|
||||||
|
importState.resolve(p),
|
||||||
|
);
|
||||||
|
const body = importBody({ importState, operation, parameters, spec });
|
||||||
|
const urlParameters = importUrlParameters({ importState, parameters });
|
||||||
|
const headers = mergeHeaders(importHeaderParameters({ importState, parameters }), body.headers);
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: "http_request",
|
||||||
|
id: importState.generateId("http_request"),
|
||||||
|
workspaceId,
|
||||||
|
folderId,
|
||||||
|
name: importOperationName(operation, method, path),
|
||||||
|
description: importOperationDescription({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
parameters,
|
||||||
|
bodyContentType: body.bodyType,
|
||||||
|
}),
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
url: buildOperationUrl(requestBaseUrl, path),
|
||||||
|
urlParameters,
|
||||||
|
headers,
|
||||||
|
body: body.body,
|
||||||
|
bodyType: body.bodyType,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
|
...importAuthentication({ importState, operation, spec }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSpec(contents: string): unknown {
|
||||||
try {
|
try {
|
||||||
postmanCollection = await new Promise((resolve, reject) => {
|
return JSON.parse(contents);
|
||||||
// oxlint-disable-next-line no-explicit-any
|
|
||||||
convert({ type: "string", data: contents }, {}, (err, result: any) => {
|
|
||||||
if (err != null) reject(err);
|
|
||||||
|
|
||||||
if (Array.isArray(result.output) && result.output.length > 0) {
|
|
||||||
resolve(result.output[0].data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch {
|
} catch {
|
||||||
// Probably not an OpenAPI file, so skip it
|
// Fall through to YAML.
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return convertPostman(JSON.stringify(postmanCollection));
|
try {
|
||||||
|
return YAML.parse(contents);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenApiSpec(value: unknown): value is UnknownRecord {
|
||||||
|
const spec = toRecord(value);
|
||||||
|
const openapi = stringAt(spec, "openapi");
|
||||||
|
const swagger = stringAt(spec, "swagger");
|
||||||
|
return isRecord(spec.paths) && (openapi?.startsWith("3.") === true || swagger === "2.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
function importInfoDescription(info: UnknownRecord): string | undefined {
|
||||||
|
const parts = [
|
||||||
|
stringAt(info, "description"),
|
||||||
|
stringAt(info, "termsOfService")
|
||||||
|
? `Terms of service: ${stringAt(info, "termsOfService")}`
|
||||||
|
: null,
|
||||||
|
isRecord(info.contact) && stringAt(info.contact, "email")
|
||||||
|
? `Contact: ${stringAt(info.contact, "email")}`
|
||||||
|
: null,
|
||||||
|
isRecord(info.license) && stringAt(info.license, "name")
|
||||||
|
? `License: ${stringAt(info.license, "name")}${
|
||||||
|
stringAt(info.license, "url") ? ` (${stringAt(info.license, "url")})` : ""
|
||||||
|
}`
|
||||||
|
: null,
|
||||||
|
].filter(isPresent);
|
||||||
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importTagDescription(tag: UnknownRecord): string | undefined {
|
||||||
|
const externalDocs = toRecord(tag.externalDocs);
|
||||||
|
const parts = [
|
||||||
|
stringAt(tag, "description"),
|
||||||
|
stringAt(externalDocs, "url")
|
||||||
|
? `${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`
|
||||||
|
: null,
|
||||||
|
].filter(isPresent);
|
||||||
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOperationName(operation: UnknownRecord, method: string, path: string): string {
|
||||||
|
return (
|
||||||
|
stringAt(operation, "summary") ??
|
||||||
|
stringAt(operation, "operationId") ??
|
||||||
|
`${method.toUpperCase()} ${path}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importOperationDescription({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
parameters,
|
||||||
|
bodyContentType,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
parameters: unknown[];
|
||||||
|
bodyContentType: string | null;
|
||||||
|
}): string | undefined {
|
||||||
|
const parts: string[] = [];
|
||||||
|
const summary = stringAt(operation, "summary");
|
||||||
|
const description = stringAt(operation, "description");
|
||||||
|
const operationId = stringAt(operation, "operationId");
|
||||||
|
|
||||||
|
if (description != null) {
|
||||||
|
parts.push(description);
|
||||||
|
} else if (summary != null) {
|
||||||
|
parts.push(summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operationId != null) {
|
||||||
|
parts.push(`Operation ID: ${operationId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const parameterDescriptions = parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.slice(0, MAX_DESCRIPTION_ITEMS)
|
||||||
|
.map((p) => {
|
||||||
|
const name = stringAt(p, "name") ?? "parameter";
|
||||||
|
const location = stringAt(p, "in") ?? "unknown";
|
||||||
|
const required = p.required === true ? ", required" : "";
|
||||||
|
const description = stringAt(p, "description");
|
||||||
|
return `- ${name} (${location}${required})${description ? `: ${description}` : ""}`;
|
||||||
|
});
|
||||||
|
if (parameterDescriptions.length > 0) {
|
||||||
|
parts.push(["Parameters:", ...parameterDescriptions].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestBody = importState.resolve(operation.requestBody);
|
||||||
|
if (isRecord(requestBody)) {
|
||||||
|
const content = toRecord(requestBody.content);
|
||||||
|
const contentTypes = Object.keys(content);
|
||||||
|
const bodyLines = [
|
||||||
|
stringAt(requestBody, "description"),
|
||||||
|
bodyContentType ? `Selected content type: ${bodyContentType}` : null,
|
||||||
|
contentTypes.length > 0 ? `Available content types: ${contentTypes.join(", ")}` : null,
|
||||||
|
].filter(isPresent);
|
||||||
|
if (bodyLines.length > 0) {
|
||||||
|
parts.push(["Request body:", ...bodyLines].join("\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseDescriptions = Object.entries(toRecord(operation.responses))
|
||||||
|
.slice(0, MAX_DESCRIPTION_ITEMS)
|
||||||
|
.map(([status, response]) => {
|
||||||
|
const responseRecord = toRecord(importState.resolve(response));
|
||||||
|
return `- ${status}: ${stringAt(responseRecord, "description") ?? ""}`.trimEnd();
|
||||||
|
});
|
||||||
|
if (responseDescriptions.length > 0) {
|
||||||
|
parts.push(["Responses:", ...responseDescriptions].join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalDocs = toRecord(operation.externalDocs);
|
||||||
|
if (stringAt(externalDocs, "url")) {
|
||||||
|
parts.push(
|
||||||
|
`${stringAt(externalDocs, "description") ?? "External docs"}: ${stringAt(externalDocs, "url")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findOrCreateFolderId({
|
||||||
|
folderIdsByTag,
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
resources,
|
||||||
|
workspaceId,
|
||||||
|
}: {
|
||||||
|
folderIdsByTag: Map<string, string>;
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
resources: ImportResources;
|
||||||
|
workspaceId: string;
|
||||||
|
}): string | null {
|
||||||
|
const tag = toArray(operation.tags).find((t): t is string => typeof t === "string");
|
||||||
|
if (tag == null) return null;
|
||||||
|
|
||||||
|
const existingFolderId = folderIdsByTag.get(tag);
|
||||||
|
if (existingFolderId != null) return existingFolderId;
|
||||||
|
|
||||||
|
const folder: ImportResources["folders"][0] = {
|
||||||
|
model: "folder",
|
||||||
|
id: importState.generateId("folder"),
|
||||||
|
workspaceId,
|
||||||
|
name: tag,
|
||||||
|
folderId: null,
|
||||||
|
sortPriority: importState.nextSortPriority(),
|
||||||
|
};
|
||||||
|
resources.folders.push(folder);
|
||||||
|
folderIdsByTag.set(tag, folder.id);
|
||||||
|
return folder.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOperationUrl(baseUrl: string, path: string): string {
|
||||||
|
return joinUrlParts(baseUrl, path.replaceAll(/{([^}/]+)}/g, ":$1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function importBaseUrl(spec: UnknownRecord): string {
|
||||||
|
const openApiServer = toArray(spec.servers)
|
||||||
|
.map((s) => toRecord(s))
|
||||||
|
.map((s) => interpolateServerUrl(s))
|
||||||
|
.find((url) => url.length > 0);
|
||||||
|
if (openApiServer != null) return openApiServer;
|
||||||
|
|
||||||
|
const host = stringAt(spec, "host");
|
||||||
|
if (host == null) return stringAt(spec, "basePath") ?? "";
|
||||||
|
|
||||||
|
const scheme = toArray(spec.schemes).find((s): s is string => typeof s === "string") ?? "https";
|
||||||
|
return joinUrlParts(`${scheme}://${host}`, stringAt(spec, "basePath") ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolateServerUrl(server: UnknownRecord): string {
|
||||||
|
let url = stringAt(server, "url") ?? "";
|
||||||
|
for (const [name, variable] of Object.entries(toRecord(server.variables))) {
|
||||||
|
url = url.replaceAll(`{${name}}`, stringifyExampleValue(toRecord(variable).default));
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinUrlParts(baseUrl: string, path: string): string {
|
||||||
|
if (baseUrl.length === 0) return path;
|
||||||
|
return `${trimTrailingSlashes(baseUrl)}/${trimLeadingSlashes(path)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimLeadingSlashes(value: string): string {
|
||||||
|
let index = 0;
|
||||||
|
while (value[index] === "/") index++;
|
||||||
|
return value.slice(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimTrailingSlashes(value: string): string {
|
||||||
|
let index = value.length;
|
||||||
|
while (value[index - 1] === "/") index--;
|
||||||
|
return value.slice(0, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importUrlParameters({
|
||||||
|
importState,
|
||||||
|
parameters,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
parameters: unknown[];
|
||||||
|
}): HttpUrlParameter[] {
|
||||||
|
return parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.filter((p) => stringAt(p, "in") === "query" || stringAt(p, "in") === "path")
|
||||||
|
.map((p) => ({
|
||||||
|
enabled: p.required === true,
|
||||||
|
name:
|
||||||
|
stringAt(p, "in") === "path"
|
||||||
|
? `:${stringAt(p, "name") ?? ""}`
|
||||||
|
: (stringAt(p, "name") ?? ""),
|
||||||
|
value: parameterExample(p, importState),
|
||||||
|
}))
|
||||||
|
.filter(({ name }) => name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function importHeaderParameters({
|
||||||
|
importState,
|
||||||
|
parameters,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
parameters: unknown[];
|
||||||
|
}): HttpRequestHeader[] {
|
||||||
|
return parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.filter((p) => stringAt(p, "in") === "header")
|
||||||
|
.map((p) => ({
|
||||||
|
enabled: p.required === true,
|
||||||
|
name: stringAt(p, "name") ?? "",
|
||||||
|
value: parameterExample(p, importState),
|
||||||
|
}))
|
||||||
|
.filter(({ name }) => name.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameterExample(parameter: UnknownRecord, importState: ImportState): string {
|
||||||
|
const directExample = firstPresent(parameter.example, firstExampleValue(parameter.examples));
|
||||||
|
if (directExample != null) return stringifyExampleValue(directExample);
|
||||||
|
return stringifyExampleValue(schemaToExample(importState.resolve(parameter.schema), importState));
|
||||||
|
}
|
||||||
|
|
||||||
|
function importBody({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
parameters,
|
||||||
|
spec,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
parameters: unknown[];
|
||||||
|
spec: UnknownRecord;
|
||||||
|
}): {
|
||||||
|
headers: HttpRequestHeader[];
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
bodyType: string | null;
|
||||||
|
} {
|
||||||
|
const openApiRequestBody = importState.resolve(operation.requestBody);
|
||||||
|
if (isRecord(openApiRequestBody)) {
|
||||||
|
return importBodyFromContent(importState, toRecord(openApiRequestBody.content));
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyParameter = parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.find((p) => isRecord(p) && stringAt(p, "in") === "body");
|
||||||
|
if (isRecord(bodyParameter)) {
|
||||||
|
const contentType = toArray(spec.consumes).find((c): c is string => typeof c === "string");
|
||||||
|
const bodyType = contentType ?? "application/json";
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: bodyType }],
|
||||||
|
bodyType,
|
||||||
|
body: {
|
||||||
|
text: formatBodyText(
|
||||||
|
schemaToExample(importState.resolve(bodyParameter.schema), importState),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formParameters = parameters
|
||||||
|
.map((p) => importState.resolve(p))
|
||||||
|
.filter(isRecord)
|
||||||
|
.filter((p) => stringAt(p, "in") === "formData");
|
||||||
|
if (formParameters.length > 0) {
|
||||||
|
const contentType =
|
||||||
|
toArray(spec.consumes).find((c): c is string => typeof c === "string") ??
|
||||||
|
(formParameters.some((p) => stringAt(p, "type") === "file")
|
||||||
|
? "multipart/form-data"
|
||||||
|
: "application/x-www-form-urlencoded");
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
|
bodyType: contentType,
|
||||||
|
body: {
|
||||||
|
form: formParameters.map((p) => ({
|
||||||
|
enabled: p.required === true,
|
||||||
|
name: stringAt(p, "name") ?? "",
|
||||||
|
value: parameterExample(p, importState),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { headers: [], body: {}, bodyType: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function importBodyFromContent(importState: ImportState, content: UnknownRecord) {
|
||||||
|
const contentType = chooseContentType(Object.keys(content));
|
||||||
|
if (contentType == null) return { headers: [], body: {}, bodyType: null };
|
||||||
|
|
||||||
|
const mediaType = toRecord(content[contentType]);
|
||||||
|
const example = mediaTypeExample(mediaType, importState);
|
||||||
|
|
||||||
|
if (
|
||||||
|
contentType === "application/x-www-form-urlencoded" ||
|
||||||
|
contentType === "multipart/form-data"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
|
bodyType: contentType,
|
||||||
|
body: {
|
||||||
|
form: schemaToFormParameters(importState.resolve(mediaType.schema), importState),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
headers: [{ enabled: true, name: "Content-Type", value: contentType }],
|
||||||
|
bodyType: contentType === "application/octet-stream" ? "binary" : contentType,
|
||||||
|
body: contentType === "application/octet-stream" ? {} : { text: formatBodyText(example) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseContentType(contentTypes: string[]): string | null {
|
||||||
|
for (const preference of BODY_CONTENT_TYPE_PREFERENCE) {
|
||||||
|
const exact = contentTypes.find((c) => c.toLowerCase() === preference);
|
||||||
|
if (exact != null) return exact;
|
||||||
|
}
|
||||||
|
return contentTypes[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mediaTypeExample(mediaType: UnknownRecord, importState: ImportState): unknown {
|
||||||
|
const directExample = firstPresent(mediaType.example, firstExampleValue(mediaType.examples));
|
||||||
|
if (directExample != null) return directExample;
|
||||||
|
return schemaToExample(importState.resolve(mediaType.schema), importState);
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaToFormParameters(schema: unknown, importState: ImportState) {
|
||||||
|
const resolvedSchema = toRecord(importState.resolve(schema));
|
||||||
|
const required = toArray(resolvedSchema.required).filter(
|
||||||
|
(name): name is string => typeof name === "string",
|
||||||
|
);
|
||||||
|
const properties = Object.entries(toRecord(resolvedSchema.properties)).slice(
|
||||||
|
0,
|
||||||
|
MAX_EXAMPLE_PROPERTIES,
|
||||||
|
);
|
||||||
|
|
||||||
|
return properties.map(([name, property]) => {
|
||||||
|
const resolvedProperty = toRecord(importState.resolve(property));
|
||||||
|
const example = schemaToExample(resolvedProperty, importState);
|
||||||
|
const base = {
|
||||||
|
enabled: required.includes(name),
|
||||||
|
name,
|
||||||
|
};
|
||||||
|
if (stringAt(resolvedProperty, "format") === "binary") {
|
||||||
|
return { ...base, file: "" };
|
||||||
|
}
|
||||||
|
return { ...base, value: stringifyExampleValue(example) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function schemaToExample(
|
||||||
|
schema: unknown,
|
||||||
|
importState: ImportState,
|
||||||
|
depth = 0,
|
||||||
|
visitedRefs = new Set<string>(),
|
||||||
|
): unknown {
|
||||||
|
if (depth > MAX_EXAMPLE_DEPTH) return {};
|
||||||
|
|
||||||
|
const resolved = importState.resolve(schema, visitedRefs);
|
||||||
|
if (!isRecord(resolved)) return "";
|
||||||
|
|
||||||
|
const explicitExample = firstPresent(
|
||||||
|
resolved.example,
|
||||||
|
firstExampleValue(resolved.examples),
|
||||||
|
resolved.default,
|
||||||
|
);
|
||||||
|
if (explicitExample != null) return explicitExample;
|
||||||
|
|
||||||
|
const enumValues = toArray(resolved.enum);
|
||||||
|
if (enumValues.length > 0) return enumValues[0];
|
||||||
|
|
||||||
|
const allOf = toArray(resolved.allOf);
|
||||||
|
if (allOf.length > 0) {
|
||||||
|
return allOf.reduce<UnknownRecord>((merged, childSchema) => {
|
||||||
|
const childExample = schemaToExample(childSchema, importState, depth + 1, visitedRefs);
|
||||||
|
return isRecord(childExample) ? { ...merged, ...childExample } : merged;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
const oneOf = toArray(resolved.oneOf);
|
||||||
|
const anyOf = toArray(resolved.anyOf);
|
||||||
|
if (oneOf.length > 0 || anyOf.length > 0) {
|
||||||
|
return schemaToExample(oneOf[0] ?? anyOf[0], importState, depth + 1, visitedRefs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = inferSchemaType(resolved);
|
||||||
|
if (type === "array") {
|
||||||
|
return [schemaToExample(resolved.items, importState, depth + 1, visitedRefs)];
|
||||||
|
}
|
||||||
|
if (type === "object") {
|
||||||
|
const required = toArray(resolved.required).filter(
|
||||||
|
(name): name is string => typeof name === "string",
|
||||||
|
);
|
||||||
|
const properties = Object.entries(toRecord(resolved.properties)).sort(([a], [b]) => {
|
||||||
|
const aRequired = required.includes(a);
|
||||||
|
const bRequired = required.includes(b);
|
||||||
|
return aRequired === bRequired ? 0 : aRequired ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
properties
|
||||||
|
.slice(0, MAX_EXAMPLE_PROPERTIES)
|
||||||
|
.map(([name, property]) => [
|
||||||
|
name,
|
||||||
|
schemaToExample(property, importState, depth + 1, visitedRefs),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (type === "integer" || type === "number") return 0;
|
||||||
|
if (type === "boolean") return false;
|
||||||
|
if (stringAt(resolved, "format") === "date-time") return "2026-01-01T00:00:00Z";
|
||||||
|
if (stringAt(resolved, "format") === "date") return "2026-01-01";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferSchemaType(schema: UnknownRecord): string {
|
||||||
|
const rawType = schema.type;
|
||||||
|
if (typeof rawType === "string") return rawType;
|
||||||
|
if (Array.isArray(rawType)) {
|
||||||
|
const nonNullType = rawType.find((t) => t !== "null");
|
||||||
|
if (typeof nonNullType === "string") return nonNullType;
|
||||||
|
}
|
||||||
|
if (isRecord(schema.properties) || isRecord(schema.additionalProperties)) return "object";
|
||||||
|
if (schema.items != null) return "array";
|
||||||
|
return "string";
|
||||||
|
}
|
||||||
|
|
||||||
|
function importAuthentication({
|
||||||
|
importState,
|
||||||
|
operation,
|
||||||
|
spec,
|
||||||
|
}: {
|
||||||
|
importState: ImportState;
|
||||||
|
operation: UnknownRecord;
|
||||||
|
spec: UnknownRecord;
|
||||||
|
}): Pick<HttpRequest, "authentication" | "authenticationType"> {
|
||||||
|
const security = operation.security ?? spec.security;
|
||||||
|
if (!Array.isArray(security) || security.length === 0) {
|
||||||
|
return { authenticationType: null, authentication: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
const schemes = {
|
||||||
|
...toRecord(toRecord(spec.components).securitySchemes),
|
||||||
|
...toRecord(spec.securityDefinitions),
|
||||||
|
};
|
||||||
|
for (const requirement of security) {
|
||||||
|
for (const schemeName of Object.keys(toRecord(requirement))) {
|
||||||
|
const scheme = toRecord(importState.resolve(schemes[schemeName]));
|
||||||
|
const type = stringAt(scheme, "type");
|
||||||
|
if (type === "apiKey") {
|
||||||
|
return {
|
||||||
|
authenticationType: "apikey",
|
||||||
|
authentication: {
|
||||||
|
location: stringAt(scheme, "in") === "query" ? "query" : "header",
|
||||||
|
key: stringAt(scheme, "name") ?? schemeName,
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "basic") {
|
||||||
|
return {
|
||||||
|
authenticationType: "basic",
|
||||||
|
authentication: { username: "", password: "" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === "http" && stringAt(scheme, "scheme")?.toLowerCase() === "bearer") {
|
||||||
|
return {
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: { token: "", prefix: "Bearer" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticationType: null, authentication: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeHeaders(...headerGroups: HttpRequestHeader[][]): HttpRequestHeader[] {
|
||||||
|
const headers: HttpRequestHeader[] = [];
|
||||||
|
for (const header of headerGroups.flat()) {
|
||||||
|
const existing = headers.find((h) => h.name.toLowerCase() === header.name.toLowerCase());
|
||||||
|
if (existing == null) {
|
||||||
|
headers.push(header);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBodyText(example: unknown): string {
|
||||||
|
return typeof example === "string" ? example : JSON.stringify(example, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyExampleValue(value: unknown): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstExampleValue(examples: unknown): unknown {
|
||||||
|
const firstExample = Object.values(toRecord(examples))[0];
|
||||||
|
if (isRecord(firstExample) && "value" in firstExample) return firstExample.value;
|
||||||
|
return firstExample;
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstPresent(...values: unknown[]): unknown {
|
||||||
|
return values.find((value) => value !== undefined && value !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringAt(record: unknown, key: string): string | undefined {
|
||||||
|
const value = toRecord(record)[key];
|
||||||
|
return typeof value === "string" ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toArray(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRecord(value: unknown): UnknownRecord {
|
||||||
|
return isRecord(value) ? value : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return value != null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPresent<T>(value: T | null | undefined): value is T {
|
||||||
|
return value != null && value !== "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively render all nested object properties */
|
||||||
|
function convertTemplateSyntax<T>(obj: T): T {
|
||||||
|
if (typeof obj === "string") {
|
||||||
|
// oxlint-disable-next-line no-template-curly-in-string -- Yaak template syntax
|
||||||
|
return obj.replaceAll(/{{\s*(_\.)?([^}]+)\s*}}/g, "${[$2]}") as T;
|
||||||
|
}
|
||||||
|
if (Array.isArray(obj) && obj != null) {
|
||||||
|
return obj.map(convertTemplateSyntax) as T;
|
||||||
|
}
|
||||||
|
if (typeof obj === "object" && obj != null) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj).map(([k, v]) => [k, convertTemplateSyntax(v)]),
|
||||||
|
) as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteUndefinedAttrs<T>(obj: T): T {
|
||||||
|
if (Array.isArray(obj) && obj != null) {
|
||||||
|
return obj.map(deleteUndefinedAttrs) as T;
|
||||||
|
}
|
||||||
|
if (typeof obj === "object" && obj != null) {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(obj)
|
||||||
|
.filter(([, v]) => v !== undefined)
|
||||||
|
.map(([k, v]) => [k, deleteUndefinedAttrs(v)]),
|
||||||
|
) as T;
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ImportState {
|
||||||
|
readonly #spec: UnknownRecord;
|
||||||
|
readonly #idCount: Partial<Record<string, number>> = {};
|
||||||
|
#sortPriority = 0;
|
||||||
|
|
||||||
|
constructor(spec: UnknownRecord) {
|
||||||
|
this.#spec = spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
generateId(model: string): string {
|
||||||
|
this.#idCount[model] = (this.#idCount[model] ?? -1) + 1;
|
||||||
|
return `GENERATE_ID::${model.toUpperCase()}_${this.#idCount[model]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextSortPriority(): number {
|
||||||
|
return this.#sortPriority++;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(value: unknown, visitedRefs = new Set<string>()): unknown {
|
||||||
|
if (!isRecord(value) || typeof value.$ref !== "string") return value;
|
||||||
|
if (visitedRefs.has(value.$ref)) return {};
|
||||||
|
|
||||||
|
const nextVisitedRefs = new Set(visitedRefs);
|
||||||
|
nextVisitedRefs.add(value.$ref);
|
||||||
|
|
||||||
|
if (!value.$ref.startsWith("#/")) return value;
|
||||||
|
|
||||||
|
const resolved = value.$ref
|
||||||
|
.slice(2)
|
||||||
|
.split("/")
|
||||||
|
.map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||||
|
.reduce<unknown>((current, part) => toRecord(current)[part], this.#spec);
|
||||||
|
|
||||||
|
return this.resolve(resolved, nextVisitedRefs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { convertPostman } from "@yaak/importer-postman/src";
|
||||||
|
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
|
||||||
|
import { convert } from "openapi-to-postmanv2";
|
||||||
|
|
||||||
|
export async function convertOpenApiWithPostman(
|
||||||
|
contents: string,
|
||||||
|
): Promise<ImportPluginResponse | undefined> {
|
||||||
|
// oxlint-disable-next-line no-explicit-any
|
||||||
|
let postmanCollection: any;
|
||||||
|
try {
|
||||||
|
postmanCollection = await new Promise((resolve, reject) => {
|
||||||
|
// oxlint-disable-next-line no-explicit-any
|
||||||
|
convert({ type: "string", data: contents }, {}, (err, result: any) => {
|
||||||
|
if (err != null) reject(err);
|
||||||
|
|
||||||
|
if (Array.isArray(result.output) && result.output.length > 0) {
|
||||||
|
resolve(result.output[0].data);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return convertPostman(JSON.stringify(postmanCollection));
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
|||||||
|
# Real-World OpenAPI Fixtures
|
||||||
|
|
||||||
|
These fixtures were copied from the public APIs.guru OpenAPI directory:
|
||||||
|
|
||||||
|
- `apis-guru.yaml`: https://api.apis.guru/v2/specs/apis.guru/2.2.0/openapi.yaml
|
||||||
|
- `httpbin.yaml`: https://api.apis.guru/v2/specs/httpbin.org/0.9.2/openapi.yaml
|
||||||
|
- `nasa-apod.yaml`: https://api.apis.guru/v2/specs/nasa.gov/apod/1.0.0/openapi.yaml
|
||||||
|
- `xkcd.yaml`: https://api.apis.guru/v2/specs/xkcd.com/1.0.0/openapi.yaml
|
||||||
@@ -0,0 +1,399 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://api.apis.guru/v2
|
||||||
|
info:
|
||||||
|
contact:
|
||||||
|
email: mike.ralphson@gmail.com
|
||||||
|
name: APIs.guru
|
||||||
|
url: https://APIs.guru
|
||||||
|
description: |
|
||||||
|
Wikipedia for Web APIs. Repository of API definitions in OpenAPI format.
|
||||||
|
**Warning**: If you want to be notified about changes in advance please join our [Slack channel](https://join.slack.com/t/mermade/shared_invite/zt-g78g7xir-MLE_CTCcXCdfJfG3CJe9qA).
|
||||||
|
Client sample: [[Demo]](https://apis.guru/simple-ui) [[Repo]](https://github.com/APIs-guru/simple-ui)
|
||||||
|
license:
|
||||||
|
name: CC0 1.0
|
||||||
|
url: https://github.com/APIs-guru/openapi-directory#licenses
|
||||||
|
title: APIs.guru
|
||||||
|
version: 2.2.0
|
||||||
|
x-apisguru-categories:
|
||||||
|
- open_data
|
||||||
|
- developer_tools
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_branding_logo_vertical.svg
|
||||||
|
x-origin:
|
||||||
|
- format: openapi
|
||||||
|
url: https://api.apis.guru/v2/openapi.yaml
|
||||||
|
version: "3.0"
|
||||||
|
x-providerName: apis.guru
|
||||||
|
x-tags:
|
||||||
|
- API
|
||||||
|
- Catalog
|
||||||
|
- Directory
|
||||||
|
- REST
|
||||||
|
- Swagger
|
||||||
|
- OpenAPI
|
||||||
|
externalDocs:
|
||||||
|
url: https://github.com/APIs-guru/openapi-directory/blob/master/API.md
|
||||||
|
security: []
|
||||||
|
tags:
|
||||||
|
- description: Actions relating to APIs in the collection
|
||||||
|
name: APIs
|
||||||
|
paths:
|
||||||
|
/list.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all APIs in the directory.
|
||||||
|
Returns links to the OpenAPI definitions for each API in the directory.
|
||||||
|
If API exist in multiple versions `preferred` one is explicitly marked.
|
||||||
|
Some basic info from the OpenAPI definition is cached inside each object.
|
||||||
|
This allows you to generate some simple views without needing to fetch the OpenAPI definition for each API.
|
||||||
|
operationId: listAPIs
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/APIs"
|
||||||
|
description: OK
|
||||||
|
summary: List all APIs
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
/metrics.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
Some basic metrics for the entire directory.
|
||||||
|
Just stunning numbers to put on a front page and are intended purely for WoW effect :)
|
||||||
|
operationId: getMetrics
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Metrics"
|
||||||
|
description: OK
|
||||||
|
summary: Get basic metrics
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
/providers.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all the providers in the directory
|
||||||
|
operationId: getProviders
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
summary: List all providers
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/specs/{provider}/{api}.json":
|
||||||
|
get:
|
||||||
|
description: Returns the API entry for one specific version of an API where there is no serviceName.
|
||||||
|
operationId: getAPI
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
- $ref: "#/components/parameters/api"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/API"
|
||||||
|
description: OK
|
||||||
|
summary: Retrieve one version of a particular API
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/specs/{provider}/{service}/{api}.json":
|
||||||
|
get:
|
||||||
|
description: Returns the API entry for one specific version of an API where there is a serviceName.
|
||||||
|
operationId: getServiceAPI
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
- in: path
|
||||||
|
name: service
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
example: graph
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
- $ref: "#/components/parameters/api"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/API"
|
||||||
|
description: OK
|
||||||
|
summary: Retrieve one version of a particular API with a serviceName.
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/{provider}.json":
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all APIs in the directory for a particular providerName
|
||||||
|
Returns links to the individual API entry for each API.
|
||||||
|
operationId: getProvider
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/APIs"
|
||||||
|
description: OK
|
||||||
|
summary: List all APIs for a particular provider
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
"/{provider}/services.json":
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
List all serviceNames in the directory for a particular providerName
|
||||||
|
operationId: getServices
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/provider"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
data:
|
||||||
|
items:
|
||||||
|
minLength: 0
|
||||||
|
type: string
|
||||||
|
minItems: 1
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
summary: List all serviceNames for a particular provider
|
||||||
|
tags:
|
||||||
|
- APIs
|
||||||
|
components:
|
||||||
|
parameters:
|
||||||
|
api:
|
||||||
|
in: path
|
||||||
|
name: api
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
example: 2.1.0
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
provider:
|
||||||
|
in: path
|
||||||
|
name: provider
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
example: apis.guru
|
||||||
|
maxLength: 255
|
||||||
|
minLength: 1
|
||||||
|
type: string
|
||||||
|
schemas:
|
||||||
|
API:
|
||||||
|
additionalProperties: false
|
||||||
|
description: Meta information about API
|
||||||
|
properties:
|
||||||
|
added:
|
||||||
|
description: Timestamp when the API was first added to the directory
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
preferred:
|
||||||
|
description: Recommended version
|
||||||
|
type: string
|
||||||
|
versions:
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/components/schemas/ApiVersion"
|
||||||
|
description: List of supported versions of the API
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- added
|
||||||
|
- preferred
|
||||||
|
- versions
|
||||||
|
type: object
|
||||||
|
APIs:
|
||||||
|
additionalProperties:
|
||||||
|
$ref: "#/components/schemas/API"
|
||||||
|
description: |
|
||||||
|
List of API details.
|
||||||
|
It is a JSON object with API IDs(`<provider>[:<service>]`) as keys.
|
||||||
|
example:
|
||||||
|
googleapis.com:drive:
|
||||||
|
added: 2015-02-22T20:00:45.000Z
|
||||||
|
preferred: v3
|
||||||
|
versions:
|
||||||
|
v2:
|
||||||
|
added: 2015-02-22T20:00:45.000Z
|
||||||
|
info:
|
||||||
|
title: Drive
|
||||||
|
version: v2
|
||||||
|
x-apiClientRegistration:
|
||||||
|
url: https://console.developers.google.com
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
|
||||||
|
x-origin:
|
||||||
|
format: google
|
||||||
|
url: https://www.googleapis.com/discovery/v1/apis/drive/v2/rest
|
||||||
|
version: v1
|
||||||
|
x-preferred: false
|
||||||
|
x-providerName: googleapis.com
|
||||||
|
x-serviceName: drive
|
||||||
|
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.json
|
||||||
|
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v2/swagger.yaml
|
||||||
|
updated: 2016-06-17T00:21:44.000Z
|
||||||
|
v3:
|
||||||
|
added: 2015-12-12T00:25:13.000Z
|
||||||
|
info:
|
||||||
|
title: Drive
|
||||||
|
version: v3
|
||||||
|
x-apiClientRegistration:
|
||||||
|
url: https://console.developers.google.com
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_www.gstatic.com_images_icons_material_product_2x_drive_32dp.png
|
||||||
|
x-origin:
|
||||||
|
format: google
|
||||||
|
url: https://www.googleapis.com/discovery/v1/apis/drive/v3/rest
|
||||||
|
version: v1
|
||||||
|
x-preferred: true
|
||||||
|
x-providerName: googleapis.com
|
||||||
|
x-serviceName: drive
|
||||||
|
swaggerUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.json
|
||||||
|
swaggerYamlUrl: https://api.apis.guru/v2/specs/googleapis.com/drive/v3/swagger.yaml
|
||||||
|
updated: 2016-06-17T00:21:44.000Z
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
ApiVersion:
|
||||||
|
additionalProperties: false
|
||||||
|
properties:
|
||||||
|
added:
|
||||||
|
description: Timestamp when the version was added
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
externalDocs:
|
||||||
|
description: Copy of `externalDocs` section from OpenAPI definition
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
info:
|
||||||
|
description: Copy of `info` section from OpenAPI definition
|
||||||
|
minProperties: 1
|
||||||
|
type: object
|
||||||
|
link:
|
||||||
|
description: Link to the individual API entry for this API
|
||||||
|
format: url
|
||||||
|
type: string
|
||||||
|
openapiVer:
|
||||||
|
description: The value of the `openapi` or `swagger` property of the source definition
|
||||||
|
type: string
|
||||||
|
swaggerUrl:
|
||||||
|
description: URL to OpenAPI definition in JSON format
|
||||||
|
format: url
|
||||||
|
type: string
|
||||||
|
swaggerYamlUrl:
|
||||||
|
description: URL to OpenAPI definition in YAML format
|
||||||
|
format: url
|
||||||
|
type: string
|
||||||
|
updated:
|
||||||
|
description: Timestamp when the version was updated
|
||||||
|
format: date-time
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- added
|
||||||
|
- updated
|
||||||
|
- swaggerUrl
|
||||||
|
- swaggerYamlUrl
|
||||||
|
- info
|
||||||
|
- openapiVer
|
||||||
|
type: object
|
||||||
|
Metrics:
|
||||||
|
additionalProperties: false
|
||||||
|
description: List of basic metrics
|
||||||
|
example:
|
||||||
|
datasets: []
|
||||||
|
fixedPct: 22
|
||||||
|
fixes: 81119
|
||||||
|
invalid: 598
|
||||||
|
issues: 28
|
||||||
|
numAPIs: 2501
|
||||||
|
numDrivers: 10
|
||||||
|
numEndpoints: 106448
|
||||||
|
numProviders: 659
|
||||||
|
numSpecs: 3329
|
||||||
|
stars: 2429
|
||||||
|
thisWeek:
|
||||||
|
added: 45
|
||||||
|
updated: 171
|
||||||
|
unofficial: 25
|
||||||
|
unreachable: 123
|
||||||
|
properties:
|
||||||
|
datasets:
|
||||||
|
description: Data used for charting etc
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
fixedPct:
|
||||||
|
description: Percentage of all APIs where auto fixes have been applied
|
||||||
|
type: integer
|
||||||
|
fixes:
|
||||||
|
description: Total number of fixes applied across all APIs
|
||||||
|
type: integer
|
||||||
|
invalid:
|
||||||
|
description: Number of newly invalid APIs
|
||||||
|
type: integer
|
||||||
|
issues:
|
||||||
|
description: Open GitHub issues on our main repo
|
||||||
|
type: integer
|
||||||
|
numAPIs:
|
||||||
|
description: Number of unique APIs
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
numDrivers:
|
||||||
|
description: Number of methods of API retrieval
|
||||||
|
type: integer
|
||||||
|
numEndpoints:
|
||||||
|
description: Total number of endpoints inside all definitions
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
numProviders:
|
||||||
|
description: Number of API providers in directory
|
||||||
|
type: integer
|
||||||
|
numSpecs:
|
||||||
|
description: Number of API definitions including different versions of the same API
|
||||||
|
minimum: 1
|
||||||
|
type: integer
|
||||||
|
stars:
|
||||||
|
description: GitHub stars for our main repo
|
||||||
|
type: integer
|
||||||
|
thisWeek:
|
||||||
|
description: Summary totals for the last 7 days
|
||||||
|
properties:
|
||||||
|
added:
|
||||||
|
description: APIs added in the last week
|
||||||
|
type: integer
|
||||||
|
updated:
|
||||||
|
description: APIs updated in the last week
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
unofficial:
|
||||||
|
description: Number of unofficial APIs
|
||||||
|
type: integer
|
||||||
|
unreachable:
|
||||||
|
description: Number of unreachable (4XX,5XX status) APIs
|
||||||
|
type: integer
|
||||||
|
required:
|
||||||
|
- numSpecs
|
||||||
|
- numAPIs
|
||||||
|
- numEndpoints
|
||||||
|
type: object
|
||||||
|
x-optic-standard: "@febf8ac6-ee67-4565-b45a-5c85a469dca7/Fz6KU3_wMIO5iJ6_VUZ30"
|
||||||
|
x-optic-url: https://app.useoptic.com/organizations/febf8ac6-ee67-4565-b45a-5c85a469dca7/apis/_0fKWqUvhs9ssYNkq1k-c
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,69 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
servers:
|
||||||
|
- url: https://api.nasa.gov/planetary
|
||||||
|
- url: http://api.nasa.gov/planetary
|
||||||
|
info:
|
||||||
|
contact:
|
||||||
|
email: evan.t.yates@nasa.gov
|
||||||
|
description: This endpoint structures the APOD imagery and associated metadata so that it can be repurposed for other applications. In addition, if the concept_tags parameter is set to True, then keywords derived from the image explanation are returned. These keywords could be used as auto-generated hashtags for twitter or instagram feeds; but generally help with discoverability of relevant imagery
|
||||||
|
license:
|
||||||
|
name: Apache 2.0
|
||||||
|
url: http://www.apache.org/licenses/LICENSE-2.0.html
|
||||||
|
title: APOD
|
||||||
|
version: 1.0.0
|
||||||
|
x-apisguru-categories:
|
||||||
|
- media
|
||||||
|
- open_data
|
||||||
|
x-origin:
|
||||||
|
- format: swagger
|
||||||
|
url: https://raw.githubusercontent.com/nasa/api-docs/gh-pages/assets/json/APOD
|
||||||
|
version: "2.0"
|
||||||
|
x-providerName: nasa.gov
|
||||||
|
x-serviceName: apod
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/https_apis.guru_assets_images_no-logo.svg
|
||||||
|
tags:
|
||||||
|
- description: An example tag
|
||||||
|
externalDocs:
|
||||||
|
description: Here's a link
|
||||||
|
url: https://example.com
|
||||||
|
name: request tag
|
||||||
|
paths:
|
||||||
|
/apod:
|
||||||
|
get:
|
||||||
|
description: Returns the picture of the day
|
||||||
|
parameters:
|
||||||
|
- description: The date of the APOD image to retrieve
|
||||||
|
in: query
|
||||||
|
name: date
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: Retrieve the URL for the high resolution image
|
||||||
|
in: query
|
||||||
|
name: hd
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: boolean
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
items:
|
||||||
|
x-thing: ok
|
||||||
|
type: array
|
||||||
|
description: successful operation
|
||||||
|
"400":
|
||||||
|
description: Date must be between Jun 16, 1995 and Mar 28, 2019.
|
||||||
|
security:
|
||||||
|
- api_key: []
|
||||||
|
summary: Returns images
|
||||||
|
tags:
|
||||||
|
- request tag
|
||||||
|
components:
|
||||||
|
securitySchemes:
|
||||||
|
api_key:
|
||||||
|
in: query
|
||||||
|
name: api_key
|
||||||
|
type: apiKey
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
openapi: 3.0.0
|
||||||
|
servers:
|
||||||
|
- url: http://xkcd.com/
|
||||||
|
info:
|
||||||
|
description: Webcomic of romance, sarcasm, math, and language.
|
||||||
|
title: XKCD
|
||||||
|
version: 1.0.0
|
||||||
|
x-apisguru-categories:
|
||||||
|
- media
|
||||||
|
x-logo:
|
||||||
|
url: https://api.apis.guru/v2/cache/logo/http_imgs.xkcd.com_static_terrible_small_logo.png
|
||||||
|
x-origin:
|
||||||
|
- format: openapi
|
||||||
|
url: https://raw.githubusercontent.com/APIs-guru/unofficial_openapi_specs/master/xkcd.com/1.0.0/openapi.yaml
|
||||||
|
version: "3.0"
|
||||||
|
x-providerName: xkcd.com
|
||||||
|
x-tags:
|
||||||
|
- humor
|
||||||
|
- comics
|
||||||
|
x-unofficialSpec: true
|
||||||
|
externalDocs:
|
||||||
|
url: https://xkcd.com/json.html
|
||||||
|
paths:
|
||||||
|
/info.0.json:
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
Fetch current comic and metadata.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
"*/*":
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/comic"
|
||||||
|
description: OK
|
||||||
|
"/{comicId}/info.0.json":
|
||||||
|
get:
|
||||||
|
description: |
|
||||||
|
Fetch comics and metadata by comic id.
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: comicId
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
"*/*":
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/comic"
|
||||||
|
description: OK
|
||||||
|
components:
|
||||||
|
schemas:
|
||||||
|
comic:
|
||||||
|
properties:
|
||||||
|
alt:
|
||||||
|
type: string
|
||||||
|
day:
|
||||||
|
type: string
|
||||||
|
img:
|
||||||
|
type: string
|
||||||
|
link:
|
||||||
|
type: string
|
||||||
|
month:
|
||||||
|
type: string
|
||||||
|
news:
|
||||||
|
type: string
|
||||||
|
num:
|
||||||
|
type: number
|
||||||
|
safe_title:
|
||||||
|
type: string
|
||||||
|
title:
|
||||||
|
type: string
|
||||||
|
transcript:
|
||||||
|
type: string
|
||||||
|
year:
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
@@ -5,7 +5,13 @@ import { convertOpenApi } from "../src";
|
|||||||
|
|
||||||
describe("importer-openapi", () => {
|
describe("importer-openapi", () => {
|
||||||
const p = path.join(__dirname, "fixtures");
|
const p = path.join(__dirname, "fixtures");
|
||||||
const fixtures = fs.readdirSync(p);
|
const fixtures = fs.readdirSync(p).filter((fixture) => {
|
||||||
|
return fs.statSync(path.join(p, fixture)).isFile();
|
||||||
|
});
|
||||||
|
const realWorldFixturesPath = path.join(p, "real-world");
|
||||||
|
const realWorldFixtures = fs
|
||||||
|
.readdirSync(realWorldFixturesPath)
|
||||||
|
.filter((fixture) => fixture.endsWith(".yaml"));
|
||||||
|
|
||||||
test("Maps operation description to request description", async () => {
|
test("Maps operation description to request description", async () => {
|
||||||
const imported = await convertOpenApi(
|
const imported = await convertOpenApi(
|
||||||
@@ -25,7 +31,195 @@ describe("importer-openapi", () => {
|
|||||||
|
|
||||||
expect(imported?.resources.httpRequests).toEqual([
|
expect(imported?.resources.httpRequests).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
description: "Lijst van klanten",
|
description: expect.stringContaining("Lijst van klanten"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Imports requests directly from OpenAPI details", async () => {
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.0.0",
|
||||||
|
info: { title: "Native Import Test", version: "1.0.0" },
|
||||||
|
servers: [
|
||||||
|
{ url: "https://api.example.com/{version}", variables: { version: { default: "v1" } } },
|
||||||
|
],
|
||||||
|
tags: [{ name: "accounts", description: "Account operations" }],
|
||||||
|
paths: {
|
||||||
|
"/accounts/{accountId}/members": {
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "accountId",
|
||||||
|
in: "path",
|
||||||
|
required: true,
|
||||||
|
description: "Account identifier",
|
||||||
|
schema: { type: "string", example: "acct_123" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
post: {
|
||||||
|
tags: ["accounts"],
|
||||||
|
summary: "Create member",
|
||||||
|
operationId: "createMember",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "include",
|
||||||
|
in: "query",
|
||||||
|
description: "Related resources to include",
|
||||||
|
schema: { type: "string", enum: ["roles"] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "X-Trace-Id",
|
||||||
|
in: "header",
|
||||||
|
schema: { type: "string", example: "trace-123" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
security: [{ tokenAuth: [] }],
|
||||||
|
requestBody: {
|
||||||
|
description: "Member payload",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/MemberInput" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
"201": { description: "Created" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
tokenAuth: { type: "http", scheme: "bearer" },
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
MemberInput: {
|
||||||
|
type: "object",
|
||||||
|
required: ["email"],
|
||||||
|
properties: {
|
||||||
|
email: { type: "string", example: "me@example.com" },
|
||||||
|
admin: { type: "boolean", default: false },
|
||||||
|
primaryContact: { $ref: "#/components/schemas/Contact" },
|
||||||
|
secondaryContact: { $ref: "#/components/schemas/Contact" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Contact: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { type: "string", example: "Taylor" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.folders).toEqual([
|
||||||
|
expect.objectContaining({ name: "accounts", description: "Account operations" }),
|
||||||
|
]);
|
||||||
|
expect(imported?.resources.environments).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Global Variables",
|
||||||
|
variables: [{ name: "baseUrl", value: "https://api.example.com/v1" }],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(imported?.resources.httpRequests).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Create member",
|
||||||
|
method: "POST",
|
||||||
|
url: "${[baseUrl]}/accounts/:accountId/members",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: { token: "", prefix: "Bearer" },
|
||||||
|
bodyType: "application/json",
|
||||||
|
body: {
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
email: "me@example.com",
|
||||||
|
admin: false,
|
||||||
|
primaryContact: { name: "Taylor" },
|
||||||
|
secondaryContact: { name: "Taylor" },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
headers: expect.arrayContaining([
|
||||||
|
{ enabled: false, name: "X-Trace-Id", value: "trace-123" },
|
||||||
|
{ enabled: true, name: "Content-Type", value: "application/json" },
|
||||||
|
]),
|
||||||
|
urlParameters: [
|
||||||
|
{ enabled: true, name: ":accountId", value: "acct_123" },
|
||||||
|
{ enabled: false, name: "include", value: "roles" },
|
||||||
|
],
|
||||||
|
description: expect.stringContaining("Operation ID: createMember"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(imported?.resources.httpRequests[0]?.description).toContain("Member payload");
|
||||||
|
expect(imported?.resources.httpRequests[0]?.description).toContain("201: Created");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Handles large schemas without the Postman converter path", async () => {
|
||||||
|
const paths: Record<string, unknown> = {};
|
||||||
|
for (let i = 0; i < 500; i++) {
|
||||||
|
paths[`/zones/{zoneId}/resources/${i}`] = {
|
||||||
|
get: {
|
||||||
|
tags: ["zones"],
|
||||||
|
summary: `Read resource ${i}`,
|
||||||
|
parameters: [
|
||||||
|
{ name: "zoneId", in: "path", required: true, schema: { type: "string" } },
|
||||||
|
{ name: "page", in: "query", schema: { type: "integer", default: 1 } },
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
"200": {
|
||||||
|
description: "OK",
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: { $ref: "#/components/schemas/Resource" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = await convertOpenApi(
|
||||||
|
JSON.stringify({
|
||||||
|
openapi: "3.1.0",
|
||||||
|
info: { title: "Large API", version: "1.0.0" },
|
||||||
|
servers: [{ url: "https://api.example.com/client/v4" }],
|
||||||
|
tags: [{ name: "zones" }],
|
||||||
|
paths,
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
Resource: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
name: { type: "string" },
|
||||||
|
metadata: { $ref: "#/components/schemas/Metadata" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Metadata: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
createdOn: { type: "string", format: "date-time" },
|
||||||
|
tags: { type: "array", items: { type: "string" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(imported?.resources.httpRequests.length).toBe(500);
|
||||||
|
expect(imported?.resources.httpRequests[499]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
name: "Read resource 499",
|
||||||
|
url: "${[baseUrl]}/zones/:zoneId/resources/499",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(imported?.resources.environments).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
variables: [{ name: "baseUrl", value: "https://api.example.com/client/v4" }],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -46,7 +240,15 @@ describe("importer-openapi", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
expect(imported?.resources.httpRequests.length).toBe(19);
|
expect(imported?.resources.httpRequests.length).toBe(19);
|
||||||
expect(imported?.resources.folders.length).toBe(7);
|
expect(imported?.resources.folders.map((f) => f.name)).toEqual(["pet", "store", "user"]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fixture of realWorldFixtures) {
|
||||||
|
test(`Snapshots real-world fixture ${fixture}`, async () => {
|
||||||
|
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
|
||||||
|
const imported = await convertOpenApi(contents);
|
||||||
|
expect(imported).toMatchSnapshot();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import * as fs from "node:fs";
|
||||||
|
import * as path from "node:path";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { convertOpenApiWithPostman } from "../src/legacy";
|
||||||
|
|
||||||
|
describe("importer-openapi legacy converter", () => {
|
||||||
|
const realWorldFixturesPath = path.join(__dirname, "fixtures", "real-world");
|
||||||
|
const realWorldFixtures = fs
|
||||||
|
.readdirSync(realWorldFixturesPath)
|
||||||
|
.filter((fixture) => fixture.endsWith(".yaml"));
|
||||||
|
|
||||||
|
for (const fixture of realWorldFixtures) {
|
||||||
|
test(`Snapshots legacy Postman-converter output for ${fixture}`, async () => {
|
||||||
|
const contents = fs.readFileSync(path.join(realWorldFixturesPath, fixture), "utf-8");
|
||||||
|
const imported = await convertOpenApiWithPostman(contents);
|
||||||
|
expect(imported).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -180,12 +180,8 @@ function convertUrl(rawUrl: unknown): Pick<HttpRequest, "url" | "urlParameters">
|
|||||||
v += `:${url.port}`;
|
v += `:${url.port}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("path" in url) {
|
if ("path" in url && Array.isArray(url.path) && url.path.length > 0) {
|
||||||
if (Array.isArray(url.path) && url.path.length > 0) {
|
v += `/${Array.isArray(url.path) ? url.path.join("/") : url.path}`;
|
||||||
v += `/${url.path.join("/")}`;
|
|
||||||
} else if (typeof url.path === "string" && url.path.length > 0) {
|
|
||||||
v += `/${url.path.replace(/^\//, "")}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const params: HttpUrlParameter[] = [];
|
const params: HttpUrlParameter[] = [];
|
||||||
|
|||||||
@@ -57,34 +57,4 @@ describe("importer-postman", () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Imports url.path when it is a string instead of an array", () => {
|
|
||||||
const result = convertPostman(
|
|
||||||
JSON.stringify({
|
|
||||||
info: {
|
|
||||||
name: "String Path Test",
|
|
||||||
schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
|
||||||
},
|
|
||||||
item: [
|
|
||||||
{
|
|
||||||
name: "String Path",
|
|
||||||
request: {
|
|
||||||
method: "GET",
|
|
||||||
url: {
|
|
||||||
host: ["example", "com"],
|
|
||||||
path: "foo/bar",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result?.resources.httpRequests).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
name: "String Path",
|
|
||||||
url: "example.com/foo/bar",
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,9 +69,6 @@ const config = JSON.stringify({
|
|||||||
const normalizedAdditionalArgs = [];
|
const normalizedAdditionalArgs = [];
|
||||||
for (let i = 0; i < additionalArgs.length; i++) {
|
for (let i = 0; i < additionalArgs.length; i++) {
|
||||||
const arg = additionalArgs[i];
|
const arg = additionalArgs[i];
|
||||||
if (arg === "--") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg === "--config" && i + 1 < additionalArgs.length) {
|
if (arg === "--config" && i + 1 < additionalArgs.length) {
|
||||||
const value = additionalArgs[i + 1];
|
const value = additionalArgs[i + 1];
|
||||||
const isInlineJson = value.trimStart().startsWith("{");
|
const isInlineJson = value.trimStart().startsWith("{");
|
||||||
|
|||||||
Reference in New Issue
Block a user