From 887e2febf89e8190fa6bb8229c835322a7e7520e Mon Sep 17 00:00:00 2001 From: Matthew Irish Date: Fri, 28 Jun 2019 16:07:45 -0500 Subject: [PATCH] UI - dynamic related capabilities (#7013) * lay groundwork for application serializer to setup capabilities relationships * add api path util and tests, and attach-capabilites fn * make attach-capabilities work with array responses, add tests --- ui/app/adapters/kmip/base.js | 7 +- ui/app/lib/attach-capabilities.js | 82 +++++++++ ui/app/models/kmip/role.js | 8 +- ui/app/serializers/application.js | 15 +- ui/app/utils/api-path.js | 24 +++ ui/tests/unit/lib/attach-capabilities-test.js | 161 ++++++++++++++++++ ui/tests/unit/utils/api-path-test.js | 23 +++ 7 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 ui/app/lib/attach-capabilities.js create mode 100644 ui/app/utils/api-path.js create mode 100644 ui/tests/unit/lib/attach-capabilities-test.js create mode 100644 ui/tests/unit/utils/api-path-test.js diff --git a/ui/app/adapters/kmip/base.js b/ui/app/adapters/kmip/base.js index 174249e53..5815933da 100644 --- a/ui/app/adapters/kmip/base.js +++ b/ui/app/adapters/kmip/base.js @@ -38,7 +38,12 @@ export default ApplicationAdapter.extend({ }, query(store, type, query) { - return this.ajax(this.urlForQuery(query, type.modelName), 'GET'); + return this.ajax(this.urlForQuery(query, type.modelName), 'GET').then(resp => { + // remove pagination query items here + const { size, page, responsePath, pageFilter, ...modelAttrs } = query; + resp._requestQuery = modelAttrs; + return resp; + }); }, queryRecord(store, type, query) { diff --git a/ui/app/lib/attach-capabilities.js b/ui/app/lib/attach-capabilities.js new file mode 100644 index 000000000..4a32a66fa --- /dev/null +++ b/ui/app/lib/attach-capabilities.js @@ -0,0 +1,82 @@ +import DS from 'ember-data'; +import { assert, debug } from '@ember/debug'; +import { typeOf } from '@ember/utils'; +import { isArray } from '@ember/array'; +const { belongsTo } = DS; + +/* + * + * attachCapabilities + * + * @param modelClass = An Ember Data model class + * @param capabilities - an Object whose keys will added to the model class as related 'capabilities' models + * and whose values should be functions that return the id of the related capabilites model + * + * definition of capabilities be done shorthand with the apiPath tagged template funtion + * + * + * @usage + * + * let Model = DS.Model.extend({ + * backend: attr(), + * scope: attr(), + * }); + * + * export default attachCapabilities(Model, { + * updatePath: apiPath`${'backend'}/scope/${'scope'}/role/${'id'}`, + * }); + * + */ +export default function attachCapabilities(modelClass, capabilities) { + let capabilityKeys = Object.keys(capabilities); + let newRelationships = capabilityKeys.reduce((ret, key) => { + ret[key] = belongsTo('capabilities'); + return ret; + }, {}); + + debug(`adding new relationships: ${capabilityKeys.join(', ')} to ${modelClass.toString()}`); + modelClass.reopen(newRelationships); + modelClass.reopenClass({ + // relatedCapabilities is called in the application serializer's + // normalizeResponse hook to add the capabilities relationships to the + // JSON-API document used by Ember Data + relatedCapabilities(jsonAPIDoc) { + let { data, included } = jsonAPIDoc; + if (!data) { + data = jsonAPIDoc; + } + if (isArray(data)) { + let newData = data.map(this.relatedCapabilities); + return { + data: newData, + included, + }; + } + let context = { + id: data.id, + ...data.attributes, + }; + for (let newCapability of capabilityKeys) { + let templateFn = capabilities[newCapability]; + let type = typeOf(templateFn); + assert(`expected value of ${newCapability} to be a function but found ${type}.`, type === 'function'); + data.relationships[newCapability] = { + data: { + type: 'capabilities', + id: templateFn(context), + }, + }; + } + + if (included) { + return { + data, + included, + }; + } else { + return data; + } + }, + }); + return modelClass; +} diff --git a/ui/app/models/kmip/role.js b/ui/app/models/kmip/role.js index ee74124b4..6190a3675 100644 --- a/ui/app/models/kmip/role.js +++ b/ui/app/models/kmip/role.js @@ -2,9 +2,11 @@ import DS from 'ember-data'; import { computed } from '@ember/object'; import { expandAttributeMeta } from 'vault/utils/field-to-attrs'; import fieldToAttrs from 'vault/utils/field-to-attrs'; +import apiPath from 'vault/utils/api-path'; +import attachCapabilities from 'vault/lib/attach-capabilities'; const { attr } = DS; -export default DS.Model.extend({ +const Model = DS.Model.extend({ useOpenAPI: true, backend: attr({ readOnly: true }), scope: attr({ readOnly: true }), @@ -24,3 +26,7 @@ export default DS.Model.extend({ return expandAttributeMeta(this, fields); }), }); + +export default attachCapabilities(Model, { + updatePath: apiPath`${'backend'}/scope/${'scope'}/role/${'id'}`, +}); diff --git a/ui/app/serializers/application.js b/ui/app/serializers/application.js index d19241e28..eabd72627 100644 --- a/ui/app/serializers/application.js +++ b/ui/app/serializers/application.js @@ -15,7 +15,14 @@ export default DS.JSONSerializer.extend({ return key; } let pk = this.get('primaryKey') || 'id'; - return { [pk]: key }; + let model = { [pk]: key }; + // if we've added a in the adapter, we want + // attach it to the individual models + if (payload._requestQuery) { + model = { ...model, ...payload._requestQuery }; + delete payload._requestQuery; + } + return model; }); return models; } @@ -40,7 +47,11 @@ export default DS.JSONSerializer.extend({ if (id && !responseJSON.id) { responseJSON.id = id; } - return this._super(store, primaryModelClass, responseJSON, id, requestType); + let jsonAPIRepresentation = this._super(store, primaryModelClass, responseJSON, id, requestType); + if (primaryModelClass.relatedCapabilities) { + jsonAPIRepresentation = primaryModelClass.relatedCapabilities(jsonAPIRepresentation); + } + return jsonAPIRepresentation; }, serializeAttribute(snapshot, json, key, attributes) { diff --git a/ui/app/utils/api-path.js b/ui/app/utils/api-path.js new file mode 100644 index 000000000..a5f845ec0 --- /dev/null +++ b/ui/app/utils/api-path.js @@ -0,0 +1,24 @@ +import { assert } from '@ember/debug'; + +// This is a tagged template function that will +// replace placeholders in the form of 'id' with the value from the passed context +// +// usage: +// let fn = apiPath`foo/bar/${'id'}`; +// let output = fn({id: 'an-id'}); +// output will result in 'foo/bar/an-id'; + +export default function apiPath(strings, ...keys) { + return function(data) { + let dict = data || {}; + let result = [strings[0]]; + assert( + `Expected ${keys.length} keys in apiPath context, only recieved ${Object.keys(data).length}`, + keys.length === Object.keys(data).length + ); + keys.forEach((key, i) => { + result.push(dict[key], strings[i + 1]); + }); + return result.join(''); + }; +} diff --git a/ui/tests/unit/lib/attach-capabilities-test.js b/ui/tests/unit/lib/attach-capabilities-test.js new file mode 100644 index 000000000..2b8778819 --- /dev/null +++ b/ui/tests/unit/lib/attach-capabilities-test.js @@ -0,0 +1,161 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import attachCapabilities from 'vault/lib/attach-capabilities'; +import apiPath from 'vault/utils/api-path'; +import { get } from '@ember/object'; + +let MODEL_TYPE = 'test-form-model'; + +module('Unit | lib | attach capabilities', function(hooks) { + setupTest(hooks); + + test('it attaches passed capabilities', function(assert) { + let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE); + mc = attachCapabilities(mc, { + updatePath: apiPath`update/{'id'}`, + deletePath: apiPath`delete/{'id'}`, + }); + let relationship = get(mc, 'relationshipsByName').get('updatePath'); + + assert.equal(relationship.key, 'updatePath', 'has updatePath relationship'); + assert.equal(relationship.kind, 'belongsTo', 'kind of relationship is belongsTo'); + assert.equal(relationship.type, 'capabilities', 'updatePath is a related capabilities model'); + + relationship = get(mc, 'relationshipsByName').get('deletePath'); + assert.equal(relationship.key, 'deletePath', 'has deletePath relationship'); + assert.equal(relationship.kind, 'belongsTo', 'kind of relationship is belongsTo'); + assert.equal(relationship.type, 'capabilities', 'deletePath is a related capabilities model'); + }); + + test('it adds a static method to the model class', function(assert) { + let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE); + mc = attachCapabilities(mc, { + updatePath: apiPath`update/{'id'}`, + deletePath: apiPath`delete/{'id'}`, + }); + assert.ok( + !!mc.relatedCapabilities && typeof mc.relatedCapabilities === 'function', + 'model class now has a relatedCapabilities static function' + ); + }); + + test('calling static method with single response JSON-API document adds expected relationships', function(assert) { + let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE); + mc = attachCapabilities(mc, { + updatePath: apiPath`update/${'id'}`, + deletePath: apiPath`delete/${'id'}`, + }); + let jsonAPIDocSingle = { + data: { + id: 'test', + type: MODEL_TYPE, + attributes: {}, + relationships: {}, + }, + included: [], + }; + + let expected = { + data: { + id: 'test', + type: MODEL_TYPE, + attributes: {}, + relationships: { + updatePath: { + data: { + type: 'capabilities', + id: 'update/test', + }, + }, + deletePath: { + data: { + type: 'capabilities', + id: 'delete/test', + }, + }, + }, + }, + included: [], + }; + + mc.relatedCapabilities(jsonAPIDocSingle); + + assert.equal( + Object.keys(jsonAPIDocSingle.data.relationships).length, + 2, + 'document now has 2 relationships' + ); + assert.deepEqual(jsonAPIDocSingle, expected, 'has the exected new document structure'); + }); + + test('calling static method with an arrary response JSON-API document adds expected relationships', function(assert) { + let mc = this.owner.lookup('service:store').modelFor(MODEL_TYPE); + mc = attachCapabilities(mc, { + updatePath: apiPath`update/${'id'}`, + deletePath: apiPath`delete/${'id'}`, + }); + let jsonAPIDocSingle = { + data: [ + { + id: 'test', + type: MODEL_TYPE, + attributes: {}, + relationships: {}, + }, + { + id: 'foo', + type: MODEL_TYPE, + attributes: {}, + relationships: {}, + }, + ], + included: [], + }; + + let expected = { + data: [ + { + id: 'test', + type: MODEL_TYPE, + attributes: {}, + relationships: { + updatePath: { + data: { + type: 'capabilities', + id: 'update/test', + }, + }, + deletePath: { + data: { + type: 'capabilities', + id: 'delete/test', + }, + }, + }, + }, + { + id: 'foo', + type: MODEL_TYPE, + attributes: {}, + relationships: { + updatePath: { + data: { + type: 'capabilities', + id: 'update/foo', + }, + }, + deletePath: { + data: { + type: 'capabilities', + id: 'delete/foo', + }, + }, + }, + }, + ], + included: [], + }; + mc.relatedCapabilities(jsonAPIDocSingle); + assert.deepEqual(jsonAPIDocSingle, expected, 'has the exected new document structure'); + }); +}); diff --git a/ui/tests/unit/utils/api-path-test.js b/ui/tests/unit/utils/api-path-test.js new file mode 100644 index 000000000..c64673111 --- /dev/null +++ b/ui/tests/unit/utils/api-path-test.js @@ -0,0 +1,23 @@ +import apiPath from 'vault/utils/api-path'; +import { module, test } from 'qunit'; + +module('Unit | Util | api path', function() { + test('it returns a function', function(assert) { + let ret = apiPath`foo`; + assert.ok(typeof ret === 'function'); + }); + + test('it iterpolates strings from passed context object', function(assert) { + let ret = apiPath`foo/${'one'}/${'two'}`; + let result = ret({ one: 1, two: 2 }); + + assert.equal(result, 'foo/1/2', 'returns the expected string'); + }); + + test('it throws when the key is not found in the context', function(assert) { + let ret = apiPath`foo/${'one'}/${'two'}`; + assert.throws(() => { + ret({ one: 1 }); + }, /Error: Assertion Failed: Expected 2 keys in apiPath context, only recieved 1/); + }); +});