feat: mock resources (#6760)

* project ui changes

* project ui changes

* create mock server model

* model mock server similarly to design doc

* use spec modelling and nav

* layout pass

* rename requestbin to mock-route

* sidebar ui pass

* load mock server

* add url bar

* can navigate to headers

* Refactor mock server and mock route creation and
retrieval

* route crud

* sidebar layout

* add delete guuard

* patch route

* Add mock response tab to request pane

* wire up mock servers in requests

* Update mock server and route selection in
RequestPane

* make it work without internet

* can create bin

* pass body and headers to bin

* can fetch logs but cant see em

* split out response pane for hmr

* basic table

* extract mock url bar

* add header tab

* made a dumb cache

* url bar pass

* send request and create response

* wire up timeline

* wire up preview

* timeline useeffect

* move to action

* fix types

* empty states

* rebase updating aria

* use har type

* can edit bins

* cookie support

* wire up status

* status text

* magic status text

* ui

* always use put rather than create bin

* add url to mock route

* scroll bar

* add content types

* validation

* fix flake

* improve logs

* fix outlet warning

* fix send to mock endpoint

* switch table to grid

* handle errors

* rotate log

* create mockbin on open if needed

* add full url ux

* reverse log order

* binId from store

* remove http method

* rename prefix

* use server Id for bin id

* fix copy

* show log har

* fix url bar

* fix button padding

* tailwind

* method select

* remove default status text

* full tailwind

* fix breadcrumb

* default to json

* move copy to end, remove save

* error msg

* only patch when needed

* fix ws colors

* fix command palette

* add isMock helper

* revert local storage mechanism

* fix redirect

* fix ignore upsert

* extract to constant

* ui test

* hide actions from mock-server

* fix code editor onBlur

* lift update to route

* refactor to return only errors

* add url to mock server model

* select mock ui pass

* can modify url in settings

* use server url from db if selected

* hide url option

* fix lint error

* extract to file

* remove binResponse

* can sync

* move things around

* rename name path sync

* fix type check

* capture kvp onBlur

* fix error message

* basic mock test

* wire up mock patcher and navigate

* rename component

* remove url prop of route

* fix lint

* fix test

* temporary skip e2e test

* fix constant url

* fix migration

* remove console logs

* rename function

* only create a single hidden request

---------

Co-authored-by: gatzjames <jamesgatzos@gmail.com>
This commit is contained in:
Jack Kavanagh 2024-01-24 16:38:31 +01:00 committed by GitHub
parent 653d497763
commit 557e5c0c6e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1732 additions and 144 deletions

View File

@ -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');

View File

@ -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

View File

@ -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();
});

View File

@ -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<string, string[]> = {
export const contentTypesMap: Record<string, string[]> = {
[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';

View File

@ -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;

View File

@ -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) {

View File

@ -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,
]);
}

View File

