Added v2 api endpoints (#9874)

refs #9866

- Registered Content API under /ghost/api/v2/content/
- Registered Admin API under /ghost/api/v2/admin/
- Moved API v0.1 implementation to web/api/v0.1
- Created web/api/v2 for the new api endpoints
- Started with reducing the implementation for the new Content API (the Content api does not serve admin api endpoints, that's why it was reducible)
- Covered parent-app module with basic test checking correct applications/routes are being mounted
- Added a readme file, which contains a warning using v2, because it's under active development!
- This PR does only make the new endpoints available, we have not:
  - optimised the web folder (e.g. res.isAdmin)
  - started with different API controllers
  - reason: we want to do more preparation tasks before we copy the api controllers
This commit is contained in:
Nazar Gargol 2018-09-18 15:59:06 +02:00 committed by Katharina Irrgang
parent 6ae5c13de7
commit 57271127f4
14 changed files with 547 additions and 21 deletions

View File

@ -0,0 +1,7 @@
# Ghost APIs
Ghost is moving towards providing more robust APIs in the future. A plan and decisions can be found [here](https://github.com/TryGhost/Ghost/issues/9866).
## WARNING!
The v2 API (`/ghost/api/v2/*` endpoints) is to be considered under active development until this message is removed. Please use with caution and don't rely too heavy on it just yet :)

View File

@ -9,16 +9,16 @@ const debug = require('ghost-ignition').debug('api'),
// Include the middleware
// API specific
versionMatch = require('../middleware/api/version-match'), // global
versionMatch = require('../../middleware/api/version-match'), // global
// Shared
bodyParser = require('body-parser'), // global, shared
cacheControl = require('../middleware/cache-control'), // global, shared
maintenance = require('../middleware/maintenance'), // global, shared
errorHandler = require('../middleware/error-handler'); // global, shared
cacheControl = require('../../middleware/cache-control'), // global, shared
maintenance = require('../../middleware/maintenance'), // global, shared
errorHandler = require('../../middleware/error-handler'); // global, shared
module.exports = function setupApiApp() {
debug('API setup start');
debug('API v0.1 setup start');
const apiApp = express();
// @TODO finish refactoring this away.
@ -54,7 +54,7 @@ module.exports = function setupApiApp() {
apiApp.use(errorHandler.resourceNotFound);
apiApp.use(errorHandler.handleJSONResponse);
debug('API setup end');
debug('API v0.1 setup end');
return apiApp;
};

View File

@ -1,7 +1,7 @@
const prettyURLs = require('../middleware/pretty-urls'),
cors = require('../middleware/api/cors'),
urlRedirects = require('../middleware/url-redirects'),
auth = require('../../services/auth');
const prettyURLs = require('../../middleware/pretty-urls'),
cors = require('../../middleware/api/cors'),
urlRedirects = require('../../middleware/url-redirects'),
auth = require('../../../services/auth');
/**
* Auth Middleware Packages

View File

@ -1,26 +1,25 @@
const express = require('express'),
// This essentially provides the controllers for the routes
api = require('../../api'),
api = require('../../../api'),
// Middleware
mw = require('./middleware'),
// API specific
auth = require('../../services/auth'),
cors = require('../middleware/api/cors'),
brute = require('../middleware/brute'),
auth = require('../../../services/auth'),
cors = require('../../middleware/api/cors'),
brute = require('../../middleware/brute'),
// Handling uploads & imports
tmpdir = require('os').tmpdir,
upload = require('multer')({dest: tmpdir()}),
validation = require('../middleware/validation'),
image = require('../middleware/image'),
validation = require('../../middleware/validation'),
image = require('../../middleware/image'),
// Temporary
// @TODO find a more appy way to do this!
labs = require('../middleware/labs');
labs = require('../../middleware/labs');
// @TODO refactor/clean this up - how do we want the routing to work long term?
module.exports = function apiRoutes() {
const apiRouter = express.Router();

View File

@ -0,0 +1,60 @@
// # API routes
const debug = require('ghost-ignition').debug('api'),
boolParser = require('express-query-boolean'),
express = require('express'),
// routes
routes = require('./routes'),
// Include the middleware
// API specific
versionMatch = require('../../../middleware/api/version-match'), // global
// Shared
bodyParser = require('body-parser'), // global, shared
cacheControl = require('../../../middleware/cache-control'), // global, shared
maintenance = require('../../../middleware/maintenance'), // global, shared
errorHandler = require('../../../middleware/error-handler'); // global, shared
module.exports = function setupApiApp() {
debug('Admin API v2 setup start');
const apiApp = express();
// @TODO finish refactoring this away.
apiApp.use(function setIsAdmin(req, res, next) {
// api === isAdmin
res.isAdmin = true;
next();
});
// API middleware
// Body parsing
apiApp.use(bodyParser.json({limit: '1mb'}));
apiApp.use(bodyParser.urlencoded({extended: true, limit: '1mb'}));
// Query parsing
apiApp.use(boolParser());
// send 503 json response in case of maintenance
apiApp.use(maintenance);
// Check version matches for API requests, depends on res.locals.safeVersion being set
// Therefore must come after themeHandler.ghostLocals, for now
apiApp.use(versionMatch);
// API shouldn't be cached
apiApp.use(cacheControl('private'));
// Routing
apiApp.use(routes());
// API error handling
apiApp.use(errorHandler.resourceNotFound);
apiApp.use(errorHandler.handleJSONResponse);
debug('Admin API v2 setup end');
return apiApp;
};

View File

@ -0,0 +1,30 @@
const prettyURLs = require('../../../middleware/pretty-urls'),
cors = require('../../../middleware/api/cors'),
urlRedirects = require('../../../middleware/url-redirects'),
auth = require('../../../../services/auth');
/**
* Authentication for private endpoints
*/
module.exports.authenticatePrivate = [
auth.authenticate.authenticateClient,
auth.authenticate.authenticateUser,
auth.authorize.requiresAuthorizedUser,
cors,
urlRedirects,
prettyURLs
];
/**
* Authentication for client endpoints
*/
module.exports.authenticateClient = function authenticateClient(client) {
return [
auth.authenticate.authenticateClient,
auth.authenticate.authenticateUser,
auth.authorize.requiresAuthorizedClient(client),
cors,
urlRedirects,
prettyURLs
];
};

View File

@ -0,0 +1,222 @@
const express = require('express'),
// This essentially provides the controllers for the routes
api = require('../../../../api'),
// Middleware
mw = require('./middleware'),
// API specific
auth = require('../../../../services/auth'),
cors = require('../../../middleware/api/cors'),
brute = require('../../../middleware/brute'),
// Handling uploads & imports
tmpdir = require('os').tmpdir,
upload = require('multer')({dest: tmpdir()}),
validation = require('../../../middleware/validation'),
image = require('../../../middleware/image'),
// Temporary
// @TODO find a more appy way to do this!
labs = require('../../../middleware/labs');
module.exports = function apiRoutes() {
const router = express.Router();
// alias delete with del
router.del = router.delete;
// ## CORS pre-flight check
router.options('*', cors);
// ## Configuration
router.get('/configuration', api.http(api.configuration.read));
router.get('/configuration/:key', mw.authenticatePrivate, api.http(api.configuration.read));
// ## Posts
router.get('/posts', mw.authenticatePrivate, api.http(api.posts.browse));
router.post('/posts', mw.authenticatePrivate, api.http(api.posts.add));
router.get('/posts/:id', mw.authenticatePrivate, api.http(api.posts.read));
router.get('/posts/slug/:slug', mw.authenticatePrivate, api.http(api.posts.read));
router.put('/posts/:id', mw.authenticatePrivate, api.http(api.posts.edit));
router.del('/posts/:id', mw.authenticatePrivate, api.http(api.posts.destroy));
// ## Schedules
router.put('/schedules/posts/:id', [
auth.authenticate.authenticateClient,
auth.authenticate.authenticateUser
], api.http(api.schedules.publishPost));
// ## Settings
router.get('/settings/routes/yaml', mw.authenticatePrivate, api.http(api.settings.download));
router.post('/settings/routes/yaml',
mw.authenticatePrivate,
upload.single('routes'),
validation.upload({type: 'routes'}),
api.http(api.settings.upload)
);
router.get('/settings', mw.authenticatePrivate, api.http(api.settings.browse));
router.get('/settings/:key', mw.authenticatePrivate, api.http(api.settings.read));
router.put('/settings', mw.authenticatePrivate, api.http(api.settings.edit));
// ## Users
router.get('/users', mw.authenticatePrivate, api.http(api.users.browse));
router.get('/users/:id', mw.authenticatePrivate, api.http(api.users.read));
router.get('/users/slug/:slug', mw.authenticatePrivate, api.http(api.users.read));
// NOTE: We don't expose any email addresses via the public api.
router.get('/users/email/:email', mw.authenticatePrivate, api.http(api.users.read));
router.put('/users/password', mw.authenticatePrivate, api.http(api.users.changePassword));
router.put('/users/owner', mw.authenticatePrivate, api.http(api.users.transferOwnership));
router.put('/users/:id', mw.authenticatePrivate, api.http(api.users.edit));
router.del('/users/:id', mw.authenticatePrivate, api.http(api.users.destroy));
// ## Tags
router.get('/tags', mw.authenticatePrivate, api.http(api.tags.browse));
router.get('/tags/:id', mw.authenticatePrivate, api.http(api.tags.read));
router.get('/tags/slug/:slug', mw.authenticatePrivate, api.http(api.tags.read));
router.post('/tags', mw.authenticatePrivate, api.http(api.tags.add));
router.put('/tags/:id', mw.authenticatePrivate, api.http(api.tags.edit));
router.del('/tags/:id', mw.authenticatePrivate, api.http(api.tags.destroy));
// ## Subscribers
router.get('/subscribers', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.browse));
router.get('/subscribers/csv', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.exportCSV));
router.post('/subscribers/csv',
labs.subscribers,
mw.authenticatePrivate,
upload.single('subscribersfile'),
validation.upload({type: 'subscribers'}),
api.http(api.subscribers.importCSV)
);
router.get('/subscribers/:id', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.read));
router.get('/subscribers/email/:email', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.read));
router.post('/subscribers', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.add));
router.put('/subscribers/:id', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.edit));
router.del('/subscribers/:id', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.destroy));
router.del('/subscribers/email/:email', labs.subscribers, mw.authenticatePrivate, api.http(api.subscribers.destroy));
// ## Roles
router.get('/roles/', mw.authenticatePrivate, api.http(api.roles.browse));
// ## Clients
router.get('/clients/slug/:slug', api.http(api.clients.read));
// ## Slugs
router.get('/slugs/:type/:name', mw.authenticatePrivate, api.http(api.slugs.generate));
// ## Themes
router.get('/themes/', mw.authenticatePrivate, api.http(api.themes.browse));
router.get('/themes/:name/download',
mw.authenticatePrivate,
api.http(api.themes.download)
);
router.post('/themes/upload',
mw.authenticatePrivate,
upload.single('theme'),
validation.upload({type: 'themes'}),
api.http(api.themes.upload)
);
router.put('/themes/:name/activate',
mw.authenticatePrivate,
api.http(api.themes.activate)
);
router.del('/themes/:name',
mw.authenticatePrivate,
api.http(api.themes.destroy)
);
// ## Notifications
router.get('/notifications', mw.authenticatePrivate, api.http(api.notifications.browse));
router.post('/notifications', mw.authenticatePrivate, api.http(api.notifications.add));
router.del('/notifications/:id', mw.authenticatePrivate, api.http(api.notifications.destroy));
// ## DB
router.get('/db', mw.authenticatePrivate, api.http(api.db.exportContent));
router.post('/db',
mw.authenticatePrivate,
upload.single('importfile'),
validation.upload({type: 'db'}),
api.http(api.db.importContent)
);
router.del('/db', mw.authenticatePrivate, api.http(api.db.deleteAllContent));
// ## Mail
router.post('/mail', mw.authenticatePrivate, api.http(api.mail.send));
router.post('/mail/test', mw.authenticatePrivate, api.http(api.mail.sendTest));
// ## Slack
router.post('/slack/test', mw.authenticatePrivate, api.http(api.slack.sendTest));
// ## Authentication
router.post('/authentication/passwordreset',
brute.globalReset,
brute.userReset,
api.http(api.authentication.generateResetToken)
);
router.put('/authentication/passwordreset', brute.globalBlock, api.http(api.authentication.resetPassword));
router.post('/authentication/invitation', api.http(api.authentication.acceptInvitation));
router.get('/authentication/invitation', api.http(api.authentication.isInvitation));
router.post('/authentication/setup', api.http(api.authentication.setup));
router.put('/authentication/setup', mw.authenticatePrivate, api.http(api.authentication.updateSetup));
router.get('/authentication/setup', api.http(api.authentication.isSetup));
router.post('/authentication/token',
mw.authenticateClient(),
brute.globalBlock,
brute.userLogin,
auth.oauth.generateAccessToken
);
router.post('/authentication/revoke', mw.authenticatePrivate, api.http(api.authentication.revoke));
// ## Uploads
// @TODO: rename endpoint to /images/upload (or similar)
router.post('/uploads',
mw.authenticatePrivate,
upload.single('uploadimage'),
validation.upload({type: 'images'}),
image.normalize,
api.http(api.uploads.add)
);
router.post('/db/backup', mw.authenticateClient('Ghost Backup'), api.http(api.db.backupContent));
router.post('/uploads/icon',
mw.authenticatePrivate,
upload.single('uploadimage'),
validation.upload({type: 'icons'}),
validation.blogIcon(),
api.http(api.uploads.add)
);
// ## Invites
router.get('/invites', mw.authenticatePrivate, api.http(api.invites.browse));
router.get('/invites/:id', mw.authenticatePrivate, api.http(api.invites.read));
router.post('/invites', mw.authenticatePrivate, api.http(api.invites.add));
router.del('/invites/:id', mw.authenticatePrivate, api.http(api.invites.destroy));
// ## Redirects (JSON based)
router.get('/redirects/json', mw.authenticatePrivate, api.http(api.redirects.download));
router.post('/redirects/json',
mw.authenticatePrivate,
upload.single('redirects'),
validation.upload({type: 'redirects'}),
api.http(api.redirects.upload)
);
// ## Webhooks (RESTHooks)
router.post('/webhooks', mw.authenticatePrivate, api.http(api.webhooks.add));
router.del('/webhooks/:id', mw.authenticatePrivate, api.http(api.webhooks.destroy));
// ## Oembed (fetch response from oembed provider)
router.get('/oembed', mw.authenticatePrivate, api.http(api.oembed.read));
return router;
};

