diff --git a/packages/insomnia-smoke-test/tests/smoke/app.test.ts b/packages/insomnia-smoke-test/tests/smoke/app.test.ts index ce7f6fd09..8da1164d7 100644 --- a/packages/insomnia-smoke-test/tests/smoke/app.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/app.test.ts @@ -1,7 +1,7 @@ import { expect } from '@playwright/test'; import { loadFixture } from '../../playwright/paths'; -import { test } from '../../playwright/test';; +import { test } from '../../playwright/test'; test('can send requests', async ({ app, page }) => { test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); diff --git a/packages/insomnia-smoke-test/tests/smoke/dashboard-interactions.test.ts b/packages/insomnia-smoke-test/tests/smoke/dashboard-interactions.test.ts index 8fe8edba3..774801f3c 100644 --- a/packages/insomnia-smoke-test/tests/smoke/dashboard-interactions.test.ts +++ b/packages/insomnia-smoke-test/tests/smoke/dashboard-interactions.test.ts @@ -105,18 +105,15 @@ test.describe('Dashboard', async () => { await expect(page.locator('.app')).toContainText('test123'); // Duplicate document - await page.click('text=Documenttest123just now >> button'); + await page.getByLabel('test123').getByRole('button').click(); await page.getByRole('menuitem', { name: 'Duplicate' }).click(); await page.locator('input[name="name"]').fill('test123-duplicate'); await page.click('[role="dialog"] button:has-text("Duplicate")'); await page.getByTestId('project').click(); - const workspaceCards = page.getByLabel('Workspaces').getByRole('gridcell'); - await expect(workspaceCards).toHaveCount(2); - // Delete document - await page.click('text=Documenttest123just now >> button'); + await page.getByLabel('test123-duplicate').getByRole('button').click(); await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click(); // @TODO: Re-enable - Requires mocking VCS operations @@ -143,17 +140,15 @@ test.describe('Dashboard', async () => { await expect(page.locator('.app')).toContainText('test123'); // Duplicate collection - await page.click('text=Collectiontest123just now >> button'); + await page.getByLabel('test123').getByRole('button').click(); await page.getByRole('menuitem', { name: 'Duplicate' }).click(); await page.locator('input[name="name"]').fill('test123-duplicate'); await page.click('[role="dialog"] button:has-text("Duplicate")'); await page.getByTestId('project').click(); - const workspaceCards = page.getByLabel('Workspaces').getByRole('gridcell'); - await expect(workspaceCards).toHaveCount(2); // Delete collection - await page.click('text=Collectiontest123just now >> button'); + await page.getByLabel('test123-duplicate').getByRole('button').click(); await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Delete' }).click(); // @TODO: Re-enable - Requires mocking VCS operations diff --git a/packages/insomnia-smoke-test/tests/smoke/mock.test.ts b/packages/insomnia-smoke-test/tests/smoke/mock.test.ts new file mode 100644 index 000000000..752a10911 --- /dev/null +++ b/packages/insomnia-smoke-test/tests/smoke/mock.test.ts @@ -0,0 +1,25 @@ +import { test } from '../../playwright/test'; + +// TODO: unskip this test when cloud mock is online +test.skip('can make a mock route', async ({ page }) => { + test.slow(process.platform === 'darwin' || process.platform === 'win32', 'Slow app start on these platforms'); + await page.getByLabel('New Mock Server').click(); + await page.getByRole('button', { name: 'Create', exact: true }).click(); + await page.getByRole('button', { name: 'New Mock Route' }).click(); + await page.getByText('GET/').click(); + await page.getByTestId('CodeEditor').getByRole('textbox').fill('123'); + await page.getByRole('tab', { name: 'Response Headers' }).click(); + await page.locator('.CodeMirror').first().click(); + await page.getByRole('textbox').nth(1).fill('my-header'); + await page.getByRole('textbox').nth(2).fill('my-value'); + await page.getByRole('tab', { name: 'Response Status' }).click(); + await page.getByPlaceholder('200').click(); + await page.getByPlaceholder('200').fill('201'); + + await page.getByRole('button', { name: 'Test' }).click(); + await page.getByRole('tab', { name: 'Preview' }).click(); + await page.getByLabel('Preview').getByText('123').click(); + await page.getByRole('tab', { name: 'Timeline' }).click(); + await page.getByText('HTTP/1.1 201 Created').click(); + await page.getByText('my-header:').click(); +}); diff --git a/packages/insomnia/src/common/constants.ts b/packages/insomnia/src/common/constants.ts index 5e8737ef6..185b93058 100644 --- a/packages/insomnia/src/common/constants.ts +++ b/packages/insomnia/src/common/constants.ts @@ -136,6 +136,7 @@ export enum UpdateURL { // API export const getApiBaseURL = () => env.INSOMNIA_API_URL || 'https://api.insomnia.rest'; +export const getMockServiceURL = () => env.INSOMNIA_MOCK_API_URL || 'https://mock.insomnia.rest'; export const getAIServiceURL = () => env.INSOMNIA_AI_URL || 'https://ai.insomnia.rest'; export const getUpdatesBaseURL = () => env.INSOMNIA_UPDATES_URL || 'https://updates.insomnia.rest'; @@ -267,7 +268,7 @@ export const CONTENT_TYPE_FORM_DATA = 'multipart/form-data'; export const CONTENT_TYPE_FILE = 'application/octet-stream'; export const CONTENT_TYPE_GRAPHQL = 'application/graphql'; export const CONTENT_TYPE_OTHER = ''; -const contentTypesMap: Record = { +export const contentTypesMap: Record = { [CONTENT_TYPE_EDN]: ['EDN', 'EDN'], [CONTENT_TYPE_FILE]: ['File', 'Binary File'], [CONTENT_TYPE_FORM_DATA]: ['Multipart', 'Multipart Form'], @@ -573,6 +574,8 @@ export const EXPORT_TYPE_REQUEST = 'request'; export const EXPORT_TYPE_GRPC_REQUEST = 'grpc_request'; export const EXPORT_TYPE_WEBSOCKET_REQUEST = 'websocket_request'; export const EXPORT_TYPE_WEBSOCKET_PAYLOAD = 'websocket_payload'; +export const EXPORT_TYPE_MOCK_SERVER = 'mock'; +export const EXPORT_TYPE_MOCK_ROUTE = 'mock_route'; export const EXPORT_TYPE_REQUEST_GROUP = 'request_group'; export const EXPORT_TYPE_UNIT_TEST_SUITE = 'unit_test_suite'; export const EXPORT_TYPE_UNIT_TEST = 'unit_test'; diff --git a/packages/insomnia/src/common/get-workspace-label.ts b/packages/insomnia/src/common/get-workspace-label.ts index 8b00a746a..7834e86ad 100644 --- a/packages/insomnia/src/common/get-workspace-label.ts +++ b/packages/insomnia/src/common/get-workspace-label.ts @@ -1,5 +1,9 @@ -import { isDesign, Workspace } from '../models/workspace'; +import { isDesign, isMockServer, Workspace } from '../models/workspace'; import { strings } from './strings'; export const getWorkspaceLabel = (workspace: Workspace) => - isDesign(workspace) ? strings.document : strings.collection; + isDesign(workspace) + ? strings.document + : isMockServer(workspace) + ? strings.mock + : strings.collection; diff --git a/packages/insomnia/src/common/har.ts b/packages/insomnia/src/common/har.ts index a7a80c4dc..24a3bb6ee 100644 --- a/packages/insomnia/src/common/har.ts +++ b/packages/insomnia/src/common/har.ts @@ -414,9 +414,8 @@ function getRequestCookies(renderedRequest: RenderedRequest) { return harCookies; } -function getResponseCookies(response: Response) { - const headers = response.headers.filter(Boolean) as HarCookie[]; - const responseCookies = getSetCookieHeaders(headers) +export function getResponseCookiesFromHeaders(headers: HarCookie[]) { + return getSetCookieHeaders(headers) .reduce((accumulator, harCookie) => { let cookie: null | undefined | ToughCookie = null; @@ -433,7 +432,11 @@ function getResponseCookies(response: Response) { mapCookie(cookie), ]; }, [] as HarCookie[]); - return responseCookies; +} + +function getResponseCookies(response: Response) { + const headers = response.headers.filter(Boolean); + return getResponseCookiesFromHeaders(headers); } function mapCookie(cookie: ToughCookie) { diff --git a/packages/insomnia/src/common/render.ts b/packages/insomnia/src/common/render.ts index d7c2bdc58..d8960487f 100644 --- a/packages/insomnia/src/common/render.ts +++ b/packages/insomnia/src/common/render.ts @@ -596,5 +596,7 @@ export async function getRenderContextAncestors(base?: Request | GrpcRequest | W models.requestGroup.type, models.workspace.type, models.project.type, + models.mockRoute.type, + models.mockServer.type, ]); } diff --git a/packages/insomnia/src/common/strings.ts b/packages/insomnia/src/common/strings.ts index f8ff7df57..3609bc9c1 100644 --- a/packages/insomnia/src/common/strings.ts +++ b/packages/insomnia/src/common/strings.ts @@ -5,6 +5,7 @@ export interface StringInfo { type StringId = | 'collection' + | 'mock' | 'document' | 'project' | 'workspace' @@ -18,6 +19,10 @@ export const strings: Record = { singular: 'Collection', plural: 'Collections', }, + mock: { + singular: 'Mock', + plural: 'Mocks', + }, document: { singular: 'Document', plural: 'Documents', diff --git a/packages/insomnia/src/models/index.ts b/packages/insomnia/src/models/index.ts index eae092287..1bff1f77b 100644 --- a/packages/insomnia/src/models/index.ts +++ b/packages/insomnia/src/models/index.ts @@ -3,6 +3,8 @@ import { EXPORT_TYPE_COOKIE_JAR, EXPORT_TYPE_ENVIRONMENT, EXPORT_TYPE_GRPC_REQUEST, + EXPORT_TYPE_MOCK_ROUTE, + EXPORT_TYPE_MOCK_SERVER, EXPORT_TYPE_PROTO_DIRECTORY, EXPORT_TYPE_PROTO_FILE, EXPORT_TYPE_REQUEST, @@ -22,6 +24,8 @@ import * as _environment from './environment'; import * as _gitRepository from './git-repository'; import * as _grpcRequest from './grpc-request'; import * as _grpcRequestMeta from './grpc-request-meta'; +import * as _mockRoute from './mock-route'; +import * as _mockServer from './mock-server'; import * as _oAuth2Token from './o-auth-2-token'; import * as _pluginData from './plugin-data'; import * as _project from './project'; @@ -64,6 +68,8 @@ export const caCertificate = _caCertificate; export const cookieJar = _cookieJar; export const environment = _environment; export const gitRepository = _gitRepository; +export const mockServer = _mockServer; +export const mockRoute = _mockRoute; export const oAuth2Token = _oAuth2Token; export const pluginData = _pluginData; export const request = _request; @@ -109,6 +115,8 @@ export function all() { requestVersion, requestMeta, response, + mockServer, + mockRoute, oAuth2Token, caCertificate, clientCertificate, @@ -211,6 +219,8 @@ export const MODELS_BY_EXPORT_TYPE: Record = { [EXPORT_TYPE_REQUEST]: request, [EXPORT_TYPE_WEBSOCKET_PAYLOAD]: webSocketPayload, [EXPORT_TYPE_WEBSOCKET_REQUEST]: webSocketRequest, + [EXPORT_TYPE_MOCK_SERVER]: mockServer, + [EXPORT_TYPE_MOCK_ROUTE]: mockRoute, [EXPORT_TYPE_GRPC_REQUEST]: grpcRequest, [EXPORT_TYPE_REQUEST_GROUP]: requestGroup, [EXPORT_TYPE_UNIT_TEST_SUITE]: unitTestSuite, diff --git a/packages/insomnia/src/models/mock-route.ts b/packages/insomnia/src/models/mock-route.ts new file mode 100644 index 000000000..5ae891c41 --- /dev/null +++ b/packages/insomnia/src/models/mock-route.ts @@ -0,0 +1,82 @@ +import { database as db } from '../common/database'; +import type { BaseModel } from './index'; +import { RequestHeader } from './request'; + +export const name = 'Mock Route'; + +export const type = 'MockRoute'; + +export const prefix = 'mock_route'; + +export const canDuplicate = true; + +export const canSync = true; + +interface BaseMockRoute { + body: string; + headers: RequestHeader[]; + parentId: string; + statusCode: number; + statusText: string; + name: string; + mimeType: string;// response body type + method: string; // used only for sending the testing request +} + +export type MockRoute = BaseModel & BaseMockRoute; + +export function init(): BaseMockRoute { + return { + body: '', + headers: [], + parentId: '', + statusCode: 200, + statusText: '', + name: '/', + mimeType: 'application/json', + method: 'GET', + }; +} + +export const isMockRoute = (model: Pick): model is MockRoute => ( + model.type === type +); + +export function migrate(doc: MockRoute) { + return doc; +} + +export function create(patch: Partial = {}) { + if (!patch.parentId) { + throw new Error('New MockRoute missing `parentId`: ' + JSON.stringify(patch)); + } + + return db.docCreate(type, patch); +} + +export function update( + mockRoute: MockRoute, + patch: Partial = {}, +) { + return db.docUpdate(mockRoute, patch); +} + +export function getById(id: string) { + return db.get(type, id); +} + +export function findByParentId(parentId: string) { + return db.find(type, { parentId }); +} + +export function removeWhere(parentId: string) { + return db.removeWhere(type, { parentId }); +} + +export function remove(mockRoute: MockRoute) { + return db.remove(mockRoute); +} + +export function all() { + return db.all(type); +} diff --git a/packages/insomnia/src/models/mock-server.ts b/packages/insomnia/src/models/mock-server.ts new file mode 100644 index 000000000..cbdcbf9af --- /dev/null +++ b/packages/insomnia/src/models/mock-server.ts @@ -0,0 +1,91 @@ +import { database as db } from '../common/database'; +import { type BaseModel, workspace } from './index'; + +export const name = 'Mock Server'; + +export const type = 'MockServer'; + +export const prefix = 'mock'; + +export const canDuplicate = true; + +export const canSync = true; + +interface BaseMockServer { + parentId: string; + name: string; + url: string; + useInsomniaCloud: boolean; +} + +export type MockServer = BaseModel & BaseMockServer; + +export function init(): BaseMockServer { + return { + parentId: '', + name: 'New Mock', + url: 'http://localhost:8080', + useInsomniaCloud: true, + }; +} + +export const isMockServer = (model: Pick): model is MockServer => ( + model.type === type +); + +export function migrate(doc: MockServer) { + return doc; +} + +export function create(patch: Partial = {}) { + if (!patch.parentId) { + throw new Error('New MockServer missing `parentId`: ' + JSON.stringify(patch)); + } + + return db.docCreate(type, patch); +} +export async function getOrCreateForParentId( + workspaceId: string, + patch: Partial = {}, +) { + const mockServer = await db.getWhere(type, { + parentId: workspaceId, + }); + + if (!mockServer) { + return db.docCreate(type, { ...patch, parentId: workspaceId }); + } + + return mockServer; +} +export function update( + mockServer: MockServer, + patch: Partial = {}, +) { + return db.docUpdate(mockServer, patch); +} + +export function getById(id: string) { + return db.get(type, id); +} + +export function getByParentId(parentId: string) { + return db.getWhere(type, { parentId }); +} + +export async function findByProjectId(projectId: string) { + const workspaces = await workspace.findByParentId(projectId); + return db.find(type, { parentId: { $in: workspaces.map(ws => ws._id) } }); +} + +export function removeWhere(parentId: string) { + return db.removeWhere(type, { parentId }); +} + +export function remove(mockServer: MockServer) { + return db.remove(mockServer); +} + +export function all() { + return db.all(type); +} diff --git a/packages/insomnia/src/models/workspace.ts b/packages/insomnia/src/models/workspace.ts index e99cd2664..16287046f 100644 --- a/packages/insomnia/src/models/workspace.ts +++ b/packages/insomnia/src/models/workspace.ts @@ -1,5 +1,6 @@ import type { Merge } from 'type-fest'; +import { ACTIVITY_DEBUG, ACTIVITY_SPEC } from '../common/constants'; import { database as db } from '../common/database'; import { strings } from '../common/strings'; import type { BaseModel } from './index'; @@ -16,7 +17,7 @@ export interface BaseWorkspace { name: string; description: string; certificates?: any; // deprecated - scope: 'design' | 'collection'; + scope: 'design' | 'collection' | 'mock-server'; } export type WorkspaceScope = BaseWorkspace['scope']; @@ -24,6 +25,7 @@ export type WorkspaceScope = BaseWorkspace['scope']; export const WorkspaceScopeKeys = { design: 'design', collection: 'collection', + mockServer: 'mock-server', } as const; export type Workspace = BaseModel & BaseWorkspace; @@ -40,6 +42,10 @@ export const isCollection = (workspace: Pick) => ( workspace.scope === WorkspaceScopeKeys.collection ); +export const isMockServer = (workspace: Pick) => ( + workspace.scope === WorkspaceScopeKeys.mockServer +); + export const init = (): BaseWorkspace => ({ name: `New ${strings.collection.singular}`, description: '', @@ -135,9 +141,12 @@ type MigrationWorkspace = Merge design, unset => collection if (workspace.scope === 'designer' || workspace.scope === 'spec') { workspace.scope = WorkspaceScopeKeys.design; } else { @@ -157,3 +166,16 @@ export const SCRATCHPAD_WORKSPACE_ID = 'wrk_scratchpad'; export function isScratchpad(workspace: Workspace) { return workspace._id === SCRATCHPAD_WORKSPACE_ID; } + +export const scopeToActivity = (scope: WorkspaceScope) => { + switch (scope) { + case WorkspaceScopeKeys.collection: + return ACTIVITY_DEBUG; + case WorkspaceScopeKeys.design: + return ACTIVITY_SPEC; + case WorkspaceScopeKeys.mockServer: + return 'mock-server'; + default: + return ACTIVITY_DEBUG; + } +}; diff --git a/packages/insomnia/src/network/network.ts b/packages/insomnia/src/network/network.ts index bc7272aba..8f800d66b 100644 --- a/packages/insomnia/src/network/network.ts +++ b/packages/insomnia/src/network/network.ts @@ -43,9 +43,14 @@ export const fetchRequestData = async (requestId: string) => { models.request.type, models.requestGroup.type, models.workspace.type, + models.mockRoute.type, + models.mockServer.type, ]); const workspaceDoc = ancestors.find(isWorkspace); - const workspaceId = workspaceDoc ? workspaceDoc._id : 'n/a'; + invariant(workspaceDoc?._id, 'failed to find workspace'); + + const workspaceId = workspaceDoc._id; + const workspace = await models.workspace.getById(workspaceId); invariant(workspace, 'failed to find workspace'); const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(workspace._id); diff --git a/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts b/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts index c1b484e0e..67398c8be 100644 --- a/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts +++ b/packages/insomnia/src/sync/git/__tests__/ne-db-client.test.ts @@ -53,6 +53,8 @@ describe('NeDBClient', () => { models.apiSpec.type, models.environment.type, models.grpcRequest.type, + models.mockRoute.type, + models.mockServer.type, models.protoDirectory.type, models.protoFile.type, models.request.type, diff --git a/packages/insomnia/src/sync/git/ne-db-client.ts b/packages/insomnia/src/sync/git/ne-db-client.ts index fcd602cd5..053680193 100644 --- a/packages/insomnia/src/sync/git/ne-db-client.ts +++ b/packages/insomnia/src/sync/git/ne-db-client.ts @@ -147,6 +147,8 @@ export class NeDBClient { models.protoDirectory.type, models.webSocketRequest.type, models.webSocketPayload.type, + models.mockRoute.type, + models.mockServer.type, ]; } else if (type !== null && id === null) { const workspace = await db.get(models.workspace.type, this._workspaceId); diff --git a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx index 91062c7db..050636992 100644 --- a/packages/insomnia/src/ui/components/codemirror/code-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/code-editor.tsx @@ -90,7 +90,7 @@ export interface CodeEditorProps { noMatchBrackets?: boolean; noStyleActiveLine?: boolean; // used only for saving env editor state - onBlur?: () => void; + onBlur?: (e: FocusEvent) => void; onChange?: (value: string) => void; onClickLink?: CodeMirrorLinkClickCallback; pinToBottom?: boolean; @@ -397,10 +397,7 @@ export const CodeEditor = forwardRef(({ }; } }; - codeMirror.current.on('blur', () => { - persistState(); - onBlur?.(); - }); + codeMirror.current.on('scroll', persistState); codeMirror.current.on('fold', persistState); codeMirror.current.on('unfold', persistState); @@ -439,7 +436,6 @@ export const CodeEditor = forwardRef(({ } }); useUnmount(() => { - onBlur?.(); codeMirror.current?.toTextArea(); codeMirror.current?.closeHintDropdown(); codeMirror.current = null; @@ -471,9 +467,9 @@ export const CodeEditor = forwardRef(({ }, [lintOptions, noLint, onChange]); useEffect(() => { - const handleOnBlur = () => onBlur?.(); + const handleOnBlur = (_: CodeMirror.Editor, e: FocusEvent) => onBlur?.(e); codeMirror.current?.on('blur', handleOnBlur); - return () => codeMirror.current?.on('blur', handleOnBlur); + return () => codeMirror.current?.off('blur', handleOnBlur); }, [onBlur]); const tryToSetOption = (key: keyof EditorConfiguration, value: any) => { diff --git a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx index 902155b7d..fa068182b 100644 --- a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx @@ -24,6 +24,7 @@ export interface OneLineEditorProps { readOnly?: boolean; type?: string; onPaste?: (text: string) => void; + onBlur?: (e: FocusEvent) => void; } export interface OneLineEditorHandle { @@ -40,6 +41,7 @@ export const OneLineEditor = forwardRef readOnly, type, onPaste, + onBlur, }, ref) => { const textAreaRef = useRef(null); const codeMirror = useRef(null); @@ -123,6 +125,12 @@ export const OneLineEditor = forwardRef } }); + codeMirror.current.on('blur', (_, e) => { + if (onBlur) { + onBlur(e); + } + }); + codeMirror.current.on('keydown', (doc: CodeMirror.Editor, event: KeyboardEvent) => { // Use default tab behaviour if we're told if (event.code === 'Tab') { diff --git a/packages/insomnia/src/ui/components/command-palette.tsx b/packages/insomnia/src/ui/components/command-palette.tsx index 8f870cf3d..a3a4c61fb 100644 --- a/packages/insomnia/src/ui/components/command-palette.tsx +++ b/packages/insomnia/src/ui/components/command-palette.tsx @@ -1,3 +1,4 @@ +import { IconName } from '@fortawesome/fontawesome-svg-core'; import React from 'react'; import { useState } from 'react'; import { Collection, ComboBox, Dialog, Header, Input, Label, ListBox, ListBoxItem, Modal, ModalOverlay, Section, Text } from 'react-aria-components'; @@ -7,6 +8,7 @@ import { isGrpcRequest } from '../../models/grpc-request'; import { isRequest } from '../../models/request'; import { isRequestGroup } from '../../models/request-group'; import { isWebSocketRequest } from '../../models/websocket-request'; +import { scopeToActivity, WorkspaceScope } from '../../models/workspace'; import { WorkspaceLoaderData } from '../routes/workspace'; import { Icon } from './icon'; import { useDocBodyKeyboardShortcuts } from './keydown-binder'; @@ -32,6 +34,12 @@ export const CommandPalette = () => { }, }); + const scopeToIconMap: Record = { + design: 'file', + collection: 'bars', + 'mock-server': 'server', + }; + return ( @@ -54,8 +62,11 @@ export const CommandPalette = () => { if (!itemId) { return; } - if (itemId.toString().startsWith('wrk_')) { - navigate(`/organization/${organizationId}/project/${projectId}/workspace/${itemId}/debug`); + const isWorkspace = itemId.toString().startsWith('wrk_'); + if (isWorkspace) { + const [id, scope] = itemId.toString().split('|'); + const activity = scopeToActivity(scope as WorkspaceScope); + navigate(`/organization/${organizationId}/project/${projectId}/workspace/${id}/${activity}`); } else { navigate(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${itemId}`); } @@ -115,11 +126,11 @@ export const CommandPalette = () => { id: 'collections-and-documents', name: 'Collections and documents', children: workspaces.map(workspace => ({ - id: workspace._id, - icon: , + id: workspace._id + '|' + workspace.scope, + icon: , name: workspace.name, description: '', - textValue: `${workspace.scope === 'collection' ? 'Collection' : 'Document'} ${workspace.name}`, + textValue: `${workspace.scope} ${workspace.name}`, })), }, ]} diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx index 87aecfd48..8e98f83a0 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-card-dropdown.tsx @@ -9,6 +9,7 @@ import { RENDER_PURPOSE_NO_RENDER } from '../../../common/render'; import type { ApiSpec } from '../../../models/api-spec'; import { CaCertificate } from '../../../models/ca-certificate'; import { ClientCertificate } from '../../../models/client-certificate'; +import { MockServer } from '../../../models/mock-server'; import { isRemoteProject, Project } from '../../../models/project'; import type { Workspace } from '../../../models/workspace'; import { WorkspaceScopeKeys } from '../../../models/workspace'; @@ -30,6 +31,7 @@ interface Props { workspace: Workspace; workspaceMeta: WorkspaceMeta; apiSpec: ApiSpec | null; + mockServer: MockServer | null; project: Project; projects: Project[]; clientCertificates: ClientCertificate[]; @@ -85,7 +87,7 @@ const useDocumentActionPlugins = ({ workspace, apiSpec, project }: Props) => { }; export const WorkspaceCardDropdown: FC = props => { - const { workspace, project, projects } = props; + const { workspace, mockServer, project, projects } = props; const fetcher = useFetcher(); const [isDuplicateModalOpen, setIsDuplicateModalOpen] = useState(false); const [isImportModalOpen, setIsImportModalOpen] = useState(false); @@ -209,6 +211,7 @@ export const WorkspaceCardDropdown: FC = props => { {isSettingsModalOpen && ( setIsSettingsModalOpen(false)} /> )} diff --git a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx index 1bdf99b9f..322636627 100644 --- a/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx +++ b/packages/insomnia/src/ui/components/dropdowns/workspace-dropdown.tsx @@ -41,6 +41,7 @@ export const WorkspaceDropdown: FC = () => { activeWorkspace, activeProject, activeApiSpec, + activeMockServer, projects, } = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData; const activeWorkspaceName = activeWorkspace.name; @@ -254,6 +255,7 @@ export const WorkspaceDropdown: FC = () => { {isSettingsModalOpen && ( setIsSettingsModalOpen(false)} /> )} diff --git a/packages/insomnia/src/ui/components/editors/mock-response-extractor.tsx b/packages/insomnia/src/ui/components/editors/mock-response-extractor.tsx new file mode 100644 index 000000000..1b99484af --- /dev/null +++ b/packages/insomnia/src/ui/components/editors/mock-response-extractor.tsx @@ -0,0 +1,124 @@ +import fs from 'fs/promises'; +import React, { useState } from 'react'; +import { Button } from 'react-aria-components'; +import { useNavigate, useParams } from 'react-router-dom'; +import { useRouteLoaderData } from 'react-router-dom'; + +import { useMockRoutePatcher } from '../../routes/mock-route'; +import { RequestLoaderData } from '../../routes/request'; +import { HelpTooltip } from '../help-tooltip'; +import { Icon } from '../icon'; + +export const MockResponseExtractor = () => { + const { mockServerAndRoutes, activeResponse } = useRouteLoaderData('request/:requestId') as RequestLoaderData; + const patchMockRoute = useMockRoutePatcher(); + const navigate = useNavigate(); + const { + organizationId, + projectId, + } = useParams(); + const [selectedMockServer, setSelectedMockServer] = useState(''); + const [selectedMockRoute, setSelectedMockRoute] = useState(''); + return ( +
+
+ +
+
+ Export this response to a mock route. +
+
{ + e.preventDefault(); + if (!selectedMockServer || !selectedMockRoute) { + return; + } + + if (activeResponse) { + // TODO: move this out of the renderer, and upsert mock + const body = await fs.readFile(activeResponse.bodyPath); + + patchMockRoute(selectedMockRoute, { + body: body.toString(), + mimeType: activeResponse.contentType, + statusCode: activeResponse.statusCode, + headers: activeResponse.headers, + }); + } + }} + > +
+
+ +
+
+
+
+ +
+
+
+ + +
+
+
+ ); +}; diff --git a/packages/insomnia/src/ui/components/editors/mock-response-headers-editor.tsx b/packages/insomnia/src/ui/components/editors/mock-response-headers-editor.tsx new file mode 100644 index 000000000..cc7d0b502 --- /dev/null +++ b/packages/insomnia/src/ui/components/editors/mock-response-headers-editor.tsx @@ -0,0 +1,95 @@ +import React, { FC, useCallback } from 'react'; +import { useParams, useRouteLoaderData } from 'react-router-dom'; + +import { getCommonHeaderNames, getCommonHeaderValues } from '../../../common/common-headers'; +import type { RequestHeader } from '../../../models/request'; +import { MockRouteLoaderData, useMockRoutePatcher } from '../../routes/mock-route'; +import { CodeEditor } from '../codemirror/code-editor'; +import { KeyValueEditor } from '../key-value-editor/key-value-editor'; + +interface Props { + bulk: boolean; + isDisabled?: boolean; + onBlur?: (e: FocusEvent) => void; +} + +export const MockResponseHeadersEditor: FC = ({ + bulk, + isDisabled, + onBlur, +}) => { + const { mockRoute } = useRouteLoaderData(':mockRouteId') as MockRouteLoaderData; + const patchMockRoute = useMockRoutePatcher(); + + const { mockRouteId } = useParams() as { mockRouteId: string }; + + const handleBulkUpdate = useCallback((headersString: string) => { + const headers: { + name: string; + value: string; + }[] = []; + + const rows = headersString.split(/\n+/); + for (const row of rows) { + const [rawName, rawValue] = row.split(/:(.*)$/); + const name = (rawName || '').trim(); + const value = (rawValue || '').trim(); + + if (!name && !value) { + continue; + } + + headers.push({ + name, + value, + }); + } + patchMockRoute(mockRouteId, { headers }); + }, [patchMockRoute, mockRouteId]); + + let headersString = ''; + for (const header of mockRoute.headers) { + // Make sure it's not disabled + if (header.disabled) { + continue; + } + // Make sure it's not blank + if (!header.name && !header.value) { + continue; + } + + headersString += `${header.name}: ${header.value}\n`; + } + + const onChangeHeaders = useCallback((headers: RequestHeader[]) => { + patchMockRoute(mockRouteId, { headers }); + }, [patchMockRoute, mockRouteId]); + + if (bulk) { + return ( +
+ +
+ ); + } + + return ( + + ); +}; diff --git a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx index 2b92c1bb8..3d0b41804 100644 --- a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx @@ -41,6 +41,7 @@ interface Props { }[]) => void; pairs: Pair[]; valuePlaceholder?: string; + onBlur?: (e: FocusEvent) => void; } export const KeyValueEditor: FC = ({ @@ -56,6 +57,7 @@ export const KeyValueEditor: FC = ({ onChange, pairs, valuePlaceholder, + onBlur, }) => { // We should make the pair.id property required and pass them in from the parent // smelly @@ -109,6 +111,7 @@ export const KeyValueEditor: FC = ({ descriptionPlaceholder={descriptionPlaceholder} hideButtons readOnly + onBlur={onBlur} onClick={() => onChange([...pairs, { // smelly id: generateId('pair'), @@ -150,6 +153,7 @@ export const KeyValueEditor: FC = ({ namePlaceholder={namePlaceholder} valuePlaceholder={valuePlaceholder} descriptionPlaceholder={descriptionPlaceholder} + onBlur={onBlur} onChange={pair => onChange(pairsWithIds.map(p => (p.id === pair.id ? pair : p)))} onDelete={pair => onChange(pairsWithIds.filter(p => p.id !== pair.id))} handleGetAutocompleteNameConstants={handleGetAutocompleteNameConstants} diff --git a/packages/insomnia/src/ui/components/key-value-editor/row.tsx b/packages/insomnia/src/ui/components/key-value-editor/row.tsx index d8e522dee..a7efa7e00 100644 --- a/packages/insomnia/src/ui/components/key-value-editor/row.tsx +++ b/packages/insomnia/src/ui/components/key-value-editor/row.tsx @@ -41,6 +41,7 @@ interface Props { onClick?: () => void; onKeydown?: (e: React.KeyboardEvent) => void; showDescription: boolean; + onBlur?: (e: FocusEvent) => void; } export const Row: FC = ({ @@ -60,6 +61,7 @@ export const Row: FC = ({ onKeydown, valuePlaceholder, showDescription, + onBlur, }) => { const { enabled } = useNunjucksEnabled(); @@ -92,6 +94,7 @@ export const Row: FC = ({ getAutocompleteConstants={() => handleGetAutocompleteNameConstants?.(pair) || []} readOnly={readOnly} onChange={name => onChange({ ...pair, name })} + onBlur={onBlur} />
= ({ ) : ( { + const { mockServer, mockRoute, activeResponse } = useRouteLoaderData(':mockRouteId') as MockRouteLoaderData; + const { settings } = useRootLoaderData(); + const [logs, setLogs] = useState(null); + const [timeline, setTimeline] = useState([]); + const [logEntryId, setLogEntryId] = useState(null); + + // refetches logs whenever the path changes, or a response is recieved + useEffect(() => { + const mockbinUrl = mockServer.useInsomniaCloud ? getMockServiceURL() : mockServer.url; + + const fn = async () => { + const compoundId = mockRoute.parentId + mockRoute.name; + try { + const res: AxiosResponse = await window.main.axiosRequest({ + url: mockbinUrl + `/bin/log/${compoundId}`, + method: 'get', + }); + if (res?.data?.log) { + setLogs(res.data); + return; + } + console.log('Error: fetching logs from remote', { mockbinUrl, res }); + } catch (e) { + // network erros will be managed by the upsert trigger, so we can ignore them here + console.log({ mockbinUrl, e }); + } + }; + fn(); + }, [activeResponse?._id, mockRoute.name, mockRoute.parentId, mockServer.url, mockServer.useInsomniaCloud]); + + useEffect(() => { + const fn = async () => { + if (activeResponse) { + const timeline = await models.response.getTimeline(activeResponse, true); + setTimeline(timeline); + } + }; + fn(); + }, [activeResponse]); + + return ( + + +
+
+
+
Method
+
Size
+
Date
+
IP
+
Path
+ {logs?.log.entries?.map((row, index) => ( + +
setLogEntryId(index)} className={`${index % 2 === 0 ? '' : 'bg-[--hl-xs]'} cursor-pointer whitespace-nowrap text-sm truncate font-medium group-last-of-type:border-none focus:outline-none`}> +
{row.request.method}
+
+
setLogEntryId(index)} className={`${index % 2 === 0 ? '' : 'bg-[--hl-xs]'} cursor-pointer whitespace-nowrap text-sm truncate font-medium group-last-of-type:border-none focus:outline-none`}> +
{row.request.bodySize + row.request.headersSize}
+
setLogEntryId(index)} className={`${index % 2 === 0 ? '' : 'bg-[--hl-xs]'} cursor-pointer whitespace-nowrap text-sm truncate font-medium group-last-of-type:border-none focus:outline-none`}> +
{getTimeFromNow(row.startedDateTime, false)}
+
+
setLogEntryId(index)} className={`${index % 2 === 0 ? '' : 'bg-[--hl-xs]'} cursor-pointer whitespace-nowrap text-sm truncate font-medium group-last-of-type:border-none focus:outline-none`}> +
{row.clientIPAddress}
+
+
setLogEntryId(index)} className={`${index % 2 === 0 ? '' : 'bg-[--hl-xs]'} cursor-pointer whitespace-nowrap truncate text-sm font-medium group-last-of-type:border-none focus:outline-none`}> +
{row.request.url}
+
+
+ )).reverse()} +
+
+ {logEntryId !== null && logs?.log.entries?.[logEntryId] && ( +
+ +
+ )} +
+
+ + {activeResponse && { }} + editorFontSize={settings.editorFontSize} + error={activeResponse.error} + filter={''} + filterHistory={[]} + getBody={() => models.response.getBodyBuffer(activeResponse)} + previewMode={PREVIEW_MODE_SOURCE} + responseId={activeResponse._id} + updateFilter={activeResponse.error ? undefined : () => { }} + url={activeResponse.url} + />} + + + + + + + +
+ ); +}; diff --git a/packages/insomnia/src/ui/components/mocks/mock-url-bar.tsx b/packages/insomnia/src/ui/components/mocks/mock-url-bar.tsx new file mode 100644 index 000000000..33f258250 --- /dev/null +++ b/packages/insomnia/src/ui/components/mocks/mock-url-bar.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { Button } from 'react-aria-components'; +import { useRouteLoaderData } from 'react-router-dom'; + +import { getMockServiceURL, HTTP_METHODS } from '../../../common/constants'; +import { MockRouteLoaderData, useMockRoutePatcher } from '../../routes/mock-route'; +import { Dropdown, DropdownButton, DropdownItem, ItemContent } from '../base/dropdown'; +import { Icon } from '../icon'; +import { showModal } from '../modals'; +import { AlertModal } from '../modals/alert-modal'; + +export const MockUrlBar = ({ onPathUpdate, onSend }: { onPathUpdate: (path: string) => void; onSend: (path: string) => void }) => { + const { mockServer, mockRoute } = useRouteLoaderData(':mockRouteId') as MockRouteLoaderData; + const patchMockRoute = useMockRoutePatcher(); + const [pathInput, setPathInput] = useState(mockRoute.name); + const mockbinUrl = mockServer.useInsomniaCloud ? getMockServiceURL() : mockServer.url; + return (
+ + {mockRoute.method}{' '} + + + } + >{HTTP_METHODS.map(method => ( + + patchMockRoute(mockRoute._id, { method })} + /> + + ))} + +
+ +
+ +
+ onPathUpdate(pathInput)} value={pathInput} onChange={e => setPathInput(e.currentTarget.value)} /> +
+
+ + +
+
); +}; diff --git a/packages/insomnia/src/ui/components/modals/alert-modal.tsx b/packages/insomnia/src/ui/components/modals/alert-modal.tsx index 5174c5b3b..e4a6a80cc 100644 --- a/packages/insomnia/src/ui/components/modals/alert-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/alert-modal.tsx @@ -43,7 +43,7 @@ export const AlertModal = forwardRef((_, ref) => { const { message, title, addCancel, okLabel } = state; return ( - + {title || 'Uh Oh!'} {message} diff --git a/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx b/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx index 2c07ae0ef..cd802e84f 100644 --- a/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx +++ b/packages/insomnia/src/ui/components/modals/workspace-settings-modal.tsx @@ -1,13 +1,15 @@ import React from 'react'; -import { Button, Dialog, Heading, Input, Label, Modal, ModalOverlay } from 'react-aria-components'; +import { Button, Dialog, Heading, Input, Label, Modal, ModalOverlay, Radio, RadioGroup, TextField } from 'react-aria-components'; import { useFetcher } from 'react-router-dom'; import { useParams } from 'react-router-dom'; import { database as db } from '../../../common/database'; import { getWorkspaceLabel } from '../../../common/get-workspace-label'; import * as models from '../../../models/index'; +import { MockServer } from '../../../models/mock-server'; import { isRequest } from '../../../models/request'; import { isScratchpad, Workspace } from '../../../models/workspace'; +import { Link } from '../base/link'; import { PromptButton } from '../base/prompt-button'; import { Icon } from '../icon'; import { MarkdownEditor } from '../markdown-editor'; @@ -15,9 +17,10 @@ import { MarkdownEditor } from '../markdown-editor'; interface Props { onClose: () => void; workspace: Workspace; + mockServer: MockServer | null; } -export const WorkspaceSettingsModal = ({ workspace, onClose }: Props) => { +export const WorkspaceSettingsModal = ({ workspace, mockServer, onClose }: Props) => { const hasDescription = !!workspace.description; const isScratchpadWorkspace = isScratchpad(workspace); @@ -25,6 +28,7 @@ export const WorkspaceSettingsModal = ({ workspace, onClose }: Props) => { const { organizationId, projectId } = useParams<{ organizationId: string; projectId: string }>(); const workspaceFetcher = useFetcher(); + const mockServerFetcher = useFetcher(); const workspacePatcher = (workspaceId: string, patch: Partial) => { workspaceFetcher.submit({ ...patch, workspaceId }, { action: `/organization/${organizationId}/project/${projectId}/workspace/update`, @@ -32,6 +36,13 @@ export const WorkspaceSettingsModal = ({ workspace, onClose }: Props) => { encType: 'application/json', }); }; + const mockServerPatcher = (mockServerId: string, patch: Partial) => { + mockServerFetcher.submit({ ...patch, mockServerId }, { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/mock-server/update`, + method: 'post', + encType: 'application/json', + }); + }; return ( {
- -
- Actions - { - const docs = await db.withDescendants(workspace, models.request.type); - const requests = docs.filter(isRequest); - for (const req of requests) { - await models.response.removeForRequest(req._id); - } - close(); - }} - className="width-auto btn btn--clicky inline-block space-left" - > - Clear All Responses -
= ({ useState(false); const patchRequest = useRequestPatcher(); - useState(false); const handleImportQueryFromUrl = () => { let query; @@ -122,6 +121,7 @@ export const RequestPane: FC = ({ const contentType = getContentTypeFromHeaders(activeRequest.headers) || activeRequest.body.mimeType; + return ( diff --git a/packages/insomnia/src/ui/components/panes/response-pane.tsx b/packages/insomnia/src/ui/components/panes/response-pane.tsx index 149f68306..588d738c1 100644 --- a/packages/insomnia/src/ui/components/panes/response-pane.tsx +++ b/packages/insomnia/src/ui/components/panes/response-pane.tsx @@ -14,6 +14,7 @@ import { useRootLoaderData } from '../../routes/root'; import { PanelContainer, TabItem, Tabs } from '../base/tabs'; import { PreviewModeDropdown } from '../dropdowns/preview-mode-dropdown'; import { ResponseHistoryDropdown } from '../dropdowns/response-history-dropdown'; +import { MockResponseExtractor } from '../editors/mock-response-extractor'; import { ErrorBoundary } from '../error-boundary'; import { showError } from '../modals'; import { ResponseTimer } from '../response-timer'; @@ -223,6 +224,9 @@ export const ResponsePane: FC = ({ /> + + + {runningRequests[activeRequest._id] && ( .join(' ') ); -function getTimeFromNow(timestamp: string | number | Date, titleCase: boolean): string { +export function getTimeFromNow(timestamp: string | number | Date, titleCase: boolean): string { const date = new Date(timestamp); let text = formatDistanceToNowStrict(date, { addSuffix: true }); const now = new Date(); diff --git a/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx b/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx index 78b1dc0d6..f02eaca00 100644 --- a/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx +++ b/packages/insomnia/src/ui/components/websockets/realtime-response-pane.tsx @@ -44,13 +44,6 @@ const EventViewWrapper = styled.div({ height: '100%', }); -const PaneBodyContent = styled.div({ - height: '100%', - width: '100%', - display: 'grid', - gridTemplateRows: 'repeat(auto-fit, minmax(0, 1fr))', -}); - const EventSearchFormControl = styled.div({ outline: 'none', width: '100%', @@ -180,7 +173,7 @@ const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response }> - +
{response.error ? : <> @@ -246,7 +239,7 @@ const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response }> )} } - +
import('./routes/workspace')); const UnitTest = lazy(() => import('./routes/unit-test')); const Debug = lazy(() => import('./routes/debug')); const Design = lazy(() => import('./routes/design')); +const MockServer = lazy(() => import('./routes/mock-server')); initializeSentry(); initializeLogging(); @@ -357,6 +358,13 @@ const router = createMemoryRouter( await import('./routes/request') ).createRequestAction(...args), }, + { + path: 'request/new-mock-send', + action: async (...args) => + ( + await import('./routes/request') + ).createAndSendToMockbinAction(...args), + }, { path: 'request/delete', action: async (...args) => @@ -425,6 +433,64 @@ const router = createMemoryRouter( }, ], }, + { + path: 'mock-server', + id: 'mock-server', + loader: async (...args) => + (await import('./routes/mock-server')).loader( + ...args, + ), + element: ( + }> + + + ), + children: [ + { + path: 'update', + action: async (...args) => + ( + await import('./routes/actions') + ).updateMockServerAction(...args), + }, + { + path: 'mock-route', + id: 'mock-route', + children: [ + { + path: ':mockRouteId', + id: ':mockRouteId', + loader: async (...args) => + ( + await import('./routes/mock-route') + ).loader(...args), + element: , + }, + { + path: 'new', + action: async (...args) => + ( + await import('./routes/actions') + ).createMockRouteAction(...args), + }, + { + path: ':mockRouteId/update', + action: async (...args) => + ( + await import('./routes/actions') + ).updateMockRouteAction(...args), + }, + { + path: ':mockRouteId/delete', + action: async (...args) => + ( + await import('./routes/actions') + ).deleteMockRouteAction(...args), + }, + ], + }, + ], + }, { path: 'cacert', children: [ diff --git a/packages/insomnia/src/ui/routes/actions.tsx b/packages/insomnia/src/ui/routes/actions.tsx index 48f38b9b3..cee483329 100644 --- a/packages/insomnia/src/ui/routes/actions.tsx +++ b/packages/insomnia/src/ui/routes/actions.tsx @@ -5,7 +5,7 @@ import { ActionFunction, redirect } from 'react-router-dom'; import * as session from '../../account/session'; import { parseApiSpec, resolveComponentSchemaRefs } from '../../common/api-specs'; -import { ACTIVITY_DEBUG, ACTIVITY_SPEC, getAIServiceURL } from '../../common/constants'; +import { ACTIVITY_DEBUG, getAIServiceURL } from '../../common/constants'; import { database } from '../../common/database'; import { database as db } from '../../common/database'; import { importResourcesToWorkspace, scanResources } from '../../common/import'; @@ -16,7 +16,7 @@ import { isRemoteProject } from '../../models/project'; import { isRequest, Request } from '../../models/request'; import { isRequestGroup, isRequestGroupId } from '../../models/request-group'; import { UnitTest } from '../../models/unit-test'; -import { isCollection, Workspace } from '../../models/workspace'; +import { isCollection, scopeToActivity, Workspace } from '../../models/workspace'; import { WorkspaceMeta } from '../../models/workspace-meta'; import { getSendRequestCallback } from '../../network/unit-test-feature'; import { initializeLocalBackendProjectAndMarkForSync } from '../../sync/vcs/initialize-backend-project'; @@ -279,7 +279,7 @@ export const createNewWorkspaceAction: ActionFunction = async ({ invariant(typeof name === 'string', 'Name is required'); const scope = formData.get('scope'); - invariant(scope === 'design' || scope === 'collection', 'Scope is required'); + invariant(scope === 'design' || scope === 'collection' || scope === 'mock-server', 'Scope is required'); const flushId = await database.bufferChanges(); @@ -289,6 +289,12 @@ export const createNewWorkspaceAction: ActionFunction = async ({ parentId: projectId, }); + if (scope === 'mock-server') { + // create a mock server under the workspace with the same name + await models.mockServer.getOrCreateForParentId(workspace._id, { name }); + return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${scopeToActivity(workspace.scope)}`); + } + if (scope === 'design') { await models.apiSpec.getOrCreateForParentId(workspace._id); } @@ -313,10 +319,7 @@ export const createNewWorkspaceAction: ActionFunction = async ({ : SegmentEvent.documentCreate, }); - return redirect( - `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${workspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC - }` - ); + return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/${scopeToActivity(workspace.scope)}`); }; export const deleteWorkspaceAction: ActionFunction = async ({ @@ -409,11 +412,8 @@ export const duplicateWorkspaceAction: ActionFunction = async ({ request, params } catch (e) { console.warn('Failed to initialize local backend project', e); } - - return redirect( - `/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${newWorkspace.scope === 'collection' ? ACTIVITY_DEBUG : ACTIVITY_SPEC - }` - ); + const activity = scopeToActivity(newWorkspace.scope); + return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${newWorkspace._id}/${activity}`); }; export const updateWorkspaceAction: ActionFunction = async ({ request }) => { @@ -431,6 +431,14 @@ export const updateWorkspaceAction: ActionFunction = async ({ request }) => { fileName: patch.name || workspace.name, }); } + if (workspace.scope === 'mock-server') { + const mockServer = await models.mockServer.getByParentId(workspaceId); + invariant(mockServer, 'No MockServer found for this workspace'); + + await models.mockServer.update(mockServer, { + name: patch.name || workspace.name, + }); + } await models.workspace.update(workspace, patch); @@ -1190,3 +1198,46 @@ export const reorderCollectionAction: ActionFunction = async ({ request, params return null; }; + +export const createMockRouteAction: ActionFunction = async ({ request }) => { + const patch = await request.json(); + invariant(typeof patch.name === 'string', 'Name is required'); + invariant(typeof patch.parentId === 'string', 'parentId is required'); + const mockRoute = await models.mockRoute.create(patch); + // create a single hidden request under the mock route for testing the mock endpoint + await models.request.create({ parentId: mockRoute._id, isPrivate: true }); + return null; +}; +export const updateMockRouteAction: ActionFunction = async ({ request, params }) => { + const { mockRouteId } = params; + invariant(typeof mockRouteId === 'string', 'Mock route id is required'); + const patch = await request.json(); + + const mockRoute = await models.mockRoute.getById(mockRouteId); + invariant(mockRoute, 'Mock route is required'); + + await models.mockRoute.update(mockRoute, patch); + return null; +}; +export const deleteMockRouteAction: ActionFunction = async ({ request, params }) => { + const { organizationId, projectId, workspaceId, mockRouteId } = params; + invariant(typeof mockRouteId === 'string', 'Mock route id is required'); + const mockRoute = await models.mockRoute.getById(mockRouteId); + invariant(mockRoute, 'mockRoute not found'); + const { isSelected } = await request.json(); + + await models.mockRoute.remove(mockRoute); + if (isSelected) { + return redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server`); + } + return null; +}; +export const updateMockServerAction: ActionFunction = async ({ request, params }) => { + const { workspaceId } = params; + invariant(typeof workspaceId === 'string', 'Workspace ID is required'); + const patch = await request.json(); + const mockServer = await models.mockServer.getByParentId(workspaceId); + invariant(mockServer, 'Mock server not found'); + await models.mockServer.update(mockServer, patch); + return null; +}; diff --git a/packages/insomnia/src/ui/routes/mock-route.tsx b/packages/insomnia/src/ui/routes/mock-route.tsx new file mode 100644 index 000000000..27ace6a28 --- /dev/null +++ b/packages/insomnia/src/ui/routes/mock-route.tsx @@ -0,0 +1,271 @@ +import { AxiosResponse } from 'axios'; +import React from 'react'; +import { LoaderFunction, useFetcher, useParams, useRouteLoaderData } from 'react-router-dom'; + +import { CONTENT_TYPE_JSON, CONTENT_TYPE_PLAINTEXT, CONTENT_TYPE_XML, CONTENT_TYPE_YAML, contentTypesMap, getMockServiceURL, RESPONSE_CODE_REASONS } from '../../common/constants'; +import { database as db } from '../../common/database'; +import { getResponseCookiesFromHeaders, HarResponse } from '../../common/har'; +import * as models from '../../models'; +import { MockRoute } from '../../models/mock-route'; +import { MockServer } from '../../models/mock-server'; +import { Request, RequestHeader } from '../../models/request'; +import { Response } from '../../models/response'; +import { invariant } from '../../utils/invariant'; +import { Dropdown, DropdownButton, DropdownItem, ItemContent } from '../components/base/dropdown'; +import { PanelContainer, TabItem, Tabs } from '../components/base/tabs'; +import { CodeEditor } from '../components/codemirror/code-editor'; +import { MockResponseHeadersEditor } from '../components/editors/mock-response-headers-editor'; +import { MockResponsePane } from '../components/mocks/mock-response-pane'; +import { MockUrlBar } from '../components/mocks/mock-url-bar'; +import { showAlert } from '../components/modals'; +import { EmptyStatePane } from '../components/panes/empty-state-pane'; +import { Pane, PaneBody, PaneHeader } from '../components/panes/pane'; +import { SvgIcon } from '../components/svg-icon'; + +export interface MockRouteLoaderData { + mockServer: MockServer; + mockRoute: MockRoute; + activeResponse?: Response; +} + +export const loader: LoaderFunction = async ({ params }): Promise => { + const { organizationId, projectId, workspaceId, mockRouteId } = params; + invariant(organizationId, 'Organization ID is required'); + invariant(projectId, 'Project ID is required'); + invariant(workspaceId, 'Workspace ID is required'); + invariant(mockRouteId, 'Mock route ID is required'); + const mockServer = await models.mockServer.getByParentId(workspaceId); + invariant(mockServer, 'Mock server is required'); + const mockRoute = await models.mockRoute.getById(mockRouteId); + invariant(mockRoute, 'Mock route is required'); + // get current response via request children of + // TODO: use the same request for try mock rather than creating lots of child requests + const reqIds = (await models.request.findByParentId(mockRouteId)).map(r => r._id); + + const responses = await db.findMostRecentlyModified(models.response.type, { parentId: { $in: reqIds } }); + return { + mockServer, + mockRoute, + activeResponse: responses?.[0], + }; +}; + +const mockContentTypes = [ + CONTENT_TYPE_PLAINTEXT, + CONTENT_TYPE_JSON, + CONTENT_TYPE_XML, + CONTENT_TYPE_YAML, +]; +// mockbin expect a HAR response structure +export const mockRouteToHar = ({ statusCode, statusText, mimeType, headersArray, body }: { statusCode: number; statusText: string; mimeType: string; headersArray: RequestHeader[]; body: string }): HarResponse => { + const validHeaders = headersArray.filter(({ name }) => !!name); + return { + status: +statusCode, + statusText: statusText || RESPONSE_CODE_REASONS[+statusCode] || '', + httpVersion: 'HTTP/1.1', + headers: validHeaders, + cookies: getResponseCookiesFromHeaders(validHeaders), + content: { + size: Buffer.byteLength(body), + mimeType, + text: body, + compression: 0, + }, + headersSize: -1, + bodySize: -1, + redirectURL: '', + }; +}; +export const useMockRoutePatcher = () => { + const { organizationId, projectId, workspaceId } = useParams<{ organizationId: string; projectId: string; workspaceId: string }>(); + const fetcher = useFetcher(); + return (id: string, patch: Partial) => { + fetcher.submit(JSON.stringify(patch), { + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server/mock-route/${id}/update`, + method: 'post', + encType: 'application/json', + }); + }; +}; +interface MockbinResult { + data: string; // returns only the mock server id not the path +} +interface MockbinError { + data: { errors: string }; +} +export const MockRouteRoute = () => { + const { mockServer, mockRoute } = useRouteLoaderData(':mockRouteId') as MockRouteLoaderData; + const patchMockRoute = useMockRoutePatcher(); + const mockbinUrl = mockServer.useInsomniaCloud ? getMockServiceURL() : mockServer.url; + + const requestFetcher = useFetcher(); + const { organizationId, projectId, workspaceId } = useParams() as { organizationId: string; projectId: string; workspaceId: string }; + + const upsertBinOnRemoteFromResponse = async (compoundId: string | null): Promise => { + try { + + const res: AxiosResponse = await window.main.axiosRequest({ + url: mockbinUrl + `/bin/upsert/${compoundId}`, + method: 'put', + data: mockRouteToHar({ + statusCode: mockRoute.statusCode, + statusText: mockRoute.statusText, + headersArray: mockRoute.headers, + mimeType: mockRoute.mimeType, + body: mockRoute.body, + }), + }); + if (typeof res?.data === 'object' && 'errors' in res?.data && typeof res?.data?.errors === 'string') { + console.error('error response', res?.data?.errors); + return res?.data?.errors; + } + if (typeof res?.data === 'string') { + return ''; + } + console.log('Error: invalid response from remote', { res, mockbinUrl }); + return 'Invalid response from ' + mockbinUrl; + } catch (e) { + console.log(e); + return 'Unhandled error: ' + e.message; + } + }; + + const createandSendPrivateRequest = (patch: Partial) => + requestFetcher.submit(JSON.stringify(patch), + { + encType: 'application/json', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/new-mock-send`, + method: 'post', + }); + + const upsertMockbinHar = async (pathInput?: string) => { + const compoundId = mockRoute.parentId + pathInput; + const error = await upsertBinOnRemoteFromResponse(compoundId); + if (error) { + showAlert({ + title: 'Network error', + message: ( +
+

The request failed due to a network error: {mockbinUrl}

+
+              {error}
+            
+
+ ), + }); + return; + } + patchMockRoute(mockRoute._id, { + name: pathInput, + }); + }; + const onSend = async (pathInput: string) => { + await upsertMockbinHar(pathInput); + const compoundId = mockRoute.parentId + pathInput; + createandSendPrivateRequest({ + url: mockbinUrl + '/bin/' + compoundId, + method: mockRoute.method, + parentId: mockRoute._id, + }); + }; + const onBlurTriggerUpsert = () => upsertMockbinHar(mockRoute.name); + return ( + + + + + + + + {mockRoute.mimeType ? 'Response ' + contentTypesMap[mockRoute.mimeType]?.[0] : 'Response Body'} + + + } + > + {mockContentTypes.map(contentType => ( + + patchMockRoute(mockRoute._id, { mimeType: contentType })} + /> + + ))} + + } + > + {mockRoute.mimeType ? + ( patchMockRoute(mockRoute._id, { body })} + onBlur={onBlurTriggerUpsert} + mode={mockRoute.mimeType} + placeholder="..." + />) : + (} + documentationLinks={[]} + secondaryAction="Set up the mock body and headers you would like to return" + title="Choose a mock body to return as a response" + />)} + + + + + + +
+
+ +
+
+
+
+ +
+
+
+
+
+
+
+ ); +}; + +export const MockRouteResponse = () => { + return ( + + ); +}; diff --git a/packages/insomnia/src/ui/routes/mock-server.tsx b/packages/insomnia/src/ui/routes/mock-server.tsx new file mode 100644 index 000000000..79940413f --- /dev/null +++ b/packages/insomnia/src/ui/routes/mock-server.tsx @@ -0,0 +1,294 @@ +import type { IconName } from '@fortawesome/fontawesome-svg-core'; +import React, { Suspense } from 'react'; +import { Breadcrumb, Breadcrumbs, Button, GridList, GridListItem, Menu, MenuTrigger, Popover } from 'react-aria-components'; +import { LoaderFunction, NavLink, Route, Routes, useFetcher, useLoaderData, useNavigate, useParams } from 'react-router-dom'; + +import * as models from '../../models'; +import { MockRoute } from '../../models/mock-route'; +import { invariant } from '../../utils/invariant'; +import { WorkspaceDropdown } from '../components/dropdowns/workspace-dropdown'; +import { WorkspaceSyncDropdown } from '../components/dropdowns/workspace-sync-dropdown'; +import { EditableInput } from '../components/editable-input'; +import { Icon } from '../components/icon'; +import { showModal, showPrompt } from '../components/modals'; +import { AskModal } from '../components/modals/ask-modal'; +import { EmptyStatePane } from '../components/panes/empty-state-pane'; +import { SidebarLayout } from '../components/sidebar-layout'; +import { SvgIcon } from '../components/svg-icon'; +import { formatMethodName } from '../components/tags/method-tag'; +import { MockRouteResponse, MockRouteRoute, useMockRoutePatcher } from './mock-route'; +interface LoaderData { + mockServerId: string; + mockRoutes: MockRoute[]; +} +export const loader: LoaderFunction = async ({ params }): Promise => { + const { organizationId, projectId, workspaceId } = params; + invariant(organizationId, 'Organization ID is required'); + invariant(projectId, 'Project ID is required'); + invariant(workspaceId, 'Workspace ID is required'); + + const activeWorkspace = await models.workspace.getById(workspaceId); + invariant(activeWorkspace, 'Workspace not found'); + const activeMockServer = await models.mockServer.getByParentId(workspaceId); + invariant(activeMockServer, 'Mock Server not found'); + const mockRoutes = await models.mockRoute.findByParentId(activeMockServer._id); + + return { + mockServerId: activeMockServer._id, + mockRoutes, + }; +}; + +const MockServerRoute = () => { + const { organizationId, projectId, workspaceId, mockRouteId } = useParams() as { + organizationId: string; + projectId: string; + workspaceId: string; + mockRouteId: string; + }; + const { mockServerId, mockRoutes } = useLoaderData() as LoaderData; + const fetcher = useFetcher(); + const navigate = useNavigate(); + const patchMockRoute = useMockRoutePatcher(); + const mockRouteActionList: { + id: string; + name: string; + icon: IconName; + action: (id: string, name: string) => void; + }[] = [ + { + id: 'rename', + name: 'Rename', + icon: 'edit', + action: id => { + showPrompt({ + title: 'Rename mock route', + defaultValue: mockRoutes.find(s => s._id === id)?.name, + submitName: 'Rename', + onComplete: name => { + name && patchMockRoute(id, { name }); + }, + }); + }, + }, + { + id: 'delete-route', + name: 'Delete mock route', + icon: 'trash', + action: (id, name) => { + showModal(AskModal, { + title: 'Delete route', + message: `Do you really want to delete "${name}"?`, + yesText: 'Delete', + noText: 'Cancel', + onDone: async (isYes: boolean) => { + if (isYes) { + fetcher.submit( + { + isSelected: mockRouteId === id, + }, + { + encType: 'application/json', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server/mock-route/${id}/delete`, + method: 'POST', + } + ); + } + }, + }); + }, + }, + + ]; + + return +
+ + + + + + + + + + + +
+
+ +
+ ({ + id: route._id, + key: route._id, + ...route, + }))} + className="overflow-y-auto flex-1 data-[empty]:py-0 py-[--padding-sm]" + disallowEmptySelection + selectedKeys={[mockRouteId]} + selectionMode="single" + onSelectionChange={keys => { + if (keys !== 'all') { + const value = keys.values().next().value; + navigate({ + pathname: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server/mock-route/${value}`, + }); + } + }} + > + {item => { + return ( + +
+ + + {formatMethodName(item.method)} + + { + navigate({ + pathname: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server/mock-route/${item._id}`, + }); + }} + onSubmit={name => { + name && fetcher.submit( + { name }, + { + encType: 'application/json', + action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server/mock-route/${item._id}/update`, + method: 'POST', + } + ); + }} + /> + + + + + { + mockRouteActionList + .find(({ id }) => key === id) + ?.action(item._id, item.name); + }} + items={mockRouteActionList} + className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none" + > + {item => ( + + + {item.name} + + )} + + + +
+
+ ); + }} +
+ + + } + + renderPaneOne={ + + + + } + /> + } + documentationLinks={[]} + title="Select or create a route to configured response here" + /> + } + /> + } + renderPaneTwo={ + + + + } + /> + } + documentationLinks={[]} + title="Select or create a route to see activity here" + /> + } + /> + } + />; +}; + +export default MockServerRoute; diff --git a/packages/insomnia/src/ui/routes/organization.tsx b/packages/insomnia/src/ui/routes/organization.tsx index 175403373..de1007854 100644 --- a/packages/insomnia/src/ui/routes/organization.tsx +++ b/packages/insomnia/src/ui/routes/organization.tsx @@ -755,16 +755,16 @@ const OrganizationRoute = () => { - - - Made with + + + Made with by - Kong - - + Kong + + diff --git a/packages/insomnia/src/ui/routes/project.tsx b/packages/insomnia/src/ui/routes/project.tsx index 917df5133..4f2bea314 100644 --- a/packages/insomnia/src/ui/routes/project.tsx +++ b/packages/insomnia/src/ui/routes/project.tsx @@ -54,6 +54,7 @@ import { ApiSpec } from '../../models/api-spec'; import { CaCertificate } from '../../models/ca-certificate'; import { ClientCertificate } from '../../models/client-certificate'; import { sortProjects } from '../../models/helpers/project'; +import { MockServer } from '../../models/mock-server'; import { isOwnerOfOrganization, isPersonalOrganization, isScratchpadOrganizationId } from '../../models/organization'; import { Organization } from '../../models/organization'; import { @@ -61,7 +62,7 @@ import { Project, SCRATCHPAD_PROJECT_ID, } from '../../models/project'; -import { isDesign, Workspace } from '../../models/workspace'; +import { isDesign, scopeToActivity, Workspace, WorkspaceScope } from '../../models/workspace'; import { WorkspaceMeta } from '../../models/workspace-meta'; import { showModal } from '../../ui/components/modals'; import { AskModal } from '../../ui/components/modals/ask-modal'; @@ -121,6 +122,7 @@ export interface WorkspaceWithMetadata { specFormat: 'openapi' | 'swagger' | null; name: string; apiSpec: ApiSpec | null; + mockServer: MockServer | null; specFormatVersion: string | null; workspace: Workspace; workspaceMeta: WorkspaceMeta; @@ -263,6 +265,7 @@ export interface ProjectLoaderData { allFilesCount: number; documentsCount: number; collectionsCount: number; + mockServersCount: number; projectsCount: number; activeProject: Project; projects: Project[]; @@ -303,7 +306,7 @@ export const loader: LoaderFunction = async ({ workspace: Workspace ): Promise => { const apiSpec = await models.apiSpec.getByParentId(workspace._id); - + const mockServer = await models.mockServer.getByParentId(workspace._id); let spec: ParsedApiSpec['contents'] = null; let specFormat: ParsedApiSpec['format'] = null; let specFormatVersion: ParsedApiSpec['formatVersion'] = null; @@ -368,6 +371,7 @@ export const loader: LoaderFunction = async ({ specFormat, name: workspace.name, apiSpec, + mockServer, specFormatVersion, workspaceMeta, clientCertificates, @@ -453,6 +457,9 @@ export const loader: LoaderFunction = async ({ collectionsCount: workspacesWithMetaData.filter( w => w.workspace.scope === 'collection' ).length, + mockServersCount: workspacesWithMetaData.filter( + w => w.workspace.scope === 'mock-server' + ).length, }; }; @@ -463,6 +470,7 @@ const ProjectRoute: FC = () => { projects, allFilesCount, collectionsCount, + mockServersCount, documentsCount, projectsCount, learningFeature, @@ -570,6 +578,28 @@ const ProjectRoute: FC = () => { }); }; + const createNewMockServer = () => { + showPrompt({ + title: 'Create New Mock Server', + submitName: 'Create', + placeholder: 'My Mock Server', + defaultValue: 'My Mock Server', + selectText: true, + onComplete: async (name: string) => { + fetcher.submit( + { + name, + scope: 'mock-server', + }, + { + action: `/organization/${organizationId}/project/${activeProject._id}/workspace/new`, + method: 'post', + } + ); + }, + }); + }; + const createNewProjectFetcher = useFetcher(); useEffect(() => { @@ -654,6 +684,12 @@ const ProjectRoute: FC = () => { action: createNewDocument, }, { + id: 'new-mock-server', + name: 'Mock Server', + icon: 'server', + action: createNewMockServer, + }, + { id: 'import', name: 'Import', icon: 'file-import', @@ -704,11 +740,36 @@ const ProjectRoute: FC = () => { run: createNewCollection, }, }, + { + id: 'mock-server', + label: `Mock (${mockServersCount})`, + icon: 'server', + action: { + icon: 'plus', + label: 'New Mock Server', + run: createNewMockServer, + }, + }, ]; const organization = organizations.find(o => o.id === organizationId); const isUserOwner = organization && accountId && isOwnerOfOrganization({ organization, accountId }); const isPersonalOrg = organization && isPersonalOrganization(organization); + const scopeToIconMap: Record = { + design: 'file', + collection: 'bars', + 'mock-server': 'server', + }; + const scopeToBgColorMap: Record = { + design: 'bg-[--color-info]', + collection: 'bg-[--color-surprise]', + 'mock-server': 'bg-[--color-warning]', + }; + const scopeToTextColorMap: Record = { + design: 'text-[--color-info-font]', + collection: 'text-[--color-surprise-font]', + 'mock-server': 'text-[--color-warning-font]', + }; return ( @@ -1153,8 +1214,11 @@ const ProjectRoute: FC = () => { aria-label="Workspaces" items={workspacesWithPresence} onAction={key => { + // hack to workaround gridlist not have access to workspace scope + const [id, scope] = key.toString().split('|'); + const activity = scopeToActivity(scope as WorkspaceScope); navigate( - `/organization/${organizationId}/project/${projectId}/workspace/${key}/debug` + `/organization/${organizationId}/project/${projectId}/workspace/${id}/${activity}` ); }} className="data-[empty]:flex data-[empty]:justify-center grid [grid-template-columns:repeat(auto-fit,200px)] [grid-template-rows:repeat(auto-fit,200px)] gap-4 p-[--padding-md]" @@ -1169,40 +1233,34 @@ const ProjectRoute: FC = () => { ); } - return ( - setImportModalType('file')} - cloneFromGit={importFromGit} - /> - ); - }} + return ( + setImportModalType('file')} + cloneFromGit={importFromGit} + /> + ); + }} > - {item => { - return ( - -
-
- {isDesign(item.workspace) ? ( -
- -
- ) : ( -
- -
- )} - - {isDesign(item.workspace) - ? 'Document' - : 'Collection'} - + {item => { + return ( + +
+
+
+ +
+ + {item.workspace.scope} +
{item.presence.length > 0 && ( diff --git a/packages/insomnia/src/ui/routes/remote-collections.tsx b/packages/insomnia/src/ui/routes/remote-collections.tsx index ca4413aa1..e765b5ba1 100644 --- a/packages/insomnia/src/ui/routes/remote-collections.tsx +++ b/packages/insomnia/src/ui/routes/remote-collections.tsx @@ -56,6 +56,13 @@ async function getSyncItems({ const testSuites = await models.unitTestSuite.findByParentId(workspaceId); const tests = await database.find(models.unitTest.type, { parentId: { $in: testSuites.map(t => t._id) } }); + const mockServer = await models.mockServer.getByParentId(workspaceId); + if (mockServer) { + syncItemsList.push(mockServer); + const mockRoutes = await database.find(models.mockRoute.type, { parentId: mockServer._id }); + mockRoutes.map(m => syncItemsList.push(m)); + } + const baseEnvironment = await models.environment.getByParentId(workspaceId); invariant(baseEnvironment, 'Base environment not found'); diff --git a/packages/insomnia/src/ui/routes/request.tsx b/packages/insomnia/src/ui/routes/request.tsx index b240444b2..44b29da95 100644 --- a/packages/insomnia/src/ui/routes/request.tsx +++ b/packages/insomnia/src/ui/routes/request.tsx @@ -17,6 +17,8 @@ import { CookieJar } from '../../models/cookie-jar'; import { GrpcRequest, isGrpcRequestId } from '../../models/grpc-request'; import { GrpcRequestMeta } from '../../models/grpc-request-meta'; import * as requestOperations from '../../models/helpers/request-operations'; +import { MockRoute } from '../../models/mock-route'; +import { MockServer } from '../../models/mock-server'; import { getPathParametersFromUrl, isEventStreamRequest, isRequest, Request, RequestAuthentication, RequestBody, RequestHeader, RequestParameter } from '../../models/request'; import { isRequestMeta, RequestMeta } from '../../models/request-meta'; import { RequestVersion } from '../../models/request-version'; @@ -49,12 +51,14 @@ export interface RequestLoaderData { activeResponse: Response | null; responses: Response[]; requestVersions: RequestVersion[]; + mockServerAndRoutes: (MockServer & { routes: MockRoute[] })[]; } export const loader: LoaderFunction = async ({ params }): Promise => { const { organizationId, projectId, requestId, workspaceId } = params; invariant(requestId, 'Request ID is required'); invariant(workspaceId, 'Workspace ID is required'); + invariant(projectId, 'Project ID is required'); const activeRequest = await requestOperations.getById(requestId); if (!activeRequest) { throw redirect(`/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug`); @@ -85,12 +89,21 @@ export const loader: LoaderFunction = async ({ params }): Promise r.environmentId === activeWorkspaceMeta.activeEnvironmentId); const responses = (filterResponsesByEnv ? filteredResponses : allResponses) .sort((a: BaseModel, b: BaseModel) => (a.created > b.created ? -1 : 1)); + + // Q(gatzjames): load mock servers here or somewhere else? + const mockServers = await models.mockServer.findByProjectId(projectId); + const mockRoutes = await database.find(models.mockRoute.type, { parentId: { $in: mockServers.map(s => s._id) } }); + const mockServerAndRoutes = mockServers.map(mockServer => ({ + ...mockServer, + routes: mockRoutes.filter(route => route.parentId === mockServer._id), + })); return { activeRequest, activeRequestMeta, activeResponse, responses, requestVersions: await models.requestVersion.findByParentId(requestId), + mockServerAndRoutes, } as RequestLoaderData | WebSocketRequestLoaderData; }; @@ -415,6 +428,37 @@ export const sendAction: ActionFunction = async ({ request, params }) => { return redirect(`${url.pathname}?${url.searchParams}`); } }; +export const createAndSendToMockbinAction: ActionFunction = async ({ request }) => { + const patch = await request.json() as Partial; + invariant(typeof patch.url === 'string', 'URL is required'); + invariant(typeof patch.method === 'string', 'method is required'); + invariant(typeof patch.parentId === 'string', 'mock route ID is required'); + const mockRoute = await models.mockRoute.getById(patch.parentId); + invariant(mockRoute, 'mock route not found'); + const childRequests = await models.request.findByParentId(mockRoute._id); + const testRequest = childRequests[0]; + invariant(testRequest, 'mock route is missing a testing request'); + const req = await models.request.update(testRequest, patch); + + const { + environment, + settings, + clientCertificates, + caCert, + activeEnvironmentId } = await fetchRequestData(req._id); + + const renderResult = await tryToInterpolateRequest(req, environment._id, RENDER_PURPOSE_SEND); + const renderedRequest = await tryToTransformRequestWithPlugins(renderResult); + const res = await sendCurlAndWriteTimeline( + renderedRequest, + clientCertificates, + caCert, + settings, + ); + const response = await responseTransform(res, activeEnvironmentId, renderedRequest, renderResult.context); + await models.response.create(response); + return null; +}; export const deleteAllResponsesAction: ActionFunction = async ({ params }) => { const { workspaceId, requestId } = params; invariant(typeof requestId === 'string', 'Request ID is required'); diff --git a/packages/insomnia/src/ui/routes/unit-test.tsx b/packages/insomnia/src/ui/routes/unit-test.tsx index 91b881927..0bf2d882a 100644 --- a/packages/insomnia/src/ui/routes/unit-test.tsx +++ b/packages/insomnia/src/ui/routes/unit-test.tsx @@ -1,4 +1,4 @@ -import { IconName } from '@fortawesome/fontawesome-svg-core'; +import type { IconName } from '@fortawesome/fontawesome-svg-core'; import React, { FC, Fragment, Suspense, useState } from 'react'; import { Breadcrumb, diff --git a/packages/insomnia/src/ui/routes/workspace.tsx b/packages/insomnia/src/ui/routes/workspace.tsx index b5cc86cd6..dcd43ab41 100644 --- a/packages/insomnia/src/ui/routes/workspace.tsx +++ b/packages/insomnia/src/ui/routes/workspace.tsx @@ -16,6 +16,7 @@ import { GitRepository } from '../../models/git-repository'; import { GrpcRequest } from '../../models/grpc-request'; import { GrpcRequestMeta } from '../../models/grpc-request-meta'; import { sortProjects } from '../../models/helpers/project'; +import { MockServer } from '../../models/mock-server'; import { Project } from '../../models/project'; import { Request } from '../../models/request'; import { isRequestGroup, RequestGroup } from '../../models/request-group'; @@ -43,6 +44,7 @@ export interface WorkspaceLoaderData { baseEnvironment: Environment; subEnvironments: Environment[]; activeApiSpec: ApiSpec | null; + activeMockServer: MockServer | null; clientCertificates: ClientCertificate[]; caCertificate: CaCertificate | null; projects: Project[]; @@ -106,6 +108,7 @@ export const workspaceLoader: LoaderFunction = async ({ invariant(activeCookieJar, 'Cookie jar not found'); const activeApiSpec = await models.apiSpec.getByParentId(workspaceId); + const activeMockServer = await models.mockServer.getByParentId(workspaceId); const clientCertificates = await models.clientCertificate.findByParentId( workspaceId, ); @@ -266,6 +269,7 @@ export const workspaceLoader: LoaderFunction = async ({ subEnvironments, baseEnvironment, activeApiSpec, + activeMockServer, clientCertificates, caCertificate: await models.caCertificate.findByParentId(workspaceId), projects,