0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-24 23:48:13 -05:00

Added initial basic Mentions implementation

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

This extends the mock API to use a more formal pattern of moving our
entity code into a separate package, and use the service/repository
patterns we've been work toward.

The repository is currently in memory, this allows us to start using
the API without having to make commitments to the database structure.

We've also injected a single fake webmention for testing. I'd expect
the Mention object to change a lot from this initial definition as we
gain more information about the type of data we expect to see.
This commit is contained in:
Fabien "egg" O'Carroll 2023-01-17 14:55:53 +07:00
parent 3d79d10ddf
commit 1babf6126a
12 changed files with 776 additions and 20 deletions

View file

@ -15,5 +15,6 @@ module.exports = {
tags: require('./tags'),
offers: require('./offers'),
newsletters: require('./newsletters'),
users: require('./users')
users: require('./users'),
mentions: require('./mentions')
};

View file

@ -0,0 +1,20 @@
const utils = require('../../../index');
module.exports = (model, frame) => {
const json = model.toJSON();
console.log(json);
return {
id: json.id,
source: json.source,
target: json.target,
timestamp: json.timestamp,
payload: json.payload,
resource_id: json.resourceId,
source_title: json.sourceTitle,
source_excerpt: json.sourceExcerpt,
source_favicon: json.sourceFavicon,
source_featured_image: json.sourceFeauredImage
};
};

View file

@ -1,24 +1,45 @@
/**
* @typedef {import('@tryghost/webmentions/lib/webmentions').MentionsAPI} MentionsAPI
* @typedef {import('@tryghost/webmentions/lib/webmentions').Mention} Mention
*/
/**
* @template Model
* @typedef {import('@tryghost/webmentions/lib/MentionsAPI').Page} Page<Model>
*/
module.exports = class MentionController {
async init() {}
/** @type {import('@tryghost/webmentions/lib/MentionsAPI')} */
#api;
async init(deps) {
this.#api = deps.api;
}
/**
* @param {import('@tryghost/api-framework').Frame} frame
* @returns {Promise<Page<Mention>>}
*/
async browse(/*frame*/) {
return {
data: [{
thing: true
}],
meta: {
pagination: {
page: 1,
limit: 'all',
pages: 1,
total: 1,
next: null,
prev: null
}
}
};
async browse(frame) {
let limit;
if (!frame.options.limit || frame.options.limit === 'all') {
limit = 'all';
} else {
limit = parseInt(frame.options.limit);
}
let page;
if (frame.options.page) {
page = parseInt(frame.options.page);
} else {
page = 1;
}
const results = await this.#api.listMentions({
filter: frame.options.filter,
limit,
page
});
return results;
}
};

View file

@ -1,8 +1,25 @@
const MentionController = require('./MentionController');
const {
InMemoryMentionRepository,
MentionsAPI
} = require('@tryghost/webmentions');
module.exports = {
controller: new MentionController(),
async init() {
this.controller.init();
const repository = new InMemoryMentionRepository();
const api = new MentionsAPI({
repository
});
api.processWebmention({
source: new URL('https://egg.com/post'),
target: new URL('https://ronald.com/pizza'),
payload: {
extra: 'data'
}
});
this.controller.init({api});
}
};

View file

@ -137,6 +137,7 @@
"@tryghost/validator": "0.1.31",
"@tryghost/verification-trigger": "0.0.0",
"@tryghost/version": "0.1.19",
"@tryghost/webmentions": "0.0.0",
"@tryghost/zip": "1.1.31",
"amperize": "0.6.1",
"analytics-node": "6.2.0",

View file

