Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions application/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export { deleteEnvironment } from './environment/delete-environment.use-case';
export { updateEnvironment, type UpdateEnvironmentPatch } from './environment/update-environment.use-case';
export { Insomnia, type InsomniaDependencies } from './insomnia';
export { deleteRequest } from './request/delete-request.use-case';
export { updateRequest, type UpdateRequestPatch } from './request/update-request.use-case';
export { createWorkspace } from './workspace/create-workspace.use-case';
export { deleteWorkspace } from './workspace/delete-workspace.use-case';
export { moveWorkspace } from './workspace/move-workspace.use-case';
Expand Down
9 changes: 9 additions & 0 deletions application/src/request/request.module.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ import { RequestModule } from './request.module';
import { buildRequest, createFakeRequestRepository } from './testing/fake-request-repository';

describe('RequestModule', () => {
it('updateById() delegates to updateRequest', async () => {
const request = buildRequest({ name: 'Original' });
const module = new RequestModule(createFakeRequestRepository([request]));

const updated = await module.updateById(request._id, { name: 'Renamed' });

expect(updated.name).toBe('Renamed');
});

it('deleteById() delegates to deleteRequest', async () => {
const request = buildRequest();
const repository = createFakeRequestRepository([request]);
Expand Down
5 changes: 5 additions & 0 deletions application/src/request/request.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import type { RequestRepository } from 'insomnia-domain';

import { deleteRequest } from './delete-request.use-case';
import { updateRequest, type UpdateRequestPatch } from './update-request.use-case';

export class RequestModule {
constructor(private readonly requestRepository: RequestRepository) {}

updateById(id: string, patch: UpdateRequestPatch) {
return updateRequest(this.requestRepository, id, patch);
}

deleteById(id: string) {
return deleteRequest(this.requestRepository, id);
}
Expand Down
88 changes: 88 additions & 0 deletions application/src/request/update-request.use-case.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Request } from 'insomnia-domain';
import { describe, expect, it } from 'vitest';

import { buildRequest, createFakeRequestRepository } from './testing/fake-request-repository';
import { updateRequest } from './update-request.use-case';

describe('updateRequest', () => {
it('applies a plain patch as-is', async () => {
const request = buildRequest({ name: 'Original' });
const repository = createFakeRequestRepository([request]);

const updated = await updateRequest(repository, request._id, { name: 'Renamed' });

expect(updated.name).toBe('Renamed');
expect((await repository.findById(request._id))?.name).toBe('Renamed');
});

it('throws when the request does not exist', async () => {
const repository = createFakeRequestRepository([]);

await expect(updateRequest(repository, 'req_missing', { name: 'Renamed' })).rejects.toThrow('Request not found');
});

it('recomputes pathParameters, preserving existing values, when the url changes', async () => {
const request = buildRequest({
url: 'https://example.com/users/:id',
pathParameters: [{ name: 'id', value: '42' }],
});
const repository = createFakeRequestRepository([request]);

const updated = await updateRequest(repository, request._id, {
url: 'https://example.com/users/:id/posts/:postId',
});

expect(updated).toMatchObject({
pathParameters: [
{ name: 'id', value: '42' },
{ name: 'postId', value: '' },
],
});
});

it('does not recompute pathParameters when the url is unchanged', async () => {
const request = buildRequest({ url: 'https://example.com/users/:id', pathParameters: [{ name: 'id', value: '42' }] });
const repository = createFakeRequestRepository([request]);

const updated = (await updateRequest(repository, request._id, {
url: 'https://example.com/users/:id',
name: 'Renamed',
})) as Request;

expect(updated.pathParameters).toEqual([{ name: 'id', value: '42' }]);
});

it('rewrites body and headers when a Request (HTTP) mimeType changes', async () => {
const request = buildRequest({
body: { mimeType: 'text/plain', text: 'hello' },
headers: [{ name: 'Content-Type', value: 'text/plain' }],
});
const repository = createFakeRequestRepository([request]);

const updated = (await updateRequest(repository, request._id, {
body: { mimeType: 'application/octet-stream' },
})) as Request;

expect(updated.body).toEqual({ mimeType: 'application/octet-stream', fileName: '' });
expect(updated.headers).toEqual([{ name: 'Content-Type', value: 'application/octet-stream' }]);
});

it('composes a simultaneous url change and mimeType change, matching the prior route behavior', async () => {
const request = buildRequest({
url: 'https://example.com/old',
body: { mimeType: 'text/plain', text: 'hello' },
headers: [{ name: 'Content-Type', value: 'text/plain' }],
});
const repository = createFakeRequestRepository([request]);

const updated = await updateRequest(repository, request._id, {
url: 'https://example.com/:id',
body: { mimeType: 'application/octet-stream' },
});

expect(updated).toMatchObject({
pathParameters: [{ name: 'id', value: '' }],
body: { mimeType: 'application/octet-stream', fileName: '' },
});
});
});
41 changes: 41 additions & 0 deletions application/src/request/update-request.use-case.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
type AnyRequest,
getPathParametersFromUrl,
getRequestBodyForMimeTypeChange,
isRequest,
isWebSocketRequest,
type RequestRepository,
} from 'insomnia-domain';

export type UpdateRequestPatch = Record<string, any>;

export async function updateRequest(
requestRepository: RequestRepository,
requestId: string,
patch: UpdateRequestPatch,
): Promise<AnyRequest> {
const request = await requestRepository.findById(requestId);
if (!request) {
throw new Error(`Request not found: ${requestId}`);
}

let effectivePatch = patch;

const isUrlChanged = (isRequest(request) || isWebSocketRequest(request)) && patch.url && patch.url !== request.url;
if (isUrlChanged) {
const pathParameters = getPathParametersFromUrl(patch.url).map(name => ({
name,
value: request.pathParameters?.find(p => p.name === name)?.value || '',
}));
effectivePatch = { ...effectivePatch, pathParameters };
}

const isMimeTypeChanged = isRequest(request) && patch.body && patch.body.mimeType !== request.body.mimeType;
if (isMimeTypeChanged) {
effectivePatch = { ...effectivePatch, ...getRequestBodyForMimeTypeChange(request, patch.body?.mimeType) };
}

const updated = { ...request, ...effectivePatch, modified: Date.now() } as AnyRequest;
await requestRepository.save(updated);
return updated;
}
Original file line number Diff line number Diff line change
@@ -1,49 +1,33 @@
import type { WebSocketRequest } from 'insomnia-data';
import { models, services } from 'insomnia-data';
import { href } from 'react-router';

import { InsomniaContext } from '~/common/application-bootstrap';
import { invariant } from '~/common/utils/invariant';
import { AnalyticsEvent } from '~/ui/analytics';
import { updateMimeType } from '~/ui/components/dropdowns/content-type-dropdown';
import { createFetcherSubmitHook } from '~/ui/utils/router';

import type { Route } from './+types/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update';

const { getPathParametersFromUrl, isRequest } = models.request;
const { isRequest } = models.request;

export async function clientAction({ params, request }: Route.ClientActionArgs) {
export async function clientAction({ params, request, context }: Route.ClientActionArgs) {
const { requestId } = params;

const req = await services.helpers.getRequestById(requestId);
invariant(req, 'Request not found');
const patch = await request.json();

const isRequestURLChanged =
(isRequest(req) || models.webSocketRequest.isWebSocketRequest(req)) && patch.url && patch.url !== req.url;

if (isRequestURLChanged) {
const { url } = patch as Request | WebSocketRequest;

// Check the URL for path parameters and store them in the request
const urlPathParameters = getPathParametersFromUrl(url);

const pathParameters = urlPathParameters.map(name => ({
name,
value: req.pathParameters?.find(p => p.name === name)?.value || '',
}));

patch.pathParameters = pathParameters;
}

// TODO: if gRPC, we should also copy the protofile to the destination workspace - INS-267
const isMimeTypeChanged = isRequest(req) && patch.body && patch.body.mimeType !== req.body.mimeType;

await context.get(InsomniaContext).request.updateById(requestId, patch);

// mimeType changes replace the whole body/headers shape - skip the rename check below in that
// case, matching the prior behavior of returning immediately after that kind of update.
if (isMimeTypeChanged) {
await services.helpers.updateRequest(req, { ...patch, ...updateMimeType(req, patch.body?.mimeType) });
return null;
}

await services.helpers.updateRequest(req, patch);

if (req.name !== patch.name) {
window.main.trackAnalyticsEvent({
event: AnalyticsEvent.requestRenamed,
Expand Down
3 changes: 2 additions & 1 deletion domain/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
},
"scripts": {
"lint": "eslint . --ext .ts,.tsx --cache",
"type-check": "tsc --noEmit --project tsconfig.json"
"type-check": "tsc --noEmit --project tsconfig.json",
"test": "vitest run"
},
"devDependencies": {
"@modelcontextprotocol/sdk": "^1.17.5"
Expand Down
3 changes: 3 additions & 0 deletions domain/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ export type { AnyRequest } from './request/any-request.entity';
export type { GrpcRequest, GrpcRequestBody, GrpcRequestHeader } from './request/grpc-request.entity';
export { MCP_TRANSPORT_TYPES } from './request/mcp-request.entity';
export type { McpRequest, McpTransportType } from './request/mcp-request.entity';
export { getPathParametersFromUrl } from './request/path-parameters';
export { getRequestBodyForMimeTypeChange } from './request/request-body-for-mime-type';
export type { RequestBodyForMimeTypeChange } from './request/request-body-for-mime-type';
export type { RequestRepository } from './request/request-repository.port';
export type {
AuthTypeAPIKey,
Expand Down
21 changes: 21 additions & 0 deletions domain/src/request/path-parameters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';

import { getPathParametersFromUrl } from './path-parameters';

describe('getPathParametersFromUrl', () => {
it('returns an empty array when the URL has no path parameters', () => {
expect(getPathParametersFromUrl('https://example.com/users')).toEqual([]);
});

it('extracts path parameters from colon-prefixed segments', () => {
expect(getPathParametersFromUrl('https://example.com/users/:id/posts/:postId')).toEqual(['id', 'postId']);
});

it('deduplicates repeated path parameters', () => {
expect(getPathParametersFromUrl('https://example.com/:id/vs/:id')).toEqual(['id']);
});

it('stops a segment at query strings, fragments, and further colons', () => {
expect(getPathParametersFromUrl('https://example.com/:id?foo=:bar#frag')).toEqual(['id']);
});
});
7 changes: 7 additions & 0 deletions domain/src/request/path-parameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
const PATH_PARAMETER_REGEX = /\/:[^/?#:]+/g;

/** Path parameters are URL segments that start with a colon, e.g. `/users/:id`. */
export function getPathParametersFromUrl(url: string): string[] {
const matches = url.match(PATH_PARAMETER_REGEX)?.map(match => match.replace('/:', '')) || [];
return [...new Set(matches)];
}
85 changes: 85 additions & 0 deletions domain/src/request/request-body-for-mime-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';

import { getRequestBodyForMimeTypeChange } from './request-body-for-mime-type';

const buildRequestShape = (overrides: { headers?: any[]; body?: any } = {}) => ({
headers: overrides.headers ?? [{ name: 'Content-Type', value: 'text/plain' }],
body: overrides.body ?? { mimeType: 'text/plain', text: 'hello' },
});

describe('getRequestBodyForMimeTypeChange', () => {
it('clears the body and Content-Type header for "No body"', () => {
const result = getRequestBodyForMimeTypeChange(buildRequestShape(), null);

expect(result).toEqual({ body: {}, headers: [] });
});

it('wraps the existing text as a GraphQL query and sets method to POST', () => {
const result = getRequestBodyForMimeTypeChange(
buildRequestShape({ body: { text: '{"query":"{ hello }"}' } }),
'application/graphql',
);

expect(result.body).toEqual({ mimeType: 'application/graphql', text: '{"query":"{ hello }"}' });
expect(result.method).toBe('POST');
expect(result.headers).toEqual([{ name: 'Content-Type', value: 'application/json' }]);
});

it('falls back to raw text for GraphQL when the existing body is not valid JSON', () => {
const result = getRequestBodyForMimeTypeChange(buildRequestShape({ body: { text: 'not json' } }), 'application/graphql');

expect(result.body).toEqual({ mimeType: 'application/graphql', text: 'not json' });
});

it('deconstructs raw text into form params for a form-urlencoded mimeType', () => {
const result = getRequestBodyForMimeTypeChange(
buildRequestShape({ body: { text: 'a=1&b=2' } }),
'application/x-www-form-urlencoded',
);

expect(result.body).toEqual({
mimeType: 'application/x-www-form-urlencoded',
params: [
{ name: 'a', value: '1' },
{ name: 'b', value: '2' },
],
});
});

it('reuses existing body.params for a form-data mimeType instead of re-parsing text', () => {
const existingParams = [{ name: 'existing', value: 'param' }];
const result = getRequestBodyForMimeTypeChange(
buildRequestShape({ body: { text: 'ignored=1', params: existingParams } }),
'multipart/form-data',
);

expect(result.body).toEqual({ mimeType: 'multipart/form-data', params: existingParams });
});

it('sets an empty fileName for a file mimeType', () => {
const result = getRequestBodyForMimeTypeChange(buildRequestShape(), 'application/octet-stream');

expect(result.body).toEqual({ mimeType: 'application/octet-stream', fileName: '' });
});

it('keeps the raw text and strips mimeType parameters for any other mimeType', () => {
const result = getRequestBodyForMimeTypeChange(
buildRequestShape({ body: { text: 'plain text' } }),
'text/plain; charset=utf-8',
);

expect(result.body).toEqual({ mimeType: 'text/plain', text: 'plain text' });
});

it('drops any existing Content-Type header before setting the new one', () => {
const result = getRequestBodyForMimeTypeChange(
buildRequestShape({ headers: [{ name: 'content-type', value: 'text/plain' }, { name: 'X-Other', value: '1' }] }),
'application/octet-stream',
);

expect(result.headers).toEqual([
{ name: 'Content-Type', value: 'application/octet-stream' },
{ name: 'X-Other', value: '1' },
]);
});
});
Loading
Loading