bump package version to 2.1.0
[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?.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(',')
51 .map((clause) => ContentNegotiation._unpackAcceptClause(clause))
52 .filter((clause) => clause);
53 return clauses.sort((a, b) => b.weight - a.weight);
54 }
55
56 /**
57 * Check if an Accept-able Content-Type matches a fixed Content-Type.
58 * (Allows for '*' fields in Accept-able type.)
59 * @param {string} pattern
60 * @param {string} type
61 */
62 static _matchType(acceptableType, fixedType) {
63 acceptableType = common.splitFirst(acceptableType, '/', '*');
64 fixedType = common.splitFirst(fixedType, '/', '*');
65 for (let i = 0; i < acceptableType.length; i++) {
66 // eslint-disable-next-line security/detect-object-injection
67 const v = acceptableType[i];
68 // eslint-disable-next-line security/detect-object-injection
69 const f = fixedType[i];
70 if (v !== f && v !== '*') {
71 return false;
72 }
73 }
74 return true;
75 }
76
77 /**
78 * Return the best match between available and acceptable types.
79 * @param {string[]} acceptableTypes
80 * @param {string} acceptHeader
81 */
82 static accept(acceptableTypes, acceptHeader) {
83 const validTypesQuality = {};
84 if (!acceptHeader) {
85 return acceptableTypes[0];
86 }
87 // For each type offered in the header, from heaviest to lightest...
88 ContentNegotiation._acceptClauses(acceptHeader).forEach((a) => {
89 // Consider each supported type...
90 acceptableTypes.forEach((t) => {
91 // Remember the heaviest weighting if it matches.
92 if (ContentNegotiation._matchType(a.type, t)
93 // eslint-disable-next-line security/detect-object-injection
94 && (!(t in validTypesQuality) || validTypesQuality[t] < a.weight)) {
95 // eslint-disable-next-line security/detect-object-injection
96 validTypesQuality[t] = a.weight;
97 }
98 });
99 });
100 return Object.keys(validTypesQuality).reduce((acc, cur) => {
101 // eslint-disable-next-line security/detect-object-injection
102 if (acc === undefined && validTypesQuality[cur] !== 0.0) {
103 return cur;
104 }
105 // istanbul ignore next
106 // eslint-disable-next-line security/detect-object-injection
107 return validTypesQuality[acc] < validTypesQuality[cur] ? cur : acc;
108 }, undefined);
109 }
110
111
112 /**
113 * Return all viable matches between acceptable and requested encodings, ordered by highest preference first.
114 * TODO: sort equal q-values by server-preference rather than header order
115 * @param {string[]} acceptableEncodings e.g. ['br', 'gzip'] in order of server preference
116 * @param {string} acceptHeader
117 */
118 static preferred(acceptableEncodings, acceptHeader) {
119 const Identity = Enum.EncodingType.Identity;
120 const Any = '*';
121
122 // Don't munge caller's list.
123 acceptableEncodings = [...acceptableEncodings];
124
125 // Server is always capable of identity encoding.
126 if (!(acceptableEncodings.includes(Identity))) {
127 acceptableEncodings.push(Identity);
128 }
129
130 const acceptClauses = ContentNegotiation._acceptClauses(acceptHeader);
131
132 // Add identity as fallback clause if an Any type was not explicitly mentioned.
133 const acceptTypes = acceptClauses.map((enc) => enc.type);
134 if (!(acceptTypes.includes(Any)) && !(acceptTypes.includes(Identity))) {
135 acceptClauses.push({
136 type: Identity,
137 weight: WeightIota,
138 params: [],
139 });
140 }
141
142 // Explicitly forbidden encodings will not be considered.
143 const forbidden = acceptClauses.filter((enc) => enc.weight == 0).map((enc) => enc.type);
144
145 acceptableEncodings = acceptableEncodings.filter((enc) => {
146 // If * is forbidden, don't allow identity encoding.
147 const fallbackIsForbidden = forbidden.includes(Any) && enc === Identity && !acceptTypes.includes(Identity);
148
149 const typeIsForbidden = forbidden.includes(enc.type);
150 return !typeIsForbidden && !fallbackIsForbidden;
151 });
152
153 // Strip forbidden and unsupported from working set.
154 const allowedClauses = acceptClauses.filter((enc) => {
155 const isAllowed = enc.weight > 0;
156 const isSupported = enc.type === Any || acceptableEncodings.includes(enc.type);
157 return isAllowed && isSupported;
158 });
159
160 // Only the types.
161 return allowedClauses.map((enc) => enc.type);
162 }
163
164 }
165
166 module.exports = ContentNegotiation;