open-consul/ui/packages/consul-ui/app/services/repository.js
John Cowen fbbdc4d352
ui: DataSource Decorator (#9746)
We use a `<DataSource @src={{url}} />` component throughout our UI for when we want to load data from within our components. The URL specified as the `@src` is used to map/lookup what is used in to retrieve data, for example we mostly use our repository methods wrapped with our Promise backed `EventSource` implementation, but DataSource URLs can also be mapped to EventTarget backed `EventSource`s and native `EventSource`s or `WebSockets` if we ever need to use those (for example these are options for potential streaming support with the Consul backend).

The URL to function/method mapping previous to this PR used a very naive humongous `switch` statement which was a temporary 'this is fine for the moment' solution, although we'd always wanted to replace with something more manageable.

Here we add `wayfarer` as a dependency - a very small (1kb), very fast, radix trie based router, and use that to perform the URL to function/method mapping.

This essentially turns every `DataSource` into a very small SPA - change its URL and the view of data changes. When the data itself changes, either the yielded view of data changes or the `onchange` event is fired with the changed data, making the externally sourced view of data completely reactive.

```javascript
// use the new decorator a service somewhere to annotate/decorate
// a method with the URL that can be used to access this method
@dataSource('/:ns/:dc/services')
async findAllByDatacenter(params) {
  // get the data
}

// can use with JS in a route somewhere
async model() {
  return this.data.source(uri => uri`/${nspace}/${dc}/services`)
}
```

```hbs
{{!-- or just straight in a template using the component --}}
<DataSource @src="/default/dc1/services" @onchange="" />
```

This also uses a new `container` Service to automatically execute/import certain services yet not execute them. This new service also provides a lookup that supports both standard ember DI lookup plus Class based lookup or these specific services. Lastly we also provide another debug function called DataSourceRoutes() which can be called from console which gives you a list of URLs and their mappings.
2021-02-23 08:56:42 +00:00

158 lines
4.9 KiB
JavaScript

import Service, { inject as service } from '@ember/service';
import { assert } from '@ember/debug';
import { typeOf } from '@ember/utils';
import { get, set } from '@ember/object';
import { isChangeset } from 'validated-changeset';
import HTTPError from 'consul-ui/utils/http/error';
import { ACCESS_READ } from 'consul-ui/abilities/base';
export default class RepositoryService extends Service {
@service('store') store;
@service('repository/permission') permissions;
getModelName() {
assert('RepositoryService.getModelName should be overridden', false);
}
getPrimaryKey() {
assert('RepositoryService.getPrimaryKey should be overridden', false);
}
getSlugKey() {
assert('RepositoryService.getSlugKey should be overridden', false);
}
/**
* Creates a set of permissions based on an id/slug, loads in the access
* permissions for them and checks/validates
*/
async authorizeBySlug(cb, access, params) {
params.resources = await this.permissions.findBySlug(params, this.getModelName());
return this.validatePermissions(cb, access, params);
}
/**
* Loads in the access permissions and checks/validates them for a set of
* permissions
*/
async authorizeByPermissions(cb, access, params) {
params.resources = await this.permissions.authorize(params);
return this.validatePermissions(cb, access, params);
}
/**
* Checks already loaded permissions for certain access before calling cb to
* return the thing you wanted to check the permissions on
*/
async validatePermissions(cb, access, params) {
// inspect the permissions for this segment/slug remotely, if we have zero
// permissions fire a fake 403 so we don't even request the model/resource
if (params.resources.length > 0) {
const resource = params.resources.find(item => item.Access === access);
if (resource && resource.Allow === false) {
// TODO: Here we temporarily make a hybrid HTTPError/ember-data HTTP error
// we should eventually use HTTPError's everywhere
const e = new HTTPError(403);
e.errors = [{ status: '403' }];
throw e;
}
}
const item = await cb();
// add the `Resource` information to the record/model so we can inspect
// them in other places like templates etc
if (get(item, 'Resources')) {
set(item, 'Resources', params.resources);
}
return item;
}
reconcile(meta = {}) {
// unload anything older than our current sync date/time
if (typeof meta.date !== 'undefined') {
const checkNspace = meta.nspace !== '';
this.store.peekAll(this.getModelName()).forEach(item => {
const dc = get(item, 'Datacenter');
if (dc === meta.dc) {
if (checkNspace) {
const nspace = get(item, 'Namespace');
if (typeof nspace !== 'undefined' && nspace !== meta.nspace) {
return;
}
}
const date = get(item, 'SyncTime');
if (!item.isDeleted && typeof date !== 'undefined' && date != meta.date) {
this.store.unloadRecord(item);
}
}
});
}
}
peekOne(id) {
return this.store.peekRecord(this.getModelName(), id);
}
findAllByDatacenter(params, configuration = {}) {
if (typeof configuration.cursor !== 'undefined') {
params.index = configuration.cursor;
params.uri = configuration.uri;
}
return this.store.query(this.getModelName(), params);
}
async findBySlug(params, configuration = {}) {
if (params.id === '') {
return this.create({
Datacenter: params.dc,
Namespace: params.ns,
});
}
if (typeof configuration.cursor !== 'undefined') {
params.index = configuration.cursor;
params.uri = configuration.uri;
}
return this.authorizeBySlug(
() => this.store.queryRecord(this.getModelName(), params),
ACCESS_READ,
params
);
}
create(obj) {
// TODO: This should probably return a Promise
return this.store.createRecord(this.getModelName(), obj);
}
persist(item) {
// workaround for saving changesets that contain fragments
// firstly commit the changes down onto the object if
// its a changeset, then save as a normal object
if (isChangeset(item)) {
item.execute();
item = item.data;
}
return item.save();
}
remove(obj) {
let item = obj;
if (typeof obj.destroyRecord === 'undefined') {
item = obj.get('data');
}
// TODO: Change this to use vanilla JS
// I think this was originally looking for a plain object
// as opposed to an ember one
if (typeOf(item) === 'object') {
item = this.store.peekRecord(this.getModelName(), item[this.getPrimaryKey()]);
}
return item.destroyRecord().then(item => {
return this.store.unloadRecord(item);
});
}
invalidate() {
// TODO: This should probably return a Promise
this.store.unloadAll(this.getModelName());
}
}