diff --git a/AGENTS.md b/AGENTS.md index 927c8b57..d28a1574 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ but with: read the same source of truth the UI uses, no IPC required. - **Local mock servers**: describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- An **MCP server**: exposes the workspace as a 94-tool catalog any +- An **MCP server**: exposes the workspace as a 97-tool catalog any Model Context Protocol client (Codex Desktop, ChatGPT, Cursor, Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - A **CLI** for headless use @@ -262,7 +262,7 @@ studio/ │ ├── git/ GitHub REST client + typed error taxonomy │ ├── ui-components/ ALL React UI + the Zustand store + IndexedDB persistence │ ├── mock-server-core/ Hono mock-server engine + OpenAPI/Postman/Insomnia parsers -│ ├── mcp-server/ stdio MCP host + 94-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 97-tool catalog + workspace providers │ └── cli/ `apicircle` binary — mock / mcp / import / export / run / workspaces ├── examples/ Demo workspaces + a standalone mock-server example ├── docs/ Product + architecture + QA docs (see §9) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8d76e2..1841fba3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,107 @@ > ship. Full per-platform walk-through: > [`docs/installing.md`](docs/installing.md). -## 1.2.1 - Unreleased +## 1.3.0 - 2026-07-18 + +_All workspace packages move to **1.3.0** in lockstep — the published +`@apicircle/shared`, `@apicircle/core`, `@apicircle/mock-server-core`, +`@apicircle/mcp-server`, `@apicircle/cli`, and the `apicircle-vscode` VS Code +extension, plus the private desktop / web / ui-components / git / desktop-shell +packages. Highlights: the **spec asset hub** (upload an OpenAPI/Swagger spec +once, then serve it live or import it as a mock, import it as a collection, and +promote mock endpoints into runnable requests) and **Mock → collection parity** +across the app, the MCP server, and the VS Code extension._ ### Added +- **Mock → collection parity across the MCP server + VS Code (catalog 96 → 97).** + `mock.promote_endpoint` now produces a RUNNABLE request like the app — + ensuring the active `Mock` environment (`MOCK_BASE_URL` + `MOCK_PORT`, + prefilled from the mock's port else `8080`, existing values preserved), + dropping it in a ` (mock)` folder, and templating the URL + `{{MOCK_BASE_URL}}:{{MOCK_PORT}}` — and a new + `mock.promote_to_collection` tool promotes every endpoint at once. The VS Code + extension gains matching **Add to Collection** (mock endpoint node) + **Add + All to Collection** (mock server node) commands. All surfaces go through one + shared `buildMockPromotion` (`@apicircle/core`), so the web/desktop store, the + MCP server, and the VS Code extension stay in lockstep. + (`@apicircle/shared`, `@apicircle/core`, `@apicircle/mcp-server`, VS Code) +- **Spec-typed Global File Assets (additive).** Uploading an OpenAPI 3.x / + Swagger 2.0 document (`.json` / `.yaml` / `.yml`) into the Global Assets + **Files** library now parses it once on upload and records a `SpecAssetMeta` + summary (`dialect`, `format`, `title`, `version`, `operationCount`, + `warnings`) on the asset. The Assets panel shows a spec badge + ("OpenAPI 3 · N ops") plus a parsed summary in the file editor, and the MCP + `assets.list_files` envelope gains a `spec` field (`null` for ordinary files). + Purely additive — existing assets and non-spec files are untouched. Foundation + for spec-driven mock servers and code-vs-spec drift. + (`@apicircle/shared`, `@apicircle/mock-server-core`, `@apicircle/ui-components`, + `@apicircle/mcp-server`) +- **Mock servers from a spec asset — "run live" vs "import & edit" (additive).** + A mock server can now be built from an uploaded spec asset (`GlobalFileAsset`) + in two modes: **linked** ("run live" — endpoints derive from the asset and stay + in sync; read-only) or **materialized** ("import & edit" — parsed into editable + endpoints). New `MockServerSource` variant + `{ kind: 'openapi-asset', assetId, format, mode }` + an `isLinkedMockSource` + helper; the store resolves the asset's bytes → parses → materializes, auto- + refreshes linked mocks when the asset changes, and keeps linked mocks read-only + on every surface. New `refreshMockServer` store action + `mock.refresh` MCP tool + (catalog **94 → 95**). Two distinct entry points in the Mocks header: **New + Mock Server → From spec asset** imports a contract as editable endpoints + (materialized), and **Serve OpenAPI contract** stands up a live, read-only + server straight from a contract (linked) with a spec preview, name, and port. + The mock panel shows a "Served directly from contract" callout and a friendly + source label for a live contract server, and the endpoint editor is fully + read-only for a linked mock — mutation CTAs (Add rule, Import rule, Add param, + Add header, Add multiplier, …) are hidden (not just disabled) and their + empty-state copy drops the "click Add …" hints, every remaining native control + is disabled via `
`, the Monaco response-body editor is + read-only, and an explanatory banner says why — so a live contract mock can't + be hand-edited. A live + contract mock can be **converted to an editable mock** in place (the Mocks + kebab, the read-only banner, or the panel callout) — this flips it to a + materialized copy while keeping the spec link, so "Re-import from spec" still + works. When the contract itself changes, **Update spec…** (the Mocks kebab or + the panel callout) re-uploads the revised OpenAPI/Swagger file — replacing the + shared asset's bytes and live-refreshing the linked mock's endpoints. Long + mock-server names now truncate in the sidebar so the per-server actions menu + stays reachable. + (`@apicircle/shared`, `@apicircle/mock-server-core`, `@apicircle/ui-components`, + `@apicircle/mcp-server`, VS Code) +- **Import an OpenAPI/Swagger spec into a collection (editor, additive).** The + editor's unified Import modal now recognises OpenAPI 3.x / Swagger 2.0 + (`Auto-detect`, or the explicit "OpenAPI / Swagger" source) — paste or upload a + spec and it becomes a new folder with one request per operation (method, path, + query/header/path params). The Global Assets spec editor gains an **Import to + collection** button that imports straight from the stored asset. `ApiRequest` + gains additive `specAssetId` + `operationId` back-refs so an imported + collection knows which spec asset + operation each request came from. + (`@apicircle/shared`, `@apicircle/ui-components`) +- **Promote mock endpoints into a collection (additive).** A mock's endpoint + kebab has **Add to collection**, and the server kebab has **Add all to + collection** (the whole mock at once) — available even on read-only "run live" + mocks. Promoted requests target the live mock: they land in a + **" (mock)" folder** with a `{{MOCK_BASE_URL}}:{{MOCK_PORT}}` URL, + backed by a dedicated, activated **"Mock" environment** (`MOCK_BASE_URL` = + `http://localhost`; `MOCK_PORT` prefilled from the server's port, else `8080`; + existing values preserved on re-promote) so you can retarget host/port before + running. New `promoteMockToCollection` store action alongside + `promoteMockEndpointToRequest`; `mock.promote_endpoint` was added to the MCP + server here (catalog 95 → **96**) and later brought to full parity with the + app — the same env, folder, and templated URL, across MCP + VS Code (see the + Mock → collection parity entry above). A shared + `requestShapeFromMockEndpoint(ep, urlPrefix)` mapper keeps promoted + + OpenAPI-imported requests identical. + (`@apicircle/ui-components`, `@apicircle/mcp-server`) +- **Spec-asset usage tracking (additive).** The Global Assets usage index now + counts, per spec asset, the mock servers whose source it is (`openapi-asset`) + and the requests imported from it (`Request.specAssetId`) — so the Assets + panel's "Used in N" and the delete confirmation surface the mocks and + collections a spec backs. This completes the spec-asset hub: upload a spec + once, then run/import it as a mock, import it as a collection, promote mock + endpoints into requests, and (in the Lens edition) drift-check code against it, + all referencing the same asset. + (`@apicircle/shared`, `@apicircle/ui-components`) - **UI sections/mode seam (open-core, additive).** Building on the `extraPanels` seam, the React shell (`@apicircle/ui-components`) now accepts edition-contributed top-level **sections** ("modes") via an optional `App` `sections` prop diff --git a/CLAUDE.md b/CLAUDE.md index 5d2fe04a..2cbb9390 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ but with: read the same source of truth the UI uses, no IPC required. - **Local mock servers**: describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- An **MCP server**: exposes the workspace as a 94-tool catalog any +- An **MCP server**: exposes the workspace as a 97-tool catalog any Model Context Protocol client (Claude Desktop, ChatGPT, Cursor, Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - A **CLI** for headless use @@ -310,7 +310,7 @@ studio/ │ │ (root = Node + swagger-parser; `/parsing` subpath = │ │ browser-safe, in-document `$ref` only — used by the │ │ web/desktop renderer to materialize endpoints at import) -│ ├── mcp-server/ stdio MCP host + 94-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 97-tool catalog + workspace providers │ ├── desktop-shell/ Reusable Electron main-process building blocks — │ │ OS-keychain secrets, mock / MCP / workspace-file IPC │ │ bridges, OAuth2 callback server, window-state. Composed diff --git a/README.md b/README.md index ff382f5c..b4cb6eba 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ rebuilt around two ideas the others miss: branch. Teams collaborate the way they collaborate on code — branches, diffs, pull requests, review. 2. **Your workspace is an AI tool catalog.** A built-in Model Context Protocol - server exposes **94 tools**, so Claude, ChatGPT, Cursor, Copilot, and any + server exposes **97 tools**, so Claude, ChatGPT, Cursor, Copilot, and any other MCP client can read, author, and run requests on your behalf. No cloud account. No vendor lock-in. Your data stays on your machine and in @@ -80,7 +80,7 @@ The bundled `@apicircle/mcp-server` speaks the open [Model Context Protocol](https://modelcontextprotocol.io) over stdio, so it works with **Claude Desktop, Claude Code, ChatGPT, GitHub Copilot, Cursor, Continue, Cline, Zed, and Windsurf** — or anything else that talks MCP. The -94-tool catalog covers request and folder CRUD, environment authoring, +97-tool catalog covers request and folder CRUD, environment authoring, assertions, execution plans, history, mock-server lifecycle, the release ledger (publish / deprecate / withdraw), linked-workspace config (list / pin / scope / unlink), codebase scanning, imports, code generation, and @@ -437,7 +437,7 @@ packages/ shared/ Types, generateId, validators, encryption helpers git/ GitHub API client + sync logic mock-server-core/ Hono mock-server engine + OpenAPI/Postman/Insomnia parsers - mcp-server/ stdio MCP host with the 94-tool catalog + mcp-server/ stdio MCP host with the 97-tool catalog cli/ `apicircle` binary — mock / mocks / mcp / import / export / run / workspaces ``` diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f335f411..67d16b38 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/desktop", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "Electron shell that hosts the API Circle Studio UI with native OS-keychain secret storage.", "homepage": "https://github.com/apicircle/studio", diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore index 92f72263..7e43d245 100644 --- a/apps/vscode/.vscodeignore +++ b/apps/vscode/.vscodeignore @@ -3,6 +3,8 @@ src/** test/** out/** +coverage/** +.turbo/** node_modules/** .gitignore .eslintrc* diff --git a/apps/vscode/CHANGELOG.md b/apps/vscode/CHANGELOG.md index 588b9d53..f8a8cb44 100644 --- a/apps/vscode/CHANGELOG.md +++ b/apps/vscode/CHANGELOG.md @@ -9,6 +9,21 @@ extension version lives in `package.json`. > repository-root [`CHANGELOG.md`](../../CHANGELOG.md). This file is the > extension-focused subset that ships inside the `.vsix`. +## 1.3.0 - 2026-07-18 + +### Added + +- **Promote mock endpoints into a collection.** A mock server's endpoint node + now has an **Add to Collection** action, and the server node has **Add All to + Collection** — promote one endpoint or the whole mock at once. Promoted + requests land in a ` (mock)` folder with a + `{{MOCK_BASE_URL}}:{{MOCK_PORT}}` URL, backed by a dedicated, activated + **Mock** environment (`MOCK_BASE_URL` + `MOCK_PORT`, prefilled from the mock's + port else `8080`) — so the request is runnable and easy to retarget. Behaves + identically to the Desktop/Web app and the MCP server (`mock.promote_endpoint` + / `mock.promote_to_collection`); all three go through one shared builder in + `@apicircle/core`. + ## 1.1.4 - 2026-06-22 ### Added diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 37f4f9a5..bf7ac053 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -47,7 +47,7 @@ VS Code is where the same engineers who use API Circle already live with Git. Ed ### MCP integration -- **94-tool MCP catalog** for AI clients — Claude Desktop, Claude Code, Codex, Cursor, Copilot, Windsurf, Zed, Continue, Cline. +- **97-tool MCP catalog** for AI clients — Claude Desktop, Claude Code, Codex, Cursor, Copilot, Windsurf, Zed, Continue, Cline. - **One-click Copilot Chat install** — writes `.vscode/mcp.json` idempotently. - **Per-client config snippets** — copy or direct-install MCP configuration. - **Curated prompts** — 19 starter prompts across 7 categories in the MCP sidebar. diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 30ca1518..fff1ba56 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -2,7 +2,7 @@ "name": "apicircle-vscode", "displayName": "API Circle Studio", "description": "Git-backed API workspace with mocks, plans, and MCP — natively in VS Code.", - "version": "1.2.0", + "version": "1.3.0", "publisher": "apicircle", "private": false, "license": "SEE LICENSE IN LICENSE", @@ -1301,6 +1301,18 @@ "category": "API Circle", "icon": "$(clippy)" }, + { + "command": "apicircle.addAllToCollection", + "title": "Add All to Collection", + "category": "API Circle", + "icon": "$(references)" + }, + { + "command": "apicircle.addEndpointToCollection", + "title": "Add to Collection", + "category": "API Circle", + "icon": "$(add)" + }, { "command": "apicircle.revealEndpointInMockYaml", "title": "Reveal Endpoint in Mock YAML", @@ -2013,6 +2025,11 @@ "when": "view == apicircle.mock && viewItem =~ /^mock-(idle|running)$/", "group": "1_actions@3" }, + { + "command": "apicircle.addAllToCollection", + "when": "view == apicircle.mock && viewItem =~ /^mock-(idle|running)$/", + "group": "1_actions@4" + }, { "command": "apicircle.deleteMock", "when": "view == apicircle.mock && viewItem =~ /^mock-(idle|running)$/", @@ -2043,6 +2060,11 @@ "when": "view == apicircle.mock && viewItem == mock-endpoint", "group": "1_actions@3" }, + { + "command": "apicircle.addEndpointToCollection", + "when": "view == apicircle.mock && viewItem == mock-endpoint", + "group": "1_actions@4" + }, { "command": "apicircle.revealEndpointInMockYaml", "when": "view == apicircle.mock && viewItem == mock-endpoint", diff --git a/apps/vscode/src/commands/promoteMockActions.test.ts b/apps/vscode/src/commands/promoteMockActions.test.ts new file mode 100644 index 00000000..b682be11 --- /dev/null +++ b/apps/vscode/src/commands/promoteMockActions.test.ts @@ -0,0 +1,166 @@ +import type { Mock } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { MockServer } from '@apicircle/shared'; +import { Uri, window } from '../../test/mocks/vscode'; +import { VsCodeBridge } from '../host/vscodeBridge'; +import { VsCodeMockController } from '../host/vscodeMockController'; +import { addAllToCollectionCommand, addEndpointToCollectionCommand } from './promoteMockActions'; + +vi.mock('@apicircle/mcp-server', () => ({ + InProcessMockController: class { + async start() { + return { port: 3000, pid: 42, startedAt: '2026-01-01T00:00:00Z' }; + } + async stop() {} + async list() { + return []; + } + }, +})); + +vi.mock('@apicircle/mock-server-core', () => ({ + parseSourceToEndpoints: vi.fn().mockResolvedValue({ endpoints: [], warnings: [] }), +})); + +function makeMockContext(globalStoragePath: string) { + const state = new Map(); + return { + subscriptions: [], + globalState: { + get: (key: string, defaultValue?: T) => + state.has(key) ? (state.get(key) as T) : defaultValue, + update: async (key: string, value: unknown) => { + state.set(key, value); + }, + keys: () => Array.from(state.keys()), + }, + workspaceState: { get: () => undefined, update: async () => undefined, keys: () => [] }, + secrets: { + get: async () => undefined, + store: async () => undefined, + delete: async () => undefined, + }, + globalStorageUri: Uri.file(globalStoragePath), + storageUri: undefined, + extensionUri: Uri.file('/ext'), + extensionPath: '/ext', + asAbsolutePath: (rel: string) => path.join('/ext', rel), + extensionMode: 3, + } as never; +} + +function seed(apicircleDir: string, mock: MockServer): void { + fs.mkdirSync(apicircleDir, { recursive: true }); + fs.writeFileSync( + path.join(apicircleDir, 'workspace.json'), + JSON.stringify({ + schemaVersion: 1, + workspaceId: 'promote', + collections: { tree: { id: 'root', type: 'root', children: [] }, requests: {}, folders: {} }, + environments: { items: {}, activeName: null, priorityOrder: [] }, + linkedWorkspaces: {}, + linkedOverrides: { requests: {}, environmentVars: {} }, + releases: { self: null, perLink: {} }, + globalAssets: { schemas: {}, graphql: {}, files: {} }, + mockServers: { [mock.id]: mock }, + executionPlans: {}, + secretKeys: {}, + secretCrypto: null, + meta: { createdAt: '2026-01-01', updatedAt: '2026-01-01', appVersion: '0.1.0' }, + }), + ); +} + +function makeMockWithEndpoint(): MockServer { + return { + id: 'm1', + name: 'Pet Store', + source: { kind: 'manual', endpoints: [] }, + endpoints: [ + { + id: 'ep-1', + method: 'GET', + pathPattern: '/pets', + name: 'list pets', + requestSchema: { pathParams: [], queryParams: [], headers: [], cookies: [] }, + requestValidation: [], + responseRules: [], + defaultResponse: { status: 200, headers: [], body: { type: 'json', content: '[]' } }, + }, + ], + defaultPort: null, + cors: { enabled: false, origins: [] }, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }; +} + +describe('promoteMockActions', () => { + let tmp: string; + let bridge: VsCodeBridge; + let controller: VsCodeMockController; + let apicircleDir: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'promote-')); + apicircleDir = path.join(tmp, '.apicircle'); + seed(apicircleDir, makeMockWithEndpoint()); + bridge = new VsCodeBridge(makeMockContext(path.join(tmp, 'globalStorage'))); + bridge.registerWorkspace({ + id: apicircleDir, + apicircleDir, + workspaceJsonPath: path.join(apicircleDir, 'workspace.json'), + workspaceFolder: { uri: Uri.file(tmp), name: 't', index: 0 } as never, + label: 't', + source: 'git-folder', + }); + bridge.setActive(apicircleDir); + controller = new VsCodeMockController({ + getActiveSurface: () => bridge.activeWorkspace() ?? undefined, + }); + (window.showWarningMessage as Mock).mockReset(); + (window.showInformationMessage as Mock).mockReset(); + }); + + afterEach(() => { + bridge.dispose(); + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('addAllToCollection creates the active Mock env + " (mock)" folder + a request per endpoint', async () => { + await addAllToCollectionCommand({ bridge, controller }, { kind: 'server', id: 'm1' }); + const s = (await bridge.activeWorkspace()!.read()).synced; + expect(s.environments.activeName).toBe('Mock'); + expect(s.environments.items['Mock'].variables.find((v) => v.key === 'MOCK_PORT')?.value).toBe( + '8080', + ); + const folder = Object.values(s.collections.folders).find((f) => f.name === 'Pet Store (mock)'); + expect(folder).toBeTruthy(); + const reqs = Object.values(s.collections.requests).filter((r) => r.folderId === folder!.id); + expect(reqs).toHaveLength(1); + expect(reqs[0].url).toBe('{{MOCK_BASE_URL}}:{{MOCK_PORT}}/pets'); + }); + + it('addEndpointToCollection promotes a single endpoint into the folder', async () => { + await addEndpointToCollectionCommand( + { bridge, controller }, + { kind: 'endpoint', serverId: 'm1', endpointId: 'ep-1' }, + ); + const s = (await bridge.activeWorkspace()!.read()).synced; + const folder = Object.values(s.collections.folders).find((f) => f.name === 'Pet Store (mock)'); + expect(folder).toBeTruthy(); + const reqs = Object.values(s.collections.requests).filter((r) => r.folderId === folder!.id); + expect(reqs).toHaveLength(1); + expect(reqs[0].url).toBe('{{MOCK_BASE_URL}}:{{MOCK_PORT}}/pets'); + }); + + it('warns and does nothing when invoked without a mock node', async () => { + await addAllToCollectionCommand({ bridge, controller }); + expect(window.showWarningMessage).toHaveBeenCalled(); + const s = (await bridge.activeWorkspace()!.read()).synced; + expect(Object.keys(s.collections.requests)).toHaveLength(0); + }); +}); diff --git a/apps/vscode/src/commands/promoteMockActions.ts b/apps/vscode/src/commands/promoteMockActions.ts new file mode 100644 index 00000000..b31d7a54 --- /dev/null +++ b/apps/vscode/src/commands/promoteMockActions.ts @@ -0,0 +1,67 @@ +import * as vscode from 'vscode'; +import { buildMockPromotion } from '@apicircle/core'; +import type { MockActionsDeps } from './mockActions'; + +// Mock → collection promotion for the VS Code extension. Reuses the shared +// `buildMockPromotion` (@apicircle/core) so the extension produces the exact +// same "Mock" env + " (mock)" folder + templated requests as the +// web/desktop app and the MCP server. Applied through the canonical +// `active.apply(patch)` cycle, one WorkspacePatch at a time. + +/** `API Circle: Add all to collection` — every endpoint of a mock at once. */ +export async function addAllToCollectionCommand( + deps: MockActionsDeps, + node?: { kind: 'server' | 'mock-running' | 'mock-idle'; id: string }, +): Promise { + if (!node?.id) { + await vscode.window.showWarningMessage( + 'Run "Add all to collection" from a mock in the Mocks view.', + ); + return; + } + const active = deps.bridge.activeWorkspace(); + if (!active) { + await vscode.window.showWarningMessage('No active API Circle workspace.'); + return; + } + const state = await active.read(); + const mock = state.synced.mockServers[node.id]; + if (!mock) { + await vscode.window.showWarningMessage('Mock no longer exists.'); + return; + } + const { patches, requestIds } = buildMockPromotion(state.synced, mock, mock.endpoints); + for (const patch of patches) await active.apply(patch); + await vscode.window.showInformationMessage( + `Added ${requestIds.length} request${requestIds.length === 1 ? '' : 's'} from "${mock.name}" ` + + `into a "${mock.name} (mock)" folder (see the Mock environment for the base URL + port).`, + ); +} + +/** `API Circle: Add to collection` — a single endpoint (endpoint tree node). */ +export async function addEndpointToCollectionCommand( + deps: MockActionsDeps, + node?: { kind: 'endpoint'; serverId: string; endpointId: string }, +): Promise { + if (!node || node.kind !== 'endpoint') { + await vscode.window.showWarningMessage('Run "Add to collection" from a mock endpoint.'); + return; + } + const active = deps.bridge.activeWorkspace(); + if (!active) { + await vscode.window.showWarningMessage('No active API Circle workspace.'); + return; + } + const state = await active.read(); + const mock = state.synced.mockServers[node.serverId]; + const ep = mock?.endpoints.find((e) => e.id === node.endpointId); + if (!mock || !ep) { + await vscode.window.showWarningMessage('Mock endpoint no longer exists.'); + return; + } + const { patches } = buildMockPromotion(state.synced, mock, [ep]); + for (const patch of patches) await active.apply(patch); + await vscode.window.showInformationMessage( + `Added "${ep.name || `${ep.method} ${ep.pathPattern}`}" into a "${mock.name} (mock)" folder.`, + ); +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index df1b6ca2..dfe96672 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -171,6 +171,10 @@ import { openMockEndpointYamlCommand, setMockPortCommand, } from './commands/mockActions'; +import { + addAllToCollectionCommand, + addEndpointToCollectionCommand, +} from './commands/promoteMockActions'; import { VsCodeMockController } from './host/vscodeMockController'; import { MockCodeLensProvider } from './lang/mockCodeLens'; import { MockCompletionProvider } from './lang/mockCompletion'; @@ -1545,6 +1549,20 @@ export function activate(context: vscode.ExtensionContext): ApicircleExtensionAp return copyEndpointPathCommand({ bridge, controller: mockController }, node); }, ), + vscode.commands.registerCommand( + 'apicircle.addEndpointToCollection', + (node?: { kind: 'endpoint'; serverId: string; endpointId: string }) => { + if (!bridge || !mockController) return; + return addEndpointToCollectionCommand({ bridge, controller: mockController }, node); + }, + ), + vscode.commands.registerCommand( + 'apicircle.addAllToCollection', + (node?: { kind: 'server' | 'mock-running' | 'mock-idle'; id: string }) => { + if (!bridge || !mockController) return; + return addAllToCollectionCommand({ bridge, controller: mockController }, node); + }, + ), vscode.commands.registerCommand( 'apicircle.revealEndpointInMockYaml', (node?: { kind: 'endpoint'; serverId: string; endpointId: string }) => { diff --git a/apps/vscode/src/fs/mockYaml.ts b/apps/vscode/src/fs/mockYaml.ts index 0bfd83d3..318b014f 100644 --- a/apps/vscode/src/fs/mockYaml.ts +++ b/apps/vscode/src/fs/mockYaml.ts @@ -42,6 +42,9 @@ interface MockYamlOutput { * the human-edited YAML. */ bytes?: number; + /** For openapi-asset: the Global File Asset id + linked/materialized mode. */ + assetId?: string; + mode?: 'linked' | 'materialized'; }; endpoints: Array<{ id: string; @@ -100,6 +103,14 @@ function serializeSource(source: MockServerSource): MockYamlOutput['source'] { if (source.kind === 'postman') { return { kind: 'postman', bytes: source.collection.length }; } + if (source.kind === 'openapi-asset') { + return { + kind: 'openapi-asset', + format: source.format, + assetId: source.assetId, + mode: source.mode, + }; + } return { kind: 'insomnia', bytes: source.export.length }; } diff --git a/apps/web/package.json b/apps/web/package.json index edcb6bb4..18d69f2a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/web", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "scripts": { diff --git a/docs/connect-your-ai-client.md b/docs/connect-your-ai-client.md index db5a97cf..1bdc42aa 100644 --- a/docs/connect-your-ai-client.md +++ b/docs/connect-your-ai-client.md @@ -1,6 +1,6 @@ # Connect your AI client -API Circle Studio's MCP server exposes 94 tools (request CRUD, environment authoring, plan creation, assertions, history, mock servers — including **`mock.set_default_port`** to pin a 1024-65535 port that survives across runs — code generation from collections, codebase scanning, imports, **folder export / import as JSON**, **Global File Asset library with provenance state**, **release ledger — publish / deprecate / withdraw the versions linked consumers pin to**, **linked-workspace config — list / pin / scope / unlink the workspaces you consume**, **GitHub network ops — link / refresh / tag-release / set-topics with a `token` or `GITHUB_TOKEN`**, **marketplace discovery — `marketplace.search` with sort by stars/updated/relevance**, prompt-driven authoring) over stdio. Any AI client that speaks the [Model Context Protocol](https://modelcontextprotocol.io) can drive the workspace. +API Circle Studio's MCP server exposes 96 tools (request CRUD, environment authoring, plan creation, assertions, history, mock servers — including **`mock.set_default_port`** to pin a 1024-65535 port that survives across runs — code generation from collections, codebase scanning, imports, **folder export / import as JSON**, **Global File Asset library with provenance state**, **release ledger — publish / deprecate / withdraw the versions linked consumers pin to**, **linked-workspace config — list / pin / scope / unlink the workspaces you consume**, **GitHub network ops — link / refresh / tag-release / set-topics with a `token` or `GITHUB_TOKEN`**, **marketplace discovery — `marketplace.search` with sort by stars/updated/relevance**, prompt-driven authoring) over stdio. Any AI client that speaks the [Model Context Protocol](https://modelcontextprotocol.io) can drive the workspace. > **Open standard.** MCP is not Anthropic-locked. Claude Desktop, ChatGPT, GitHub Copilot, Cursor, Continue, Cline, Zed, and Windsurf all support it. Snippets below cover the major clients; if yours isn't listed, the _Generic stdio_ section is the fallback. diff --git a/docs/context/api-circle.md b/docs/context/api-circle.md index aaad3858..306fcb7d 100644 --- a/docs/context/api-circle.md +++ b/docs/context/api-circle.md @@ -25,7 +25,7 @@ Insomnia — with a handful of things that set it apart: external tools can read or edit the same source of truth the UI uses. - **Local mock servers.** Describe an API in OpenAPI / Postman / Insomnia and run a Hono-backed mock on `localhost`. -- **An MCP server.** The workspace is exposed as a 94-tool catalog any +- **An MCP server.** The workspace is exposed as a 97-tool catalog any Model Context Protocol client (Claude Desktop, ChatGPT, Cursor, GitHub Copilot, Codex, Continue, Cline, Zed, Windsurf) can drive. - **A CLI** (`apicircle mock | mcp | import | run | workspaces`) for @@ -64,7 +64,7 @@ studio/ │ ├── git/ GitHub REST client + typed error taxonomy │ ├── ui-components/ ALL React UI + the Zustand store + IndexedDB persistence │ ├── mock-server-core/ Hono mock engine + OpenAPI/Postman/Insomnia parsers -│ ├── mcp-server/ stdio MCP host + 94-tool catalog + workspace providers +│ ├── mcp-server/ stdio MCP host + 97-tool catalog + workspace providers │ └── cli/ `apicircle` binary — mock / mcp / import / run / workspaces ├── examples/ Demo workspaces + a standalone example mock server ├── docs/ Product + architecture + QA docs (see §16) @@ -214,7 +214,7 @@ Full matrix: [`docs/auth.md`](../auth.md). ## 7. MCP server -`@apicircle/mcp-server` exposes **94 tools** over stdio, namespaced by +`@apicircle/mcp-server` exposes **97 tools** over stdio, namespaced by capability: imports, code generation, multi-workspace discovery (`workspace.list`), workspace read/write, request / folder / environment / plan / assertion CRUD, **folder export / import as JSON** @@ -313,7 +313,7 @@ promises`, `consistent-type-imports`, `prefer-const`, `eqeqeq` are all The product surfaces are built and functional: the web + desktop apps, the 17 auth types, the mock-server engine across all three runtimes, -the 94-tool MCP server, the CLI with multi-workspace addressing, the +the 97-tool MCP server, the CLI with multi-workspace addressing, the disk-mirror persistence layer, the MCP **Connection / Prompts** panel sections, the Settings → Community surface, and the GitHub Pages web deploy. Unit tests are green. @@ -401,7 +401,7 @@ Because the product is pre-launch with zero users: | [`docs/architecture/platform.md`](../architecture/platform.md) | MCP / mock engine / CLI / desktop design record | | [`docs/auth.md`](../auth.md) | The 17-auth-type matrix | | [`docs/mock-server.md`](../mock-server.md) | Mock server feature guide | -| [`docs/mcp-tools-reference.md`](../mcp-tools-reference.md) | MCP tool catalog reference (94 tools) | +| [`docs/mcp-tools-reference.md`](../mcp-tools-reference.md) | MCP tool catalog reference (97 tools) | | [`docs/connect-your-ai-client.md`](../connect-your-ai-client.md) | Wiring an MCP client | | [`docs/installing.md`](../installing.md) | Install instructions | | [`docs/qa/README.md`](../qa/README.md) | QA status, E2E CI reference, coverage tooling | diff --git a/docs/mcp-tools-reference.md b/docs/mcp-tools-reference.md index 6197bb59..c14735e4 100644 --- a/docs/mcp-tools-reference.md +++ b/docs/mcp-tools-reference.md @@ -1,6 +1,6 @@ # MCP tool catalog reference -The `@apicircle/mcp-server` host exposes 94 tools, namespaced by capability area. The full list is canonical in [`packages/shared/src/mcp.ts`](../packages/shared/src/mcp.ts) and registered in [`packages/mcp-server/src/tools/registry.ts`](../packages/mcp-server/src/tools/registry.ts). +The `@apicircle/mcp-server` host exposes 97 tools, namespaced by capability area. The full list is canonical in [`packages/shared/src/mcp.ts`](../packages/shared/src/mcp.ts) and registered in [`packages/mcp-server/src/tools/registry.ts`](../packages/mcp-server/src/tools/registry.ts). ## Imports @@ -122,9 +122,11 @@ Each `list` entry carries the asset's **provenance state**, derived from `workin - `missing` — both refs dropped, no local copy. - `diverged` — both refs hold different blob shas (audit before pushing). +Each entry also carries `spec`: `null` for an ordinary file, or `{ dialect, format, title?, version?, operationCount, parsedAt, warnings }` when the file is a recognised OpenAPI 3.x / Swagger 2.0 document (parsed once on upload). + | Tool | Input | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `assets.list_files` | `{}` → `{ count, files: [{ id, name, filename, size, mimeType, sha256, state, workingBranchRef, baseBranchRef, usage: { requests, mockEndpoints, total } }] }` | +| `assets.list_files` | `{}` → `{ count, files: [{ id, name, filename, size, mimeType, sha256, state, spec, workingBranchRef, baseBranchRef, usage: { requests, mockEndpoints, total } }] }` | | `assets.create_file` | `{ name, description?, filename, size, mimeType?, sha256? }` — metadata only; MCP cannot carry bytes. Returns `{ id, slotId }`. Asset starts in `missing` state until the desktop / web supplies bytes on the next foreground reconciliation. | | `assets.update_file` | `{ id, patch: { name?, description? } }` — rename / re-describe. Provenance refs preserved. | | `assets.delete_file` | `{ id }` — cascade. Returns `{ found, id, filename, unbound: { requests, mockEndpoints, total } }` describing every consumer that was cleared. If the asset had any push provenance (`workingBranchRef` or `baseBranchRef`), the slotId is queued for remote-blob deletion; the next desktop push emits a `{path: '.apicircle/workspace-/attachments/', sha: null}` tree entry so the orphan blob is removed from the working branch and (after PR merge) from the base branch. Local-only assets that were never pushed skip the queue. | @@ -177,16 +179,19 @@ These tools accept LLM-shaped JSON envelopes — flat, sensible defaults, ids au Endpoint-level editing for manual-mode mock servers. Validation- and response-rule shapes mirror the `prompt.set_endpoint_*` tools above. -| Tool | Input | -| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `mock.list_endpoints` | `{ mockId }` → `[{ id, method, path, name }]` | -| `mock.add_endpoint` | `{ mockId, method, pathPattern, name?, description?, response? }` — defaults to a `200` JSON `{}` response | -| `mock.update_endpoint` | `{ mockId, endpointId, method?, pathPattern?, name?, description?, ... }` — patches only the supplied fields | -| `mock.delete_endpoint` | `{ mockId, endpointId }` | -| `mock.set_validation_rules` | `{ mockId, endpointId, rules: [{ kind, target, expected?, message?, enabled?, failResponse? }] }` — empty array clears | -| `mock.set_response_rules` | `{ mockId, endpointId, rules: [{ name, enabled?, when: [...], response }] }` — first match wins; empty array falls back to `defaultResponse` | -| `mock.set_multipliers` | `{ mockId, endpointId, multipliers: [{ source, targetJsonPath, defaultCount, min?, max? }] }` — capped at MAX_RESPONSE_MULTIPLIERS (1); empty array clears | -| `mock.set_request_schema` | `{ mockId, endpointId, pathParams?, queryParams?, headers?, cookies?: [{ name, typeHint?, required?, description?, example? }], body?: { description?, example? } }` — declares the endpoint's expected inputs (documentation + OpenAPI export); omitted lists clear | +| Tool | Input | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `mock.list_endpoints` | `{ mockId }` → `[{ id, method, path, name }]` | +| `mock.refresh` | `{ id }` — re-derive endpoints from `source` (re-parse the spec) after the spec changed. Inline sources re-parse; asset-backed (`openapi-asset`) mocks refresh in the desktop/web app. Returns `{ ok, endpointCount, warnings }`. Endpoint edits on a linked mock are rejected as read-only. | +| `mock.promote_endpoint` | `{ mockId, endpointId, folderId? }` — promote a mock endpoint into a RUNNABLE request: ensures the active `Mock` env (`MOCK_BASE_URL` + `MOCK_PORT`, prefilled from the mock's port else `8080`, existing values preserved), drops it in a ` (mock)` folder, and templates the URL `{{MOCK_BASE_URL}}:{{MOCK_PORT}}`. `folderId` nests that folder (root when omitted). Returns `{ ok, requestId, folderId, changedIds }`. Allowed on read-only linked mocks (promoting is a read). | +| `mock.promote_to_collection` | `{ mockId, folderId? }` — promote EVERY endpoint of a mock into one ` (mock)` folder at once (same `Mock` env + URL templating as `promote_endpoint`). Returns `{ ok, folderId, requestIds, changedIds }`. Allowed on read-only linked mocks. | +| `mock.add_endpoint` | `{ mockId, method, pathPattern, name?, description?, response? }` — defaults to a `200` JSON `{}` response | +| `mock.update_endpoint` | `{ mockId, endpointId, method?, pathPattern?, name?, description?, ... }` — patches only the supplied fields | +| `mock.delete_endpoint` | `{ mockId, endpointId }` | +| `mock.set_validation_rules` | `{ mockId, endpointId, rules: [{ kind, target, expected?, message?, enabled?, failResponse? }] }` — empty array clears | +| `mock.set_response_rules` | `{ mockId, endpointId, rules: [{ name, enabled?, when: [...], response }] }` — first match wins; empty array falls back to `defaultResponse` | +| `mock.set_multipliers` | `{ mockId, endpointId, multipliers: [{ source, targetJsonPath, defaultCount, min?, max? }] }` — capped at MAX_RESPONSE_MULTIPLIERS (1); empty array clears | +| `mock.set_request_schema` | `{ mockId, endpointId, pathParams?, queryParams?, headers?, cookies?: [{ name, typeHint?, required?, description?, example? }], body?: { description?, example? } }` — declares the endpoint's expected inputs (documentation + OpenAPI export); omitted lists clear | Prompt-shaped (LLM-friendly, fresh ids) authoring variants live alongside the `prompt.*` tools, including **`prompt.set_endpoint_request_schema`** (same fields, every param re-id'd). diff --git a/docs/mock-server.md b/docs/mock-server.md index 739edb59..fca0fd66 100644 --- a/docs/mock-server.md +++ b/docs/mock-server.md @@ -114,6 +114,29 @@ parsed immediately and the resulting `MockEndpoint[]` is stored on `MockServer.endpoints`. The runtime router serves that array verbatim and never re-parses `source`, so a mock created with zero endpoints stays empty. +### Spec sources: paste, or a spec asset (serve live / import) + +A spec-backed mock's `source` is either a **verbatim inline spec** +(`{ kind: 'openapi', spec, format }`, from the paste-spec flow) or a reference to +an uploaded **spec asset** (`{ kind: 'openapi-asset', assetId, format, mode }` — +upload the OpenAPI/Swagger file under Global Assets → Files). Asset-backed mocks +come in two modes, each with its own entry point in the Mocks header: + +- **`linked` (Serve OpenAPI contract)** — endpoints derive from the asset and + stay in sync: re-uploading the spec asset auto-refreshes every linked mock, and + the endpoints are read-only (edits are rejected on every surface). The dedicated + **Serve OpenAPI contract** flow picks a contract, name, and port and stands up a + live, read-only server; the mock panel shows a "Served directly from contract" + callout. Best for "just run my contract." A live contract mock can be + **converted to an editable mock** in place (`convertMockToEditable`) — this + flips the source to `materialized`, unlocking the endpoints while keeping the + spec link so "Re-import from spec" still works. When the contract itself + changes, **Update spec…** (`reuploadMockSpec`) re-uploads the revised file, + replacing the backing asset's bytes and live-refreshing the linked endpoints. +- **`materialized` (New Mock Server → From spec asset)** — the spec is parsed + once into editable endpoints you can modify; an explicit refresh + (`refreshMockServer` / the `mock.refresh` MCP tool) re-imports from the asset. + The parser ships as two entry points that differ only in how OpenAPI `$ref`s are dereferenced: diff --git a/e2e/desktop/package.json b/e2e/desktop/package.json index 891936cf..3e864b64 100644 --- a/e2e/desktop/package.json +++ b/e2e/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/e2e-desktop", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "Playwright E2E suite for the desktop (Electron) app.", "scripts": { diff --git a/e2e/mock/package.json b/e2e/mock/package.json index dc22a671..c097dc0d 100644 --- a/e2e/mock/package.json +++ b/e2e/mock/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/e2e-mock", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "description": "Localhost mock server for the Editor E2E suite. Echoes requests, issues auth challenges, and serves deterministic responses.", diff --git a/e2e/vscode/package.json b/e2e/vscode/package.json index fdb1fd2f..ed435b1e 100644 --- a/e2e/vscode/package.json +++ b/e2e/vscode/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/e2e-vscode", - "version": "1.2.0", + "version": "1.3.0", "private": true, "description": "E2E suite for the APICircle Studio VS Code extension. Launches a real VS Code instance via @vscode/test-electron and drives it with Mocha.", "type": "module", diff --git a/e2e/web/package.json b/e2e/web/package.json index 9d9d95b1..275b90d0 100644 --- a/e2e/web/package.json +++ b/e2e/web/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/e2e-web", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "description": "Playwright E2E suite for the web app.", diff --git a/examples/mock-server/package.json b/examples/mock-server/package.json index d7eea5c8..763c05ce 100644 --- a/examples/mock-server/package.json +++ b/examples/mock-server/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/example-mock-server", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "description": "Tiny Hono mock server backing the examples/demo-workspace fixture.", diff --git a/package.json b/package.json index 77d4785a..e5fa89a0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "studio", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "description": "API Circle Studio - greenfield API workspace with Git-backed sync, local mock servers, and an MCP server for AI clients", diff --git a/packages/cli/package.json b/packages/cli/package.json index 2433162d..88476f60 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/cli", - "version": "1.2.0", + "version": "1.3.0", "private": false, "type": "module", "description": "Command-line interface for API Circle Studio. Run mock servers, drive the MCP server, and import OpenAPI / Postman / Insomnia collections from any terminal.", diff --git a/packages/core/package.json b/packages/core/package.json index 566185f2..e6208097 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/core", - "version": "1.2.0", + "version": "1.3.0", "private": false, "type": "module", "sideEffects": false, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c741cd3b..3089521c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -298,6 +298,8 @@ export type { export { applyMutation } from './workspace/applyMutation'; export type { ApplyMutationOptions, ApplyMutationResult } from './workspace/applyMutation'; export type { WorkspacePatch, WorkspacePatchKind, WorkspaceState } from './workspace/patches'; +export { buildMockPromotion } from './workspace/mockPromotion'; +export type { MockPromotionOptions, MockPromotionResult } from './workspace/mockPromotion'; export { importApicircleFolderInto } from './workspace/apicircleFolderImport'; export type { ImportApicircleFolderResult } from './workspace/apicircleFolderImport'; diff --git a/packages/core/src/workspace/mockPromotion.test.ts b/packages/core/src/workspace/mockPromotion.test.ts new file mode 100644 index 00000000..713cc59d --- /dev/null +++ b/packages/core/src/workspace/mockPromotion.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import type { + MockEndpoint, + MockRequestSchema, + MockServer, + WorkspaceLocal, + WorkspaceSynced, +} from '@apicircle/shared'; +import { makeDefaultRequestSchema } from '@apicircle/shared'; +import { applyMutation } from './applyMutation'; +import type { WorkspacePatch } from './patches'; +import { buildMockPromotion } from './mockPromotion'; + +const T0 = '2026-04-27T00:00:00.000Z'; + +function makeSynced(overrides: Partial = {}): WorkspaceSynced { + return { + schemaVersion: 1, + workspaceId: 'ws-1', + collections: { tree: { id: 'root', type: 'root', children: [] }, requests: {}, folders: {} }, + environments: { items: {}, activeName: null, priorityOrder: [] }, + linkedWorkspaces: {}, + linkedOverrides: { requests: {}, environmentVars: {} }, + releases: { self: null, perLink: {} }, + globalAssets: { schemas: {}, graphql: {} }, + mockServers: {}, + meta: { createdAt: T0, updatedAt: T0, appVersion: '0.1.0' }, + ...overrides, + }; +} + +function makeLocal(): WorkspaceLocal { + return { + schemaVersion: 1, + workspaceId: 'ws-1', + executionPlans: {}, + history: { requestRuns: [], planRuns: [] }, + secretIndex: { entries: {} }, + sessions: { github: { workspace: null, links: {} } }, + connectedRepo: null, + workingBranch: null, + seededWorkspaceSha: null, + retiredBranch: null, + sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] }, + linkedCollections: {}, + globalContext: {}, + mockRuntime: { active: {} }, + ui: { + activeRequestId: null, + sidebarExpandedSections: [], + themeId: 'studio-dark', + fontId: 'system-mono', + fontSizePercent: 100, + }, + settings: { validateOnSend: true, monacoConsumesWheel: false }, + snapshots: { entries: [], maxBytes: 50 * 1024 * 1024 }, + }; +} + +function makeEndpoint( + id: string, + method: MockEndpoint['method'], + path: string, + schema: Partial = {}, +): MockEndpoint { + return { + id, + name: '', + method, + pathPattern: path, + requestSchema: { ...makeDefaultRequestSchema(), ...schema }, + requestValidation: [], + responseRules: [], + defaultResponse: { status: 200, headers: [], body: { type: 'json', content: '{}' } }, + }; +} + +function makeMock(overrides: Partial = {}): MockServer { + return { + id: 'm1', + name: 'Petstore', + source: { kind: 'manual', endpoints: [] }, + endpoints: [], + defaultPort: null, + cors: { enabled: false, origins: [] }, + createdAt: T0, + updatedAt: T0, + ...overrides, + }; +} + +/** Apply the built patch sequence and return the resulting synced doc. */ +function applyAll(synced: WorkspaceSynced, patches: WorkspacePatch[]): WorkspaceSynced { + let state = { synced, local: makeLocal() }; + for (const p of patches) state = applyMutation(state, p, { now: T0 }).next; + return state.synced; +} + +describe('buildMockPromotion', () => { + it('ensures an active "Mock" env, a " (mock)" folder, and a templated request per endpoint', () => { + const ep = makeEndpoint('e1', 'POST', '/pets/{id}', { + pathParams: [{ id: 'p1', name: 'id' }], + queryParams: [{ id: 'q1', name: 'limit' }], + headers: [{ id: 'h1', name: 'X-Key' }], + }); + const mock = makeMock({ endpoints: [ep] }); + const synced0 = makeSynced({ mockServers: { m1: mock } }); + + const { patches, folderId, requestIds, envName } = buildMockPromotion( + synced0, + mock, + mock.endpoints, + { now: T0 }, + ); + expect(envName).toBe('Mock'); + const synced = applyAll(synced0, patches); + + // Env: created, active, both vars (MOCK_PORT falls back to 8080). + expect(synced.environments.activeName).toBe('Mock'); + const env = synced.environments.items['Mock']; + expect(env.variables.find((v) => v.key === 'MOCK_BASE_URL')?.value).toBe('http://localhost'); + expect(env.variables.find((v) => v.key === 'MOCK_PORT')?.value).toBe('8080'); + + // Folder + request. + expect(synced.collections.folders[folderId].name).toBe('Petstore (mock)'); + const req = synced.collections.requests[requestIds[0]]; + expect(req.method).toBe('POST'); + expect(req.url).toBe('{{MOCK_BASE_URL}}:{{MOCK_PORT}}/pets/{id}'); + expect(req.folderId).toBe(folderId); + expect(req.query.some((q) => q.key === 'limit' && !q.enabled)).toBe(true); + expect(req.headers.some((h) => h.key === 'X-Key')).toBe(true); + expect(req.pathParams).toHaveProperty('id'); + }); + + it('prefills MOCK_PORT from the mock port and preserves an edited existing env value', () => { + const mock = makeMock({ defaultPort: 4010, endpoints: [makeEndpoint('e1', 'GET', '/pets')] }); + const synced0 = makeSynced({ + mockServers: { m1: mock }, + environments: { + items: { + Mock: { + name: 'Mock', + variables: [{ key: 'MOCK_BASE_URL', value: 'http://edited', encrypted: false }], + }, + }, + activeName: null, + priorityOrder: [], + }, + }); + const { patches } = buildMockPromotion(synced0, mock, mock.endpoints, { now: T0 }); + const env = applyAll(synced0, patches).environments.items['Mock']; + expect(env.variables.find((v) => v.key === 'MOCK_BASE_URL')?.value).toBe('http://edited'); + expect(env.variables.find((v) => v.key === 'MOCK_PORT')?.value).toBe('4010'); + }); + + it('reuses an existing " (mock)" folder instead of creating a duplicate', () => { + const mock = makeMock({ endpoints: [makeEndpoint('e1', 'GET', '/pets')] }); + const synced0 = makeSynced({ + mockServers: { m1: mock }, + collections: { + tree: { id: 'root', type: 'root', children: [{ kind: 'folder', id: 'f1' }] }, + requests: {}, + folders: { f1: { id: 'f1', name: 'Petstore (mock)', parentId: null } }, + }, + }); + const { patches, folderId } = buildMockPromotion(synced0, mock, mock.endpoints, { now: T0 }); + expect(folderId).toBe('f1'); + expect(patches.some((p) => p.kind === 'folder.create')).toBe(false); + }); +}); diff --git a/packages/core/src/workspace/mockPromotion.ts b/packages/core/src/workspace/mockPromotion.ts new file mode 100644 index 00000000..39fd5091 --- /dev/null +++ b/packages/core/src/workspace/mockPromotion.ts @@ -0,0 +1,105 @@ +// buildMockPromotion — the single source of truth for turning mock endpoints +// into runnable collection requests, shared by the web/desktop store, the MCP +// server, and the VS Code extension. It reads the current `synced` (to merge an +// existing "Mock" env + reuse an existing " (mock)" folder) and returns +// the ordered `WorkspacePatch` sequence for the caller to apply through its own +// `applyMutation` / provider path. Pure: no persistence, no IDB, no network. + +import type { + Environment, + Folder, + MockEndpoint, + MockServer, + Request as ApiRequest, + WorkspaceSynced, +} from '@apicircle/shared'; +import { + generateId, + MOCK_ENV_NAME, + mockEnvVarDefaults, + mockFolderName, + MOCK_URL_PREFIX, + requestShapeFromMockEndpoint, +} from '@apicircle/shared'; +import type { WorkspacePatch } from './patches'; + +export interface MockPromotionOptions { + /** Folder to nest the " (mock)" folder under. Defaults to the root. */ + parentFolderId?: string | null; + /** ISO timestamp stamped into created requests. Injectable for tests. */ + now?: string; +} + +export interface MockPromotionResult { + /** Apply these in order (env upsert + activate, folder create, requests). */ + patches: WorkspacePatch[]; + /** Id of the " (mock)" folder the requests land in (new or reused). */ + folderId: string; + /** Ids of the created requests, one per input endpoint, in order. */ + requestIds: string[]; + /** Name of the environment ensured (always {@link MOCK_ENV_NAME}). */ + envName: string; +} + +export function buildMockPromotion( + synced: WorkspaceSynced, + mock: MockServer, + endpoints: MockEndpoint[], + options: MockPromotionOptions = {}, +): MockPromotionResult { + const parentFolderId = options.parentFolderId ?? null; + const now = options.now ?? new Date().toISOString(); + const patches: WorkspacePatch[] = []; + + // 1) Ensure the shared "Mock" environment: merge the host/port defaults into + // any existing same-name env (preserving edited values), then activate it. + const existingEnv = synced.environments.items[MOCK_ENV_NAME]; + const variables = existingEnv ? [...existingEnv.variables] : []; + for (const [key, value] of mockEnvVarDefaults(mock.defaultPort)) { + if (!variables.some((v) => v.key === key)) variables.push({ key, value, encrypted: false }); + } + const environment: Environment = { name: MOCK_ENV_NAME, variables }; + patches.push({ kind: 'environment.upsert', environment }); + patches.push({ kind: 'environment.setActive', name: MOCK_ENV_NAME }); + + // 2) Find-or-create the " (mock)" folder under parentFolderId so + // re-promoting the same mock reuses one folder. + const folderName = mockFolderName(mock.name); + const existingFolder = Object.values(synced.collections.folders).find( + (f) => f.name === folderName && f.parentId === parentFolderId, + ); + let folderId: string; + if (existingFolder) { + folderId = existingFolder.id; + } else { + folderId = generateId(); + const folder: Folder = { id: folderId, name: folderName, parentId: parentFolderId }; + patches.push({ kind: 'folder.create', folder }); + } + + // 3) One request per endpoint, targeting the live mock via the templated URL. + const requestIds: string[] = []; + for (const ep of endpoints) { + const id = generateId(); + requestIds.push(id); + const request: ApiRequest = { + id, + name: ep.name || `${ep.method} ${ep.pathPattern}`, + folderId, + ...requestShapeFromMockEndpoint(ep, MOCK_URL_PREFIX), + cookies: [], + body: { type: 'none', content: '' }, + // `inherit` so a request picks up folder auth; resolves to none at the + // root where the mock folder has no auth. + auth: { type: 'inherit' }, + contextVars: [], + extractions: [], + assertions: [], + createdAt: now, + updatedAt: now, + }; + patches.push({ kind: 'request.create', request }); + } + + return { patches, folderId, requestIds, envName: MOCK_ENV_NAME }; +} diff --git a/packages/desktop-shell/package.json b/packages/desktop-shell/package.json index 210d42e3..1de32bc4 100644 --- a/packages/desktop-shell/package.json +++ b/packages/desktop-shell/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/desktop-shell", - "version": "1.1.5", + "version": "1.3.0", "private": true, "type": "module", "description": "Reusable Electron main-process bridges (OS-keychain secrets, mock/MCP/workspace-file IPC, OAuth2 callback server, window-state) that an edition's desktop app composes. Main-process only; the renderer contract lives in @apicircle/ui-components.", diff --git a/packages/git/package.json b/packages/git/package.json index e38fae14..0b864941 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/git", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 2765c4a5..036aa081 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -6,12 +6,12 @@

Give your AI assistant a real API client.
- A Model Context Protocol server that exposes the API Circle Studio workspace as a 94-tool catalog — so Claude, ChatGPT, Cursor, Copilot, and every other MCP client can read, author, mock, and run requests for you. + A Model Context Protocol server that exposes the API Circle Studio workspace as a 97-tool catalog — so Claude, ChatGPT, Cursor, Copilot, and every other MCP client can read, author, mock, and run requests for you.

npm version - 94 MCP tools + 97 MCP tools stdio transport Multi-workspace Node ≥ 20 @@ -140,7 +140,7 @@ In multi-workspace mode the assistant gets two extra surfaces: Entity tools (`request.read`, `environment.create`, `mock.start`, etc.) all default to the active workspace — multi-workspace scoping is opt-in per call. -## The tool catalog (94 tools) +## The tool catalog (97 tools) The full list lives at [`docs/mcp-tools-reference.md`](https://github.com/apicircle/studio/blob/main/docs/mcp-tools-reference.md). diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index 7beb728b..1ef362e8 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/mcp-server", - "version": "1.2.0", + "version": "1.3.0", "private": false, "type": "module", "sideEffects": false, diff --git a/packages/mcp-server/src/tools/globalAssets.test.ts b/packages/mcp-server/src/tools/globalAssets.test.ts index cf07f0f4..29c51a92 100644 --- a/packages/mcp-server/src/tools/globalAssets.test.ts +++ b/packages/mcp-server/src/tools/globalAssets.test.ts @@ -104,6 +104,36 @@ describe('globalAssets.files.list', () => { expect(out.files).toEqual([]); }); + it('surfaces the parsed spec summary when the asset is an OpenAPI document', async () => { + const specAsset = asset({ + id: 'spec1', + filename: 'petstore.json', + mimeType: 'application/json', + spec: { + dialect: 'openapi-3', + format: 'json', + title: 'Petstore', + version: '1.0', + operationCount: 4, + parsedAt: T0, + warnings: [], + }, + }); + setupCtx(freshState({ spec1: specAsset })); + const out = (await globalAssetsFilesListTool.handler({}, ctx)) as { + files: Array<{ spec: unknown }>; + }; + expect(out.files[0]?.spec).toMatchObject({ dialect: 'openapi-3', operationCount: 4 }); + }); + + it('returns spec: null for ordinary file assets', async () => { + setupCtx(freshState({ a1: asset({ id: 'a1' }) })); + const out = (await globalAssetsFilesListTool.handler({}, ctx)) as { + files: Array<{ spec: unknown }>; + }; + expect(out.files[0]?.spec).toBeNull(); + }); + it('derives each asset state from refs + pending bytes', async () => { const a1 = asset({ id: 'a1' }); const a2 = asset({ diff --git a/packages/mcp-server/src/tools/globalAssets.ts b/packages/mcp-server/src/tools/globalAssets.ts index b4d47ef9..4160f8ca 100644 --- a/packages/mcp-server/src/tools/globalAssets.ts +++ b/packages/mcp-server/src/tools/globalAssets.ts @@ -52,7 +52,7 @@ function deriveState( export const globalAssetsFilesListTool: AnyToolDef = { name: 'assets.list_files', description: - 'List every Global File Asset with its provenance state and reference count. Each entry includes id, name, filename, size, mimeType, sha256, state (uploading | workingOnly | merged | baseOnly | missing | diverged), workingBranchRef, baseBranchRef, and usage { requests, mockEndpoints, total }.', + 'List every Global File Asset with its provenance state and reference count. Each entry includes id, name, filename, size, mimeType, sha256, state (uploading | workingOnly | merged | baseOnly | missing | diverged), spec (present when the file is an OpenAPI/Swagger document — { dialect, format, title?, version?, operationCount, parsedAt, warnings } — otherwise null), workingBranchRef, baseBranchRef, and usage { requests, mockEndpoints, total }.', inputSchema: z.object({}).strict(), async handler(_input, ctx) { const state = await ctx.workspace.read(); @@ -71,6 +71,7 @@ export const globalAssetsFilesListTool: AnyToolDef = { mimeType: asset.mimeType, sha256: asset.sha256 ?? null, state: deriveState(asset, hasPending), + spec: asset.spec ?? null, workingBranchRef: asset.workingBranchRef ?? null, baseBranchRef: asset.baseBranchRef ?? null, usage: { ...u }, diff --git a/packages/mcp-server/src/tools/mockRefresh.test.ts b/packages/mcp-server/src/tools/mockRefresh.test.ts new file mode 100644 index 00000000..8808a3fb --- /dev/null +++ b/packages/mcp-server/src/tools/mockRefresh.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { MockServer, WorkspaceLocal, WorkspaceSynced } from '@apicircle/shared'; +import { InMemoryWorkspaceProvider } from '../providers/InMemoryWorkspaceProvider'; +import { SingleWorkspaceAdapter } from '../providers/Workspaces'; +import { InProcessMockController } from '../providers/InProcessMockController'; +import { + mockAddEndpointTool, + mockCreateFromOpenApiTool, + mockPromoteEndpointTool, + mockRefreshTool, + mockUpdateEndpointTool, +} from './mocks'; + +const T0 = '2026-04-27T00:00:00.000Z'; + +const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'P', version: '1' }, + paths: { '/pets': { get: { responses: { '200': { description: 'ok' } } } } }, +}); + +function linkedMock(id: string): MockServer { + return { + id, + name: 'Linked', + source: { kind: 'openapi-asset', assetId: 'a1', format: 'json', mode: 'linked' }, + endpoints: [ + { + id: 'e1', + name: 'GET /pets', + method: 'GET', + pathPattern: '/pets', + requestSchema: { pathParams: [], queryParams: [], headers: [], cookies: [] }, + requestValidation: [], + responseRules: [], + defaultResponse: { status: 200, headers: [], body: { type: 'json', content: '{}' } }, + }, + ], + defaultPort: null, + cors: { enabled: false, origins: [] }, + createdAt: T0, + updatedAt: T0, + }; +} + +function freshState(mocks: Record = {}): { + synced: WorkspaceSynced; + local: WorkspaceLocal; +} { + return { + synced: { + schemaVersion: 1, + workspaceId: 'ws-1', + collections: { tree: { id: 'r', type: 'root', children: [] }, requests: {}, folders: {} }, + environments: { items: {}, activeName: null, priorityOrder: [] }, + linkedWorkspaces: {}, + linkedOverrides: { requests: {}, environmentVars: {} }, + releases: { self: null, perLink: {} }, + globalAssets: { schemas: {}, graphql: {} }, + mockServers: mocks, + meta: { createdAt: T0, updatedAt: T0, appVersion: '0.1.0' }, + }, + local: { + schemaVersion: 1, + workspaceId: 'ws-1', + executionPlans: {}, + history: { requestRuns: [], planRuns: [] }, + secretIndex: { entries: {} }, + sessions: { github: { workspace: null, links: {} } }, + connectedRepo: null, + workingBranch: null, + seededWorkspaceSha: null, + retiredBranch: null, + sync: { lastPulledSnapshot: null, lastPulledSha: null, lastPulledAt: null, dirtyKeys: [] }, + linkedCollections: {}, + globalContext: {}, + mockRuntime: { active: {} }, + ui: { + activeRequestId: null, + sidebarExpandedSections: [], + themeId: 'studio-dark', + fontId: 'system-mono', + fontSizePercent: 100, + }, + settings: { validateOnSend: true, monacoConsumesWheel: false }, + snapshots: { entries: [], maxBytes: 50 * 1024 * 1024 }, + }, + }; +} + +let ctx: { + workspace: InMemoryWorkspaceProvider; + workspaces: SingleWorkspaceAdapter; + mock: InProcessMockController; +}; + +function setupCtx(state: { synced: WorkspaceSynced; local: WorkspaceLocal }) { + const workspace = new InMemoryWorkspaceProvider(state); + ctx = { + workspace, + workspaces: new SingleWorkspaceAdapter(workspace, 'ws-test'), + mock: new InProcessMockController(), + }; +} + +beforeEach(() => { + setupCtx(freshState()); +}); + +describe('mock.refresh', () => { + it('re-derives endpoints for an inline-spec mock', async () => { + const created = (await mockCreateFromOpenApiTool.handler( + { name: 'API', spec: openapi, format: 'json' }, + ctx, + )) as { id: string }; + const out = (await mockRefreshTool.handler({ id: created.id }, ctx)) as { + ok: boolean; + endpointCount: number; + }; + expect(out.ok).toBe(true); + expect(out.endpointCount).toBe(1); + }); + + it('refuses to refresh an asset-backed mock (no attachment bytes over MCP)', async () => { + setupCtx(freshState({ m1: linkedMock('m1') })); + const out = (await mockRefreshTool.handler({ id: 'm1' }, ctx)) as { + ok: boolean; + error: string; + }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/backed by a spec asset/); + }); + + it('returns not found for an unknown mock', async () => { + const out = (await mockRefreshTool.handler({ id: 'nope' }, ctx)) as { + ok: boolean; + error: string; + }; + expect(out.ok).toBe(false); + expect(out.error).toBe('mock not found'); + }); +}); + +describe('linked mocks are read-only over MCP', () => { + beforeEach(() => { + setupCtx(freshState({ m1: linkedMock('m1') })); + }); + + it('rejects mock.add_endpoint', async () => { + const out = (await mockAddEndpointTool.handler( + { mockId: 'm1', method: 'POST', pathPattern: '/x' }, + ctx, + )) as { ok: boolean; error: string }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/read-only/); + }); + + it('rejects mock.update_endpoint', async () => { + const out = (await mockUpdateEndpointTool.handler( + { mockId: 'm1', endpointId: 'e1', pathPattern: '/y' }, + ctx, + )) as { ok: boolean; error: string }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/read-only/); + }); +}); + +describe('mock.promote_endpoint', () => { + beforeEach(() => { + setupCtx(freshState({ m1: linkedMock('m1') })); + }); + + it('promotes a mock endpoint into a runnable collection request (allowed on linked mocks)', async () => { + const out = (await mockPromoteEndpointTool.handler( + { mockId: 'm1', endpointId: 'e1' }, + ctx, + )) as { ok: boolean; requestId: string; folderId: string }; + expect(out.ok).toBe(true); + const state = await ctx.workspace.read(); + const req = state.synced.collections.requests[out.requestId]; + expect(req.method).toBe('GET'); + // Promote now yields a RUNNABLE request: templated on the active "Mock" env + // (MOCK_PORT falls back to 8080 for this portless linked mock) and filed + // under a " (mock)" folder — see buildMockPromotion. + expect(req.url).toBe('{{MOCK_BASE_URL}}:{{MOCK_PORT}}/pets'); + expect(req.folderId).toBe(out.folderId); + expect(state.synced.collections.folders[out.folderId].name).toBe('Linked (mock)'); + expect(state.synced.environments.activeName).toBe('Mock'); + expect( + state.synced.environments.items['Mock'].variables.find((v) => v.key === 'MOCK_PORT')?.value, + ).toBe('8080'); + }); + + it('returns not found for a missing endpoint', async () => { + const out = (await mockPromoteEndpointTool.handler( + { mockId: 'm1', endpointId: 'nope' }, + ctx, + )) as { ok: boolean; error: string }; + expect(out.ok).toBe(false); + expect(out.error).toMatch(/not found/); + }); +}); diff --git a/packages/mcp-server/src/tools/mocks.test.ts b/packages/mcp-server/src/tools/mocks.test.ts index 511ef2f8..d7abcc2d 100644 --- a/packages/mcp-server/src/tools/mocks.test.ts +++ b/packages/mcp-server/src/tools/mocks.test.ts @@ -16,6 +16,8 @@ import { mockImportPostmanMockCollectionTool, mockListTool, mockSetDefaultPortTool, + mockPromoteEndpointTool, + mockPromoteToCollectionTool, mockStartTool, mockStopTool, } from './mocks'; @@ -130,6 +132,112 @@ describe('mock tools', () => { expect(state.synced.mockServers[out.id]).toBeDefined(); }); + it('promote_to_collection creates the active Mock env + " (mock)" folder + templated requests', async () => { + const mock: MockServer = { + id: 'm1', + name: 'Petstore', + source: { kind: 'manual', endpoints: [] }, + endpoints: [ + { + id: 'e1', + name: '', + method: 'GET', + pathPattern: '/pets', + requestSchema: { + pathParams: [], + queryParams: [{ id: 'q1', name: 'limit' }], + headers: [], + cookies: [], + }, + requestValidation: [], + responseRules: [], + defaultResponse: { status: 200, headers: [], body: { type: 'json', content: '{}' } }, + }, + { + id: 'e2', + name: '', + method: 'POST', + pathPattern: '/pets/{id}', + requestSchema: { + pathParams: [{ id: 'p1', name: 'id' }], + queryParams: [], + headers: [], + cookies: [], + }, + requestValidation: [], + responseRules: [], + defaultResponse: { status: 200, headers: [], body: { type: 'json', content: '{}' } }, + }, + ], + defaultPort: 4010, + cors: { enabled: false, origins: [] }, + createdAt: T0, + updatedAt: T0, + }; + await ctx.workspace.apply({ kind: 'mock.upsert', mock }); + + const out = (await mockPromoteToCollectionTool.handler({ mockId: 'm1' }, ctx)) as { + ok: boolean; + folderId: string; + requestIds: string[]; + }; + expect(out.ok).toBe(true); + expect(out.requestIds).toHaveLength(2); + + const s = (await ctx.workspace.read()).synced; + expect(s.environments.activeName).toBe('Mock'); + expect(s.environments.items['Mock'].variables.find((v) => v.key === 'MOCK_PORT')?.value).toBe( + '4010', + ); + expect(s.collections.folders[out.folderId].name).toBe('Petstore (mock)'); + const reqs = Object.values(s.collections.requests).filter((r) => r.folderId === out.folderId); + expect(reqs).toHaveLength(2); + expect(reqs.every((r) => r.url.startsWith('{{MOCK_BASE_URL}}:{{MOCK_PORT}}'))).toBe(true); + }); + + it('promote_endpoint templates the URL + ensures the Mock env (port falls back to 8080)', async () => { + const mock: MockServer = { + id: 'm2', + name: 'API', + source: { kind: 'manual', endpoints: [] }, + endpoints: [ + { + id: 'e1', + name: '', + method: 'GET', + pathPattern: '/health', + requestSchema: { pathParams: [], queryParams: [], headers: [], cookies: [] }, + requestValidation: [], + responseRules: [], + defaultResponse: { status: 200, headers: [], body: { type: 'json', content: '{}' } }, + }, + ], + defaultPort: null, + cors: { enabled: false, origins: [] }, + createdAt: T0, + updatedAt: T0, + }; + await ctx.workspace.apply({ kind: 'mock.upsert', mock }); + + const out = (await mockPromoteEndpointTool.handler( + { mockId: 'm2', endpointId: 'e1' }, + ctx, + )) as { + ok: boolean; + requestId: string; + folderId: string; + }; + expect(out.ok).toBe(true); + const s = (await ctx.workspace.read()).synced; + expect(s.collections.requests[out.requestId].url).toBe( + '{{MOCK_BASE_URL}}:{{MOCK_PORT}}/health', + ); + expect(s.collections.folders[out.folderId].name).toBe('API (mock)'); + expect(s.environments.items['Mock'].variables.find((v) => v.key === 'MOCK_PORT')?.value).toBe( + '8080', + ); + }); + it('create_from_postman persists a mock', async () => { const collection = JSON.stringify({ info: { name: 'X' }, diff --git a/packages/mcp-server/src/tools/mocks.ts b/packages/mcp-server/src/tools/mocks.ts index c8938a36..7290eb04 100644 --- a/packages/mcp-server/src/tools/mocks.ts +++ b/packages/mcp-server/src/tools/mocks.ts @@ -7,11 +7,13 @@ import type { } from '@apicircle/shared'; import { generateId, + isLinkedMockSource, makeDefaultMockResponse, makeDefaultRequestSchema, MAX_RESPONSE_MULTIPLIERS, } from '@apicircle/shared'; import { parseSourceToEndpoints } from '@apicircle/mock-server-core'; +import { buildMockPromotion } from '@apicircle/core'; import type { AnyToolDef } from './types'; // ============================================================================= @@ -41,6 +43,18 @@ async function ingestSource( return { mock, warnings }; } +/** Reject endpoint edits on a "run live" (linked) mock — its endpoints are + * derived from a spec asset and would be clobbered on the next refresh. */ +function linkedReadOnly(mock: MockServer): { ok: false; error: string } | null { + return isLinkedMockSource(mock.source) + ? { + ok: false, + error: + 'This mock is linked to a spec asset and is read-only. Edit the spec asset, or recreate the mock in "Import & edit" (materialized) mode.', + } + : null; +} + export const mockCreateFromOpenApiTool: AnyToolDef = { name: 'mock.create_from_openapi', description: 'Create a mock server from an OpenAPI / Swagger spec (YAML or JSON).', @@ -305,6 +319,83 @@ export const mockListEndpointsTool: AnyToolDef = { }, }; +export const mockRefreshTool: AnyToolDef = { + name: 'mock.refresh', + description: + "Re-derive a mock server's endpoints from its source (re-parse the spec) — use after the underlying spec changed. Inline sources (openapi / postman / insomnia) re-parse directly; asset-backed (openapi-asset) mocks must be refreshed from the desktop/web app, which owns the attachment bytes. Returns the new endpoint count + parser warnings.", + inputSchema: z.object({ id: z.string() }), + async handler(input, ctx) { + const state = await ctx.workspace.read(); + const mock = state.synced.mockServers[input.id]; + if (!mock) return { ok: false as const, error: 'mock not found' as const }; + if (mock.source.kind === 'openapi-asset') { + return { + ok: false as const, + error: + 'This mock is backed by a spec asset — refresh it from the desktop/web app (the MCP host cannot read attachment bytes).', + }; + } + const { endpoints, warnings } = await parseSourceToEndpoints(mock.source); + const next: MockServer = { ...mock, endpoints, updatedAt: new Date().toISOString() }; + const out = await ctx.workspace.apply({ kind: 'mock.upsert', mock: next }); + return { + ok: true as const, + endpointCount: endpoints.length, + changedIds: out.changedIds, + warnings, + }; + }, +}; + +export const mockPromoteEndpointTool: AnyToolDef = { + name: 'mock.promote_endpoint', + description: + 'Promote a mock endpoint into a RUNNABLE request. Ensures the shared "Mock" environment (MOCK_BASE_URL + MOCK_PORT, prefilled from the mock\'s port else 8080, existing values preserved), drops the request into a " (mock)" folder, and templates the URL as {{MOCK_BASE_URL}}:{{MOCK_PORT}} so it targets the live mock. `folderId` nests that folder under a parent (root when omitted). Returns the new request id + the folder id.', + inputSchema: z.object({ + mockId: z.string(), + endpointId: z.string(), + folderId: z.string().nullish(), + }), + async handler(input, ctx) { + const state = await ctx.workspace.read(); + const mock = state.synced.mockServers[input.mockId]; + const ep = mock?.endpoints.find((e) => e.id === input.endpointId); + if (!mock || !ep) return { ok: false as const, error: 'mock or endpoint not found' as const }; + const { patches, folderId, requestIds } = buildMockPromotion(state.synced, mock, [ep], { + parentFolderId: input.folderId ?? null, + }); + const changedIds: string[] = []; + for (const patch of patches) changedIds.push(...(await ctx.workspace.apply(patch)).changedIds); + return { ok: true as const, requestId: requestIds[0], folderId, changedIds }; + }, +}; + +export const mockPromoteToCollectionTool: AnyToolDef = { + name: 'mock.promote_to_collection', + description: + 'Promote EVERY endpoint of a mock into a single " (mock)" collection folder at once (not one endpoint at a time). Ensures the shared "Mock" environment (MOCK_BASE_URL + MOCK_PORT) and templates each request\'s URL as {{MOCK_BASE_URL}}:{{MOCK_PORT}} so they target the live mock. `folderId` nests the folder under a parent (root when omitted). Returns the folder id + the created request ids.', + inputSchema: z.object({ + mockId: z.string(), + folderId: z.string().nullish(), + }), + async handler(input, ctx) { + const state = await ctx.workspace.read(); + const mock = state.synced.mockServers[input.mockId]; + if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const { patches, folderId, requestIds } = buildMockPromotion( + state.synced, + mock, + mock.endpoints, + { + parentFolderId: input.folderId ?? null, + }, + ); + const changedIds: string[] = []; + for (const patch of patches) changedIds.push(...(await ctx.workspace.apply(patch)).changedIds); + return { ok: true as const, folderId, requestIds, changedIds }; + }, +}; + const ENDPOINT_RESPONSE = z.object({ status: z.number().int().min(100).max(599).default(200), jsonBody: z.string().default('{}'), @@ -358,6 +449,8 @@ export const mockAddEndpointTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const endpoint = buildDefaultEndpoint(input); const nextEndpoints = [...mock.endpoints, endpoint]; // Manual-mode mocks mirror endpoints back into source so the runtime @@ -394,6 +487,8 @@ export const mockUpdateEndpointTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const idx = mock.endpoints.findIndex((e) => e.id === input.endpointId); if (idx === -1) return { ok: false, error: 'endpoint not found' as const }; const existing = mock.endpoints[idx]; @@ -446,6 +541,8 @@ export const mockDeleteEndpointTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const nextEndpoints = mock.endpoints.filter((e) => e.id !== input.endpointId); if (nextEndpoints.length === mock.endpoints.length) { return { ok: false, error: 'endpoint not found' as const }; @@ -578,6 +675,8 @@ export const mockSetValidationRulesTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const rules: Array> = input.rules; const next = patchEndpoint(mock, input.endpointId, (e) => ({ ...e, @@ -610,6 +709,8 @@ export const mockSetResponseRulesTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const rules: Array> = input.rules; const next = patchEndpoint(mock, input.endpointId, (e) => ({ ...e, @@ -672,6 +773,8 @@ export const mockSetRequestSchemaTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const toParams = (list: Array>) => list.map((p) => ({ id: p.id ?? generateId(), @@ -710,6 +813,8 @@ export const mockSetMultipliersTool: AnyToolDef = { const state = await ctx.workspace.read(); const mock = state.synced.mockServers[input.mockId]; if (!mock) return { ok: false as const, error: 'mock not found' as const }; + const ro = linkedReadOnly(mock); + if (ro) return ro; const multipliers: Array> = input.multipliers; if (multipliers.length > MAX_RESPONSE_MULTIPLIERS) { return { ok: false as const, error: 'too many multipliers' as const }; diff --git a/packages/mcp-server/src/tools/registry.ts b/packages/mcp-server/src/tools/registry.ts index 904fc144..b836e0b8 100644 --- a/packages/mcp-server/src/tools/registry.ts +++ b/packages/mcp-server/src/tools/registry.ts @@ -79,6 +79,9 @@ import { mockCreateManualTool, mockListTool, mockListEndpointsTool, + mockRefreshTool, + mockPromoteEndpointTool, + mockPromoteToCollectionTool, mockStartTool, mockStopTool, mockDeleteTool, @@ -184,6 +187,9 @@ export const TOOL_REGISTRY: AnyToolDef[] = [ mockCreateManualTool, mockListTool, mockListEndpointsTool, + mockRefreshTool, + mockPromoteEndpointTool, + mockPromoteToCollectionTool, mockStartTool, mockStopTool, mockDeleteTool, diff --git a/packages/mock-server-core/package.json b/packages/mock-server-core/package.json index fabc2493..4a2780ca 100644 --- a/packages/mock-server-core/package.json +++ b/packages/mock-server-core/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/mock-server-core", - "version": "1.2.0", + "version": "1.3.0", "private": false, "type": "module", "sideEffects": false, diff --git a/packages/mock-server-core/src/index.ts b/packages/mock-server-core/src/index.ts index 4c5b8b33..6232daee 100644 --- a/packages/mock-server-core/src/index.ts +++ b/packages/mock-server-core/src/index.ts @@ -31,6 +31,8 @@ export { parsePostmanToEndpoints } from './parsers/postman'; export { parseInsomniaToEndpoints } from './parsers/insomnia'; export { schemaToExample } from './faker/schemaToExample'; export { dereferenceInternal } from './parsers/refDeref'; +export { summarizeSpec } from './specSummary'; +export type { SpecSummary } from './specSummary'; export type { ParseSourceResult } from './parsing'; export { getFreePort, isPortFree } from './runtime/portFinder'; export { buildRouter }; diff --git a/packages/mock-server-core/src/parsing.test.ts b/packages/mock-server-core/src/parsing.test.ts index c4e59475..cb7cb69b 100644 --- a/packages/mock-server-core/src/parsing.test.ts +++ b/packages/mock-server-core/src/parsing.test.ts @@ -82,6 +82,17 @@ describe('parseSourceToEndpoints (browser dispatch)', () => { expect(endpoints).toHaveLength(1); }); + it('warns when an unresolved openapi-asset source reaches the parser', async () => { + const { endpoints, warnings } = await parseSourceToEndpoints({ + kind: 'openapi-asset', + assetId: 'a1', + format: 'json', + mode: 'linked', + }); + expect(endpoints).toEqual([]); + expect(warnings[0]).toMatch(/openapi-asset source reached the parser unresolved/); + }); + it('returns manual endpoints verbatim', async () => { const endpoint = { id: 'e1', diff --git a/packages/mock-server-core/src/parsing.ts b/packages/mock-server-core/src/parsing.ts index b77e873b..0e05efe8 100644 --- a/packages/mock-server-core/src/parsing.ts +++ b/packages/mock-server-core/src/parsing.ts @@ -26,6 +26,8 @@ export { parseInsomniaToEndpoints } from './parsers/insomnia'; export { schemaToExample } from './faker/schemaToExample'; export type { JsonSchemaLike } from './faker/schemaToExample'; export { dereferenceInternal, type DereferenceResult } from './parsers/refDeref'; +export { summarizeSpec } from './specSummary'; +export type { SpecSummary } from './specSummary'; export interface ParseSourceResult { endpoints: MockEndpoint[]; @@ -54,6 +56,17 @@ export async function parseSourceToEndpointsWith( return parseInsomniaToEndpoints(source.export); case 'manual': return { endpoints: source.endpoints, warnings: [] }; + case 'openapi-asset': + // Asset-backed sources must be resolved to an inline OpenAPI source by + // the caller (the UI store reads the asset's bytes; the MCP host reads + // the attachment blob) before reaching the parser — this engine owns no + // asset store. Reaching here means the bytes weren't resolved. + return { + endpoints: [], + warnings: [ + 'An openapi-asset source reached the parser unresolved — resolve its bytes to an inline OpenAPI source first.', + ], + }; } } diff --git a/packages/mock-server-core/src/specSummary.test.ts b/packages/mock-server-core/src/specSummary.test.ts new file mode 100644 index 00000000..3e3e0890 --- /dev/null +++ b/packages/mock-server-core/src/specSummary.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import yaml from 'js-yaml'; +import { summarizeSpec } from './specSummary'; + +const openapiDoc = { + openapi: '3.0.3', + info: { title: 'Petstore', version: '1.2.0' }, + paths: { + '/pets': { + // `parameters` and `summary` are NOT operations and must not be counted. + parameters: [], + summary: 'pets collection', + get: { responses: { '200': { description: 'ok' } } }, + post: { responses: { '201': { description: 'created' } } }, + }, + '/pets/{id}': { + get: { responses: { '200': { description: 'ok' } } }, + delete: { responses: { '204': { description: 'gone' } } }, + }, + }, +}; + +describe('summarizeSpec', () => { + it('summarises an OpenAPI 3 JSON document', () => { + const summary = summarizeSpec(JSON.stringify(openapiDoc), 'petstore.json'); + expect(summary).toEqual({ + dialect: 'openapi-3', + format: 'json', + title: 'Petstore', + version: '1.2.0', + operationCount: 4, + warnings: [], + }); + }); + + it('summarises a Swagger 2.0 document', () => { + const swagger = { + swagger: '2.0', + info: { title: 'Legacy', version: '0.9' }, + paths: { '/health': { get: { responses: { '200': {} } } } }, + }; + const summary = summarizeSpec(JSON.stringify(swagger), 'swagger.json'); + expect(summary?.dialect).toBe('swagger-2'); + expect(summary?.operationCount).toBe(1); + expect(summary?.title).toBe('Legacy'); + }); + + it('parses YAML by filename hint', () => { + const summary = summarizeSpec(yaml.dump(openapiDoc), 'petstore.yaml'); + expect(summary?.format).toBe('yaml'); + expect(summary?.operationCount).toBe(4); + }); + + it('parses YAML by filename hint (.yml)', () => { + const summary = summarizeSpec(yaml.dump(openapiDoc), 'petstore.yml'); + expect(summary?.format).toBe('yaml'); + }); + + it('sniffs JSON from leading brace when no filename hint is given', () => { + expect(summarizeSpec(JSON.stringify(openapiDoc))?.format).toBe('json'); + }); + + it('sniffs YAML from content when no filename hint and no leading brace', () => { + expect(summarizeSpec(yaml.dump(openapiDoc))?.format).toBe('yaml'); + }); + + it('falls back to the other format when the hinted one fails', () => { + // YAML content but a .json filename → JSON.parse fails, YAML succeeds. + const summary = summarizeSpec(yaml.dump(openapiDoc), 'mislabelled.json'); + expect(summary?.format).toBe('yaml'); + expect(summary?.dialect).toBe('openapi-3'); + }); + + it('omits title/version when info is absent', () => { + const doc = { openapi: '3.1.0', paths: { '/x': { get: { responses: { '200': {} } } } } }; + const summary = summarizeSpec(JSON.stringify(doc)); + expect(summary?.title).toBeUndefined(); + expect(summary?.version).toBeUndefined(); + }); + + it('warns when the spec declares no paths', () => { + const doc = { openapi: '3.0.0', info: { title: 'Empty' } }; + const summary = summarizeSpec(JSON.stringify(doc)); + expect(summary?.operationCount).toBe(0); + expect(summary?.warnings).toContain('The spec declares no paths.'); + }); + + it('warns when paths exist but declare no operations', () => { + const doc = { openapi: '3.0.0', paths: { '/x': { parameters: [] } } }; + const summary = summarizeSpec(JSON.stringify(doc)); + expect(summary?.operationCount).toBe(0); + expect(summary?.warnings).toContain('The spec declares paths but no operations.'); + }); + + it('skips non-object path items when counting', () => { + const doc = { + openapi: '3.0.0', + paths: { '/ref': null, '/ok': { get: { responses: { '200': {} } } } }, + }; + expect(summarizeSpec(JSON.stringify(doc))?.operationCount).toBe(1); + }); + + it('returns null for a JSON document that is not a spec', () => { + expect(summarizeSpec(JSON.stringify({ foo: 1, paths: {} }))).toBeNull(); + }); + + it('returns null when openapi/swagger is present but not a string', () => { + expect(summarizeSpec(JSON.stringify({ openapi: 3, paths: {} }))).toBeNull(); + }); + + it('returns null for a non-object payload', () => { + expect(summarizeSpec(JSON.stringify('just a string'))).toBeNull(); + }); + + it('returns null for unparseable bytes', () => { + expect(summarizeSpec('{ this is neither json nor: [yaml')).toBeNull(); + }); +}); diff --git a/packages/mock-server-core/src/specSummary.ts b/packages/mock-server-core/src/specSummary.ts new file mode 100644 index 00000000..35aacedd --- /dev/null +++ b/packages/mock-server-core/src/specSummary.ts @@ -0,0 +1,100 @@ +// OpenAPI / Swagger 2.0 spec detection + summary. +// +// A lightweight, browser-safe pass over a file's text that answers "is this +// an API spec, and if so, what does it declare?" WITHOUT the full endpoint +// materialization (`parseOpenApiToEndpoints`) or `$ref` dereferencing. It is +// what a Global File Asset stores as `SpecAssetMeta` on upload, so every +// consumer — the Assets panel, the mock "run/import from spec" flows, and (in +// the Lens edition) the code-vs-spec drift check — reads one authoritative +// summary instead of re-parsing the blob. + +import yaml from 'js-yaml'; +import type { SpecAssetMeta } from '@apicircle/shared'; + +/** The eight OpenAPI Path-Item operation keys. Everything else under a path + * (`parameters`, `summary`, `$ref`, `servers`, …) is not an operation. */ +const HTTP_METHODS: ReadonlySet = new Set([ + 'get', + 'put', + 'post', + 'delete', + 'options', + 'head', + 'patch', + 'trace', +]); + +/** The spec summary minus the caller-stamped `parsedAt` (kept side-effect-free). */ +export type SpecSummary = Omit; + +function tryParse(text: string, format: 'json' | 'yaml'): unknown { + try { + return format === 'yaml' ? yaml.load(text) : JSON.parse(text); + } catch { + return undefined; + } +} + +/** Sniff JSON vs YAML from a filename hint first, then the content. */ +function sniffFormat(text: string, filename?: string): 'json' | 'yaml' { + const lower = filename?.toLowerCase() ?? ''; + if (lower.endsWith('.json')) return 'json'; + if (lower.endsWith('.yaml') || lower.endsWith('.yml')) return 'yaml'; + return text.trimStart().startsWith('{') ? 'json' : 'yaml'; +} + +/** + * Detect whether `text` is an OpenAPI 3.x / Swagger 2.0 document and, if so, + * summarise it. Returns `null` for anything that isn't a spec (a plain JSON + * file, a random upload, unparseable bytes) so the caller leaves the asset's + * `spec` field undefined. + * + * `filenameHint` disambiguates JSON vs YAML; the parser also falls back to the + * other format if the hinted one fails. Pure + synchronous — no `$ref` + * resolution, no network, no endpoint build. Operation count is the sum of + * HTTP methods across `paths`, which is exactly the presence-drift denominator + * the Lens edition compares code against. + */ +export function summarizeSpec(text: string, filenameHint?: string): SpecSummary | null { + const primary = sniffFormat(text, filenameHint); + const secondary = primary === 'json' ? 'yaml' : 'json'; + let format: 'json' | 'yaml' = primary; + let parsed = tryParse(text, primary); + if (parsed === undefined) { + parsed = tryParse(text, secondary); + format = secondary; + } + if (!parsed || typeof parsed !== 'object') return null; + + const doc = parsed as Record; + const dialect: SpecSummary['dialect'] | null = + typeof doc.openapi === 'string' + ? 'openapi-3' + : typeof doc.swagger === 'string' + ? 'swagger-2' + : null; + if (!dialect) return null; + + const info = + doc.info && typeof doc.info === 'object' ? (doc.info as Record) : {}; + const title = typeof info.title === 'string' ? info.title : undefined; + const version = typeof info.version === 'string' ? info.version : undefined; + + const warnings: string[] = []; + const paths = + doc.paths && typeof doc.paths === 'object' ? (doc.paths as Record) : undefined; + let operationCount = 0; + if (!paths || Object.keys(paths).length === 0) { + warnings.push('The spec declares no paths.'); + } else { + for (const item of Object.values(paths)) { + if (!item || typeof item !== 'object') continue; + for (const key of Object.keys(item)) { + if (HTTP_METHODS.has(key.toLowerCase())) operationCount += 1; + } + } + if (operationCount === 0) warnings.push('The spec declares paths but no operations.'); + } + + return { dialect, format, title, version, operationCount, warnings }; +} diff --git a/packages/shared/package.json b/packages/shared/package.json index 110d6479..addd6ce1 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/shared", - "version": "1.2.0", + "version": "1.3.0", "private": false, "type": "module", "sideEffects": false, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ee150978..caa0105b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -62,7 +62,15 @@ export { makeDefaultRequestSchema, MAX_RESPONSE_MULTIPLIERS, MAX_RESPONSE_RULE_CONDITIONS, + isLinkedMockSource, } from './mock'; +export { + MOCK_ENV_NAME, + MOCK_URL_PREFIX, + mockEnvVarDefaults, + mockFolderName, + requestShapeFromMockEndpoint, +} from './mockPromotion'; export type { FontFamilyId, PanelId, @@ -109,6 +117,7 @@ export type { GlobalSchema, GlobalGraphQL, GlobalFileAsset, + SpecAssetMeta, AssetGitRef, PendingFileUpload, AssetUsage, diff --git a/packages/shared/src/mcp.ts b/packages/shared/src/mcp.ts index 1b7f9dfd..16f9c5fe 100644 --- a/packages/shared/src/mcp.ts +++ b/packages/shared/src/mcp.ts @@ -101,6 +101,9 @@ export type McpToolName = | 'mock.create_manual' | 'mock.list' | 'mock.list_endpoints' + | 'mock.refresh' + | 'mock.promote_endpoint' + | 'mock.promote_to_collection' | 'mock.start' | 'mock.stop' | 'mock.delete' @@ -212,6 +215,9 @@ export const MCP_TOOL_NAMES: ReadonlyArray = [ 'mock.create_manual', 'mock.list', 'mock.list_endpoints', + 'mock.refresh', + 'mock.promote_endpoint', + 'mock.promote_to_collection', 'mock.start', 'mock.stop', 'mock.delete', diff --git a/packages/shared/src/mock.test.ts b/packages/shared/src/mock.test.ts index 13112288..6d8510ab 100644 --- a/packages/shared/src/mock.test.ts +++ b/packages/shared/src/mock.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { coerceMockResponseBodyTypeForStatus, getAllowedMockResponseBodyTypes, + isLinkedMockSource, MAX_RESPONSE_MULTIPLIERS, MAX_RESPONSE_RULE_CONDITIONS, } from './mock'; @@ -12,6 +13,7 @@ describe('MockServerSource discriminator', () => { const sources: MockServerSource[] = [ { kind: 'openapi', spec: '{}', format: 'json' }, { kind: 'openapi', spec: 'paths: {}\n', format: 'yaml' }, + { kind: 'openapi-asset', assetId: 'a1', format: 'json', mode: 'linked' }, { kind: 'postman', collection: '{"info":{}}' }, { kind: 'insomnia', export: '{"resources":[]}' }, { @@ -40,6 +42,10 @@ describe('MockServerSource discriminator', () => { expect(s.spec).toBeDefined(); expect(['json', 'yaml']).toContain(s.format); break; + case 'openapi-asset': + expect(s.assetId).toBeDefined(); + expect(['linked', 'materialized']).toContain(s.mode); + break; case 'postman': expect(s.collection).toBeDefined(); break; @@ -168,3 +174,26 @@ describe('MockRuntime', () => { expect(rt.active['m-2']).toBeUndefined(); }); }); + +describe('isLinkedMockSource', () => { + it('is true only for an openapi-asset source in linked mode', () => { + expect( + isLinkedMockSource({ kind: 'openapi-asset', assetId: 'a', format: 'json', mode: 'linked' }), + ).toBe(true); + }); + + it('is false for a materialized asset source and every inline/manual source', () => { + expect( + isLinkedMockSource({ + kind: 'openapi-asset', + assetId: 'a', + format: 'json', + mode: 'materialized', + }), + ).toBe(false); + expect(isLinkedMockSource({ kind: 'openapi', spec: '{}', format: 'json' })).toBe(false); + expect(isLinkedMockSource({ kind: 'manual', endpoints: [] })).toBe(false); + expect(isLinkedMockSource({ kind: 'postman', collection: '{}' })).toBe(false); + expect(isLinkedMockSource({ kind: 'insomnia', export: '{}' })).toBe(false); + }); +}); diff --git a/packages/shared/src/mock.ts b/packages/shared/src/mock.ts index 80a0dfce..a35e6c73 100644 --- a/packages/shared/src/mock.ts +++ b/packages/shared/src/mock.ts @@ -262,10 +262,36 @@ export interface MockEndpoint { export type MockServerSource = | { kind: 'openapi'; spec: string; format: 'json' | 'yaml' } + | { + // A spec-typed Global File Asset (see `GlobalFileAsset.spec`) drives this + // mock. Unlike `kind: 'openapi'` (a verbatim inline copy), the bytes live + // once in the asset library and are resolved on create / refresh. + kind: 'openapi-asset'; + /** id of the Global File Asset holding the OpenAPI/Swagger document. */ + assetId: string; + format: 'json' | 'yaml'; + /** + * `linked` — endpoints are derived live from the asset and kept in sync + * when the asset changes; they are NOT hand-editable ("run the spec + * directly"). `materialized` — parsed once into editable endpoints the + * user can modify ("import & edit"); an explicit refresh re-imports. + */ + mode: 'linked' | 'materialized'; + } | { kind: 'postman'; collection: string } | { kind: 'insomnia'; export: string } | { kind: 'manual'; endpoints: MockEndpoint[] }; +/** + * True when a mock's endpoints are derived live from a spec asset and must not + * be hand-edited — the source is an asset in `linked` mode. Endpoint-mutating + * store actions and MCP tools consult this to stay read-only, so "run the spec + * directly" mocks always reflect the asset. + */ +export function isLinkedMockSource(source: MockServerSource): boolean { + return source.kind === 'openapi-asset' && source.mode === 'linked'; +} + export interface MockServer { id: string; name: string; diff --git a/packages/shared/src/mockPromotion.ts b/packages/shared/src/mockPromotion.ts new file mode 100644 index 00000000..a817812b --- /dev/null +++ b/packages/shared/src/mockPromotion.ts @@ -0,0 +1,58 @@ +// Shared conventions for promoting mock endpoints into runnable collection +// requests. Kept here (pure, types-only) so every surface — the web/desktop +// store, the MCP server, and the VS Code extension — produces IDENTICAL env +// variables, folder names, URL templates, and request shapes. Change the +// convention once, here, and all three stay in lockstep. + +import type { MockEndpoint } from './mock'; +import type { Request as ApiRequest } from './types'; + +/** Name of the shared environment that holds the mock host + port. */ +export const MOCK_ENV_NAME = 'Mock'; + +/** + * URL prefix a promoted request targets: `{{MOCK_BASE_URL}}:{{MOCK_PORT}}` + * resolves against the {@link MOCK_ENV_NAME} environment at run time. + */ +export const MOCK_URL_PREFIX = '{{MOCK_BASE_URL}}:{{MOCK_PORT}}'; + +/** + * The `MOCK_BASE_URL` + `MOCK_PORT` variable defaults as `[key, value]` pairs. + * `MOCK_PORT` prefills from the mock server's port, falling back to `8080` when + * none is set. Callers add these only when a variable is missing so re-promoting + * never clobbers values the user has edited. + */ +export function mockEnvVarDefaults(port: number | null): Array<[string, string]> { + return [ + ['MOCK_BASE_URL', 'http://localhost'], + ['MOCK_PORT', String(port ?? 8080)], + ]; +} + +/** The folder that groups a mock's promoted requests: `" (mock)"`. */ +export function mockFolderName(mockName: string): string { + return `${mockName} (mock)`; +} + +/** + * Map a {@link MockEndpoint} to the request fields a promoted (or OpenAPI- + * imported) request carries: method + templated URL + request-schema params as + * disabled/empty rows the user fills in. `urlPrefix` is prepended to the path so + * a promoted request targets the live mock; pass `''` for a bare-path shape. + */ +export function requestShapeFromMockEndpoint( + ep: MockEndpoint, + urlPrefix = '', +): Pick { + return { + method: ep.method, + url: `${urlPrefix}${ep.pathPattern}`, + query: ep.requestSchema.queryParams.map((p) => ({ + key: p.name, + value: p.example != null ? String(p.example) : '', + enabled: false, + })), + headers: ep.requestSchema.headers.map((p) => ({ key: p.name, value: '', enabled: false })), + pathParams: Object.fromEntries(ep.requestSchema.pathParams.map((p) => [p.name, ''])), + }; +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2e934cc2..2b381508 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -331,6 +331,15 @@ export interface Request { // Optional reference to a workspace-wide GraphQL schema definition. Used // for GraphQL request body autocomplete (P19). graphqlSchemaId?: string | null; + /** + * Provenance for requests imported from an OpenAPI/Swagger spec: the Global + * File Asset the collection was imported from (`specAssetId`) and the + * operation it maps to (`operationId` = `" "`, the stable + * operation key). Enables re-syncing a collection when its source spec asset + * changes. Additive; absent on hand-authored or non-spec-imported requests. + */ + specAssetId?: string; + operationId?: string; assertions: Assertion[]; createdAt: string; updatedAt: string; @@ -412,6 +421,33 @@ export interface AssetGitRef { verifiedAt: string; } +/** + * Parsed summary of a Global File Asset that IS an OpenAPI 3.x / Swagger 2.0 + * document. Present only on spec files — an ordinary file asset leaves `spec` + * undefined. Derived once when the bytes are uploaded (and re-derived when they + * change) by `summarizeSpec` in `@apicircle/mock-server-core`, so the Assets + * panel, the mock "run/import from spec" pickers, and (in the Lens edition) the + * code-vs-spec drift check all read one authoritative parse instead of + * re-parsing the blob. Purely additive — existing assets and non-spec files are + * unaffected. + */ +export interface SpecAssetMeta { + /** `openapi-3` when the doc has a top-level `openapi:` string; `swagger-2` for `swagger:`. */ + dialect: 'openapi-3' | 'swagger-2'; + /** How the bytes are encoded, so consumers parse with the right reader. */ + format: 'json' | 'yaml'; + /** `info.title`, when present. */ + title?: string; + /** `info.version`, when present. */ + version?: string; + /** Operations declared — the sum of HTTP methods across `paths`. */ + operationCount: number; + /** ISO timestamp of the parse; re-derived whenever the bytes (sha256) change. */ + parsedAt: string; + /** Non-fatal structural warnings surfaced in the Assets panel. */ + warnings: string[]; +} + export interface GlobalFileAsset { id: string; name: string; @@ -438,6 +474,13 @@ export interface GlobalFileAsset { * never been merged leave it `undefined`. */ baseBranchRef?: AssetGitRef | null; + /** + * Present when this file asset is a recognised OpenAPI/Swagger document — a + * parsed summary derived on upload (see {@link SpecAssetMeta}). Absent on + * ordinary file assets. Additive; drives the Assets-panel spec badge and the + * mock "run/import from spec" pickers. + */ + spec?: SpecAssetMeta; } /** @@ -469,6 +512,15 @@ export interface AssetUsage { requests: string[]; /** Mock endpoints whose responses bind this asset. */ mockEndpoints: Array<{ mockId: string; endpointId: string }>; + /** + * Mock servers whose SOURCE is this spec asset (`openapi-asset`). Present only + * for spec assets (Increment E). Deleting the asset breaks linked mocks and + * removes the re-import source for materialized ones, so the delete cascade + * surfaces these. + */ + mockServers?: string[]; + /** Request ids imported from this spec asset (`Request.specAssetId`) — spec assets only (Increment E). */ + importedRequests?: string[]; /** Total reference count — denormalised for cheap badge rendering. */ total: number; } diff --git a/packages/ui-components/package.json b/packages/ui-components/package.json index b3b7100a..7a5e14e5 100644 --- a/packages/ui-components/package.json +++ b/packages/ui-components/package.json @@ -1,6 +1,6 @@ { "name": "@apicircle/ui-components", - "version": "1.2.0", + "version": "1.3.0", "private": true, "type": "module", "main": "./src/index.ts", diff --git a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx index 9f9e48dd..43d46d62 100644 --- a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx +++ b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.test.tsx @@ -58,6 +58,53 @@ describe('GlobalAssetsDockPanel', () => { expect(useWorkspaceStore.getState().synced!.globalAssets.schemas[id]).toBeUndefined(); }); + it('shows a spec badge and summary for an uploaded OpenAPI file', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /^Files/ })); + const input = screen.getByLabelText('Global file asset'); + const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { '/pets': { get: { responses: { '200': {} } } } }, + }); + await userEvent.upload( + input, + new File([openapi], 'petstore.json', { type: 'application/json' }), + ); + + // Upload auto-selects the asset, so the editor summary mounts with the + // labelled dialect + op count + title; the list row also carries an + // (icon-only) aria-labelled spec badge. + expect(await screen.findByText('OpenAPI 3 · 1 op')).toBeInTheDocument(); + expect(screen.getByText('Petstore')).toBeInTheDocument(); + expect(screen.getAllByLabelText(/API spec:/).length).toBeGreaterThan(0); + }); + + it('imports a spec asset into a collection with the source back-ref', async () => { + render(); + await userEvent.click(screen.getByRole('button', { name: /^Files/ })); + const input = screen.getByLabelText('Global file asset'); + const openapi = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { '/pets': { get: { responses: { '200': {} } } } }, + }); + await userEvent.upload( + input, + new File([openapi], 'petstore.json', { type: 'application/json' }), + ); + + await userEvent.click(await screen.findByRole('button', { name: /Import to collection/i })); + await waitFor(() => { + const req = Object.values(useWorkspaceStore.getState().synced!.collections.requests).find( + (r) => r.url === '/pets', + ); + expect(req?.operationId).toBe('GET /pets'); + // The imported request carries the source spec asset back-ref. + expect(req?.specAssetId).toBeTruthy(); + }); + }); + it('uploads and edits a reusable file asset', async () => { render(); await userEvent.click(screen.getByRole('button', { name: /^Files/ })); diff --git a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx index 01801362..1b1f90cb 100644 --- a/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx +++ b/packages/ui-components/src/layout/dock/GlobalAssetsDockPanel.tsx @@ -12,7 +12,15 @@ // request referencing the deleted id has its mapping cleared. import { useEffect, useMemo, useRef, useState } from 'react'; -import { ArrowLeft, FileArchive, Plus, Trash2, Upload } from 'lucide-react'; +import { + AlertTriangle, + ArrowLeft, + FileArchive, + FolderInput, + Plus, + Trash2, + Upload, +} from 'lucide-react'; import { formatBytes, type AssetUsage, @@ -25,9 +33,11 @@ import { ConfirmDialog } from '../../primitives/ConfirmDialog'; import { MonacoEditorBase } from '../../editors/MonacoEditorBase'; import { cn } from '../../primitives/cn'; import { deriveFileAssetState, FileAssetStatusPill } from '../../primitives/FileAssetStatusPill'; +import { SpecAssetBadge } from '../../primitives/SpecAssetBadge'; +import { getAttachment } from '../../persistence/attachments'; interface FileAssetConsumer { - kind: 'request' | 'mock'; + kind: 'request' | 'mock' | 'spec-mock' | 'spec-request'; /** Friendly label for the row ("My request", "Petstore · GET /pets"). */ label: string; /** Stable id for the list `key`. */ @@ -49,6 +59,19 @@ function consumersFromIndex( const label = meta ? `${meta.server} · ${meta.endpoint}` : `${ref.mockId} · ${ref.endpointId}`; out.push({ kind: 'mock', id: `mock:${ref.mockId}:${ref.endpointId}`, label }); } + // Spec-source consumers (Increment E): mocks driven by this spec asset + + // requests imported from it. + for (const mockId of usage.mockServers ?? []) { + const named = Object.entries(mockNames).find(([key]) => key.startsWith(`${mockId}:`)); + out.push({ + kind: 'spec-mock', + id: `spec-mock:${mockId}`, + label: named ? named[1].server : mockId, + }); + } + for (const id of usage.importedRequests ?? []) { + out.push({ kind: 'spec-request', id: `spec-req:${id}`, label: requestNames[id] ?? id }); + } return out; } @@ -517,7 +540,10 @@ function FileAssetListRow({ @@ -723,6 +749,32 @@ function FileAssetEditor({ id }: { id: string | null }) { return out; }); const update = useWorkspaceStore((s) => s.updateGlobalFileAsset); + const importOpenApiToCollection = useWorkspaceStore((s) => s.importOpenApiToCollection); + const pushToast = useWorkspaceStore((s) => s.pushToast); + const importSpecToCollection = async (): Promise => { + if (!file?.spec) return; + const record = await getAttachment(file.slotId); + if (!record) { + pushToast({ + tone: 'error', + title: 'Spec bytes are not available locally — re-upload the file.', + ttlMs: 8000, + }); + return; + } + const res = await importOpenApiToCollection({ + spec: new TextDecoder().decode(record.bytes), + format: file.spec.format, + specAssetId: file.id, + title: file.spec.title, + }); + pushToast({ + tone: res.warnings.length > 0 ? 'info' : 'success', + title: `Imported ${res.requests} request${res.requests === 1 ? '' : 's'} to a new collection`, + detail: res.warnings.length > 0 ? res.warnings.join(' · ') : undefined, + ttlMs: res.warnings.length > 0 ? 12000 : 6000, + }); + }; const remove = useWorkspaceStore((s) => s.removeGlobalFileAsset); const fillBytes = useWorkspaceStore((s) => s.fillGlobalFileAssetBytes); const hasPending = useWorkspaceStore((s) => @@ -832,6 +884,38 @@ function FileAssetEditor({ id }: { id: string | null }) { )} + {file.spec && ( +

+
+ + {file.spec.title && ( + + {file.spec.title} + + )} + {file.spec.version && v{file.spec.version}} +
+ + {file.spec.warnings.length > 0 && ( +
    + {file.spec.warnings.map((w) => ( +
  • +
  • + ))} +
+ )} +
+ )} +
{consumers.length === 0 diff --git a/packages/ui-components/src/panels/editor/ImportModal.test.tsx b/packages/ui-components/src/panels/editor/ImportModal.test.tsx index 0a0eb93d..38e15308 100644 --- a/packages/ui-components/src/panels/editor/ImportModal.test.tsx +++ b/packages/ui-components/src/panels/editor/ImportModal.test.tsx @@ -1,4 +1,4 @@ -import { act, render, screen } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { useWorkspaceStore } from '../../store/workspaceStore'; @@ -85,6 +85,22 @@ describe('ImportModal — auto-detect', () => { expect(screen.getByText('POST')).toBeInTheDocument(); }); + it('detects an OpenAPI spec and imports it into a collection', async () => { + render( {}} />); + const spec = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0' }, + paths: { '/pets': { get: { responses: { '200': { description: 'ok' } } } } }, + }); + pasteInto(screen.getByLabelText('Import source'), spec); + expect(await screen.findByText(/OpenAPI 3\)/)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: 'Import' })); + await waitFor(() => { + const reqs = Object.values(useWorkspaceStore.getState().synced!.collections.requests); + expect(reqs.some((r) => r.url === '/pets' && r.operationId === 'GET /pets')).toBe(true); + }); + }); + it('surfaces a parse error for non-JSON, non-cURL input', async () => { render( {}} />); pasteInto(screen.getByLabelText('Import source'), 'just garbage'); diff --git a/packages/ui-components/src/panels/editor/ImportModal.tsx b/packages/ui-components/src/panels/editor/ImportModal.tsx index ff22d237..9b3e35c2 100644 --- a/packages/ui-components/src/panels/editor/ImportModal.tsx +++ b/packages/ui-components/src/panels/editor/ImportModal.tsx @@ -43,6 +43,7 @@ import { type ParsedPostmanCollection, type ParsedPostmanEnvironment, } from '@apicircle/core'; +import { summarizeSpec, type SpecSummary } from '@apicircle/mock-server-core/parsing'; import { useWorkspaceStore, type ApicircleEnvironmentPendingBinding, @@ -51,8 +52,16 @@ import { Modal } from '../../primitives/Modal'; import { Select } from '../../primitives/Select'; import { cn } from '../../primitives/cn'; -type SourceFormat = 'auto' | 'postman' | 'postman-env' | 'insomnia' | 'curl' | 'apicircle'; +type SourceFormat = + | 'auto' + | 'openapi' + | 'postman' + | 'postman-env' + | 'insomnia' + | 'curl' + | 'apicircle'; type DetectedKind = + | { kind: 'openapi'; summary: SpecSummary } | { kind: 'postman-collection'; parsed: ParsedPostmanCollection } | { kind: 'postman-environment'; parsed: ParsedPostmanEnvironment } | { kind: 'insomnia-collection'; parsed: ParsedPostmanCollection } @@ -72,6 +81,7 @@ interface ImportModalProps { const FORMAT_LABELS: Record = { auto: 'Auto-detect', + openapi: 'OpenAPI / Swagger', postman: 'Postman v2.1 collection', 'postman-env': 'Postman environment', insomnia: 'Insomnia v4 export', @@ -111,6 +121,7 @@ export function ImportModal({ const [binding, setBinding] = useState(false); const importPostmanCollection = useWorkspaceStore((s) => s.importPostmanCollection); + const importOpenApiToCollection = useWorkspaceStore((s) => s.importOpenApiToCollection); const importPostmanEnvironment = useWorkspaceStore((s) => s.importPostmanEnvironment); const importApicircleFolder = useWorkspaceStore((s) => s.importApicircleFolder); const importApicircleEnvironment = useWorkspaceStore((s) => s.importApicircleEnvironment); @@ -143,6 +154,22 @@ export function ImportModal({ importPostmanEnvironment(d.parsed); } else if (d.kind === 'curl') { addRequestFromCurl(text, parentFolderId); + } else if (d.kind === 'openapi') { + // Async parse + create; the modal closes immediately (fire-and-forget), + // surfacing the outcome via a toast when it lands. + void importOpenApiToCollection({ + spec: text, + format: d.summary.format, + parentFolderId, + title: d.summary.title, + }).then((res) => { + pushToast({ + tone: res.warnings.length > 0 ? 'info' : 'success', + title: `Imported ${res.requests} request${res.requests === 1 ? '' : 's'} from ${d.summary.title ?? 'the spec'}`, + detail: res.warnings.length > 0 ? res.warnings.join(' · ') : undefined, + ttlMs: res.warnings.length > 0 ? 12000 : 6000, + }); + }); } else if (d.kind === 'apicircle-folder') { const result = importApicircleFolder(d.parsed, parentFolderId); if (result && result.filesRequiringReattachment.length > 0) { @@ -449,6 +476,16 @@ function detect(text: string, format: SourceFormat): DetectedKind { throw new Error('Selected source is "cURL" but the input doesn\'t start with "curl ".'); } + // OpenAPI / Swagger — recognised by a top-level `openapi:`/`swagger:` key. + // Handled before JSON.parse so YAML specs work too; summarizeSpec is sync. + if (format === 'openapi' || format === 'auto') { + const summary = summarizeSpec(trimmed); + if (summary) return { kind: 'openapi', summary }; + if (format === 'openapi') { + throw new Error('Selected source is "OpenAPI / Swagger" but this is not a recognised spec.'); + } + } + // The remaining formats are JSON. let json: unknown; try { @@ -494,6 +531,10 @@ function detect(text: string, format: SourceFormat): DetectedKind { } function labelForDetection(d: DetectedKind): string { + if (d.kind === 'openapi') { + const dialect = d.summary.dialect === 'swagger-2' ? 'Swagger 2' : 'OpenAPI 3'; + return `${d.summary.title ?? 'API'} · ${d.summary.operationCount} operation${d.summary.operationCount === 1 ? '' : 's'} (${dialect})`; + } if (d.kind === 'postman-collection') { return `${d.parsed.collectionName} · ${d.parsed.requests.length} request${d.parsed.requests.length === 1 ? '' : 's'} (Postman)`; } @@ -514,6 +555,29 @@ function labelForDetection(d: DetectedKind): string { } function DetectionPreview({ detection }: { detection: DetectedKind }) { + if (detection.kind === 'openapi') { + const s = detection.summary; + const dialect = s.dialect === 'swagger-2' ? 'Swagger 2.0' : 'OpenAPI 3.x'; + return ( +
+

