Skip to content

Commit 87c578a

Browse files
committed
Support custom negotiation strategies
Refactors the core negotiation algorithm out of `negotiateMediaTypeFactory` and into a new, swappable `defaultStrategy`. The factory now accepts an optional `strategy` function, allowing consumers to provide custom logic for media type selection. This change also resolves a minor bug in the tie-breaking logic where the best available type was not always chosen for a given `Accept` media range, particularly when parameter matching was the deciding factor. - Adds `IMediaTypeNegotiationStrategy` interface for custom implementations. - Includes dedicated unit tests for `defaultStrategy`. - Updates CI permissions.
1 parent a3e48bc commit 87c578a

File tree

8 files changed

+487
-98
lines changed

8 files changed

+487
-98
lines changed

.github/workflows/npm-publish.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ jobs:
1111
publish-npm:
1212
runs-on: ubuntu-latest
1313
permissions:
14+
attestations: write
1415
contents: read
1516
id-token: write
1617
environment: CI

.github/workflows/pull-request.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
name: Run tests before merging
2+
permissions:
3+
contents: read
24

35
on:
46
pull_request:

package-lock.json

Lines changed: 2 additions & 2 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@apeleghq/http-media-type-negotiator",
3-
"version": "1.0.6",
3+
"version": "1.0.7",
44
"description": "RFC-aware HTTP media type negotiator - parses and normalises Accept headers and media types (RFC 9110), supports q-value ranking, wildcard matching, and a permissive mode for real-world headers.",
55
"type": "module",
66
"main": "dist/index.cjs",

