mirror of https://github.com/hashicorp/consul
318 lines
10 KiB
JavaScript
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);
|
|
}
|
|
}
|