0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-11 02:12:21 -05:00

Rewrite url service (#9550)

refs https://github.com/TryGhost/Team/issues/65

We are currently work on dynamic routing (aka channels).
An important piece of this feature is the url service, which always knows the url of a resource at any time.
Resources can belong to collections or taxonomies, which can be defined in a [routing yaml file](https://github.com/TryGhost/Ghost/issues/9528). We are currently shipping portions, which will at end form the full dynamic routing feature.

### Key Notes

- each routing type (collections, taxonomies, static pages) is registered in order - depending on the yaml routes file configuration
- static pages are an internal concept - they sit at the end of the subscriber queue
- we make use of a temporary [`Channels2`](https://github.com/TryGhost/Ghost/pull/9550/files#diff-9e7251409844521470c9829013cd1563) file, which simulates the current static routing in Ghost (this file will be modified, removed or whatever - this is one of the next steps)
- two way binding: you can ask for a resource url based on the resource id, you can ask for the resource based on the url
- in theory it's possible that multiple resources generate the same url: we don't handle this with collision (because this is error prone), we handle this with the order of serving content. if you ask the service for a resource, which lives behind e.g. /test/, you will get the resource which is served
- loose error handling -> log errors and handle instead of throw error and do nothing (we log the errors with a specific code, so we can react in case there is a bug)
- the url services fetches all resources on bootstrap. we only fetch and keep a reduced set of attributes (basically the main body of a resource)
- the bootstrap time will decrease a very little (depending on the amount of resources you have in your database)
- we still offer the option to disable url preloading (in your config `disableUrlPreload: true`) - this option will be removed as soon as the url service is connected. You can disable the service in case you encounter a problem
- **the url service is not yet connected, we will connect the service step by step. The first version should be released to pre-catch bugs. The next version will add 503 handling if the url service is not ready and it will consume urls for resources.**


----

- the url service generates urls based on resources (posts, pages, users, tags)
- the url service keeps track of resource changes
- the url service keeps track of resource removal/insert
- the architecture:
  - each routing type is represented by a url generator
    - a routing type is a collection, a taxonomiy or static pages
  - a queue which ensures that urls are unique and can be owned by one url generator
    - the hierarchy of registration defines that
  - we query knex, because bookshelf is too slow
- removed old url service files + logic
- added temp channels alternative (Channels2) -> this file will look different soon, it's for now the temporary connector to the url service. Also the name of the file is not optimal, but that is not really important right now.
This commit is contained in:
Katharina Irrgang 2018-04-17 11:29:04 +02:00 committed by GitHub
parent defe65c2de
commit 6a4af1f465
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 2823 additions and 245 deletions

View file

@ -11,7 +11,9 @@ const _ = require('lodash'),
bookshelf = require('bookshelf'),
moment = require('moment'),
Promise = require('bluebird'),
gql = require('ghost-gql'),
ObjectId = require('bson-objectid'),
debug = require('ghost-ignition').debug('models:base'),
config = require('../../config'),
db = require('../../data/db'),
common = require('../../lib/common'),
@ -324,7 +326,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
* all supported databases (sqlite, mysql) return different values
*
* sqlite:
* - knex returns a UTC String
* - knex returns a UTC String (2018-04-12 20:50:35)
* mysql:
* - knex wraps the UTC value into a local JS Date
*/
@ -422,7 +424,7 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
* @returns {*}
*/
toJSON: function toJSON(unfilteredOptions) {
var options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'toJSON');
const options = ghostBookshelf.Model.filterOptions(unfilteredOptions, 'toJSON');
options.omitPivot = true;
return proto.toJSON.call(this, options);
@ -973,6 +975,214 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
}
return _.map(visibility.split(','), _.trim);
},
/**
* If you want to fetch all data fast, i recommend using this function.
* Bookshelf is just too slow, too much ORM overhead.
*
* If we e.g. instantiate for each object a model, it takes twice long.
*/
raw_knex: {
fetchAll: function (options) {
options = options || {};
const modelName = options.modelName;
const tableNames = {
Post: 'posts',
User: 'users',
Tag: 'tags'
};
const reducedFields = options.reducedFields;
const exclude = {
Post: [
'title',
'mobiledoc',
'html',
'plaintext',
'amp',
'codeinjection_head',
'codeinjection_foot',
'meta_title',
'meta_description',
'custom_excerpt',
'og_image',
'og_title',
'og_description',
'twitter_image',
'twitter_title',
'twitter_description',
'custom_template'
],
User: [
'bio',
'website',
'location',
'facebook',
'twitter',
'accessibility',
'meta_title',
'meta_description',
'tour'
],
Tag: [
'description',
'meta_title',
'meta_description'
]
};
const filter = options.filter;
const withRelated = options.withRelated;
const withRelatedFields = options.withRelatedFields;
const relations = {
tags: {
targetTable: 'tags',
name: 'tags',
innerJoin: {
relation: 'posts_tags',
condition: ['posts_tags.tag_id', '=', 'tags.id']
},
select: ['posts_tags.post_id as post_id'],
whereIn: 'posts_tags.post_id',
whereInKey: 'post_id',
orderBy: 'sort_order'
},
authors: {
targetTable: 'users',
name: 'authors',
innerJoin: {
relation: 'posts_authors',
condition: ['posts_authors.author_id', '=', 'users.id']
},
select: ['posts_authors.post_id as post_id'],
whereIn: 'posts_authors.post_id',
whereInKey: 'post_id',
orderBy: 'sort_order'
}
};
let query = ghostBookshelf.knex(tableNames[modelName]);
// exclude fields if enabled
if (reducedFields) {
const toSelect = _.keys(schema.tables[tableNames[modelName]]);
_.each(exclude[modelName], (key) => {
if (toSelect.indexOf(key) !== -1) {
toSelect.splice(toSelect.indexOf(key), 1);
}
});
query.select(toSelect);
}
// filter data
gql.knexify(query, gql.parse(filter));
return query.then((objects) => {
debug('fetched', modelName, filter);
let props = {};
if (!withRelated) {
return _.map(objects, (object) => {
object = ghostBookshelf._models[modelName].prototype.toJSON.bind({
attributes: object,
related: function (key) {
return object[key];
},
serialize: ghostBookshelf._models[modelName].prototype.serialize,
formatsToJSON: ghostBookshelf._models[modelName].prototype.formatsToJSON
})();
object = ghostBookshelf._models[modelName].prototype.fixBools(object);
object = ghostBookshelf._models[modelName].prototype.fixDatesWhenFetch(object);
return object;
});
}
_.each(withRelated, (withRelatedKey) => {
const relation = relations[withRelatedKey];
props[relation.name] = (() => {
debug('fetch withRelated', relation.name);
let query = db.knex(relation.targetTable);
// default fields to select
_.each(relation.select, (fieldToSelect) => {
query.select(fieldToSelect);
});
// custom fields to select
_.each(withRelatedFields[withRelatedKey], (toSelect) => {
query.select(toSelect);
});
query.innerJoin(
relation.innerJoin.relation,
relation.innerJoin.condition[0],
relation.innerJoin.condition[1],
relation.innerJoin.condition[2]
);
query.whereIn(relation.whereIn, _.map(objects, 'id'));
query.orderBy(relation.orderBy);
return query
.then((relations) => {
debug('fetched withRelated', relation.name);
// arr => obj[post_id] = [...] (faster access)
return relations.reduce((obj, item) => {
if (!obj[item[relation.whereInKey]]) {
obj[item[relation.whereInKey]] = [];
}
obj[item[relation.whereInKey]].push(_.omit(item, relation.select));
return obj;
}, {});
});
})();
});
return Promise.props(props)
.then((relations) => {
debug('attach relations', modelName);
objects = _.map(objects, (object) => {
_.each(Object.keys(relations), (relation) => {
if (!relations[relation][object.id]) {
object[relation] = [];
return;
}
object[relation] = relations[relation][object.id];
});
object = ghostBookshelf._models[modelName].prototype.toJSON.bind({
attributes: object,
_originalOptions: {
withRelated: Object.keys(relations)
},
related: function (key) {
return object[key];
},
serialize: ghostBookshelf._models[modelName].prototype.serialize,
formatsToJSON: ghostBookshelf._models[modelName].prototype.formatsToJSON
})();
object = ghostBookshelf._models[modelName].prototype.fixBools(object);
object = ghostBookshelf._models[modelName].prototype.fixDatesWhenFetch(object);
return object;
});
debug('attached relations', modelName);
return objects;
});
});
}
}
});

View file

