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:
Hannah Wolfe 2022-05-05 08:45:24 +01:00 committed by GitHub
parent 420697291b
commit 7c795b4e26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 263 additions and 47 deletions

View File

@ -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;

View File

@ -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'));

View File

@ -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",
}
`;

View File

@ -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
}]
});
});
});
});