mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-11-24 14:43:08 +03:00
Rewrite old version URLs to unversioned URLs with headers (#14646)
closes: https://github.com/TryGhost/Toolbox/issues/315 - For all the current versioned URLs, rewrite the URL as unversioned - Add the accept-version header - Add the deprecation header - Add the link header - This then does the content-version middleware afterwards, ensuring that rewritten requests get this in the response
This commit is contained in:
parent
420697291b
commit
7c795b4e26
@ -4,7 +4,9 @@ const versionMismatchHandler = require('@tryghost/mw-api-version-mismatch');
|
||||
const ghostVersion = require('@tryghost/version');
|
||||
const {GhostMailer} = require('../mail');
|
||||
const settingsService = require('../../services/settings');
|
||||
const urlUtils = require('../../../shared/url-utils');
|
||||
const models = require('../../models');
|
||||
const routeMatch = require('path-match')();
|
||||
|
||||
let serviceInstance;
|
||||
|
||||
@ -30,6 +32,13 @@ module.exports.errorHandler = (err, req, res, next) => {
|
||||
return versionMismatchHandler(serviceInstance)(err, req, res, next);
|
||||
};
|
||||
|
||||
/**
|
||||
* If Accept-Version is set on the request set Content-Version on the response
|
||||
*
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
module.exports.contentVersion = (req, res, next) => {
|
||||
if (req.header('accept-version')) {
|
||||
res.header('Content-Version', `v${ghostVersion.safe}`);
|
||||
@ -37,4 +46,38 @@ module.exports.contentVersion = (req, res, next) => {
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* If there is a version in the URL, and this is a valid API URL containing admin/content
|
||||
* Rewrite the URL and add the accept-version & deprecation headers
|
||||
* @param {import('express').Request} req
|
||||
* @param {import('express').Response} res
|
||||
* @param {import('express').NextFunction} next
|
||||
*/
|
||||
module.exports.versionRewrites = (req, res, next) => {
|
||||
let {version} = routeMatch('/:version(v2|v3|v4|canary)/:api(admin|content)/*')(req.url);
|
||||
|
||||
// If we don't match a valid version, carry on
|
||||
if (!version) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const versionlessUrl = req.url.replace(`${version}/`, '');
|
||||
|
||||
// Always send the explicit, numeric version in headers
|
||||
if (version === 'canary') {
|
||||
version = 'v4';
|
||||
}
|
||||
|
||||
// Rewrite the url
|
||||
req.url = versionlessUrl;
|
||||
|
||||
// Add the accept-version header so our internal systems will act as if it was set on the request
|
||||
req.headers['accept-version'] = req.headers['accept-version'] || `${version}.0`;
|
||||
|
||||
res.header('Deprecation', `version="${version}"`);
|
||||
res.header('Link', `<${urlUtils.urlJoin(urlUtils.urlFor('admin', true), 'api', versionlessUrl)}>; rel="latest-version"`);
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports.init = init;
|
||||
|
@ -13,19 +13,7 @@ module.exports = function setupApiApp() {
|
||||
apiApp.use(require('./testmode')());
|
||||
}
|
||||
|
||||
// If there is a version in the URL, and this is a valid API URL containing admin/content
|
||||
// Then 307 redirect (preserves the HTTP method) to a versionless URL with `accept-version` set.
|
||||
apiApp.all('/:version(v2|v3|v4|canary)/:api(admin|content)/*', (req, res) => {
|
||||
const {version} = req.params;
|
||||
const versionlessURL = req.originalUrl.replace(`${version}/`, '');
|
||||
if (version.startsWith('v')) {
|
||||
res.header('accept-version', `${version}.0`);
|
||||
} else {
|
||||
res.header('accept-version', version);
|
||||
}
|
||||
res.redirect(307, versionlessURL);
|
||||
});
|
||||
|
||||
apiApp.use(APIVersionCompatibilityService.versionRewrites);
|
||||
apiApp.use(APIVersionCompatibilityService.contentVersion);
|
||||
|
||||
apiApp.lazyUse('/content/', require('./canary/content/app'));
|
||||
|
@ -1,23 +1,131 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`API Versioning Admin API 307 redirects GET with accept version set when version is included in the URL 1: [headers] 1`] = `
|
||||
exports[`API Versioning Admin API Does an internal rewrite for canary URLs with accept version set 1: [body] 1`] = `
|
||||
Object {
|
||||
"accept-version": "canary",
|
||||
"content-length": "57",
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"location": StringMatching /\\^\\\\/ghost\\\\/api\\\\/admin\\\\/site\\\\/\\$/,
|
||||
"vary": "Accept, Accept-Encoding",
|
||||
"site": Object {
|
||||
"accent_color": "#FF1A75",
|
||||
"description": "Thoughts, stories and ideas",
|
||||
"icon": null,
|
||||
"logo": null,
|
||||
"title": "Ghost",
|
||||
"url": "http://127.0.0.1:2369/",
|
||||
"version": StringMatching /\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Admin API Does an internal rewrite for canary URLs with accept version set 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "167",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"deprecation": "version=\\"v4\\"",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"link": "<http://127.0.0.1:2369/ghost/api/admin/site/>; rel=\\"latest-version\\"",
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Admin API 307 redirects POST with accept version set when version is included in the URL 1: [headers] 1`] = `
|
||||
exports[`API Versioning Admin API Does an internal rewrite for v3 URL + POST with accept version set 1: [body] 1`] = `
|
||||
Object {
|
||||
"accept-version": "v3.0",
|
||||
"content-length": "60",
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"location": StringMatching /\\^\\\\/ghost\\\\/api\\\\/admin\\\\/session\\\\/\\$/,
|
||||
"vary": "Accept, Accept-Encoding",
|
||||
"tags": Array [
|
||||
Object {
|
||||
"accent_color": null,
|
||||
"canonical_url": null,
|
||||
"codeinjection_foot": null,
|
||||
"codeinjection_head": null,
|
||||
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"description": null,
|
||||
"feature_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
"name": "version tag",
|
||||
"og_description": null,
|
||||
"og_image": null,
|
||||
"og_title": null,
|
||||
"slug": "version-tag",
|
||||
"twitter_description": null,
|
||||
"twitter_image": null,
|
||||
"twitter_title": null,
|
||||
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
|
||||
"url": "http://127.0.0.1:2369/404/",
|
||||
"visibility": "public",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Admin API Does an internal rewrite for v3 URL + POST with accept version set 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "521",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"deprecation": "version=\\"v3\\"",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"link": "<http://127.0.0.1:2369/ghost/api/admin/tags/>; rel=\\"latest-version\\"",
|
||||
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/tags\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-cache-invalidate": "/*",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Admin API allows invalid accept-version header 1: [body] 1`] = `
|
||||
Object {
|
||||
"site": Object {
|
||||
"accent_color": "#FF1A75",
|
||||
"description": "Thoughts, stories and ideas",
|
||||
"icon": null,
|
||||
"logo": null,
|
||||
"title": "Ghost",
|
||||
"url": "http://127.0.0.1:2369/",
|
||||
"version": StringMatching /\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Admin API allows invalid accept-version header 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "167",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Admin API ignores invalid accept version header 1: [body] 1`] = `
|
||||
Object {
|
||||
"site": Object {
|
||||
"accent_color": "#FF1A75",
|
||||
"description": "Thoughts, stories and ideas",
|
||||
"icon": null,
|
||||
"logo": null,
|
||||
"title": "Ghost",
|
||||
"url": "http://127.0.0.1:2369/",
|
||||
"version": StringMatching /\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
},
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Admin API ignores invalid accept version header 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "http://127.0.0.1:2369",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "167",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"vary": "Origin, Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
@ -310,13 +418,55 @@ Object {
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Content API 307 redirects with accept version set when version is included in the URL 1: [headers] 1`] = `
|
||||
exports[`API Versioning Content API Does an internal rewrite with accept version set when version is included in the URL 1: [body] 1`] = `
|
||||
Object {
|
||||
"accept-version": "canary",
|
||||
"content-length": "91",
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"location": StringMatching /\\^\\\\/ghost\\\\/api\\\\/content\\\\/posts\\\\//,
|
||||
"vary": "Accept, Accept-Encoding",
|
||||
"meta": Object {
|
||||
"pagination": Object {
|
||||
"limit": 1,
|
||||
"next": null,
|
||||
"page": 1,
|
||||
"pages": 1,
|
||||
"prev": null,
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
"tags": Array [
|
||||
Object {
|
||||
"accent_color": null,
|
||||
"canonical_url": null,
|
||||
"codeinjection_foot": null,
|
||||
"codeinjection_head": null,
|
||||
"description": null,
|
||||
"feature_image": null,
|
||||
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
|
||||
"meta_description": null,
|
||||
"meta_title": null,
|
||||
"name": "Getting Started",
|
||||
"og_description": null,
|
||||
"og_image": null,
|
||||
"og_title": null,
|
||||
"slug": "getting-started",
|
||||
"twitter_description": null,
|
||||
"twitter_image": null,
|
||||
"twitter_title": null,
|
||||
"url": "http://127.0.0.1:2369/tag/getting-started/",
|
||||
"visibility": "public",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`API Versioning Content API Does an internal rewrite with accept version set when version is included in the URL 2: [headers] 1`] = `
|
||||
Object {
|
||||
"access-control-allow-origin": "*",
|
||||
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
|
||||
"content-length": "552",
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
|
||||
"deprecation": "version=\\"v4\\"",
|
||||
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
|
||||
"link": "<http://127.0.0.1:2369/ghost/api/content/tags/?limit=1&key=cccccccccccccccccccccccccc>; rel=\\"latest-version\\"",
|
||||
"vary": "Accept-Encoding",
|
||||
"x-powered-by": "Express",
|
||||
}
|
||||
`;
|
||||
|
@ -1,5 +1,5 @@
|
||||
const {agentProvider, fixtureManager, matchers, mockManager} = require('../../utils/e2e-framework');
|
||||
const {anyErrorId, stringMatching, anyEtag} = matchers;
|
||||
const {anyErrorId, stringMatching, anyObjectId, anyLocationFor, anyISODateTime, anyEtag} = matchers;
|
||||
|
||||
describe('API Versioning', function () {
|
||||
describe('Admin API', function () {
|
||||
@ -65,6 +65,22 @@ describe('API Versioning', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('allows invalid accept-version header', async function () {
|
||||
await agentAdminAPI
|
||||
.get('site/')
|
||||
.header('Accept-Version', 'canary')
|
||||
.expectStatus(200)
|
||||
.matchBodySnapshot({
|
||||
site: {
|
||||
version: stringMatching(/\d+\.\d+/)
|
||||
}
|
||||
})
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
'content-version': stringMatching(/v\d+\.\d+/)
|
||||
});
|
||||
});
|
||||
|
||||
it('responds with error requested version is AHEAD and CANNOT respond', async function () {
|
||||
// CASE 2: If accept-version is behind, send a 406 & tell them the client needs updating.
|
||||
await agentAdminAPI
|
||||
@ -166,24 +182,38 @@ describe('API Versioning', function () {
|
||||
});
|
||||
});
|
||||
|
||||
it('307 redirects GET with accept version set when version is included in the URL', async function () {
|
||||
it('Does an internal rewrite for canary URLs with accept version set', async function () {
|
||||
await agentAdminAPI
|
||||
.get('/site/', {baseUrl: '/ghost/api/canary/admin/'})
|
||||
.expectStatus(307)
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
location: stringMatching(/^\/ghost\/api\/admin\/site\/$/)
|
||||
etag: anyEtag,
|
||||
'content-version': stringMatching(/v\d+\.\d+/)
|
||||
})
|
||||
.expectEmptyBody();
|
||||
.matchBodySnapshot({site: {
|
||||
version: stringMatching(/\d+\.\d+/)
|
||||
}});
|
||||
});
|
||||
|
||||
it('307 redirects POST with accept version set when version is included in the URL', async function () {
|
||||
it('Does an internal rewrite for v3 URL + POST with accept version set', async function () {
|
||||
await agentAdminAPI
|
||||
.post('/session/', {baseUrl: '/ghost/api/v3/admin/'})
|
||||
.expectStatus(307)
|
||||
.matchHeaderSnapshot({
|
||||
location: stringMatching(/^\/ghost\/api\/admin\/session\/$/)
|
||||
.post('/tags/', {baseUrl: '/ghost/api/v3/admin/'})
|
||||
.body({
|
||||
tags: [{name: 'version tag'}]
|
||||
})
|
||||
.expectEmptyBody();
|
||||
.expectStatus(201)
|
||||
.matchHeaderSnapshot({
|
||||
etag: anyEtag,
|
||||
location: anyLocationFor('tags'),
|
||||
'content-version': stringMatching(/v\d+\.\d+/)
|
||||
})
|
||||
.matchBodySnapshot({
|
||||
tags: [{
|
||||
id: anyObjectId,
|
||||
created_at: anyISODateTime,
|
||||
updated_at: anyISODateTime
|
||||
}]
|
||||
});
|
||||
});
|
||||
|
||||
it('responds with 406 for an unknown version with accept-version set ahead', async function () {
|
||||
@ -243,14 +273,19 @@ describe('API Versioning', function () {
|
||||
.matchBodySnapshot();
|
||||
});
|
||||
|
||||
it('307 redirects with accept version set when version is included in the URL', async function () {
|
||||
it('Does an internal rewrite with accept version set when version is included in the URL', async function () {
|
||||
await agentContentAPI
|
||||
.get('/posts/', {baseUrl: '/ghost/api/canary/content/'})
|
||||
.expectStatus(307)
|
||||
.get('/tags/?limit=1', {baseUrl: '/ghost/api/canary/content/'})
|
||||
.expectStatus(200)
|
||||
.matchHeaderSnapshot({
|
||||
location: stringMatching(/^\/ghost\/api\/content\/posts\//)
|
||||
etag: anyEtag,
|
||||
'content-version': stringMatching(/v\d+\.\d+/)
|
||||
})
|
||||
.expectEmptyBody();
|
||||
.matchBodySnapshot({
|
||||
tags: [{
|
||||
id: anyObjectId
|
||||
}]
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user