mirror of
https://github.com/Kong/insomnia
synced 2024-11-08 14:49:53 +00:00
208aec6ec8
This adds a new trigger condition for re-sending a request for the `response` plugin called "When Expired". This condition allows specifying a maximum age of a response to use. This is very useful when configuring a hierarchy of environment variables that ultimately depend on an auth token that needs to be refreshed periodically. Previous workarounds are: 1. Use "always refresh". This fails in cases where you must use the same auth token since it is refreshed for every request. 2. Use "no history" or "never" and manually refresh the token. This is inconvenient and requires pinning the token. Alternative solutions are: 1. Add more sophisticated logic to determine when the token needs to be refreshed (e.g. refresh if the current request returns a `401` status code) 2. Write a custom response plugin to time out the token. I believe this functionality is small enough and general enough to be contributed back generally. Fixes #1972
267 lines
7.8 KiB
JavaScript
267 lines
7.8 KiB
JavaScript
const jq = require('jsonpath');
|
|
const iconv = require('iconv-lite');
|
|
const { query: queryXPath } = require('insomnia-xpath');
|
|
|
|
function isFilterableField(field) {
|
|
return field !== 'raw' && field !== 'url';
|
|
}
|
|
|
|
module.exports.templateTags = [
|
|
{
|
|
name: 'response',
|
|
displayName: 'Response',
|
|
description: "reference values from other request's responses",
|
|
args: [
|
|
{
|
|
displayName: 'Attribute',
|
|
type: 'enum',
|
|
options: [
|
|
{
|
|
displayName: 'Body Attribute',
|
|
description: 'value of response body',
|
|
value: 'body',
|
|
},
|
|
{
|
|
displayName: 'Raw Body',
|
|
description: 'entire response body',
|
|
value: 'raw',
|
|
},
|
|
{
|
|
displayName: 'Header',
|
|
description: 'value of response header',
|
|
value: 'header',
|
|
},
|
|
{
|
|
displayName: 'Request URL',
|
|
description: 'Url of initiating request',
|
|
value: 'url',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
displayName: 'Request',
|
|
type: 'model',
|
|
model: 'Request',
|
|
},
|
|
{
|
|
type: 'string',
|
|
encoding: 'base64',
|
|
hide: args => !isFilterableField(args[0].value),
|
|
displayName: args => {
|
|
switch (args[0].value) {
|
|
case 'body':
|
|
return 'Filter (JSONPath or XPath)';
|
|
case 'header':
|
|
return 'Header Name';
|
|
default:
|
|
return 'Filter';
|
|
}
|
|
},
|
|
},
|
|
{
|
|
displayName: 'Trigger Behavior',
|
|
help: 'Configure when to resend the dependent request',
|
|
type: 'enum',
|
|
options: [
|
|
{
|
|
displayName: 'Never',
|
|
description: 'never resend request',
|
|
value: 'never',
|
|
},
|
|
{
|
|
displayName: 'No History',
|
|
description: 'resend when no responses present',
|
|
value: 'no-history',
|
|
},
|
|
{
|
|
displayName: 'When Expired',
|
|
description: 'resend when existing response has expired',
|
|
value: 'when-expired',
|
|
},
|
|
{
|
|
displayName: 'Always',
|
|
description: 'resend request when needed',
|
|
value: 'always',
|
|
},
|
|
],
|
|
},
|
|
{
|
|
displayName: 'Max age (seconds)',
|
|
help: 'The maximum age of a response to use before it expires',
|
|
type: 'number',
|
|
hide: args => args[3].value !== 'when-expired',
|
|
defaultValue: 60,
|
|
},
|
|
],
|
|
|
|
async run(context, field, id, filter, resendBehavior, maxAgeSeconds) {
|
|
filter = filter || '';
|
|
resendBehavior = (resendBehavior || 'never').toLowerCase();
|
|
|
|
if (!['body', 'header', 'raw', 'url'].includes(field)) {
|
|
throw new Error(`Invalid response field ${field}`);
|
|
}
|
|
|
|
if (!id) {
|
|
throw new Error('No request specified');
|
|
}
|
|
|
|
const request = await context.util.models.request.getById(id);
|
|
if (!request) {
|
|
throw new Error(`Could not find request ${id}`);
|
|
}
|
|
|
|
const environmentId = context.context.getEnvironmentId();
|
|
let response = await context.util.models.response.getLatestForRequestId(id, environmentId);
|
|
|
|
let shouldResend = false;
|
|
if (context.context.getExtraInfo('fromResponseTag')) {
|
|
shouldResend = false;
|
|
} else if (resendBehavior === 'never') {
|
|
shouldResend = false;
|
|
} else if (resendBehavior === 'no-history') {
|
|
shouldResend = !response;
|
|
} else if (resendBehavior === 'when-expired') {
|
|
if (!response) {
|
|
shouldResend = true;
|
|
} else {
|
|
const ageSeconds = (Date.now() - response.created) / 1000;
|
|
shouldResend = ageSeconds > maxAgeSeconds;
|
|
}
|
|
} else if (resendBehavior === 'always') {
|
|
shouldResend = true;
|
|
}
|
|
|
|
// Make sure we only send the request once per render so we don't have infinite recursion
|
|
const fromResponseTag = context.context.getExtraInfo('fromResponseTag');
|
|
if (fromResponseTag) {
|
|
console.log('[response tag] Preventing recursive render');
|
|
shouldResend = false;
|
|
}
|
|
|
|
if (shouldResend && context.renderPurpose === 'send') {
|
|
console.log('[response tag] Resending dependency');
|
|
response = await context.network.sendRequest(request, [
|
|
{ name: 'fromResponseTag', value: true },
|
|
]);
|
|
}
|
|
|
|
if (!response) {
|
|
console.log('[response tag] No response found');
|
|
throw new Error('No responses for request');
|
|
}
|
|
|
|
if (response.error) {
|
|
console.log('[response tag] Response error ' + response.error);
|
|
throw new Error('Failed to send dependent request ' + response.error);
|
|
}
|
|
|
|
if (!response.statusCode) {
|
|
console.log('[response tag] Invalid status code ' + response.statusCode);
|
|
throw new Error('No successful responses for request');
|
|
}
|
|
|
|
if (isFilterableField(field) && !filter) {
|
|
throw new Error(`No ${field} filter specified`);
|
|
}
|
|
|
|
const sanitizedFilter = filter.trim();
|
|
|
|
if (field === 'header') {
|
|
return matchHeader(response.headers, sanitizedFilter);
|
|
} else if (field === 'url') {
|
|
return response.url;
|
|
} else if (field === 'raw') {
|
|
const bodyBuffer = context.util.models.response.getBodyBuffer(response, '');
|
|
const match = response.contentType.match(/charset=([\w-]+)/);
|
|
const charset = match && match.length >= 2 ? match[1] : 'utf-8';
|
|
|
|
// Sometimes iconv conversion fails so fallback to regular buffer
|
|
try {
|
|
return iconv.decode(bodyBuffer, charset);
|
|
} catch (err) {
|
|
console.warn('[response] Failed to decode body', err);
|
|
return bodyBuffer.toString();
|
|
}
|
|
} else if (field === 'body') {
|
|
const bodyBuffer = context.util.models.response.getBodyBuffer(response, '');
|
|
const match = response.contentType.match(/charset=([\w-]+)/);
|
|
const charset = match && match.length >= 2 ? match[1] : 'utf-8';
|
|
|
|
// Sometimes iconv conversion fails so fallback to regular buffer
|
|
let body;
|
|
try {
|
|
body = iconv.decode(bodyBuffer, charset);
|
|
} catch (err) {
|
|
body = bodyBuffer.toString();
|
|
console.warn('[response] Failed to decode body', err);
|
|
}
|
|
|
|
if (sanitizedFilter.indexOf('$') === 0) {
|
|
return matchJSONPath(body, sanitizedFilter);
|
|
} else {
|
|
return matchXPath(body, sanitizedFilter);
|
|
}
|
|
} else {
|
|
throw new Error(`Unknown field ${field}`);
|
|
}
|
|
},
|
|
},
|
|
];
|
|
|
|
function matchJSONPath(bodyStr, query) {
|
|
let body;
|
|
let results;
|
|
|
|
try {
|
|
body = JSON.parse(bodyStr);
|
|
} catch (err) {
|
|
throw new Error(`Invalid JSON: ${err.message}`);
|
|
}
|
|
|
|
try {
|
|
results = jq.query(body, query);
|
|
} catch (err) {
|
|
throw new Error(`Invalid JSONPath query: ${query}`);
|
|
}
|
|
|
|
if (results.length === 0) {
|
|
throw new Error(`Returned no results: ${query}`);
|
|
} else if (results.length > 1) {
|
|
throw new Error(`Returned more than one result: ${query}`);
|
|
}
|
|
|
|
if (typeof results[0] !== 'string') {
|
|
return JSON.stringify(results[0]);
|
|
} else {
|
|
return results[0];
|
|
}
|
|
}
|
|
|
|
function matchXPath(bodyStr, query) {
|
|
const results = queryXPath(bodyStr, query);
|
|
|
|
if (results.length === 0) {
|
|
throw new Error(`Returned no results: ${query}`);
|
|
} else if (results.length > 1) {
|
|
throw new Error(`Returned more than one result: ${query}`);
|
|
}
|
|
|
|
return results[0].inner;
|
|
}
|
|
|
|
function matchHeader(headers, name) {
|
|
if (!headers.length) {
|
|
throw new Error('No headers available');
|
|
}
|
|
|
|
const header = headers.find(h => h.name.toLowerCase() === name.toLowerCase());
|
|
|
|
if (!header) {
|
|
const names = headers.map(c => `"${c.name}"`).join(',\n\t');
|
|
throw new Error(`No header with name "${name}".\nChoices are [\n\t${names}\n]`);
|
|
}
|
|
|
|
return header.value;
|
|
}
|