consul/ui/packages/consul-ui/app/services/client/http.js

318 lines
10 KiB
JavaScript

/**
* Copyright (c) HashiCorp, Inc.
* SPDX-License-Identifier: BUSL-1.1
*/
import Service, { inject as service } from '@ember/service';
import { get } from '@ember/object';
import { next } from '@ember/runloop';
import { CACHE_CONTROL, CONTENT_TYPE } from 'consul-ui/utils/http/headers';
import {
HEADERS_TOKEN as CONSUL_TOKEN,
HEADERS_PARTITION as CONSUL_PARTITION,
HEADERS_NAMESPACE as CONSUL_NAMESPACE,
HEADERS_DATACENTER as CONSUL_DATACENTER,
} from 'consul-ui/utils/http/consul';
import createURL from 'consul-ui/utils/http/create-url';
import createHeaders from 'consul-ui/utils/http/create-headers';
import createQueryParams from 'consul-ui/utils/http/create-query-params';
// reopen EventSources if a user changes tab
export const restartWhenAvailable = function (client) {
return function (e) {
// setup the aborted connection restarting
// this should happen here to avoid cache deletion
const status = get(e, 'errors.firstObject.status');
// TODO: Reconsider a proper custom HTTP code
// -1 is a UI only error code for 'user switched tab'
if (status === '-1') {
// Any '0' errors (abort) should possibly try again, depending upon the circumstances
// whenAvailable returns a Promise that resolves when the client is available
// again
return client.whenAvailable(e);
}
throw e;
};
};
const QueryParams = {
stringify: createQueryParams(encodeURIComponent),
};
const parseHeaders = createHeaders();
const parseBody = function (strs, ...values) {
let body = {};
const doubleBreak = strs.reduce(function (prev, item, i) {
// Ensure each line has no whitespace either end, including empty lines
item = item
.split('\n')
.map((item) => item.trim())
.join('\n');
if (item.indexOf('\n\n') !== -1) {
return i;
}
return prev;
}, -1);
if (doubleBreak !== -1) {
// This merges request bodies together, so you can specify multiple bodies
// in the request and it will merge them together.
// Turns out we never actually do this, so it might be worth removing as it complicates
// matters slightly as we assumed post bodies would be an object.
// This actually works as it just uses the value of the first object, if its an array
// it concats
body = values.splice(doubleBreak).reduce(function (prev, item, i) {
switch (true) {
case Array.isArray(item):
if (i === 0) {
prev = [];
}
return prev.concat(item);
case typeof item !== 'string':
return {
...prev,
...item,
};
default:
return item;
}
}, body);
}
return [body, ...values];
};
const CLIENT_HEADERS = [CACHE_CONTROL, 'X-Request-ID', 'X-Range', 'Refresh'];
export default class HttpService extends Service {
@service('dom') dom;
@service('env') env;
@service('client/connections') connections;
@service('client/transports/xhr') transport;
@service('settings') settings;
@service('encoder') encoder;
@service('store') store;
init() {
super.init(...arguments);
this._listeners = this.dom.listeners();
this.parseURL = createURL(encodeURIComponent, (obj) =>
QueryParams.stringify(this.sanitize(obj))
);
const uriTag = this.encoder.uriTag();
this.cache = (data, id) => {
// interpolate the URI
data.uri = id(uriTag);
// save the time we received it for cache management purposes
data.SyncTime = new Date().getTime();
// save the data to the cache
return this.store.push({
data: {
id: data.uri,
// the model is encoded as the protocol in the URI
type: new URL(data.uri).protocol.slice(0, -1),
attributes: data,
},
});
};
}
sanitize(obj) {
if (!this.env.var('CONSUL_NSPACES_ENABLED')) {
delete obj.ns;
} else {
if (typeof obj.ns === 'undefined' || obj.ns === null || obj.ns === '') {
delete obj.ns;
}
}
if (!this.env.var('CONSUL_PARTITIONS_ENABLED')) {
delete obj.partition;
} else {
if (typeof obj.partition === 'undefined' || obj.partition === null || obj.partition === '') {
delete obj.partition;
}
}
return obj;
}
willDestroy() {
this._listeners.remove();
super.willDestroy(...arguments);
}
url() {
return this.parseURL(...arguments);
}
body() {
const res = parseBody(...arguments);
this.sanitize(res[0]);
return res;
}
requestParams(strs, ...values) {
// first go to the end and remove/parse the http body
const [body, ...urlVars] = this.body(...arguments);
// with whats left get the method off the front
const [method, ...urlParts] = this.url(strs, ...urlVars).split(' ');
// with whats left use the rest of the line for the url
// with whats left after the line, use for the headers
const [url, ...headerParts] = urlParts.join(' ').split('\n');
const params = {
url: url.trim(),
method: method.trim(),
headers: {
[CONTENT_TYPE]: 'application/json; charset=utf-8',
...parseHeaders(headerParts),
},
body: null,
data: body,
};
// Remove and save things that shouldn't be sent in the request
params.clientHeaders = CLIENT_HEADERS.reduce(function (prev, item) {
if (typeof params.headers[item] !== 'undefined') {
prev[item.toLowerCase()] = params.headers[item];
delete params.headers[item];
}
return prev;
}, {});
if (typeof body !== 'undefined') {
// Only read add HTTP body if we aren't GET
// Right now we do this to avoid having to put data in the templates
// for write-like actions
// potentially we should change things so you _have_ to do that
// as doing it this way is a little magical
if (params.method !== 'GET') {
if (params.headers[CONTENT_TYPE].indexOf('json') !== -1) {
params.body = JSON.stringify(params.data);
} else {
if (
(typeof params.data === 'string' && params.data.length > 0) ||
Object.keys(params.data).length > 0
) {
params.body = params.data;
}
}
} else {
const str = QueryParams.stringify(params.data);
if (str.length > 0) {
if (params.url.indexOf('?') !== -1) {
params.url = `${params.url}&${str}`;
} else {
params.url = `${params.url}?${str}`;
}
}
}
}
// temporarily reset the headers/content-type so it works the same
// as previously, should be able to remove this once the data layer
// rewrite is over and we can assert sending via form-encoded is fine
// also see adapters/kv content-types in requestForCreate/UpdateRecord
// also see https://github.com/hashicorp/consul/issues/3804
params.headers[CONTENT_TYPE] = 'application/json; charset=utf-8';
params.url = `${this.env.var('CONSUL_API_PREFIX')}${params.url}`;
return params;
}
fetchWithToken(path, params) {
return this.settings.findBySlug('token').then((token) => {
return fetch(`${this.env.var('CONSUL_API_PREFIX')}${path}`, {
...params,
credentials: 'include',
headers: {
'X-Consul-Token': typeof token.SecretID === 'undefined' ? '' : token.SecretID,
...params.headers,
},
});
});
}
request(cb) {
const client = this;
const cache = this.cache;
return cb(function (strs, ...values) {
const params = client.requestParams(...arguments);
return client.settings.findBySlug('token').then((token) => {
const options = {
...params,
headers: {
[CONSUL_TOKEN]: typeof token.SecretID === 'undefined' ? '' : token.SecretID,
...params.headers,
},
};
const request = client.transport.request(options);
return new Promise((resolve, reject) => {
const remove = client._listeners.add(request, {
open: (e) => {
client.acquire(e.target);
},
message: (e) => {
const headers = {
...Object.entries(e.data.headers).reduce(function (prev, [key, value], i) {
if (!CLIENT_HEADERS.includes(key)) {
prev[key] = value;
}
return prev;
}, {}),
...params.clientHeaders,
// Add a 'pretend' Datacenter/Nspace/Partition header, they are
// not headers the come from the request but we add them here so
// we can use them later for store reconciliation. Namespace
// will look at the ns query parameter first, followed by the
// Namespace property of the users token, defaulting back to
// 'default' which will mainly be used in CE
[CONSUL_DATACENTER]: params.data.dc,
[CONSUL_NAMESPACE]: params.data.ns || token.Namespace || 'default',
[CONSUL_PARTITION]: params.data.partition || token.Partition || 'default',
};
const respond = function (cb) {
let res = cb(headers, e.data.response, cache);
const meta = res.meta || {};
if (meta.version === 2) {
if (Array.isArray(res.body)) {
res = new Proxy(res.body, {
get: (target, prop) => {
switch (prop) {
case 'meta':
return meta;
}
return target[prop];
},
});
} else {
res = res.body;
res.meta = meta;
}
}
return res;
};
next(() => resolve(respond));
},
error: (e) => {
next(() => reject(e.error));
},
close: (e) => {
client.release(e.target);
remove();
},
});
request.fetch();
});
});
});
}
whenAvailable(e) {
return this.connections.whenAvailable(e);
}
abort() {
return this.connections.purge(...arguments);
}
acquire() {
return this.connections.acquire(...arguments);
}
release() {
return this.connections.release(...arguments);
}
}