Extracted shared API framework to separate package

refs https://github.com/TryGhost/Toolbox/issues/363

- this API framework is standalone and should be pulled out into a
  separate package so we can define its boundaries more clearly, and
  promote better testing of smaller parts
This commit is contained in:
Daniel Lockyer 2022-08-11 16:39:37 +02:00
parent 9aa5eab5ed
commit 687e68d5de
41 changed files with 156 additions and 87 deletions

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/node'
]
};

View File

@ -0,0 +1,23 @@
# Api Framework
API framework used by Ghost
## Usage
## Develop
This is a monorepo package.
Follow the instructions for the top-level repo.
1. `git clone` this repo & `cd` into it as usual
2. Run `yarn` to install top-level dependencies.
## Test
- `yarn lint` run just eslint
- `yarn test` run lint and tests

View File

@ -0,0 +1 @@
module.exports = require('./lib/api-framework');

View File

@ -21,5 +21,9 @@ module.exports = {
get serializers() { get serializers() {
return require('./serializers'); return require('./serializers');
},
get utils() {
return require('./utils');
} }
}; };

View File

@ -0,0 +1,34 @@
{
"name": "@tryghost/api-framework",
"version": "0.0.0",
"repository": "https://github.com/TryGhost/Ghost/tree/main/packages/api-framework",
"author": "Ghost Foundation",
"private": true,
"main": "index.js",
"scripts": {
"dev": "echo \"Implement me!\"",
"test": "NODE_ENV=testing c8 --all --reporter text --reporter cobertura mocha './test/**/*.test.js'",
"lint:code": "eslint *.js lib/ --ext .js --cache",
"lint": "yarn lint:code && yarn lint:test",
"lint:test": "eslint -c test/.eslintrc.js test/ --ext .js --cache"
},
"files": [
"index.js",
"lib"
],
"devDependencies": {
"c8": "7.12.0",
"mocha": "10.0.0",
"should": "13.2.3",
"sinon": "14.0.0"
},
"dependencies": {
"@tryghost/debug": "0.1.18",
"@tryghost/errors": "1.2.15",
"@tryghost/promise": "0.1.21",
"@tryghost/tpl": "0.1.18",
"@tryghost/validator": "0.1.27",
"jsonpath": "1.1.1",
"lodash": "4.17.21"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: ['ghost'],
extends: [
'plugin:ghost/test'
]
};

View File

@ -1,5 +1,5 @@
const should = require('should'); const should = require('should');
const shared = require('../../../../core/server/api/shared'); const shared = require('../');
describe('Unit: api/shared/frame', function () { describe('Unit: api/shared/frame', function () {
it('constructor', function () { it('constructor', function () {
@ -37,7 +37,7 @@ describe('Unit: api/shared/frame', function () {
should.exist(frame.data.posts); should.exist(frame.data.posts);
}); });
it('transform', function () { it('transform with query', function () {
const original = { const original = {
context: {user: 'id'}, context: {user: 'id'},
body: {posts: []}, body: {posts: []},
@ -78,7 +78,7 @@ describe('Unit: api/shared/frame', function () {
should.exist(frame.options.slug); should.exist(frame.options.slug);
}); });
it('transform', function () { it('transform with data', function () {
const original = { const original = {
context: {user: 'id'}, context: {user: 'id'},
options: { options: {

View File

@ -1,5 +1,4 @@
const should = require('should'); const shared = require('../');
const shared = require('../../../../core/server/api/shared');
describe('Unit: api/shared/headers', function () { describe('Unit: api/shared/headers', function () {
it('empty headers config', function () { it('empty headers config', function () {
@ -13,7 +12,7 @@ describe('Unit: api/shared/headers', function () {
return shared.headers.get({}, {disposition: {type: 'json', value: 'value'}}) return shared.headers.get({}, {disposition: {type: 'json', value: 'value'}})
.then((result) => { .then((result) => {
result.should.eql({ result.should.eql({
'Content-Disposition': 'Attachment; filename=\"value\"', 'Content-Disposition': 'Attachment; filename="value"',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': 2 'Content-Length': 2
}); });
@ -24,7 +23,7 @@ describe('Unit: api/shared/headers', function () {
return shared.headers.get({}, {disposition: {type: 'csv', value: 'my.csv'}}) return shared.headers.get({}, {disposition: {type: 'csv', value: 'my.csv'}})
.then((result) => { .then((result) => {
result.should.eql({ result.should.eql({
'Content-Disposition': 'Attachment; filename=\"my.csv\"', 'Content-Disposition': 'Attachment; filename="my.csv"',
'Content-Type': 'text/csv' 'Content-Type': 'text/csv'
}); });
}); });
@ -34,7 +33,7 @@ describe('Unit: api/shared/headers', function () {
return shared.headers.get('yaml file', {disposition: {type: 'yaml', value: 'my.yaml'}}) return shared.headers.get('yaml file', {disposition: {type: 'yaml', value: 'my.yaml'}})
.then((result) => { .then((result) => {
result.should.eql({ result.should.eql({
'Content-Disposition': 'Attachment; filename=\"my.yaml\"', 'Content-Disposition': 'Attachment; filename="my.yaml"',
'Content-Type': 'application/yaml', 'Content-Type': 'application/yaml',
'Content-Length': 11 'Content-Length': 11
}); });

View File

@ -1,6 +1,6 @@
const should = require('should'); const should = require('should');
const sinon = require('sinon'); const sinon = require('sinon');
const shared = require('../../../../core/server/api/shared'); const shared = require('../');
describe('Unit: api/shared/http', function () { describe('Unit: api/shared/http', function () {
let req; let req;
@ -73,7 +73,7 @@ describe('Unit: api/shared/http', function () {
shared.http(apiImpl)(req, res, next); shared.http(apiImpl)(req, res, next);
}); });
it('api response is fn', function (done) { it('api response is fn (data)', function (done) {
const apiImpl = sinon.stub().resolves('data'); const apiImpl = sinon.stub().resolves('data');
next.callsFake(done); next.callsFake(done);

View File

@ -1,8 +1,7 @@
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const should = require('should'); const should = require('should');
const sinon = require('sinon'); const sinon = require('sinon');
const Promise = require('bluebird'); const shared = require('../');
const shared = require('../../../../core/server/api/shared');
describe('Unit: api/shared/pipeline', function () { describe('Unit: api/shared/pipeline', function () {
afterEach(function () { afterEach(function () {

View File

@ -1,8 +1,7 @@
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const should = require('should');
const Promise = require('bluebird'); const Promise = require('bluebird');
const sinon = require('sinon'); const sinon = require('sinon');
const shared = require('../../../../../core/server/api/shared'); const shared = require('../../');
describe('Unit: api/shared/serializers/handle', function () { describe('Unit: api/shared/serializers/handle', function () {
afterEach(function () { afterEach(function () {

View File

@ -1,5 +1,5 @@
const should = require('should'); const should = require('should');
const shared = require('../../../../../../core/server/api/shared'); const shared = require('../../../');
describe('Unit: utils/serializers/input/all', function () { describe('Unit: utils/serializers/input/all', function () {
describe('all', function () { describe('all', function () {

View File

@ -1,5 +1,4 @@
const should = require('should'); const optionsUtil = require('../../lib/utils/options');
const optionsUtil = require('../../../../../core/server/api/shared/utils/options');
describe('Unit: api/shared/util/options', function () { describe('Unit: api/shared/util/options', function () {
it('returns an array with empty string when no parameters are passed', function () { it('returns an array with empty string when no parameters are passed', function () {

View File

@ -1,7 +1,7 @@
const errors = require('@tryghost/errors'); const errors = require('@tryghost/errors');
const Promise = require('bluebird'); const Promise = require('bluebird');
const sinon = require('sinon'); const sinon = require('sinon');
const shared = require('../../../../../core/server/api/shared'); const shared = require('../../');
describe('Unit: api/shared/validators/handle', function () { describe('Unit: api/shared/validators/handle', function () {
afterEach(function () { afterEach(function () {

View File

@ -2,7 +2,7 @@ const errors = require('@tryghost/errors');
const should = require('should'); const should = require('should');
const sinon = require('sinon'); const sinon = require('sinon');
const Promise = require('bluebird'); const Promise = require('bluebird');
const shared = require('../../../../../../core/server/api/shared'); const shared = require('../../../');
describe('Unit: api/shared/validators/input/all', function () { describe('Unit: api/shared/validators/input/all', function () {
afterEach(function () { afterEach(function () {
@ -287,7 +287,7 @@ describe('Unit: api/shared/validators/input/all', function () {
}); });
}); });
it('fails', function () { it('fails with docName', function () {
const frame = { const frame = {
data: { data: {
docName: true docName: true
@ -305,7 +305,7 @@ describe('Unit: api/shared/validators/input/all', function () {
}); });
}); });
it('fails', function () { it('fails for required field', function () {
const frame = { const frame = {
data: { data: {
docName: [{ docName: [{
@ -331,7 +331,7 @@ describe('Unit: api/shared/validators/input/all', function () {
}); });
}); });
it('fails', function () { it('fails for invalid field', function () {
const frame = { const frame = {
data: { data: {
docName: [{ docName: [{

View File

@ -8,7 +8,7 @@
], ],
"statements": 57, "statements": 57,
"branches": 85, "branches": 85,
"functions": 52, "functions": 51,
"lines": 57, "lines": 57,
"include": [ "include": [
"core/{*.js,frontend,server,shared}" "core/{*.js,frontend,server,shared}"

View File

@ -1,4 +1,4 @@
const shared = require('../shared'); const apiFramework = require('@tryghost/api-framework');
const localUtils = require('./utils'); const localUtils = require('./utils');
// ESLint Override Notice // ESLint Override Notice
@ -9,19 +9,19 @@ const localUtils = require('./utils');
module.exports = { module.exports = {
get authentication() { get authentication() {
return shared.pipeline(require('./authentication'), localUtils); return apiFramework.pipeline(require('./authentication'), localUtils);
}, },
get db() { get db() {
return shared.pipeline(require('./db'), localUtils); return apiFramework.pipeline(require('./db'), localUtils);
}, },
get identities() { get identities() {
return shared.pipeline(require('./identities'), localUtils); return apiFramework.pipeline(require('./identities'), localUtils);
}, },
get integrations() { get integrations() {
return shared.pipeline(require('./integrations'), localUtils); return apiFramework.pipeline(require('./integrations'), localUtils);
}, },
// @TODO: transform // @TODO: transform
@ -30,147 +30,147 @@ module.exports = {
}, },
get schedules() { get schedules() {
return shared.pipeline(require('./schedules'), localUtils); return apiFramework.pipeline(require('./schedules'), localUtils);
}, },
get pages() { get pages() {
return shared.pipeline(require('./pages'), localUtils); return apiFramework.pipeline(require('./pages'), localUtils);
}, },
get redirects() { get redirects() {
return shared.pipeline(require('./redirects'), localUtils); return apiFramework.pipeline(require('./redirects'), localUtils);
}, },
get roles() { get roles() {
return shared.pipeline(require('./roles'), localUtils); return apiFramework.pipeline(require('./roles'), localUtils);
}, },
get slugs() { get slugs() {
return shared.pipeline(require('./slugs'), localUtils); return apiFramework.pipeline(require('./slugs'), localUtils);
}, },
get webhooks() { get webhooks() {
return shared.pipeline(require('./webhooks'), localUtils); return apiFramework.pipeline(require('./webhooks'), localUtils);
}, },
get posts() { get posts() {
return shared.pipeline(require('./posts'), localUtils); return apiFramework.pipeline(require('./posts'), localUtils);
}, },
get invites() { get invites() {
return shared.pipeline(require('./invites'), localUtils); return apiFramework.pipeline(require('./invites'), localUtils);
}, },
get mail() { get mail() {
return shared.pipeline(require('./mail'), localUtils); return apiFramework.pipeline(require('./mail'), localUtils);
}, },
get notifications() { get notifications() {
return shared.pipeline(require('./notifications'), localUtils); return apiFramework.pipeline(require('./notifications'), localUtils);
}, },
get settings() { get settings() {
return shared.pipeline(require('./settings'), localUtils); return apiFramework.pipeline(require('./settings'), localUtils);
}, },
get membersStripeConnect() { get membersStripeConnect() {
return shared.pipeline(require('./members-stripe-connect'), localUtils); return apiFramework.pipeline(require('./members-stripe-connect'), localUtils);
}, },
get members() { get members() {
return shared.pipeline(require('./members'), localUtils); return apiFramework.pipeline(require('./members'), localUtils);
}, },
get offers() { get offers() {
return shared.pipeline(require('./offers'), localUtils); return apiFramework.pipeline(require('./offers'), localUtils);
}, },
get tiers() { get tiers() {
return shared.pipeline(require('./tiers'), localUtils); return apiFramework.pipeline(require('./tiers'), localUtils);
}, },
get memberSigninUrls() { get memberSigninUrls() {
return shared.pipeline(require('./member-signin-urls.js'), localUtils); return apiFramework.pipeline(require('./member-signin-urls.js'), localUtils);
}, },
get labels() { get labels() {
return shared.pipeline(require('./labels'), localUtils); return apiFramework.pipeline(require('./labels'), localUtils);
}, },
get images() { get images() {
return shared.pipeline(require('./images'), localUtils); return apiFramework.pipeline(require('./images'), localUtils);
}, },
get media() { get media() {
return shared.pipeline(require('./media'), localUtils); return apiFramework.pipeline(require('./media'), localUtils);
}, },
get files() { get files() {
return shared.pipeline(require('./files'), localUtils); return apiFramework.pipeline(require('./files'), localUtils);
}, },
get tags() { get tags() {
return shared.pipeline(require('./tags'), localUtils); return apiFramework.pipeline(require('./tags'), localUtils);
}, },
get users() { get users() {
return shared.pipeline(require('./users'), localUtils); return apiFramework.pipeline(require('./users'), localUtils);
}, },
get previews() { get previews() {
return shared.pipeline(require('./previews'), localUtils); return apiFramework.pipeline(require('./previews'), localUtils);
}, },
get emailPost() { get emailPost() {
return shared.pipeline(require('./email-post'), localUtils); return apiFramework.pipeline(require('./email-post'), localUtils);
}, },
get oembed() { get oembed() {
return shared.pipeline(require('./oembed'), localUtils); return apiFramework.pipeline(require('./oembed'), localUtils);
}, },
get slack() { get slack() {
return shared.pipeline(require('./slack'), localUtils); return apiFramework.pipeline(require('./slack'), localUtils);
}, },
get config() { get config() {
return shared.pipeline(require('./config'), localUtils); return apiFramework.pipeline(require('./config'), localUtils);
}, },
get explore() { get explore() {
return shared.pipeline(require('./explore'), localUtils); return apiFramework.pipeline(require('./explore'), localUtils);
}, },
get themes() { get themes() {
return shared.pipeline(require('./themes'), localUtils); return apiFramework.pipeline(require('./themes'), localUtils);
}, },
get actions() { get actions() {
return shared.pipeline(require('./actions'), localUtils); return apiFramework.pipeline(require('./actions'), localUtils);
}, },
get email_previews() { get email_previews() {
return shared.pipeline(require('./email-previews'), localUtils); return apiFramework.pipeline(require('./email-previews'), localUtils);
}, },
get emails() { get emails() {
return shared.pipeline(require('./emails'), localUtils); return apiFramework.pipeline(require('./emails'), localUtils);
}, },
get site() { get site() {
return shared.pipeline(require('./site'), localUtils); return apiFramework.pipeline(require('./site'), localUtils);
}, },
get snippets() { get snippets() {
return shared.pipeline(require('./snippets'), localUtils); return apiFramework.pipeline(require('./snippets'), localUtils);
}, },
get stats() { get stats() {
return shared.pipeline(require('./stats'), localUtils); return apiFramework.pipeline(require('./stats'), localUtils);
}, },
get customThemeSettings() { get customThemeSettings() {
return shared.pipeline(require('./custom-theme-settings'), localUtils); return apiFramework.pipeline(require('./custom-theme-settings'), localUtils);
}, },
get serializers() { get serializers() {
@ -178,11 +178,11 @@ module.exports = {
}, },
get newsletters() { get newsletters() {
return shared.pipeline(require('./newsletters'), localUtils); return apiFramework.pipeline(require('./newsletters'), localUtils);
}, },
get comments() { get comments() {
return shared.pipeline(require('./comments'), localUtils); return apiFramework.pipeline(require('./comments'), localUtils);
}, },
/** /**
@ -194,38 +194,38 @@ module.exports = {
* `api.admin` soon. Need to figure out how serializers & validation works then. * `api.admin` soon. Need to figure out how serializers & validation works then.
*/ */
get pagesPublic() { get pagesPublic() {
return shared.pipeline(require('./pages-public'), localUtils, 'content'); return apiFramework.pipeline(require('./pages-public'), localUtils, 'content');
}, },
get tagsPublic() { get tagsPublic() {
return shared.pipeline(require('./tags-public'), localUtils, 'content'); return apiFramework.pipeline(require('./tags-public'), localUtils, 'content');
}, },
get publicSettings() { get publicSettings() {
return shared.pipeline(require('./settings-public'), localUtils, 'content'); return apiFramework.pipeline(require('./settings-public'), localUtils, 'content');
}, },
get postsPublic() { get postsPublic() {
return shared.pipeline(require('./posts-public'), localUtils, 'content'); return apiFramework.pipeline(require('./posts-public'), localUtils, 'content');
}, },
get authorsPublic() { get authorsPublic() {
return shared.pipeline(require('./authors-public'), localUtils, 'content'); return apiFramework.pipeline(require('./authors-public'), localUtils, 'content');
}, },
get tiersPublic() { get tiersPublic() {
return shared.pipeline(require('./tiers-public'), localUtils, 'content'); return apiFramework.pipeline(require('./tiers-public'), localUtils, 'content');
}, },
get newslettersPublic() { get newslettersPublic() {
return shared.pipeline(require('./newsletters-public'), localUtils, 'content'); return apiFramework.pipeline(require('./newsletters-public'), localUtils, 'content');
}, },
get offersPublic() { get offersPublic() {
return shared.pipeline(require('./offers-public'), localUtils, 'content'); return apiFramework.pipeline(require('./offers-public'), localUtils, 'content');
}, },
get commentsMembers() { get commentsMembers() {
return shared.pipeline(require('./comments-members'), localUtils, 'members'); return apiFramework.pipeline(require('./comments-members'), localUtils, 'members');
} }
}; };

View File

@ -1,6 +1,6 @@
const _ = require('lodash'); const _ = require('lodash');
const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:input:db'); const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:input:db');
const optionsUtil = require('../../../../shared/utils/options'); const optionsUtil = require('@tryghost/api-framework').utils.options;
const INTERNAL_OPTIONS = ['transacting', 'forUpdate']; const INTERNAL_OPTIONS = ['transacting', 'forUpdate'];

View File

@ -3,7 +3,7 @@ const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output
const allowedIncludes = ['monthly_price', 'yearly_price']; const allowedIncludes = ['monthly_price', 'yearly_price'];
const localUtils = require('../../index'); const localUtils = require('../../index');
const utils = require('../../../../shared/utils'); const {utils} = require('@tryghost/api-framework');
const labs = require('../../../../../../shared/labs'); const labs = require('../../../../../../shared/labs');
module.exports = { module.exports = {

View File

@ -1,3 +1 @@
module.exports.endpoints = require('./endpoints'); module.exports.endpoints = require('./endpoints');
module.exports.shared = require('./shared');

View File

@ -5,7 +5,7 @@ const urlUtils = require('../../../shared/url-utils');
const labs = require('../../../shared/labs'); const labs = require('../../../shared/labs');
const moment = require('moment-timezone'); const moment = require('moment-timezone');
const api = require('../../api').endpoints; const api = require('../../api').endpoints;
const apiShared = require('../../api').shared; const apiFramework = require('@tryghost/api-framework');
const {URL} = require('url'); const {URL} = require('url');
const mobiledocLib = require('../../lib/mobiledoc'); const mobiledocLib = require('../../lib/mobiledoc');
const htmlToPlaintext = require('@tryghost/html-to-plaintext'); const htmlToPlaintext = require('@tryghost/html-to-plaintext');
@ -104,7 +104,7 @@ const serializePostModel = async (model) => {
const frame = {options: {context: {user: true}, formats: 'mobiledoc'}}; const frame = {options: {context: {user: true}, formats: 'mobiledoc'}};
const docName = 'posts'; const docName = 'posts';
await apiShared await apiFramework
.serializers .serializers
.handle .handle
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame); .output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);

View File

@ -2,7 +2,7 @@ module.exports = (event, model) => {
const _ = require('lodash'); const _ = require('lodash');
const {sequence} = require('@tryghost/promise'); const {sequence} = require('@tryghost/promise');
const api = require('../../api').endpoints; const api = require('../../api').endpoints;
const apiShared = require('../../api').shared; const apiFramework = require('@tryghost/api-framework');
const resourceName = event.match(/(\w+)\./)[1]; const resourceName = event.match(/(\w+)\./)[1];
const docName = `${resourceName}s`; const docName = `${resourceName}s`;
@ -23,7 +23,7 @@ module.exports = (event, model) => {
}; };
} }
return apiShared return apiFramework
.serializers .serializers
.handle .handle
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame) .output(model, {docName: docName, method: 'read'}, api.serializers.output, frame)
@ -46,7 +46,7 @@ module.exports = (event, model) => {
frame.options.withRelated = ['tags', 'authors']; frame.options.withRelated = ['tags', 'authors'];
} }
return apiShared return apiFramework
.serializers .serializers
.handle .handle
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame) .output(model, {docName: docName, method: 'read'}, api.serializers.output, frame)

View File

@ -1,6 +1,6 @@
const express = require('../../../../../shared/express'); const express = require('../../../../../shared/express');
const api = require('../../../../api').endpoints; const api = require('../../../../api').endpoints;
const http = require('../../../../api').shared.http; const {http} = require('@tryghost/api-framework');
const apiMw = require('../../middleware'); const apiMw = require('../../middleware');
const mw = require('./middleware'); const mw = require('./middleware');

View File

@ -1,7 +1,7 @@
const express = require('../../../../../shared/express'); const express = require('../../../../../shared/express');
const cors = require('cors'); const cors = require('cors');
const api = require('../../../../api').endpoints; const api = require('../../../../api').endpoints;
const http = require('../../../../api').shared.http; const {http} = require('@tryghost/api-framework');
const mw = require('./middleware'); const mw = require('./middleware');
const config = require('../../../../../shared/config'); const config = require('../../../../../shared/config');

View File

@ -1,6 +1,6 @@
const express = require('../../../shared/express'); const express = require('../../../shared/express');
const api = require('../../api').endpoints; const api = require('../../api').endpoints;
const http = require('../../api').shared.http; const {http} = require('@tryghost/api-framework');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const membersService = require('../../../server/services/members'); const membersService = require('../../../server/services/members');

View File

@ -55,6 +55,7 @@
"@sentry/node": "7.9.0", "@sentry/node": "7.9.0",
"@tryghost/adapter-manager": "0.0.0", "@tryghost/adapter-manager": "0.0.0",
"@tryghost/admin-api-schema": "4.1.1", "@tryghost/admin-api-schema": "4.1.1",
"@tryghost/api-framework": "0.0.0",
"@tryghost/api-version-compatibility-service": "0.0.0", "@tryghost/api-version-compatibility-service": "0.0.0",
"@tryghost/bookshelf-plugins": "0.5.0", "@tryghost/bookshelf-plugins": "0.5.0",
"@tryghost/bootstrap-socket": "0.0.0", "@tryghost/bootstrap-socket": "0.0.0",