@ -0,0 +1,169 @@
'use strict';
/* eslint-disable */
const _ = require('lodash');
const path = require('path');
const EventEmitter = require('events').EventEmitter;
const common = require('../../lib/common');
const settingsCache = require('../settings/cache');
/**
* @temporary
*
* This is not designed yet. This is all temporary.
*/
class RoutingType extends EventEmitter {
constructor(obj) {
super();
this.route = _.defaults(obj.route, {value: null, extensions: {}});
this.config = obj.config;
}
getRoute() {
return this.route;
}
getPermalinks() {
return false;
}
getType() {
return this.config.type;
}
getFilter() {
return this.config.options && this.config.options.filter;
}
toString() {
return `Type: ${this.getType()}, Route: ${this.getRoute().value}`;
}
}
class Collection extends RoutingType {
constructor(obj) {
super(obj);
this.permalinks = _.defaults(obj.permalinks, {value: null, extensions: {}});
this.permalinks.getValue = () => {
/**
* @deprecated Remove in Ghost 2.0
*/
if (this.permalinks.value.match(/settings\.permalinks/)) {
const value = this.permalinks.value.replace(/\/{settings\.permalinks}\//, settingsCache.get('permalinks'));
return path.join(this.route.value, value);
}
return path.join(this.route.value, this.permalinks.value);
};
this._listeners();
common.events.emit('routingType.created', this);
}
getPermalinks() {
return this.permalinks;
}
_listeners() {
/**
* @deprecated Remove in Ghost 2.0
*/
if (this.getPermalinks() && this.getPermalinks().value.match(/settings\.permalinks/)) {
common.events.on('settings.permalinks.edited', () => {
this.emit('updated');
});
}
}
toString() {
return `Type: ${this.getType()}, Route: ${this.getRoute().value}, Permalinks: ${this.getPermalinks().value}`;
}
}
class Taxonomy extends RoutingType {
constructor(obj) {
super(obj);
this.permalinks = {value: '/:slug/', extensions: {}};
this.permalinks.getValue = () => {
return path.join(this.route.value, this.permalinks.value);
};
common.events.emit('routingType.created', this);
}
getPermalinks() {
return this.permalinks;
}
toString() {
return `Type: ${this.getType()}, Route: ${this.getRoute().value}, Permalinks: ${this.getPermalinks().value}`;
}
}
class StaticPages extends RoutingType {
constructor(obj) {
super(obj);
this.permalinks = {value: '/:slug/', extensions: {}};
this.permalinks.getValue = () => {
return path.join(this.route.value, this.permalinks.value);
};
common.events.emit('routingType.created', this);
}
getPermalinks() {
return this.permalinks;
}
}
const collection1 = new Collection({
route: {
value: '/'
},
permalinks: {
value: '/{settings.permalinks}/'
},
config: {
type: 'posts',
options: {
filter: 'featured:false'
}
}
});
const taxonomy1 = new Taxonomy({
route: {
value: '/author/'
},
config: {
type: 'users',
options: {}
}
});
const taxonomy2 = new Taxonomy({
route: {
value: '/tag/'
},
config: {
type: 'tags',
options: {}
}
});
const staticPages = new StaticPages({
route: {
value: '/'
},
config: {
type: 'pages',
options: {}
}
});

View file

@ -0,0 +1,208 @@
'use strict';
const debug = require('ghost-ignition').debug('services:url:queue'),
EventEmitter = require('events').EventEmitter,
_ = require('lodash'),
common = require('../../lib/common');
/**
* ### Purpose of this queue
*
* Ghost fetches as earliest as possible the resources from the database. The reason is simply: we
* want to know all urls as soon as possible.
*
* Parallel to this, the routes/channels are read/prepared and registered in express.
* So the challenge is to handle both resource availability and route registration.
* If you start an event, all subscribers of it are executed in a sequence. The queue will wait
* till the current subscriber has finished it's work.
* The url service must ensure that each url in the system exists once. The order of
* subscribers defines who will possibly own an url first.
*
* If an event has finished, the subscribers of this event still remain in the queue.
* That means:
*
* - you can re-run an event
* - you can add more subscribers to an existing queue
* - you can order subscribers (helpful if you want to order routes/channels)
*
* Each subscriber represents one instance of the url generator. One url generator represents one channel/route.
*
* ### Tolerance option
*
* You can define a tolerance value per event. If you want to wait an amount of time till you think
* all subscribers have registered.
*
* ### Some examples to understand cases
*
* e.g.
* - resources have been loaded, event has started
* - no subscribers yet, we need to wait, express still initialises
* - okay, routes are coming in
* - we notify the subscribers
*
* e.g.
* - resources are in the progress of fetching from the database
* - routes are already waiting for the resources
*
* e.g.
* - resources are in the progress of fetching from the database
* - 2 subscribers are already registered
* - resources finished, event starts
* - 2 more subscribers are coming in
* ### Events
* - unique events e.g. added, updated, init, all
* - has subscribers
* - we remember the subscriber
*
* ### Actions
* - one event can have multiple actions
* - unique actions e.g. add post 1, add post 2
* - one event might only allow a single action to avoid collisions e.g. you initialise data twice
* - if an event has no action, the name of the action is the name of the event
* - in this case the event can only run once at a time
* - makes use of `toNotify` to remember who was notified already
*/
class Queue extends EventEmitter {
constructor() {
super();
this.queue = {};
this.toNotify = {};
}
/**
* `tolerance`:
* - 0: don't wait for more subscribers [default]
* - 100: wait long enough till all subscribers have registered (e.g. bootstrap)
*/
register(options, fn) {
if (!options.hasOwnProperty('tolerance')) {
options.tolerance = 0;
}
// CASE: nobody has initialised the queue event yet
if (!this.queue.hasOwnProperty(options.event)) {
this.queue[options.event] = {
tolerance: options.tolerance,
subscribers: []
};
}
debug('add', options.event, options.tolerance);
this.queue[options.event].subscribers.push(fn);
}
run(options) {
const event = options.event,
action = options.action,
eventData = options.eventData;
clearTimeout(this.toNotify[action].timeout);
this.toNotify[action].timeout = null;
debug('run', action, event, this.queue[event].subscribers.length, this.toNotify[action].notified.length);
if (this.queue[event].subscribers.length && this.queue[event].subscribers.length !== this.toNotify[action].notified.length) {
const fn = this.queue[event].subscribers[this.toNotify[action].notified.length];
debug('execute', action, event, this.toNotify[action].notified.length);
/**
* Currently no async operations happen in the subscribers functions.
* We can trigger the functions sync.
*/
try {
fn(eventData);
debug('executed', action, event, this.toNotify[action].notified.length);
this.toNotify[action].notified.push(fn);
this.run(options);
} catch (err) {
debug('error', err.message);
common.logging.error(new common.errors.InternalServerError({
message: 'Something bad happened.',
code: 'SERVICES_URL_QUEUE',
err: err
}));
// just try again
this.run(options);
}
} else {
// CASE 1: zero tolerance, kill run fn
// CASE 2: okay, i was tolerant enough, kill me
// CASE 3: wait for more subscribers, i am still tolerant
if (this.queue[event].tolerance === 0) {
delete this.toNotify[action];
debug('ended (1)', event, action);
this.emit('ended', event);
} else if (this.toNotify[action].timeoutInMS > this.queue[event].tolerance) {
delete this.toNotify[action];
debug('ended (2)', event, action);
this.emit('ended', event);
} else {
this.toNotify[action].timeoutInMS = this.toNotify[action].timeoutInMS * 1.1;
this.toNotify[action].timeout = setTimeout(() => {
this.run(options);
}, this.toNotify[action].timeoutInMS);
}
}
}
start(options) {
debug('start');
// CASE: nobody is in the event queue waiting yet
// e.g. all resources are fetched already, but no subscribers (bootstrap)
// happens only for high tolerant events
if (!this.queue.hasOwnProperty(options.event)) {
this.queue[options.event] = {
tolerance: options.tolerance || 0,
subscribers: []
};
}
// an event doesn't need an action
if (!options.action) {
options.action = options.event;
}
// CASE 1: the queue supports killing an event e.g. resource edit is triggered twice very fast
// CASE 2: is the action already running, stop it, because e.g. configuration has changed
if (this.toNotify[options.action]) {
// CASE: timeout was registered, kill it, this will stop the run function of this action
if (this.toNotify[options.action].timeout) {
clearTimeout(this.toNotify[options.action].timeout);
this.toNotify[options.action].timeout = null;
} else {
debug('ignore. is already running', options.event, options.action);
return;
}
}
// reset who was already notified
this.toNotify[options.action] = {
event: options.event,
timeoutInMS: options.timeoutInMS || 50,
notified: []
};
this.emit('started', options.event);
this.run(options);
}
reset() {
this.queue = {};
_.each(this.toNotify, (obj) => {
clearTimeout(obj.timeout);
});
this.toNotify = {};
}
}
module.exports = Queue;

View file

@ -1,50 +1,60 @@
'use strict';
const _ = require('lodash'),
localUtils = require('./utils'),
prefetchDefaults = {
context: {
internal: true
},
limit: 'all'
};
const EventEmitter = require('events').EventEmitter,
common = require('../../lib/common');
class Resource {
constructor(config) {
this.name = config.name;
this.api = config.api;
this.prefetchOptions = config.prefetchOptions || {};
this.urlLookup = config.urlLookup || config.name;
this.events = config.events;
this.items = {};
}
class Resource extends EventEmitter {
constructor(type, obj) {
super();
fetchAll() {
const options = _.defaults(this.prefetchOptions, prefetchDefaults);
return require('../../api')[this.api]
.browse(options)
.then((resp) => {
this.items = resp[this.api];
return this.items;
});
}
toUrl(item) {
const data = {
[this.urlLookup]: item
this.data = {};
this.config = {
type: type,
reserved: false
};
return localUtils.urlFor(this.urlLookup, data);
Object.assign(this.data, obj);
}
toData(item) {
return {
slug: item.slug,
resource: {
type: this.name,
id: item.id
}
};
getType() {
return this.config.type;
}
reserve() {
if (!this.config.reserved) {
this.config.reserved = true;
} else {
common.logging.error(new common.errors.InternalServerError({
message: 'Resource is already taken. This should not happen.',
code: 'URLSERVICE_RESERVE_RESOURCE'
}));
}
}
release() {
this.config.reserved = false;
}
isReserved() {
return this.config.reserved === true;
}
update(obj) {
this.data = obj;
if (!this.isReserved()) {
return;
}
this.emit('updated', this);
}
remove() {
if (!this.isReserved()) {
return;
}
this.emit('removed', this);
}
}

View file

@ -0,0 +1,253 @@
'use strict';
const debug = require('ghost-ignition').debug('services:url:resources'),
Promise = require('bluebird'),
_ = require('lodash'),
Resource = require('./Resource'),
models = require('../../models'),
common = require('../../lib/common');
/**
* These are the default resources and filters.
* These are the minimum filters for public accessibility of resources.
*/
const resourcesConfig = [
{
type: 'posts',
modelOptions: {
modelName: 'Post',
filter: 'visibility:public+status:published+page:false',
reducedFields: true,
withRelated: ['tags', 'authors'],
withRelatedFields: {
tags: ['tags.id', 'tags.slug'],
authors: ['users.id', 'users.slug']
}
},
events: {
add: 'post.published',
update: 'post.published.edited',
remove: 'post.unpublished'
}
},
{
type: 'pages',
modelOptions: {
modelName: 'Post',
reducedFields: true,
filter: 'visibility:public+status:published+page:true'
},
events: {
add: 'page.published',
update: 'page.published.edited',
remove: 'page.unpublished'
}
},
{
type: 'tags',
keep: ['id', 'slug', 'updated_at', 'created_at'],
modelOptions: {
modelName: 'Tag',
reducedFields: true,
filter: 'visibility:public'
},
events: {
add: 'tag.added',
update: 'tag.edited',
remove: 'tag.deleted'
}
},
{
type: 'users',
modelOptions: {
modelName: 'User',
reducedFields: true,
filter: 'visibility:public'
},
events: {
add: 'user.activated',
update: 'user.activated.edited',
remove: 'user.deactivated'
}
}
];
/**
* NOTE: We are querying knex directly, because the Bookshelf ORM overhead is too slow.
*/
class Resources {
constructor(queue) {
this.queue = queue;
this.data = {};
this.listeners = [];
this._listeners();
}
_listenOn(eventName, listener) {
this.listeners.push({
eventName: eventName,
listener: listener
});
common.events.on(eventName, listener);
}
_listeners() {
/**
* We fetch the resources as early as possible.
* Currently the url service needs to use the settings cache,
* because we need to `settings.permalink`.
*/
this._listenOn('db.ready', this._onDatabaseReady.bind(this));
}
_onDatabaseReady() {
const ops = [];
debug('db ready. settings cache ready.');
_.each(resourcesConfig, (resourceConfig) => {
this.data[resourceConfig.type] = [];
ops.push(this._fetch(resourceConfig));
this._listenOn(resourceConfig.events.add, (model) => {
return this._onResourceAdded.bind(this)(resourceConfig.type, model);
});
this._listenOn(resourceConfig.events.update, (model) => {
return this._onResourceUpdated.bind(this)(resourceConfig.type, model);
});
this._listenOn(resourceConfig.events.remove, (model) => {
return this._onResourceRemoved.bind(this)(resourceConfig.type, model);
});
});
Promise.all(ops)
.then(() => {
// CASE: all resources are fetched, start the queue
this.queue.start({
event: 'init',
tolerance: 100
});
});
}
_fetch(resourceConfig) {
debug('_fetch', resourceConfig.type, resourceConfig.modelOptions);
return models.Base.Model.raw_knex.fetchAll(resourceConfig.modelOptions)
.then((objects) => {
debug('fetched', resourceConfig.type, objects.length);
_.each(objects, (object) => {
this.data[resourceConfig.type].push(new Resource(resourceConfig.type, object));
});
});
}
_onResourceAdded(type, model) {
const resource = new Resource(type, model.toJSON());
debug('_onResourceAdded', type);
this.data[type].push(resource);
this.queue.start({
event: 'added',
action: 'added:' + model.id,
eventData: {
id: model.id,
type: type
}
});
}
/**
* CASE:
* - post was fetched on bootstrap
* - that means, the post is already published
* - resource exists, but nobody owns it
* - if the model changes, it can be that somebody will then own the post
*
* CASE:
* - post was fetched on bootstrap
* - that means, the post is already published
* - resource exists and is owned by somebody
* - but the data changed and is maybe no longer owned?
* - e.g. featured:false changes and your filter requires featured posts
*/
_onResourceUpdated(type, model) {
debug('_onResourceUpdated', type);
this.data[type].every((resource) => {
if (resource.data.id === model.id) {
resource.update(model.toJSON());
// CASE: pretend it was added
if (!resource.isReserved()) {
this.queue.start({
event: 'added',
action: 'added:' + model.id,
eventData: {
id: model.id,
type: type
}
});
}
// break!
return false;
}
return true;
});
}
_onResourceRemoved(type, model) {
let index = null;
let resource;
this.data[type].every((_resource, _index) => {
if (_resource.data.id === model._previousAttributes.id) {
resource = _resource;
index = _index;
// break!
return false;
}
return true;
});
// CASE: there are possible cases that the resource was not fetched e.g. visibility is internal
if (index === null) {
debug('can\'t find resource', model._previousAttributes.id);
return;
}
delete this.data[type][index];
resource.remove();
}
getAll() {
return this.data;
}
getAllByType(type) {
return this.data[type];
}
getByIdAndType(type, id) {
return _.find(this.data[type], {data: {id: id}});
}
reset() {
_.each(this.listeners, (obj) => {
common.events.removeListener(obj.eventName, obj.listener);
});
this.listeners = [];
this.data = {};
}
}
module.exports = Resources;

View file

@ -0,0 +1,244 @@
'use strict';
const _ = require('lodash'),
Promise = require('bluebird'),
moment = require('moment-timezone'),
jsonpath = require('jsonpath'),
debug = require('ghost-ignition').debug('services:url:generator'),
config = require('../../config'),
settingsCache = require('../settings/cache'),
/**
* @TODO: This is a fake version of the upcoming GQL tool.
* GQL will offer a tool to match a JSON against a filter.
*/
transformFilter = (filter) => {
filter = '$[?(' + filter + ')]';
filter = filter.replace(/(\w+):(\w+)/g, '@.$1 == "$2"');
filter = filter.replace(/"true"/g, 'true');
filter = filter.replace(/"false"/g, 'false');
filter = filter.replace(/"0"/g, '0');
filter = filter.replace(/"1"/g, '1');
filter = filter.replace(/\+/g, ' && ');
return filter;
};
class UrlGenerator {
constructor(routingType, queue, resources, urls, position) {
this.routingType = routingType;
this.queue = queue;
this.urls = urls;
this.resources = resources;
this.uid = position;
debug('constructor', this.toString());
// CASE: channels can define custom filters, but not required.
if (this.routingType.getFilter()) {
this.filter = transformFilter(this.routingType.getFilter());
debug('filter', this.filter);
}
this._listeners();
}
_listeners() {
/**
* @NOTE: currently only used if the permalink setting changes and it's used for this url generator.
* @TODO: remove in Ghost 2.0
*/
this.routingType.addListener('updated', () => {
const myResources = this.urls.getByGeneratorId(this.uid);
myResources.forEach((object) => {
this.urls.removeResourceId(object.resource.data.id);
object.resource.release();
this._try(object.resource);
});
});
/**
* Listen on two events:
*
* - init: bootstrap or url reset
* - added: resource was added
*/
this.queue.register({
event: 'init',
tolerance: 100
}, this._onInit.bind(this));
// @TODO: listen on added event per type (post optimisation)
this.queue.register({
event: 'added'
}, this._onAdded.bind(this));
}
_onInit() {
debug('_onInit', this.toString());
// @NOTE: get the resources of my type e.g. posts.
const resources = this.resources.getAllByType(this.routingType.getType());
_.each(resources, (resource) => {
this._try(resource);
});
}
_onAdded(event) {
debug('onAdded', this.toString());
// CASE: you are type "pages", but the incoming type is "users"
if (event.type !== this.routingType.getType()) {
return Promise.resolve();
}
const resource = this.resources.getByIdAndType(event.type, event.id);
this._try(resource);
}
_try(resource) {
/**
* CASE: another url generator has taken this resource already.
*
* We have to remember that, because each url generator can generate a different url
* for a resource. So we can't directly check `this.urls.getUrl(url)`.
*/
if (resource.isReserved()) {
return false;
}
const url = this._generateUrl(resource);
// CASE 1: route has no custom filter, it will own the resource for sure
// CASE 2: find out if my filter matches the resource
if (!this.filter) {
this.urls.add({
url: url,
generatorId: this.uid,
resource: resource
});
resource.reserve();
this._resourceListeners(resource);
return true;
} else if (jsonpath.query(resource, this.filter).length) {
this.urls.add({
url: url,
generatorId: this.uid,
resource: resource
});
resource.reserve();
this._resourceListeners(resource);
return true;
} else {
return false;
}
}
_generateUrl(resource) {
const url = this.routingType.getPermalinks().getValue();
return this._replacePermalink(url, resource);
}
/**
* @TODO:
* This is a copy of `replacePermalink` of our url utility, see ./utils.
* But it has modifications, because the whole url utility doesn't work anymore.
* We will rewrite some of the functions in the utility.
*/
_replacePermalink(url, resource) {
var output = url,
primaryTagFallback = config.get('routeKeywords').primaryTagFallback,
publishedAtMoment = moment.tz(resource.data.published_at || Date.now(), settingsCache.get('active_timezone')),
permalink = {
year: function () {
return publishedAtMoment.format('YYYY');
},
month: function () {
return publishedAtMoment.format('MM');
},
day: function () {
return publishedAtMoment.format('DD');
},
author: function () {
return resource.data.primary_author.slug;
},
primary_author: function () {
return resource.data.primary_author ? resource.data.primary_author.slug : primaryTagFallback;
},
primary_tag: function () {
return resource.data.primary_tag ? resource.data.primary_tag.slug : primaryTagFallback;
},
slug: function () {
return resource.data.slug;
},
id: function () {
return resource.data.id;
}
};
// replace tags like :slug or :year with actual values
output = output.replace(/(:[a-z_]+)/g, function (match) {
if (_.has(permalink, match.substr(1))) {
return permalink[match.substr(1)]();
}
});
return output;
}
/**
* I want to know if my resources changes.
* Register events of resource.
*/
_resourceListeners(resource) {
const onUpdate = (updatedResource) => {
// 1. remove old resource
this.urls.removeResourceId(updatedResource.data.id);
// 2. free resource, the url <-> resource connection no longer exists
updatedResource.release();
// 3. try to own the resource again
// Imagine you change `featured` to true and your filter excludes featured posts.
const isMine = this._try(updatedResource);
// 4. if the resource is no longer mine, tell the others
// e.g. post -> page
// e.g. post is featured now
if (!isMine) {
debug('free, this is not mine anymore', updatedResource.data.id);
this.queue.start({
event: 'added',
action: 'added:' + resource.data.id,
eventData: {
id: resource.data.id,
type: this.routingType.getType()
}
});
}
};
const onRemoved = (removedResource) => {
this.urls.removeResourceId(removedResource.data.id);
removedResource.release();
};
resource.removeAllListeners();
resource.addListener('updated', onUpdate.bind(this));
resource.addListener('removed', onRemoved.bind(this));
}
getUrls() {
return this.urls.getByGeneratorId(this.uid);
}
toString() {
return this.routingType.toString();
}
}
module.exports = UrlGenerator;

View file

@ -1,144 +1,143 @@
'use strict';
/**
* # URL Service
*
* This file defines a class of URLService, which serves as a centralised place to handle
* generating, storing & fetching URLs of all kinds.
*/
const _ = require('lodash'),
Promise = require('bluebird'),
_debug = require('ghost-ignition').debug._base,
debug = _debug('ghost:services:url'),
const _debug = require('ghost-ignition').debug._base,
debug = _debug('ghost:services:url:service'),
_ = require('lodash'),
common = require('../../lib/common'),
// TODO: make this dynamic
resourceConfig = require('./config.json'),
Resource = require('./Resource'),
urlCache = require('./cache'),
UrlGenerator = require('./UrlGenerator'),
Queue = require('./Queue'),
Urls = require('./Urls'),
Resources = require('./Resources'),
localUtils = require('./utils');
class UrlService {
constructor(options) {
this.resources = [];
this.utils = localUtils;
options = options || {};
_.each(resourceConfig, (config) => {
this.resources.push(new Resource(config));
});
this.utils = localUtils;
// You can disable the url preload, in case we encounter a problem with the new url service.
if (options.disableUrlPreload) {
return;
}
this.bind();
this.finished = false;
this.urlGenerators = [];
// Hardcoded routes
// @TODO figure out how to do this from channel or other config
// @TODO get rid of name concept (for compat with sitemaps)
UrlService.cacheRoute('/', {name: 'home'});
this.urls = new Urls();
this.queue = new Queue();
this.resources = new Resources(this.queue);
// @TODO figure out how to do this from apps
// @TODO only do this if subscribe is enabled!
UrlService.cacheRoute('/subscribe/', {});
// Register a listener for server-start to load all the known urls
common.events.on('server.start', (() => {
debug('URL service, loading all URLS');
this.loadResourceUrls();
}));
this._listeners();
}
bind() {
const eventHandlers = {
add(model, resource) {
UrlService.cacheResourceItem(resource, model.toJSON());
},
update(model, resource) {
const newItem = model.toJSON();
const oldItem = model.updatedAttributes();
_listeners() {
/**
* The purpose of this event is to notify the url service as soon as a channel get's created.
*/
this._onRoutingTypeListener = this._onRoutingType.bind(this);
common.events.on('routingType.created', this._onRoutingTypeListener);
const oldUrl = resource.toUrl(oldItem);
const storedData = urlCache.get(oldUrl);
/**
* The queue will notify us if url generation has started/finished.
*/
this._onQueueStartedListener = this._onQueueStarted.bind(this);
this.queue.addListener('started', this._onQueueStartedListener);
const newUrl = resource.toUrl(newItem);
const newData = resource.toData(newItem);
this._onQueueEndedListener = this._onQueueEnded.bind(this);
this.queue.addListener('ended', this._onQueueEnded.bind(this));
debug('update', oldUrl, newUrl);
if (oldUrl && oldUrl !== newUrl && storedData) {
// CASE: we are updating a cached item and the URL has changed
debug('Changing URL, unset first');
urlCache.unset(oldUrl);
}
this._resetListener = this.reset.bind(this);
common.events.on('server.stop', this._resetListener);
}
// CASE: the URL is either new, or the same, this will create or update
urlCache.set(newUrl, newData);
},
_onQueueStarted(event) {
if (event === 'init') {
this.finished = false;
}
}
remove(model, resource) {
const url = resource.toUrl(model.toJSON());
urlCache.unset(url);
},
_onQueueEnded(event) {
if (event === 'init') {
this.finished = true;
}
}
reload(model, resource) {
// @TODO: get reload working, so that permalink changes are reflected
// NOTE: the current implementation of sitemaps doesn't have this
debug('Need to reload all resources: ' + resource.name);
_onRoutingType(routingType) {
debug('routingType.created');
let urlGenerator = new UrlGenerator(routingType, this.queue, this.resources, this.urls, this.urlGenerators.length);
this.urlGenerators.push(urlGenerator);
}
/**
* You have a url and want to know which the url belongs to.
* It's in theory possible that multiple resources generate the same url,
* but they both would serve different content e.g. static pages and collections.
*
* We only return the resource, which would be served.
*/
getResource(url) {
let objects = this.urls.getByUrl(url);
if (!objects.length) {
if (!this.hasFinished()) {
throw new common.errors.InternalServerError({
message: 'UrlService is processing.',
code: 'URLSERVICE_NOT_READY'
});
} else {
return null;
}
};
}
_.each(this.resources, (resource) => {
_.each(resource.events, (method, eventName) => {
common.events.on(eventName, (model) => {
eventHandlers[method].call(this, model, resource, eventName);
});
});
});
}
if (objects.length > 1) {
objects = _.reduce(objects, (toReturn, object) => {
if (!toReturn.length) {
toReturn.push(object);
} else {
const i1 = _.findIndex(this.urlGenerators, {uid: toReturn[0].generatorId});
const i2 = _.findIndex(this.urlGenerators, {uid: object.generatorId});
fetchAll() {
return Promise.each(this.resources, (resource) => {
return resource.fetchAll();
});
}
loadResourceUrls() {
debug('load start');
this.fetchAll()
.then(() => {
debug('load end, start processing');
_.each(this.resources, (resource) => {
_.each(resource.items, (item) => {
UrlService.cacheResourceItem(resource, item);
});
});
debug('processing done, url cache built. Number urls', _.size(urlCache.getAll()));
// Wrap this in a check, because else this is a HUGE amount of output
// To output this, use DEBUG=ghost:*,ghost-url
if (_debug.enabled('ghost-url')) {
debug('url-cache', require('util').inspect(urlCache.getAll(), false, null));
if (i2 < i1) {
toReturn = [];
toReturn.push(object);
}
}
})
.catch((err) => {
debug('load error', err);
});
}, []);
}
return objects[0].resource;
}
static cacheResourceItem(resource, item) {
const url = resource.toUrl(item);
const data = resource.toData(item);
urlCache.set(url, data);
hasFinished() {
return this.finished;
}
static cacheRoute(relativeUrl, data) {
const url = localUtils.urlFor({relativeUrl: relativeUrl});
data.static = true;
urlCache.set(url, data);
/**
* Get url by resource id.
*/
getUrl(id) {
const obj = this.urls.getByResourceId(id);
if (obj) {
return obj.url;
}
return null;
}
reset() {
this.urlGenerators = [];
this.urls.reset();
this.queue.reset();
this.resources.reset();
this._onQueueStartedListener && this.queue.removeListener('started', this._onQueueStartedListener);
this._onQueueEndedListener && this.queue.removeListener('ended', this._onQueueEndedListener);
this._onRoutingTypeListener && common.events.removeListener('routingType.created', this._onRoutingTypeListener);
this._resetListener && common.events.removeListener('server.stop', this._resetListener);
}
}

View file

@ -0,0 +1,102 @@
'use strict';
const _ = require('lodash');
const debug = require('ghost-ignition').debug('services:url:urls');
const common = require('../../lib/common');
/**
* Keeps track of all urls.
* Each resource has exactly one url.
*
* Connector for url generator and resources.
*/
class Urls {
constructor() {
this.urls = {};
}
add(options) {
const url = options.url;
const generatorId = options.generatorId;
const resource = options.resource;
debug('cache', url);
if (this.urls[resource.data.id]) {
common.logging.error(new common.errors.InternalServerError({
message: 'This should not happen.',
code: 'URLSERVICE_RESOURCE_DUPLICATE'
}));
this.removeResourceId(resource.data.id);
}
this.urls[resource.data.id] = {
url: url,
generatorId: generatorId,
resource: resource
};
common.events.emit('url.added', {
url: url,
resource: resource
});
}
getByResourceId(id) {
return this.urls[id];
}
/**
* Get all by `uid`.
*/
getByGeneratorId(generatorId) {
return _.reduce(Object.keys(this.urls), (toReturn, resourceId) => {
if (this.urls[resourceId].generatorId === generatorId) {
toReturn.push(this.urls[resourceId]);
}
return toReturn;
}, []);
}
/**
* @NOTE:
* It's is in theory possible that:
*
* - resource1 -> /welcome/
* - resource2 -> /welcome/
*
* But depending on the routing registration, you will always serve e.g. resource1.
*/
getByUrl(url) {
return _.reduce(Object.keys(this.urls), (toReturn, resourceId) => {
if (this.urls[resourceId].url === url) {
toReturn.push(this.urls[resourceId]);
}
return toReturn;
}, []);
}
removeResourceId(id) {
if (!this.urls[id]) {
return;
}
debug('removed', this.urls[id].url, this.urls[id].generatorId);
common.events.emit('url.removed', {
url: this.urls[id].url,
resource: this.urls[id].resource
});
delete this.urls[id];
}
reset() {
this.urls = {};
}
}
module.exports = Urls;

View file

@ -1,39 +0,0 @@
'use strict';
// Based heavily on the settings cache
const _ = require('lodash'),
debug = require('ghost-ignition').debug('services:url:cache'),
common = require('../../lib/common'),
urlCache = {};
module.exports = {
/**
* Get the entire cache object
* Uses clone to prevent modifications from being reflected
* @return {{}} urlCache
*/
getAll() {
return _.cloneDeep(urlCache);
},
set(key, value) {
const existing = this.get(key);
if (!existing) {
debug('adding url', key);
urlCache[key] = _.cloneDeep(value);
common.events.emit('url.added', key, value);
} else if (!_.isEqual(value, existing)) {
debug('overwriting url', key);
urlCache[key] = _.cloneDeep(value);
common.events.emit('url.edited', key, value);
}
},
unset(key) {
const value = this.get(key);
delete urlCache[key];
debug('removing url', key);
common.events.emit('url.removed', key, value);
},
get(key) {
return _.cloneDeep(urlCache[key]);
}
};

View file

@ -1,55 +0,0 @@
[
{
"name": "post",
"api" : "posts",
"prefetchOptions": {
"filter": "visibility:public+status:published+page:false",
"include": "author,tags"
},
"events": {
"post.published": "add",
"post.published.edited": "update",
"post.unpublished": "remove",
"settings.permalinks.edited": "reload"
}
},
{
"name": "page",
"api" : "posts",
"prefetchOptions": {
"filter": "visibility:public+status:published+page:true",
"include": "author,tags"
},
"urlLookup": "post",
"events": {
"page.published": "add",
"page.published.edited": "update",
"page.unpublished": "remove"
}
},
{
"name": "tag",
"api" : "tags",
"prefetchOptions": {
"filter": "visibility:public"
},
"events": {
"tag.added": "add",
"tag.edited": "update",
"tag.deleted": "remove"
}
},
{
"name": "author",
"api" : "users",
"prefetchOptions": {
"filter": "visibility:public"
},
"events": {
"user.activated": "add",
"user.activated.edited": "update",
"user.deactivated": "remove"
}
}
]

View file

@ -123,6 +123,9 @@ module.exports = function setupSiteApp() {
debug('General middleware done');
// @temporary
require('../../services/channels/Channels2');
// Set up Frontend routes (including private blogging routes)
siteApp.use(siteRoutes());

View file

@ -0,0 +1,247 @@
'use strict';
// jshint unused: false
const _ = require('lodash');
const Promise = require('bluebird');
const should = require('should');
const sinon = require('sinon');
const Queue = require('../../../../server/services/url/Queue');
const sandbox = sinon.sandbox.create();
describe('Unit: services/url/Queue', function () {
let queue;
beforeEach(function () {
queue = new Queue();
sandbox.spy(queue, 'run');
});
afterEach(function () {
sandbox.restore();
});
it('fn: register', function () {
queue.register({
event: 'chocolate'
}, null);
should.exist(queue.queue.chocolate);
queue.queue.chocolate.subscribers.length.should.eql(1);
queue.register({
event: 'chocolate'
}, null);
queue.queue.chocolate.subscribers.length.should.eql(2);
queue.register({
event: 'nachos'
}, null);
should.exist(queue.queue.chocolate);
should.exist(queue.queue.nachos);
queue.queue.chocolate.subscribers.length.should.eql(2);
queue.queue.nachos.subscribers.length.should.eql(1);
// events have not been triggered yet
queue.toNotify.should.eql({});
});
describe('fn: start (no tolerance)', function () {
it('no subscribers', function (done) {
queue.addListener('ended', function (event) {
event.should.eql('nachos');
queue.run.callCount.should.eql(1);
done();
});
queue.start({
event: 'nachos'
});
});
it('1 subscriber', function (done) {
let notified = 0;
queue.addListener('ended', function (event) {
event.should.eql('nachos');
queue.run.callCount.should.eql(2);
notified.should.eql(1);
done();
});
queue.register({
event: 'nachos'
}, function () {
notified = notified + 1;
});
queue.start({
event: 'nachos'
});
});
it('x subscriber', function (done) {
let notified = 0;
let order = [];
queue.addListener('ended', function (event) {
event.should.eql('nachos');
// 9 subscribers + start triggers run
queue.run.callCount.should.eql(10);
notified.should.eql(9);
order.should.eql([0, 1, 2, 3, 4, 5, 6, 7, 8]);
done();
});
_.each(_.range(9), function (i) {
queue.register({
event: 'nachos'
}, function () {
order.push(i);
notified = notified + 1;
});
});
queue.start({
event: 'nachos'
});
});
it('late subscriber', function (done) {
let notified = 0;
queue.addListener('ended', function (event) {
event.should.eql('nachos');
queue.run.callCount.should.eql(1);
notified.should.eql(0);
done();
});
queue.start({
event: 'nachos'
});
queue.register({
event: 'nachos'
}, function () {
notified = notified + 1;
});
});
it('subscriber throws error', function (done) {
let i = 0;
let notified = 0;
queue.addListener('ended', function (event) {
event.should.eql('nachos');
queue.run.callCount.should.eql(3);
notified.should.eql(1);
done();
});
queue.register({
event: 'nachos'
}, function () {
if (i === 0) {
i = i + 1;
throw new Error('oops');
}
notified = notified + 1;
});
queue.start({
event: 'nachos'
});
});
});
describe('fn: start (with tolerance)', function () {
it('late subscriber', function (done) {
let notified = 0;
queue.addListener('ended', function (event) {
event.should.eql('nachos');
notified.should.eql(1);
done();
});
queue.start({
event: 'nachos',
tolerance: 20,
timeoutInMS: 20
});
queue.register({
event: 'nachos',
tolerance: 20
}, function () {
notified = notified + 1;
});
});
it('start twice', function (done) {
let notified = 0;
let called = 0;
queue.addListener('ended', function (event) {
event.should.eql('nachos');
notified.should.eql(1);
called.should.eql(1);
done();
});
queue.start({
event: 'nachos',
tolerance: 20,
timeoutInMS: 20
});
queue.register({
event: 'nachos',
tolerance: 70
}, function () {
if (called !== 0) {
return done(new Error('Should only be triggered once.'));
}
called = called + 1;
notified = notified + 1;
});
queue.start({
event: 'nachos',
tolerance: 20,
timeoutInMS: 20
});
});
it('start twice', function (done) {
let notified = 0;
let called = 0;
queue.addListener('ended', function (event) {
event.should.eql('nachos');
notified.should.eql(0);
called.should.eql(0);
done();
});
queue.start({
event: 'nachos',
tolerance: 20,
timeoutInMS: 20
});
queue.start({
event: 'nachos',
tolerance: 20,
timeoutInMS: 20
});
});
});
});

View file

@ -0,0 +1,183 @@
'use strict';
// jshint unused: false
const should = require('should');
const _ = require('lodash');
const sinon = require('sinon');
const testUtils = require('../../../utils');
const models = require('../../../../server/models');
const common = require('../../../../server/lib/common');
const Resources = require('../../../../server/services/url/Resources');
const sandbox = sinon.sandbox.create();
describe('Unit: services/url/Resources', function () {
let knexMock, onEvents, emitEvents, resources, queue;
before(function () {
models.init();
});
beforeEach(function () {
knexMock = new testUtils.mocks.knex();
knexMock.mock();
onEvents = {};
emitEvents = {};
sandbox.stub(common.events, 'on').callsFake(function (eventName, callback) {
onEvents[eventName] = callback;
});
sandbox.stub(common.events, 'emit').callsFake(function (eventName, data) {
emitEvents[eventName] = data;
});
queue = {
start: sandbox.stub()
};
});
afterEach(function () {
sandbox.restore();
resources.reset();
knexMock.unmock();
});
it('db.ready', function (done) {
resources = new Resources(queue);
queue.start.callsFake(function (options) {
options.event.should.eql('init');
const created = resources.getAll();
created.posts.length.should.eql(4);
should.exist(created.posts[0].data.primary_author);
should.exist(created.posts[0].data.primary_tag);
should.exist(created.posts[1].data.primary_author);
should.exist(created.posts[1].data.primary_tag);
should.exist(created.posts[2].data.primary_author);
should.exist(created.posts[2].data.primary_tag);
should.exist(created.posts[3].data.primary_author);
should.not.exist(created.posts[3].data.primary_tag);
created.pages.length.should.eql(1);
// all mocked tags are public
created.tags.length.should.eql(testUtils.DataGenerator.forKnex.tags.length);
// all mocked users are active
created.users.length.should.eql(testUtils.DataGenerator.forKnex.users.length);
done();
});
onEvents['db.ready']();
});
it('add resource', function (done) {
resources = new Resources(queue);
queue.start.callsFake(function (options) {
options.event.should.eql('init');
queue.start.callsFake(function (options) {
options.event.should.eql('added');
should.exist(resources.getByIdAndType(options.eventData.type, options.eventData.id));
done();
});
models.Post.add({
title: 'test',
status: 'published'
}, testUtils.context.owner)
.then(function () {
onEvents['post.published'](emitEvents['post.published']);
})
.catch(done);
});
onEvents['db.ready']();
});
it('update taken resource', function (done) {
resources = new Resources(queue);
queue.start.callsFake(function (options) {
options.event.should.eql('init');
const randomResource = resources.getAll().posts[Math.floor(Math.random() * (resources.getAll().posts.length - 0) + 0)];
randomResource.reserve();
randomResource.addListener('updated', function () {
randomResource.data.title.should.eql('new title, wow');
done();
});
models.Post.edit({
title: 'new title, wow'
}, _.merge({id: randomResource.data.id}, testUtils.context.owner))
.then(function () {
onEvents['post.published.edited'](emitEvents['post.published.edited']);
})
.catch(done);
});
onEvents['db.ready']();
});
it('update free resource', function (done) {
resources = new Resources(queue);
queue.start.callsFake(function (options) {
options.event.should.eql('init');
const randomResource = resources.getAll().posts[Math.floor(Math.random() * (resources.getAll().posts.length - 0) + 0)];
randomResource.update = sandbox.stub();
queue.start.callsFake(function (options) {
options.event.should.eql('added');
randomResource.update.calledOnce.should.be.true();
done();
});
models.Post.edit({
title: 'new title, wow'
}, _.merge({id: randomResource.data.id}, testUtils.context.owner))
.then(function () {
onEvents['post.published.edited'](emitEvents['post.published.edited']);
})
.catch(done);
});
onEvents['db.ready']();
});
it('remove resource', function (done) {
resources = new Resources(queue);
queue.start.callsFake(function (options) {
options.event.should.eql('init');
const randomResource = resources.getAll().posts[Math.floor(Math.random() * (resources.getAll().posts.length - 0) + 0)];
randomResource.reserve();
randomResource.addListener('removed', function () {
should.not.exist(resources.getByIdAndType('posts', randomResource.data.id));
done();
});
models.Post.destroy(_.merge({id: randomResource.data.id}, testUtils.context.owner))
.then(function () {
onEvents['post.unpublished'](emitEvents['post.unpublished']);
})
.catch(done);
});
onEvents['db.ready']();
});
});

View file

@ -0,0 +1,391 @@
'use strict';
// jshint unused: false
const _ = require('lodash');
const Promise = require('bluebird');
const should = require('should');
const jsonpath = require('jsonpath');
const sinon = require('sinon');
const UrlGenerator = require('../../../../server/services/url/UrlGenerator');
const sandbox = sinon.sandbox.create();
describe('Unit: services/url/UrlGenerator', function () {
let queue, routingType, urls, resources, resource, resource2;
beforeEach(function () {
queue = {
register: sandbox.stub(),
start: sandbox.stub()
};
routingType = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub()
};
urls = {
add: sandbox.stub(),
getByUrl: sandbox.stub(),
removeResourceId: sandbox.stub(),
getByGeneratorId: sandbox.stub()
};
resources = {
getAllByType: sandbox.stub(),
getByIdAndType: sandbox.stub()
};
resource = {
reserve: sandbox.stub(),
release: sandbox.stub(),
isReserved: sandbox.stub(),
removeAllListeners: sandbox.stub(),
addListener: sandbox.stub()
};
resource2 = {
reserve: sandbox.stub(),
release: sandbox.stub(),
isReserved: sandbox.stub(),
removeAllListeners: sandbox.stub(),
addListener: sandbox.stub()
};
});
afterEach(function () {
sandbox.restore();
});
it('ensure listeners', function () {
const urlGenerator = new UrlGenerator(routingType, queue);
queue.register.calledTwice.should.be.true();
routingType.addListener.calledOnce.should.be.true();
should.not.exist(urlGenerator.filter);
});
it('routing type has filter', function () {
routingType.getFilter.returns('featured:true');
const urlGenerator = new UrlGenerator(routingType, queue);
urlGenerator.filter.should.eql('$[?(@.featured == true)]');
});
it('routing type has changed', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_try');
urls.getByGeneratorId.returns([
{
url: '/something/',
resource: resource
},
{
url: '/else/',
resource: resource2
}]);
resource.data = {
id: 'object-id-1'
};
resource2.data = {
id: 'object-id-1'
};
routingType.addListener.args[0][1]();
urls.removeResourceId.calledTwice.should.be.true();
resource.release.calledOnce.should.be.true();
resource2.release.calledOnce.should.be.true();
urlGenerator._try.calledTwice.should.be.true();
});
describe('fn: _onInit', function () {
it('1 resource', function () {
routingType.getType.returns('posts');
resources.getAllByType.withArgs('posts').returns([resource]);
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_try');
urlGenerator._onInit();
urlGenerator._try.calledOnce.should.be.true();
});
it('no resource', function () {
routingType.getType.returns('posts');
resources.getAllByType.withArgs('posts').returns([]);
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_try');
urlGenerator._onInit();
urlGenerator._try.called.should.be.false();
});
});
describe('fn: _onAdded', function () {
it('type is equal', function () {
routingType.getType.returns('posts');
resources.getByIdAndType.withArgs('posts', 1).returns(resource);
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_try');
urlGenerator._onAdded({id: 1, type: 'posts'});
urlGenerator._try.calledOnce.should.be.true();
});
it('type is not equal', function () {
routingType.getType.returns('pages');
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_try');
urlGenerator._onAdded({id: 1, type: 'posts'});
urlGenerator._try.called.should.be.false();
});
});
describe('fn: _try', function () {
describe('no filter', function () {
it('resource is not taken', function () {
routingType.getFilter.returns(false);
routingType.getType.returns('posts');
resource.isReserved.returns(false);
sandbox.stub(jsonpath, 'query');
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('something');
sandbox.stub(urlGenerator, '_resourceListeners');
urlGenerator._try(resource);
urlGenerator._generateUrl.calledOnce.should.be.true();
urlGenerator._resourceListeners.calledOnce.should.be.true();
urls.add.calledOnce.should.be.true();
resource.reserve.calledOnce.should.be.true();
jsonpath.query.called.should.be.false();
});
it('resource is taken', function () {
routingType.getFilter.returns(false);
routingType.getType.returns('posts');
resource.isReserved.returns(true);
sandbox.stub(jsonpath, 'query');
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('something');
sandbox.stub(urlGenerator, '_resourceListeners');
urlGenerator._try(resource);
urlGenerator._generateUrl.called.should.be.false();
urlGenerator._resourceListeners.called.should.be.false();
urls.add.called.should.be.false();
resource.reserve.called.should.be.false();
jsonpath.query.called.should.be.false();
});
});
describe('custom filter', function () {
it('matches', function () {
routingType.getFilter.returns('featured:true');
routingType.getType.returns('posts');
resource.isReserved.returns(false);
sandbox.stub(jsonpath, 'query').returns([true]);
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('something');
sandbox.stub(urlGenerator, '_resourceListeners');
urlGenerator._try(resource);
urlGenerator._generateUrl.calledOnce.should.be.true();
urlGenerator._resourceListeners.calledOnce.should.be.true();
urls.add.calledOnce.should.be.true();
resource.reserve.calledOnce.should.be.true();
jsonpath.query.calledOnce.should.be.true();
});
it('no match', function () {
routingType.getFilter.returns('featured:true');
routingType.getType.returns('posts');
resource.isReserved.returns(false);
sandbox.stub(jsonpath, 'query').returns([]);
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('something');
sandbox.stub(urlGenerator, '_resourceListeners');
urlGenerator._try(resource);
urlGenerator._generateUrl.calledOnce.should.be.true();
urlGenerator._resourceListeners.called.should.be.false();
urls.add.called.should.be.false();
resource.reserve.called.should.be.false();
jsonpath.query.calledOnce.should.be.true();
});
it('resource is taken', function () {
routingType.getFilter.returns('featured:true');
routingType.getType.returns('posts');
resource.isReserved.returns(true);
sandbox.stub(jsonpath, 'query').returns([]);
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('something');
sandbox.stub(urlGenerator, '_resourceListeners');
urlGenerator._try(resource);
urlGenerator._generateUrl.called.should.be.false();
urlGenerator._resourceListeners.called.should.be.false();
urls.add.called.should.be.false();
resource.reserve.called.should.be.false();
jsonpath.query.called.should.be.false();
});
});
});
describe('fn: _generateUrl', function () {
it('returns url', function () {
routingType.getPermalinks.returns({
getValue: function () {
return '/:slug/';
}
});
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_replacePermalink').returns('/url');
urlGenerator._generateUrl(resource).should.eql('/url');
urlGenerator._replacePermalink.calledWith('/:slug/', resource).should.be.true();
});
});
describe('fn: _replacePermalink', function () {
it('/:slug/', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
resource.data = {
slug: 'welcome'
};
urlGenerator._replacePermalink('/:slug/', resource).should.eql('/welcome/');
});
it('/:year/:slug/', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
resource.data = {
slug: 'welcome',
published_at: '2017-04-13T20:00:53.584Z'
};
urlGenerator._replacePermalink('/:year/:slug/', resource).should.eql('/2017/welcome/');
});
it('/:author/:slug/', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
resource.data = {
slug: 'welcome',
primary_author: {
slug: 'joe'
}
};
urlGenerator._replacePermalink('/:author/:slug/', resource).should.eql('/joe/welcome/');
});
it('/:primary_tag/:slug/', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
resource.data = {
slug: 'welcome',
primary_tag: {
slug: 'football'
}
};
urlGenerator._replacePermalink('/:primary_tag/:slug/', resource).should.eql('/football/welcome/');
});
});
describe('fn: _resourceListeners', function () {
it('ensure events', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
urlGenerator._resourceListeners(resource);
resource.removeAllListeners.calledOnce.should.be.true();
resource.addListener.calledTwice.should.be.true();
});
it('url has not changed', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('/welcome/');
sandbox.stub(urlGenerator, '_try').returns(true);
resource.data = {
id: 'object-id'
};
urlGenerator._resourceListeners(resource);
resource.addListener.args[0][1](resource);
urlGenerator._try.called.should.be.true();
urls.removeResourceId.called.should.be.true();
resource.release.called.should.be.true();
queue.start.called.should.be.false();
});
it('url has changed, but is still mine', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('/salute/');
sandbox.stub(urlGenerator, '_try').returns(true);
resource.data = {
id: 'object-id'
};
urlGenerator._resourceListeners(resource);
resource.addListener.args[0][1](resource);
urlGenerator._try.calledOnce.should.be.true();
urls.removeResourceId.calledOnce.should.be.true();
resource.release.calledOnce.should.be.true();
queue.start.called.should.be.false();
});
it('url has changed and is no longer mine (e.g. filter does not match anymore)', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
sandbox.stub(urlGenerator, '_generateUrl').returns('/salute/');
sandbox.stub(urlGenerator, '_try').returns(false);
urlGenerator._resourceListeners(resource);
resource.data = {
id: 'object-id'
};
resource.addListener.args[0][1](resource);
urlGenerator._try.calledOnce.should.be.true();
urls.removeResourceId.calledOnce.should.be.true();
resource.release.calledOnce.should.be.true();
queue.start.calledOnce.should.be.true();
});
it('resource got removed', function () {
const urlGenerator = new UrlGenerator(routingType, queue, resources, urls);
urlGenerator._resourceListeners(resource);
resource.data = {
id: 'object-id'
};
resource.addListener.args[1][1](resource);
urls.removeResourceId.calledOnce.should.be.true();
resource.release.calledOnce.should.be.true();
});
});
});

View file

@ -0,0 +1,544 @@
'use strict';
// jshint unused: false
const _ = require('lodash');
const Promise = require('bluebird');
const should = require('should');
const sinon = require('sinon');
const testUtils = require('../../../utils');
const models = require('../../../../server/models');
const common = require('../../../../server/lib/common');
const UrlService = require('../../../../server/services/url/UrlService');
const sandbox = sinon.sandbox.create();
describe('Unit: services/url/UrlService', function () {
let knexMock, urlService;
before(function () {
models.init();
// @NOTE: we auto create a singleton - as soon as you require the file, it will listen on events
require('../../../../server/services/url').reset();
});
beforeEach(function () {
knexMock = new testUtils.mocks.knex();
knexMock.mock();
});
afterEach(function () {
sandbox.restore();
});
afterEach(function () {
knexMock.unmock();
});
after(function () {
sandbox.restore();
});
describe('functional: default routing set', function () {
let routingType1, routingType2, routingType3, routingType4;
beforeEach(function (done) {
urlService = new UrlService();
routingType1 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'post collection';
}
};
routingType2 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'authors';
}
};
routingType3 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'tags';
}
};
routingType4 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'static pages';
}
};
routingType1.getFilter.returns('featured:false');
routingType1.getType.returns('posts');
routingType1.getPermalinks.returns({
getValue: function () {
return '/:slug/';
}
});
routingType2.getFilter.returns(false);
routingType2.getType.returns('users');
routingType2.getPermalinks.returns({
getValue: function () {
return '/author/:slug/';
}
});
routingType3.getFilter.returns(false);
routingType3.getType.returns('tags');
routingType3.getPermalinks.returns({
getValue: function () {
return '/tag/:slug/';
}
});
routingType4.getFilter.returns(false);
routingType4.getType.returns('pages');
routingType4.getPermalinks.returns({
getValue: function () {
return '/:slug/';
}
});
common.events.emit('routingType.created', routingType1);
common.events.emit('routingType.created', routingType2);
common.events.emit('routingType.created', routingType3);
common.events.emit('routingType.created', routingType4);
common.events.emit('db.ready');
let timeout;
(function retry() {
clearTimeout(timeout);
if (urlService.hasFinished()) {
return done();
}
setTimeout(retry, 50);
})();
});
afterEach(function () {
urlService.reset();
});
it('check url generators', function () {
urlService.urlGenerators.length.should.eql(4);
urlService.urlGenerators[0].routingType.should.eql(routingType1);
urlService.urlGenerators[1].routingType.should.eql(routingType2);
urlService.urlGenerators[2].routingType.should.eql(routingType3);
urlService.urlGenerators[3].routingType.should.eql(routingType4);
});
it('getUrl', function () {
urlService.urlGenerators.forEach(function (generator) {
if (generator.routingType.getType() === 'posts') {
generator.getUrls().length.should.eql(2);
}
if (generator.routingType.getType() === 'pages') {
generator.getUrls().length.should.eql(1);
}
if (generator.routingType.getType() === 'tags') {
generator.getUrls().length.should.eql(5);
}
if (generator.routingType.getType() === 'users') {
generator.getUrls().length.should.eql(5);
}
});
let url = urlService.getUrl(testUtils.DataGenerator.forKnex.posts[0].id);
url.should.eql('/html-ipsum/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.posts[1].id);
url.should.eql('/ghostly-kitchen-sink/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.posts[2].id);
should.not.exist(url);
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[0].id);
url.should.eql('/tag/kitchen-sink/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[1].id);
url.should.eql('/tag/bacon/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[2].id);
url.should.eql('/tag/chorizo/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[3].id);
url.should.eql('/tag/pollo/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[4].id);
url.should.eql('/tag/injection/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[0].id);
url.should.eql('/author/joe-bloggs/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[1].id);
url.should.eql('/author/smith-wellingsworth/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[2].id);
url.should.eql('/author/jimothy-bogendath/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[3].id);
url.should.eql('/author/slimer-mcectoplasm/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[4].id);
url.should.eql('/author/contributor/');
});
it('getResource', function () {
let resource = urlService.getResource('/html-ipsum/');
resource.data.id.should.eql(testUtils.DataGenerator.forKnex.posts[0].id);
resource = urlService.getResource('/does-not-exist/');
should.not.exist(resource);
});
describe('update resource', function () {
it('featured: false => featured:true', function () {
return models.Post.edit({featured: true}, {id: testUtils.DataGenerator.forKnex.posts[1].id})
.then(function (post) {
// There is no collection which owns featured posts.
let url = urlService.getUrl(post.id);
should.not.exist(url);
urlService.urlGenerators.forEach(function (generator) {
if (generator.routingType.getType() === 'posts') {
generator.getUrls().length.should.eql(1);
}
if (generator.routingType.getType() === 'pages') {
generator.getUrls().length.should.eql(1);
}
});
});
});
it('page: false => page:true', function () {
return models.Post.edit({page: true}, {id: testUtils.DataGenerator.forKnex.posts[1].id})
.then(function (post) {
let url = urlService.getUrl(post.id);
url.should.eql('/ghostly-kitchen-sink/');
urlService.urlGenerators.forEach(function (generator) {
if (generator.routingType.getType() === 'posts') {
generator.getUrls().length.should.eql(1);
}
if (generator.routingType.getType() === 'pages') {
generator.getUrls().length.should.eql(2);
}
});
});
});
it('page: true => page:false', function () {
return models.Post.edit({page: false}, {id: testUtils.DataGenerator.forKnex.posts[5].id})
.then(function (post) {
let url = urlService.getUrl(post.id);
url.should.eql('/static-page-test/');
urlService.urlGenerators.forEach(function (generator) {
if (generator.routingType.getType() === 'posts') {
generator.getUrls().length.should.eql(3);
}
if (generator.routingType.getType() === 'pages') {
generator.getUrls().length.should.eql(0);
}
});
});
});
});
describe('add new resource', function () {
it('already published', function () {
return models.Post.add({
featured: false,
page: false,
status: 'published',
title: 'Brand New Story!',
author_id: testUtils.DataGenerator.forKnex.users[4].id
}).then(function (post) {
let url = urlService.getUrl(post.id);
url.should.eql('/brand-new-story/');
let resource = urlService.getResource(url);
resource.data.primary_author.id.should.eql(testUtils.DataGenerator.forKnex.users[4].id);
});
});
it('draft', function () {
return models.Post.add({
featured: false,
page: false,
status: 'draft',
title: 'Brand New Story!',
author_id: testUtils.DataGenerator.forKnex.users[4].id
}).then(function (post) {
let url = urlService.getUrl(post.id);
should.not.exist(url);
let resource = urlService.getResource(url);
should.not.exist(resource);
});
});
});
});
describe('functional: extended/modified routing set', function () {
let routingType1, routingType2, routingType3, routingType4, routingType5;
beforeEach(function (done) {
urlService = new UrlService();
routingType1 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'post collection 1';
}
};
routingType2 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'post collection 2';
}
};
routingType3 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'authors';
}
};
routingType4 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'tags';
}
};
routingType5 = {
getFilter: sandbox.stub(),
addListener: sandbox.stub(),
getType: sandbox.stub(),
getPermalinks: sandbox.stub(),
toString: function () {
return 'static pages';
}
};
routingType1.getFilter.returns('featured:false');
routingType1.getType.returns('posts');
routingType1.getPermalinks.returns({
getValue: function () {
return '/collection/:year/:slug/';
}
});
routingType2.getFilter.returns('featured:true');
routingType2.getType.returns('posts');
routingType2.getPermalinks.returns({
getValue: function () {
return '/podcast/:slug/';
}
});
routingType3.getFilter.returns(false);
routingType3.getType.returns('users');
routingType3.getPermalinks.returns({
getValue: function () {
return '/persons/:slug/';
}
});
routingType4.getFilter.returns(false);
routingType4.getType.returns('tags');
routingType4.getPermalinks.returns({
getValue: function () {
return '/category/:slug/';
}
});
routingType5.getFilter.returns(false);
routingType5.getType.returns('pages');
routingType5.getPermalinks.returns({
getValue: function () {
return '/:slug/';
}
});
common.events.emit('routingType.created', routingType1);
common.events.emit('routingType.created', routingType2);
common.events.emit('routingType.created', routingType3);
common.events.emit('routingType.created', routingType4);
common.events.emit('routingType.created', routingType5);
common.events.emit('db.ready');
let timeout;
(function retry() {
clearTimeout(timeout);
if (urlService.hasFinished()) {
return done();
}
setTimeout(retry, 50);
})();
});
afterEach(function () {
urlService.reset();
});
it('check url generators', function () {
urlService.urlGenerators.length.should.eql(5);
urlService.urlGenerators[0].routingType.should.eql(routingType1);
urlService.urlGenerators[1].routingType.should.eql(routingType2);
urlService.urlGenerators[2].routingType.should.eql(routingType3);
urlService.urlGenerators[3].routingType.should.eql(routingType4);
urlService.urlGenerators[4].routingType.should.eql(routingType5);
});
it('getUrl', function () {
urlService.urlGenerators.forEach(function (generator) {
if (generator.routingType.getType() === 'posts' && generator.routingType.getFilter() === 'featured:false') {
generator.getUrls().length.should.eql(2);
}
if (generator.routingType.getType() === 'posts' && generator.routingType.getFilter() === 'featured:true') {
generator.getUrls().length.should.eql(2);
}
if (generator.routingType.getType() === 'pages') {
generator.getUrls().length.should.eql(1);
}
if (generator.routingType.getType() === 'tags') {
generator.getUrls().length.should.eql(5);
}
if (generator.routingType.getType() === 'users') {
generator.getUrls().length.should.eql(5);
}
});
let url = urlService.getUrl(testUtils.DataGenerator.forKnex.posts[0].id);
url.should.eql('/collection/2015/html-ipsum/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.posts[1].id);
url.should.eql('/collection/2015/ghostly-kitchen-sink/');
// featured
url = urlService.getUrl(testUtils.DataGenerator.forKnex.posts[2].id);
url.should.eql('/podcast/short-and-sweet/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[0].id);
url.should.eql('/category/kitchen-sink/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[1].id);
url.should.eql('/category/bacon/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[2].id);
url.should.eql('/category/chorizo/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[3].id);
url.should.eql('/category/pollo/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.tags[4].id);
url.should.eql('/category/injection/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[0].id);
url.should.eql('/persons/joe-bloggs/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[1].id);
url.should.eql('/persons/smith-wellingsworth/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[2].id);
url.should.eql('/persons/jimothy-bogendath/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[3].id);
url.should.eql('/persons/slimer-mcectoplasm/');
url = urlService.getUrl(testUtils.DataGenerator.forKnex.users[4].id);
url.should.eql('/persons/contributor/');
});
describe('update resource', function () {
it('featured: false => featured:true', function () {
return models.Post.edit({featured: true}, {id: testUtils.DataGenerator.forKnex.posts[1].id})
.then(function (post) {
// There is no collection which owns featured posts.
let url = urlService.getUrl(post.id);
url.should.eql('/podcast/ghostly-kitchen-sink/');
urlService.urlGenerators.forEach(function (generator) {
if (generator.routingType.getType() === 'posts' && generator.routingType.getFilter() === 'featured:false') {
generator.getUrls().length.should.eql(1);
}
if (generator.routingType.getType() === 'posts' && generator.routingType.getFilter() === 'featured:true') {
generator.getUrls().length.should.eql(3);
}
});
});
});
it('featured: true => featured:false', function () {
return models.Post.edit({featured: false}, {id: testUtils.DataGenerator.forKnex.posts[2].id})
.then(function (post) {
// There is no collection which owns featured posts.
let url = urlService.getUrl(post.id);
url.should.eql('/collection/2015/short-and-sweet/');
urlService.urlGenerators.forEach(function (generator) {
if (generator.routingType.getType() === 'posts' && generator.routingType.getFilter() === 'featured:false') {
generator.getUrls().length.should.eql(3);
}
if (generator.routingType.getType() === 'posts' && generator.routingType.getFilter() === 'featured:true') {
generator.getUrls().length.should.eql(1);
}
});
});
});
});
});
});

View file

@ -0,0 +1,109 @@
'use strict';
// jshint unused: false
const _ = require('lodash');
const Promise = require('bluebird');
const should = require('should');
const jsonpath = require('jsonpath');
const sinon = require('sinon');
const common = require('../../../../server/lib/common');
const Urls = require('../../../../server/services/url/Urls');
const sandbox = sinon.sandbox.create();
describe('Unit: services/url/Urls', function () {
let urls, eventsToRemember;
beforeEach(function () {
urls = new Urls();
urls.add({
url: '/test/',
resource: {
data: {
id: 'object-id-1'
}
},
generatorId: 2
});
urls.add({
url: '/something/',
resource: {
data: {
id: 'object-id-2'
}
},
generatorId: 1
});
urls.add({
url: '/casper/',
resource: {
data: {
id: 'object-id-3'
}
},
generatorId: 2
});
eventsToRemember = {};
sandbox.stub(common.events, 'emit').callsFake(function (eventName, callback) {
eventsToRemember[eventName] = callback;
});
});
afterEach(function () {
sandbox.restore();
});
it('fn: add', function () {
urls.add({
url: '/test/',
resource: {
data: {
id: 'object-id-x',
slug: 'a'
}
},
generatorId: 1
});
should.exist(eventsToRemember['url.added']);
urls.getByResourceId('object-id-x').resource.data.slug.should.eql('a');
// add duplicate
urls.add({
url: '/test/',
resource: {
data: {
id: 'object-id-x',
slug: 'b'
}
},
generatorId: 1
});
should.exist(eventsToRemember['url.added']);
urls.getByResourceId('object-id-x').resource.data.slug.should.eql('b');
});
it('fn: getByResourceId', function () {
urls.getByResourceId('object-id-2').url.should.eql('/something/');
});
it('fn: getByGeneratorId', function () {
urls.getByGeneratorId(2).length.should.eql(2);
});
it('fn: getByUrl', function () {
urls.getByUrl('/something/').length.should.eql(1);
});
it('fn: removeResourceId', function () {
urls.removeResourceId('object-id-2');
should.not.exist(urls.getByResourceId('object-id-2'));
urls.removeResourceId('does not exist');
});
});