Skip to content

Commit d562ea2

Browse files
arlolracscott
authored andcommitted
Content negotiation
* Add the facilities for content negotiation, as specified by https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.1 * The provided options are sorted by quality. When matching, we use semver caret semantics of the provided version to allow backward compatible changes, without the need for clients to constantly update their headers. * We use an unreleased version of the npm `negotiator` package from github. We can switch to the official npm release once jshttp/negotiator#40 is merged. Change-Id: Ie3a7042d78c310ef20eee79cb0a38c7cd40ec3cc
1 parent 15e76d3 commit d562ea2

File tree

6 files changed

+216
-1
lines changed

6 files changed

+216
-1
lines changed

lib/api/apiUtils.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,10 @@ apiUtils.dataParsoidContentType = function(env) {
282282
return 'application/json; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/data-parsoid/' + env.conf.parsoid.DATA_PARSOID_VERSION + '"';
283283
};
284284

285+
apiUtils.pagebundleContentType = function(env) {
286+
return 'application/json; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/pagebundle/' + env.conf.parsoid.HTML_VERSION + '"';
287+
};
288+
285289
/**
286290
* Validates that data-parsoid was provided in the expected format.
287291
*
@@ -312,3 +316,36 @@ apiUtils.fatalRequest = function(env, text, httpStatus) {
312316
err.suppressLoggingStack = true;
313317
env.log('fatal/request', err);
314318
};
319+
320+
var profileRE = /^https:\/\/www.mediawiki.org\/wiki\/Specs\/(HTML|pagebundle)\/(\d+\.\d+\.\d+)$/;
321+
322+
/**
323+
* Returns false if Parsoid is unable to supply an acceptable version.
324+
*
325+
* @method
326+
* @param {Response} res
327+
* @return {Boolean}
328+
*/
329+
apiUtils.validateContentVersion = function(res) {
330+
var env = res.locals.env;
331+
var opts = res.locals.opts;
332+
333+
// `acceptableTypes` is already sorted by quality.
334+
return !res.locals.acceptableTypes.length ||
335+
res.locals.acceptableTypes.some(function(t) {
336+
if ((opts.format === 'html' && t.type === 'text/html') ||
337+
(opts.format === 'pagebundle' && t.type === 'application/json')) {
338+
var tp = t.parameters;
339+
if (tp && tp.profile) {
340+
var match = profileRE.exec(tp.profile);
341+
return match && (opts.format === match[1].toLowerCase()) &&
342+
(env.resolveContentVersion(match[2]) !== null);
343+
} else {
344+
return true;
345+
}
346+
} else if (t.type === '*/*' ||
347+
(opts.format === 'html' && t.type === 'text/*')) {
348+
return true;
349+
}
350+
});
351+
};

lib/api/routes.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ require('../../core-upgrade.js');
44
var childProcess = require('child_process');
55
var corepath = require('path');
66
var qs = require('querystring');
7+
var Negotiator = require('negotiator');
78

89
var pkg = require('../../package.json');
910
var apiUtils = require('./apiUtils.js');
@@ -106,6 +107,11 @@ module.exports = function(parsoidConfig, processLogger) {
106107
}
107108
}
108109

