Merge pull request #44 from cult-of-coders/feature/41-exposure-body

[RFC] Added exposure to body #41
This commit is contained in:
Theodor Diaconu 2016-10-19 17:27:30 +03:00 committed by GitHub
commit ce3fe7e3c0
20 changed files with 381 additions and 53 deletions

View file

@ -1,3 +1,6 @@
## 1.1.12
- Added body to exposure that will intersect with the actual request
## 1.1.11 ## 1.1.11
- Written rigurous unit tests for deep cloning - Written rigurous unit tests for deep cloning
- Auto-adding $metadata field when coming from an inversed link. - Auto-adding $metadata field when coming from an inversed link.

View file

@ -41,7 +41,12 @@ function extractCollectionDocumentation() {
} }
DocumentationObject[name] = {}; DocumentationObject[name] = {};
DocumentationObject[name]['isExposed'] = !!instance.__isExposedForGrapher; var isExposed = !!instance.__isExposedForGrapher;
DocumentationObject[name]['isExposed'] = isExposed;
if (isExposed && instance.__exposure.config.body) {
DocumentationObject[name]['exposureBody'] = deepClone(instance.__exposure.config.body);
}
extractSchema(DocumentationObject[name], instance); extractSchema(DocumentationObject[name], instance);
extractLinks(DocumentationObject[name], instance); extractLinks(DocumentationObject[name], instance);

View file

