diff --git a/core/server/services/api-version-compatibility/index.js b/core/server/services/api-version-compatibility/index.js
index 8fb4f772d2..975c0d8231 100644
--- a/core/server/services/api-version-compatibility/index.js
+++ b/core/server/services/api-version-compatibility/index.js
@@ -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;
diff --git a/core/server/web/api/app.js b/core/server/web/api/app.js
index 63e3dc89af..6aeb734e01 100644
--- a/core/server/web/api/app.js
+++ b/core/server/web/api/app.js
@@ -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'));
diff --git a/test/e2e-api/shared/__snapshots__/version.test.js.snap b/test/e2e-api/shared/__snapshots__/version.test.js.snap
index dca9868afb..f71f2a33fa 100644
--- a/test/e2e-api/shared/__snapshots__/version.test.js.snap
+++ b/test/e2e-api/shared/__snapshots__/version.test.js.snap
@@ -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": "; 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": "; 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": "; rel=\\"latest-version\\"",
+ "vary": "Accept-Encoding",
"x-powered-by": "Express",
}
`;
diff --git a/test/e2e-api/shared/version.test.js b/test/e2e-api/shared/version.test.js
index 90d59420ce..a1ec1ce23d 100644
--- a/test/e2e-api/shared/version.test.js
+++ b/test/e2e-api/shared/version.test.js
@@ -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
+ }]
+ });
});
});
});