src/defaultStrategy.ts

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
/* Copyright © 2025 Apeleg Limited. All rights reserved.
2+
*
3+
* Permission to use, copy, modify, and distribute this software for any
4+
* purpose with or without fee is hereby granted, provided that the above
5+
* copyright notice and this permission notice appear in all copies.
6+
*
7+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8+
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
9+
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10+
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
11+
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
12+
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
13+
* PERFORMANCE OF THIS SOFTWARE.
14+
*/
15+
16+
import { $subtype, $type } from './constants.js';
17+
import type { IMediaTypeNegotiationStrategy } from './negotiateMediaTypeFactory.js';
18+
import type { TMediaType } from './parseMediaType.js';
19+
import { wm } from './utils.js';
20+
21+
/**
22+
* Find parameters that overlap between two media types, excluding the `q`
23+
* parameter.
24+
*
25+
* @internal
26+
* @param a - Candidate media type (typically from `Accept` header).
27+
* @param b - Available media type (server-provided).
28+
* @returns Array of matching `[name, value]` parameter pairs.
29+
*/
30+
const findOverlappingParams = (a: TMediaType, b: TMediaType) => {
31+
return a[1].filter((aparam) => {
32+
return (
33+
aparam[0] !== 'q' &&
34+
b[1].some((bparam) => {
35+
return aparam[0] === bparam[0] && aparam[1] === bparam[1];
36+
})
37+
);
38+
});
39+
};
40+
41+
/**
42+
* Implements the default HTTP content negotiation strategy, ranking candidates
43+
* according to the rules outlined in RFC 7231 §5.3.2.
44+
*
45+
* This function takes pre-parsed and pre-sorted lists of available (server) and
46+
* acceptable (client) media types and finds the single best match from the
47+
* available list. The selection process follows a strict, multi-stage ranking
48+
* algorithm:
49+
*
50+
* 1. **Filtering:** It first identifies all potential matches by comparing
51+
* each acceptable media range against all available types. A match can be
52+
* an exact type/subtype match, a subtype wildcard match (e.g., `text/*`)
53+
* or a full wildcard match (`*` + `/*`).
54+
* 2. **Quality Value (`q`):** It retains only the candidates that match the
55+
* highest q-value present among all potential matches. All others are
56+
* discarded.
57+
* 3. **Specificity Tie-Breaking:** If multiple candidates remain, they are
58+
* ranked by specificity in the following order of preference:
59+
* a. Full type/subtype (e.g., `application/json`) is preferred over
60+
* wildcard subtypes.
61+
* b. A wildcard subtype (e.g., `text/*`) is preferred over the full
62+
* wildcard (`*` + `/*`).
63+
* c. The number of matching non-q parameters. A media range with more
64+
* matching parameters to an available type is preferred.
65+
* d. Server preference. As a final tie-breaker, the original order of the
66+
* `availableMediaTypes` array is used, preferring the one that
67+
* appeared earliest.
68+
*
69+
* @internal
70+
* @param availableMediaTypes - A readonly array of pre-parsed and normalised
71+
* media types supported by the server, in order of server preference.
72+
* @param acceptableMediaTypes - A readonly array of pre-parsed media types
73+
* from the client's `Accept` header, pre-sorted by q-value in descending
74+
* order.
75+
* @param qMap - A map that holds the q-value (weight) for each acceptable
76+
* media type, used for efficient lookups.
77+
* @returns The best-matching normalised media type from the
78+
* `parsedAvailableTypes` array, or `null` if no suitable match is found.
79+
*/
80+
const defaultStrategy = ((availableMediaTypes, acceptableMediaTypes, qMap) => {
81+
const map = wm<
82+
(typeof acceptableMediaTypes)[number],
83+
(typeof availableMediaTypes)[number]
84+
>();
85+
86+
// First pass: remove media types that aren't possible
87+
const overlappingTypes = acceptableMediaTypes.filter(
88+
(acceptableMediaType) => {
89+
const possible: (typeof availableMediaTypes)[number][] = [];
90+
availableMediaTypes.forEach((availableMediaType) => {
91+
if (acceptableMediaType[0] === availableMediaType[0]) {
92+
possible.push(availableMediaType);
93+
} else if (
94+
acceptableMediaType[$subtype] === '*' &&
95+
acceptableMediaType[$type] === availableMediaType[$type]
96+
) {
97+
possible.push(availableMediaType);
98+
} else if (
99+
acceptableMediaType[$type] === '*' &&
100+
acceptableMediaType[$subtype] === '*'
101+
) {
102+
possible.push(availableMediaType);
103+
}
104+
});
105+
106+
possible.sort((a, b) => {
107+
const overlappingA = findOverlappingParams(
108+
acceptableMediaType,
109+
a,
110+
).length;
111+
const overlappingB = findOverlappingParams(
112+
acceptableMediaType,
113+
b,
114+
).length;
115+
116+
return overlappingB - overlappingA;
117+
});
118+
119+
if (possible.length) {
120+
map.set(acceptableMediaType, possible[0]);
121+
return true;
122+
}
123+
124+
return false;
125+
},
126+
);
127+
128+
if (overlappingTypes.length === 0) {
129+
return null;
130+
}
131+
132+
// Second pass: keep highest preference only
133+
const highestQ = qMap.get(overlappingTypes[0])!;
134+
for (let i = 1; i < overlappingTypes.length; i++) {
135+
const q = qMap.get(overlappingTypes[i])!;
136+
if (q !== highestQ) {
137+
overlappingTypes.splice(i);
138+
break;
139+
}
140+
}
141+
142+
// Now, find the type with the highest specificity
143+
overlappingTypes.sort((a, b) => {
144+
if (a[$type] === '*' && b[$type] !== '*') {
145+
return 1;
146+
} else if (a[$type] !== '*' && b[$type] === '*') {
147+
return -1;
148+
} else if (a[$subtype] === '*' && b[$subtype] !== '*') {
149+
return 1;
150+
} else if (a[$subtype] !== '*' && b[$subtype] === '*') {
151+
return -1;
152+
}
153+
154+
const availableA = map.get(a)!;
155+
const availableB = map.get(b)!;
156+
157+
const overlappingA = findOverlappingParams(a, availableA).length;
158+
const overlappingB = findOverlappingParams(b, availableB).length;
159+
160+
const result = overlappingB - overlappingA;
161+
if (result !== 0) {
162+
return result;
163+
}
164+
165+
// If everything is equal, prefer server order
166+
return (
167+
availableMediaTypes.indexOf(availableA) -
168+
availableMediaTypes.indexOf(availableB)
169+
);
170+
});
171+
172+
const highestRanked = overlappingTypes[0];
173+
const availableType = map.get(highestRanked)!;
174+
175+
return availableType;
176+
}) satisfies IMediaTypeNegotiationStrategy;
177+
178+
export default defaultStrategy;

0 commit comments

Comments
 (0)