+ {s.title ?? 'API'} + {s.version ? v{s.version} : null} · {dialect} +

+

+ {s.operationCount} operation{s.operationCount === 1 ? '' : 's'} → one request each in + a new folder. +

+ {s.warnings.length > 0 ? ( +
    + {s.warnings.map((w) => ( +
  • • {w}
  • + ))} +
+ ) : null} +
+ ); + } if (detection.kind === 'postman-collection' || detection.kind === 'insomnia-collection') { return ; } diff --git a/packages/ui-components/src/panels/help/helpContent.ts b/packages/ui-components/src/panels/help/helpContent.ts index 37eeba69..c7cea624 100644 --- a/packages/ui-components/src/panels/help/helpContent.ts +++ b/packages/ui-components/src/panels/help/helpContent.ts @@ -571,7 +571,7 @@ Extracted values are local-only — they live in this machine's context, never i ## Import in the app -The Editor sidebar's Import action opens a modal that takes pasted text or an uploaded file and auto-detects the format — Postman v2.1 collections, Postman environments (keys import as context variables), Insomnia v4 exports, **API Circle folder exports** (the \`.apicircle.json\` files produced by the new "Export as JSON" folder action — see below), **API Circle environment exports** (the JSON the Environments sidebar's "Export as JSON" action produces; encrypted variables travel with the slot's user-recognizable label and trigger a **"Provide secret values"** second step in the modal where you can fill the value to bind on the spot, or **Skip & finish** and re-bind later under Environments), and pasted cURL commands: +The Editor sidebar's Import action opens a modal that takes pasted text or an uploaded file and auto-detects the format — Postman v2.1 collections, Postman environments (keys import as context variables), Insomnia v4 exports, **OpenAPI 3.x / Swagger 2.0 specs** (each operation becomes a request in a new folder — or use **Import to collection** on a spec you've uploaded to Global Assets → Files), **API Circle folder exports** (the \`.apicircle.json\` files produced by the new "Export as JSON" folder action — see below), **API Circle environment exports** (the JSON the Environments sidebar's "Export as JSON" action produces; encrypted variables travel with the slot's user-recognizable label and trigger a **"Provide secret values"** second step in the modal where you can fill the value to bind on the spot, or **Skip & finish** and re-bind later under Environments), and pasted cURL commands: curl -X POST https://api.example.com/v1/users \\ -H "Content-Type: application/json" \\ @@ -967,6 +967,10 @@ Snapshots live only on this machine. They are kept within a size budget — pick - **Empty** — a blank server you add endpoints to by hand. - **From a spec** — paste an OpenAPI, Postman, or Insomnia source; it is parsed into endpoints the moment you create the mock, so the endpoint table is populated right away. On the Desktop app the parse runs in the native process and resolves external \`$ref\`s; the web app resolves in-document \`$ref\`s only and warns about any external references it can't follow. +- **From a spec asset** — import an OpenAPI/Swagger file you uploaded to Global Assets → Files as **editable** endpoints (materialized). Modify them freely; a refresh-from-spec re-import pulls changes when the asset updates. +- **Serve OpenAPI contract** (a separate action in the Mocks header) — stand up a **live, read-only** server straight from an uploaded contract (linked): pick the contract, name it, choose a port, and start/stop it from the mock's panel. Endpoints derive from the contract and stay in sync — re-uploading the spec updates the server, and the panel shows a "Served directly from contract" callout. Use this when you just want to run a contract; use From a spec asset when you want to tweak the endpoints. Changed your mind after creating it? **Convert to editable mock** (the ⋯ menu, the read-only banner, or the panel callout) flips a live contract into an editable copy in place — the spec link stays, so you can still re-import. And when the contract itself changes, **Update spec…** (the ⋯ menu or the panel callout) re-uploads the revised file — replacing the shared asset and live-refreshing the mock's endpoints. + +Every mock endpoint has an **Add to collection** action (the ⋯ menu on the endpoint row), and the server ⋯ menu has **Add all to collection** to promote the whole mock at once. Promoted requests carry the method, path pattern, and declared query/header/path params, land in a **" (mock)" folder**, and target the live mock via a \`{{MOCK_BASE_URL}}:{{MOCK_PORT}}\` URL — backed by a dedicated **"Mock" environment** (MOCK_PORT prefilled from the server's port, else 8080) that is created and activated for you, so you just tweak host/port before running. It works even on read-only "run live" mocks. ## Endpoints and the response flow @@ -1337,6 +1341,8 @@ A request with a graphql body that references the definition gets field and argu Every file you drop — into the Global Assets sidebar, a binary request body, a form-data file row, or a mock binary response — becomes a reusable Global Asset entry. The workspace tracks filename, size, MIME type, checksum, and the requests or mock responses that bind to the file. +When the file you drop is an OpenAPI 3.x or Swagger 2.0 document (\`.json\` / \`.yaml\` / \`.yml\`), Studio recognises it on upload and tags the asset with a **spec badge** ("OpenAPI 3 · N ops"). Selecting the asset shows the parsed title, version, operation count, and any parse warnings — so the workspace knows which of its files are API contracts. + Each asset shows a small status pill next to its name. The pill tells you where the bytes live: - **Uploaded locally** — bytes are in your local IDB; the next push uploads them. @@ -1346,7 +1352,7 @@ Each asset shows a small status pill next to its name. The pill tells you where - **Missing** — both refs dropped and no local copy. Re-upload from the same row to restore. - **Diverged** — both refs hold different blob shas. Usually means someone force-pushed the base branch with a different file at the same slot — review before pushing. -Each row also shows "Used in N" — clicking through the Global Assets panel shows every request and mock endpoint that binds to the file. Zero-use assets get an "Unused" badge so you can identify and prune orphans deliberately. +Each row also shows "Used in N" — clicking through the Global Assets panel shows every request and mock endpoint that binds to the file. For a spec asset, that count also includes the mock servers built from it and the requests imported from it, so you can see what a spec backs before deleting it. Zero-use assets get an "Unused" badge so you can identify and prune orphans deliberately. When a workspace is pushed to GitHub, file bytes are stored as attachment blobs next to the synced doc under \`.apicircle/workspace-/attachments/\`, separate from the workspace document. That keeps the JSON small and makes diffs readable. On another machine, linked or synced file assets show as missing until you download them. Sending a request or running a plan that needs missing files opens a download prompt; after the download verifies the checksum, execution continues. The \`apicircle run\` CLI follows the same rule for headless plans. diff --git a/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx b/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx index e908e1b1..4af561cb 100644 --- a/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx +++ b/packages/ui-components/src/panels/mocks/CreateMockServerModal.test.tsx @@ -96,4 +96,52 @@ describe('CreateMockServerModal', () => { await user.click(screen.getByRole('button', { name: /Done/i })); await waitFor(() => expect(useWorkspaceStore.getState().mocksCreateModalOpen).toBe(false)); }); + + it('shows an empty state on the spec-asset tab when no spec assets exist', async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /From spec asset/i })); + expect(screen.getByText(/No spec assets yet/i)).toBeInTheDocument(); + }); + + it('imports a spec asset as editable (materialized) endpoints', async () => { + const user = userEvent.setup(); + // Seed a spec asset — parse-on-upload tags it as an OpenAPI doc. + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + + await user.type(screen.getByLabelText('Mock server name'), 'Imported petstore'); + await user.click(screen.getByRole('button', { name: /From spec asset/i })); + await user.selectOptions(await screen.findByLabelText('Spec asset'), assetId); + await user.click(screen.getByRole('button', { name: /Create mock server/i })); + + await waitFor(() => expect(useWorkspaceStore.getState().mocksCreateModalOpen).toBe(false)); + const created = Object.values(useWorkspaceStore.getState().synced!.mockServers).find( + (m) => m.name === 'Imported petstore', + ); + expect(created?.source.kind).toBe('openapi-asset'); + // The unified modal now always IMPORTS (materialized); "run live" moved to + // the dedicated Serve OpenAPI contract flow. + if (created?.source.kind === 'openapi-asset') expect(created.source.mode).toBe('materialized'); + expect(created?.endpoints.length).toBe(1); + }); + + it('no longer offers a "run live" toggle on the spec-asset tab, and points to Serve OpenAPI contract', async () => { + const user = userEvent.setup(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + await user.click(screen.getByRole('button', { name: /From spec asset/i })); + expect(screen.queryByLabelText('Run live')).not.toBeInTheDocument(); + expect(screen.getByText(/Serve OpenAPI contract/i)).toBeInTheDocument(); + }); }); diff --git a/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx b/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx index bb8d5e17..17e65f97 100644 --- a/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx +++ b/packages/ui-components/src/panels/mocks/CreateMockServerModal.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { AlertTriangle, FileCode, Info, Plus } from 'lucide-react'; import type { MockServerSource } from '@apicircle/shared'; import { useWorkspaceStore } from '../../store/workspaceStore'; @@ -28,7 +28,7 @@ export function CreateMockServerModal() { // in the Node main process; otherwise we're browser-only (web app). const canResolveExternalRefs = getDesktopMockBridge()?.parseSpec != null; - const [tab, setTab] = useState<'manual' | 'spec'>('manual'); + const [tab, setTab] = useState<'manual' | 'spec' | 'asset'>('manual'); const [name, setName] = useState(''); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); @@ -40,11 +40,17 @@ export function CreateMockServerModal() { // closing on a partially-resolved import. const [result, setResult] = useState<{ endpointCount: number; warnings: string[] } | null>(null); + // Spec-typed Global File Assets available to build a mock from (Increment A). + const files = useWorkspaceStore((s) => s.synced?.globalAssets.files); + const specAssets = useMemo(() => Object.values(files ?? {}).filter((f) => f.spec), [files]); + const [assetId, setAssetId] = useState(''); + const reset = () => { setName(''); setSpecKind('openapi'); setSpecFormat('json'); setSpecText(''); + setAssetId(''); setError(null); setResult(null); setTab('manual'); @@ -57,6 +63,21 @@ export function CreateMockServerModal() { let source: MockServerSource; if (tab === 'manual') { source = { kind: 'manual', endpoints: [] }; + } else if (tab === 'asset') { + const asset = specAssets.find((f) => f.id === assetId); + if (!asset?.spec) { + setError('Select a spec asset to build the mock from.'); + return; + } + // This unified modal always IMPORTS a spec asset as editable endpoints + // (materialized). Running a contract live (read-only) is its own + // first-class flow — the "Serve OpenAPI contract" entry point. + source = { + kind: 'openapi-asset', + assetId: asset.id, + format: asset.spec.format, + mode: 'materialized', + }; } else { if (!specText.trim()) { setError('Paste the spec content.'); @@ -83,7 +104,7 @@ export function CreateMockServerModal() { const { id, warnings } = await createMockServer({ name, source }); // Activate the new server so the panel surfaces its endpoint list. setActiveMockEndpoint({ serverId: id, endpointId: null }); - if (tab === 'spec') { + if (tab !== 'manual') { const endpointCount = useWorkspaceStore.getState().synced?.mockServers[id]?.endpoints.length ?? 0; if (warnings.length > 0 || endpointCount === 0) { @@ -191,6 +212,14 @@ export function CreateMockServerModal() {
{tab === 'manual' ? ( @@ -200,6 +229,53 @@ export function CreateMockServerModal() { add endpoints — you can edit method, path, response body, headers, and rules per endpoint there.

+ ) : tab === 'asset' ? ( +
+

+ Import a spec you’ve uploaded to Global Assets as editable endpoints you can + modify — re-import from the spec anytime. To run a contract{' '} + live (read-only, always in sync), use{' '} + Serve OpenAPI contract in the Mocks + menu instead. Upload OpenAPI/Swagger files under Assets → Files first. +

+ {specAssets.length === 0 ? ( +
+
+ ) : ( + <> + + +

+ Endpoints are parsed into an editable table you can modify after creating. +

+ + )} +
) : (

@@ -270,7 +346,7 @@ export function CreateMockServerModal() {

+
+ +
+ + + +
+
+ +
+
+
+ +
+ ); } diff --git a/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx b/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx index d5694319..a642ea2b 100644 --- a/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx +++ b/packages/ui-components/src/panels/mocks/MockNodeEditor.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useContext, useState } from 'react'; import { ArrowDown, ArrowLeft, @@ -29,6 +29,7 @@ import { MockResponseEditor } from './MockResponseEditor'; import { MockRequestSchemaEditor } from './MockRequestSchemaEditor'; import { MockRulePicker } from './MockRulePicker'; import { Select } from '../../primitives/Select'; +import { MockReadOnlyContext } from './mockReadOnly'; // Node-editor surface for whichever flow node the user has selected. // Imports each inner editor below so the selection switch is a single @@ -229,6 +230,7 @@ function ValidationOverview({ onSelect: (next: MockNodeSelection) => void; }) { const [importerOpen, setImporterOpen] = useState(false); + const readOnly = useContext(MockReadOnlyContext); const move = (idx: number, dir: -1 | 1) => { const next = [...endpoint.requestValidation]; const target = idx + dir; @@ -281,7 +283,13 @@ function ValidationOverview({

{endpoint.requestValidation.length === 0 ? (

- No validation rules. Click Add rule below to create one. + No validation rules. + {!readOnly && ( + <> + {' '} + Click Add rule below to create one. + + )}

) : (
    @@ -352,25 +360,27 @@ function ValidationOverview({ ))}
)} -
- - -
+ {!readOnly && ( +
+ + +
+ )} void; }) { const [importerOpen, setImporterOpen] = useState(false); + const readOnly = useContext(MockReadOnlyContext); const move = (idx: number, dir: -1 | 1) => { const next = [...endpoint.responseRules]; const target = idx + dir; @@ -591,7 +602,13 @@ function ResponseRulesOverview({

{endpoint.responseRules.length === 0 ? (

- No response rules. Click Add rule below to create one. + No response rules. + {!readOnly && ( + <> + {' '} + Click Add rule below to create one. + + )}

) : (
    @@ -658,25 +675,27 @@ function ResponseRulesOverview({ ))}
)} -
- - -
+ {!readOnly && ( +
+ + +
+ )} ) => void; }) { + const readOnly = useContext(MockReadOnlyContext); const schema = endpoint.requestSchema; const setSchema = (next: MockRequestSchema) => setEndpoint({ requestSchema: next }); const setList = (key: ParamListKey, next: MockParamDef[]) => @@ -77,7 +80,7 @@ export function MockRequestSchemaEditor({

Request schema

- {undeclaredSlots.length > 0 && ( + {!readOnly && undeclaredSlots.length > 0 && ( + {!readOnly && ( + + )} ))} )} - + {!readOnly && ( + + )} ); } diff --git a/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx b/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx index ffedf23f..8410a580 100644 --- a/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx +++ b/packages/ui-components/src/panels/mocks/MockResponseEditor.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react'; +import { useContext, useRef, useState } from 'react'; import { Paperclip, Plus, Trash2, X } from 'lucide-react'; import { coerceMockResponseBodyTypeForStatus, @@ -19,6 +19,7 @@ import { applyContentTypeForBodyType } from '@apicircle/core'; import { useWorkspaceStore } from '../../store/workspaceStore'; import { FilePickerMenu } from '../../primitives/FilePickerMenu'; import { MonacoBodyEditor } from '../../editors/MonacoBodyEditor'; +import { MockReadOnlyContext } from './mockReadOnly'; import { HeaderKeyAutocomplete, HeaderValueRecommendations } from '../editor/HeaderAutocomplete'; import { cn } from '../../primitives/cn'; import { FileAssetStatusPill } from '../../primitives/FileAssetStatusPill'; @@ -185,6 +186,7 @@ function HeadersEditor({ // expanded everywhere so the editable rows are visible at a glance. The //
element still allows manual collapse via the summary. void compact; + const readOnly = useContext(MockReadOnlyContext); const update = (idx: number, patch: Partial) => { const next = [...headers]; next[idx] = { ...next[idx], ...patch }; @@ -210,14 +212,16 @@ function HeadersEditor({ /> ))} - + {!readOnly && ( + + )}
); } @@ -238,6 +242,7 @@ function ResponseHeaderRow({ // Track focus on the value input so we can show the curated-values // popover only when focused — mirrors the request editor's UX. const [valueFocused, setValueFocused] = useState(false); + const readOnly = useContext(MockReadOnlyContext); return (
  • - + {!readOnly && ( + + )}
  • ); } @@ -356,6 +363,7 @@ function BodyContentEditor({ attachmentSlot: { serverId: string; endpointId: string } | null; compact?: boolean; }) { + const readOnly = useContext(MockReadOnlyContext); if (body.type === 'none') { return (

    @@ -392,6 +400,7 @@ function BodyContentEditor({ onChange={onContentChange} modelPath={`inmemory://apicircle/mock-response/${attachmentSlot?.endpointId ?? 'inline'}.body`} ariaLabel={`${label} body`} + readOnly={readOnly} /> ); @@ -631,6 +640,7 @@ function MultipliersEditor({ multipliers: MockResponseMultiplier[]; onChange: (next: MockResponseMultiplier[]) => void; }) { + const readOnly = useContext(MockReadOnlyContext); const update = (idx: number, patch: Partial) => { onChange(multipliers.map((m, i) => (i === idx ? { ...m, ...patch } : m))); }; @@ -667,8 +677,9 @@ function MultipliersEditor({

    {multipliers.length === 0 ? (

    - No multipliers — server returns the body as-authored. Add one to repeat an array inside - the response body based on a request value. + No multipliers — server returns the body as-authored. + {!readOnly && + ' Add one to repeat an array inside the response body based on a request value.'}

    ) : (
      @@ -776,23 +787,23 @@ function MultipliersEditor({ ))}
    )} - {atCap ? ( - multipliers.length > 0 && ( -

    - Limit reached ({MAX_RESPONSE_MULTIPLIERS}). Multiple multipliers are coming soon. -

    - ) - ) : ( - - )} + {atCap + ? multipliers.length > 0 && ( +

    + Limit reached ({MAX_RESPONSE_MULTIPLIERS}). Multiple multipliers are coming soon. +

    + ) + : !readOnly && ( + + )}
    ); diff --git a/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx index 3556c666..c1b3f2b8 100644 --- a/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx +++ b/packages/ui-components/src/panels/mocks/MockServersPanel.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { screen } from '@testing-library/react'; +import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { MockRuntimeEntry, MockServer } from '@apicircle/shared'; import { MockServersPanel } from './MockServersPanel'; @@ -8,6 +8,16 @@ import { useWorkspaceStore } from '../../store/workspaceStore'; const T0 = '2026-04-27T00:00:00.000Z'; +const OPENAPI_JSON = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Petstore', version: '1.0.0' }, + paths: { + '/pets': { + get: { responses: { '200': { content: { 'application/json': { example: [{ id: 1 }] } } } } }, + }, + }, +}); + function fixtureMock(id: string, name: string): MockServer { const endpoint = { id: 'ep1', @@ -94,6 +104,147 @@ describe('MockServersPanel (post-rich-editor redesign)', () => { expect(await screen.findByLabelText('Mock server name')).toHaveValue('Petstore'); }); + it('shows a "Served directly from contract" callout + friendly label for a linked mock', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Petstore live', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'linked' }, + }); + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: null }); + }); + + expect(await screen.findByText(/Served directly from contract/i)).toBeInTheDocument(); + // Friendly source label instead of the raw `openapi-asset` union tag. + expect(screen.getByText('OpenAPI contract')).toBeInTheDocument(); + // The callout exposes both spec actions. + expect(screen.getByRole('button', { name: /Update spec/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Convert to editable/i })).toBeInTheDocument(); + }); + + it('renders the endpoint editor READ-ONLY for a linked contract mock', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Live', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'linked' }, + }); + const ep = useWorkspaceStore.getState().synced!.mockServers[id].endpoints[0]; + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: ep.id }); + }); + + // Banner + native controls disabled via the wrapping
    . + expect(await screen.findByText(/Read-only/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Mock endpoint method')).toBeDisabled(); + expect(screen.getByLabelText('Mock endpoint name')).toBeDisabled(); + }); + + it('hides mutation CTAs across the read-only editor and drops "click Add rule" hints', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Live', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'linked' }, + }); + const ep = useWorkspaceStore.getState().synced!.mockServers[id].endpoints[0]; + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: ep.id }); + }); + + // Validation node: no Add/Import CTAs, and the empty copy doesn't tell you + // to click a button that isn't there. + await userEvent.click(await screen.findByRole('button', { name: /Validation node/i })); + expect(screen.getByText(/No validation rules/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Add rule/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Import rule/i })).not.toBeInTheDocument(); + expect(screen.queryByText(/Click.*Add rule/i)).not.toBeInTheDocument(); + + // Default response node: no "+ Header" CTA. + await userEvent.click(screen.getByRole('button', { name: /Default response node/i })); + expect(screen.queryByRole('button', { name: /Add.*header/i })).not.toBeInTheDocument(); + }); + + it('the read-only editor "Convert to editable" button unlocks editing', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Live', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'linked' }, + }); + const ep = useWorkspaceStore.getState().synced!.mockServers[id].endpoints[0]; + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: ep.id }); + }); + + // Read-only initially. + expect(await screen.findByText(/Read-only/i)).toBeInTheDocument(); + expect(screen.getByLabelText('Mock endpoint method')).toBeDisabled(); + + // Convert unlocks it: banner gone, controls enabled, source now materialized. + await userEvent.click(screen.getByRole('button', { name: /Convert to editable/i })); + await waitFor(() => expect(screen.queryByText(/Read-only/i)).not.toBeInTheDocument()); + expect(screen.getByLabelText('Mock endpoint method')).not.toBeDisabled(); + const src = useWorkspaceStore.getState().synced!.mockServers[id].source; + expect(src).toMatchObject({ kind: 'openapi-asset', mode: 'materialized' }); + }); + + it('leaves the endpoint editor EDITABLE for a materialized (imported) mock', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .addGlobalFileAsset( + new File([OPENAPI_JSON], 'petstore.json', { type: 'application/json' }), + ); + }); + const assetId = Object.values(useWorkspaceStore.getState().synced!.globalAssets.files!)[0].id; + const { id } = await useWorkspaceStore.getState().createMockServer({ + name: 'Imported', + source: { kind: 'openapi-asset', assetId, format: 'json', mode: 'materialized' }, + }); + const ep = useWorkspaceStore.getState().synced!.mockServers[id].endpoints[0]; + act(() => { + useWorkspaceStore.getState().setActiveMockEndpoint({ serverId: id, endpointId: ep.id }); + }); + + expect(await screen.findByLabelText('Mock endpoint method')).not.toBeDisabled(); + expect(screen.queryByText(/Read-only/i)).not.toBeInTheDocument(); + + // An editable mock keeps its mutation CTAs. + await userEvent.click(screen.getByRole('button', { name: /Validation node/i })); + expect(screen.getByRole('button', { name: /Add rule/i })).toBeInTheDocument(); + }); + it('renders the MockEndpointEditor flow + node editor when both a server and endpoint are active', async () => { await renderWithStore(); useWorkspaceStore.setState((s) => ({ diff --git a/packages/ui-components/src/panels/mocks/MockServersPanel.tsx b/packages/ui-components/src/panels/mocks/MockServersPanel.tsx index e82fd5c8..94a57015 100644 --- a/packages/ui-components/src/panels/mocks/MockServersPanel.tsx +++ b/packages/ui-components/src/panels/mocks/MockServersPanel.tsx @@ -1,11 +1,40 @@ -import { useEffect, useState } from 'react'; -import { AlertTriangle, Loader2, Play, Plus, Server, Square, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { + AlertTriangle, + Loader2, + Play, + Plus, + Radio, + Server, + Square, + Trash2, + Unlock, + Upload, +} from 'lucide-react'; import type { MockRuntimeEntry, MockServer } from '@apicircle/shared'; import { useWorkspaceStore } from '../../store/workspaceStore'; import { MockEndpointEditor } from './MockEndpointEditor'; import { CreateMockServerModal } from './CreateMockServerModal'; +import { ServeContractModal } from './ServeContractModal'; import { DesktopAppLink } from '../../primitives/desktopDownload'; +// Friendly label for a mock's source kind — the raw union tags (`openapi-asset`) +// read poorly in the panel; a linked contract in particular should say so. +function friendlySourceKind(source: MockServer['source']): string { + switch (source.kind) { + case 'manual': + return 'Manual'; + case 'openapi': + return 'OpenAPI (pasted)'; + case 'postman': + return 'Postman'; + case 'insomnia': + return 'Insomnia'; + case 'openapi-asset': + return source.mode === 'linked' ? 'OpenAPI contract' : 'OpenAPI (imported from asset)'; + } +} + // ============================================================================= // MockServersPanel — split pane: the sidebar (handled by Sidebar.tsx for the // 'mocks' panel) selects a server + endpoint; this pane renders the @@ -218,6 +247,7 @@ export function MockServersPanel() { )} + ); } @@ -272,6 +302,14 @@ function ServerSummary({ onStop: () => void; }) { const setMockServerName = useWorkspaceStore((s) => s.setMockServerName); + const convertMockToEditable = useWorkspaceStore((s) => s.convertMockToEditable); + const reuploadMockSpec = useWorkspaceStore((s) => s.reuploadMockSpec); + const specInput = useRef(null); + const files = useWorkspaceStore((s) => s.synced?.globalAssets.files); + // A "run live" (linked) contract mock gets a clear provenance callout below. + const src = server.source; + const linked = src.kind === 'openapi-asset' && src.mode === 'linked' ? src : null; + const linkedAsset = linked ? files?.[linked.assetId] : undefined; return (
    @@ -286,9 +324,59 @@ function ServerSummary({ className="mt-1 h-8 w-full max-w-md rounded-sm border border-border bg-card px-2 text-sm text-text-primary focus:border-accent focus:outline-none" />
    + {linked && ( +
    +
    + )}
    Source kind
    -
    {server.source.kind}
    +
    {friendlySourceKind(server.source)}
    Endpoints
    {server.endpoints.length}
    diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx new file mode 100644 index 00000000..9a8fac7b --- /dev/null +++ b/packages/ui-components/src/panels/mocks/MocksSidebar.test.tsx @@ -0,0 +1,176 @@ +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it } from 'vitest'; +import { MocksSidebar, MocksSidebarActions } from './MocksSidebar'; +import { renderWithStore } from '../../../test/renderWithStore'; +import { useWorkspaceStore } from '../../store/workspaceStore'; + +const OPENAPI = JSON.stringify({ + openapi: '3.0.0', + info: { title: 'P', version: '1' }, + paths: { '/pets': { get: { responses: { '200': { description: 'ok' } } } } }, +}); + +async function seedAssetMock(name: string, mode: 'linked' | 'materialized'): Promise { + await act(async () => { + const assetId = await useWorkspaceStore + .getState() + .addGlobalFileAsset(new File([OPENAPI], 'p.json', { type: 'application/json' })); + await useWorkspaceStore + .getState() + .createMockServer({ name, source: { kind: 'openapi-asset', assetId, format: 'json', mode } }); + }); +} + +describe('MocksSidebar — asset-backed mocks', () => { + it('marks a linked mock read-only: Refresh (not Add endpoint) + a read-only indicator', async () => { + await renderWithStore(); + await seedAssetMock('Live', 'linked'); + + expect(await screen.findByLabelText('Linked spec (read-only)')).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', { name: /Live actions/i })); + expect(screen.getByText('Refresh from spec')).toBeInTheDocument(); + expect(screen.queryByText('Add endpoint')).not.toBeInTheDocument(); + }); + + it('offers "Convert to editable mock" on a linked mock and flips it to materialized', async () => { + await renderWithStore(); + await seedAssetMock('Live', 'linked'); + + await userEvent.click(await screen.findByRole('button', { name: /Live actions/i })); + await userEvent.click(screen.getByText('Convert to editable mock')); + + await waitFor(() => { + const m = Object.values(useWorkspaceStore.getState().synced!.mockServers).find( + (s) => s.name === 'Live', + ); + expect(m?.source).toMatchObject({ kind: 'openapi-asset', mode: 'materialized' }); + }); + // Now editable: Add endpoint returns, Convert is gone, read-only indicator lifts. + await userEvent.click(screen.getByRole('button', { name: /Live actions/i })); + expect(screen.getByText('Add endpoint')).toBeInTheDocument(); + expect(screen.queryByText('Convert to editable mock')).not.toBeInTheDocument(); + }); + + it('offers "Update spec…" on a linked mock and re-derives endpoints from the new file', async () => { + await renderWithStore(); + await seedAssetMock('Live', 'linked'); + const mockId = Object.values(useWorkspaceStore.getState().synced!.mockServers)[0].id; + expect(useWorkspaceStore.getState().synced!.mockServers[mockId].endpoints.length).toBe(1); + + // Click "Update spec…" (stashes the target server), then supply the new file. + await userEvent.click(await screen.findByRole('button', { name: /Live actions/i })); + await userEvent.click(screen.getByText('Update spec…')); + + const revised = new File( + [ + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'P', version: '1' }, + paths: { + '/a': { get: { responses: { '200': { description: 'ok' } } } }, + '/b': { get: { responses: { '200': { description: 'ok' } } } }, + }, + }), + ], + 'revised.json', + { type: 'application/json' }, + ); + fireEvent.change(screen.getByLabelText('Update OpenAPI/Swagger spec file'), { + target: { files: [revised] }, + }); + + await waitFor(() => { + expect(useWorkspaceStore.getState().synced!.mockServers[mockId].endpoints.length).toBe(2); + }); + }); + + it('offers "Re-import from spec" alongside Add endpoint on a materialized mock', async () => { + await renderWithStore(); + await seedAssetMock('Editable', 'materialized'); + + await userEvent.click(await screen.findByRole('button', { name: /Editable actions/i })); + expect(screen.getByText('Re-import from spec')).toBeInTheDocument(); + expect(screen.getByText('Add endpoint')).toBeInTheDocument(); + }); + + it('a manual mock offers Add endpoint and no refresh action', async () => { + await renderWithStore(); + await act(async () => { + await useWorkspaceStore + .getState() + .createMockServer({ name: 'Manual', source: { kind: 'manual', endpoints: [] } }); + }); + + await userEvent.click(await screen.findByRole('button', { name: /Manual actions/i })); + expect(screen.getByText('Add endpoint')).toBeInTheDocument(); + expect(screen.queryByText('Refresh from spec')).not.toBeInTheDocument(); + expect(screen.queryByText('Re-import from spec')).not.toBeInTheDocument(); + }); + + it('promotes an endpoint into the collection via "Add to collection"', async () => { + await renderWithStore(); + let mockId = ''; + await act(async () => { + const r = await useWorkspaceStore + .getState() + .createMockServer({ name: 'API', source: { kind: 'manual', endpoints: [] } }); + mockId = r.id; + useWorkspaceStore.getState().addMockEndpoint(mockId); + }); + + // The active server auto-expands, so the endpoint row + its kebab are visible. + await userEvent.click(await screen.findByRole('button', { name: /GET \/path actions/i })); + await userEvent.click(screen.getByText('Add to collection')); + + await waitFor(() => { + const reqs = Object.values(useWorkspaceStore.getState().synced!.collections.requests); + expect(reqs.some((r) => r.url === '{{MOCK_BASE_URL}}:{{MOCK_PORT}}/path')).toBe(true); + }); + }); + + it('promotes ALL endpoints to a " (mock)" folder via the server kebab', async () => { + await renderWithStore(); + let mockId = ''; + await act(async () => { + const r = await useWorkspaceStore + .getState() + .createMockServer({ name: 'API', source: { kind: 'manual', endpoints: [] } }); + mockId = r.id; + }); + act(() => { + const e1 = useWorkspaceStore.getState().addMockEndpoint(mockId); + useWorkspaceStore + .getState() + .updateMockEndpoint(mockId, e1, { method: 'GET', pathPattern: '/pets' }); + const e2 = useWorkspaceStore.getState().addMockEndpoint(mockId); + useWorkspaceStore + .getState() + .updateMockEndpoint(mockId, e2, { method: 'POST', pathPattern: '/pets' }); + }); + + await userEvent.click(await screen.findByRole('button', { name: /API actions/i })); + await userEvent.click(screen.getByText('Add all to collection')); + + await waitFor(() => { + const s = useWorkspaceStore.getState().synced!; + const folder = Object.values(s.collections.folders).find((f) => f.name === 'API (mock)'); + expect(folder).toBeTruthy(); + const reqs = Object.values(s.collections.requests).filter((r) => r.folderId === folder!.id); + expect(reqs).toHaveLength(2); + expect(s.environments.activeName).toBe('Mock'); + }); + }); +}); + +describe('MocksSidebarActions', () => { + it('offers "New Mock Server" and "Serve OpenAPI contract"; the latter opens the serve modal', async () => { + await renderWithStore(); + await userEvent.click(screen.getByRole('button', { name: /Mocks actions/i })); + expect(screen.getByText('New Mock Server')).toBeInTheDocument(); + const serve = screen.getByText('Serve OpenAPI contract'); + expect(serve).toBeInTheDocument(); + await userEvent.click(serve); + expect(useWorkspaceStore.getState().mocksServeContractModalOpen).toBe(true); + }); +}); diff --git a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx index a878ff27..04a039a9 100644 --- a/packages/ui-components/src/panels/mocks/MocksSidebar.tsx +++ b/packages/ui-components/src/panels/mocks/MocksSidebar.tsx @@ -1,14 +1,20 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { ChevronDown, ChevronRight, Copy, FileCode, + FolderPlus, Plus, + Radio, + RefreshCw, Search, Server, Trash2, + Unlock, + Upload, } from 'lucide-react'; +import { isLinkedMockSource } from '@apicircle/shared'; import { useWorkspaceStore } from '../../store/workspaceStore'; import { ConfirmDialog } from '../../primitives/ConfirmDialog'; import { KebabMenu, type KebabMenuItem } from '../../primitives/KebabMenu'; @@ -33,6 +39,16 @@ export function MocksSidebar() { const removeMock = useWorkspaceStore((s) => s.removeMockServer); const duplicateMockServer = useWorkspaceStore((s) => s.duplicateMockServer); const duplicateMockEndpoint = useWorkspaceStore((s) => s.duplicateMockEndpoint); + const refreshMockServer = useWorkspaceStore((s) => s.refreshMockServer); + const convertMockToEditable = useWorkspaceStore((s) => s.convertMockToEditable); + const reuploadMockSpec = useWorkspaceStore((s) => s.reuploadMockSpec); + const promoteMockEndpointToRequest = useWorkspaceStore((s) => s.promoteMockEndpointToRequest); + const promoteMockToCollection = useWorkspaceStore((s) => s.promoteMockToCollection); + const pushToast = useWorkspaceStore((s) => s.pushToast); + // "Update spec…" (linked mocks) triggers this one hidden file input; the + // target server id is stashed in a ref so a single input serves the list. + const specUploadInput = useRef(null); + const specUploadServerId = useRef(null); const allServers = Object.values(mockServers); const [expanded, setExpanded] = useState>({}); @@ -126,16 +142,71 @@ export function MocksSidebar() { ? server.endpoints : server.endpoints.filter((e) => matchingEndpointIds.has(e.id)); const isServerActive = activeServerId === server.id && activeEndpointId === null; + const isLinked = isLinkedMockSource(server.source); const serverItems: KebabMenuItem[] = [ - { - id: 'add-endpoint', - label: 'Add endpoint', - icon:
    ); } @@ -321,6 +433,7 @@ export function MocksSidebar() { */ export function MocksSidebarActions() { const openCreateMockServer = useWorkspaceStore((s) => s.openMocksCreateModal); + const openServeContract = useWorkspaceStore((s) => s.openMocksServeContractModal); const items: KebabMenuItem[] = [ { @@ -329,6 +442,12 @@ export function MocksSidebarActions() { icon: