mirror of
https://github.com/vale981/grapher
synced 2025-03-06 01:51:38 -05:00
Merge pull request #44 from cult-of-coders/feature/41-exposure-body
[RFC] Added exposure to body #41
This commit is contained in:
commit
ce3fe7e3c0
20 changed files with 381 additions and 53 deletions
|
@ -1,3 +1,6 @@
|
|||
## 1.1.12
|
||||
- Added body to exposure that will intersect with the actual request
|
||||
|
||||
## 1.1.11
|
||||
- Written rigurous unit tests for deep cloning
|
||||
- Auto-adding $metadata field when coming from an inversed link.
|
||||
|
|
|
@ -41,7 +41,12 @@ function extractCollectionDocumentation() {
|
|||
}
|
||||
|
||||
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);
|
||||
extractLinks(DocumentationObject[name], instance);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { SimpleSchema } from 'meteor/aldeed:simple-schema';
|
||||
import createGraph from '../query/lib/createGraph.js';
|
||||
|
||||
export default new SimpleSchema({
|
||||
let Schema = new SimpleSchema({
|
||||
firewall: {
|
||||
type: Function,
|
||||
optional: true
|
||||
|
@ -36,5 +37,23 @@ export default new SimpleSchema({
|
|||
method: {
|
||||
type: Boolean,
|
||||
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())
|
||||
}
|
||||
}
|
||||
})
|
|
@ -4,6 +4,8 @@ import hypernova from '../query/hypernova/hypernova.js';
|
|||
import ExposureConfigSchema from './exposure.config.schema.js';
|
||||
import enforceMaxDepth from './lib/enforceMaxDepth.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 restrictLinks from './lib/restrictLinks.js';
|
||||
|
||||
|
@ -61,44 +63,87 @@ export default class Exposure {
|
|||
ExposureConfigSchema.clean(this.config);
|
||||
ExposureConfigSchema.validate(this.config);
|
||||
|
||||
if (this.config.body) {
|
||||
ExposureConfigSchema.validateBody(this.collection, this.config.body);
|
||||
}
|
||||
|
||||
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() {
|
||||
const collection = this.collection;
|
||||
const config = this.config;
|
||||
const getTransformedBody = this.getTransformedBody.bind(this);
|
||||
|
||||
Meteor.publishComposite(this.name, function (body) {
|
||||
const rootNode = createGraph(collection, body);
|
||||
let transformedBody = getTransformedBody(body);
|
||||
const rootNode = createGraph(collection, transformedBody);
|
||||
|
||||
enforceMaxDepth(rootNode, config.maxDepth);
|
||||
restrictLinks(rootNode, this.userId);
|
||||
|
||||
return recursiveCompose(rootNode, this.userId);
|
||||
return recursiveCompose(rootNode, this.userId, {
|
||||
bypassFirewalls: !!config.body
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
initMethod() {
|
||||
const collection = this.collection;
|
||||
const config = this.config;
|
||||
const getTransformedBody = this.getTransformedBody.bind(this);
|
||||
|
||||
Meteor.methods({
|
||||
[this.name](body) {
|
||||
this.unblock();
|
||||
let transformedBody = getTransformedBody(body);
|
||||
|
||||
const rootNode = createGraph(collection, body);
|
||||
const rootNode = createGraph(collection, transformedBody);
|
||||
enforceMaxDepth(rootNode, config.maxDepth);
|
||||
|
||||
restrictLinks(rootNode, this.userId);
|
||||
|
||||
return hypernova(rootNode, this.userId);
|
||||
return hypernova(rootNode, this.userId, {
|
||||
bypassFirewalls: !!config.body
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initCountMethod() {
|
||||
const collection = this.collection;
|
||||
const config = this.config;
|
||||
|
||||
Meteor.methods({
|
||||
[this.name + '.count'](body) {
|
||||
|
|
32
lib/exposure/lib/intersectDeep.js
Normal file
32
lib/exposure/lib/intersectDeep.js
Normal 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;
|
||||
}
|
|
@ -24,4 +24,3 @@ DemoLink.addLinks({
|
|||
|
||||
export const DemoPublication = new Mongo.Collection('DemoPublication');
|
||||
export const DemoMethod = new Mongo.Collection('DemoPublicationMethod');
|
||||
|
||||
|
|
38
lib/exposure/testing/bootstrap/expose.js
Normal file
38
lib/exposure/testing/bootstrap/expose.js
Normal 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!')
|
||||
}
|
||||
});
|
|
@ -5,23 +5,17 @@ Exposure.setConfig({
|
|||
});
|
||||
|
||||
import Demo, {DemoPublication, DemoMethod, DemoRestrictedLink} from './demo.js';
|
||||
import Intersect, { CollectionLink as IntersectLink } from './intersect';
|
||||
|
||||
Demo.remove({});
|
||||
DemoRestrictedLink.remove({});
|
||||
|
||||
Demo.insert({
|
||||
isPrivate: true,
|
||||
restrictedField: 'PRIVATE'
|
||||
});
|
||||
Intersect.remove({});
|
||||
IntersectLink.remove({});
|
||||
|
||||
Demo.insert({
|
||||
isPrivate: false,
|
||||
restrictedField: 'PRIVATE'
|
||||
});
|
||||
|
||||
Demo.insert({
|
||||
isPrivate: false,
|
||||
restrictedField: 'PRIVATE'
|
||||
});
|
||||
Demo.insert({isPrivate: true, restrictedField: 'PRIVATE'});
|
||||
Demo.insert({isPrivate: false, restrictedField: 'PRIVATE'});
|
||||
Demo.insert({isPrivate: false, restrictedField: 'PRIVATE'});
|
||||
|
||||
const restrictedDemoId = Demo.insert({
|
||||
isPrivate: false,
|
||||
|
@ -32,20 +26,18 @@ Demo.getLink(restrictedDemoId, 'restrictedLink').set({
|
|||
test: true
|
||||
});
|
||||
|
||||
Demo.expose({
|
||||
firewall(filters, options, userId) {
|
||||
Exposure.restrictFields(filters, options, ['restrictedField']);
|
||||
filters.isPrivate = false;
|
||||
},
|
||||
maxLimit: 2,
|
||||
maxDepth: 2,
|
||||
restrictLinks(userId) {
|
||||
return ['restrictedLink'];
|
||||
}
|
||||
// INTERSECTION TEST LINKS
|
||||
|
||||
const intersectId = Intersect.insert({
|
||||
value: 'Hello',
|
||||
privateValue: 'Bad!'
|
||||
});
|
||||
DemoMethod.expose({
|
||||
publication: false
|
||||
});
|
||||
DemoPublication.expose({
|
||||
method: false
|
||||
|
||||
const intersectLinkId = IntersectLink.insert({
|
||||
value: 'Hello, I am a Link',
|
||||
privateValue: 'Bad!'
|
||||
});
|
||||
|
||||
Intersect.getLink(intersectId, 'link').set(intersectLinkId);
|
||||
Intersect.getLink(intersectId, 'privateLink').set(intersectLinkId);
|
||||
IntersectLink.getLink(intersectLinkId, 'myself').set(intersectLinkId);
|
||||
|
|
22
lib/exposure/testing/bootstrap/intersect.js
Normal file
22
lib/exposure/testing/bootstrap/intersect.js
Normal 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
|
||||
}
|
||||
});
|
|
@ -3,7 +3,9 @@ import Demo, {
|
|||
DemoPublication
|
||||
} 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) {
|
||||
const query = Demo.createQuery({
|
||||
$options: {limit: 3},
|
||||
|
@ -98,7 +100,7 @@ describe('Exposure', function () {
|
|||
it('Should restrict links # restrictLinks ', function (done) {
|
||||
const query = Demo.createQuery({
|
||||
_id: 1,
|
||||
restrictedLink: 1
|
||||
restrictedLink: {}
|
||||
});
|
||||
|
||||
query.fetch((err, res) => {
|
||||
|
@ -114,4 +116,82 @@ describe('Exposure', function () {
|
|||
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();
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
|
|
|
@ -1,2 +1,4 @@
|
|||
import './bootstrap/fixtures.js';
|
||||
import './bootstrap/expose.js';
|
||||
|
||||
import './units/units.js';
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import restrictFields from '../../lib/restrictFields.js';
|
||||
import enforceMaxLimit from '../../lib/enforceMaxLimit.js';
|
||||
import intersectDeep from '../../lib/intersectDeep.js';
|
||||
import enforceMaxDepth, {getDepth} from '../../lib/enforceMaxDepth.js';
|
||||
import CollectionNode from '../../../query/nodes/collectionNode.js';
|
||||
|
||||
|
@ -154,4 +155,63 @@ describe('Unit Tests', function () {
|
|||
|
||||
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);
|
||||
})
|
||||
});
|
|
@ -4,6 +4,7 @@ import mergeDeep from './lib/mergeDeep.js';
|
|||
import createGraph from '../../query/lib/createGraph.js';
|
||||
import recursiveCompose from '../../query/lib/recursiveCompose.js';
|
||||
import applyFilterFunction from '../../query/lib/applyFilterFunction.js';
|
||||
import deepClone from '../../query/lib/deepClone.js';
|
||||
|
||||
_.extend(NamedQuery.prototype, {
|
||||
expose(config = {}) {
|
||||
|
@ -33,7 +34,10 @@ _.extend(NamedQuery.prototype, {
|
|||
this._initCountMethod();
|
||||
|
||||
if (config.embody) {
|
||||
this.body = mergeDeep(this.body, config.embody);
|
||||
this.body = mergeDeep(
|
||||
deepClone(this.body),
|
||||
config.embody
|
||||
);
|
||||
}
|
||||
|
||||
this.isExposed = true;
|
||||
|
|
|
@ -3,7 +3,10 @@ import deepClone from '../query/lib/deepClone.js';
|
|||
export default class {
|
||||
constructor(name, collection, body, params = {}) {
|
||||
this.queryName = name;
|
||||
this.body = body;
|
||||
|
||||
this.body = deepClone(body);
|
||||
Object.freeze(this.body);
|
||||
|
||||
this.subscriptionHandle = null;
|
||||
this.params = params;
|
||||
this.collection = collection;
|
||||
|
|
|
@ -3,7 +3,7 @@ import LinkResolve from '../../links/linkTypes/linkResolve.js';
|
|||
import storeHypernovaResults from './storeHypernovaResults.js';
|
||||
import assembler from './assembler.js';
|
||||
|
||||
function hypernova(collectionNode, userId, debug) {
|
||||
function hypernova(collectionNode, userId) {
|
||||
_.each(collectionNode.collectionNodes, childCollectionNode => {
|
||||
let {filters, options} = applyProps(childCollectionNode);
|
||||
|
||||
|
@ -13,22 +13,22 @@ function hypernova(collectionNode, userId, debug) {
|
|||
result[childCollectionNode.linkName] = accessor.find(filters, options);
|
||||
});
|
||||
} 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);
|
||||
|
||||
const collection = collectionNode.collection;
|
||||
|
||||
collectionNode.results = collection.find(filters, options, userId).fetch();
|
||||
|
||||
hypernova(collectionNode, userId, debug);
|
||||
|
||||
const userIdToPass = (config.bypassFirewalls) ? undefined : userId;
|
||||
hypernova(collectionNode, userIdToPass);
|
||||
|
||||
return collectionNode.results;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import assemble from './assembler.js';
|
|||
import assembleAggregateResults from './assembleAggregateResults.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) {
|
||||
return childCollectionNode.results = [];
|
||||
}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import applyProps from './applyProps.js';
|
||||
|
||||
export default function compose(node, userId) {
|
||||
function compose(node, userId) {
|
||||
return {
|
||||
find(parent) {
|
||||
let {filters, options} = applyProps(node);
|
||||
|
@ -28,3 +28,19 @@ export default function compose(node, 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);
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,8 +1,13 @@
|
|||
import FieldNode from './fieldNode.js';
|
||||
import deepClone from '../lib/deepClone';
|
||||
|
||||
export default class CollectionNode {
|
||||
constructor(collection, body, linkName) {
|
||||
this.body = body;
|
||||
constructor(collection, body = {}, linkName = null) {
|
||||
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.collection = collection;
|
||||
|
||||
|
|
|
@ -3,7 +3,10 @@ import deepClone from './lib/deepClone.js';
|
|||
export default class {
|
||||
constructor(collection, body, params = {}) {
|
||||
this.collection = collection;
|
||||
this.body = body;
|
||||
|
||||
this.body = deepClone(body);
|
||||
Object.freeze(this.body);
|
||||
|
||||
this._params = params;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
Package.describe({
|
||||
name: 'cultofcoders:grapher',
|
||||
version: '1.1.11',
|
||||
version: '1.1.12',
|
||||
// Brief, one-line summary of the package.
|
||||
summary: 'Grapher makes linking collections easily. And fetching data as a graph.',
|
||||
// URL to the Git repository containing the source code for this package.
|
||||
|
|
Loading…
Add table
Reference in a new issue