440137448895b393fe2d7dc0903d9086a60347a9
[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 // eslint-disable-next-line security/detect-object-injection
106 return validTypesQuality[acc] < validTypesQuality[cur] ? cur : acc;
107 }, undefined);
108 }
109
110
111 /**
112 * Return all viable matches between acceptable and requested encodings, ordered by highest preference first.
113 * TODO: sort equal q-values by server-preference rather than header order
114 * @param {string[]} acceptableEncodings e.g. ['br', 'gzip'] in order of server preference
115 * @param {string} acceptHeader
116 */
117 static preferred(acceptableEncodings, acceptHeader) {
118 const Identity = Enum.EncodingType.Identity;
119 const Any = '*';
120
121 // Don't munge caller's list.
122 acceptableEncodings = [...acceptableEncodings];
123
124 // Server is always capable of identity encoding.
125 if (!(acceptableEncodings.includes(Identity))) {
126 acceptableEncodings.push(Identity);
127 }
128
129 const acceptClauses = ContentNegotiation._acceptClauses(acceptHeader);
130
131 // Add identity as fallback clause if an Any type was not explicitly mentioned.
132 const acceptTypes = acceptClauses.map((enc) => enc.type);
133 if (!(acceptTypes.includes(Any)) && !(acceptTypes.includes(Identity))) {
134 acceptClauses.push({
135 type: Identity,
136 weight: WeightIota,
137 params: [],
138 });
139 }
140
141 // Explicitly forbidden encodings will not be considered.
142 const forbidden = acceptClauses.filter((enc) => enc.weight == 0).map((enc) => enc.type);
143
144 acceptableEncodings = acceptableEncodings.filter((enc) => {
145 // If * is forbidden, don't allow identity encoding.
146 const fallbackIsForbidden = forbidden.includes(Any) && enc === Identity && !acceptTypes.includes(Identity);
147
148 const typeIsForbidden = forbidden.includes(enc.type);
149 return !typeIsForbidden && !fallbackIsForbidden;
150 });
151
152 // Strip forbidden and unsupported from working set.
153 const allowedClauses = acceptClauses.filter((enc) => {
154 const isAllowed = enc.weight > 0;
155 const isSupported = enc.type === Any || acceptableEncodings.includes(enc.type);
156 return isAllowed && isSupported;
157 });
158
159 // Only the types.
160 return allowedClauses.map((enc) => enc.type);
161 }
162
163 }
164
165 module.exports = ContentNegotiation;