@ -0,0 +1,108 @@
const nql = require('@tryghost/nql');
/**
* @typedef {import('./Mention')} Mention
* @typedef {import('./MentionsAPI').GetPageOptions} GetPageOptions
* @typedef {import('./MentionsAPI').IMentionRepository} IMentionRepository
*/
/**
* @template Model
* @typedef {import('./MentionsAPI').Page<Model>} Page<Model>
*/
/**
* @implements {IMentionRepository}
*/
module.exports = class InMemoryMentionRepository {
/** @type {Mention[]} */
#store = [];
/** @type {Object.<string, true>} */
#ids = {};
/**
* @param {Mention} mention
* @returns {any}
*/
toPrimitive(mention) {
return {
...mention.toJSON(),
id: mention.id.toHexString(),
resource_id: mention.resourceId ? mention.resourceId.toHexString() : null
};
}
/**
* @param {Mention} mention
* @returns {Promise<void>}
*/
async save(mention) {
if (this.#ids[mention.id.toHexString()]) {
const existing = this.#store.findIndex((item) => {
return item.id.equals(mention.id);
});
this.#store.splice(existing, 1, mention);
} else {
this.#store.push(mention);
this.#ids[mention.id.toHexString()] = true;
}
}
/**
* @param {URL} source
* @param {URL} target
* @returns {Promise<Mention>}
*/
async getBySourceAndTarget(source, target) {
return this.#store.find((item) => {
return item.source.href === source.href && item.target.href === target.href;
});
}
/**
* @param {object} options
* @param {string} [options.filter]
* @param {number | null} options.page
* @param {number | 'all'} options.limit
* @returns {Promise<Page<Mention>>}
*/
async getPage(options) {
const filter = nql(options.filter || '', {});
const results = this.#store.slice().filter((item) => {
return filter.queryJSON(this.toPrimitive(item));
});
if (options.limit === 'all') {
return {
data: results,
meta: {
pagination: {
page: 1,
pages: 1,
limit: 'all',
total: results.length,
prev: null,
next: null
}
}
};
}
const start = (options.page - 1) * options.limit;
const end = start + options.limit;
const pages = Math.ceil(results.length / options.limit);
return {
data: results.slice(start, end),
meta: {
pagination: {
page: options.page,
pages: pages,
limit: options.limit,
total: results.length,
prev: options.page === 1 ? null : options.page - 1,
next: options.page === pages ? null : options.page + 1
}
}
};
}
};

View file

