Skip to content

Commit 5d2074f

Browse files
committed
feat(falcor): add idempotent book operations
closes #9
1 parent 4594236 commit 5d2074f

File tree

10 files changed

+793
-8
lines changed

10 files changed

+793
-8
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
},
3434
"homepage": "https://github.com/StoryShop/api#readme",
3535
"dependencies": {
36-
"@reactivex/rxjs": "^5.0.0-beta.12",
3736
"aws-sdk": "^2.2.41",
3837
"babel-preset-es2015": "^6.5.0",
3938
"babel-preset-stage-0": "^6.5.0",
@@ -52,7 +51,9 @@
5251
"multiparty": "^4.1.2",
5352
"neo4j-driver": "^1.0.4",
5453
"opml-generator": "^1.1.1",
54+
"rx": "^4.1.0",
5555
"shortid": "^2.2.4",
56+
"slug": "^0.9.1",
5657
"uservoice-sso": "^0.1.0"
5758
},
5859
"devDependencies": {

src/db.js

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,7 @@ export default ({ mongodb, neo4j }) => {
1919
}
2020

2121
// convert the neo4j run function's Result into an Observable.
22-
return new Observable( sub => {
23-
this.session.run( ...args ).subscribe({
24-
onNext: ( ...args ) => sub.next( ...args ),
25-
onError: ( ...args ) => sub.error( ...args ),
26-
onCompleted: ( ...args ) => sub.completed( ...args ),
27-
});
28-
});
22+
return Observable.create( sub => this.session.run( ...args ).subscribe( sub ) );
2923
},
3024

