mockttp
Version:
Mock HTTP server for testing HTTP clients and stubbing webservices
291 lines (288 loc) • 11.9 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.setHeaderValue = exports.findRawHeaders = exports.findRawHeaderIndex = exports.getHeaderValue = exports.findRawHeader = void 0;
exports.pairFlatRawHeaders = pairFlatRawHeaders;
exports.flattenPairedRawHeaders = flattenPairedRawHeaders;
exports.rawHeadersToObject = rawHeadersToObject;
exports.rawHeadersToObjectPreservingCase = rawHeadersToObjectPreservingCase;
exports.objectHeadersToRaw = objectHeadersToRaw;
exports.objectHeadersToFlat = objectHeadersToFlat;
exports.updateRawHeaders = updateRawHeaders;
exports.h2HeadersToH1 = h2HeadersToH1;
exports.h1HeadersToH2 = h1HeadersToH2;
exports.dropDefaultHeaders = dropDefaultHeaders;
exports.validateHeader = validateHeader;
const _ = require("lodash");
const http = require("http");
/*
These utils support conversion between the various header representations that we deal
with. Those are:
- Flat arrays of [key, value, key, value, key, ...]. This is the raw header format
generally used by Node.js's APIs throughout.
- Raw header tuple arrays like [[key, value], [key, value]]. This is our own raw header
format, aiming to be fairly easy to use and to preserve header order, header dupes &
header casing throughout.
- Formatted header objects of { key: value, key: value }. These are returned as the most
convenient and consistent header format: keys are lowercased, and values are either
strings or arrays of strings (for duplicate headers). This is returned by Node's APIs,
but with some unclear normalization rules, so in practice we build raw headers and
reconstruct this ourselves everyhere, by lowercasing & building arrays of values.
*/
const findRawHeader = (rawHeaders, targetKey) => rawHeaders.find(([key]) => key.toLowerCase() === targetKey);
exports.findRawHeader = findRawHeader;
const getHeaderValue = (headers, targetKey) => {
if (Array.isArray(headers)) {
return (0, exports.findRawHeader)(headers, targetKey)?.[1];
}
else {
const value = headers[targetKey];
if (Array.isArray(value)) {
return value[0];
}
else {
return value;
}
}
};
exports.getHeaderValue = getHeaderValue;
const findRawHeaderIndex = (rawHeaders, targetKey) => rawHeaders.findIndex(([key]) => key.toLowerCase() === targetKey);
exports.findRawHeaderIndex = findRawHeaderIndex;
const findRawHeaders = (rawHeaders, targetKey) => rawHeaders.filter(([key]) => key.toLowerCase() === targetKey);
exports.findRawHeaders = findRawHeaders;
/**
* Return node's _very_ raw headers ([k, v, k, v, ...]) into our slightly more convenient
* pairwise tuples [[k, v], [k, v], ...] RawHeaders structure.
*/
function pairFlatRawHeaders(flatRawHeaders) {
const result = [];
for (let i = 0; i < flatRawHeaders.length; i += 2 /* Move two at a time */) {
result[i / 2] = [flatRawHeaders[i], flatRawHeaders[i + 1]];
}
return result;
}
function flattenPairedRawHeaders(rawHeaders) {
return rawHeaders.flat();
}
/**
* Take a raw headers, and turn them into headers, but without some of Node's concessions
* to ease of use, i.e. keeping multiple values as arrays.
*
* This lowercases all names along the way, to provide a convenient header API for most
* downstream use cases, and to match Node's own behaviour.
*/
function rawHeadersToObject(rawHeaders) {
return rawHeaders.reduce((headers, [key, value]) => {
key = key.toLowerCase();
const existingValue = headers[key];
if (Array.isArray(existingValue)) {
existingValue.push(value);
}
else if (existingValue) {
headers[key] = [existingValue, value];
}
else {
headers[key] = value;
}
return headers;
}, {});
}
/**
* Take raw headers, and turn them into headers just like `rawHeadersToObject` but
* also preserves case en route.
*
* This is separated because our public APIs should _not_ do this, but there's a few
* internal use cases where we want to, notably including passing headers to WS which
* only accepts a headers object when sending upstream requests, but does preserve
* case from the object.
*/
function rawHeadersToObjectPreservingCase(rawHeaders) {
// Duplicate keys with different cases in the final object clobber each other (last
// value wins) so we need to pick a single casing for each header name. We don't want
// to just use lowercase, because we want to preserve original casing wherever possible.
// To make that work, we use the casing from the first instance of each header, along with
// a lowercase -> first casing map here to look up that value later:
const headerNameMap = {};
return rawHeaders.reduce((headers, [key, value]) => {
const lowerCaseKey = key.toLowerCase();
if (headerNameMap[lowerCaseKey]) {
// If we've already seen this header, we need to use the same
// casing as before to avoid issues with duplicates:
key = headerNameMap[lowerCaseKey];
}
else {
// If we haven't, we store this key as the canonical format
// to make it easy to merge with any duplicates:
headerNameMap[lowerCaseKey] = key;
}
const existingValue = headers[key];
if (Array.isArray(existingValue)) {
existingValue.push(value);
}
else if (existingValue) {
headers[key] = [existingValue, value];
}
else {
headers[key] = value;
}
return headers;
}, {});
}
function objectHeadersToRaw(headers) {
const rawHeaders = [];
for (let key in headers) {
const value = headers[key];
if (value === undefined)
continue; // Drop undefined header values
if (Array.isArray(value)) {
value.forEach((v) => rawHeaders.push([key, v.toString()]));
}
else {
rawHeaders.push([key, value.toString()]);
}
}
return rawHeaders;
}
function objectHeadersToFlat(headers) {
const flatHeaders = [];
for (let key in headers) {
const value = headers[key];
if (value === undefined)
continue; // Drop undefined header values
if (Array.isArray(value)) {
value.forEach((v) => {
flatHeaders.push(key);
flatHeaders.push(v.toString());
});
}
else {
flatHeaders.push(key);
flatHeaders.push(value.toString());
}
}
return flatHeaders;
}
/**
* Combine the given headers with the raw headers, preserving the raw header details where
* possible. Headers keys that exist in the raw headers (case insensitive) will be overridden,
* while undefined header values will remove the header from the raw headers entirely.
*
* When proxying we often have raw received headers that we want to forward upstream exactly
* as they were received, but we also want to add or modify a subset of those headers. This
* method carefully does that - preserving everything that isn't actively modified as-is.
*/
function updateRawHeaders(rawHeaders, headers) {
const updatedRawHeaders = [...rawHeaders];
const rawHeaderKeys = updatedRawHeaders.map(([key]) => key.toLowerCase());
for (const key of Object.keys(headers)) {
const lowerCaseKey = key.toLowerCase();
for (let i = 0; i < rawHeaderKeys.length; i++) {
// If you insert a header that already existed, remove all previous values
if (rawHeaderKeys[i] === lowerCaseKey) {
updatedRawHeaders.splice(i, 1);
rawHeaderKeys.splice(i, 1);
}
}
}
// We do all removals in advance, then do all additions here, to ensure that adding
// a new header twice works correctly.
for (const [key, value] of Object.entries(headers)) {
// Skip (effectively delete) undefined/null values from the headers
if (value === undefined || value === null)
continue;
if (Array.isArray(value)) {
value.forEach((v) => updatedRawHeaders.push([key, v]));
}
else {
updatedRawHeaders.push([key, value]);
}
}
return updatedRawHeaders;
}
// See https://httptoolkit.com/blog/translating-http-2-into-http-1/ for details on the
// transformations required between H2 & H1 when proxying.
function h2HeadersToH1(h2Headers, method) {
let h1Headers = h2Headers.filter(([key]) => key[0] !== ':');
if (!(0, exports.findRawHeader)(h1Headers, 'host') && (0, exports.findRawHeader)(h2Headers, ':authority')) {
h1Headers.unshift(['Host', (0, exports.findRawHeader)(h2Headers, ':authority')[1]]);
}
// In HTTP/1 you MUST only send one cookie header - in HTTP/2 sending multiple is fine,
// so we have to concatenate them:
const cookieHeaders = (0, exports.findRawHeaders)(h1Headers, 'cookie');
if (cookieHeaders.length > 1) {
h1Headers = h1Headers.filter(([key]) => key.toLowerCase() !== 'cookie');
h1Headers.push(['Cookie', cookieHeaders.join('; ')]);
}
// We don't know if the request has a body yet - but just in case, we ensure it could:
if (
// If the request is a method that probably has a body
method !== 'GET' &&
method !== 'HEAD' &&
!( // And you haven't set any kind of framing headers:
(0, exports.findRawHeader)(h1Headers, 'content-length') ||
(0, exports.findRawHeader)(h1Headers, 'transfer-encoding')?.includes('chunked'))) { // Add transfer-encoding chunked, which should support all possible cases:
h1Headers.push(['Transfer-Encoding', 'chunked']);
}
return h1Headers;
}
// Take from http2/util.js in Node itself
const HTTP2_ILLEGAL_HEADERS = [
'connection',
'upgrade',
'host',
'http2-settings',
'keep-alive',
'proxy-connection',
'transfer-encoding'
];
function h1HeadersToH2(headers) {
return headers.filter(([key]) => !HTTP2_ILLEGAL_HEADERS.includes(key.toLowerCase()));
}
// If the user explicitly specifies headers, we tell Node not to handle them,
// so the user-defined headers are the full set.
function dropDefaultHeaders(response) {
// Drop the default headers, so only the headers we explicitly configure are included
[
'connection',
'content-length',
'transfer-encoding',
'date'
].forEach((defaultHeader) => response.removeHeader(defaultHeader));
}
function validateHeader(name, value) {
try {
http.validateHeaderName(name);
http.validateHeaderValue(name, value);
return true;
}
catch (e) {
return false;
}
}
/**
* Set the value of a given header, overwriting it if present or otherwise adding it as a new header.
*
* For header objects, this overwrites all values. For raw headers, this overwrites the last value, so
* if multiple values are present others may remain. In general you probably don't want to use this
* for headers that could legally have multiple values present.
*/
const setHeaderValue = (headers, headerKey, headerValue, options = {}) => {
const lowercaseHeaderKey = headerKey.toLowerCase();
if (Array.isArray(headers)) {
const headerPair = _.findLast(headers, ([key]) => key.toLowerCase() === lowercaseHeaderKey);
if (headerPair) {
headerPair[1] = headerValue;
}
else {
if (options.prepend)
headers.unshift([headerKey, headerValue]);
else
headers.push([headerKey, headerValue]);
}
}
else {
const existingKey = Object.keys(headers).find(k => k.toLowerCase() === lowercaseHeaderKey);
headers[existingKey || headerKey] = headerValue;
}
};
exports.setHeaderValue = setHeaderValue;
//# sourceMappingURL=header-utils.js.map