View File

@ -0,0 +1,48 @@
// # API routes
const debug = require('ghost-ignition').debug('api'),
boolParser = require('express-query-boolean'),
express = require('express'),
// routes
routes = require('./routes'),
// Include the middleware
// Shared
cacheControl = require('../../../middleware/cache-control'), // global, shared
maintenance = require('../../../middleware/maintenance'), // global, shared
errorHandler = require('../../../middleware/error-handler'); // global, shared
module.exports = function setupApiApp() {
debug('Content API v2 setup start');
const apiApp = express();
// @TODO finish refactoring this away.
apiApp.use(function setIsAdmin(req, res, next) {
// api === isAdmin
res.isAdmin = true;
next();
});
// API middleware
// Query parsing
apiApp.use(boolParser());
// send 503 json response in case of maintenance
apiApp.use(maintenance);
// API shouldn't be cached
apiApp.use(cacheControl('private'));
// Routing
apiApp.use(routes());
// API error handling
apiApp.use(errorHandler.resourceNotFound);
apiApp.use(errorHandler.handleJSONResponse);
debug('Content API v2 setup end');
return apiApp;
};

View File

@ -0,0 +1,26 @@
const prettyURLs = require('../../../middleware/pretty-urls'),
cors = require('../../../middleware/api/cors'),
urlRedirects = require('../../../middleware/url-redirects'),
auth = require('../../../../services/auth');
/**
* Auth Middleware Packages
*
* IMPORTANT
* - cors middleware MUST happen before pretty urls, because otherwise cors header can get lost on redirect
* - cors middleware MUST happen after authenticateClient, because authenticateClient reads the trusted domains
* - url redirects MUST happen after cors, otherwise cors header can get lost on redirect
*/
/**
* Authentication for public endpoints
*/
module.exports.authenticatePublic = [
auth.authenticate.authenticateClient,
auth.authenticate.authenticateUser,
// This is a labs-enabled middleware
auth.authorize.requiresAuthorizedUserPublicAPI,
cors,
urlRedirects,
prettyURLs
];