3125
close ( ...args ) {

src/falcor/books/index.js

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { Observable } from 'rx';
2+
import { keys, $ref } from '../../utils';
3+
import {
4+
getBooks,
5+
setBookProps,
6+
getWorldByBook
7+
} from '../transforms/books'
8+
import slug from 'slug'
9+
10+
11+
export default ( db, req, res ) => {
12+
const {user} = req;
13+
return [
14+
{
15+
route: 'booksById[{keys:ids}]["_id", "title", "slug"]',
16+
get: ({ids})=> {
17+
return db
18+
::getBooks(ids,user._id)
19+
.flatMap(book =>
20+
[
21+
{path: ["booksById", book._id, "_id"], value: book._id},
22+
{path: ["booksById", book._id, "title"], value: book.title},
23+
{path: ["booksById", book._id, "slug"], value: slug(book.title,{lower: true})},
24+
]
25+
)
26+
}
27+
},
28+
{
29+
route: 'booksById[{keys:ids}]["title"]',
30+
set: pathSet => {
31+
return db
32+
::setBookProps( pathSet.booksById, user )
33+
.flatMap(book => {
34+
return [
35+
{path: ["booksById", book._id, "title"], value: book.title},
36+
{path: ["booksById", book._id, "slug"], value: slug(book.title,{lower: true})},
37+
]
38+
})
39+
}
40+
},
41+
{
42+
route: 'booksById[{keys:ids}].world',
43+
get: pathSet => {
44+
const {ids} = pathSet;
45+
return db
46+
::getBooks(ids,user._id)
47+
.flatMap(book =>
48+
db::getWorldByBook(book._id)
49+
.map((worldId) =>
50+
[
51+
{path: ["booksById", book._id, "world",], value: $ref(['worldsById', worldId])},
52+
]
53+
)
54+
)
55+
}
56+
},
57+
{
58+
route: 'booksById[{keys:ids}].status',
59+
get: pathSet => {
60+
const {ids} = pathSet;
61+
ids.map(id => {
62+
return [
63+
{path: ["booksById", id, "status"], value: 0},
64+
]
65+
})
66+
}
67+
},
68+
69+
]
70+
}

src/falcor/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import users from './users';
55
import characters from './characters';
66
import outlines from './outlines';
77
import elements from './elements';
8+
import books from './books'
89

910
export default db => falcorExpress.dataSourceRoute( ( req, res ) => new Router(
1011
[]
@@ -13,5 +14,6 @@ export default db => falcorExpress.dataSourceRoute( ( req, res ) => new Router(
1314
.concat( characters( db, req, res ) )
1415
.concat( outlines( db, req, res ) )
1516
.concat( elements( db, req, res ) )
17+
.concat( books( db, req, res ) )
1618
));
1719

src/falcor/transforms/books.js

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {Observable} from 'rx';
2+
import {
3+
keysO,
4+
} from '../../utils';
5+
6+
import {accessControl} from './index'
7+
8+
9+
export function getBooks(ids, userId, secure) {
10+
return this.flatMap(db =>
11+
Observable.from(ids)
12+
.flatMap(id =>
13+
this::permissionBook(id, userId, secure)
14+
.map(permission => {
15+
if (permission === false)
16+
throw new Error("Not authorized");
17+
return id;
18+
})
19+
)
20+
.toArray()
21+
.flatMap(ids => {
22+
return db.mongo.collection('books').find({_id: {$in: ids}}).toArray()
23+
}
24+
)
25+
.flatMap(normalize => normalize)
26+
)
27+
}
28+
29+
export function setBookProps(propsById) {
30+
return this.flatMap(db => {
31+
return keysO(propsById)
32+
.flatMap(_id => {
33+
return db.mongo.collection('books').findOneAndUpdate({_id}, {$set: propsById[_id]}, {
34+
returnOriginal: false,
35+
});
36+
})
37+
.map(book => book.value)
38+
;
39+
});
40+
}
41+
42+
43+
export function getBooksLength(worldID) {
44+
const query = `
45+
match (b:Book)-[rel:IN]->(w:World)
46+
WHERE rel.archived = false and w._id = {worldID}
47+
return count(b) as count
48+
`;
49+
return this.flatMap(db =>db.neo.run(query, {worldID}))
50+
.map(record =>
51+
record.get('count').toNumber()
52+
)
53+
}
54+
55+
export function getWorldByBook(bookId) {
56+
const query = `
57+
match (b:Book)-[rel:IN]->(w:World)
58+
WHERE rel.archived = false and b._id = "${bookId}"
59+
return w._id as id
60+
`;
61+
return this.flatMap(db => db.neo.run(query))
62+
.map(record =>
63+
record.get('id')
64+
)
65+
}
66+
67+
68+
export function permissionBook(bookId, userId, write) {
69+
70+
const permissions = accessControl(write);
71+
72+
const query = `
73+
MATCH (b:Book)-[r:IN]->(w:World)<-[rel]-(u:User)
74+
WHERE b._id = {bookId} AND u._id ={userId}
75+
AND (${permissions})
76+
return count(rel) > 0 as permission
77+
`;
78+
79+
return this.flatMap(db => db.neo.run(query, {bookId, userId}))
80+
.map(record =>
81+
record.get('permission')
82+
)
83+
}
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import test from 'tape';
2+
import {Observable} from 'rx';
3+
import database from '../../db';
4+
import {
5+
getBooks,
6+
getBooksLength,
7+
permissionBook
8+
} from './books'
9+
10+
const dbconf = {
11+
mongodb: {
12+
uri: process.env.MONGO_URI || 'mongodb://localhost:27017/dev',
13+
},
14+
15+
neo4j: {
16+
uri: process.env.NEO_URI || 'bolt://localhost',
17+
},
18+
};
19+
20+
const populate = `
21+
CREATE (reader:User { _id:"testUser1" })
22+
CREATE (user:User { _id:"testUser" })
23+
CREATE (world:World {_id: "testWorld"})<-[:OWNER {archived: false}]-(user)
24+
CREATE (world)<-[:READER {archived: false}]-(reader)
25+
CREATE (:Book {_id : "testBook"})-[:IN {archived: false}]->(world)
26+
CREATE (:Book {_id: "testBook1" })-[:IN {archived: false}]->(world)
27+
CREATE (:Book {_id: "testBookAlone"})
28+
return user
29+
`;
30+
const remove = `
31+
MATCH (n)
32+
OPTIONAL MATCH (n)-[r]-()
33+
WITH n,r LIMIT 50000
34+
WHERE n._id =~ ".*test.*"
35+
DELETE n,r
36+
RETURN count(n) as deletedNodesCount
37+
`;
38+
39+
const books = [
40+
{_id: 'testBook', title: 'Book for test'},
41+
{_id: 'testBook1', title: 'Book for test2'},
42+
];
43+
44+
const bookIds = books.map(book => book._id);
45+
46+
test('permissionBook', t => {
47+
const db = database(dbconf);
48+
let neo, mongo, actual, expected;
49+
let book = 'testBook';
50+
let owner = 'testUser';
51+
let reader = 'testUser1'
52+
53+
db
54+
.map(db => {
55+
neo = db.neo;
56+
mongo = db.mongo;
57+
return neo;
58+
})
59+
.flatMap(neo =>
60+
neo.run(populate)
61+
)
62+
.flatMap(neo =>
63+
db::permissionBook(book, owner)
64+
)
65+
.flatMap(permission => {
66+
67+
actual = permission;
68+
expected = true;
69+
t.equals(actual, expected, "should have permission to read the book");
70+
71+
72+
return db::permissionBook(book, owner, true)
73+
})
74+
.flatMap(permission => {
75+
76+
actual = permission;
77+
expected = true;
78+
t.equals(actual, expected, "should have permission to modify the book");
79+
80+
return db::permissionBook(book, reader, true)
81+
})
82+
.flatMap(permission => {
83+
84+
actual = permission;
85+
expected = false;
86+
t.equals(actual, expected, "should not have permission to modify the book");
87+
88+
return neo.run(remove);
89+
})
90+
.subscribe(() => {
91+
mongo.close().then(() => neo.close(() => neo.disconnect()));
92+
t.end();
93+
})
94+
});
95+
96+
test('getBooks', t => {
97+
const db = database(dbconf);
98+
let neo, mongo, actual, expected;
99+
let booksToFind = ["testBook", "testBook1"];
100+
let userId = 'testUser';
101+
db
102+
.map(db => {
103+
neo = db.neo;
104+
mongo = db.mongo;
105+
return neo;
106+
})
107+
.flatMap(neo =>
108+
neo.run(populate)
109+
)
110+
.flatMap(() =>
111+
mongo.collection('books').insertMany(books)
112+
)
113+
.flatMap(neo =>
114+
db::getBooks(booksToFind, userId)
115+
)
116+
.flatMap((book) => {
117+
118+
actual = bookIds.find(id => book._id) != null;
119+
expected = true;
120+
t.equals(actual, expected, 'should match one of the ids that has been passed to getBooks');
121+
122+
return neo.run(remove)
123+
})
124+
125+
.flatMap(() => {
126+
//t.throws(() => db::getBooks(['invalidID'], userId), 'should throw an exception');
127+
return mongo.collection('books').removeMany({_id: {$in: bookIds}})
128+
})
129+
.subscribe(
130+
() => {
131+
mongo.close().then(() => neo.close(() => neo.disconnect()));
132+
t.end();
133+
},
134+
error => {
135+
mongo.close().then(() => neo.close(() => neo.disconnect()));
136+
}
137+
)
138+
});
139+
140+
141+
test('getBooksLength', t => {
142+
const db = database(dbconf);
143+
let neo, mongo, actual, expected;
144+
145+
db
146+
.map(db => {
147+
neo = db.neo;
148+
mongo = db.mongo;
149+
return neo;
150+
})
151+
.flatMap(neo =>
152+
neo.run(populate)
153+
)
154+
.flatMap(() =>
155+
db::getBooksLength('testWorld')
156+
)
157+
.flatMap(length => {
158+
159+
actual = length;
160+
expected = 2;
161+
t.equals(actual, expected, "should match the number of books the world has");
162+
163+
return neo.run(remove);
164+
})
165+
.flatMap(() =>
166+
db::getBooksLength('invalidWorld')
167+
)
168+
.flatMap(length => {
169+
170+
actual = length;
171+
expected = 0;
172+
t.equals(actual, expected, "should not have any book when the world is not valid");
173+
174+
return neo.run(remove);
175+
})
176+
.subscribe(() => {
177+
mongo.close().then(() => neo.close(() => neo.disconnect()));
178+
t.end();
179+
})
180+
});
181+

0 commit comments

Comments
 (0)