Merge branch 'v2.1-dev'
[squeep-api-dingus] / lib / content-negotiation.js
1 'use strict';
2
3 const common = require('./common');
4 const Enum = require('./enum');
5
6
7 // A weight value smaller than the allowed resolution, for minute wildcard de-preferencing.
8 const WeightIota = 0.0001;
9
10 class ContentNegotiation {
11
12 /**
13 * Convert accept clause string to object.
14 * Adjust weight based on wildcards, to prefer literal matches.
15 * @param {string} clause
16 */
17 static _unpackAcceptClause(clause) {
18 let params = clause.split(';');
19 const type = params.shift().trim();
20 if (type) {
21 let weight = 1.0;
22 params = params.reduce((acc, param) => {
23 const [p, v] = common.splitFirst(param, '=').map((x) => x && x.trim());
24 if (p && v) {
25 if (p === 'q') {
26 weight = Number(v);
27 // Enforce max precision from spec, so that we can...
28 weight = Number(weight.toFixed(3));
29 // De-tune the quality slightly for wildcards.
30 const blur = (type.split('*').length - 1) * WeightIota;
31 if (weight) {
32 weight -= blur;
33 }
34 } else {
35 // eslint-disable-next-line security/detect-object-injection
36 acc[p] = v;
37 }
38 }
39 return acc;
40 }, {});
41 return { type, weight, params };
42 }
43 }
44
45 /**
46 * Split an accept field into clauses, return list of clauses sorted by heaviest weights first.
47 * @param {string} acceptHeader
48 */
49 static _acceptClauses(acceptHeader) {
50 const clauses = (acceptHeader||'').split(',').map((clause) => ContentNegotiation._unpackAcceptClause(clause)).filter((clause) => clause);
51 return clauses.sort((a, b) => b.weight - a.weight);
52 }
53
54 /**
55 * Check if an Accept-able Content-Type matches a fixed Content-Type.
56 * (Allows for '*' fields in Accept-able type.)
57 * @param {string} pattern
58 * @param {string} type
59 */
60 static _matchType(acceptableType, fixedType) {
61 acceptableType = common.splitFirst(acceptableType, '/', '*');
62 fixedType = common.splitFirst(fixedType, '/', '*');
63 for (let i = 0; i < acceptableType.length; i++) {
64 // eslint-disable-next-line security/detect-object-injection
65 const v = acceptableType[i];
66 // eslint-disable-next-line security/detect-object-injection
67 const f = fixedType[i];
68 if (v !== f && v !== '*') {
69 return false;
70 }
71 }
72 return true;
73 }
74
75 /**
76 * Return the best match between available and acceptable types.
77 * @param {string[]} acceptableTypes
78 * @param {string} acceptHeader
79 */
80 static accept(acceptableTypes, acceptHeader) {
81 const validTypesQuality = {};
82 if (!acceptHeader) {
83 return acceptableTypes[0];
84 }
85 // For each type offered in the header, from heaviest to lightest...
86 ContentNegotiation._acceptClauses(acceptHeader).forEach((a) => {
87 // Consider each supported type...
88 acceptableTypes.forEach((t) => {
89 // Remember the heaviest weighting if it matches.
90 if (ContentNegotiation._matchType(a.type, t)
91 // eslint-disable-next-line security/detect-object-injection
92 && (!(t in validTypesQuality) || validTypesQuality[t] < a.weight)) {
93 // eslint-disable-next-line security/detect-object-injection
94 validTypesQuality[t] = a.weight;
95 }
96 });
97 });
98 return Object.keys(validTypesQuality).reduce((acc, cur) => {
99 // eslint-disable-next-line security/detect-object-injection
100 if (acc === undefined && validTypesQuality[cur] !== 0.0) {
101 return cur;
102 }
103 // eslint-disable-next-line security/detect-object-injection
104 return validTypesQuality[acc] < validTypesQuality[cur] ? cur : acc;
105 }, undefined);
106 }
107
108
109 /**
110 * Return all viable matches between acceptable and requested encodings, ordered by highest preference first.
111 * TODO: sort equal q-values by server-preference rather than header order
112 * @param {string[]} acceptableEncodings e.g. ['br', 'gzip'] in order of server preference
113 * @param {string} acceptHeader
114 */
115 static preferred(acceptableEncodings, acceptHeader) {
116 const Identity = Enum.EncodingType.Identity;
117 const Any = '*';
118
119 // Don't munge caller's list.
120 acceptableEncodings = [...acceptableEncodings];
121
122 // Server is always capable of identity encoding.
123 if (!(acceptableEncodings.includes(Identity))) {
124 acceptableEncodings.push(Identity);
125 }
126
127 const acceptClauses = ContentNegotiation._acceptClauses(acceptHeader);
128
129 // Add identity as fallback clause if an Any type was not explicitly mentioned.
130 const acceptTypes = acceptClauses.map((enc) => enc.type);
131 if (!(acceptTypes.includes(Any)) && !(acceptTypes.includes(Identity))) {
132 acceptClauses.push({
133 type: Identity,
134 weight: WeightIota,
135 params: [],
136 });
137 }
138
139 // Explicitly forbidden encodings will not be considered.
140 const forbidden = acceptClauses.filter((enc) => enc.weight == 0).map((enc) => enc.type);
141
142 acceptableEncodings = acceptableEncodings.filter((enc) => {
143 // If * is forbidden, don't allow identity encoding.
144 const fallbackIsForbidden = forbidden.includes(Any) && enc === Identity && !acceptTypes.includes(Identity);
145
146 const typeIsForbidden = forbidden.includes(enc.type);
147 return !typeIsForbidden && !fallbackIsForbidden;
148 });
149
150 // Strip forbidden and unsupported from working set.
151 const allowedClauses = acceptClauses.filter((enc) => {
152 const isAllowed = enc.weight > 0;
153 const isSupported = enc.type === Any || acceptableEncodings.includes(enc.type);
154 return isAllowed && isSupported;
155 });
156
157 // Only the types.
158 return allowedClauses.map((enc) => enc.type);
159 }
160
161 }
162
163 module.exports = ContentNegotiation;