John Cowen b16a6fa033
ui: Adds Partitions to the HTTP layer (#10447)
This PR mainly adds partition to our HTTP adapter. Additionally and perhaps most importantly, we've also taken the opportunity to move our 'conditional namespaces' deeper into the app.

The reason for doing this was, we like that namespaces should be thought of as required instead of conditional, 'special' things and would like the same thinking to be applied to partitions.

Now, instead of using code throughout the app throughout the adapters to add/remove namespaces or partitions depending on whether they are enabled or not. As a UI engineer you just pretend that namespaces and partitions are always enabled, and we remove them for you deeper in the app, out of the way of you forgetting to treat these properties as a special case.

Notes:

Added a PartitionAbility while we were there (not used as yet)
Started to remove the CONSTANT variables we had just for property names. I prefer that our adapters are as readable and straightforwards as possible, it just looks like HTTP.
We'll probably remove our formatDatacenter method we use also at some point, it was mainly too make it look the same as our previous formatNspace, but now we don't have that, it instead now looks different!
We enable parsing of partition in the UIs URL, but this is feature flagged so still does nothing just yet.
All of the test changes were related to the fact that we were treating client.url as a function rather than a method, and now that we reference this in client.url (etc) it needs binding to client.
2021-09-15 18:09:55 +01:00

272 lines
8.8 KiB
JavaScript

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_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;
init() {
super.init(...arguments);
this._listeners = this.dom.listeners();
this.parseURL = createURL(encodeURIComponent, obj => QueryParams.stringify(this.sanitize(obj)));
}
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';
return params;
}
fetchWithToken(path, params) {
return this.settings.findBySlug('token').then(token => {
return fetch(`${path}`, {
...params,
headers: {
'X-Consul-Token': typeof token.SecretID === 'undefined' ? '' : token.SecretID,
...params.headers,
},
});
});
}
request(cb) {
const client = this;
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 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
// OSS
[CONSUL_DATACENTER]: params.data.dc,
[CONSUL_NAMESPACE]: params.data.ns || token.Namespace || 'default',
};
const respond = function(cb) {
return cb(headers, e.data.response);
};
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);
}
}