110+
// Just do the parsing here.
111+
var negotiator = new Negotiator(req);
112+
res.locals.acceptableTypes =
113+
negotiator.mediaTypes(undefined, { detailed: true });
114+
109115
res.locals.opts = opts;
110116
next();
111117
};
@@ -373,6 +379,14 @@ module.exports = function(parsoidConfig, processLogger) {
373379
var stats = env.conf.parsoid.stats;
374380
var startTimers = new Map();
375381

382+
// Validate the content version
383+
if (!apiUtils.validateContentVersion(res)) {
384+
var text = env.availableVersions.reduce(function(prev, curr) {
385+
return prev + apiUtils[opts.format + 'ContentType'](env) + '\n';
386+
}, 'Not acceptable.\n');
387+
return apiUtils.fatalRequest(env, text, 406);
388+
}
389+
376390
var p = Promise.method(function() {
377391
// Check early if we have a wt string.
378392
if (typeof wt === 'string') {

lib/config/MWParserEnvironment.js

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22
require('../../core-upgrade.js');
33

4+
var semver = require('semver');
45
var Promise = require('../utils/promise.js');
56
var WikiConfig = require('./WikiConfig.js').WikiConfig;
67
var ConfigRequest = require('../mw/ApiRequest.js').ConfigRequest;
@@ -582,9 +583,35 @@ MWParserEnvironment.prototype.newAboutId = function() {
582583
return "#" + this.newObjectId();
583584
};
584585

585-
// Apply extra normalizations before serializing DOM.
586+
/**
587+
* Apply extra normalizations before serializing DOM.
588+
*/
586589
MWParserEnvironment.prototype.scrubWikitext = false;
587590

591+
/**
592+
* The content versions Parsoid knows how to produce.
593+
*/
594+
MWParserEnvironment.prototype.availableVersions = ['1.2.1']; // env.conf.parsoid.HTML_VERSION
595+
596+
/**
597+
* See if any content version Parsoid knows how to produce satisfies the
598+
* the supplied version, when interpreted with semver caret semantics.
599+
* This will allow us to make backwards compatible changes, without the need
600+
* for clients to bump the version in their headers all the time.
601+
*
602+
* @method
603+
* @param {String} v
604+
* @return {String|null}
605+
*/
606+
MWParserEnvironment.prototype.resolveContentVersion = function(v) {
607+
for (var i = 0; i < this.availableVersions.length; i++) {
608+
var a = this.availableVersions[i];
609+
if (semver.satisfies(a, '^' + v)) { return a; }
610+
}
611+
return null;
612+
};
613+
614+
588615
if (typeof module === "object") {
589616
module.exports.MWParserEnvironment = MWParserEnvironment;
590617
}

npm-shrinkwrap.json

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"finalhandler": "^0.4.0",
2020
"gelf-stream": "^0.2.4",
2121
"html5": "^1.0.5",
22+
"negotiator": "git+https://github.com/ethanresnick/negotiator#full-parse-access",
2223
"node-txstatsd": "^0.1.5",
2324
"node-uuid": "^1.4.7",
2425
"pegjs": "git+https://github.com/tstarling/pegjs#fork",

tests/mocha/api.js

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,137 @@ describe('Parsoid API', function() {
8282

8383
}); // formats
8484

85+
describe('accepts', function() {
86+
87+
var acceptableHtmlResponse = function(profile) {
88+
return function(res) {
89+
res.statusCode.should.equal(200);
90+
res.headers.should.have.property('content-type');
91+
res.headers['content-type'].should.equal(
92+
'text/html; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/HTML/' + profile + '"'
93+
);
94+
res.text.should.not.equal('');
95+
};
96+
};
97+
98+
var acceptablePageBundleResponse = function(profile) {
99+
return function(res) {
100+
res.statusCode.should.equal(200);
101+
res.body.should.have.property('html');
102+
res.body.html.should.have.property('headers');
103+
res.body.html.headers.should.have.property('content-type');
104+
res.body.html.headers['content-type'].should.equal(
105+
'text/html; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/HTML/' + profile + '"'
106+
);
107+
res.body.html.should.have.property('body');
108+
res.body.should.have.property('data-parsoid');
109+
res.body['data-parsoid'].should.have.property('headers');
110+
res.body['data-parsoid'].headers.should.have.property('content-type');
111+
// FIXME: There's only one content version now, and they should all have it.
112+
// res.body['data-parsoid'].headers['content-type'].should.equal(
113+
// 'application/json; charset=utf-8; profile="https://www.mediawiki.org/wiki/Specs/data-parsoid/' + profile + '"'
114+
// );
115+
res.body['data-parsoid'].should.have.property('body');
116+
};
117+
};
118+
119+
it('should not accept requests for older html versions', function(done) {
120+
request(api)
121+
.post(mockDomain + '/v3/transform/wikitext/to/html/')
122+
.set('Accept', 'text/html; profile="https://www.mediawiki.org/wiki/Specs/HTML/0.0.0"')
123+
.send({ wikitext: '== h2 ==' })
124+
.expect(406)
125+
.end(done);
126+
});
127+
128+
it('should not accept requests for older pagebundle versions', function(done) {
129+
request(api)
130+
.post(mockDomain + '/v3/transform/wikitext/to/pagebundle/')
131+
.set('Accept', 'application/json; profile="https://www.mediawiki.org/wiki/Specs/HTML/0.0.0"')
132+
.send({ wikitext: '== h2 ==' })
133+
.expect(406)
134+
.end(done);
135+
});
136+
137+
it('should not accept requests for other profiles', function(done) {
138+
request(api)
139+
.post(mockDomain + '/v3/transform/wikitext/to/html/')
140+
.set('Accept', 'text/html; profile="something different"')
141+
.send({ wikitext: '== h2 ==' })
142+
.expect(406)
143+
.end(done);
144+
});
145+
146+
it('should accept requests with no accept header', function(done) {
147+
var profile = '1.2.1';
148+
request(api)
149+
.post(mockDomain + '/v3/transform/wikitext/to/html/')
150+
.send({ wikitext: '== h2 ==' })
151+
.expect(200)
152+
.expect(acceptableHtmlResponse(profile))
153+
.end(done);
154+
});
155+
156+
it('should accept wildcards', function(done) {
157+
var profile = '1.2.1';
158+
request(api)
159+
.post(mockDomain + '/v3/transform/wikitext/to/html/')
160+
.set('Accept', '*/*')
161+
.send({ wikitext: '== h2 ==' })
162+
.expect(200)
163+
.expect(acceptableHtmlResponse(profile))
164+
.end(done);
165+
});
166+
167+
it('should prefer higher quality', function(done) {
168+
// FIXME: This test would be better if there were more available
169+
// versions. Fix it in the followup.
170+
var profile = '1.2.1';
171+
request(api)
172+
.post(mockDomain + '/v3/transform/wikitext/to/html/')
173+
.set('Accept', 'text/html; profile="https://www.mediawiki.org/wiki/Specs/HTML/0.0.0"; q=0.5,' +
174+
'text/html; profile="https://www.mediawiki.org/wiki/Specs/HTML/' + profile + '"; q=0.8')
175+
.send({ wikitext: '== h2 ==' })
176+
.expect(200)
177+
.expect(acceptableHtmlResponse(profile))
178+
.end(done);
179+
});
180+
181+
it('should accept requests for the latest html versions', function(done) {
182+
var profile = '1.2.1';
183+
request(api)
184+
.post(mockDomain + '/v3/transform/wikitext/to/html/')
185+
.set('Accept', 'text/html; profile="https://www.mediawiki.org/wiki/Specs/HTML/' + profile + '"')
186+
.send({ wikitext: '== h2 ==' })
187+
.expect(200)
188+
.expect(acceptableHtmlResponse(profile))
189+
.end(done);
190+
});
191+
192+
it('should accept requests for the latest pagebundle versions', function(done) {
193+
var profile = '1.2.1';
194+
request(api)
195+
.post(mockDomain + '/v3/transform/wikitext/to/pagebundle/')
196+
.set('Accept', 'application/json; profile="https://www.mediawiki.org/wiki/Specs/pagebundle/' + profile + '"')
197+
.send({ wikitext: '== h2 ==' })
198+
.expect(200)
199+
.expect(acceptablePageBundleResponse(profile))
200+
.end(done);
201+
});
202+
203+
it('should accept requests for the latest pagebundle versions', function(done) {
204+
var profile = '1.2.1';
205+
request(api)
206+
.post(mockDomain + '/v3/transform/wikitext/to/pagebundle/')
207+
.set('Accept', 'application/json; profile="https://www.mediawiki.org/wiki/Specs/pagebundle/' + profile + '"')
208+
.send({ wikitext: '== h2 ==' })
209+
.expect(200)
210+
.expect(acceptablePageBundleResponse(profile))
211+
.end(done);
212+
});
213+
214+
}); // accepts
215+
85216
describe("wt2html", function() {
86217

87218
var validHtmlResponse = function(expectFunc) {

0 commit comments

Comments
 (0)