View File

@ -0,0 +1,49 @@
const express = require('express'),
// This essentially provides the controllers for the routes
api = require('../../../../api'),
// Middleware
mw = require('./middleware'),
// API specific
cors = require('../../../middleware/api/cors'),
// Temporary
// @TODO find a more appy way to do this!
labs = require('../../../middleware/labs');
module.exports = function apiRoutes() {
const router = express.Router();
// alias delete with del
router.del = router.delete;
// ## CORS pre-flight check
router.options('*', cors);
// ## Configuration
router.get('/configuration', api.http(api.configuration.read));
// ## Posts
router.get('/posts', mw.authenticatePublic, api.http(api.posts.browse));
router.get('/posts/:id', mw.authenticatePublic, api.http(api.posts.read));
router.get('/posts/slug/:slug', mw.authenticatePublic, api.http(api.posts.read));
// ## Users
router.get('/users', mw.authenticatePublic, api.http(api.users.browse));
router.get('/users/:id', mw.authenticatePublic, api.http(api.users.read));
router.get('/users/slug/:slug', mw.authenticatePublic, api.http(api.users.read));
// ## Tags
router.get('/tags', mw.authenticatePublic, api.http(api.tags.browse));
router.get('/tags/:id', mw.authenticatePublic, api.http(api.tags.read));
router.get('/tags/slug/:slug', mw.authenticatePublic, api.http(api.tags.read));
// ## Subscribers
router.post('/subscribers', labs.subscribers, mw.authenticatePublic, api.http(api.subscribers.add));
// ## Clients
router.get('/clients/slug/:slug', api.http(api.clients.read));
return router;
};