@ -0,0 +1,204 @@
const ObjectID = require('bson-objectid').default;
const {ValidationError} = require('@tryghost/errors');
module.exports = class Mention {
/** @type {ObjectID} */
#id;
get id() {
return this.#id;
}
/** @type {URL} */
#source;
get source() {
return this.#source;
}
/** @type {URL} */
#target;
get target() {
return this.#target;
}
/** @type {Date} */
#timestamp;
get timestamp() {
return this.#timestamp;
}
/** @type {Object<string, any> | null} */
#payload;
get payload() {
return this.#payload;
}
/** @type {ObjectID | null} */
#resourceId;
get resourceId() {
return this.#resourceId;
}
/** @type {string} */
#sourceTitle;
get sourceTitle() {
return this.#sourceTitle;
}
/** @type {string} */
#sourceExcerpt;
get sourceExcerpt() {
return this.#sourceExcerpt;
}
/** @type {URL | null} */
#sourceFavicon;
get sourceFavicon() {
return this.#sourceFavicon;
}
/** @type {URL | null} */
#sourceFeaturedImage;
get sourceFeaturedImage() {
return this.#sourceFeaturedImage;
}
toJSON() {
return {
id: this.id,
source: this.source,
target: this.target,
timestamp: this.timestamp,
payload: this.payload,
resourceId: this.resourceId,
sourceTitle: this.sourceTitle,
sourceExcerpt: this.sourceExcerpt,
sourceFavicon: this.sourceFavicon,
sourceFeaturedImage: this.sourceFeaturedImage
};
}
/** @private */
constructor(data) {
this.#id = data.id;
this.#source = data.source;
this.#target = data.target;
this.#timestamp = data.timestamp;
this.#payload = data.payload;
this.#resourceId = data.resourceId;
this.#sourceTitle = data.sourceTitle;
this.#sourceExcerpt = data.sourceExcerpt;
this.#sourceFavicon = data.sourceFavicon;
this.#sourceFeaturedImage = data.sourceFeaturedImage;
}
/**
* @param {any} data
* @returns {Promise<Mention>}
*/
static async create(data) {
let id;
if (!data.id) {
id = new ObjectID();
} else if (typeof data.id === 'string') {
id = ObjectID.createFromHexString(data.id);
} else if (data.id instanceof ObjectID) {
id = data.id;
} else {
throw new ValidationError({
message: 'Invalid ID provided for Mention'
});
}
let source;
if (data.source instanceof URL) {
source = data.source;
} else {
source = new URL(data.source);
}
let target;
if (data.target instanceof URL) {
target = data.target;
} else {
target = new URL(data.target);
}
let timestamp;
if (data.timestamp instanceof Date) {
timestamp = data.timestamp;
} else if (data.timestamp) {
timestamp = new Date(data.timestamp);
if (isNaN(timestamp.valueOf())) {
throw new ValidationError({
message: 'Invalid Date'
});
}
} else {
timestamp = new Date();
}
let payload;
payload = data.payload ? JSON.parse(JSON.stringify(data.payload)) : null;
let resourceId = null;
if (data.resourceId) {
if (data.resourceId instanceof ObjectID) {
resourceId = data.resourceId;
} else {
resourceId = ObjectID.createFromHexString(data.resourceId);
}
}
const sourceTitle = validateString(data.sourceTitle, 191, 'sourceTitle');
const sourceExcerpt = validateString(data.sourceExcerpt, 1000, 'sourceExcerpt');
let sourceFavicon = null;
if (data.sourceFavicon instanceof URL) {
sourceFavicon = data.sourceFavicon;
} else if (data.sourceFavicon) {
sourceFavicon = new URL(data.sourceFavicon);
}
let sourceFeaturedImage = null;
if (data.sourceFeaturedImage instanceof URL) {
sourceFeaturedImage = data.sourceFeaturedImage;
} else if (data.sourceFeaturedImage) {
sourceFeaturedImage = new URL(data.sourceFeaturedImage);
}
return new Mention({
id,
source,
target,
timestamp,
payload,
resourceId,
sourceTitle,
sourceExcerpt,
sourceFavicon,
sourceFeaturedImage
});
}
};
function validateString(value, maxlength, name) {
if (!value) {
throw new ValidationError({
message: `Missing ${name} for Mention`
});
}
if (typeof value !== 'string') {
throw new ValidationError({
message: `${name} must be a string`
});
}
if (value.length > maxlength) {
throw new ValidationError({
message: `${name} must be less than ${maxlength + 1} characters`
});
}
return value;
}

View file

@ -0,0 +1,110 @@
const Mention = require('./Mention');
/**
* @template Model
* @typedef {object} Page<Model>
* @prop {Model[]} data
* @prop {object} meta
* @prop {object} meta.pagination
* @prop {number} meta.pagination.page - The current page
* @prop {number} meta.pagination.pages - The total number of pages
* @prop {number | 'all'} meta.pagination.limit - The limit of models per page
* @prop {number} meta.pagination.total - The total number of models across all pages
* @prop {number|null} meta.pagination.prev - The number of the previous page, or null if there isn't one
* @prop {number|null} meta.pagination.next - The number of the next page, or null if there isn't one
*/
/**
* @typedef {object} PaginatedOptions
* @prop {string} [filter] A valid NQL string
* @prop {number} page
* @prop {number} limit
*/
/**
* @typedef {object} NonPaginatedOptions
* @prop {string} [filter] A valid NQL string
* @prop {'all'} limit
*/
/**
* @typedef {PaginatedOptions | NonPaginatedOptions} GetPageOptions
*/
/**
* @typedef {object} IMentionRepository
* @prop {(mention: Mention) => Promise<void>} save
* @prop {(options: GetPageOptions) => Promise<Page<Mention>>} getPage
* @prop {(source: URL, target: URL) => Promise<Mention>} getBySourceAndTarget
*/
module.exports = class MentionsAPI {
/** @type {IMentionRepository} */
#repository;
constructor(deps) {
this.#repository = deps.repository;
}
/**
* @param {object} options
* @returns {Promise<Page<Mention>>}
*/
async listMentions(options) {
/** @type {GetPageOptions} */
let pageOptions;
if (options.limit === 'all') {
pageOptions = {
filter: options.filter,
limit: options.limit
};
} else {
pageOptions = {
filter: options.filter,
limit: options.limit,
page: options.page
};
}
const page = await this.#repository.getPage(pageOptions);
return page;
}
/**
* @param {object} webmention
* @param {URL} webmention.source
* @param {URL} webmention.target
* @param {Object<string, any>} webmention.payload
*
* @returns {Promise<Mention>}
*/
async processWebmention(webmention) {
const existing = await this.#repository.getBySourceAndTarget(
webmention.source,
webmention.target
);
if (existing) {
await this.#repository.save(existing);
return existing;
}
const mention = await Mention.create({
source: webmention.source,
target: webmention.target,
timestamp: new Date(),
payload: webmention.payload,
resourceId: null,
sourceTitle: 'Fake title',
sourceExcerpt: 'Wow, what an awesome article, blah blah blah',
sourceFavicon: null,
sourceFeaturedImage: null
});
await this.#repository.save(mention);
return mention;
}
};

