← 中文资源

简体中文版本

基于 q 值的 Accept 内容协商

解析带 q 值与通配符的 Accept 头,按质量、具体程度与服务器顺序选出最佳媒体类型。

阅读英文原文

功能说明

该示例实现服务端内容协商的核心逻辑。parseAcceptHeader 把 Accept 头拆成类型、子类型、q 值、附加参数与出现顺序;negotiateContentType 针对每个服务器支持的媒体类型,找到最具体的匹配范围,再按 q 值、具体程度和服务器偏好排序,返回最合适的类型或 null。可用于 API 返回 JSON 或 HTML、图片格式选择等场景。

关键代码

解析阶段分别处理缺失或空头,此时等价于星号接受一切。q 值必须落在零到一之间,非法值直接抛错而不是猜测。协商阶段对每个候选类型遍历全部范围,记录匹配范围中具体程度最高的一项:完全匹配为二,类型通配为一,全通配为零;具体程度相同时取头部中出现更早的范围。这样 q=0 的精确排除可以覆盖低 q 值的通配规则,随后才按 q 值排序。

使用注意

该实现不处理媒体类型参数级别的协商,例如 profile 或 charset,只比较类型与子类型。q 值相同的候选按 supported 数组顺序决定,调用方可以把更偏好的格式放在前面。返回 null 表示没有任何可接受类型,服务端通常应返回 406。解析器对明显畸形条目抛 SyntaxError,生产环境若需容错可在外层捕获并回退到默认类型。

验证方式

测试带多个 q 值的头部,确认高 q 值胜出;用 application/json;q=0, /;q=0.5 验证精确排除优先于通配;测试 text/* 与具体类型的优先级;缺少头部时返回 supported 第一项;q 值超出范围抛 RangeError;不支持的格式组合返回 null。

代码示例

export function parseAcceptHeader(header) {
  if (typeof header !== "string" || header.trim() === "") {
    return [{ type: "*", subtype: "*", q: 1, params: {}, order: 0 }];
  }
  const ranges = [];
  for (const [order, part] of header.split(",").entries()) {
    const segments = part.split(";").map((segment) => segment.trim());
    const mediaRange = segments.shift();
    if (!mediaRange) continue;
    const slash = mediaRange.indexOf("/");
    if (slash <= 0 || slash === mediaRange.length - 1) {
      throw new SyntaxError("invalid media range: " + mediaRange);
    }
    const type = mediaRange.slice(0, slash).toLowerCase();
    const subtype = mediaRange.slice(slash + 1).toLowerCase();
    const params = {};
    let q = 1;
    for (const segment of segments) {
      if (!segment) continue;
      const equals = segment.indexOf("=");
      if (equals <= 0) throw new SyntaxError("invalid parameter: " + segment);
      const name = segment.slice(0, equals).trim().toLowerCase();
      const raw = segment.slice(equals + 1).trim().replace(/^"|"$/g, "");
      if (name === "q") {
        q = Number(raw);
        if (!Number.isFinite(q) || q < 0 || q > 1) {
          throw new RangeError("invalid q value: " + raw);
        }
      } else {
        params[name] = raw;
      }
    }
    ranges.push({ type, subtype, q, params, order });
  }
  if (ranges.length === 0) throw new SyntaxError("Accept header has no media ranges");
  return ranges;
}

export function negotiateContentType(header, supported) {
  if (!Array.isArray(supported) || supported.length === 0) {
    throw new TypeError("supported must be a non-empty array");
  }
  const ranges = parseAcceptHeader(header);
  const candidates = [];
  for (const [order, mediaType] of supported.entries()) {
    const slash = mediaType.indexOf("/");
    if (slash <= 0) throw new TypeError("invalid supported media type: " + mediaType);
    const type = mediaType.slice(0, slash).toLowerCase();
    const subtype = mediaType.slice(slash + 1).toLowerCase();
    let best;
    for (const range of ranges) {
      const typeMatches = range.type === "*" || range.type === type;
      const subtypeMatches = range.subtype === "*" || range.subtype === subtype;
      if (!typeMatches || !subtypeMatches) continue;
      const specificity = (range.type === "*" ? 0 : 1) + (range.subtype === "*" ? 0 : 1);
      if (!best || specificity > best.specificity ||
        (specificity === best.specificity && range.order < best.order)) {
        best = { q: range.q, specificity, order: range.order };
      }
    }
    if (best && best.q > 0) {
      candidates.push({ mediaType, q: best.q, specificity: best.specificity, order });
    }
  }
  candidates.sort((a, b) => b.q - a.q || b.specificity - a.specificity || a.order - b.order);
  return candidates.length > 0 ? candidates[0].mediaType : null;
}