Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
[![Build Status][ci-image]][ci-url]
[![Coverage Status][coveralls-image]][coveralls-url]

Create and parse HTTP Content-Type header according to RFC 7231
Create and parse HTTP Content-Type header.

## Installation

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"http",
"req",
"res",
"rfc7231"
"rfc7231",
"rfc9110"
],
"repository": "jshttp/content-type",
"funding": {
Expand All @@ -24,6 +25,7 @@
"dist/"
],
"scripts": {
"bench": "vitest bench",
"build": "ts-scripts build",
"format": "ts-scripts format",
"lint": "ts-scripts lint",
Expand Down
59 changes: 59 additions & 0 deletions src/index.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { bench, describe } from "vitest";
import { format, parse } from "./index";

describe("parse", () => {
const BASIC_HEADER = "text/html";
const PARAMS_HEADER = "application/json; charset=utf-8; foo=bar; version=1";
const QUOTED_HEADER =
'text/plain; filename="report\\"-2026.csv"; foo=bar; version=1';
const OWS_HEADER =
"application/json; \t charset = utf-8 ;\t foo = bar ; version = 1";

bench("basic", () => {
parse(BASIC_HEADER);
});

bench("simple parameters", () => {
parse(PARAMS_HEADER);
});

bench("quoted and escaped parameters", () => {
parse(QUOTED_HEADER);
});

bench("OWS-heavy parameters", () => {
parse(OWS_HEADER);
});
});

describe("format", () => {
const BASIC_OBJECT = { type: "text/html" };
const PARAMS_OBJECT = {
type: "application/json",
parameters: {
charset: "utf-8",
profile: "urn:example:v1",
version: "1",
},
};
const QUOTED_OBJECT = {
type: "text/plain",
parameters: {
filename: 'report"-2026.csv',
foo: "test=bar",
q: "0.9",
},
};

bench("basic", () => {
format(BASIC_OBJECT);
});

bench("simple parameters", () => {
format(PARAMS_OBJECT);
});

bench("quoted and escaped parameters", () => {
format(QUOTED_OBJECT);
});
});
203 changes: 115 additions & 88 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,41 +4,16 @@
* MIT Licensed
*/

/**
* RegExp to match *( ";" parameter ) in RFC 7231 sec 3.1.1.1
*
* parameter = token "=" ( token / quoted-string )
* token = 1*tchar
* tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
* / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
* / DIGIT / ALPHA
* ; any VCHAR, except delimiters
* quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
* qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
* obs-text = %x80-FF
* quoted-pair = "\\" ( HTAB / SP / VCHAR / obs-text )
*/
const PARAM_REGEXP =
/;[\t ]*([!#$%&'*+.^_`|~0-9A-Za-z-]+)[\t ]*=[\t ]*("(?:[\u000b\u0020\u0021\u0023-\u005b\u005d-\u007e\u0080-\u00ff]|\\[\u000b\u0020-\u00ff])*"|[!#$%&'*+.^_`|~0-9A-Za-z-]+)[\t ]*/g;

const TEXT_REGEXP = /^[\u000b\u0020-\u007e\u0080-\u00ff]+$/; // eslint-disable-line no-control-regex
const TEXT_REGEXP = /^[\u000b\u0020-\u007e\u0080-\u00ff]+$/;
const TOKEN_REGEXP = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;

/**
* RegExp to match quoted-pair in RFC 7230 sec 3.2.6
*
* quoted-pair = "\\" ( HTAB / SP / VCHAR / obs-text )
* obs-text = %x80-FF
*/
const QESC_REGEXP = /\\([\u000b\u0020-\u00ff])/g; // eslint-disable-line no-control-regex

/**
* RegExp to match chars that must be quoted-pair in RFC 7230 sec 3.2.6
* RegExp to match chars that must be quoted-pair in RFC 9110 sec 5.6.4
*/
const QUOTE_REGEXP = /([\\"])/g;
const QUOTE_REGEXP = /[\\"]/g;

/**
* RegExp to match type in RFC 7231 sec 3.1.1.1
* RegExp to match type in RFC 9110 sec 8.3.1
*
* media-type = type "/" subtype
* type = token
Expand Down Expand Up @@ -67,8 +42,7 @@ export function format(obj: ContentTypeFormat): string {
throw new TypeError("argument obj is required");
}

const parameters = obj.parameters;
const type = obj.type;
const { type, parameters } = obj;

if (!type || !TYPE_REGEXP.test(type)) {
throw new TypeError("invalid type");
Expand All @@ -78,16 +52,12 @@ export function format(obj: ContentTypeFormat): string {

// append parameters
if (parameters && typeof parameters === "object") {
const params = Object.keys(parameters).sort();

for (let i = 0; i < params.length; i++) {
const param = params[i];

for (const param of Object.keys(parameters).sort()) {
if (!TOKEN_REGEXP.test(param)) {
throw new TypeError("invalid parameter name");
}

string += "; " + param + "=" + qstring(parameters[param]);
string += `; ${param}=${qstring(parameters[param])}`;
}
}

Expand All @@ -99,75 +69,142 @@ export function parse(string: string | ContentTypeSource): ContentType {
throw new TypeError("argument string is required");
}

// support req/res-like objects as argument
const header = typeof string === "object" ? getcontenttype(string) : string;

if (typeof header !== "string") {
throw new TypeError("argument string is required to be a string");
}

let index = header.indexOf(";");
const type = index !== -1 ? header.slice(0, index).trim() : header.trim();
const len = header.length;
const semiIndex = header.indexOf(";");
const end = semiIndex !== -1 ? semiIndex : len;
const valueStart = skipOWS(header, 0, end);
const valueEnd = trailingOWS(header, valueStart, end);
const type =
valueStart === 0 && valueEnd === len
? header.toLowerCase()
: header.slice(valueStart, valueEnd).toLowerCase();

if (!TYPE_REGEXP.test(type)) {
throw new TypeError("invalid media type");
}

const obj = new ContentTypeImpl(type.toLowerCase());
const parameters = parseParameters(header, end, len);

// parse parameters
if (index !== -1) {
let match: RegExpExecArray | null;
return { type, parameters };
}

PARAM_REGEXP.lastIndex = index;
function getcontenttype(obj: ContentTypeSource): string {
let header: unknown;

while ((match = PARAM_REGEXP.exec(header))) {
if (match.index !== index) {
throw new TypeError("invalid parameter format");
}
if (typeof obj.getHeader === "function") {
header = obj.getHeader("content-type");
} else if (typeof obj.headers === "object") {
header = obj.headers && obj.headers["content-type"];
}

if (typeof header !== "string") {
throw new TypeError("content-type header is missing from object");
}

return header;
}

function parseParameters(
header: string,
index: number,
len: number,
): Record<string, string> {
const parameters = Object.create(null);

while (index < len) {
// Skip `;` and OWS before parameter key.
index = skipOWS(header, index + 1, len);

const keyStart = index;

while (index < len) {
const char = header[index];
if (char === ";") break; // End of parameter, no value found, skip.

if (char === "=") {
const keyEnd = trailingOWS(header, keyStart, index);
const key = header.slice(keyStart, keyEnd).toLowerCase();

index = skipOWS(header, index + 1, len);

index += match[0].length;
if (header[index] === '"') {
index++;

const key = match[1].toLowerCase();
let value = match[2];
let value = "";
let quoted = false;
while (index < len) {
const char = header[index++];
if (char === '"') {
quoted = true;
break;
}

if (value.charCodeAt(0) === 0x22 /* " */) {
// remove quotes
value = value.slice(1, -1);
if (char === "\\") {
value += header[index++];
continue;
}

// remove escapes
if (value.indexOf("\\") !== -1) {
value = value.replace(QESC_REGEXP, "$1");
value += char;
}

if (!quoted) throw new TypeError("unexpected end of input");

index = skipOWS(header, index, len);
if (index < len && header[index] !== ";") {
throw new TypeError("unexpected non-separator character");
}

parameters[key] = value;
break;
}
}

obj.parameters[key] = value;
}
const valueStart = index;
while (index < len && header[index] !== ";") index++;

const valueEnd = trailingOWS(header, valueStart, index);
parameters[key] = header.slice(valueStart, valueEnd);
break;
}

if (index !== header.length) {
throw new TypeError("invalid parameter format");
index++;
}
}

return obj;
return parameters;
}