View file

@ -1 +1,3 @@
// webmentions.js
module.exports.InMemoryMentionRepository = require('./InMemoryMentionRepository');
module.exports.MentionsAPI = require('./MentionsAPI');
module.exports.Mention = require('./Mention');

View file

@ -0,0 +1,79 @@
const assert = require('assert');
const ObjectID = require('bson-objectid');
const InMemoryMentionRepository = require('../lib/InMemoryMentionRepository');
const Mention = require('../lib/Mention');
describe('InMemoryMentionRepository', function () {
it('Can handle filtering on resourceId', async function () {
const resourceId = new ObjectID();
const repository = new InMemoryMentionRepository();
const validInput = {
source: 'https://source.com',
target: 'https://target.com',
sourceTitle: 'Title!',
sourceExcerpt: 'Excerpt!'
};
const mentions = await Promise.all([
Mention.create(validInput),
Mention.create({
...validInput,
resourceId
}),
Mention.create({
...validInput,
resourceId
}),
Mention.create(validInput),
Mention.create({
...validInput,
resourceId
}),
Mention.create({
...validInput,
resourceId
}),
Mention.create(validInput),
Mention.create({
...validInput,
resourceId
}),
Mention.create(validInput)
]);
for (const mention of mentions) {
await repository.save(mention);
}
const pageOne = await repository.getPage({
filter: `resource_id:${resourceId.toHexString()}`,
limit: 2,
page: 1
});
assert(pageOne.meta.pagination.total === 5);
assert(pageOne.meta.pagination.pages === 3);
assert(pageOne.meta.pagination.prev === null);
assert(pageOne.meta.pagination.next === 2);
const pageTwo = await repository.getPage({
filter: `resource_id:${resourceId.toHexString()}`,
limit: 2,
page: 2
});
assert(pageTwo.meta.pagination.total === 5);
assert(pageTwo.meta.pagination.pages === 3);
assert(pageTwo.meta.pagination.prev === 1);
assert(pageTwo.meta.pagination.next === 3);
const pageThree = await repository.getPage({
filter: `resource_id:${resourceId.toHexString()}`,
limit: 2,
page: 3
});
assert(pageThree.meta.pagination.total === 5);
assert(pageThree.meta.pagination.pages === 3);
assert(pageThree.meta.pagination.prev === 2);
assert(pageThree.meta.pagination.next === null);
});
});

View file

