From ccc4651952fe8353c45e87df48c5747564278d48 Mon Sep 17 00:00:00 2001 From: James Gatz Date: Thu, 6 Aug 2026 14:37:00 +0200 Subject: [PATCH] feat(arch): unblock and add Request update use-case to the Insomnia facade Resolves the two architectural blockers flagged for Request's update route: - Ports getPathParametersFromUrl (previously insomnia-data's models.request helper) into domain/src/request/path-parameters.ts - a pure regex-based URL parser with no dependencies. - Ports the UI-only ContentTypeDropdown's updateMimeType into domain/src/request/request-body-for-mime-type.ts as getRequestBodyForMimeTypeChange, including its own minimal, default-options port of insomnia-data's deconstructQueryStringToParams (the general version stays in insomnia-data/common-src/querystring.ts unchanged - it's used by several other UI call sites unrelated to this use-case, so only the specific default-args behavior needed here was ported, not moved). With both helpers now in `domain`, application/src/request/update-request. use-case.ts recomposes the route's original branching: recompute pathParameters when a Request or WebSocketRequest's url changes, rewrite body/headers when a Request's mimeType changes, otherwise apply the patch as-is. debug.request.$requestId.update.tsx now delegates the actual merge to RequestModule.updateById(), while keeping its own pre-fetch to preserve two side-effect-only behaviors that don't belong in the use-case: skipping the requestRenamed analytics event when mimeType also changed (matching the route's prior early-return), and comparing old vs. new name for that event. Also adds a missing "test" script to domain/package.json - domain had zero test files before this change, so its absence was never noticed, but without it these new domain unit tests (and any future ones) would silently never run under `npm test`. --- application/src/index.ts | 1 + .../src/request/request.module.test.ts | 9 ++ application/src/request/request.module.ts | 5 + .../request/update-request.use-case.test.ts | 88 +++++++++++++++++ .../src/request/update-request.use-case.ts | 41 ++++++++ ...paceId.debug.request.$requestId.update.tsx | 32 ++----- domain/package.json | 3 +- domain/src/index.ts | 3 + domain/src/request/path-parameters.test.ts | 21 ++++ domain/src/request/path-parameters.ts | 7 ++ .../request-body-for-mime-type.test.ts | 85 +++++++++++++++++ .../src/request/request-body-for-mime-type.ts | 95 +++++++++++++++++++ 12 files changed, 365 insertions(+), 25 deletions(-) create mode 100644 application/src/request/update-request.use-case.test.ts create mode 100644 application/src/request/update-request.use-case.ts create mode 100644 domain/src/request/path-parameters.test.ts create mode 100644 domain/src/request/path-parameters.ts create mode 100644 domain/src/request/request-body-for-mime-type.test.ts create mode 100644 domain/src/request/request-body-for-mime-type.ts 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], + }; +}