diff --git a/application/src/index.ts b/application/src/index.ts index eef96aee13b..95d5fb37b39 100644 --- a/application/src/index.ts +++ b/application/src/index.ts @@ -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'; diff --git a/application/src/request/request.module.test.ts b/application/src/request/request.module.test.ts index 09bbef35e56..e58390d22a9 100644 --- a/application/src/request/request.module.test.ts +++ b/application/src/request/request.module.test.ts @@ -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]); diff --git a/application/src/request/request.module.ts b/application/src/request/request.module.ts index b2e0eaba323..9b21af8489f 100644 --- a/application/src/request/request.module.ts +++ b/application/src/request/request.module.ts @@ -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); } diff --git a/application/src/request/update-request.use-case.test.ts b/application/src/request/update-request.use-case.test.ts new file mode 100644 index 00000000000..bfa901e3055 --- /dev/null +++ b/application/src/request/update-request.use-case.test.ts @@ -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: '' }, + }); + }); +}); diff --git a/application/src/request/update-request.use-case.ts b/application/src/request/update-request.use-case.ts new file mode 100644 index 00000000000..79d6033d10e --- /dev/null +++ b/application/src/request/update-request.use-case.ts @@ -0,0 +1,41 @@ +import { + type AnyRequest, + getPathParametersFromUrl, + getRequestBodyForMimeTypeChange, + isRequest, + isWebSocketRequest, + type RequestRepository, +} from 'insomnia-domain'; + +export type UpdateRequestPatch = Record; + +export async function updateRequest( + requestRepository: RequestRepository, + requestId: string, + patch: UpdateRequestPatch, +): Promise { + 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; +} diff --git a/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx b/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx index 5b67f9c08c1..904ae158f7d 100644 --- a/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx +++ b/apps/desktop/src/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId.debug.request.$requestId.update.tsx @@ -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, diff --git a/domain/package.json b/domain/package.json index e1425b4bc9e..c142b736476 100644 --- a/domain/package.json +++ b/domain/package.json @@ -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" diff --git a/domain/src/index.ts b/domain/src/index.ts index c4511c27bf1..c907968e17d 100644 --- a/domain/src/index.ts +++ b/domain/src/index.ts @@ -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, diff --git a/domain/src/request/path-parameters.test.ts b/domain/src/request/path-parameters.test.ts new file mode 100644 index 00000000000..b64c0ecf320 --- /dev/null +++ b/domain/src/request/path-parameters.test.ts @@ -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']); + }); +}); diff --git a/domain/src/request/path-parameters.ts b/domain/src/request/path-parameters.ts new file mode 100644 index 00000000000..ddebf681634 --- /dev/null +++ b/domain/src/request/path-parameters.ts @@ -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)]; +} diff --git a/domain/src/request/request-body-for-mime-type.test.ts b/domain/src/request/request-body-for-mime-type.test.ts new file mode 100644 index 00000000000..699ed483cb4 --- /dev/null +++ b/domain/src/request/request-body-for-mime-type.test.ts @@ -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' }, + ]); + }); +}); diff --git a/domain/src/request/request-body-for-mime-type.ts b/domain/src/request/request-body-for-mime-type.ts new file mode 100644 index 00000000000..b71006700a3 --- /dev/null +++ b/domain/src/request/request-body-for-mime-type.ts @@ -0,0 +1,95 @@ +import type { RequestBody, RequestHeader } from './request-shared.entity'; + +const CONTENT_TYPE_GRAPHQL = 'application/graphql'; +const CONTENT_TYPE_JSON = 'application/json'; +const CONTENT_TYPE_FORM_URLENCODED = 'application/x-www-form-urlencoded'; +const CONTENT_TYPE_FORM_DATA = 'multipart/form-data'; +const CONTENT_TYPE_FILE = 'application/octet-stream'; +const METHOD_POST = 'POST'; + +/** Minimal, default-options port of insomnia-data's deconstructQueryStringToParams. */ +function deconstructQueryString(qs?: string): { name: string; value: string }[] { + if (!qs) { + return []; + } + return qs.split('&').flatMap(pair => { + const [encodedName, ...encodedValueParts] = pair.split('='); + const encodedValue = encodedValueParts.join('='); + + let name = ''; + try { + name = decodeURIComponent(encodedName || ''); + } catch { + name = encodedName; + } + if (!name) { + return []; + } + + let value = ''; + try { + value = decodeURIComponent(encodedValue || ''); + } catch { + value = encodedValue; + } + + return [{ name, value }]; + }); +} + +function graphQLBodyFrom(rawBody: string): RequestBody { + try { + // Only strip the newlines if rawBody is parsable JSON. + JSON.parse(rawBody); + return { mimeType: CONTENT_TYPE_GRAPHQL, text: rawBody.replace(/\\\\n/g, '') }; + } catch { + return { mimeType: CONTENT_TYPE_GRAPHQL, text: rawBody }; + } +} + +export interface RequestBodyForMimeTypeChange { + body: RequestBody; + headers: RequestHeader[]; + method?: string; +} + +/** + * Computes the body/headers (and, for GraphQL, method) a Request should switch to when its + * mimeType changes - e.g. switching to GraphQL wraps the existing body text in a GraphQL query + * shape, switching to a form type deconstructs the existing raw text into form params. + */ +export function getRequestBodyForMimeTypeChange( + request: { headers: RequestHeader[]; body: RequestBody }, + mimeType: string | null, +): RequestBodyForMimeTypeChange { + const withoutContentType = request.headers.filter(h => h?.name?.toLowerCase() !== 'content-type'); + + // 'No body' selected + if (typeof mimeType !== 'string') { + return { body: {}, headers: withoutContentType }; + } + if (mimeType === CONTENT_TYPE_GRAPHQL) { + return { + body: graphQLBodyFrom(request.body.text || ''), + headers: [{ name: 'Content-Type', value: CONTENT_TYPE_JSON }, ...withoutContentType], + method: METHOD_POST, + }; + } + if (mimeType === CONTENT_TYPE_FORM_URLENCODED || mimeType === CONTENT_TYPE_FORM_DATA) { + const params = request.body.params || deconstructQueryString(request.body.text); + return { + body: { mimeType, params }, + headers: [{ name: 'Content-Type', value: mimeType }, ...withoutContentType], + }; + } + if (mimeType === CONTENT_TYPE_FILE) { + return { + body: { mimeType, fileName: '' }, + headers: [{ name: 'Content-Type', value: mimeType }, ...withoutContentType], + }; + } + return { + body: { mimeType: mimeType.split(';')[0], text: request.body.text || '' }, + headers: [{ name: 'Content-Type', value: mimeType }, ...withoutContentType], + }; +}