@api-client/har
Version:
Everything related to HAR processing and visualizing in API Client.
454 lines (431 loc) • 14.1 kB
JavaScript
/* eslint-disable class-methods-use-this */
import { HeadersParser } from '@advanced-rest-client/arc-headers';
import { BodyProcessor } from '@advanced-rest-client/body-editor';
import { Cookies } from '@advanced-rest-client/arc-cookies';
import * as DataSize from '../lib/DataSize.js';
/** @typedef {import('@advanced-rest-client/arc-types').ArcRequest.ArcBaseRequest} ArcBaseRequest */
/** @typedef {import('@advanced-rest-client/arc-types').ArcRequest.TransportRequest} TransportRequest */
/** @typedef {import('@advanced-rest-client/arc-types').ArcResponse.ErrorResponse} ErrorResponse */
/** @typedef {import('@advanced-rest-client/arc-types').ArcResponse.Response} ArcResponse */
/** @typedef {import('@advanced-rest-client/arc-types').ArcResponse.HTTPResponse} HTTPResponse */
/** @typedef {import('@advanced-rest-client/arc-types').ArcResponse.TransformedPayload} TransformedPayload */
/** @typedef {import('har-format').Har} Har */
/** @typedef {import('har-format').Log} Log */
/** @typedef {import('har-format').Creator} Creator */
/** @typedef {import('har-format').Entry} Entry */
/** @typedef {import('har-format').Cache} Cache */
/** @typedef {import('har-format').Request} Request */
/** @typedef {import('har-format').Response} Response */
/** @typedef {import('har-format').Header} Header */
/** @typedef {import('har-format').PostData} PostData */
/** @typedef {import('har-format').Content} Content */
/** @typedef {import('har-format').QueryString} QueryString */
/** @typedef {import('har-format').Cookie} Cookie */
export const createLog = Symbol('createLog');
export const createCreator = Symbol('createCreator');
export const createEntry = Symbol('createEntry');
export const createCache = Symbol('createCache');
export const createRequest = Symbol('createRequest');
export const createResponse = Symbol('createResponse');
export const createResponseContent = Symbol('createResponseContent');
export const createHeaders = Symbol('createHeaders');
export const createPostData = Symbol('createPostData');
export const readBodyString = Symbol('readBodyString');
export const readQueryString = Symbol('readQueryString');
export const readRequestCookies = Symbol('readRequestCookies');
export const readResponseCookies = Symbol('readResponseCookies');
/**
* A class that transforms ARC request objects into a HAR format.
*/
export class HarTransformer {
/**
* @param {string=} version The application version name.
* @param {string=} name The name of the "creator" field.
*/
constructor(version, name) {
this.name = name || 'Advanced REST Client';
this.version = version || 'Unknown';
}
/**
* Transforms the request objects to a log.
* @param {ArcBaseRequest[]} requests
* @returns {Promise<Har>}
*/
async transform(requests) {
const log = this[createLog]();
log.entries = await this.createEntries(requests);
const result = /** @type Har */ ({
log,
});
return result;
}
/**
* @returns {Log}
*/
[createLog]() {
const log = /** @type Log */ ({
creator: this[createCreator](),
version: '1.2',
entries: [],
});
return log;
}
/**
* @returns {Creator}
*/
[createCreator]() {
const { name, version } = this;
const result = /** @type Creator */ ({
name,
version
});
return result;
}
/**
* @param {ArcBaseRequest[]} requests
* @returns {Promise<Entry[]>}
*/
async createEntries(requests) {
const ps = requests.map((r) => this.createEntry(r));
const result = await Promise.all(ps);
let entires = [];
result.forEach((entry) => {
if (!entry) {
return;
}
if (Array.isArray(entry)) {
entires = entires.concat(entry);
} else {
entires.push(entry);
}
});
entires = entires.sort((a, b) => new Date(b.startedDateTime).getTime() - new Date(a.startedDateTime).getTime());
return entires;
}
/**
* @param {ArcBaseRequest} request
* @returns {Promise<Entry|Entry[]|null>}
*/
async createEntry(request) {
const processedRequest = BodyProcessor.restorePayload(request);
const { response, transportRequest } = processedRequest;
if (!response || !transportRequest) {
return null;
}
const typedError = /** @type ErrorResponse */ (response);
if (typedError.error) {
// In ARC this means a general error, like I can't make a connection error.
// The HTTP errors are reported via the regular response object.
return null;
}
let typedResponse = /** @type ArcResponse */ (response);
typedResponse = BodyProcessor.restorePayload(typedResponse);
const item = await this[createEntry](processedRequest, transportRequest, typedResponse);
if (Array.isArray(typedResponse.redirects) && typedResponse.redirects.length) {
const result = await this.createRedirectEntries(processedRequest, typedResponse);
result.push(item);
return result;
}
return item;
}
/**
* @param {ArcBaseRequest} request
* @param {TransportRequest} transportRequest
* @param {ArcResponse} response
* @return {Promise<Entry>}
*/
async [createEntry](request, transportRequest, response) {
const { loadingTime, timings } = response;
const { startTime = Date.now(), } = transportRequest;
const entry = /** @type Entry */ ({
startedDateTime: new Date(startTime).toISOString(),
time: loadingTime,
cache: this[createCache](),
timings,
request: await this[createRequest](request),
response: await this[createResponse](response),
});
return entry;
}
/**
* @param {ArcBaseRequest} request
* @param {ArcResponse} response
* @return {Promise<Entry[]>}
*/
async createRedirectEntries(request, response) {
const ps = response.redirects.map(async (redirect) => {
const { startTime=Date.now(), endTime=Date.now(), timings, response: redirectResponse, url } = redirect;
const loadingTime = endTime - startTime;
const entry = /** @type Entry */ ({
startedDateTime: new Date(startTime).toISOString(),
time: loadingTime,
cache: this[createCache](),
timings,
request: await this[createRequest](request),
response: await this[createResponse](redirectResponse, url),
});
return entry;
});
return Promise.all(ps);
}
/**
* @returns {Cache}
*/
[createCache]() {
const result = /** @type Cache */ ({
afterRequest: null,
beforeRequest: null,
comment: 'This application does not support caching.'
});
return result;
}
/**
* @param {ArcBaseRequest} request
* @returns {Promise<Request>}
*/
async [createRequest](request) {
const { url, method, headers, payload } = request;
const result = /** @type Request */ ({
method,
url,
httpVersion: 'HTTP/1.1',
headers: this[createHeaders](headers),
bodySize: 0,
headersSize: 0,
cookies: this[readRequestCookies](headers),
queryString: this[readQueryString](url),
});
if (payload) {
result.bodySize = await DataSize.computePayloadSize(payload);
result.postData = await this[createPostData](payload, headers);
}
if (headers) {
// Total number of bytes from the start of the HTTP request message until (and including)
// the double CRLF before the body.
// @todo: compute size of the message header
result.headersSize = DataSize.calculateBytes(headers) + 4;
}
return result;
}
/**
* @param {HTTPResponse} response The response data
* @param {string=} redirectURL Optional redirect URL for the redirected request.
* @returns {Promise<Response>}
*/
async [createResponse](response, redirectURL) {
const { status, statusText, payload, headers, } = response;
const result = /** @type Response */ ({
status,
statusText,
httpVersion: 'HTTP/1.1',
cookies: this[readResponseCookies](headers),
headers: this[createHeaders](headers),
redirectURL,
headersSize: 0,
bodySize: 0,
});
if (payload) {
result.content = await this[createResponseContent](/** @type string | Buffer | ArrayBuffer */ (payload), headers);
result.bodySize = result.content.size || 0;
}
if (headers) {
// Total number of bytes from the start of the HTTP request message until (and including)
// the double CRLF before the body.
// @todo: compute size of the message header
result.headersSize = DataSize.calculateBytes(headers) + 4;
}
return result;
}
/**
* @param {string} headers
* @returns {Header[]}
*/
[createHeaders](headers) {
if (!headers || typeof headers !== 'string') {
return [];
}
const list = HeadersParser.toJSON(headers);
return list.map((item) => {
const { name, value } = item;
return {
name,
value,
};
});
}
/**
* @param {string | File | Blob | Buffer | ArrayBuffer | FormData} payload
* @param {string} headers
* @returns {Promise<PostData>}
*/
async [createPostData](payload, headers) {
const mimeType = HeadersParser.contentType(headers);
const result = /** @type PostData */ ({
mimeType,
});
const type = typeof payload;
if (['string', 'boolean', 'undefined'].includes(type)) {
result.text = /** @type string */ (payload);
} else if (payload instanceof Blob) {
result.text = await BodyProcessor.fileToString(/** @type File */ (payload));
} else if (payload instanceof FormData) {
const r = new Request('/', {
body: payload,
method: 'POST',
});
const buff = await r.arrayBuffer();
result.text = this[readBodyString](buff);
} else {
result.text = this[readBodyString](/** string|Buffer|ArrayBuffer */ (payload));
}
return result;
}
/**
* @param {string|Buffer|ArrayBuffer} body The body
* @param {string=} charset The optional charset to use with the text decoder.
* @returns {string}
*/
[readBodyString](body, charset) {
const type = typeof body;
if (['string', 'boolean', 'undefined'].includes(type)) {
return /** @type string */ (body);
}
let typed = /** @type Buffer|ArrayBuffer */(body);
// don't remember. I think it's either Node's or ARC's property.
// @ts-ignore
if (typed && typed.type === 'Buffer') {
// @ts-ignore
typed = new Uint8Array(typed.data);
}
const decoder = new TextDecoder(charset);
try {
return decoder.decode(typed);
} catch (e) {
return '';
}
}
/**
* @param {string | Buffer | ArrayBuffer} payload
* @param {string} headers
* @returns {Promise<Content>}
*/
async [createResponseContent](payload, headers) {
const headerItem = HeadersParser.toJSON(headers).find((item) => item.name.toLowerCase() === 'content-type');
let mimeType = /** @type string */ (headerItem && headerItem.value);
let encoding;
if (mimeType && mimeType.includes('charset=')) {
const parts = mimeType.split(';');
// Content-Type: text/html; charset=UTF-8
parts.forEach((part) => {
const item = part.trim();
const _tmp = item.split('=');
if (_tmp.length === 1) {
mimeType = item;
} else if (_tmp[0].trim() === 'charset') {
encoding = _tmp[1].trim();
}
});
}
const result = /** @type Content */ ({
mimeType,
});
if (payload) {
result.text = this[readBodyString](payload);
result.size = await DataSize.computePayloadSize(payload);
}
if (encoding) {
result.encoding = encoding;
}
return result;
}
/**
* @param {string} url
* @returns {QueryString[]}
*/
[readQueryString](url) {
const result = /** @type QueryString[] */ ([]);
try {
const parser = new URL(url);
parser.searchParams.forEach(([value ,name]) => {
result.push({
name,
value,
});
});
} catch (e) {
//
}
return result;
}
/**
* Produces a list of cookies for the request.
* @param {string} headers Request headers
* @returns {Cookie[]}
*/
[readRequestCookies](headers) {
const result = /** @type Cookie[] */ ([]);
if (!headers || typeof headers !== 'string') {
return result;
}
const parsed = HeadersParser.toJSON(headers);
const cookieItem = parsed.find((item) => item.name.toLowerCase() === 'cookie');
if (!cookieItem) {
return result;
}
const { value: cookieString } = cookieItem;
if (!cookieString) {
return result;
}
const parser = new Cookies(cookieString);
parser.cookies.forEach((item) => {
const { name, value } = item;
result.push({
name,
value,
});
});
return result;
}
/**
* Produces a list of cookies for the response.
* @param {string} headers Response headers
* @returns {Cookie[]}
*/
[readResponseCookies](headers) {
const result = /** @type Cookie[] */ ([]);
if (!headers || typeof headers !== 'string') {
return result;
}
const parsed = HeadersParser.toJSON(headers);
const cookieItem = parsed.find((item) => item.name.toLowerCase() === 'set-cookie');
if (!cookieItem) {
return result;
}
const { value: cookieString } = cookieItem;
if (!cookieString) {
return result;
}
const parser = new Cookies(cookieString);
parser.cookies.forEach((item) => {
const { name, value, path, domain, expires, httpOnly, secure } = item;
let expiresString;
if (typeof expires === 'number') {
const d = new Date(expires);
expiresString = d.toISOString();
}
const cookie = /** @type Cookie */ ({
name,
value,
path,
domain,
httpOnly,
secure,
});
if (expiresString) {
cookie.expires = expiresString;
}
result.push(cookie);
});
return result;
}
}