@ -0,0 +1,92 @@
const assert = require('assert');
const ObjectID = require('bson-objectid');
const Mention = require('../lib/Mention');
const validInput = {
source: 'https://source.com',
target: 'https://target.com',
sourceTitle: 'Title!',
sourceExcerpt: 'Excerpt!'
};
describe('Mention', function () {
describe('toJSON', function () {
it('Returns a object with the expected properties', async function () {
const mention = await Mention.create(validInput);
const actual = Object.keys(mention.toJSON());
const expected = [
'id',
'source',
'target',
'timestamp',
'payload',
'resourceId',
'sourceTitle',
'sourceExcerpt',
'sourceFavicon',
'sourceFeaturedImage'
];
assert.deepEqual(actual, expected);
});
});
describe('create', function () {
it('Will error with invalid inputs', async function () {
const invalidInputs = [
{id: 'Not valid ID'},
{id: 123},
{source: 'Not a valid source'},
{target: 'Not a valid target'},
{timestamp: 'Not a valid timestamp'},
{resourceId: 'Invalid resourceId'},
{sourceTitle: null},
{sourceTitle: 123},
{sourceTitle: Array.from({length: 200}).join('A')},
{sourceExcerpt: null},
{sourceExcerpt: 123},
{sourceExcerpt: Array.from({length: 3000}).join('A')},
{sourceFavicon: 'Invalid source favicon'},
{sourceFeaturedImage: 'Invalid source featured image'}
];
for (const invalidInput of invalidInputs) {
let errored = false;
try {
await Mention.create({
...validInput,
...invalidInput
});
} catch (err) {
errored = true;
} finally {
if (!errored) {
assert.fail(`Should have errored with invalid input ${JSON.stringify(invalidInput)}`);
}
}
}
});
it('Will not error with valid inputs', async function () {
const validInputs = [
{id: new ObjectID()},
{source: new URL('https://source.com/')},
{target: new URL('https://target.com/')},
{timestamp: new Date()},
{timestamp: '2023-01-01T00:00:00Z'},
{payload: {extra: 'shit'}},
{resourceId: new ObjectID()},
{sourceFavicon: 'https://source.com/favicon.ico'},
{sourceFavicon: new URL('https://source.com/favicon.ico')},
{sourceFeaturedImage: 'https://source.com/assets/image.jpg'},
{sourceFeaturedImage: new URL('https://source.com/assets/image.jpg')}
];
for (const localValidInput of validInputs) {
await Mention.create({
...validInput,
...localValidInput
});
}
});
});
});

View file

@ -0,0 +1,101 @@
const assert = require('assert');
const Mention = require('../lib/Mention');
const MentionsAPI = require('../lib/MentionsAPI');
const InMemoryMentionRepository = require('../lib/InMemoryMentionRepository');
describe('MentionsAPI', function () {
it('Can list paginated mentions', async function () {
const repository = new InMemoryMentionRepository();
const api = new MentionsAPI({repository});
const mention = await api.processWebmention({
source: new URL('https://source.com'),
target: new URL('https://target.com'),
payload: {}
});
assert(mention instanceof Mention);
const page = await api.listMentions({
limit: 1,
page: 1
});
assert.equal(page.data[0].id, mention.id);
});
it('Can list all mentions', async function () {
const repository = new InMemoryMentionRepository();
const api = new MentionsAPI({repository});
const mention = await api.processWebmention({
source: new URL('https://source.com'),
target: new URL('https://target.com'),
payload: {}
});
assert(mention instanceof Mention);
const page = await api.listMentions({
limit: 'all'
});
assert.equal(page.data[0].id, mention.id);
});
it('Can list filtered mentions', async function () {
const repository = new InMemoryMentionRepository();
const api = new MentionsAPI({repository});
const mentionOne = await api.processWebmention({
source: new URL('https://diff-source.com'),
target: new URL('https://target.com'),
payload: {}
});
const mentionTwo = await api.processWebmention({
source: new URL('https://source.com'),
target: new URL('https://target.com'),
payload: {}
});
assert(mentionOne instanceof Mention);
assert(mentionTwo instanceof Mention);
const page = await api.listMentions({
filter: 'source.host:source.com',
limit: 'all'
});
assert(page.meta.pagination.total === 1);
assert(page.data[0].id === mentionTwo.id);
});
it('Can handle updating mentions', async function () {
const repository = new InMemoryMentionRepository();
const api = new MentionsAPI({repository});
const mentionOne = await api.processWebmention({
source: new URL('https://source.com'),
target: new URL('https://target.com'),
payload: {}
});
const mentionTwo = await api.processWebmention({
source: new URL('https://source.com'),
target: new URL('https://target.com'),
payload: {
new: 'info'
}
});
assert(mentionOne.id === mentionTwo.id);
const page = await api.listMentions({
limit: 'all'
});
assert(page.meta.pagination.total === 1);
assert(page.data[0].id === mentionOne.id);
});
});