@ -5,6 +5,7 @@ export interface StringInfo {
type StringId =
| 'collection'
| 'mock'
| 'document'
| 'project'
| 'workspace'
@ -18,6 +19,10 @@ export const strings: Record<StringId, StringInfo> = {
singular: 'Collection',
plural: 'Collections',
},
mock: {
singular: 'Mock',
plural: 'Mocks',
},
document: {
singular: 'Document',
plural: 'Documents',

View File

@ -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<string, any> = {
[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,

View File

@ -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<BaseModel, 'type'>): model is MockRoute => (
model.type === type
);
export function migrate(doc: MockRoute) {
return doc;
}
export function create(patch: Partial<MockRoute> = {}) {
if (!patch.parentId) {
throw new Error('New MockRoute missing `parentId`: ' + JSON.stringify(patch));
}
return db.docCreate<MockRoute>(type, patch);
}
export function update(
mockRoute: MockRoute,
patch: Partial<MockRoute> = {},
) {
return db.docUpdate<MockRoute>(mockRoute, patch);
}
export function getById(id: string) {
return db.get<MockRoute>(type, id);
}
export function findByParentId(parentId: string) {
return db.find<MockRoute>(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<MockRoute>(type);
}

View File

@ -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<BaseModel, 'type'>): model is MockServer => (
model.type === type
);
export function migrate(doc: MockServer) {
return doc;
}
export function create(patch: Partial<MockServer> = {}) {
if (!patch.parentId) {
throw new Error('New MockServer missing `parentId`: ' + JSON.stringify(patch));
}
return db.docCreate<MockServer>(type, patch);
}
export async function getOrCreateForParentId(
workspaceId: string,
patch: Partial<MockServer> = {},
) {
const mockServer = await db.getWhere<MockServer>(type, {
parentId: workspaceId,
});
if (!mockServer) {
return db.docCreate<MockServer>(type, { ...patch, parentId: workspaceId });
}
return mockServer;
}
export function update(
mockServer: MockServer,
patch: Partial<MockServer> = {},
) {
return db.docUpdate<MockServer>(mockServer, patch);
}
export function getById(id: string) {
return db.get<MockServer>(type, id);
}
export function getByParentId(parentId: string) {
return db.getWhere<MockServer>(type, { parentId });
}
export async function findByProjectId(projectId: string) {
const workspaces = await workspace.findByParentId(projectId);
return db.find<MockServer>(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<MockServer>(type);
}

View File

@ -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'>) => (
workspace.scope === WorkspaceScopeKeys.collection
);
export const isMockServer = (workspace: Pick<Workspace, 'scope'>) => (
workspace.scope === WorkspaceScopeKeys.mockServer
);
export const init = (): BaseWorkspace => ({
name: `New ${strings.collection.singular}`,
description: '',
@ -135,9 +141,12 @@ type MigrationWorkspace = Merge<Workspace, { scope: OldScopeTypes | Workspace['s
* Ensure workspace scope is set to a valid entry
*/
function _migrateScope(workspace: MigrationWorkspace) {
if (workspace.scope === WorkspaceScopeKeys.design || workspace.scope === WorkspaceScopeKeys.collection) {
if (workspace.scope === WorkspaceScopeKeys.design
|| workspace.scope === WorkspaceScopeKeys.collection
|| workspace.scope === WorkspaceScopeKeys.mockServer) {
return workspace as Workspace;
}
// designer and spec => 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;
}
};

View File

@ -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);

View File

@ -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,

View File

@ -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);

View File

@ -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<CodeEditorHandle, CodeEditorProps>(({
};
}
};
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<CodeEditorHandle, CodeEditorProps>(({
}
});
useUnmount(() => {
onBlur?.();
codeMirror.current?.toTextArea();
codeMirror.current?.closeHintDropdown();
codeMirror.current = null;
@ -471,9 +467,9 @@ export const CodeEditor = forwardRef<CodeEditorHandle, CodeEditorProps>(({
}, [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) => {

View File

@ -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<OneLineEditorHandle, OneLineEditorProps>
readOnly,
type,
onPaste,
onBlur,
}, ref) => {
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const codeMirror = useRef<CodeMirror.EditorFromTextArea | null>(null);
@ -123,6 +125,12 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
}
});
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') {

View File

@ -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<string, IconName> = {
design: 'file',
collection: 'bars',
'mock-server': 'server',
};
return (
<ModalOverlay isOpen={isOpen} onOpenChange={setIsOpen} isDismissable className="w-full h-[--visual-viewport-height] fixed z-10 top-0 left-0 flex pt-20 justify-center bg-black/30">
<Modal className="max-w-2xl h-max w-full rounded-md flex flex-col overflow-hidden border border-solid border-[--hl-sm] max-h-[80vh] bg-[--color-bg] text-[--color-font]">
@ -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: <Icon icon={workspace.scope === 'collection' ? 'bars' : 'file'} className="text-[--color-font] w-10 flex-shrink-0 flex items-center justify-center" />,
id: workspace._id + '|' + workspace.scope,
icon: <Icon icon={scopeToIconMap[workspace.scope]} className="text-[--color-font] w-10 flex-shrink-0 flex items-center justify-center" />,
name: workspace.name,
description: '',
textValue: `${workspace.scope === 'collection' ? 'Collection' : 'Document'} ${workspace.name}`,
textValue: `${workspace.scope} ${workspace.name}`,
})),
},
]}

View File

@ -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> = 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> = props => {
{isSettingsModalOpen && (
<WorkspaceSettingsModal
workspace={workspace}
mockServer={mockServer}
onClose={() => setIsSettingsModalOpen(false)}
/>
)}

View File

@ -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 && (
<WorkspaceSettingsModal
workspace={activeWorkspace}
mockServer={activeMockServer}
onClose={() => setIsSettingsModalOpen(false)}
/>
)}

View File

@ -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 (
<div className="px-32 h-full flex flex-col justify-center">
<div className="flex place-content-center text-9xl pb-2">
<Icon icon="cube" />
</div>
<div className="flex place-content-center pb-2">
Export this response to a mock route.
</div>
<form
onSubmit={async e => {
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,
});
}
}}
>
<div className="form-row">
<div className="form-control form-control--outlined">
<label>
Choose Mock Server
<HelpTooltip position="top" className="space-left">
Select from created mock servers to add the route to
</HelpTooltip>
<select
value={selectedMockServer}
onChange={event => {
const selected = event.currentTarget.value;
setSelectedMockServer(selected);
}}
>
<option value="">-- Select... --</option>
{mockServerAndRoutes
.map(w => (
<option key={w._id} value={w._id}>
{w.name}
</option>
))
}
</select>
</label>
</div>
</div>
<div className="form-row">
<div className="form-control form-control--outlined">
<label>
Choose Mock Route
<HelpTooltip position="top" className="space-left">
Select from created mock routes to send this request to
</HelpTooltip>
<select
value={selectedMockRoute}
onChange={event => {
const selected = event.currentTarget.value;
setSelectedMockRoute(selected);
}}
>
<option value="">-- Select... --</option>
{mockServerAndRoutes.find(s => s._id === selectedMockServer)?.routes
.map(w => (
<option key={w._id} value={w._id}>
{w.name}
</option>
))
}
</select>
</label>
</div>
</div>
<div className="flex justify-end">
<Button
isDisabled={!selectedMockServer || !selectedMockRoute}
onPress={() => {
const mockWorkspaceId = mockServerAndRoutes.find(s => s._id === selectedMockServer)?.parentId;
navigate(`/organization/${organizationId}/project/${projectId}/workspace/${mockWorkspaceId}/mock-server/mock-route/${selectedMockRoute}`);
}}
className="mr-2 hover:no-underline bg-[--color-surprise] hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font-surprise] transition-colors rounded-sm"
>
Go to mock
</Button>
<Button
type="submit"
isDisabled={!selectedMockServer || !selectedMockRoute}
className="hover:no-underline bg-[--color-surprise] hover:bg-opacity-90 border border-solid border-[--hl-md] py-2 px-3 text-[--color-font-surprise] transition-colors rounded-sm"
>
Export
</Button>
</div>
</form>
</div>
);
};

