diff --git a/apps/ui/css/openapi-import.css b/apps/ui/css/openapi-import.css
new file mode 100644
index 0000000..b1da85a
--- /dev/null
+++ b/apps/ui/css/openapi-import.css
@@ -0,0 +1,449 @@
+.page-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.openapi-import-modal[hidden] {
+ display: none;
+}
+
+.openapi-import-modal {
+ position: fixed;
+ inset: 0;
+ z-index: 1200;
+}
+
+.openapi-import-backdrop {
+ position: absolute;
+ inset: 0;
+ background: rgba(15, 23, 42, 0.52);
+ backdrop-filter: blur(5px);
+}
+
+.openapi-import-dialog {
+ position: relative;
+ width: min(1040px, calc(100vw - 32px));
+ max-height: min(860px, calc(100vh - 32px));
+ margin: 16px auto;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 22px;
+ background: var(--surface);
+ box-shadow: 0 24px 80px rgba(15, 23, 42, 0.28);
+}
+
+.openapi-import-header {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ padding: 22px 24px;
+ border-bottom: 1px solid var(--border);
+}
+
+.openapi-import-header h2 {
+ margin: 0 0 6px;
+ font-size: 22px;
+}
+
+.openapi-import-header p {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 14px;
+}
+
+.openapi-import-body {
+ overflow: auto;
+ padding: 20px 24px 24px;
+}
+
+.openapi-import-upload {
+ display: grid;
+ gap: 12px;
+}
+
+.openapi-file-label {
+ display: grid;
+ gap: 8px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 600;
+}
+
+#openapi-import-document {
+ min-height: 180px;
+ resize: vertical;
+ padding: 14px;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface-muted);
+ color: var(--text-primary);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ line-height: 1.55;
+}
+
+.openapi-import-actions,
+.openapi-import-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.openapi-import-status {
+ margin-top: 14px;
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.openapi-import-status.error {
+ color: var(--red);
+}
+
+.openapi-import-preview {
+ display: grid;
+ gap: 16px;
+ margin-top: 18px;
+}
+
+.openapi-import-source,
+.openapi-import-group {
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface-muted);
+}
+
+.openapi-import-source {
+ padding: 14px 16px;
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.openapi-import-server-row {
+ display: grid;
+ gap: 8px;
+}
+
+.openapi-import-server-row label {
+ color: var(--text-secondary);
+ font-size: 13px;
+ font-weight: 700;
+}
+
+#openapi-import-server,
+#openapi-import-conflict-mode,
+#openapi-import-search,
+#openapi-import-method-filter,
+.openapi-import-server-custom {
+ max-width: 520px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--surface);
+ color: var(--text-primary);
+}
+
+.openapi-import-toolbar {
+ display: grid;
+ grid-template-columns: minmax(220px, 1fr) minmax(160px, 220px) auto;
+ gap: 12px;
+ align-items: end;
+ padding: 14px 16px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface-muted);
+}
+
+.openapi-import-filter {
+ display: grid;
+ gap: 8px;
+}
+
+.openapi-import-filter label {
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+#openapi-import-search,
+#openapi-import-method-filter {
+ width: 100%;
+ max-width: none;
+ background: var(--surface);
+}
+
+.openapi-import-bulk-actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+}
+
+.openapi-import-groups {
+ display: grid;
+ gap: 14px;
+}
+
+.openapi-import-group-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+ font-weight: 800;
+}
+
+.openapi-import-group-count {
+ margin-left: auto;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.openapi-import-group-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.openapi-import-operation {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 12px;
+ align-items: start;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--border);
+}
+
+.openapi-import-operation:last-child {
+ border-bottom: 0;
+}
+
+.openapi-import-operation-title {
+ font-weight: 800;
+}
+
+.openapi-import-operation-meta {
+ margin-top: 4px;
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.openapi-import-method {
+ padding: 4px 8px;
+ border-radius: 999px;
+ background: var(--accent-muted);
+ color: var(--accent);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.openapi-import-findings {
+ grid-column: 2 / -1;
+ display: grid;
+ gap: 4px;
+ font-size: 12px;
+}
+
+.openapi-import-finding {
+ display: inline-flex;
+ align-items: baseline;
+ gap: 6px;
+ color: var(--amber);
+ line-height: 1.45;
+}
+
+.openapi-import-finding-info {
+ color: var(--blue);
+}
+
+.openapi-import-finding-error {
+ color: var(--red);
+}
+
+.openapi-import-finding strong {
+ font-weight: 900;
+}
+
+.openapi-import-mapping-preview {
+ grid-column: 2 / -1;
+ display: grid;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid var(--border);
+ border-radius: 12px;
+ background: var(--surface);
+}
+
+.openapi-import-mapping-group {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.openapi-import-mapping-label {
+ min-width: 52px;
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.openapi-import-mapping-chip {
+ padding: 3px 7px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--surface-muted);
+ color: var(--text-primary);
+ font-size: 11px;
+}
+
+.openapi-import-mapping-more {
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 800;
+}
+
+.openapi-import-document-findings,
+.openapi-import-result {
+ display: grid;
+ gap: 8px;
+ padding: 14px 16px;
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ background: var(--surface-muted);
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.openapi-import-result strong {
+ color: var(--text-primary);
+}
+
+.openapi-import-primary-result {
+ display: flex;
+ justify-content: flex-start;
+ margin: 4px 0;
+}
+
+.openapi-import-primary-result .btn-primary {
+ text-decoration: none;
+}
+
+.openapi-import-result-table {
+ display: grid;
+ gap: 0;
+ overflow: hidden;
+ border: 1px solid var(--border);
+ border-radius: 14px;
+ background: var(--surface);
+}
+
+.openapi-import-result-row {
+ display: grid;
+ grid-template-columns: minmax(180px, 0.9fr) minmax(260px, 1.5fr) auto;
+ gap: 12px;
+ align-items: start;
+ padding: 12px 14px;
+ border-bottom: 1px solid var(--border);
+}
+
+.openapi-import-result-row:last-child {
+ border-bottom: 0;
+}
+
+.openapi-import-result-head {
+ color: var(--text-secondary);
+ font-size: 11px;
+ font-weight: 900;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ background: var(--surface-muted);
+}
+
+.openapi-import-result-name {
+ color: var(--text-primary);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.openapi-import-result-meta {
+ margin-top: 4px;
+ color: var(--text-secondary);
+ font-size: 12px;
+}
+
+.openapi-import-result-findings {
+ display: grid;
+ gap: 5px;
+}
+
+.openapi-import-result-ok {
+ color: var(--green);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.openapi-import-result-more {
+ color: var(--text-secondary);
+ font-size: 12px;
+ font-weight: 800;
+}
+
+.openapi-import-result-action {
+ white-space: nowrap;
+ text-decoration: none;
+}
+
+.openapi-import-result-skipped {
+ color: var(--text-secondary);
+ font-size: 13px;
+}
+
+.openapi-import-created-list {
+ display: grid;
+ gap: 6px;
+ margin: 4px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.openapi-import-created-list a {
+ color: var(--accent);
+ font-weight: 700;
+ text-decoration: none;
+}
+
+@media (max-width: 720px) {
+ .openapi-import-toolbar {
+ grid-template-columns: 1fr;
+ }
+
+ .openapi-import-bulk-actions {
+ justify-content: flex-start;
+ }
+
+ .openapi-import-operation {
+ grid-template-columns: auto 1fr;
+ }
+
+ .openapi-import-result-row {
+ grid-template-columns: 1fr;
+ }
+
+ .openapi-import-method {
+ justify-self: start;
+ }
+}
diff --git a/apps/ui/index.html b/apps/ui/index.html
index 5e0d71a..2d46d87 100644
--- a/apps/ui/index.html
+++ b/apps/ui/index.html
@@ -9,6 +9,7 @@
+
@@ -88,10 +89,16 @@
Operations
Catalog of tools. List of created MCP tools for API endpoints.
-
+
@@ -367,6 +374,75 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js
index 6332141..d9466cc 100644
--- a/apps/ui/js/api.js
+++ b/apps/ui/js/api.js
@@ -244,6 +244,14 @@
}
);
},
+ previewOpenApiImport: function(workspaceId, documentText) {
+ return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', {
+ document: documentText,
+ });
+ },
+ createOpenApiImport: function(workspaceId, jobId, payload) {
+ return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/' + encodeURIComponent(jobId) + '/create', payload);
+ },
listAgents: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents');
},
diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js
index fe6fc34..8c43e8d 100644
--- a/apps/ui/js/catalog.js
+++ b/apps/ui/js/catalog.js
@@ -506,6 +506,15 @@ document.addEventListener('alpine:init', function() {
window.location.href = (window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/';
},
+ handleOpenApiImport() {
+ if (window.CrankOpenApiImport) {
+ window.CrankOpenApiImport.open({
+ workspaceId: this.workspaceId,
+ onImported: this.reload.bind(this),
+ });
+ }
+ },
+
handleLogout() {
window.CrankAuth.logout();
},
diff --git a/apps/ui/js/openapi-import.js b/apps/ui/js/openapi-import.js
new file mode 100644
index 0000000..2267e55
--- /dev/null
+++ b/apps/ui/js/openapi-import.js
@@ -0,0 +1,554 @@
+(function() {
+ var state = {
+ workspaceId: null,
+ onImported: null,
+ jobId: null,
+ preview: null,
+ filterQuery: '',
+ filterMethod: '',
+ };
+
+ function qs(id) {
+ return document.getElementById(id);
+ }
+
+ function setStatus(message, isError) {
+ var node = qs('openapi-import-status');
+ if (!node) return;
+ node.textContent = message || '';
+ node.classList.toggle('error', !!isError);
+ }
+
+ function selectedKeys() {
+ return Array.from(document.querySelectorAll('[data-openapi-operation]:checked'))
+ .map(function(input) { return input.value; });
+ }
+
+ function operationRows() {
+ return Array.from(document.querySelectorAll('.openapi-import-operation'));
+ }
+
+ function visibleOperationRows() {
+ return operationRows().filter(function(row) { return !row.hidden; });
+ }
+
+ function updateSelection() {
+ var node = qs('openapi-import-selection');
+ var selectedCount = selectedKeys().length;
+ var totalCount = document.querySelectorAll('[data-openapi-operation]').length;
+ var visibleCount = visibleOperationRows().length;
+ if (node) node.textContent = 'Выбрано: ' + selectedCount + ' из ' + totalCount + ', показано: ' + visibleCount;
+ updateGroupSelectionState();
+ }
+
+ function updateGroupSelectionState() {
+ document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) {
+ var rows = Array.from(groupNode.querySelectorAll('.openapi-import-operation'));
+ var visibleRows = rows.filter(function(row) { return !row.hidden; });
+ var visibleInputs = visibleRows
+ .map(function(row) { return row.querySelector('[data-openapi-operation]'); })
+ .filter(Boolean);
+ var selectedVisible = visibleInputs.filter(function(input) { return input.checked; }).length;
+ var selectedTotal = rows
+ .map(function(row) { return row.querySelector('[data-openapi-operation]'); })
+ .filter(function(input) { return input && input.checked; }).length;
+ var checkbox = groupNode.querySelector('[data-openapi-group]');
+ if (checkbox) {
+ checkbox.indeterminate = selectedVisible > 0 && selectedVisible < visibleInputs.length;
+ checkbox.checked = visibleInputs.length > 0 && selectedVisible === visibleInputs.length;
+ }
+ var counter = groupNode.querySelector('[data-openapi-group-count]');
+ if (counter) {
+ counter.textContent = 'выбрано ' + selectedTotal + ' из ' + rows.length + ', показано ' + visibleRows.length;
+ }
+ });
+ }
+
+ function applyFilters() {
+ var query = state.filterQuery.toLowerCase();
+ var method = state.filterMethod.toLowerCase();
+ document.querySelectorAll('.openapi-import-group').forEach(function(groupNode) {
+ var visibleInGroup = 0;
+ groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) {
+ var rowMethod = String(row.dataset.openapiMethod || '').toLowerCase();
+ var searchText = String(row.dataset.openapiSearch || '').toLowerCase();
+ var visible = (!method || rowMethod === method) && (!query || searchText.indexOf(query) >= 0);
+ row.hidden = !visible;
+ if (visible) visibleInGroup += 1;
+ });
+ groupNode.hidden = visibleInGroup === 0;
+ });
+ updateSelection();
+ }
+
+ function setVisibleSelection(checked) {
+ visibleOperationRows().forEach(function(row) {
+ var input = row.querySelector('[data-openapi-operation]');
+ if (input) input.checked = checked;
+ });
+ updateSelection();
+ }
+
+ function renderPreview(response) {
+ state.jobId = response.job_id;
+ state.preview = response.preview;
+ state.filterQuery = '';
+ state.filterMethod = '';
+ var preview = response.preview;
+ qs('openapi-import-preview-panel').hidden = false;
+ qs('openapi-import-result').hidden = true;
+ qs('openapi-import-result').innerHTML = '';
+ qs('openapi-import-source').textContent = [
+ preview.source.title || 'Imported API',
+ preview.source.format + (preview.source.version ? ' ' + preview.source.version : ''),
+ 'методов: ' + preview.groups.reduce(function(sum, group) { return sum + group.operations.length; }, 0),
+ ].join(' · ');
+
+ var serverSelect = qs('openapi-import-server');
+ serverSelect.innerHTML = '';
+ var servers = preview.source.servers && preview.source.servers.length ? preview.source.servers : [''];
+ servers.forEach(function(server) {
+ var option = document.createElement('option');
+ option.value = server;
+ option.textContent = server || 'Указать позже';
+ serverSelect.appendChild(option);
+ });
+
+ var searchInput = qs('openapi-import-search');
+ var methodFilter = qs('openapi-import-method-filter');
+ if (searchInput) searchInput.value = '';
+ if (methodFilter) {
+ methodFilter.innerHTML = '';
+ Array.from(new Set(preview.groups.flatMap(function(group) {
+ return group.operations.map(function(operation) { return operation.method; });
+ }))).sort().forEach(function(method) {
+ var option = document.createElement('option');
+ option.value = method;
+ option.textContent = method;
+ methodFilter.appendChild(option);
+ });
+ }
+
+ var groupsNode = qs('openapi-import-groups');
+ groupsNode.innerHTML = '';
+ if (preview.findings && preview.findings.length) {
+ var documentFindings = document.createElement('div');
+ documentFindings.className = 'openapi-import-document-findings';
+ documentFindings.innerHTML = preview.findings.map(function(finding) {
+ return renderFinding(finding);
+ }).join('');
+ groupsNode.appendChild(documentFindings);
+ }
+ preview.groups.forEach(function(group) {
+ var groupNode = document.createElement('section');
+ groupNode.className = 'openapi-import-group';
+ var header = document.createElement('div');
+ header.className = 'openapi-import-group-header';
+ header.innerHTML = '' + escapeHtml(group.title) + ' · ' + group.operations.length + ''
+ + ''
+ + '';
+ groupNode.appendChild(header);
+
+ group.operations.forEach(function(operation) {
+ var row = document.createElement('label');
+ row.className = 'openapi-import-operation';
+ row.dataset.openapiMethod = operation.method;
+ row.dataset.openapiSearch = [
+ group.title,
+ operation.key,
+ operation.suggested_name,
+ operation.suggested_display_name,
+ operation.path,
+ operation.method,
+ ].join(' ');
+ row.innerHTML = [
+ '',
+ '' + escapeHtml(operation.suggested_display_name) + '
',
+ '
' + escapeHtml(operation.suggested_name) + ' · ' + escapeHtml(operation.path) + ' · входов: ' + operation.input_fields + ' · выходов: ' + operation.output_fields + '
',
+ '' + escapeHtml(operation.method) + '',
+ ].join('');
+ row.appendChild(renderOperationMappingPreview(operation));
+ if (operation.findings && operation.findings.length) {
+ var findings = document.createElement('div');
+ findings.className = 'openapi-import-findings';
+ findings.innerHTML = operation.findings.map(function(finding) {
+ return renderFinding(finding);
+ }).join('');
+ row.appendChild(findings);
+ }
+ groupNode.appendChild(row);
+ });
+ var groupCheckbox = groupNode.querySelector('[data-openapi-group]');
+ groupCheckbox.addEventListener('change', function() {
+ groupNode.querySelectorAll('.openapi-import-operation').forEach(function(row) {
+ if (row.hidden) return;
+ var input = row.querySelector('[data-openapi-operation]');
+ if (input) input.checked = groupCheckbox.checked;
+ });
+ updateSelection();
+ });
+ groupsNode.appendChild(groupNode);
+ });
+ groupsNode.querySelectorAll('[data-openapi-operation]').forEach(function(input) {
+ input.addEventListener('change', updateSelection);
+ });
+ applyFilters();
+ }
+
+ function renderOperationMappingPreview(operation) {
+ var details = document.createElement('div');
+ details.className = 'openapi-import-mapping-preview';
+
+ var request = summarizeRequestMapping(operation);
+ [
+ ['Path', request.path],
+ ['Query', request.query],
+ ['Header', request.headers],
+ ['Body', request.body],
+ ].forEach(function(entry) {
+ appendMappingGroup(details, entry[0], entry[1]);
+ });
+ appendMappingGroup(details, 'Ответ', summarizeResponseMapping(operation));
+ return details;
+ }
+
+ function summarizeRequestMapping(operation) {
+ var result = {
+ path: [],
+ query: [],
+ headers: [],
+ body: [],
+ };
+ var rules = operation
+ && operation.draft
+ && operation.draft.input_mapping
+ && Array.isArray(operation.draft.input_mapping.rules)
+ ? operation.draft.input_mapping.rules
+ : [];
+
+ rules.forEach(function(rule) {
+ var target = String(rule.target || '');
+ if (target.indexOf('$.request.path.') === 0) {
+ result.path.push(target.replace('$.request.path.', ''));
+ } else if (target.indexOf('$.request.query.') === 0) {
+ result.query.push(target.replace('$.request.query.', ''));
+ } else if (target.indexOf('$.request.headers.') === 0) {
+ result.headers.push(target.replace('$.request.headers.', ''));
+ } else if (target.indexOf('$.request.body.') === 0) {
+ result.body.push(target.replace('$.request.body.', ''));
+ } else if (target === '$.request.body') {
+ result.body.push('body');
+ }
+ });
+ return result;
+ }
+
+ function summarizeResponseMapping(operation) {
+ var rules = operation
+ && operation.draft
+ && operation.draft.output_mapping
+ && Array.isArray(operation.draft.output_mapping.rules)
+ ? operation.draft.output_mapping.rules
+ : [];
+
+ return rules.map(function(rule) {
+ var target = String(rule.target || '').replace('$.output.', '').replace('$.output', 'result');
+ var source = String(rule.source || '').replace('$.response.body.', '').replace('$.response.body', 'body');
+ return target + ' ← ' + source;
+ });
+ }
+
+ function appendMappingGroup(root, label, values) {
+ if (!values || !values.length) return;
+ var group = document.createElement('div');
+ group.className = 'openapi-import-mapping-group';
+
+ var title = document.createElement('span');
+ title.className = 'openapi-import-mapping-label';
+ title.textContent = label;
+ group.appendChild(title);
+
+ values.slice(0, 8).forEach(function(value) {
+ var chip = document.createElement('code');
+ chip.className = 'openapi-import-mapping-chip';
+ chip.textContent = value;
+ group.appendChild(chip);
+ });
+ if (values.length > 8) {
+ var more = document.createElement('span');
+ more.className = 'openapi-import-mapping-more';
+ more.textContent = '+' + (values.length - 8);
+ group.appendChild(more);
+ }
+ root.appendChild(group);
+ }
+
+ function escapeHtml(value) {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"');
+ }
+
+ function renderFinding(finding) {
+ var severity = String(finding && finding.severity || 'warning').toLowerCase();
+ var label = severity === 'info' ? 'i' : severity === 'error' ? '×' : '!';
+ return ''
+ + '' + label + ' '
+ + escapeHtml(finding.message)
+ + '';
+ }
+
+ function previewOperationByName(name) {
+ if (!state.preview || !state.preview.groups) return null;
+ for (var groupIndex = 0; groupIndex < state.preview.groups.length; groupIndex += 1) {
+ var operations = state.preview.groups[groupIndex].operations || [];
+ for (var operationIndex = 0; operationIndex < operations.length; operationIndex += 1) {
+ if (operations[operationIndex].suggested_name === name) {
+ return operations[operationIndex];
+ }
+ }
+ }
+ return null;
+ }
+
+ function wizardHref(operationId) {
+ return ((window.CrankRoutes && window.CrankRoutes.wizard) || '/wizard/')
+ + '?mode=edit&operationId=' + encodeURIComponent(operationId);
+ }
+
+ function appendFindingNodes(root, findings) {
+ if (!findings || !findings.length) {
+ var empty = document.createElement('span');
+ empty.className = 'openapi-import-result-ok';
+ empty.textContent = 'Критичных замечаний нет';
+ root.appendChild(empty);
+ return;
+ }
+ findings.slice(0, 4).forEach(function(finding) {
+ var wrapper = document.createElement('div');
+ wrapper.innerHTML = renderFinding(finding);
+ root.appendChild(wrapper.firstElementChild);
+ });
+ if (findings.length > 4) {
+ var more = document.createElement('span');
+ more.className = 'openapi-import-result-more';
+ more.textContent = '+' + (findings.length - 4) + ' еще';
+ root.appendChild(more);
+ }
+ }
+
+ async function preview() {
+ if (!state.workspaceId) {
+ setStatus('Не выбран workspace.', true);
+ return;
+ }
+ var documentText = qs('openapi-import-document').value.trim();
+ if (!documentText) {
+ setStatus('Вставьте документ OpenAPI/Swagger или выберите файл.', true);
+ return;
+ }
+ setStatus('Разбираю документ...');
+ qs('openapi-import-preview').disabled = true;
+ try {
+ var response = await window.CrankApi.previewOpenApiImport(state.workspaceId, documentText);
+ renderPreview(response);
+ setStatus('Документ разобран. Выберите методы для импорта.');
+ } catch (error) {
+ setStatus(error.message || 'Не удалось разобрать документ.', true);
+ } finally {
+ qs('openapi-import-preview').disabled = false;
+ }
+ }
+
+ async function createDrafts() {
+ var keys = selectedKeys();
+ if (!keys.length) {
+ setStatus('Выберите хотя бы один метод.', true);
+ return;
+ }
+ if (!state.jobId) {
+ setStatus('Сначала разберите OpenAPI документ.', true);
+ return;
+ }
+ var serverUrl = (qs('openapi-import-server-custom').value || qs('openapi-import-server').value || '').trim();
+ if (!serverUrl) {
+ setStatus('Укажите base URL для создаваемых операций.', true);
+ return;
+ }
+ setStatus('Создаю черновики...');
+ qs('openapi-import-create').disabled = true;
+ try {
+ var response = await window.CrankApi.createOpenApiImport(state.workspaceId, state.jobId, {
+ selected_operation_keys: keys,
+ server_url: serverUrl,
+ conflict_mode: qs('openapi-import-conflict-mode').value || 'rename',
+ });
+ var message = 'Создано: ' + response.created.length;
+ if (response.skipped.length) message += ', пропущено: ' + response.skipped.length;
+ setStatus(message);
+ renderResult(response);
+ if (typeof state.onImported === 'function') {
+ await state.onImported();
+ }
+ } catch (error) {
+ setStatus(error.message || 'Не удалось создать черновики.', true);
+ } finally {
+ qs('openapi-import-create').disabled = false;
+ }
+ }
+
+ function renderResult(response) {
+ var node = qs('openapi-import-result');
+ if (!node) return;
+ node.replaceChildren();
+
+ var title = document.createElement('strong');
+ title.textContent = 'Результат импорта';
+ node.appendChild(title);
+
+ if (response.created && response.created.length) {
+ var firstHref = wizardHref(response.created[0].operation_id);
+ var primary = document.createElement('div');
+ primary.className = 'openapi-import-primary-result';
+ primary.innerHTML = 'Открыть первый черновик в мастере';
+ node.appendChild(primary);
+
+ var table = document.createElement('div');
+ table.className = 'openapi-import-result-table';
+ table.setAttribute('role', 'table');
+ table.innerHTML = [
+ '',
+ '
Черновик
',
+ '
Замечания
',
+ '
Действие
',
+ '
',
+ ].join('');
+
+ response.created.forEach(function(operation) {
+ var previewOperation = previewOperationByName(operation.name);
+ var row = document.createElement('div');
+ row.className = 'openapi-import-result-row';
+ row.setAttribute('role', 'row');
+
+ var nameCell = document.createElement('div');
+ nameCell.setAttribute('role', 'cell');
+ var name = document.createElement('div');
+ name.className = 'openapi-import-result-name';
+ name.textContent = operation.name;
+ var meta = document.createElement('div');
+ meta.className = 'openapi-import-result-meta';
+ meta.textContent = previewOperation
+ ? previewOperation.method + ' ' + previewOperation.path + ' · v' + operation.version
+ : 'v' + operation.version;
+ nameCell.appendChild(name);
+ nameCell.appendChild(meta);
+
+ var findingsCell = document.createElement('div');
+ findingsCell.className = 'openapi-import-result-findings';
+ findingsCell.setAttribute('role', 'cell');
+ appendFindingNodes(findingsCell, previewOperation ? previewOperation.findings : []);
+
+ var actionCell = document.createElement('div');
+ actionCell.setAttribute('role', 'cell');
+ var action = document.createElement('a');
+ action.className = 'btn-secondary openapi-import-result-action';
+ action.href = wizardHref(operation.operation_id);
+ action.textContent = previewOperation && previewOperation.findings && previewOperation.findings.length
+ ? 'Исправить в мастере'
+ : 'Открыть в мастере';
+ actionCell.appendChild(action);
+
+ row.appendChild(nameCell);
+ row.appendChild(findingsCell);
+ row.appendChild(actionCell);
+ table.appendChild(row);
+ });
+ node.appendChild(table);
+ }
+ if (response.skipped && response.skipped.length) {
+ var skipped = document.createElement('div');
+ skipped.className = 'openapi-import-result-skipped';
+ skipped.textContent = 'Пропущено: ' + response.skipped.map(function(item) {
+ return (item.name || item.operation_key) + ' — ' + item.reason;
+ }).join('; ');
+ node.appendChild(skipped);
+ }
+ if (response.findings && response.findings.length) {
+ var responseFindings = document.createElement('div');
+ responseFindings.className = 'openapi-import-result-findings';
+ response.findings.forEach(function(finding) {
+ var wrapper = document.createElement('div');
+ wrapper.innerHTML = renderFinding(finding);
+ responseFindings.appendChild(wrapper.firstElementChild);
+ });
+ node.appendChild(responseFindings);
+ }
+ node.hidden = false;
+ }
+
+ function reset() {
+ state.jobId = null;
+ state.preview = null;
+ state.filterQuery = '';
+ state.filterMethod = '';
+ qs('openapi-import-document').value = '';
+ qs('openapi-import-file').value = '';
+ qs('openapi-import-server-custom').value = '';
+ qs('openapi-import-conflict-mode').value = 'rename';
+ if (qs('openapi-import-search')) qs('openapi-import-search').value = '';
+ if (qs('openapi-import-method-filter')) qs('openapi-import-method-filter').innerHTML = '';
+ qs('openapi-import-preview-panel').hidden = true;
+ qs('openapi-import-groups').innerHTML = '';
+ qs('openapi-import-result').hidden = true;
+ qs('openapi-import-result').innerHTML = '';
+ setStatus('');
+ updateSelection();
+ }
+
+ function open(options) {
+ state.workspaceId = options.workspaceId;
+ state.onImported = options.onImported;
+ qs('openapi-import-modal').hidden = false;
+ }
+
+ function close() {
+ qs('openapi-import-modal').hidden = true;
+ }
+
+ document.addEventListener('DOMContentLoaded', function() {
+ if (!qs('openapi-import-modal')) return;
+ qs('openapi-import-preview').addEventListener('click', preview);
+ qs('openapi-import-create').addEventListener('click', createDrafts);
+ qs('openapi-import-reset').addEventListener('click', reset);
+ qs('openapi-import-search').addEventListener('input', function(event) {
+ state.filterQuery = event.target.value.trim();
+ applyFilters();
+ });
+ qs('openapi-import-method-filter').addEventListener('change', function(event) {
+ state.filterMethod = event.target.value;
+ applyFilters();
+ });
+ qs('openapi-import-select-visible').addEventListener('click', function() {
+ setVisibleSelection(true);
+ });
+ qs('openapi-import-clear-visible').addEventListener('click', function() {
+ setVisibleSelection(false);
+ });
+ qs('openapi-import-file').addEventListener('change', async function(event) {
+ var file = event.target.files && event.target.files[0];
+ if (!file) return;
+ qs('openapi-import-document').value = await file.text();
+ });
+ document.querySelectorAll('[data-openapi-close]').forEach(function(node) {
+ node.addEventListener('click', close);
+ });
+ });
+
+ window.CrankOpenApiImport = {
+ open: open,
+ close: close,
+ reset: reset,
+ };
+}());
diff --git a/apps/ui/tests/e2e/operations.spec.js b/apps/ui/tests/e2e/operations.spec.js
index 628ddd5..0a56bda 100644
--- a/apps/ui/tests/e2e/operations.spec.js
+++ b/apps/ui/tests/e2e/operations.spec.js
@@ -1,5 +1,5 @@
const { test, expect } = require('@playwright/test');
-const { login, localized } = require('./helpers');
+const { login, localized, uniqueName } = require('./helpers');
test('operations page shows demo catalog and filter works', async ({ page }) => {
await login(page);
@@ -11,3 +11,109 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
});
+
+test('operations page imports OpenAPI methods as drafts', async ({ page }) => {
+ await login(page);
+ const operationId = uniqueName('get_rates');
+ const statusOperationId = `${operationId}_status`;
+ await page.getByRole('button', { name: /Импорт OpenAPI/i }).click();
+ await expect(page.locator('#openapi-import-modal')).toBeVisible();
+ await page.locator('#openapi-import-document').fill(`
+openapi: 3.0.3
+info:
+ title: Demo Import API
+servers:
+paths:
+ /v1/rates/{date}:
+ post:
+ operationId: ${operationId}
+ summary: Получить курсы валют
+ description: Курсы.
+ tags: [currency]
+ parameters:
+ - name: date
+ in: path
+ required: true
+ schema: { type: string }
+ - name: base
+ in: query
+ required: true
+ schema: { type: string }
+ - name: X-API-Version
+ in: header
+ required: false
+ schema: { type: string }
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ required: [symbols]
+ properties:
+ symbols: { type: string }
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ base: { type: string }
+ /v1/status:
+ get:
+ operationId: ${statusOperationId}
+ summary: Проверить статус
+ tags: [service]
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ status: { type: string }
+`);
+ await page.locator('#openapi-import-preview').click();
+ await expect(page.locator('#openapi-import-preview-panel')).toBeVisible();
+ await expect(page.locator('.openapi-import-document-findings')).toContainText(/base URL/i);
+ await expect(page.locator('.openapi-import-group').filter({ hasText: 'currency' })).toBeVisible();
+ const importedOperation = page.locator('.openapi-import-operation').filter({ hasText: 'Получить курсы валют' });
+ await expect(importedOperation).toBeVisible();
+ await expect(page.locator('.openapi-import-operation').filter({ hasText: 'Проверить статус' })).toBeVisible();
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Path');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('date');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Query');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('base');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Header');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('X-API-Version');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Body');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('symbols');
+ await expect(importedOperation.locator('.openapi-import-mapping-preview')).toContainText('Ответ');
+ await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 2');
+ await page.locator('#openapi-import-search').fill('status');
+ await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
+ await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Проверить статус' })).toBeVisible();
+ await page.locator('#openapi-import-clear-visible').click();
+ await expect(page.locator('#openapi-import-selection')).toContainText('Выбрано: 1');
+ await page.locator('#openapi-import-search').fill('');
+ await page.locator('#openapi-import-method-filter').selectOption('POST');
+ await expect(page.locator('.openapi-import-operation:visible')).toHaveCount(1);
+ await expect(page.locator('.openapi-import-operation:visible').filter({ hasText: 'Получить курсы валют' })).toBeVisible();
+ await page.locator('#openapi-import-method-filter').selectOption('');
+ await page.locator('#openapi-import-server-custom').fill('https://api.example.test');
+ await page.locator('#openapi-import-create').click();
+ await expect(page.locator('#openapi-import-result')).toContainText('Результат импорта');
+ await expect(page.locator('.openapi-import-result-table')).toBeVisible();
+ await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('POST /v1/rates/{date}');
+ await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText(/Описание инструмента слишком короткое/);
+ await expect(page.locator('.openapi-import-result-row').filter({ hasText: operationId })).toContainText('Исправить в мастере');
+ await expect(page.locator('.openapi-import-primary-result a')).toHaveAttribute(
+ 'href',
+ /\/wizard\/\?mode=edit&operationId=op_/
+ );
+ await expect(page.locator('tbody')).toContainText(new RegExp(operationId, 'i'));
+ await expect(page.locator('tbody')).not.toContainText(new RegExp(statusOperationId, 'i'));
+});