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