View File

@ -45,7 +45,9 @@ module.exports = function setupParentApp(options = {}) {
// API
// @TODO: finish refactoring the API app
// @TODO: decide what to do with these paths - config defaults? config overrides?
parentApp.use('/ghost/api/v0.1/', require('./api/app')());
parentApp.use('/ghost/api/v0.1/', require('./api/v0.1/app')());
parentApp.use('/ghost/api/v2/content/', require('./api/v2/content/app')());
parentApp.use('/ghost/api/v2/admin/', require('./api/v2/admin/app')());
// ADMIN
parentApp.use('/ghost', require('./admin')());

View File

@ -0,0 +1,57 @@
var should = require('should'),
sinon = require('sinon'),
proxyquire = require('proxyquire'),
sandbox = sinon.sandbox.create();
describe('parent app', function () {
let expressStub;
let use;
let apiV01Spy;
let apiContentV2Spy;
let apiAdminV2Spy;
let parentApp;
let adminSpy;
let siteSpy;
beforeEach(function () {
use = sandbox.spy();
expressStub = () => ({
use,
enable: () => {}
});
apiV01Spy = sinon.spy();
apiContentV2Spy = sinon.spy();
apiAdminV2Spy = sinon.spy();
adminSpy = sinon.spy();
siteSpy = sinon.spy();
parentApp = proxyquire('../../../server/web/parent-app', {
express: expressStub,
'./api/v0.1/app': apiV01Spy,
'./api/v2/content/app': apiContentV2Spy,
'./api/v2/admin/app': apiAdminV2Spy,
'./admin': adminSpy,
'./site': siteSpy
});
});
afterEach(function () {
sandbox.restore();
});
it('should mount 5 apps and assign correct routes to them', function () {
parentApp();
use.calledWith('/ghost/api/v0.1/').should.be.true();
use.calledWith('/ghost/api/v2/content/').should.be.true();
use.calledWith('/ghost/api/v2/admin/').should.be.true();
use.calledWith('/ghost').should.be.true();
apiV01Spy.called.should.be.true();
apiContentV2Spy.called.should.be.true();
apiAdminV2Spy.called.should.be.true();
adminSpy.called.should.be.true();
siteSpy.called.should.be.true();
});
});

View File

@ -128,6 +128,7 @@
"minimist": "1.2.0",
"mocha": "4.1.0",
"nock": "9.4.0",
"proxyquire": "2.1.0",
"rewire": "3.0.2",
"should": "13.2.1",
"should-http": "0.1.1",

View File

@ -1934,6 +1934,13 @@ file-sync-cmp@^0.1.0:
version "0.1.1"
resolved "https://registry.yarnpkg.com/file-sync-cmp/-/file-sync-cmp-0.1.1.tgz#a5e7a8ffbfa493b43b923bbd4ca89a53b63b612b"
fill-keys@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/fill-keys/-/fill-keys-1.0.2.tgz#9a8fa36f4e8ad634e3bf6b4f3c8882551452eb20"
dependencies:
is-object "~1.0.1"
merge-descriptors "~1.0.0"
fill-range@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-4.0.0.tgz#d544811d428f98eb06a63dc402d2403c328c38f7"
@ -3065,7 +3072,7 @@ is-number@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/is-number/-/is-number-4.0.0.tgz#0026e37f5454d73e356dfe6564699867c6a7f0ff"
is-object@^1.0.1:
is-object@^1.0.1, is-object@~1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
@ -3782,7 +3789,7 @@ meow@^3.1.0, meow@^3.3.0:
redent "^1.0.0"
trim-newlines "^1.0.0"
merge-descriptors@1.0.1:
merge-descriptors@1.0.1, merge-descriptors@~1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
@ -3971,6 +3978,10 @@ mocha@^3.1.2:
mkdirp "0.5.1"
supports-color "3.1.2"
module-not-found-error@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/module-not-found-error/-/module-not-found-error-1.0.1.tgz#cf8b4ff4f29640674d6cdd02b0e3bc523c2bbdc0"
moment-timezone@0.5.21:
version "0.5.21"
resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.21.tgz#3cba247d84492174dbf71de2a9848fa13207b845"
@ -4944,6 +4955,14 @@ proxy-addr@~2.0.3:
forwarded "~0.1.2"
ipaddr.js "1.6.0"
proxyquire@2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxyquire/-/proxyquire-2.1.0.tgz#c2263a38bf0725f2ae950facc130e27510edce8d"
dependencies:
fill-keys "^1.0.2"
module-not-found-error "^1.0.0"
resolve "~1.8.1"
pseudomap@^1.0.1, pseudomap@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3"
@ -5235,6 +5254,12 @@ resolve@1.7.1, resolve@^1.1.6, resolve@^1.1.7, resolve@^1.4.0:
dependencies:
path-parse "^1.0.5"
resolve@~1.8.1:
version "1.8.1"
resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.8.1.tgz#82f1ec19a423ac1fbd080b0bab06ba36e84a7a26"
dependencies:
path-parse "^1.0.5"
restore-cursor@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"