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