function getcontenttype(obj: ContentTypeSource): string {
let header: unknown;

if (typeof obj.getHeader === "function") {
// res-like
header = obj.getHeader("content-type");
} else if (typeof obj.headers === "object") {
// req-like
header = obj.headers && obj.headers["content-type"];
/**
* Skip optional whitespace (OWS) in an HTTP header value.
*
* OWS is defined in RFC 9110 sec 5.6.3 as SP (" ") or HTAB ("\t").
*/
function skipOWS(header: string, index: number, len: number): number {
Comment thread
blakeembrey marked this conversation as resolved.
while (index < len) {
const code = header[index];
if (code !== " " && code !== "\t") break;
index++;
}
return index;
}

if (typeof header !== "string") {
throw new TypeError("content-type header is missing from object");
/**
* Trim optional whitespace (OWS) from the end of a substring.
*
* OWS is defined in RFC 9110 sec 5.6.3 as SP (" ") or HTAB ("\t").
*/
function trailingOWS(header: string, start: number, end: number): number {
Comment thread
blakeembrey marked this conversation as resolved.
while (end > start) {
const code = header[end - 1];
if (code !== " " && code !== "\t") break;
end--;
}

return header;
return end;
}

function qstring(val: unknown): string {
Expand All @@ -182,15 +219,5 @@ function qstring(val: unknown): string {
throw new TypeError("invalid parameter value");
}

return '"' + str.replace(QUOTE_REGEXP, "\\$1") + '"';
}

class ContentTypeImpl implements ContentType {
parameters: Record<string, string>;
type: string;

constructor(type: string) {
this.parameters = Object.create(null);
this.type = type;
}
return '"' + str.replace(QUOTE_REGEXP, "\\$&") + '"';
}
Loading
Loading