From 76aa2479f86134c4b07166c225abd6bc58c899a2 Mon Sep 17 00:00:00 2001 From: Naz Date: Wed, 6 Apr 2022 16:12:20 +0800 Subject: [PATCH] Added 'content-version' header response refs https://github.com/TryGhost/Toolbox/issues/280 - In response to 'Accept-Version' header in the request headers, Ghost will always respond with a content-version header indicating the version of the Ghost install that is responding. This should signal to the client the content version that is bein g served - This is a bare bones implementation and more logic with edge cases where `content-version` is served with a version value of "best format API could respond with" will be added later. --- core/server/api/shared/http.js | 5 +++- .../admin/__snapshots__/version.test.js.snap | 26 +++++++++++++++++ test/e2e-api/admin/version.test.js | 28 +++++++++++++++++++ test/unit/api/shared/http.test.js | 27 +++++++++++++++++- 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 test/e2e-api/admin/__snapshots__/version.test.js.snap create mode 100644 test/e2e-api/admin/version.test.js diff --git a/core/server/api/shared/http.js b/core/server/api/shared/http.js index 4318c98e7a..c77dc90586 100644 --- a/core/server/api/shared/http.js +++ b/core/server/api/shared/http.js @@ -64,7 +64,7 @@ const http = (apiImpl) => { const result = await apiImpl(frame); debug(`External API request to ${frame.docName}.${frame.method}`); - const headers = await shared.headers.get(result, apiImpl.headers, frame); + const headers = await shared.headers.get(result, apiImpl.headers, frame) || {}; // CASE: api ctrl wants to handle the express response (e.g. streams) if (typeof result === 'function') { @@ -82,6 +82,9 @@ const http = (apiImpl) => { res.status(statusCode); // CASE: generate headers based on the api ctrl configuration + if (req && req.headers && req.headers['accept-version'] && res.locals) { + headers['content-version'] = `v${res.locals.safeVersion}`; + } res.set(headers); const send = (format) => { diff --git a/test/e2e-api/admin/__snapshots__/version.test.js.snap b/test/e2e-api/admin/__snapshots__/version.test.js.snap new file mode 100644 index 0000000000..148dca50b0 --- /dev/null +++ b/test/e2e-api/admin/__snapshots__/version.test.js.snap @@ -0,0 +1,26 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`API Versioning responds with current content version header when requested version is behind current version with no known changes 1: [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": Any, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`API Versioning responds with no content version header when accept version header is not present 1: [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", + "etag": Any, + "vary": "Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/test/e2e-api/admin/version.test.js b/test/e2e-api/admin/version.test.js new file mode 100644 index 0000000000..4be017ba60 --- /dev/null +++ b/test/e2e-api/admin/version.test.js @@ -0,0 +1,28 @@ +const {agentProvider, matchers} = require('../../utils/e2e-framework'); +const {anyString, stringMatching} = matchers; + +describe('API Versioning', function () { + let agent; + + before(async function () { + agent = await agentProvider.getAdminAPIAgent(); + }); + + it('responds with no content version header when accept version header is NOT PRESENT', async function () { + await agent + .get('site/') + .matchHeaderSnapshot({ + etag: anyString + }); + }); + + it('responds with current content version header when requested version is behind current version with no known changes', async function () { + await agent + .get('site/') + .header('Accept-Version', 'v3.0') + .matchHeaderSnapshot({ + etag: anyString, + 'content-version': stringMatching(/v\d+\.\d+/) + }); + }); +}); diff --git a/test/unit/api/shared/http.test.js b/test/unit/api/shared/http.test.js index 0e754e1444..de5e712bfb 100644 --- a/test/unit/api/shared/http.test.js +++ b/test/unit/api/shared/http.test.js @@ -22,7 +22,9 @@ describe('Unit: api/shared/http', function () { res.status = sinon.stub(); res.json = sinon.stub(); - res.set = sinon.stub(); + res.set = (headers) => { + res.headers = headers; + }; res.send = sinon.stub(); sinon.stub(shared.headers, 'get').resolves(); @@ -85,4 +87,27 @@ describe('Unit: api/shared/http', function () { shared.http(apiImpl)(req, res, next); }); + + it('adds content-version header to the response when accept-version header is present in the request', function (done) { + const apiImpl = sinon.stub().resolves('data'); + req.headers = { + 'accept-version': 'v5.1' + }; + apiImpl.headers = { + 'Content-Type': 'application/json' + }; + res.locals = { + safeVersion: '5.4' + }; + next.callsFake(done); + + res.json.callsFake(function () { + shared.headers.get.calledOnce.should.be.true(); + res.status.calledOnce.should.be.true(); + res.headers['content-version'].should.equal('v5.4'); + done(); + }); + + shared.http(apiImpl)(req, res, next); + }); });