@ -1,6 +1,7 @@
import { SimpleSchema } from 'meteor/aldeed:simple-schema'; import { SimpleSchema } from 'meteor/aldeed:simple-schema';
import createGraph from '../query/lib/createGraph.js';
export default new SimpleSchema({ let Schema = new SimpleSchema({
firewall: { firewall: {
type: Function, type: Function,
optional: true optional: true
@ -36,5 +37,23 @@ export default new SimpleSchema({
method: { method: {
type: Boolean, type: Boolean,
defaultValue: true defaultValue: true
},
body: {
type: null,
blackbox: true,
optional: true
}
});
export default Schema;
_.extend(Schema, {
validateBody(collection, body) {
try {
createGraph(collection, body);
} catch (e) {
throw new Meteor.Error('invalid-body', 'We could not build a valid graph when trying to create your exposure: ' + e.toString())
}
} }
}) })

View file

@ -4,6 +4,8 @@ import hypernova from '../query/hypernova/hypernova.js';
import ExposureConfigSchema from './exposure.config.schema.js'; import ExposureConfigSchema from './exposure.config.schema.js';
import enforceMaxDepth from './lib/enforceMaxDepth.js'; import enforceMaxDepth from './lib/enforceMaxDepth.js';
import enforceMaxLimit from './lib/enforceMaxLimit.js'; import enforceMaxLimit from './lib/enforceMaxLimit.js';
import intersectDeep from './lib/intersectDeep.js';
import deepClone from '../query/lib/deepClone';
import restrictFieldsFn from './lib/restrictFields.js'; import restrictFieldsFn from './lib/restrictFields.js';
import restrictLinks from './lib/restrictLinks.js'; import restrictLinks from './lib/restrictLinks.js';
@ -61,44 +63,87 @@ export default class Exposure {
ExposureConfigSchema.clean(this.config); ExposureConfigSchema.clean(this.config);
ExposureConfigSchema.validate(this.config); ExposureConfigSchema.validate(this.config);
if (this.config.body) {
ExposureConfigSchema.validateBody(this.collection, this.config.body);
}
this.config = _.extend({}, Exposure.getConfig(), this.config); this.config = _.extend({}, Exposure.getConfig(), this.config);
} }
/**
* Takes the body and intersects it with the exposure body, if it exists.
*
* @param body
* @param userId
* @returns {*}
*/
getTransformedBody(body, userId) {
if (!this.config.body) {
return body;
}
return intersectDeep(this.getBody(userId), body);
}
/**
* Gets the exposure body
*/
getBody(userId) {
if (!this.config.body) {
throw new Meteor.Error('Cannot get exposure body because it was not defined.');
}
if (_.isFunction(this.config.body)) {
return deepClone(
this.config.body.call(this, userId)
);
} else {
return deepClone(this.config.body);
}
}
initPublication() { initPublication() {
const collection = this.collection; const collection = this.collection;
const config = this.config; const config = this.config;
const getTransformedBody = this.getTransformedBody.bind(this);
Meteor.publishComposite(this.name, function (body) { Meteor.publishComposite(this.name, function (body) {
const rootNode = createGraph(collection, body); let transformedBody = getTransformedBody(body);
const rootNode = createGraph(collection, transformedBody);
enforceMaxDepth(rootNode, config.maxDepth); enforceMaxDepth(rootNode, config.maxDepth);
restrictLinks(rootNode, this.userId); restrictLinks(rootNode, this.userId);
return recursiveCompose(rootNode, this.userId); return recursiveCompose(rootNode, this.userId, {
bypassFirewalls: !!config.body
});
}); });
} }
initMethod() { initMethod() {
const collection = this.collection; const collection = this.collection;
const config = this.config; const config = this.config;
const getTransformedBody = this.getTransformedBody.bind(this);
Meteor.methods({ Meteor.methods({
[this.name](body) { [this.name](body) {
this.unblock(); this.unblock();
let transformedBody = getTransformedBody(body);
const rootNode = createGraph(collection, body); const rootNode = createGraph(collection, transformedBody);
enforceMaxDepth(rootNode, config.maxDepth); enforceMaxDepth(rootNode, config.maxDepth);
restrictLinks(rootNode, this.userId); restrictLinks(rootNode, this.userId);
return hypernova(rootNode, this.userId); return hypernova(rootNode, this.userId, {
bypassFirewalls: !!config.body
});
} }
}); });
} }
initCountMethod() { initCountMethod() {
const collection = this.collection; const collection = this.collection;
const config = this.config;
Meteor.methods({ Meteor.methods({
[this.name + '.count'](body) { [this.name + '.count'](body) {

View file

@ -0,0 +1,32 @@
import deepClone from '../../query/lib/deepClone';
/**
* Given to objects, it will intersect what they have in common.
*
* It will favor objects on intersection meaning: { item: 1 } INTERSECT { item: { anything } } => { item: { anything } }
*
* @param first Object
* @param second Object
*/
export default function intersectDeep(first, second) {
let object = {};
_.each(first, (value, key) => {
if (second[key] !== undefined) {
if (_.isObject(value)) {
if (_.isObject(second[key])) {
object[key] = intersectDeep(value, second[key]);
} else {
object[key] = deepClone(value);
}
} else {
if (_.isObject(second[key])) {
object[key] = deepClone(second[key]);
} else {
object[key] = value;
}
}
}
});
return object;
}

View file

@ -24,4 +24,3 @@ DemoLink.addLinks({
export const DemoPublication = new Mongo.Collection('DemoPublication'); export const DemoPublication = new Mongo.Collection('DemoPublication');
export const DemoMethod = new Mongo.Collection('DemoPublicationMethod'); export const DemoMethod = new Mongo.Collection('DemoPublicationMethod');

View file

@ -0,0 +1,38 @@
import Demo, {DemoPublication, DemoMethod} from './demo.js';
import Intersect, { CollectionLink as IntersectLink } from './intersect';
import { Exposure } from 'meteor/cultofcoders:grapher';
Demo.expose({
firewall(filters, options, userId) {
Exposure.restrictFields(filters, options, ['restrictedField']);
filters.isPrivate = false;
},
maxLimit: 2,
maxDepth: 2,
restrictLinks(userId) {
return ['restrictedLink'];
}
});
DemoMethod.expose({
publication: false
});
DemoPublication.expose({
method: false
});
Intersect.expose({
body: {
value: 1,
link: {
value: 1
}
}
});
IntersectLink.expose({
firewall() {
throw new Meteor.Error('I do not allow!')
}
});

View file

@ -5,23 +5,17 @@ Exposure.setConfig({
}); });
import Demo, {DemoPublication, DemoMethod, DemoRestrictedLink} from './demo.js'; import Demo, {DemoPublication, DemoMethod, DemoRestrictedLink} from './demo.js';
import Intersect, { CollectionLink as IntersectLink } from './intersect';
Demo.remove({}); Demo.remove({});
DemoRestrictedLink.remove({});
Demo.insert({ Intersect.remove({});
isPrivate: true, IntersectLink.remove({});
restrictedField: 'PRIVATE'
});
Demo.insert({ Demo.insert({isPrivate: true, restrictedField: 'PRIVATE'});
isPrivate: false, Demo.insert({isPrivate: false, restrictedField: 'PRIVATE'});
restrictedField: 'PRIVATE' Demo.insert({isPrivate: false, restrictedField: 'PRIVATE'});
});
Demo.insert({
isPrivate: false,
restrictedField: 'PRIVATE'
});
const restrictedDemoId = Demo.insert({ const restrictedDemoId = Demo.insert({
isPrivate: false, isPrivate: false,
@ -32,20 +26,18 @@ Demo.getLink(restrictedDemoId, 'restrictedLink').set({
test: true test: true
}); });
Demo.expose({ // INTERSECTION TEST LINKS
firewall(filters, options, userId) {
Exposure.restrictFields(filters, options, ['restrictedField']); const intersectId = Intersect.insert({
filters.isPrivate = false; value: 'Hello',
}, privateValue: 'Bad!'
maxLimit: 2,
maxDepth: 2,
restrictLinks(userId) {
return ['restrictedLink'];
}
}); });
DemoMethod.expose({
publication: false const intersectLinkId = IntersectLink.insert({
}); value: 'Hello, I am a Link',
DemoPublication.expose({ privateValue: 'Bad!'
method: false
}); });
Intersect.getLink(intersectId, 'link').set(intersectLinkId);
Intersect.getLink(intersectId, 'privateLink').set(intersectLinkId);
IntersectLink.getLink(intersectLinkId, 'myself').set(intersectLinkId);

View file

@ -0,0 +1,22 @@
const Collection = new Mongo.Collection('exposure_intersect');
export default Collection;
export const CollectionLink = new Mongo.Collection('exposure_intersect_link');
Collection.addLinks({
link: {
collection: CollectionLink,
type: 'one'
},
privateLink: {
collection: CollectionLink,
type: 'one'
}
});
CollectionLink.addLinks({
myself: {
type: 'one',
collection: CollectionLink
}
});

View file

@ -3,7 +3,9 @@ import Demo, {
DemoPublication DemoPublication
} from './bootstrap/demo.js'; } from './bootstrap/demo.js';
describe('Exposure', function () { import Intersect, { CollectionLink as IntersectLink } from './bootstrap/intersect';
describe('Exposure Tests', function () {
it('Should fetch only allowed data and limitations should be applied', function (done) { it('Should fetch only allowed data and limitations should be applied', function (done) {
const query = Demo.createQuery({ const query = Demo.createQuery({
$options: {limit: 3}, $options: {limit: 3},
@ -98,7 +100,7 @@ describe('Exposure', function () {
it('Should restrict links # restrictLinks ', function (done) { it('Should restrict links # restrictLinks ', function (done) {
const query = Demo.createQuery({ const query = Demo.createQuery({
_id: 1, _id: 1,
restrictedLink: 1 restrictedLink: {}
}); });
query.fetch((err, res) => { query.fetch((err, res) => {
@ -114,4 +116,82 @@ describe('Exposure', function () {
done(); done();
}); });
}); });
it('Should intersect the body graphs - Method', function (done) {
const query = Intersect.createQuery({
value: 1,
privateValue: 1,
link: {
value: 1,
privateValue: 1,
myself: {
value: 1
}
},
privateLink: {
value: 1,
privateValue: 1
}
});
query.fetch((err, res) => {
assert.isUndefined(err);
assert.lengthOf(res, 1);
const result = _.first(res);
assert.isDefined(result.value);
assert.isUndefined(result.privateValue);
assert.isUndefined(result.privateLink);
assert.isObject(result.link);
assert.isDefined(result.link.value);
assert.isUndefined(result.link.privateValue);
assert.isUndefined(result.link.myself);
done();
});
});
it('Should intersect the body graphs - Subscription', function (done) {
const query = Intersect.createQuery({
value: 1,
privateValue: 1,
link: {
value: 1,
privateValue: 1,
myself: {
value: 1
}
},
privateLink: {
value: 1,
privateValue: 1
}
});
const handle = query.subscribe();
Tracker.autorun((c) => {
if (handle.ready()) {
c.stop();
const res = query.fetch();
assert.lengthOf(res, 1);
const result = _.first(res);
assert.isDefined(result.value);
assert.isUndefined(result.privateValue);
assert.isUndefined(result.privateLink);
assert.isObject(result.link);
assert.isDefined(result.link.value);
assert.isUndefined(result.link.privateValue);
assert.isUndefined(result.link.myself);
done();
}
});
})
}); });

View file

@ -1,2 +1,4 @@
import './bootstrap/fixtures.js'; import './bootstrap/fixtures.js';
import './bootstrap/expose.js';
import './units/units.js'; import './units/units.js';

View file

@ -1,5 +1,6 @@
import restrictFields from '../../lib/restrictFields.js'; import restrictFields from '../../lib/restrictFields.js';
import enforceMaxLimit from '../../lib/enforceMaxLimit.js'; import enforceMaxLimit from '../../lib/enforceMaxLimit.js';
import intersectDeep from '../../lib/intersectDeep.js';
import enforceMaxDepth, {getDepth} from '../../lib/enforceMaxDepth.js'; import enforceMaxDepth, {getDepth} from '../../lib/enforceMaxDepth.js';
import CollectionNode from '../../../query/nodes/collectionNode.js'; import CollectionNode from '../../../query/nodes/collectionNode.js';
@ -154,4 +155,63 @@ describe('Unit Tests', function () {
assert.throws(fn, /graph request is too deep/); assert.throws(fn, /graph request is too deep/);
}); });
it('Should intersect two objects deeply', function () {
const obj1 = {
a: 1,
b: 1,
c: {
c1: 1,
c2: 1
},
d: {
d1: {
d11: 1,
d12: 1,
d13: {
d131: 1
}
}
}
};
const obj2 = {
a: 1,
x: '!',
b: {
b1: 1
},
c: {
c1: 1,
c3: '!'
},
d: {
d2: '!',
d1: {
d11: 1,
d13: 1
}
}
};
Object.freeze(obj1);
Object.freeze(obj2);
const result = intersectDeep(obj1, obj2);
assert.isObject(result);
assert.equal(result.a, 1);
assert.isObject(result.b);
assert.equal(result.b.b1, 1);
assert.isUndefined(result.x);
assert.equal(result.c.c1, 1);
assert.isUndefined(result.c.c2);
assert.isUndefined(result.c.c3);
assert.equal(result.d.d1.d11, 1);
assert.isUndefined(result.d.d1.d12);
assert.isUndefined(result.d.d2);
assert.isObject(result.d.d1.d13);
assert.equal(result.d.d1.d13.d131, 1);
})
}); });

View file

@ -4,6 +4,7 @@ import mergeDeep from './lib/mergeDeep.js';
import createGraph from '../../query/lib/createGraph.js'; import createGraph from '../../query/lib/createGraph.js';
import recursiveCompose from '../../query/lib/recursiveCompose.js'; import recursiveCompose from '../../query/lib/recursiveCompose.js';
import applyFilterFunction from '../../query/lib/applyFilterFunction.js'; import applyFilterFunction from '../../query/lib/applyFilterFunction.js';
import deepClone from '../../query/lib/deepClone.js';
_.extend(NamedQuery.prototype, { _.extend(NamedQuery.prototype, {
expose(config = {}) { expose(config = {}) {
@ -33,7 +34,10 @@ _.extend(NamedQuery.prototype, {
this._initCountMethod(); this._initCountMethod();
if (config.embody) { if (config.embody) {
this.body = mergeDeep(this.body, config.embody); this.body = mergeDeep(
deepClone(this.body),
config.embody
);
} }
this.isExposed = true; this.isExposed = true;

View file

@ -3,7 +3,10 @@ import deepClone from '../query/lib/deepClone.js';
export default class { export default class {
constructor(name, collection, body, params = {}) { constructor(name, collection, body, params = {}) {
this.queryName = name; this.queryName = name;
this.body = body;
this.body = deepClone(body);
Object.freeze(this.body);
this.subscriptionHandle = null; this.subscriptionHandle = null;
this.params = params; this.params = params;
this.collection = collection; this.collection = collection;

View file

@ -3,7 +3,7 @@ import LinkResolve from '../../links/linkTypes/linkResolve.js';
import storeHypernovaResults from './storeHypernovaResults.js'; import storeHypernovaResults from './storeHypernovaResults.js';
import assembler from './assembler.js'; import assembler from './assembler.js';
function hypernova(collectionNode, userId, debug) { function hypernova(collectionNode, userId) {
_.each(collectionNode.collectionNodes, childCollectionNode => { _.each(collectionNode.collectionNodes, childCollectionNode => {
let {filters, options} = applyProps(childCollectionNode); let {filters, options} = applyProps(childCollectionNode);
@ -13,22 +13,22 @@ function hypernova(collectionNode, userId, debug) {
result[childCollectionNode.linkName] = accessor.find(filters, options); result[childCollectionNode.linkName] = accessor.find(filters, options);
}); });
} else { } else {
storeHypernovaResults(childCollectionNode, userId, debug); storeHypernovaResults(childCollectionNode, userId);
hypernova(childCollectionNode, userId, debug); hypernova(childCollectionNode, userId);
} }
}); });
} }
export default function hypernovaInit(collectionNode, userId, debug) { export default function hypernovaInit(collectionNode, userId, config = {bypassFirewalls: false}) {
let {filters, options} = applyProps(collectionNode); let {filters, options} = applyProps(collectionNode);
const collection = collectionNode.collection; const collection = collectionNode.collection;
collectionNode.results = collection.find(filters, options, userId).fetch(); collectionNode.results = collection.find(filters, options, userId).fetch();
hypernova(collectionNode, userId, debug); const userIdToPass = (config.bypassFirewalls) ? undefined : userId;
hypernova(collectionNode, userIdToPass);
return collectionNode.results; return collectionNode.results;
} }

View file

@ -4,7 +4,7 @@ import assemble from './assembler.js';
import assembleAggregateResults from './assembleAggregateResults.js'; import assembleAggregateResults from './assembleAggregateResults.js';
import buildAggregatePipeline from './buildAggregatePipeline.js'; import buildAggregatePipeline from './buildAggregatePipeline.js';
export default function storeHypernovaResults(childCollectionNode, userId, debug) { export default function storeHypernovaResults(childCollectionNode, userId) {
if (childCollectionNode.parent.results.length === 0) { if (childCollectionNode.parent.results.length === 0) {
return childCollectionNode.results = []; return childCollectionNode.results = [];
} }

View file

@ -1,6 +1,6 @@
import applyProps from './applyProps.js'; import applyProps from './applyProps.js';
export default function compose(node, userId) { function compose(node, userId) {
return { return {
find(parent) { find(parent) {
let {filters, options} = applyProps(node); let {filters, options} = applyProps(node);
@ -28,3 +28,19 @@ export default function compose(node, userId) {
children: _.map(node.collectionNodes, n => compose(n, userId)) children: _.map(node.collectionNodes, n => compose(n, userId))
} }
} }
export default (node, userId, config = {bypassFirewalls: false}) => {
return {
find() {
let {filters, options} = applyProps(node);
return node.collection.find(filters, options, userId);
},
children: _.map(node.collectionNodes, n => {
const userIdToPass = (config.bypassFirewalls) ? undefined : userId;
return compose(n, userIdToPass);
})
}
}

View file

@ -1,8 +1,13 @@
import FieldNode from './fieldNode.js'; import FieldNode from './fieldNode.js';
import deepClone from '../lib/deepClone';
export default class CollectionNode { export default class CollectionNode {
constructor(collection, body, linkName) { constructor(collection, body = {}, linkName = null) {
this.body = body; if (collection && !_.isObject(body)) {
throw new Meteor.Error('invalid-body', 'Every collection link should have its body defined as an object.');
}
this.body = deepClone(body);
this.linkName = linkName; this.linkName = linkName;
this.collection = collection; this.collection = collection;

View file

@ -3,7 +3,10 @@ import deepClone from './lib/deepClone.js';
export default class { export default class {
constructor(collection, body, params = {}) { constructor(collection, body, params = {}) {
this.collection = collection; this.collection = collection;
this.body = body;
this.body = deepClone(body);
Object.freeze(this.body);
this._params = params; this._params = params;
} }

View file

@ -1,6 +1,6 @@
Package.describe({ Package.describe({
name: 'cultofcoders:grapher', name: 'cultofcoders:grapher',
version: '1.1.11', version: '1.1.12',
// Brief, one-line summary of the package. // Brief, one-line summary of the package.
summary: 'Grapher makes linking collections easily. And fetching data as a graph.', summary: 'Grapher makes linking collections easily. And fetching data as a graph.',
// URL to the Git repository containing the source code for this package. // URL to the Git repository containing the source code for this package.