View File

@ -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<Props> = ({
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 (
<div className="tall">
<CodeEditor
id="request-headers-editor"
onChange={handleBulkUpdate}
defaultValue={headersString}
enableNunjucks
onBlur={onBlur}
/>
</div>
);
}
return (
<KeyValueEditor
namePlaceholder="header"
valuePlaceholder="value"
descriptionPlaceholder="description"
pairs={mockRoute.headers}
handleGetAutocompleteNameConstants={getCommonHeaderNames}
handleGetAutocompleteValueConstants={getCommonHeaderValues}
onChange={onChangeHeaders}
isDisabled={isDisabled}
onBlur={onBlur}
/>
);
};

View File

@ -41,6 +41,7 @@ interface Props {
}[]) => void;
pairs: Pair[];
valuePlaceholder?: string;
onBlur?: (e: FocusEvent) => void;
}
export const KeyValueEditor: FC<Props> = ({
@ -56,6 +57,7 @@ export const KeyValueEditor: FC<Props> = ({
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<Props> = ({
descriptionPlaceholder={descriptionPlaceholder}
hideButtons
readOnly
onBlur={onBlur}
onClick={() => onChange([...pairs, {
// smelly
id: generateId('pair'),
@ -150,6 +153,7 @@ export const KeyValueEditor: FC<Props> = ({
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}

View File

@ -41,6 +41,7 @@ interface Props {
onClick?: () => void;
onKeydown?: (e: React.KeyboardEvent) => void;
showDescription: boolean;
onBlur?: (e: FocusEvent) => void;
}
export const Row: FC<Props> = ({
@ -60,6 +61,7 @@ export const Row: FC<Props> = ({
onKeydown,
valuePlaceholder,
showDescription,
onBlur,
}) => {
const { enabled } = useNunjucksEnabled();
@ -92,6 +94,7 @@ export const Row: FC<Props> = ({
getAutocompleteConstants={() => handleGetAutocompleteNameConstants?.(pair) || []}
readOnly={readOnly}
onChange={name => onChange({ ...pair, name })}
onBlur={onBlur}
/>
</div>
<div
@ -126,6 +129,7 @@ export const Row: FC<Props> = ({
) : (
<OneLineEditor
id={'key-value-editor__value' + pair.id}
onBlur={onBlur}
type="text"
readOnly={readOnly}
placeholder={valuePlaceholder || 'Value'}

View File

@ -0,0 +1,152 @@
import { AxiosResponse } from 'axios';
import React, { Fragment, useEffect, useState } from 'react';
import { useRouteLoaderData } from 'react-router-dom';
import { getMockServiceURL, PREVIEW_MODE_SOURCE } from '../../../common/constants';
import { HarRequest } from '../../../common/har';
import { ResponseTimelineEntry } from '../../../main/network/libcurl-promise';
import * as models from '../../../models';
import { MockRouteLoaderData } from '../../routes/mock-route';
import { useRootLoaderData } from '../../routes/root';
import { TabItem, Tabs } from '../base/tabs';
import { CodeEditor } from '../codemirror/code-editor';
import { getTimeFromNow } from '../time-from-now';
import { ResponseHeadersViewer } from '../viewers/response-headers-viewer';
import { ResponseTimelineViewer } from '../viewers/response-timeline-viewer';
import { ResponseViewer } from '../viewers/response-viewer';
interface MockbinLogOutput {
log: {
version: string;
creator: {
name: string;
version: string;
};
entries: [
{
startedDateTime: string;
clientIPAddress: string;
request: HarRequest;
}
];
};
}
export const MockResponsePane = () => {
const { mockServer, mockRoute, activeResponse } = useRouteLoaderData(':mockRouteId') as MockRouteLoaderData;
const { settings } = useRootLoaderData();
const [logs, setLogs] = useState<MockbinLogOutput | null>(null);
const [timeline, setTimeline] = useState<ResponseTimelineEntry[]>([]);
const [logEntryId, setLogEntryId] = useState<number | null>(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<MockbinLogOutput> = 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 (
<Tabs aria-label="Mock response">
<TabItem key="history" title="History">
<div className="h-full w-full grid grid-rows-[repeat(auto-fit,minmax(0,1fr))]">
<div className="w-full flex-1 overflow-hidden box-border overflow-y-scroll">
<div className="grid grid-cols-[repeat(5,auto)] divide-solid divide-y divide-[--hl-sm]">
<div className="uppercase p-2 bg-[--hl-sm] text-left text-xs font-semibold focus:outline-none">Method</div>
<div className="uppercase p-2 bg-[--hl-sm] text-left text-xs font-semibold focus:outline-none">Size</div>
<div className="uppercase p-2 bg-[--hl-sm] text-left text-xs font-semibold focus:outline-none">Date</div>
<div className="uppercase p-2 bg-[--hl-sm] text-left text-xs font-semibold focus:outline-none">IP</div>
<div className="uppercase p-2 bg-[--hl-sm] text-left text-xs font-semibold focus:outline-none">Path</div>
{logs?.log.entries?.map((row, index) => (
<Fragment key={row.startedDateTime}>
<div onClick={() => 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`}>
<div className='p-2'>{row.request.method}</div>
</div>
<div onClick={() => 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`}>
<div className='p-2'>{row.request.bodySize + row.request.headersSize}</div></div>
<div onClick={() => 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`}>
<div className='p-2 truncate'>{getTimeFromNow(row.startedDateTime, false)}</div>
</div>
<div onClick={() => 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`}>
<div className='p-2 truncate'>{row.clientIPAddress}</div>
</div>
<div onClick={() => 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`}>
<div className='p-2 truncate'>{row.request.url}</div>
</div>
</Fragment>
)).reverse()}
</div>
</div>
{logEntryId !== null && logs?.log.entries?.[logEntryId] && (
<div className='flex-1 h-full border-solid border border-[--hl-md]'>
<CodeEditor
id="log-body-preview"
key={logEntryId + logs?.log.entries?.[logEntryId].startedDateTime}
hideLineNumbers
mode={'text/json'}
defaultValue={JSON.stringify(logs?.log.entries?.[logEntryId], null, '\t')}
readOnly
/>
</div>
)}
</div>
</TabItem>
<TabItem key="preview" title="Preview">
{activeResponse && <ResponseViewer
key={activeResponse._id}
bytes={Math.max(activeResponse.bytesContent, activeResponse.bytesRead)}
contentType={activeResponse.contentType || ''}
disableHtmlPreviewJs={settings.disableHtmlPreviewJs}
disablePreviewLinks={settings.disableResponsePreviewLinks}
download={() => { }}
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}
/>}
</TabItem>
<TabItem key="headers" title="Headers">
<ResponseHeadersViewer headers={activeResponse?.headers || []} />
</TabItem>
<TabItem key="timeline" title="Timeline">
<ResponseTimelineViewer
key={activeResponse?._id}
timeline={timeline}
pinToBottom={true}
/>
</TabItem>
</Tabs>
);
};

View File

@ -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<string>(mockRoute.name);
const mockbinUrl = mockServer.useInsomniaCloud ? getMockServiceURL() : mockServer.url;
return (<div className='w-full flex justify-between urlbar'>
<Dropdown
className="method-dropdown"
triggerButton={
<DropdownButton className="pad-right pad-left vertically-center hover:bg-[--color-surprise] focus:bg-[--color-surprise]">
<span className={`http-method-${mockRoute.method}`}>{mockRoute.method}</span>{' '}
<i className="fa fa-caret-down space-left" />
</DropdownButton>
}
>{HTTP_METHODS.map(method => (
<DropdownItem key={method}>
<ItemContent
className={`http-method-${method}`}
label={method}
onClick={() => patchMockRoute(mockRoute._id, { method })}
/>
</DropdownItem>
))}
</Dropdown>
<div className='flex p-1'>
<Button
className="bg-[--hl-sm] px-3 rounded-sm"
onPress={() => {
const compoundId = mockRoute.parentId + pathInput;
showModal(AlertModal, {
title: 'Full URL',
message: mockbinUrl + '/bin/' + compoundId,
});
}}
>
<Icon icon="eye" /> Show URL
</Button>
</div>
<div className='flex flex-1 p-1 items-center'>
<input className='flex-1' onBlur={() => onPathUpdate(pathInput)} value={pathInput} onChange={e => setPathInput(e.currentTarget.value)} />
</div>
<div className='flex p-1'>
<Button
className="bg-[--hl-sm] px-3 rounded-sm"
onPress={() => {
const compoundId = mockRoute.parentId + pathInput;
window.clipboard.writeText(mockbinUrl + '/bin/' + compoundId);
}}
>
<Icon icon="copy" />
</Button>
<Button
className="px-5 ml-1 text-[--color-font-surprise] bg-[--color-surprise] hover:bg-opacity-90 rounded-sm"
onPress={() => onSend(pathInput)}
>
Test
</Button>
</div>
</div>);
};

View File

@ -43,7 +43,7 @@ export const AlertModal = forwardRef<AlertModalHandle, ModalProps>((_, ref) => {
const { message, title, addCancel, okLabel } = state;
return (
<Modal ref={modalRef} skinny>
<Modal ref={modalRef}>
<ModalHeader>{title || 'Uh Oh!'}</ModalHeader>
<ModalBody className="wide pad">{message}</ModalBody>
<ModalFooter>

View File

@ -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<Workspace>) => {
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<MockServer>) => {
mockServerFetcher.submit({ ...patch, mockServerId }, {
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspace._id}/mock-server/update`,
method: 'post',
encType: 'application/json',
});
};
return (
<ModalOverlay
@ -63,45 +74,97 @@ export const WorkspaceSettingsModal = ({ workspace, onClose }: Props) => {
</Button>
</div>
<div className='rounded flex-1 w-full overflow-hidden basis-96 flex flex-col gap-2 select-none overflow-y-auto'>
<Label className='flex flex-col gap-1 px-1' aria-label='Host'>
<span>Name</span>
<Input
name='name'
type='text'
required
readOnly={isScratchpadWorkspace}
defaultValue={activeWorkspaceName}
placeholder='Awesome API'
className='p-2 w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors'
onChange={event => workspacePatcher(workspace._id, { name: event.target.value })}
/>
</Label>
<Label className='flex flex-col px-1 gap-1' aria-label='Description'>
<span>Description</span>
<MarkdownEditor
defaultPreviewMode={hasDescription}
placeholder="Write a description"
defaultValue={workspace.description}
onChange={(description: string) => {
workspacePatcher(workspace._id, { description });
}}
/>
<Label className='text-sm text-[--hl]'>
Name
</Label>
<Input
name='name'
type='text'
required
readOnly={isScratchpadWorkspace}
defaultValue={activeWorkspaceName}
placeholder='Awesome API'
className='p-2 w-full rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors'
onChange={event => workspacePatcher(workspace._id, { name: event.target.value })}
/>
{workspace.scope !== 'mock-server' && (
<>
<Label className='text-sm text-[--hl]' aria-label='Description'>
Description
</Label>
<MarkdownEditor
defaultPreviewMode={hasDescription}
placeholder="Write a description"
defaultValue={workspace.description}
onChange={(description: string) => {
workspacePatcher(workspace._id, { description });
}}
/>
<Heading>Actions</Heading>
<PromptButton
onClick={async () => {
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"
>
<i className="fa fa-trash-o" /> Clear All Responses
</PromptButton>
</>)}
{Boolean(workspace.scope === 'mock-server' && mockServer) && (
<>
<RadioGroup onChange={value => mockServer && mockServerPatcher(mockServer._id, { useInsomniaCloud: value === 'remote' })} name="type" defaultValue={mockServer?.useInsomniaCloud ? 'remote' : 'local'} className="flex flex-col gap-2">
<Label className="text-sm text-[--hl]">
Mock type
</Label>
<div className="flex gap-2">
<Radio
value="remote"
className="data-[selected]:border-[--color-surprise] flex-1 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<Icon icon="globe" />
<Heading className="text-lg font-bold">Cloud Mock</Heading>
<p className='pt-2'>
The mock server runs on Insomnia cloud. Ideal for collaboration.
</p>
</Radio>
<Radio
value="local"
className="data-[selected]:border-[--color-surprise] flex-1 data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise] hover:bg-[--hl-xs] focus:bg-[--hl-sm] border border-solid border-[--hl-md] rounded p-4 focus:outline-none transition-colors"
>
<Icon icon="laptop" />
<Heading className="text-lg font-bold">Local Mock</Heading>
<p className="pt-2">
The mock server runs locally.
</p>
</Radio>
</div>
</RadioGroup>
{!mockServer?.useInsomniaCloud && <TextField
autoFocus
name="name"
defaultValue={mockServer?.url || ''}
className={`group relative flex-1 flex flex-col gap-2 ${mockServer?.useInsomniaCloud ? 'disabled' : ''}`}
>
<Label className='text-sm text-[--hl]'>
Local mock server URL
</Label>
<Input
placeholder="http://localhost:8080"
onChange={e => mockServer && mockServerPatcher(mockServer._id, { url: e.target.value })}
className="py-1 placeholder:italic w-full pl-2 pr-7 rounded-sm border border-solid border-[--hl-sm] bg-[--color-bg] text-[--color-font] focus:outline-none focus:ring-1 focus:ring-[--hl-md] transition-colors"
/>
<Label className='text-sm text-[--hl]'>
You can run a mock server locally. <Link href="https://github.com/Kong/mockbin" className='underline'>Learn more</Link>
</Label>
</TextField>}
</>
)}
</div>
<Heading>Actions</Heading>
<PromptButton
onClick={async () => {
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"
>
<i className="fa fa-trash-o" /> Clear All Responses
</PromptButton>
<div className='flex items-center gap-2 justify-end'>
<Button
onPress={close}

View File

@ -73,11 +73,12 @@ const AlmostSquareButton = styled(Button)({
interface Props {
createRequestCollection: () => void;
createDesignDocument: () => void;
createMockServer: () => void;
importFrom: () => void;
cloneFromGit: () => void;
}
export const EmptyStatePane: FC<Props> = ({ createRequestCollection, createDesignDocument, importFrom, cloneFromGit }) => {
export const EmptyStatePane: FC<Props> = ({ createRequestCollection, createDesignDocument, createMockServer, importFrom, cloneFromGit }) => {
const { organizationId } = useParams<{ organizationId: string }>();
const { organizations } = useOrganizationLoaderData();
const currentOrg = organizations.find(organization => (organization.id === organizationId));
@ -144,6 +145,16 @@ export const EmptyStatePane: FC<Props> = ({ createRequestCollection, createDesig
}}
/> New Document
</SquareButton>
<SquareButton
onClick={createMockServer}
>
<i
className='fa fa-server'
style={{
fontSize: 'var(--font-size-xl)',
}}
/> New Mock Server
</SquareButton>
</div>
<Divider
style={{

View File

@ -76,7 +76,6 @@ export const RequestPane: FC<Props> = ({
useState(false);
const patchRequest = useRequestPatcher();
useState(false);
const handleImportQueryFromUrl = () => {
let query;
@ -122,6 +121,7 @@ export const RequestPane: FC<Props> = ({
const contentType =
getContentTypeFromHeaders(activeRequest.headers) ||
activeRequest.body.mimeType;
return (
<Pane type="request">
<PaneHeader>

View File

@ -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<Props> = ({
/>
</ErrorBoundary>
</TabItem>
<TabItem key="mock-response" title="Mock Response">
<MockResponseExtractor />
</TabItem>
</Tabs>
<ErrorBoundary errorClassName="font-error pad text-center">
{runningRequests[activeRequest._id] && <ResponseTimer

View File

@ -16,7 +16,7 @@ const toTitleCase = (value: string) => (
.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();

View File

@ -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 }>
</PaneHeader>
<Tabs aria-label="Curl response pane tabs">
<TabItem key="events" title="Events">
<PaneBodyContent>
<div className='h-full w-full grid grid-rows-[repeat(auto-fit,minmax(0,1fr))]'>
{response.error ? <ResponseErrorViewer url={response.url} error={response.error} />
: <>
<EventLogTableWrapper>
@ -246,7 +239,7 @@ const RealtimeActiveResponsePane: FC<{ response: WebSocketResponse | Response }>
</EventViewWrapper>
)}
</>}
</PaneBodyContent>
</div>
</TabItem>
<TabItem
key="headers"

View File

@ -42,6 +42,7 @@ const Workspace = lazy(() => 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: (
<Suspense fallback={<AppLoadingIndicator />}>
<MockServer />
</Suspense>
),
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: <Outlet />,
},
{
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: [

View File

@ -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;
};

View File

@ -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<MockRouteLoaderData> => {
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<Response>(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<MockRoute>) => {
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<string> => {
try {
const res: AxiosResponse<MockbinResult | MockbinError> = 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<Request>) =>
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: (
<div>
<p>The request failed due to a network error: {mockbinUrl}</p>
<pre className="pad-top-sm force-wrap selectable">
<code className="wide">{error}</code>
</pre>
</div>
),
});
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 (
<Pane type="request">
<PaneHeader>
<MockUrlBar key={mockRoute._id + mockRoute.name} onSend={onSend} onPathUpdate={upsertMockbinHar} />
</PaneHeader>
<PaneBody>
<Tabs aria-label="Mock response config">
<TabItem
key="content-type"
title={<Dropdown
aria-label='Change Body Type'
triggerButton={
<DropdownButton>
{mockRoute.mimeType ? 'Response ' + contentTypesMap[mockRoute.mimeType]?.[0] : 'Response Body'}
<i className="fa fa-caret-down space-left" />
</DropdownButton>
}
>
{mockContentTypes.map(contentType => (
<DropdownItem key={contentType}>
<ItemContent
label={contentTypesMap[contentType]?.[1]}
onClick={() => patchMockRoute(mockRoute._id, { mimeType: contentType })}
/>
</DropdownItem>
))}
</Dropdown>
}
>
{mockRoute.mimeType ?
(<CodeEditor
id="mock-response-body-editor"
key={mockRoute._id}
showPrettifyButton
defaultValue={mockRoute.body}
enableNunjucks
onChange={body => patchMockRoute(mockRoute._id, { body })}
onBlur={onBlurTriggerUpsert}
mode={mockRoute.mimeType}
placeholder="..."
/>) :
(<EmptyStatePane
icon={<SvgIcon icon="bug" />}
documentationLinks={[]}
secondaryAction="Set up the mock body and headers you would like to return"
title="Choose a mock body to return as a response"
/>)}
</TabItem>
<TabItem key="headers" title="Response Headers">
<MockResponseHeadersEditor
onBlur={onBlurTriggerUpsert}
bulk={false}
/>
</TabItem>
<TabItem key="status" title="Response Status">
<PanelContainer className="pad">
<div className="form-row">
<div className='form-control form-control--outlined'>
<label htmlFor="mock-response-status-code-editor">
<small>Status Code</small>
<input
id="mock-response-status-code-editor"
type="number"
defaultValue={mockRoute.statusCode}
onChange={e => patchMockRoute(mockRoute._id, { statusCode: parseInt(e.currentTarget.value, 10) })}
onBlur={onBlurTriggerUpsert}
placeholder="200"
/>
</label>
</div>
</div>
<div className="form-row">
<div className='form-control form-control--outlined'>
<label htmlFor="mock-response-status-text-editor">
<small>Status Text</small>
<input
id="mock-response-status-text-editor"
type="string"
defaultValue={mockRoute.statusText}
onChange={e => patchMockRoute(mockRoute._id, { statusText: e.currentTarget.value })}
onBlur={onBlurTriggerUpsert}
placeholder={RESPONSE_CODE_REASONS[mockRoute.statusCode || 200] || 'Unknown'}
/>
</label>
</div>
</div>
</PanelContainer>
</TabItem>
</Tabs>
</PaneBody>
</Pane>
);
};
export const MockRouteResponse = () => {
return (
<MockResponsePane />
);
};

View File

@ -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<LoaderData> => {
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 <SidebarLayout
className="new-sidebar"
renderPageSidebar={
<div className="flex flex-1 flex-col overflow-hidden divide-solid divide-y divide-[--hl-md]">
<div className="flex flex-col items-start gap-2 justify-between p-[--padding-sm]">
<Breadcrumbs className='flex list-none items-center m-0 p-0 gap-2 font-bold w-full'>
<Breadcrumb className="flex select-none items-center gap-2 text-[--color-font] h-full outline-none data-[focused]:outline-none">
<NavLink data-testid="project" className="px-1 py-1 aspect-square h-7 flex flex-shrink-0 outline-none data-[focused]:outline-none items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm" to={`/organization/${organizationId}/project/${projectId}`}>
<Icon className='text-xs' icon="chevron-left" />
</NavLink>
<span aria-hidden role="separator" className='text-[--hl-lg] h-4 outline outline-1' />
</Breadcrumb>
<Breadcrumb className="flex truncate select-none items-center gap-2 text-[--color-font] h-full outline-none data-[focused]:outline-none">
<WorkspaceDropdown />
</Breadcrumb>
</Breadcrumbs>
</div>
<div className="p-[--padding-sm]">
<Button
className="px-4 py-1 flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
onPress={() => {
fetcher.submit(
{
name: '/',
parentId: mockServerId,
},
{
encType: 'application/json',
method: 'post',
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/mock-server/mock-route/new`,
}
);
}}
>
<Icon icon="plus" />
New Mock Route
</Button>
</div>
<GridList
aria-label="Mock Routes"
items={mockRoutes.map(route => ({
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 (
<GridListItem
key={item._id}
id={item._id}
textValue={item.name}
className="group outline-none select-none w-full"
>
<div className="flex select-none outline-none group-aria-selected:text-[--color-font] relative group-hover:bg-[--hl-xs] group-focus:bg-[--hl-sm] transition-colors gap-2 px-4 items-center h-[--line-height-xs] w-full overflow-hidden text-[--hl]">
<span className="group-aria-selected:bg-[--color-surprise] transition-colors top-0 left-0 absolute h-full w-[2px] bg-transparent" />
<span
className={
`w-10 flex-shrink-0 flex text-[0.65rem] rounded-sm border border-solid border-[--hl-sm] items-center justify-center
${{
'GET': 'text-[--color-font-surprise] bg-[rgba(var(--color-surprise-rgb),0.5)]',
'POST': 'text-[--color-font-success] bg-[rgba(var(--color-success-rgb),0.5)]',
'HEAD': 'text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]',
'OPTIONS': 'text-[--color-font-info] bg-[rgba(var(--color-info-rgb),0.5)]',
'DELETE': 'text-[--color-font-danger] bg-[rgba(var(--color-danger-rgb),0.5)]',
'PUT': 'text-[--color-font-warning] bg-[rgba(var(--color-warning-rgb),0.5)]',
'PATCH': 'text-[--color-font-notice] bg-[rgba(var(--color-notice-rgb),0.5)]',
}[item.method] || 'text-[--color-font] bg-[--hl-md]'}`
}
>
{formatMethodName(item.method)}
</span>
<EditableInput
value={item.name}
name="name"
ariaLabel="Mock route name"
onSingleClick={() => {
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',
}
);
}}
/>
<span className="flex-1" />
<MenuTrigger>
<Button
aria-label="Project Actions"
className="opacity-0 items-center hover:opacity-100 focus:opacity-100 data-[pressed]:opacity-100 flex group-focus:opacity-100 group-hover:opacity-100 justify-center h-6 aspect-square data-[pressed]:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
>
<Icon icon="caret-down" />
</Button>
<Popover className="min-w-max">
<Menu
aria-label="Project Actions Menu"
selectionMode="single"
onAction={key => {
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 => (
<Breadcrumb
key={item.id}
id={item.id}
className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors"
aria-label={item.name}
>
<Icon icon={item.icon} />
<span>{item.name}</span>
</Breadcrumb>
)}
</Menu>
</Popover>
</MenuTrigger>
</div>
</GridListItem>
);
}}
</GridList>
<WorkspaceSyncDropdown />
</div>}
renderPaneOne={<Routes>
<Route
path={'mock-route/:mockRouteId/*'}
element={
<Suspense>
<MockRouteRoute />
</Suspense>
}
/>
<Route
path="*"
element={
<EmptyStatePane
icon={<SvgIcon icon="bug" />}
documentationLinks={[]}
title="Select or create a route to configured response here"
/>
}
/>
</Routes>}
renderPaneTwo={<Routes>
<Route
path={'mock-route/:mockRouteId/*'}
element={
<Suspense>
<MockRouteResponse />
</Suspense>
}
/>
<Route
path="*"
element={
<EmptyStatePane
icon={<SvgIcon icon="bug" />}
documentationLinks={[]}
title="Select or create a route to see activity here"
/>
}
/>
</Routes>}
/>;
};
export default MockServerRoute;

View File

@ -755,16 +755,16 @@ const OrganizationRoute = () => {
</Tooltip>
</TooltipTrigger>
<span className='w-[1px] h-full bg-[--hl-sm]' />
<Link>
<a
className="flex focus:outline-none focus:underline gap-1 items-center text-xs text-[--color-font] px-[--padding-md]"
href="https://konghq.com/"
>
Made with
<Link>
<a
className="flex focus:outline-none focus:underline gap-1 items-center text-xs text-[--color-font] px-[--padding-md]"
href="https://konghq.com/"
>
Made with
<Icon className="text-[--color-surprise-font]" icon="heart" /> by
Kong
</a>
</Link>
Kong
</a>
</Link>
</div>
</div>
</div>

View File

@ -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<WorkspaceWithMetadata> => {
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<string, IconName> = {
design: 'file',
collection: 'bars',
'mock-server': 'server',
};
const scopeToBgColorMap: Record<string, string> = {
design: 'bg-[--color-info]',
collection: 'bg-[--color-surprise]',
'mock-server': 'bg-[--color-warning]',
};
const scopeToTextColorMap: Record<string, string> = {
design: 'text-[--color-info-font]',
collection: 'text-[--color-surprise-font]',
'mock-server': 'text-[--color-warning-font]',
};
return (
<ErrorBoundary>
@ -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 (
<EmptyStatePane
createRequestCollection={createNewCollection}
createDesignDocument={createNewDocument}
importFrom={() => setImportModalType('file')}
cloneFromGit={importFromGit}
/>
);
}}
return (
<EmptyStatePane
createRequestCollection={createNewCollection}
createDesignDocument={createNewDocument}
createMockServer={createNewMockServer}
importFrom={() => setImportModalType('file')}
cloneFromGit={importFromGit}
/>
);
}}
>
{item => {
return (
<GridListItem
key={item._id}
id={item._id}
textValue={item.name}
className="flex-1 overflow-hidden flex-col outline-none p-[--padding-md] flex select-none w-full rounded-sm hover:shadow-md aspect-square ring-1 ring-[--hl-md] hover:ring-[--hl-sm] focus:ring-[--hl-lg] hover:bg-[--hl-xs] focus:bg-[--hl-sm] transition-all"
>
<div className="flex gap-2 h-[20px]">
<div className="flex pr-2 h-full flex-shrink-0 items-center rounded-sm gap-2 bg-[--hl-xs] text-[--color-font] text-sm">
{isDesign(item.workspace) ? (
<div className="px-2 flex justify-center items-center h-[20px] w-[20px] rounded-s-sm bg-[--color-info] text-[--color-font-info]">
<Icon icon="file" />
</div>
) : (
<div className="px-2 flex justify-center items-center h-[20px] w-[20px] rounded-s-sm bg-[--color-surprise] text-[--color-font-surprise]">
<Icon icon="bars" />
</div>
)}
<span>
{isDesign(item.workspace)
? 'Document'
: 'Collection'}
</span>
{item => {
return (
<GridListItem
key={item._id}
// hack to workaround gridlist not have access to workspace scope
id={item._id + '|' + item.workspace.scope}
textValue={item.name}
className="flex-1 overflow-hidden flex-col outline-none p-[--padding-md] flex select-none w-full rounded-sm hover:shadow-md aspect-square ring-1 ring-[--hl-md] hover:ring-[--hl-sm] focus:ring-[--hl-lg] hover:bg-[--hl-xs] focus:bg-[--hl-sm] transition-all"
>
<div className="flex gap-2 h-[20px]">
<div className="flex pr-2 h-full flex-shrink-0 items-center rounded-sm gap-2 bg-[--hl-xs] text-[--color-font] text-sm">
<div className={`${scopeToBgColorMap[item.workspace.scope]} ${scopeToTextColorMap[item.workspace.scope]} px-2 flex justify-center items-center h-[20px] w-[20px] rounded-s-sm`}>
<Icon icon={scopeToIconMap[item.workspace.scope]} />
</div>
<span>
{item.workspace.scope}
</span>
</div>
<span className="flex-1" />
{item.presence.length > 0 && (

View File

@ -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');

View File

@ -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<RequestLoaderData | WebSocketRequestLoaderData | GrpcRequestLoaderData> => {
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<RequestLoaderD
.filter((r: Response | WebSocketResponse) => 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<MockRoute>(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<Request>;
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');

View File

@ -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,

View File

@ -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,