fix: http request loading indicator (#6265)

* flatten url bar

* flattening continued

* more flat

* more more flat

* fix lint

* flatten network send into one call

* unpack send everywhere

* remove send with settgins

* fix types

* fix bug in download

* contain interpolation and modal

* abstract render try catch

* send action

* extract to file

* remove plugin ignore code

* remove unused

* unpack misc functions

* less misc functions

* readd inso tests

* split test runs

* fix test

* fix test

* fix test

* use workspace pathing

* remove check-engine

* add tech debt list
This commit is contained in:
Jack Kavanagh 2023-08-13 12:30:04 +02:00 committed by GitHub
parent 94d035b6b1
commit f9bd4ff82a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 626 additions and 928 deletions

View File

@ -52,8 +52,8 @@ jobs:
shell: bash
run: NODE_OPTIONS='--max_old_space_size=6144' BUILD_TARGETS='${{ matrix.build-targets }}' npm run app-package
- name: Run critical test on packaged app
run: npm run test:package --prefix packages/insomnia-smoke-test -- --project=Critical
- name: Test critical path on packaged electron app
run: npm run test:package -w packages/insomnia-smoke-test -- --project=Critical
- name: Upload smoke test traces
uses: actions/upload-artifact@v3

View File

@ -43,21 +43,30 @@ jobs:
- name: Type checks
run: npm run type-check
- name: Run tests
run: npm test
- name: Test Insomnia
run: npm test -w packages/insomnia
- name: Test Inso
run: npm test -w packages/insomnia-inso
- name: Test Insomnia Testing
run: npm test -w packages/insomnia-testing
- name: Test O2K
run: npm test -w packages/openapi-2-kong
- name: Build app for smoke tests
run: npm run app-build
- name: Run Smoke tests
- name: Smoke test electron app
# Partial Smoke test run, for regular CI triggers
if: ${{ !startsWith(github.head_ref, 'release/') }}
run: npm run test:build --prefix packages/insomnia-smoke-test -- --project=Smoke
run: npm run test:build -w packages/insomnia-smoke-test -- --project=Smoke
- name: Run Prerelease tests
- name: Prerelease test electron app
# Full Smoke test run, for Release PRs
if: ${{ startsWith(github.head_ref, 'release/') }}
run: npm run test:build --prefix packages/insomnia-smoke-test -- --project=Default
run: npm run test:build -w packages/insomnia-smoke-test -- --project=Default
- name: Set Inso CLI variables
id: inso-variables
@ -65,7 +74,6 @@ jobs:
run: |
INSO_VERSION="$(jq .version packages/insomnia-inso/package.json -rj)-run.${{ github.run_number }}"
PKG_NAME="inso-ubuntu-latest-$INSO_VERSION"
echo "pkg-name=$PKG_NAME" >> $GITHUB_OUTPUT
echo "inso-version=$INSO_VERSION" >> $GITHUB_OUTPUT
@ -74,7 +82,7 @@ jobs:
- name: Package Inso CLI binary
run: |
echo "Replacing electron binary with node binary"
echo "Replacing node-libcurl electron binary with node binary"
node_modules/.bin/node-pre-gyp install --update-binary --directory node_modules/@getinsomnia/node-libcurl
npm run inso-package
env:
@ -92,7 +100,7 @@ jobs:
name: ${{ steps.inso-variables.outputs.pkg-name }}
path: packages/insomnia-inso/artifacts
- name: Run Inso CLI smoke tests
- name: Smoke test Inso CLI
run: npm run test:smoke:cli
- name: Upload smoke test traces

1
.npmrc
View File

@ -2,3 +2,4 @@ runtime = electron
target = 25.2.0
disturl = https://electronjs.org/headers
playwright_skip_browser_download=true
engine-strict=true

View File

@ -69,7 +69,26 @@ This is just a brief summary of Insomnia's current technical debt.
- Loading large responses (~20 MB) can crash the app on weaker hardware.
- Bundling `libcurl` (native module) has caused many weeks of headaches trying to get builds working across Windows, Mac, and Linux. More expertise here is definitely needed.
- All input fields that support features like templating or code completion are actually [CodeMirror](https://codemirror.net/6/) instances. This isn't really debt, but may affect things going forward.
- Use of `libcurl` means Insomnia can't run in a web browser and can't support bidirectional socket communication.
- [x] upgrade spectral e2e testing
- [x] upgrading electron
- [x] preload electron main functions
- [x] update react classes to function components
- [x] remove excess packages
- [x] migrate redux to remix
- [x] migrate lerna to npm workspaces
- [x] CI slow ~30m (now 10m)
- [x] styling vision (react-aria + tailwind)
- [ ] de-polymorph database
- [ ] codemirror is unmaintained
- [ ] nedb is unmaintained
- [ ] grpc state state should be in main rather than renderer
- [ ] drag and drop is flakey
- [ ] sync code is spaghetti
- [ ] template rendering is spaghetti and has poor discoverability
- [ ] inso abstraction limits networking improvements
- [ ] testing feature doesn't scale with investment
- [ ] unify curl.ts and libcurl-promise implementations
## Electron upgrade

1
package-lock.json generated
View File

@ -7,7 +7,6 @@
"": {
"name": "insomnia",
"version": "1.0.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
"packages/openapi-2-kong",

View File

@ -28,7 +28,6 @@
"type-check": "npm run type-check --workspaces --if-present",
"test": "npm run test --workspaces --if-present",
"lint:markdown": "npx markdownlint-cli2 \"**/*.md\" \"#**/node_modules\"",
"preinstall": "npx -y check-engine",
"clean": "git clean -dfX",
"inso-start": "npm start --workspace=packages/insomnia-inso",
"inso-package": "npm run build:sr --workspace=packages/insomnia && npm run package --workspace=packages/insomnia-inso",

View File

@ -21,7 +21,7 @@
},
"scripts": {
"lint": "eslint . --ext .js,.ts,.tsx --cache",
"test": "echo 'fix these jest --runInBand'",
"test": "esr esbuild.ts jest",
"test:watch": "npm run test -- --watch",
"test:snapshots": "npm run build && npm run test -- -u",
"test:bundled-inso": "cross-env ./bin/inso run test \"Another suite\" -e \"OpenAPI env\" --src src/db/fixtures/nedb",

View File

@ -33,13 +33,13 @@ From project root, in separate terminals:
```sh
# start smoke test api
npm run serve --prefix packages/insomnia-smoke-test
npm run serve -w packages/insomnia-smoke-test
# build send-request
npm run build:sr --prefix packages/insomnia
npm run build:sr -w packages/insomnia
# watch inso
npm run start --prefix packages/insomnia-inso
npm run start -w packages/insomnia-inso
# run api test with dev bundle
$PWD/packages/insomnia-inso/bin/inso run test "Echo Test Suite" --src $PWD/packages/insomnia-smoke-test/fixtures/inso-nedb --env Dev --verbose
@ -49,7 +49,7 @@ $PWD/packages/insomnia-inso/bin/inso run test "Echo Test Suite" --src $PWD/packa
```sh
# run modify package command and then a unit test
npm run package --prefix packages/insomnia-inso && \
npm run package -w packages/insomnia-inso && \
$PWD/packages/insomnia-inso/binaries/inso run test "Echo Test Suite" --src $PWD/packages/insomnia-smoke-test/fixtures/inso-nedb --env Dev --verbose
```

View File

@ -126,5 +126,5 @@ Each of the above commands will automatically run the Express server, so you do
Non recurring / non-CI tests, like pre-release ones, can be run using [Playwright VS Code extension](#playwright-vs-code-extension) or by running `test:dev` against the desired test file:
```shell
npm run test:dev --prefix packages/insomnia-smoke-test -- preferences-interactions
npm run test:dev -w packages/insomnia-smoke-test -- preferences-interactions
```

View File

@ -1,12 +1,9 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import { globalBeforeEach } from '../../__jest__/before-each';
import { chunkArray } from '../../sync/vcs/vcs';
import {
capitalize,
chunkArray,
convertEpochToMilliseconds,
debounce,
diffPatchObj,
filterHeaders,
fuzzyMatch,
fuzzyMatchAll,
@ -14,11 +11,7 @@ import {
hasAuthHeader,
isNotNullOrUndefined,
keyedDebounce,
pluralize,
snapNumberToLimits,
toKebabCase,
toTitleCase,
xmlDecode,
} from '../misc';
describe('hasAuthHeader()', () => {
@ -257,7 +250,6 @@ describe('fuzzyMatchAll()', () => {
expect(fuzzyMatchAll('wrong this ou', ['testing', 'this', 'out'])).toEqual(null);
});
});
describe('chunkArray()', () => {
it('works with exact divisor', () => {
const chunks = chunkArray([1, 2, 3, 4, 5, 6], 3);
@ -286,121 +278,6 @@ describe('chunkArray()', () => {
});
});
describe('pluralize()', () => {
it('should not change pluralization', () => {
expect(pluralize('Requests')).toBe('Requests');
});
it('should end with s', () => {
expect(pluralize('Request')).toBe('Requests');
});
it('should end with ies', () => {
expect(pluralize('Directory')).toBe('Directories');
});
});
describe('diffPatchObj()', () => {
const a = {
x: 1,
};
const b = {
x: 2,
y: 3,
};
const c = {
x: 4,
y: {
z: 5,
},
};
it('does a basic merge', () => {
expect(diffPatchObj(a, b)).toEqual({
x: 2,
y: 3,
});
expect(diffPatchObj(b, a)).toEqual({
x: 1,
y: 3,
});
});
it.skip('does a basic merge, deep', () => {
expect(diffPatchObj(a, c, true)).toEqual({
x: 2,
y: 3,
});
expect(diffPatchObj(c, a, true)).toEqual({
x: 1,
});
});
it.skip('does a basic nested merge', () => {
expect(diffPatchObj(a, b)).toEqual({
x: 2,
y: 3,
});
expect(diffPatchObj(b, a)).toEqual({
x: 1,
y: {
z: 5,
},
});
});
it.skip('does a basic nested merge, deep', () => {
expect(diffPatchObj(a, c, true)).toEqual({
x: 2,
y: 3,
});
expect(diffPatchObj(c, a, true)).toEqual({
x: 1,
y: {
z: 5,
},
});
});
});
describe('convertEpochToMilliseconds()', () => {
it('should convert microseconds to milliseconds', () => {
expect(convertEpochToMilliseconds(1617616858412123)).toBe(1617616858412);
});
it('should convert seconds to milliseconds', () => {
expect(convertEpochToMilliseconds(1617617010)).toBe(1617617010000);
});
it('should output same if value already in milliseconds', () => {
expect(convertEpochToMilliseconds(1617617141412)).toBe(1617617141412);
});
});
describe('snapNumberToLimits()', () => {
it('should return value', () => {
expect(snapNumberToLimits(2)).toBe(2);
expect(snapNumberToLimits(2, 0)).toBe(2);
expect(snapNumberToLimits(2, 0, 3)).toBe(2);
expect(snapNumberToLimits(2, 2, 2)).toBe(2);
expect(snapNumberToLimits(2, null, null)).toBe(2);
expect(snapNumberToLimits(2, NaN, NaN)).toBe(2);
});
it('should snap to min', () => {
expect(snapNumberToLimits(2, 3)).toBe(3);
expect(snapNumberToLimits(2, 3, 5)).toBe(3);
expect(snapNumberToLimits(2, 3, null)).toBe(3);
expect(snapNumberToLimits(2, 3, NaN)).toBe(3);
});
it('should snap to max', () => {
expect(snapNumberToLimits(5, 0, 3)).toBe(3);
expect(snapNumberToLimits(5, null, 3)).toBe(3);
expect(snapNumberToLimits(5, NaN, 3)).toBe(3);
});
});
describe('isNotNullOrUndefined', () => {
it('should return correctly', () => {
expect(isNotNullOrUndefined(0)).toBe(true);
@ -411,14 +288,6 @@ describe('isNotNullOrUndefined', () => {
});
});
describe('xmlDecode()', () => {
it('unescape characters', () => {
const input = '<a href="http://example.com?query1=value1&query2=value2">a link</a>';
const output = '<a href="http://example.com?query1=value1&query2=value2">a link</a>';
expect(xmlDecode(input)).toEqual(output);
});
});
describe('toKebabCase', () => {
it('leaves strings without spaces alone', () => {
expect(toKebabCase('')).toEqual('');
@ -433,31 +302,3 @@ describe('toKebabCase', () => {
expect(toKebabCase('a A b B c')).toEqual('a-A-b-B-c');
});
});
describe('capitalize', () => {
it('capitalizes first letter', () => {
expect(capitalize('')).toEqual('');
expect(capitalize('a')).toEqual('A');
expect(capitalize('A')).toEqual('A');
expect(capitalize('abcd')).toEqual('Abcd');
expect(capitalize('abcd efg')).toEqual('Abcd efg');
});
it('lowercases all other letters but the first', () => {
expect(capitalize('aBcd efg')).toEqual('Abcd efg');
expect(capitalize('aBcd Efg')).toEqual('Abcd efg');
});
});
describe('toTitleCase', () => {
it('capitalizes first letter of each word', () => {
expect(toTitleCase('')).toEqual('');
expect(toTitleCase('a')).toEqual('A');
expect(toTitleCase('A')).toEqual('A');
expect(toTitleCase('abcd')).toEqual('Abcd');
expect(toTitleCase('abcd efg')).toEqual('Abcd Efg');
});
it('lowercases all other letters but the first of each word', () => {
expect(toTitleCase('aBcd efg')).toEqual('Abcd Efg');
expect(toTitleCase('aBcd Efg')).toEqual('Abcd Efg');
});
});

View File

@ -1,9 +1,8 @@
import fuzzysort from 'fuzzysort';
import { join as pathJoin } from 'path';
import { v4 as uuidv4 } from 'uuid';
import zlib from 'zlib';
import { DEBOUNCE_MILLIS, METHOD_DELETE, METHOD_OPTIONS } from './constants';
import { DEBOUNCE_MILLIS } from './constants';
const ESCAPE_REGEX_MATCH = /[-[\]/{}()*+?.\\^$|]/g;
@ -12,23 +11,7 @@ interface Header {
value: string;
}
interface Parameter {
name: string;
value: string;
}
export function filterParameters<T extends Parameter>(
parameters: T[],
name: string,
): T[] {
if (!Array.isArray(parameters) || !name) {
return [];
}
return parameters.filter(h => (!h || !h.name ? false : h.name === name));
}
export function filterHeaders<T extends Header>(headers: T[], name?: string): T[] {
export function filterHeaders<T extends { name: string; value: string }>(headers: T[], name?: string): T[] {
if (!Array.isArray(headers) || !name || typeof name !== 'string') {
return [];
}
@ -111,22 +94,6 @@ export function delay(milliseconds: number = DEBOUNCE_MILLIS) {
return new Promise<void>(resolve => setTimeout(resolve, milliseconds));
}
export function removeVowels(str: string) {
return str.replace(/[aeiouyAEIOUY]/g, '');
}
export function formatMethodName(method: string) {
let methodName = method || '';
if (method === METHOD_DELETE || method === METHOD_OPTIONS) {
methodName = method.slice(0, 3);
} else if (method.length > 4) {
methodName = removeVowels(method).slice(0, 4);
}
return methodName;
}
export function keyedDebounce<T>(
callback: (t: Record<string, T[]>) => void,
millis: number = DEBOUNCE_MILLIS
@ -186,19 +153,6 @@ export function describeByteSize(bytes: number, long = false) {
return `${rounded} ${unit}`;
}
export function xmlDecode(input: string) {
const ESCAPED_CHARACTERS_MAP = {
'&amp;': '&',
'&quot;': '"',
'&lt;': '<',
'&gt;': '>',
};
return input.replace(/(&quot;|&lt;|&gt;|&amp;)/g, (_: string, item: keyof typeof ESCAPED_CHARACTERS_MAP) => (
ESCAPED_CHARACTERS_MAP[item])
);
}
export function fnOrString(v: string | ((...args: any[]) => any), ...args: any[]) {
if (typeof v === 'string') {
return v;
@ -221,28 +175,6 @@ export function decompressObject<ObjectType>(input: string | null): ObjectType |
return JSON.parse(jsonBuffer.toString('utf8')) as ObjectType;
}
export function resolveHomePath(p: string) {
if (p.indexOf('~/') === 0) {
return pathJoin(process.env['HOME'] || '/', p.slice(1));
} else {
return p;
}
}
export function jsonParseOr(str: string, fallback: any): any {
try {
return JSON.parse(str);
} catch (err) {
return fallback;
}
}
export function escapeHTML(unsafeText: string) {
const div = document.createElement('div');
div.innerText = unsafeText;
return div.innerHTML;
}
/**
* Escape a dynamic string for use inside of a regular expression
* @param str - string to escape
@ -327,101 +259,6 @@ export function fuzzyMatchAll(
};
}
export function chunkArray<T>(arr: T[], chunkSize: number) {
const chunks: T[][] = [];
for (let i = 0, j = arr.length; i < j; i += chunkSize) {
chunks.push(arr.slice(i, i + chunkSize));
}
return chunks;
}
export function pluralize(text: string) {
let trailer = 's';
let chop = 0;
// Things already ending with 's' stay that way
if (text.match(/s$/)) {
trailer = '';
chop = 0;
}
// Things ending in 'y' convert to ies
if (text.match(/y$/)) {
trailer = 'ies';
chop = 1;
}
// Add the trailer for pluralization
return `${text.slice(0, text.length - chop)}${trailer}`;
}
export function diffPatchObj(baseObj: any, patchObj: any, deep = false) {
const clonedBaseObj = JSON.parse(JSON.stringify(baseObj));
for (const prop in baseObj) {
if (!Object.prototype.hasOwnProperty.call(baseObj, prop)) {
continue;
}
if (Object.prototype.hasOwnProperty.call(patchObj, prop)) {
const left = baseObj[prop];
const right = patchObj[prop];
if (right !== left) {
if (deep && isObject(left) && isObject(right)) {
clonedBaseObj[prop] = diffPatchObj(left, right, deep);
} else if (isObject(left) && !isObject(right)) {
// when right is empty but left isn't, prefer left to avoid a sparse array
clonedBaseObj[prop] = left;
} else {
// otherwise prefer right when both elements aren't objects to ensure values don't get overwritten
clonedBaseObj[prop] = right;
}
}
}
}
for (const prop in patchObj) {
if (!Object.prototype.hasOwnProperty.call(patchObj, prop)) {
continue;
}
if (!Object.prototype.hasOwnProperty.call(baseObj, prop)) {
clonedBaseObj[prop] = patchObj[prop];
}
}
return clonedBaseObj;
}
export function isObject(obj: unknown) {
return obj !== null && typeof obj === 'object';
}
/**
Finds epoch's digit count and converts it to make it exactly 13 digits.
Which is the epoch millisecond representation.
*/
export function convertEpochToMilliseconds(epoch: number) {
const expDigitCount = epoch.toString().length;
return parseInt(String(epoch * 10 ** (13 - expDigitCount)), 10);
}
export function snapNumberToLimits(value: number, min?: number, max?: number) {
const moreThanMax = max && !Number.isNaN(max) && value > max;
const lessThanMin = min && !Number.isNaN(min) && value < min;
if (moreThanMax) {
return max;
} else if (lessThanMin) {
return min;
}
return value;
}
export function isNotNullOrUndefined<ValueType>(
value: ValueType | null | undefined
): value is ValueType {
@ -433,15 +270,3 @@ export function isNotNullOrUndefined<ValueType>(
}
export const toKebabCase = (value: string) => value.replace(/ /g, '-');
export const capitalize = (value: string) => (
`${value.slice(0, 1).toUpperCase()}${value.slice(1).toLowerCase()}`
);
export const toTitleCase = (value: string) => (
value
.toLowerCase()
.split(' ')
.map(capitalize)
.join(' ')
);

View File

@ -9,7 +9,6 @@ import {
tryToInterpolateRequest,
tryToTransformRequestWithPlugins,
} from '../network/network';
import * as plugins from '../plugins';
import { invariant } from '../utils/invariant';
import { database } from './database';
import { RENDER_PURPOSE_SEND } from './render';
@ -65,30 +64,27 @@ export async function getSendRequestCallbackMemDb(environmentId: string, memDB:
};
// Return callback helper to send requests
return async function sendRequest(requestId: string) {
try {
const {
request,
settings,
clientCertificates,
caCert,
} = await fetchInsoRequestData(requestId);
// NOTE: inso ignores active environment, using the one passed in
const renderResult = await tryToInterpolateRequest(request, environmentId, RENDER_PURPOSE_SEND);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
const response = await sendCurlAndWriteTimeline(
renderedRequest,
clientCertificates,
caCert,
settings,
);
const res = await responseTransform(response, environmentId, renderedRequest, renderResult.context);
const { statusCode: status, statusMessage, headers: headerArray, elapsedTime: responseTime } = res;
const headers = headerArray?.reduce((acc, { name, value }) => ({ ...acc, [name.toLowerCase() || '']: value || '' }), []);
const bodyBuffer = await getBodyBuffer(res) as Buffer;
const data = bodyBuffer ? bodyBuffer.toString('utf8') : undefined;
return { status, statusMessage, data, headers, responseTime };
} finally {
plugins.clearIgnores();
}
const {
request,
settings,
clientCertificates,
caCert,
} = await fetchInsoRequestData(requestId);
// NOTE: inso ignores active environment, using the one passed in
const renderResult = await tryToInterpolateRequest(request, environmentId, RENDER_PURPOSE_SEND);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
const response = await sendCurlAndWriteTimeline(
renderedRequest,
clientCertificates,
caCert,
settings,
);
const res = await responseTransform(response, environmentId, renderedRequest, renderResult.context);
const { statusCode: status, statusMessage, headers: headerArray, elapsedTime: responseTime } = res;
const headers = headerArray?.reduce((acc, { name, value }) => ({ ...acc, [name.toLowerCase() || '']: value || '' }), []);
const bodyBuffer = await getBodyBuffer(res) as Buffer;
const data = bodyBuffer ? bodyBuffer.toString('utf8') : undefined;
return { status, statusMessage, data, headers, responseTime };
};
}

View File

@ -13,7 +13,7 @@ import {
EXPORT_TYPE_WEBSOCKET_REQUEST,
EXPORT_TYPE_WORKSPACE,
} from '../common/constants';
import { generateId, pluralize } from '../common/misc';
import { generateId } from '../common/misc';
import * as _apiSpec from './api-spec';
import * as _caCertificate from './ca-certificate';
import * as _clientCertificate from './client-certificate';
@ -163,18 +163,6 @@ export function canDuplicate(type: string) {
return model ? model.canDuplicate : false;
}
export function getModelName(type: string, count = 1) {
const model = getModel(type);
if (!model) {
return 'Unknown';
} else if (count === 1) {
return model.name;
} else {
return pluralize(model.name);
}
}
export async function initModel<T extends BaseModel>(type: string, ...sources: Record<string, any>[]): Promise<T> {
const model = getModel(type);

View File

@ -15,7 +15,6 @@ import type { ExtraRenderInfo, RenderedRequest, RenderPurpose, RequestAndContext
import {
getRenderedRequestAndContext,
RENDER_PURPOSE_NO_RENDER,
RENDER_PURPOSE_SEND,
} from '../common/render';
import type { HeaderResult, ResponsePatch, ResponseTimelineEntry } from '../main/network/libcurl-promise';
import * as models from '../models';
@ -38,69 +37,7 @@ import { getAuthHeader, getAuthQueryParams } from './authentication';
import { cancellableCurlRequest } from './cancellation';
import { urlMatchesCertHost } from './url-matches-cert-host';
// used for oauth grant types
// creates a new request with the patch args
// and uses env and settings from workspace
// not cancellable but currently is
// used indirectly by send and getAuthHeader to fetch tokens
// @TODO unpack oauth into regular timeline and remove oauth timeine dialog
export async function sendWithSettings(
requestId: string,
requestPatch: Record<string, any>,
) {
console.log(`[network] Sending with settings req=${requestId}`);
const { request,
environment,
settings,
clientCertificates,
caCert,
activeEnvironmentId } = await fetchRequestData(requestId);
const newRequest: Request = await models.initModel(models.request.type, requestPatch, {
_id: request._id + '.other',
parentId: request._id,
});
const renderResult = await tryToInterpolateRequest(newRequest, environment._id);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
const response = await sendCurlAndWriteTimeline(
renderResult.request,
clientCertificates,
caCert,
{ ...settings, validateSSL: settings.validateAuthSSL },
);
return responseTransform(response, activeEnvironmentId, renderedRequest, renderResult.context);
}
// used by test feature, inso, and plugin api
// not all need to be cancellable or to use curl
export async function send(
requestId: string,
environmentId?: string,
extraInfo?: ExtraRenderInfo,
) {
console.log(`[network] Sending req=${requestId} env=${environmentId || 'null'}`);
const { request,
environment,
settings,
clientCertificates,
caCert,
activeEnvironmentId } = await fetchRequestData(requestId);
const renderResult = await tryToInterpolateRequest(request, environment._id, RENDER_PURPOSE_SEND, extraInfo);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
const response = await sendCurlAndWriteTimeline(
renderedRequest,
clientCertificates,
caCert,
settings,
);
return responseTransform(response, activeEnvironmentId, renderedRequest, renderResult.context);
}
const fetchRequestData = async (requestId: string) => {
export const fetchRequestData = async (requestId: string) => {
const request = await models.request.getById(requestId);
invariant(request, 'failed to find request');
const ancestors = await db.withAncestors(request, [
@ -224,8 +161,8 @@ export async function sendCurlAndWriteTimeline(
...patch,
};
}
export const responseTransform = (patch: ResponsePatch, environmentId: string | null, renderedRequest: RenderedRequest, context: Record<string, any>) => {
const response = {
export const responseTransform = async (patch: ResponsePatch, environmentId: string | null, renderedRequest: RenderedRequest, context: Record<string, any>) => {
const response: ResponsePatch = {
...patch,
// important for filter by responses
environmentId,
@ -239,7 +176,7 @@ export const responseTransform = (patch: ResponsePatch, environmentId: string |
return response;
}
console.log(`[network] Response succeeded req=${patch.parentId} status=${response.statusCode || '?'}`,);
return _applyResponsePluginHooks(
return await _applyResponsePluginHooks(
response,
renderedRequest,
context,
@ -325,7 +262,7 @@ async function _applyRequestPluginHooks(
...pluginContexts.data.init(renderedContext.getProjectId()),
...(pluginContexts.store.init(plugin) as Record<string, any>),
...(pluginContexts.request.init(newRenderedRequest, renderedContext) as Record<string, any>),
...(pluginContexts.network.init(renderedContext.getEnvironmentId?.()) as Record<string, any>),
...(pluginContexts.network.init() as Record<string, any>),
};
try {
@ -355,7 +292,7 @@ async function _applyResponsePluginHooks(
...(pluginContexts.store.init(plugin) as Record<string, any>),
...(pluginContexts.response.init(newResponse) as Record<string, any>),
...(pluginContexts.request.init(newRequest, renderedContext, true) as Record<string, any>),
...(pluginContexts.network.init(renderedContext.getEnvironmentId?.()) as Record<string, any>),
...(pluginContexts.network.init() as Record<string, any>),
};
try {

View File

@ -6,11 +6,12 @@ import { escapeRegex } from '../../common/misc';
import * as models from '../../models';
import type { OAuth2Token } from '../../models/o-auth-2-token';
import type { AuthTypeOAuth2, OAuth2ResponseType, RequestHeader, RequestParameter } from '../../models/request';
import type { Request } from '../../models/request';
import type { Response } from '../../models/response';
import { invariant } from '../../utils/invariant';
import { setDefaultProtocol } from '../../utils/url/protocol';
import { getBasicAuthHeader } from '../basic-auth/get-header';
import { sendWithSettings } from '../network';
import { fetchRequestData, responseTransform, sendCurlAndWriteTimeline, tryToInterpolateRequest, tryToTransformRequestWithPlugins } from '../network';
import {
AuthKeys,
GRANT_TYPE_AUTHORIZATION_CODE,
@ -289,7 +290,16 @@ const transformNewAccessTokenToOauthModel = (accessToken: Partial<Record<AuthKey
const sendAccessTokenRequest = async (requestId: string, authentication: AuthTypeOAuth2, params: RequestParameter[], headers: RequestHeader[]) => {
invariant(authentication.accessTokenUrl, 'Missing access token URL');
const responsePatch = await sendWithSettings(requestId, {
console.log(`[network] Sending with settings req=${requestId}`);
// @TODO unpack oauth into regular timeline and remove oauth timeine dialog
const { request,
environment,
settings,
clientCertificates,
caCert,
activeEnvironmentId } = await fetchRequestData(requestId);
const newRequest: Request = await models.initModel(models.request.type, {
headers: [
{ name: 'Content-Type', value: 'application/x-www-form-urlencoded' },
{ name: 'Accept', value: 'application/x-www-form-urlencoded, application/json' },
@ -301,7 +311,22 @@ const sendAccessTokenRequest = async (requestId: string, authentication: AuthTyp
mimeType: 'application/x-www-form-urlencoded',
params,
},
}, {
_id: request._id + '.other',
parentId: request._id,
});
const renderResult = await tryToInterpolateRequest(newRequest, environment._id);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
const response = await sendCurlAndWriteTimeline(
renderResult.request,
clientCertificates,
caCert,
{ ...settings, validateSSL: settings.validateAuthSSL },
);
const responsePatch = await responseTransform(response, activeEnvironmentId, renderedRequest, renderResult.context);
return await models.response.create(responsePatch);
};
export const encodePKCE = (buffer: Buffer) => {

View File

@ -1,22 +1,33 @@
import { RENDER_PURPOSE_SEND } from '../common/render';
import { stats } from '../models';
import { getBodyBuffer } from '../models/response';
import * as plugins from '../plugins';
import { send } from './network';
import { fetchRequestData, responseTransform, sendCurlAndWriteTimeline, tryToInterpolateRequest, tryToTransformRequestWithPlugins } from './network';
export function getSendRequestCallback(environmentId?: string) {
export function getSendRequestCallback() {
return async function sendRequest(requestId: string) {
stats.incrementExecutedRequests();
try {
// NOTE: unit tests will use the UI selected environment
// TODO: unpack this and then unpack all other network.sends in order to match the realtime loading mechanism workaround
const res = await send(requestId, environmentId);
const { statusCode: status, statusMessage, headers: headerArray, elapsedTime: responseTime } = res;
const headers = headerArray?.reduce((acc, { name, value }) => ({ ...acc, [name.toLowerCase() || '']: value || '' }), []);
const bodyBuffer = await getBodyBuffer(res) as Buffer;
const data = bodyBuffer ? bodyBuffer.toString('utf8') : undefined;
return { status, statusMessage, data, headers, responseTime };
} finally {
plugins.clearIgnores();
}
// NOTE: unit tests will use the UI selected environment
const { request,
environment,
settings,
clientCertificates,
caCert,
activeEnvironmentId } = await fetchRequestData(requestId);
const renderResult = await tryToInterpolateRequest(request, environment._id, RENDER_PURPOSE_SEND);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
const response = await sendCurlAndWriteTimeline(
renderedRequest,
clientCertificates,
caCert,
settings,
);
const res = await responseTransform(response, activeEnvironmentId, renderedRequest, renderResult.context);
const { statusCode: status, statusMessage, headers: headerArray, elapsedTime: responseTime } = res;
const headers = headerArray?.reduce((acc, { name, value }) => ({ ...acc, [name.toLowerCase() || '']: value || '' }), []);
const bodyBuffer = await getBodyBuffer(res) as Buffer;
const data = bodyBuffer ? bodyBuffer.toString('utf8') : undefined;
return { status, statusMessage, data, headers, responseTime };
};
}

View File

@ -1,14 +1,28 @@
import type { ExtraRenderInfo } from '../../common/render';
import { ExtraRenderInfo, RENDER_PURPOSE_SEND } from '../../common/render';
import * as models from '../../models';
import type { Request } from '../../models/request';
import { send } from '../../network/network';
import { fetchRequestData, responseTransform, sendCurlAndWriteTimeline, tryToInterpolateRequest, tryToTransformRequestWithPlugins } from '../../network/network';
export function init(activeEnvironmentId: string | null) {
export function init() {
return {
network: {
async sendRequest(request: Request, extraInfo?: ExtraRenderInfo) {
const responsePatch = await send(request._id, activeEnvironmentId || undefined, extraInfo);
const settings = await models.settings.getOrCreate();
async sendRequest(req: Request, extraInfo?: ExtraRenderInfo) {
const { request,
environment,
settings,
clientCertificates,
caCert,
activeEnvironmentId } = await fetchRequestData(req._id);
const renderResult = await tryToInterpolateRequest(request, environment._id, RENDER_PURPOSE_SEND, extraInfo);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
const response = await sendCurlAndWriteTimeline(
renderedRequest,
clientCertificates,
caCert,
settings,
);
const responsePatch = await responseTransform(response, activeEnvironmentId, renderedRequest, renderResult.context);
return models.response.create(responsePatch, settings.maxHistoryResponses);
},
},

View File

@ -1,7 +1,16 @@
import * as misc from '../../common/misc';
import type { RenderedRequest } from '../../common/render';
import type { RequestBody } from '../../models/request';
export function filterParameters<T extends { name: string; value: string }>(
parameters: T[],
name: string,
): T[] {
if (!Array.isArray(parameters) || !name) {
return [];
}
return parameters.filter(h => (!h || !h.name ? false : h.name === name));
}
export function init(
renderedRequest: RenderedRequest | null,
renderedContext: Record<string, any>,
@ -129,7 +138,7 @@ export function init(
},
getParameter(name: string) {
const parameters = misc.filterParameters(renderedRequest.parameters, name);
const parameters = filterParameters(renderedRequest.parameters, name);
if (parameters.length) {
// Use the last parameter if there are multiple of the same
@ -152,12 +161,12 @@ export function init(
},
removeParameter(name: string) {
const parameters = misc.filterParameters(renderedRequest.parameters, name);
const parameters = filterParameters(renderedRequest.parameters, name);
renderedRequest.parameters = renderedRequest.parameters.filter(p => !parameters.includes(p));
},
setParameter(name: string, value: string) {
const parameter = misc.filterParameters(renderedRequest.parameters, name)[0];
const parameter = filterParameters(renderedRequest.parameters, name)[0];
if (parameter) {
parameter.value = value;
@ -167,7 +176,7 @@ export function init(
},
addParameter(name: string, value: string) {
const parameter = misc.filterParameters(renderedRequest.parameters, name)[0];
const parameter = filterParameters(renderedRequest.parameters, name)[0];
if (!parameter) {
renderedRequest.parameters.push({

View File

@ -3,7 +3,6 @@ import fs from 'fs';
import path from 'path';
import { ParsedApiSpec } from '../common/api-specs';
import { resolveHomePath } from '../common/misc';
import type { PluginConfig, PluginConfigMap } from '../common/settings';
import * as models from '../models';
import { GrpcRequest } from '../models/grpc-request';
@ -105,25 +104,12 @@ export type ColorScheme = 'default' | 'light' | 'dark';
let plugins: Plugin[] | null | undefined = null;
let ignorePlugins: string[] = [];
export async function init() {
clearIgnores();
await reloadPlugins();
}
export function ignorePlugin(name: string) {
if (!ignorePlugins.includes(name)) {
ignorePlugins.push(name);
}
}
export function clearIgnores() {
ignorePlugins = [];
}
async function _traversePluginPath(
pluginMap: Record<string, any>,
pluginMap: Record<string, Plugin>,
allPaths: string[],
allConfigs: PluginConfigMap,
) {
@ -169,8 +155,16 @@ async function _traversePluginPath(
// Delete require cache entry and re-require
const module = global.require(modulePath);
const pluginName = pluginJson.name;
pluginMap[pluginName] = _initPlugin(pluginJson || {}, module, allConfigs, modulePath);
pluginMap[pluginJson.name] = {
name: pluginJson.name,
description: pluginJson.description || pluginJson.insomnia.description || '',
version: pluginJson.version || 'unknown',
directory: modulePath || '',
config: allConfigs.hasOwnProperty(pluginJson.name)
? allConfigs[pluginJson.name]
: { disabled: false },
module: module,
};
console.log(`[plugin] Loaded ${modulePath}`);
} catch (err) {
showError({
@ -194,7 +188,13 @@ export async function getPlugins(force = false): Promise<Plugin[]> {
const extraPaths = settings.pluginPath
.split(':')
.filter(p => p)
.map(resolveHomePath);
.map(p => {
if (p.indexOf('~/') === 0) {
return path.join(process.env['HOME'] || '/', p.slice(1));
} else {
return p;
}
});
// Make sure the default directories exist
const pluginPath = path.join(process.env['INSOMNIA_DATA_PATH'] || (process.type === 'renderer' ? window : electron).app.getPath('userData'), 'plugins');
fs.mkdirSync(pluginPath, { recursive: true });
@ -400,29 +400,3 @@ export async function getThemes(): Promise<Theme[]> {
return extensions;
}
const _defaultPluginConfig: PluginConfig = {
disabled: false,
};
function _initPlugin(
packageJSON: Record<string, any>,
module: any,
allConfigs: PluginConfigMap,
path?: string | null,
): Plugin {
const meta = packageJSON.insomnia || {};
const name = packageJSON.name || meta.name;
// Find config
const config: PluginConfig = allConfigs.hasOwnProperty(name)
? allConfigs[name]
: _defaultPluginConfig;
return {
name,
description: packageJSON.description || meta.description || '',
version: packageJSON.version || 'unknown',
directory: path || '',
config,
module: module,
};
}

View File

@ -4,7 +4,7 @@ import path from 'path';
import * as crypt from '../../account/crypt';
import * as session from '../../account/session';
import { chunkArray, generateId } from '../../common/misc';
import { generateId } from '../../common/misc';
import { strings } from '../../common/strings';
import { BaseModel } from '../../models';
import Store from '../store';
@ -43,6 +43,14 @@ const EMPTY_HASH = crypto.createHash('sha1').digest('hex').replace(/./g, '0');
type ConflictHandler = (conflicts: MergeConflict[]) => Promise<MergeConflict[]>;
// breaks one array into multiple arrays of size chunkSize
export function chunkArray<T>(arr: T[], chunkSize: number) {
const chunks: T[][] = [];
for (let i = 0, j = arr.length; i < j; i += chunkSize) {
chunks.push(arr.slice(i, i + chunkSize));
}
return chunks;
}
export class VCS {
_store: Store;
_driver: BaseDriver;

View File

@ -96,8 +96,6 @@ export default class BaseExtension {
const renderMeta = renderContext.getMeta ? renderContext.getMeta() : {};
// Pull out the purpose
const renderPurpose = renderContext.getPurpose ? renderContext.getPurpose() : null;
// Pull out the environment ID
const environmentId = renderContext.getEnvironmentId ? renderContext.getEnvironmentId() : 'n/a';
// Extract the rest of the args
const args = runArgs
.slice(0, runArgs.length - 1)
@ -108,7 +106,7 @@ export default class BaseExtension {
...pluginContexts.app.init(renderPurpose),
// @ts-expect-error -- TSCONVERSION
...pluginContexts.store.init(this._plugin),
...pluginContexts.network.init(environmentId),
...pluginContexts.network.init(),
context: renderContext,
meta: renderMeta,
renderPurpose,

View File

@ -1,7 +1,6 @@
import { type Environment } from 'nunjucks';
import nunjucks from 'nunjucks/browser/nunjucks';
import type { TemplateTag } from '../plugins/index';
import * as plugins from '../plugins/index';
import { localTemplateTags } from '../ui/components/templating/local-template-tags';
import BaseExtension from './base-extension';
@ -178,16 +177,10 @@ async function getNunjucks(renderMode: string): Promise<NunjucksEnvironment> {
// Create Env with Extensions //
// ~~~~~~~~~~~~~~~~~~~~~~~~~~ //
const nunjucksEnvironment = nunjucks.configure(config) as NunjucksEnvironment;
let allTemplateTagPlugins: TemplateTag[];
try {
const pluginTemplateTags = await plugins.getTemplateTags();
allTemplateTagPlugins = [...pluginTemplateTags, ...localTemplateTags] as TemplateTag[];
} finally {
plugins.clearIgnores();
}
const pluginTemplateTags = await plugins.getTemplateTags();
const allExtensions = allTemplateTagPlugins;
const allExtensions = [...pluginTemplateTags, ...localTemplateTags];
for (const extension of allExtensions) {
const { templateTag, plugin } = extension;

View File

@ -3,7 +3,7 @@ import 'codemirror/addon/mode/overlay';
import CodeMirror, { EnvironmentAutocompleteOptions, Hint, ShowHintOptions } from 'codemirror';
import { getPlatformKeyCombinations } from '../../../../common/hotkeys';
import { escapeHTML, escapeRegex, fnOrString, isNotNullOrUndefined } from '../../../../common/misc';
import { escapeRegex, fnOrString, isNotNullOrUndefined } from '../../../../common/misc';
import { getDefaultFill, NunjucksParsedTag } from '../../../../templating/utils';
import { isNunjucksMode } from '../modes/nunjucks';
@ -506,6 +506,11 @@ function replaceWithSurround(text: string, find: string, prefix: string, suffix:
return text.replace(re, matched => prefix + matched + suffix);
}
function escapeHTML(unsafeText: string) {
const div = document.createElement('div');
div.innerText = unsafeText;
return div.innerHTML;
}
/**
* Render the autocomplete list entry
*/

View File

@ -60,12 +60,11 @@ export const RequestActionsDropdown = forwardRef<DropdownHandle, Props>(({
setLoadingActions({ ...loadingActions, [label]: true });
try {
const activeEnvironmentId = activeEnvironment ? activeEnvironment._id : null;
const context = {
...(pluginContexts.app.init(RENDER_PURPOSE_NO_RENDER)),
...pluginContexts.data.init(activeProject._id),
...(pluginContexts.store.init(plugin)),
...(pluginContexts.network.init(activeEnvironmentId)),
...(pluginContexts.network.init()),
};
await action(context, {
request,
@ -81,7 +80,7 @@ export const RequestActionsDropdown = forwardRef<DropdownHandle, Props>(({
if (ref && 'current' in ref) { // this `in` operator statement type-narrows to `MutableRefObject`
ref.current?.hide();
}
}, [request, activeEnvironment, requestGroup, loadingActions, activeProject._id, ref]);
}, [request, requestGroup, loadingActions, activeProject._id, ref]);
const duplicate = useCallback(() => {
handleDuplicateRequest(request);

View File

@ -29,7 +29,6 @@ export const RequestGroupActionsDropdown = forwardRef<RequestGroupActionsDropdow
...other
}, ref) => {
const {
activeEnvironment,
activeProject,
} = useRouteLoaderData(':workspaceId') as WorkspaceLoaderData;
const {
@ -125,7 +124,7 @@ export const RequestGroupActionsDropdown = forwardRef<RequestGroupActionsDropdow
...(pluginContexts.app.init(RENDER_PURPOSE_NO_RENDER) as Record<string, any>),
...pluginContexts.data.init(activeProject._id),
...(pluginContexts.store.init(plugin) as Record<string, any>),
...(pluginContexts.network.init(activeEnvironment._id) as Record<string, any>),
...(pluginContexts.network.init() as Record<string, any>),
};
const requests = await models.request.findByParentId(requestGroup._id);
requests.sort((a, b) => a.metaSortKey - b.metaSortKey);
@ -147,7 +146,7 @@ export const RequestGroupActionsDropdown = forwardRef<RequestGroupActionsDropdow
dropdownRef.current?.hide();
}, [dropdownRef, loadingActions, activeEnvironment, requestGroup, activeProject]);
}, [dropdownRef, loadingActions, requestGroup, activeProject]);
return (
<Dropdown

View File

@ -30,7 +30,6 @@ export const WorkspaceDropdown: FC = () => {
const {
activeWorkspace,
activeWorkspaceMeta,
activeEnvironment,
activeProject,
activeApiSpec,
clientCertificates,
@ -63,7 +62,7 @@ export const WorkspaceDropdown: FC = () => {
...(pluginContexts.app.init(RENDER_PURPOSE_NO_RENDER) as Record<string, any>),
...pluginContexts.data.init(activeProject._id),
...(pluginContexts.store.init(plugin) as Record<string, any>),
...(pluginContexts.network.init(activeEnvironment._id) as Record<string, any>),
...(pluginContexts.network.init() as Record<string, any>),
};
const docs = await db.withDescendants(workspace);
@ -86,7 +85,7 @@ export const WorkspaceDropdown: FC = () => {
}
setLoadingActions({ ...loadingActions, [label]: false });
dropdownRef.current?.hide();
}, [activeEnvironment, activeProject._id, loadingActions]);
}, [activeProject._id, loadingActions]);
const handleDropdownOpen = useCallback(async () => {
const actionPlugins = await getWorkspaceActions();

View File

@ -1,7 +1,7 @@
import React, { ChangeEvent, FC, ReactNode, useEffect, useMemo, useState } from 'react';
import { useRouteLoaderData } from 'react-router-dom';
import { convertEpochToMilliseconds, toKebabCase } from '../../../../common/misc';
import { toKebabCase } from '../../../../common/misc';
import accessTokenUrls from '../../../../datasets/access-token-urls';
import authorizationUrls from '../../../../datasets/authorization-urls';
import * as models from '../../../../models';
@ -280,7 +280,14 @@ export const OAuth2Auth: FC = () => {
</>
);
};
/**
Finds epoch's digit count and converts it to make it exactly 13 digits.
Which is the epoch millisecond representation. (trims last 2 digits)
*/
export function convertEpochToMilliseconds(epoch: number) {
const expDigitCount = epoch.toString().length;
return parseInt(String(epoch * 10 ** (13 - expDigitCount)), 10);
}
const renderIdentityTokenExpiry = (token?: Pick<OAuth2Token, 'identityToken'>) => {
if (!token || !token.identityToken) {
return;

View File

@ -14,11 +14,11 @@ import { useLocalStorage } from 'react-use';
import { CONTENT_TYPE_JSON } from '../../../../common/constants';
import { database as db } from '../../../../common/database';
import { markdownToHTML } from '../../../../common/markdown-to-html';
import { jsonParseOr } from '../../../../common/misc';
import { RENDER_PURPOSE_SEND } from '../../../../common/render';
import type { ResponsePatch } from '../../../../main/network/libcurl-promise';
import * as models from '../../../../models';
import type { Request } from '../../../../models/request';
import * as network from '../../../../network/network';
import { fetchRequestData, responseTransform, sendCurlAndWriteTimeline, tryToInterpolateRequest, tryToTransformRequestWithPlugins } from '../../../../network/network';
import { invariant } from '../../../../utils/invariant';
import { jsonPrettify } from '../../../../utils/prettify/json';
import { RootLoaderData } from '../../../routes/root';
@ -63,7 +63,6 @@ const isOperationDefinition = (def: DefinitionNode): def is OperationDefinitionN
const fetchGraphQLSchemaForRequest = async ({
requestId,
environmentId,
url,
}: {
requestId: string;
@ -74,9 +73,9 @@ const fetchGraphQLSchemaForRequest = async ({
return;
}
const request = await models.request.getById(requestId);
const req = await models.request.getById(requestId);
if (!request) {
if (!req) {
return;
}
@ -87,10 +86,10 @@ const fetchGraphQLSchemaForRequest = async ({
operationName: 'IntrospectionQuery',
});
const introspectionRequest = await db.upsert(
Object.assign({}, request, {
_id: request._id + '.graphql',
Object.assign({}, req, {
_id: req._id + '.graphql',
settingMaxTimelineDataSize: 5000,
parentId: request._id,
parentId: req._id,
isPrivate: true,
// So it doesn't get synced or exported
body: {
@ -99,7 +98,22 @@ const fetchGraphQLSchemaForRequest = async ({
},
}),
);
const response = await network.send(introspectionRequest._id, environmentId);
const { request,
environment,
settings,
clientCertificates,
caCert,
activeEnvironmentId } = await fetchRequestData(introspectionRequest._id);
const renderResult = await tryToInterpolateRequest(request, 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);
const statusCode = response.statusCode || 0;
if (!response) {
return {
@ -160,6 +174,7 @@ interface State {
documentAST: null | DocumentNode;
disabledOperationMarkers: (TextMarker | undefined)[];
}
export const GraphQLEditor: FC<Props> = ({
request,
environmentId,
@ -175,7 +190,11 @@ export const GraphQLEditor: FC<Props> = ({
requestBody = { query: '' };
}
if (typeof requestBody.variables === 'string') {
requestBody.variables = jsonParseOr(requestBody.variables, '');
try {
requestBody.variables = JSON.parse(requestBody.variables);
} catch (err) {
requestBody.variables = '';
}
}
let documentAST;
try {

View File

@ -68,7 +68,7 @@ export const SyncStagingModal = ({ vcs, branch, onSnapshot, handlePush, onHide }
lookupMap[key] = {
changes: hasDocAndLastSnapshot ? describeChanges(document, lastSnapshot) : null,
entry: entry,
type: models.getModelName(docOrLastSnapshot.type),
type: models.getModel(docOrLastSnapshot.type)?.name || 'Unknown',
checked: !!status.stage[key],
};
}

View File

@ -6,11 +6,12 @@ import styled from 'styled-components';
import { getCommonHeaderNames, getCommonHeaderValues } from '../../../common/common-headers';
import { documentationLinks } from '../../../common/documentation';
import { generateId } from '../../../common/misc';
import { getRenderContext, getRenderedGrpcRequest, getRenderedGrpcRequestMessage, render, RENDER_PURPOSE_SEND } from '../../../common/render';
import { getRenderedGrpcRequest, getRenderedGrpcRequestMessage, RENDER_PURPOSE_SEND } from '../../../common/render';
import { GrpcMethodType } from '../../../main/ipc/grpc';
import * as models from '../../../models';
import type { GrpcRequestHeader } from '../../../models/grpc-request';
import { queryAllWorkspaceUrls } from '../../../models/helpers/query-all-workspace-urls';
import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate';
import { useRequestPatcher } from '../../hooks/use-request';
import { useActiveRequestSyncVCSVersion, useGitVCSVersion } from '../../hooks/use-vcs-version';
import { GrpcRequestState } from '../../routes/debug';
@ -195,8 +196,7 @@ export const GrpcRequestPane: FunctionComponent<Props> = ({
disabled={!activeRequest.url}
onClick={async () => {
try {
const renderContext = await getRenderContext({ request: activeRequest, environmentId, purpose: RENDER_PURPOSE_SEND });
const rendered = await render({ url: activeRequest.url, metadata: activeRequest.metadata }, renderContext);
const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({ request: activeRequest, environmentId, payload: { url: activeRequest.url, metadata: activeRequest.metadata } });
const methods = await window.main.grpc.loadMethodsFromReflection(rendered);
setGrpcState({ ...grpcState, methods });
patchRequest(requestId, { protoFileId: '', protoMethodName: '' });

View File

@ -1,27 +1,23 @@
import * as contentDisposition from 'content-disposition';
import fs from 'fs';
import { extension as mimeExtension } from 'mime-types';
import path from 'path';
import React, { forwardRef, useCallback, useImperativeHandle, useRef, useState } from 'react';
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useFetcher, useParams, useRouteLoaderData } from 'react-router-dom';
import { useInterval } from 'react-use';
import styled from 'styled-components';
import { database } from '../../common/database';
import { getContentDispositionHeader } from '../../common/misc';
import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../common/render';
import { RENDER_PURPOSE_SEND } from '../../common/render';
import * as models from '../../models';
import { isEventStreamRequest, isRequest } from '../../models/request';
import * as network from '../../network/network';
import { fetchRequestData, tryToInterpolateRequest, tryToTransformRequestWithPlugins } from '../../network/network';
import { convert } from '../../utils/importers/convert';
import { invariant } from '../../utils/invariant';
import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../utils/try-interpolate';
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../utils/url/querystring';
import { SegmentEvent } from '../analytics';
import { useReadyState } from '../hooks/use-ready-state';
import { useRequestPatcher } from '../hooks/use-request';
import { useRequestMetaPatcher } from '../hooks/use-request';
import { useTimeoutWhen } from '../hooks/useTimeoutWhen';
import { ConnectActionParams, RequestLoaderData } from '../routes/request';
import { ConnectActionParams, RequestLoaderData, SendActionParams } from '../routes/request';
import { RootLoaderData } from '../routes/root';
import { WorkspaceLoaderData } from '../routes/workspace';
import { Dropdown, DropdownButton, type DropdownHandle, DropdownItem, DropdownSection, ItemContent } from './base/dropdown';
@ -30,7 +26,6 @@ import { MethodDropdown } from './dropdowns/method-dropdown';
import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from './keydown-binder';
import { GenerateCodeModal } from './modals/generate-code-modal';
import { showAlert, showModal, showPrompt } from './modals/index';
import { RequestRenderErrorModal } from './modals/request-render-error-modal';
const StyledDropdownButton = styled(DropdownButton)({
'&:hover:not(:disabled)': {
@ -75,9 +70,6 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
const inputRef = useRef<OneLineEditorHandle>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const handleGenerateCode = () => {
showModal(GenerateCodeModal, { request: activeRequest });
};
const focusInput = useCallback(() => {
if (inputRef.current) {
inputRef.current.focusEnd();
@ -88,9 +80,34 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
const [currentInterval, setCurrentInterval] = useState<number | null>(null);
const [currentTimeout, setCurrentTimeout] = useState<number | undefined>(undefined);
const fetcher = useFetcher();
// TODO: unpick this loading hack
useEffect(() => {
if (fetcher.state !== 'idle') {
setLoading(true);
} else {
setLoading(false);
}
}, [fetcher.state, setLoading]);
const { organizationId, projectId, workspaceId, requestId } = useParams() as { organizationId: string; projectId: string; workspaceId: string; requestId: string };
const connect = (connectParams: ConnectActionParams) => {
fetcher.submit(JSON.stringify(connectParams),
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${requestId}/connect`,
method: 'post',
encType: 'application/json',
});
};
const send = (sendParams: SendActionParams) => {
fetcher.submit(JSON.stringify(sendParams),
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${requestId}/send`,
method: 'post',
encType: 'application/json',
});
};
const sendThenSetFilePath = useCallback(async () => {
// Update request stats
const sendOrConnect = async (shouldPromptForPathAfterResponse?: boolean) => {
models.stats.incrementExecutedRequests();
window.main.trackSegmentEvent({
event: SegmentEvent.requestExecute,
@ -100,61 +117,47 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
mimeType: activeRequest.body.mimeType,
},
});
setLoading(true);
try {
const responsePatch = await network.send(activeRequest._id, activeEnvironment._id);
const is2XXWithBodyPath = responsePatch.statusCode && responsePatch.statusCode >= 200 && responsePatch.statusCode < 300 && responsePatch.bodyPath;
if (!is2XXWithBodyPath) {
// Save the bad responses so failures are shown still
await models.response.create(responsePatch, settings.maxHistoryResponses);
setLoading(false);
return;
}
let downloadPathAndName = '';
if (downloadPath) {
const sanitizedExtension = responsePatch.contentType && mimeExtension(responsePatch.contentType);
const extension = sanitizedExtension || 'unknown';
const headers = responsePatch.headers || [];
const header = getContentDispositionHeader(headers);
const nameFromHeader = header ? contentDisposition.parse(header.value).parameters.filename : null;
const name = nameFromHeader || `${activeRequest.name.replace(/\s/g, '-').toLowerCase()}.${extension}`;
downloadPathAndName = path.join(downloadPath, name);
}
if (!downloadPath) {
const defaultPath = window.localStorage.getItem('insomnia.sendAndDownloadLocation');
const { filePath } = await window.dialog.showSaveDialog({
title: 'Select Download Location',
buttonLabel: 'Save',
// NOTE: An error will be thrown if defaultPath is supplied but not a String
...(defaultPath ? { defaultPath } : {}),
});
if (!filePath) {
setLoading(false);
return;
}
window.localStorage.setItem('insomnia.sendAndDownloadLocation', filePath);
downloadPathAndName = filePath;
}
invariant(downloadPathAndName, 'filename should be set by now');
// reset timeout
setCurrentTimeout(undefined);
const to = fs.createWriteStream(downloadPathAndName);
const readStream = models.response.getBodyStream(responsePatch);
if (!readStream || typeof readStream === 'string') {
setLoading(false);
return;
}
readStream.pipe(to);
readStream.on('end', async () => {
responsePatch.error = `Saved to ${downloadPathAndName}`;
await models.response.create(responsePatch, settings.maxHistoryResponses);
setLoading(false);
return;
});
readStream.on('error', async err => {
console.warn('Failed to download request after sending', responsePatch.bodyPath, err);
await models.response.create(responsePatch, settings.maxHistoryResponses);
setLoading(false);
return;
if (isEventStreamRequest(activeRequest)) {
const startListening = async () => {
const environmentId = activeEnvironment._id;
const workspaceId = activeWorkspace._id;
// Render any nunjucks tags in the url/headers/authentication settings/cookies
const workspaceCookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId);
const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({
request: activeRequest,
environmentId,
payload: {
url: activeRequest.url,
headers: activeRequest.headers,
authentication: activeRequest.authentication,
parameters: activeRequest.parameters.filter(p => !p.disabled),
workspaceCookieJar,
},
});
rendered && connect({
url: joinUrlAndQueryString(rendered.url, buildQueryStringFromParams(rendered.parameters)),
headers: rendered.headers,
authentication: rendered.authentication,
cookieJar: rendered.workspaceCookieJar,
});
};
startListening();
return;
}
try {
const { request,
environment } = await fetchRequestData(requestId);
const renderResult = await tryToInterpolateRequest(request, environment._id, RENDER_PURPOSE_SEND);
const renderedRequest = await tryToTransformRequestWithPlugins(renderResult);
renderedRequest && send({
renderedRequest,
shouldPromptForPathAfterResponse,
context: renderResult.context,
});
} catch (err) {
showAlert({
@ -168,143 +171,12 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
</div>
),
});
setLoading(false);
}
}, [activeEnvironment._id, activeRequest._id, activeRequest.authentication?.type, activeRequest.body.mimeType, activeRequest.name, downloadPath, setLoading, settings.maxHistoryResponses, settings.preferredHttpVersion]);
const handleSend = useCallback(async () => {
if (!activeRequest) {
return;
}
// Update request stats
models.stats.incrementExecutedRequests();
window.main.trackSegmentEvent({
event: SegmentEvent.requestExecute,
properties: {
preferredHttpVersion: settings.preferredHttpVersion,
authenticationType: activeRequest.authentication?.type,
mimeType: activeRequest.body.mimeType,
},
});
setLoading(true);
try {
const responsePatch = await network.send(activeRequest._id, activeEnvironment._id);
const response = await models.response.create(responsePatch, settings.maxHistoryResponses);
await patchRequestMeta(activeRequest._id, { activeResponseId: response._id });
} catch (err) {
if (err.type === 'render') {
showModal(RequestRenderErrorModal, {
request: activeRequest,
error: err,
});
} else {
showAlert({
title: 'Unexpected Request Failure',
message: (
<div>
<p>The request failed due to an unhandled error:</p>
<code className="wide selectable">
<pre>{err.message}</pre>
</code>
</div>
),
});
}
}
setLoading(false);
}, [activeEnvironment._id, activeRequest, setLoading, settings.maxHistoryResponses, settings.preferredHttpVersion, patchRequestMeta]);
const fetcher = useFetcher();
const { organizationId, projectId, workspaceId, requestId } = useParams() as { organizationId: string; projectId: string; workspaceId: string; requestId: string };
const connect = (connectParams: ConnectActionParams) => {
fetcher.submit(JSON.stringify(connectParams),
{
action: `/organization/${organizationId}/project/${projectId}/workspace/${workspaceId}/debug/request/${requestId}/connect`,
method: 'post',
encType: 'application/json',
});
};
const send = () => {
setCurrentTimeout(undefined);
if (downloadPath) {
sendThenSetFilePath();
return;
}
if (isEventStreamRequest(activeRequest)) {
const startListening = async () => {
const environmentId = activeEnvironment._id;
const workspaceId = activeWorkspace._id;
const renderContext = await getRenderContext({ request: activeRequest, environmentId, purpose: RENDER_PURPOSE_SEND });
// Render any nunjucks tags in the url/headers/authentication settings/cookies
const workspaceCookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId);
const rendered = await render({
url: activeRequest.url,
headers: activeRequest.headers,
authentication: activeRequest.authentication,
parameters: activeRequest.parameters.filter(p => !p.disabled),
workspaceCookieJar,
}, renderContext);
connect({
url: joinUrlAndQueryString(rendered.url, buildQueryStringFromParams(rendered.parameters)),
headers: rendered.headers,
authentication: rendered.authentication,
cookieJar: rendered.workspaceCookieJar,
});
};
startListening();
return;
}
handleSend();
};
useInterval(send, currentInterval ? currentInterval : null);
useTimeoutWhen(send, currentTimeout, !!currentTimeout);
useInterval(sendOrConnect, currentInterval ? currentInterval : null);
useTimeoutWhen(sendOrConnect, currentTimeout, !!currentTimeout);
const patchRequest = useRequestPatcher();
const handleStop = () => {
if (isEventStreamRequest(activeRequest)) {
window.main.curl.close({ requestId: activeRequest._id });
return;
}
setCurrentInterval(null);
setCurrentTimeout(undefined);
};
const handleSendOnInterval = useCallback(() => {
showPrompt({
inputType: 'decimal',
title: 'Send on Interval',
label: 'Interval in seconds',
defaultValue: '3',
submitName: 'Start',
onComplete: seconds => {
setCurrentInterval(+seconds * 1000);
},
});
}, []);
const handleSendAfterDelay = () => {
showPrompt({
inputType: 'decimal',
title: 'Send After Delay',
label: 'Delay in seconds',
defaultValue: '3',
onComplete: seconds => {
setCurrentTimeout(+seconds * 1000);
},
});
};
const downloadAfterSend = useCallback(async () => {
const { canceled, filePaths } = await window.dialog.showOpenDialog({
title: 'Select Download Location',
buttonLabel: 'Select',
properties: ['openDirectory'],
});
if (canceled) {
return;
}
patchRequestMeta(activeRequest._id, { downloadPath: filePaths[0] });
}, [activeRequest._id, patchRequestMeta]);
const handleClearDownloadLocation = () => patchRequestMeta(activeRequest._id, { downloadPath: null });
useDocBodyKeyboardShortcuts({
request_focusUrl: () => {
@ -312,7 +184,7 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
},
request_send: () => {
if (activeRequest.url) {
send();
sendOrConnect();
}
},
request_toggleHttpMethodMenu: () => {
@ -399,107 +271,130 @@ export const RequestUrlBar = forwardRef<RequestUrlBarHandle, Props>(({
defaultValue={url}
onChange={handleUrlChange}
onKeyDown={createKeybindingsHandler({
'Enter': () => send(),
'Enter': () => sendOrConnect(),
})}
/>
{isCancellable ? (
<button
type="button"
className="urlbar__send-btn"
onClick={handleStop}
onClick={() => {
if (isEventStreamRequest(activeRequest)) {
window.main.curl.close({ requestId: activeRequest._id });
return;
}
setCurrentInterval(null);
setCurrentTimeout(undefined);
}}
>
{isEventStreamRequest(activeRequest) ? 'Disconnect' : 'Cancel'}
</button>
) : (
<>
) : (<>
<button
type="button"
onClick={() => sendOrConnect()}
className="urlbar__send-btn"
onClick={send}
type="button"
>
{buttonText}</button>
{isEventStreamRequest(activeRequest) ? null : (<Dropdown
key="dropdown"
className="tall"
ref={dropdownRef}
aria-label="Request Options"
onClose={handleSendDropdownHide}
closeOnSelect={false}
triggerButton={
<StyledDropdownButton
className="urlbar__send-context"
removeBorderRadius={true}
>
<i className="fa fa-caret-down" />
</StyledDropdownButton>
}
{buttonText}</button>
{isEventStreamRequest(activeRequest) ? null : (<Dropdown
key="dropdown"
className="tall"
ref={dropdownRef}
aria-label="Request Options"
onClose={handleSendDropdownHide}
closeOnSelect={false}
triggerButton={
<StyledDropdownButton
className="urlbar__send-context"
removeBorderRadius={true}
>
<i className="fa fa-caret-down" />
</StyledDropdownButton>
}
>
<DropdownSection
aria-label="Basic Section"
title="Basic"
>
<DropdownSection
aria-label="Basic Section"
title="Basic"
>
<DropdownItem aria-label="send-now">
<DropdownItem aria-label="send-now">
<ItemContent icon="arrow-circle-o-right" label="Send Now" hint={hotKeyRegistry.request_send} onClick={sendOrConnect} />
</DropdownItem>
<DropdownItem aria-label='Generate Client Code'>
<ItemContent
icon="code"
label="Generate Client Code"
onClick={() => showModal(GenerateCodeModal, { request: activeRequest })}
/>
</DropdownItem>
</DropdownSection>
<DropdownSection
aria-label="Advanced Section"
title="Advanced"
>
<DropdownItem aria-label='Send After Delay'>
<ItemContent
icon="clock-o"
label="Send After Delay"
onClick={() => showPrompt({
inputType: 'decimal',
title: 'Send After Delay',
label: 'Delay in seconds',
defaultValue: '3',
onComplete: seconds => {
setCurrentTimeout(+seconds * 1000);
},
})}
/>
</DropdownItem>
<DropdownItem aria-label='Repeat on Interval'>
<ItemContent
icon="repeat"
label="Repeat on Interval"
onClick={() => showPrompt({
inputType: 'decimal',
title: 'Send on Interval',
label: 'Interval in seconds',
defaultValue: '3',
submitName: 'Start',
onComplete: seconds => {
setCurrentInterval(+seconds * 1000);
},
})}
/>
</DropdownItem>
{downloadPath
? (<DropdownItem aria-label='Stop Auto-Download'>
<ItemContent
icon="arrow-circle-o-right"
label="Send Now"
hint={hotKeyRegistry.request_send}
onClick={send}
icon="stop-circle"
label="Stop Auto-Download"
withPrompt
onClick={() => patchRequestMeta(activeRequest._id, { downloadPath: null })}
/>
</DropdownItem>
<DropdownItem aria-label='Generate Client Code'>
<ItemContent
icon="code"
label="Generate Client Code"
onClick={handleGenerateCode}
/>
</DropdownItem>
</DropdownSection>
<DropdownSection
aria-label="Advanced Section"
title="Advanced"
>
<DropdownItem aria-label='Send After Delay'>
<ItemContent
icon="clock-o"
label="Send After Delay"
onClick={handleSendAfterDelay}
/>
</DropdownItem>
<DropdownItem aria-label='Repeat on Interval'>
<ItemContent
icon="repeat"
label="Repeat on Interval"
onClick={handleSendOnInterval}
/>
</DropdownItem>
{downloadPath ? (
<DropdownItem aria-label='Stop Auto-Download'>
<ItemContent
icon="stop-circle"
label="Stop Auto-Download"
withPrompt
onClick={handleClearDownloadLocation}
/>
</DropdownItem>) :
(<DropdownItem aria-label='Download After Send'>
<ItemContent
icon="download"
label="Download After Send"
onClick={downloadAfterSend}
/>
</DropdownItem>
)}
<DropdownItem aria-label='Send And Download'>
</DropdownItem>)
: (<DropdownItem aria-label='Download After Send'>
<ItemContent
icon="download"
label="Send And Download"
onClick={sendThenSetFilePath}
label="Download After Send"
onClick={async () => {
const { canceled, filePaths } = await window.dialog.showOpenDialog({
title: 'Select Download Location',
buttonLabel: 'Select',
properties: ['openDirectory'],
});
if (canceled) {
return;
}
patchRequestMeta(activeRequest._id, { downloadPath: filePaths[0] });
}}
/>
</DropdownItem>
</DropdownSection>
</Dropdown>)}
</>
)}
</DropdownItem>)}
<DropdownItem aria-label='Send And Download'>
<ItemContent icon="download" label="Send And Download" onClick={() => sendOrConnect(true)} />
</DropdownItem>
</DropdownSection>
</Dropdown>)}
</>)}
</div>
</div>
);

View File

@ -1,7 +1,6 @@
import React, { ChangeEventHandler, FC, InputHTMLAttributes, useCallback } from 'react';
import { useRouteLoaderData } from 'react-router-dom';
import { snapNumberToLimits } from '../../../common/misc';
import { SettingsOfType } from '../../../common/settings';
import { useSettingsPatcher } from '../../hooks/use-request';
import { RootLoaderData } from '../../routes/root';
@ -15,7 +14,17 @@ interface Props {
setting: SettingsOfType<number>;
step?: InputHTMLAttributes<HTMLInputElement>['step'];
}
export function snapNumberToLimits(value: number, min?: number, max?: number) {
const moreThanMax = max && !Number.isNaN(max) && value > max;
if (moreThanMax) {
return max;
}
const lessThanMin = min && !Number.isNaN(min) && value < min;
if (lessThanMin) {
return min;
}
return value;
}
export const NumberSetting: FC<Props> = ({
help,
label,

View File

@ -1,20 +1,34 @@
import React, { FC, memo } from 'react';
import * as util from '../../../common/misc';
import { METHOD_DELETE, METHOD_OPTIONS } from '../../../common/constants';
interface Props {
method: string;
override?: string | null;
fullNames?: boolean;
}
function removeVowels(str: string) {
return str.replace(/[aeiouyAEIOUY]/g, '');
}
function formatMethodName(method: string) {
let methodName = method || '';
if (method === METHOD_DELETE || method === METHOD_OPTIONS) {
methodName = method.slice(0, 3);
} else if (method.length > 4) {
methodName = removeVowels(method).slice(0, 4);
}
return methodName;
}
export const MethodTag: FC<Props> = memo(({ method, override, fullNames }) => {
let methodName = method;
let overrideName = override;
if (!fullNames) {
methodName = util.formatMethodName(method);
overrideName = override ? util.formatMethodName(override) : override;
methodName = formatMethodName(method);
overrideName = override ? formatMethodName(override) : override;
}
return (

View File

@ -2,14 +2,19 @@ import { differenceInMinutes, formatDistanceToNowStrict } from 'date-fns';
import React, { FC, useState } from 'react';
import { useInterval } from 'react-use';
import { toTitleCase } from '../../common/misc';
interface Props {
timestamp: number | Date | string;
intervalSeconds?: number;
className?: string;
titleCase?: boolean;
}
const toTitleCase = (value: string) => (
value
.toLowerCase()
.split(' ')
.map(value => value.charAt(0).toUpperCase() + value.slice(1))
.join(' ')
);
function getTimeFromNow(timestamp: string | number | Date, titleCase: boolean): string {
const date = new Date(timestamp);

View File

@ -11,7 +11,6 @@ import {
PREVIEW_MODE_FRIENDLY,
PREVIEW_MODE_RAW,
} from '../../../common/constants';
import { xmlDecode } from '../../../common/misc';
import { CodeEditor, CodeEditorHandle } from '../codemirror/code-editor';
import { useDocBodyKeyboardShortcuts } from '../keydown-binder';
import { ResponseCSVViewer } from './response-csv-viewer';
@ -25,7 +24,18 @@ let alwaysShowLargeResponses = false;
export interface ResponseViewerHandle {
refresh: () => void;
}
export function xmlDecode(input: string) {
const ESCAPED_CHARACTERS_MAP = {
'&amp;': '&',
'&quot;': '"',
'&lt;': '<',
'&gt;': '>',
};
return input.replace(/(&quot;|&lt;|&gt;|&amp;)/g, (_: string, item: keyof typeof ESCAPED_CHARACTERS_MAP) => (
ESCAPED_CHARACTERS_MAP[item])
);
}
export interface ResponseViewerProps {
bytes: number;
contentType: string;

View File

@ -2,15 +2,13 @@ import React, { FC, useLayoutEffect, useRef } from 'react';
import { useFetcher, useParams } from 'react-router-dom';
import styled from 'styled-components';
import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../../common/render';
import * as models from '../../../models';
import { WebSocketRequest } from '../../../models/websocket-request';
import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate';
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../../utils/url/querystring';
import { ConnectActionParams } from '../../routes/request';
import { OneLineEditor, OneLineEditorHandle } from '../codemirror/one-line-editor';
import { createKeybindingsHandler, useDocBodyKeyboardShortcuts } from '../keydown-binder';
import { showAlert, showModal } from '../modals';
import { RequestRenderErrorModal } from '../modals/request-render-error-modal';
import { DisconnectButton } from './disconnect-button';
const Button = styled.button<{ warning?: boolean }>(({ warning }) => ({
@ -84,48 +82,32 @@ export const WebSocketActionBar: FC<ActionBarProps> = ({ request, environmentId,
encType: 'application/json',
});
};
const handleSubmit = async () => {
if (isOpen) {
window.main.webSocket.close({ requestId: request._id });
return;
}
try {
const renderContext = await getRenderContext({ request, environmentId, purpose: RENDER_PURPOSE_SEND });
// Render any nunjucks tags in the url/headers/authentication settings/cookies
const workspaceCookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId);
const rendered = await render({
// Render any nunjucks tags in the url/headers/authentication settings/cookies
const workspaceCookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId);
const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({
request,
environmentId,
payload: {
url: request.url,
headers: request.headers,
authentication: request.authentication,
parameters: request.parameters.filter(p => !p.disabled),
workspaceCookieJar,
}, renderContext);
connect({
url: joinUrlAndQueryString(rendered.url, buildQueryStringFromParams(rendered.parameters)),
headers: rendered.headers,
authentication: rendered.authentication,
cookieJar: rendered.workspaceCookieJar,
});
} catch (err) {
if (err.type === 'render') {
showModal(RequestRenderErrorModal, {
request,
error: err,
});
} else {
showAlert({
title: 'Unexpected Request Failure',
message: (
<div>
<p>The request failed due to an unhandled error:</p>
<code className="wide selectable">
<pre>{err.message}</pre>
</code>
</div>
),
});
}
}
},
});
rendered && connect({
url: joinUrlAndQueryString(rendered.url, buildQueryStringFromParams(rendered.parameters)),
headers: rendered.headers,
authentication: rendered.authentication,
cookieJar: rendered.workspaceCookieJar,
});
};
useDocBodyKeyboardShortcuts({

View File

@ -3,10 +3,10 @@ import { useParams, useRouteLoaderData } from 'react-router-dom';
import styled from 'styled-components';
import { AuthType, CONTENT_TYPE_JSON } from '../../../common/constants';
import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../../../common/render';
import * as models from '../../../models';
import { Environment } from '../../../models/environment';
import { WebSocketRequest } from '../../../models/websocket-request';
import { tryToInterpolateRequestOrShowRenderErrorModal } from '../../../utils/try-interpolate';
import { buildQueryStringFromParams, joinUrlAndQueryString } from '../../../utils/url/querystring';
import { useReadyState } from '../../hooks/use-ready-state';
import { useRequestPatcher } from '../../hooks/use-request';
@ -107,21 +107,25 @@ const WebSocketRequestForm: FC<FormProps> = ({
init();
}, [request._id]);
// NOTE: Nunjucks interpolation can throw errors
const interpolateOpenAndSend = async (payload: string) => {
try {
const renderContext = await getRenderContext({ request, environmentId, purpose: RENDER_PURPOSE_SEND });
const renderedMessage = await render(payload, renderContext);
const renderedMessage = await tryToInterpolateRequestOrShowRenderErrorModal({ request, environmentId, payload });
const readyState = await window.main.webSocket.readyState.getCurrent({ requestId: request._id });
if (!readyState) {
const workspaceCookieJar = await models.cookieJar.getOrCreateForParentId(workspaceId);
const rendered = await render({
url: request.url,
headers: request.headers,
authentication: request.authentication,
parameters: request.parameters.filter(p => !p.disabled),
workspaceCookieJar,
}, renderContext);
const rendered = await tryToInterpolateRequestOrShowRenderErrorModal({
request,
environmentId,
payload: {
url: request.url,
headers: request.headers,
authentication: request.authentication,
parameters: request.parameters.filter(p => !p.disabled),
workspaceCookieJar,
},
});
window.main.webSocket.open({
requestId: request._id,
workspaceId,

View File

@ -148,6 +148,10 @@ const router = createMemoryRouter(
loader: async (...args) => (await import('./routes/request')).loader(...args),
element: (<Outlet />),
children: [
{
path: 'send',
action: async (...args) => (await import('./routes/request')).sendAction(...args),
},
{
path: 'connect',
action: async (...args) => (await import('./routes/request')).connectAction(...args),

View File

@ -316,13 +316,7 @@ export const runAllTestsAction: ActionFunction = async ({
const src = generate([{ name: 'My Suite', suites: [], tests }]);
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(
workspaceId
);
const sendRequest = getSendRequestCallback(
workspaceMeta?.activeEnvironmentId || undefined
);
const sendRequest = getSendRequestCallback();
const results = await runTests(src, { sendRequest });
@ -435,13 +429,8 @@ export const runTestAction: ActionFunction = async ({ params }) => {
},
];
const src = generate([{ name: 'My Suite', suites: [], tests }]);
const workspaceMeta = await models.workspaceMeta.getOrCreateByParentId(
unitTest.parentId
);
const sendRequest = getSendRequestCallback(
workspaceMeta?.activeEnvironmentId || undefined
);
const sendRequest = getSendRequestCallback();
const results = await runTests(src, { sendRequest });

View File

@ -1,7 +1,15 @@
import { createWriteStream } from 'node:fs';
import path from 'node:path';
import * as contentDisposition from 'content-disposition';
import { extension as mimeExtension } from 'mime-types';
import { ActionFunction, LoaderFunction, redirect } from 'react-router-dom';
import { CONTENT_TYPE_EVENT_STREAM, CONTENT_TYPE_GRAPHQL, CONTENT_TYPE_JSON, METHOD_GET, METHOD_POST } from '../../common/constants';
import { ChangeBufferEvent, database } from '../../common/database';
import { getContentDispositionHeader } from '../../common/misc';
import { RenderedRequest } from '../../common/render';
import { ResponsePatch } from '../../main/network/libcurl-promise';
import * as models from '../../models';
import { BaseModel } from '../../models';
import { CookieJar } from '../../models/cookie-jar';
@ -14,6 +22,7 @@ import { RequestVersion } from '../../models/request-version';
import { Response } from '../../models/response';
import { isWebSocketRequestId, WebSocketRequest } from '../../models/websocket-request';
import { WebSocketResponse } from '../../models/websocket-response';
import { fetchRequestData, responseTransform, sendCurlAndWriteTimeline } from '../../network/network';
import { invariant } from '../../utils/invariant';
import { SegmentEvent } from '../analytics';
import { updateMimeType } from '../components/dropdowns/content-type-dropdown';
@ -263,7 +272,91 @@ export const connectAction: ActionFunction = async ({ request, params }) => {
});
});
};
const writeToDownloadPath = (downloadPathAndName: string, responsePatch: ResponsePatch, requestMeta: RequestMeta, maxHistoryResponses: number) => {
invariant(downloadPathAndName, 'filename should be set by now');
const to = createWriteStream(downloadPathAndName);
const readStream = models.response.getBodyStream(responsePatch);
if (!readStream || typeof readStream === 'string') {
// setLoading(false);
return null;
}
readStream.pipe(to);
return new Promise(resolve => {
readStream.on('end', async () => {
responsePatch.error = `Saved to ${downloadPathAndName}`;
const response = await models.response.create(responsePatch, maxHistoryResponses);
await models.requestMeta.update(requestMeta, { activeResponseId: response._id });
// setLoading(false);
resolve(null);
});
readStream.on('error', async err => {
console.warn('Failed to download request after sending', responsePatch.bodyPath, err);
const response = await models.response.create(responsePatch, maxHistoryResponses);
await models.requestMeta.update(requestMeta, { activeResponseId: response._id });
// setLoading(false);
resolve(null);
});
});
};
export interface SendActionParams {
renderedRequest: RenderedRequest;
shouldPromptForPathAfterResponse?: boolean;
context: Record<string, any>;
}
export const sendAction: ActionFunction = async ({ request, params }) => {
const { requestId, workspaceId } = params;
invariant(typeof requestId === 'string', 'Request ID is required');
const req = await requestOperations.getById(requestId);
invariant(req, 'Request not found');
invariant(workspaceId, 'Workspace ID is required');
const {
settings,
clientCertificates,
caCert,
activeEnvironmentId } = await fetchRequestData(requestId);
const { renderedRequest, shouldPromptForPathAfterResponse, context } = await request.json() as SendActionParams;
const response = await sendCurlAndWriteTimeline(
renderedRequest,
clientCertificates,
caCert,
settings,
);
const requestMeta = await models.requestMeta.getByParentId(requestId);
invariant(requestMeta, 'RequestMeta not found');
const responsePatch = await responseTransform(response, activeEnvironmentId, renderedRequest, context);
const is2XXWithBodyPath = responsePatch.statusCode && responsePatch.statusCode >= 200 && responsePatch.statusCode < 300 && responsePatch.bodyPath;
const shouldWriteToFile = shouldPromptForPathAfterResponse && is2XXWithBodyPath;
if (!shouldWriteToFile) {
const response = await models.response.create(responsePatch, settings.maxHistoryResponses);
await models.requestMeta.update(requestMeta, { activeResponseId: response._id });
// setLoading(false);
return null;
}
if (requestMeta.downloadPath) {
const header = getContentDispositionHeader(responsePatch.headers || []);
const name = header
? contentDisposition.parse(header.value).parameters.filename
: `${req.name.replace(/\s/g, '-').toLowerCase()}.${responsePatch.contentType && mimeExtension(responsePatch.contentType) || 'unknown'}`;
return writeToDownloadPath(path.join(requestMeta.downloadPath, name), responsePatch, requestMeta, settings.maxHistoryResponses);
} else {
const defaultPath = window.localStorage.getItem('insomnia.sendAndDownloadLocation');
const { filePath } = await window.dialog.showSaveDialog({
title: 'Select Download Location',
buttonLabel: 'Save',
// NOTE: An error will be thrown if defaultPath is supplied but not a String
...(defaultPath ? { defaultPath } : {}),
});
if (!filePath) {
// setLoading(false);
return null;
}
window.localStorage.setItem('insomnia.sendAndDownloadLocation', filePath);
return writeToDownloadPath(filePath, responsePatch, requestMeta, settings.maxHistoryResponses);
};
};
export const deleteAllResponsesAction: ActionFunction = async ({ params }) => {
const { workspaceId, requestId } = params;
invariant(typeof requestId === 'string', 'Request ID is required');

View File

@ -0,0 +1,20 @@
import { getRenderContext, render, RENDER_PURPOSE_SEND } from '../common/render';
import { GrpcRequest } from '../models/grpc-request';
import { Request } from '../models/request';
import { WebSocketRequest } from '../models/websocket-request';
import { showModal } from '../ui/components/modals';
import { RequestRenderErrorModal } from '../ui/components/modals/request-render-error-modal';
// NOTE: template interpolation is tightly coupled with modal implementation
export const tryToInterpolateRequestOrShowRenderErrorModal = async ({ request, environmentId, payload }: { request: Request | WebSocketRequest | GrpcRequest; environmentId: string; payload: any }): Promise<any> => {
try {
const renderContext = await getRenderContext({ request, environmentId, purpose: RENDER_PURPOSE_SEND });
return await render(payload, renderContext);
} catch (error) {
if (error.type === 'render') {
showModal(RequestRenderErrorModal, { request, error });
return;
}
throw error;
}
};