Skip to content

Instantly share code, notes, and snippets.

@iso2022jp
Created September 23, 2025 07:04
Show Gist options
  • Select an option

  • Save iso2022jp/da95dc6cb5ae630e86407c9a32a2cc82 to your computer and use it in GitHub Desktop.

Select an option

Save iso2022jp/da95dc6cb5ae630e86407c9a32a2cc82 to your computer and use it in GitHub Desktop.
Structured Field Values for HTTP
'use strict';
const base32 = require('./base32'); // IETF HTTP Working Group Structured Field Tests JSON Schema support
/** @module RFC9651 */
/**
* Structured Field Values for HTTP
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-working-with-structured-fie
*/
/**
* HTTP structured field types
* @readonly
* @enum {string}
*/
const FieldType = {
LIST: 'list',
DICTIONARY: 'dictionary',
ITEM: 'item',
}
/**
* Bare item types
* @readonly
* @enum {string}
*/
const DataType = {
// INNER_LIST: 'inner-list',
INTEGER: 'integer',
DECIMAL: 'decimal',
STRING: 'string',
TOKEN: 'token',
BYTE_SEQUENCE: 'binary',
BOOLEAN: 'boolean',
DATE: 'date',
DISPLAY_STRING: 'displaystring',
}
//
// Type converters
//
/** @typedef {List<ListMember>|Iterable<ListMemberLike>} ListLike */
/** @typedef {ListMember|ItemLike|InnerListLike} ListMemberLike */
/** @typedef {InnerList<Item>|Iterable<ItemLike>} InnerListLike */
/** @typedef {Parameters|Object<string, BareItemObject|BareItem>|Map<string, BareItemObject|BareItem>} ParametersLike */
/** @typedef {Dictionary|Object<string, MemberValueLike>|Iterable<[string, MemberValue]>} DictionaryLike */
/** @typedef {MemberValue|ItemLike|InnerListLike} MemberValueLike */
/** @typedef {Item|BareItemObject|BareItem} ItemLike */
/**
* Lists are arrays of zero or more members, each of which can be an Item (Section 3.3) or an Inner List (Section 3.1.1), both of which can be Parameterized (Section 3.1.2).
*
* An empty List is denoted by not serializing the field at all. This implies that fields defined as Lists have a default empty value.
*
* When serialized as a textual HTTP field, each member is separated by a comma and optional whitespace.
* ```
* sf-list = list-member *( OWS "," OWS list-member )
* list-member = sf-item / inner-list
* ```
* @param {ListLike} members
* @returns {List<ListMember>}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-lists 3.1. Lists
*/
const toList = members => members instanceof List ? members : new List(members);
/** @typedef {Item|InnerList<Item>} ListMember */
/**
* An Inner List is an array of zero or more Items (Section 3.3). Both the individual Items and the Inner List itself can be Parameterized (Section 3.1.2).
*
* When serialized in a textual HTTP field, Inner Lists are denoted by surrounding parenthesis, and their values are delimited by one or more spaces.
* ```
* inner-list = "(" *SP [ sf-item *( 1*SP sf-item ) *SP ] ")" parameters
* ```
* @param {InnerListLike} items
* @param {ParametersLike} parameters
* @returns {InnerList<Item>}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-inner-lists 3.1.1. Inner Lists
*/
const toInnerList = (items, parameters = {}) =>
items instanceof InnerList ? items : new InnerList(items, parameters);
/**
* Parameters are an ordered map of key-value pairs that are associated with an Item (Section 3.3) or Inner List (Section 3.1.1). The keys are unique within the scope of the Parameters they occur within, and the values are bare items (i.e., they themselves cannot be parameterized; see Section 3.3).
* ```
* parameters = *( ";" *SP parameter )
* parameter = param-key [ "=" param-value ]
* param-key = key
* key = ( lcalpha / "*" ) *( lcalpha / DIGIT / "_" / "-" / "." / "*" )
* lcalpha = %x61-7A ; a-z
* param-value = bare-item
* ```
* @param {ParametersLike} parameters
* @returns {Parameters}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-parameters 3.1.2. Parameters
*/
const toParameters = parameters => parameters instanceof Parameters ? parameters : new Parameters(parameters);
/**
* Dictionaries are ordered maps of key-value pairs, where the keys are short textual strings and the values are Items (Section 3.3) or arrays of Items, both of which can be Parameterized (Section 3.1.2). There can be zero or more members, and their keys are unique in the scope of the Dictionary they occur within. * @param {RFC9651InnerListLike} items
* ```
* sf-dictionary = dict-member *( OWS "," OWS dict-member )
* dict-member = member-key ( parameters / ( "=" member-value ))
* member-key = key
* member-value = sf-item / inner-list
* ```
* @param {DictionaryLike} dictionary
* @returns {Dictionary}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-dictionaries 3.2. Dictionaries
*/
const toDictionary = dictionary => dictionary instanceof Dictionary ? dictionary : new Dictionary(dictionary);
/** @typedef {Item|InnerList<Item>} MemberValue */
/**
* An Item can be an Integer (Section 3.3.1), a Decimal (Section 3.3.2), a String (Section 3.3.3), a Token (Section 3.3.4), a Byte Sequence (Section 3.3.5), a Boolean (Section 3.3.6), or a Date (Section 3.3.7). It can have associated parameters (Section 3.1.2).
* ```
* sf-item = bare-item parameters
* ```
* @param {ItemLike} value
* @param {ParametersLike} [parameters]
* @param {DataType} [type]
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-items 3.3. Items
*/
const toItem = (value, parameters = {}, type = undefined) => {
if (value instanceof Item) {
if (type !== undefined && value.type !== type) {
throw new TypeError('Inconsistent bare item type');
}
return value;
}
if (isWrapper(value)) {
if (type !== undefined && value[dataTypeProperty] !== type) {
throw new TypeError('Inconsistent bare item type');
}
return new Item(value, parameters);
}
return new Item(toBareItem(value, type), parameters);
};
/**
* Typed objects
* ```
* bare-item = sf-integer / sf-decimal / sf-string / sf-token / sf-binary / sf-boolean / sf-date / sf-displaystring
* ```
* @typedef {Number|String|Symbol|Uint8Array|Boolean|Date} BareItemObject
*/
/**
* Raw values
* @typedef {number|string|symbol|Uint8Array|boolean|Date} BareItem
*/
/**
* Strict-typed (objective) bare-item
* @param {BareItemObject|BareItem} value
* @param {DataType} [type]
* @returns {BareItemObject}
*/
const toBareItem = (value, type = undefined) => {
return wrap(value, type);
};
/**
* Integers have a range of -999,999,999,999,999 to 999,999,999,999,999 inclusive (i.e., up to fifteen digits, signed), for IEEE 754 compatibility [IEEE754].
* ```
* sf-integer = ["-"] 1*15DIGIT
* ```
* @param {Item|Number|number} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-integers 3.3.1. Integers
*/
const toIntegerItem = (value, parameters = {}) => toItem(value, parameters, DataType.INTEGER);
/**
* Decimals are numbers with an integer and a fractional component. The integer component has at most 12 digits; the fractional component has at most three digits.
* ```
* sf-decimal = ["-"] 1*12DIGIT "." 1*3DIGIT
* ```
* @param {Item|Number|number} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-decimals 3.3.2. Decimals
*/
const toDecimalItem = (value, parameters = {}) => toItem(value, parameters, DataType.DECIMAL);
/**
* Strings are zero or more printable ASCII [RFC0020] characters (i.e., the range %x20 to %x7E). Note that this excludes tabs, newlines, carriage returns, etc.
*
* Non-ASCII characters are not directly supported in Strings because they cause a number of interoperability issues, and -- with few exceptions -- field values do not require them.
*
* When it is necessary for a field value to convey non-ASCII content, a Display String (Section 3.3.8) can be specified.
*
* When serialized in a textual HTTP field, Strings are delimited with double quotes, using a backslash ("\") to escape double quotes and backslashes.
* ```
* sf-string = DQUOTE *( unescaped / "%" / bs-escaped ) DQUOTE
* ```
* @param {Item|String|string} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-strings 3.3.3. Strings
*/
const toStringItem = (value, parameters = {}) => toItem(value, parameters, DataType.STRING);
/**
* Tokens are short textual words that begin with an alphabetic character or "*", followed by zero to many token characters, which are the same as those allowed by the "token" ABNF rule defined in [HTTP] plus the ":" and "/" characters.
* ```
* sf-token = ( ALPHA / "*" ) *( tchar / ":" / "/" )
* ```
* @param {Item|String|Symbol|string|symbol} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-tokens 3.3.4. Tokens
*/
const toTokenItem = (value, parameters = {}) => toItem(value, parameters, DataType.TOKEN);
/**
* Byte Sequences can be conveyed in Structured Fields.
*
* When serialized in a textual HTTP field, a Byte Sequence is delimited with colons and encoded using base64 ([RFC4648], Section 4).
* ```
* sf-binary = ":" base64 ":"
* ```
* @param {Item|Uint8Array} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-byte-sequences 3.3.5. Byte Sequences
*/
const toByteSequenceItem = (value, parameters = {}) => toItem(value, parameters, DataType.BYTE_SEQUENCE);
/**
* Boolean values can be conveyed in Structured Fields.
*
* When serialized in a textual HTTP field, a Boolean is indicated with a leading "?" character followed by a "1" for a true value or "0" for false
* ```
* sf-boolean = "?" ( "0" / "1" )
* ```
* @param {Item|Boolean|boolean} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-booleans 3.3.6. Booleans
*/
const toBooleanItem = (value, parameters = {}) => toItem(value, parameters, DataType.BOOLEAN);
/**
* Date values can be conveyed in Structured Fields.
*
* Dates have a data model that is similar to Integers, representing a (possibly negative) delta in seconds from 1970-01-01T00:00:00Z, excluding leap seconds. Accordingly, their serialization in textual HTTP fields is similar to that of Integers, distinguished from them with a leading "@".
* ```
* sf-date = "@" sf-integer
* ```
* @param {Item|Number|number|Date} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-dates 3.3.7. Dates
*/
const toDateItem = (value, parameters = {}) => toItem(value, parameters, DataType.DATE);
/**
* Display Strings are similar to Strings, in that they consist of zero or more characters, but they allow Unicode scalar values (i.e., all Unicode code points except for surrogates), unlike Strings.
*
* Display Strings are intended for use in cases where a value is displayed to end users and therefore may need to carry non-ASCII content. It is NOT RECOMMENDED that they be used in situations where a String (Section 3.3.3) or Token (Section 3.3.4) would be adequate because Unicode has processing considerations (e.g., normalization) and security considerations (e.g., homograph attacks) that make it more difficult to handle correctly.
*
* Note that Display Strings do not indicate the language used in the value; that can be done separately if necessary (e.g., with a parameter).
*
* In textual HTTP fields, Display Strings are represented in a manner similar to Strings, except that non-ASCII characters are percent-encoded; there is a leading "%" to distinguish them from Strings.
* ```
* sf-displaystring = "%" DQUOTE *( unescaped / "\" / pct-encoded ) DQUOTE
* ```
* @param {Item|BareItemObject|string} value
* @param {ParametersLike} parameters
* @returns {Item}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-display-strings 3.3.8. Display Strings
*/
const toDisplayStringItem = (value, parameters = {}) => toItem(value, parameters, DataType.DISPLAY_STRING);
//
// Serializers
//
/**
* Given a structure defined in this specification, return an ASCII string suitable for use in an HTTP field value.
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-structured-fiel 4.1. Serializing Structured Fields
* @param {ListLike|DictionaryLike|ItemLike} structure
* @returns {string}
*/
/*export*/
const serialize = structure => {
// 1. If the structure is a Dictionary or List and its value is empty (i.e., it has no members), do not serialize the field at all (i.e., omit both the field-name and field-value).
let outputString;
// 2. If the structure is a List, let output_string be the result of running Serializing a List (Section 4.1.1) with the structure.
if (isListLike(structure)) {
outputString = serializeList(structure);
}
// 3. Else, if the structure is a Dictionary, let output_string be the result of running Serializing a Dictionary (Section 4.1.2) with the structure.
else if (isDictionaryLike(structure)) {
outputString = serializeDictionary(structure);
}
// 4. Else, if the structure is an Item, let output_string be the result of running Serializing an Item (Section 4.1.3) with the structure.
else if (isItemLike(structure)) {
const [bareItem, parameters] = destructureItemLike(structure);
outputString = serializeItem(bareItem, parameters);
}
// 5. Else, fail serialization.
else {
throw new Error('Invalid structure'); // May not happen
}
// 6. Return output_string converted into an array of bytes, using ASCII encoding [RFC0020].
if (!isAsciiString(outputString)) {
throw new SyntaxError('Invalid output string');
}
return outputString;
};
/**
* Given an array of (member_value, parameters) tuples as input_list, return an ASCII string suitable for use in an HTTP field value.
* @param {ListLike} inputList
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-list 4.1.1. Serializing a List
*/
/*export*/
const serializeList = inputList => {
// 1. Let output be an empty string.
let output = '';
// 2. For each (member_value, parameters) of input_list:
for (const [member, more] of eachListLike(inputList)) {
let memberValue, parameters;
if (isListLike(member)) {
const innerList = toInnerList(member);
memberValue = innerList;
parameters = innerList.parameters;
} else {
[memberValue, parameters] = destructureItemLike(member);
}
// 1. If member_value is an array, append the result of running Serializing an Inner List (Section 4.1.1.1) with (member_value, parameters) to output.
if (isListLike(memberValue)) {
output += serializeInnerList(memberValue, parameters);
}
// 2. Otherwise, append the result of running Serializing an Item (Section 4.1.3) with (member_value, parameters) to output.
else {
output += serializeItem(memberValue, parameters);
}
// 3. If more member_values remain in input_list:
if (more) {
// 1. Append "," to output.
output += ',';
// 2. Append a single SP to output.
output += ' ';
}
}
// 3. Return output.
return output;
};
/**
* Given an array of (member_value, parameters) tuples as inner_list, and parameters as list_parameters, return an ASCII string suitable for use in an HTTP field value.
* @param {Iterable<ItemLike>} innerList
* @param {ParametersLike} listParameters
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-an-inner-list 4.1.1.1. Serializing an Inner List
*/
/*export*/
const serializeInnerList = (innerList, listParameters) => {
// 1. Let output be the string "(".
let output = '(';
// 2. For each (member_value, parameters) of inner_list:
for (const [item, more] of eachListLike(innerList)) {
const [memberValue, parameters] = destructureItemLike(item);
// 1. Append the result of running Serializing an Item (Section 4.1.3) with (member_value, parameters) to output.
output += serializeItem(memberValue, parameters);
// 2. If more values remain in inner_list, append a single SP to output.
if (more) {
output += ' ';
}
}
// 3. Append ")" to output.
output += ')';
// 4. Append the result of running Serializing Parameters (Section 4.1.1.2) with list_parameters to output.
output += serializeParameters(listParameters);
// 5. Return output.
return output;
};
/**
* Given an ordered Dictionary as input_parameters (each member having a param_key and a param_value), return an ASCII string suitable for use in an HTTP field value.
* @param {ParametersLike} inputParameters
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-parameters 4.1.1.2. Serializing Parameters
*/
/*export*/
const serializeParameters = inputParameters => {
// 1. Let output be an empty string.
let output = '';
// 2. For each param_key with a value of param_value in input_parameters:
for (const [[paramKey, value]] of eachDictionaryLike(inputParameters)) {
const paramValue = wrap(value);
// 1. Append ";" to output.
output += ';';
// 2. Append the result of running Serializing a Key (Section 4.1.1.3) with param_key to output.
output += serializeKey(paramKey);
// 3. If param_value is not Boolean true:
if (unwrap(paramValue) !== true) {
// 1. Append "=" to output.
output += '=';
// 2. Append the result of running Serializing a bare Item (Section 4.1.3.1) with param_value to output.
output += serializeBareItem(paramValue);
}
}
// 3. Return output.
return output;
};
/**
* Given a key as input_key, return an ASCII string suitable for use in an HTTP field value.
* @param {string} input_key
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-key 4.1.1.3. Serializing a Key
*/
/*export*/
const serializeKey = inputKey => {
// 1. Convert input_key into a sequence of ASCII characters; if conversion fails, fail serialization.
if (!isAsciiString(inputKey)) {
throw new Error('value is not a valid ASCII string');
}
// 2. If input_key contains characters not in lcalpha, DIGIT, "_", "-", ".", or "*", fail serialization.
if (!/^[a-z0-9_\-.*]*$/.test(inputKey)) {
throw new Error('value contains invalid characters');
}
// 3. If the first character of input_key is not lcalpha or "*", fail serialization.
if (!/^[a-z*]/.test(inputKey)) {
throw new Error('First character must be lowercase letter or "*"');
}
// 4. Let output be an empty string.
let output = '';
// 5. Append input_key to output.
output += inputKey;
// 6. Return output.
return output;
};
/**
* Given an ordered Dictionary as input_dictionary (each member having a member_key and a tuple value of (member_value, parameters)), return an ASCII string suitable for use in an HTTP field value.
* @param {DictionaryLike} inputDictionary
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-dictionary 4.1.2. Serializing a Dictionary
*/
/*export*/
const serializeDictionary = inputDictionary => {
// 1. Let output be an empty string.
let output = '';
// 2. For each member_key with a value of (member_value, parameters) in input_dictionary:
for (const [[memberKey, memberValueLike], more] of eachDictionaryLike(inputDictionary)) {
// 1. Append the result of running Serializing a Key (Section 4.1.1.3) with member's member_key to output.
output += serializeKey(memberKey);
let memberValue, parameters;
if (isListLike(memberValueLike)) {
const innerList = toInnerList(memberValueLike);
memberValue = innerList;
parameters = innerList.parameters;
} else {
[memberValue, parameters] = destructureItemLike(memberValueLike);
}
// 2. If member_value is Boolean true:
if (unwrap(memberValue) === true) {
// 1. Append the result of running Serializing Parameters (Section 4.1.1.2) with parameters to output.
output += serializeParameters(parameters);
}
// 3. Otherwise:
else {
// 1. Append "=" to output.
output += '=';
// 2, If member_value is an array, append the result of running Serializing an Inner List (Section 4.1.1.1) with (member_value, parameters) to output.
if (isListLike(memberValue)) {
output += serializeInnerList(memberValue, parameters);
}
// 3. Otherwise, append the result of running Serializing an Item (Section 4.1.3) with (member_value, parameters) to output.
else {
output += serializeItem(memberValue, parameters);
}
}
// 4. If more members remain in input_dictionary:
if (more) {
// 1. Append "," to output.
output += ',';
// 2. Append a single SP to output.
output += ' ';
}
}
// 3. Return output.
return output;
};
/**
* Given an Item as bare_item and Parameters as item_parameters, return an ASCII string suitable for use in an HTTP field value.
* @param {BareItemObject|BareItem} bareItem
* @param {ParametersLike} itemParameters
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-an-item 4.1.3. Serializing an Item
*/
/*export*/
const serializeItem = (bareItem, itemParameters) => {
// 1. Let output be an empty string.
let output = '';
// 2. Append the result of running Serializing a Bare Item (Section 4.1.3.1) with bare_item to output.
output += serializeBareItem(bareItem);
// 3. Append the result of running Serializing Parameters (Section 4.1.1.2) with item_parameters to output.
output += serializeParameters(itemParameters);
// 4. Return output.
return output;
};
/**
* Given an Item as input_item, return an ASCII string suitable for use in an HTTP field value.
* @param {BareItemObject|BareItem} inputItem
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-bare-item 4.1.3.1. Serializing a Bare Item
*/
/*export*/
const serializeBareItem = inputItem => {
const [value, type] = destructureBareItemLike(inputItem);
// 1. If input_item is an Integer, return the result of running Serializing an Integer (Section 4.1.4) with input_item.
if (type === DataType.INTEGER) {
return serializeInteger(value);
}
// 2. If input_item is a Decimal, return the result of running Serializing a Decimal (Section 4.1.5) with input_item.
if (type === DataType.DECIMAL) {
return serializeDecimal(value);
}
// 3. If input_item is a String, return the result of running Serializing a String (Section 4.1.6) with input_item.
if (type === DataType.STRING) {
return serializeString(value);
}
// 4. If input_item is a Token, return the result of running Serializing a Token (Section 4.1.7) with input_item.
if (type === DataType.TOKEN) {
return serializeToken(value);
}
// 5. If input_item is a Byte Sequence, return the result of running Serializing a Byte Sequence (Section 4.1.8) with input_item.
if (type === DataType.BYTE_SEQUENCE) {
return serializeByteSequence(value);
}
// 6. If input_item is a Boolean, return the result of running Serializing a Boolean (Section 4.1.9) with input_item.
if (type === DataType.BOOLEAN) {
return serializeBoolean(value);
}
// 7. If input_item is a Date, return the result of running Serializing a Date (Section 4.1.10) with input_item.
if (type === DataType.DATE) {
return serializeDate(value);
}
// 8. If input_item is a Display String, return the result of running Serializing a Display String (Section 4.1.11) with input_item.
if (type === DataType.DISPLAY_STRING) {
return serializeDisplayString(value);
}
// 9. Otherwise, fail serialization.
throw new Error('input value is not a valid bare item');
};
/**
* Given an Integer as input_integer, return an ASCII string suitable for use in an HTTP field value.
* @param {number|Number} value
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-an-integer 4.1.4. Serializing an Integer
*/
/*export*/
const serializeInteger = inputInteger => {
inputInteger = unwrap(inputInteger);
// 1. If input_integer is not an integer in the range of -999,999,999,999,999 to 999,999,999,999,999 inclusive, fail serialization.
if (!isInteger(inputInteger)) {
throw new Error('value is not an integer');
}
if (inputInteger < -999999999999999 || inputInteger > 999999999999999) {
throw new Error('value is not an integer in the range of -999,999,999,999,999 to 999,999,999,999,999 inclusive');
}
// 2. Let output be an empty string.
let output = '';
// 3. If input_integer is less than (but not equal to) 0, append "-" to output.
// 4. Append input_integer's numeric value represented in base 10 using only decimal digits to output.
output += inputInteger.toFixed();
// 5. Return output.
return output;
};
/**
* Given a decimal number as input_decimal, return an ASCII string suitable for use in an HTTP field value.
* @param {number|Number} inputDecimal
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-decimal 4.1.5. Serializing a Decimal
*/
/*export*/
const serializeDecimal = inputDecimal => {
inputDecimal = unwrap(inputDecimal);
// 1. If input_decimal is not a decimal number, fail serialization.
if (!isFinite(inputDecimal)) {
throw new Error('value is not a decimal number');
}
// 2. If input_decimal has more than three significant digits to the right of the decimal point, round it to three decimal places, rounding the final digit to the nearest value, or to the even value if it is equidistant.
const unit = Math.pow(2, -1074); // IEEE 754 subnormal unit (= 2**-1074 ≒ 5e-324)
const rounded = inputDecimal * 1000 * unit / unit / 1000;
// 3. If input_decimal has more than 12 significant digits to the left of the decimal point after rounding, fail serialization.
const integerPart = Math.abs(Math.trunc(rounded));
if (integerPart.toString().length > 12) {
throw new Error('value has more than 12 significant digits to the left of the decimal point');
}
// 4. Let output be an empty string.
let output = '';
// 5. If input_decimal is less than (but not equal to) 0, append "-" to output.
if (rounded < 0) {
output += '-';
}
// 6. Append input_decimal's integer component represented in base 10 (using only decimal digits) to output; if it is zero, append "0".
output += Math.abs(Math.trunc(rounded)).toString();
// 7. Append "." to output.
output += '.';
// 8. If input_decimal's fractional component is zero, append "0" to output.
const fraction = Math.abs(rounded % 1);
if (fraction === 0) {
output += '0';
}
// 9. Otherwise, append the significant digits of input_decimal's fractional component represented in base 10 (using only decimal digits) to output.
else {
const fractionalPart = fraction.toFixed(3).slice(2).replace(/0+$/, '');
output += fractionalPart.length > 0 ? fractionalPart : '0';
}
// 10. Return output.
return output;
};
/**
* Given a String as input_string, return an ASCII string suitable for use in an HTTP field value.
* @param {string|String} inputString
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-string 4.1.6. Serializing a String
*/
/*export*/
const serializeString = inputString => {
inputString = unwrap(inputString);
// 1. Convert input_string into a sequence of ASCII characters; if conversion fails, fail serialization.
if (!isAsciiString(inputString)) {
throw new Error('value is not a valid ASCII string');
}
// 2. If input_string contains characters in the range %x00-1f or %x7f-ff (i.e., not in VCHAR or SP), fail serialization.
if (/[\x00-\x1F\x7F-\xFF]/.test(inputString)) {
throw new Error('value contains characters in the range %x00-1f or %x7f-ff (i.e., not in VCHAR or SP)');
}
// 3. Let output be the string DQUOTE.
let output = '"';
// 4. For each character char in input_string:
for (const char of inputString) {
// 1. If char is "\" or DQUOTE:
if (char === '\\' || char === '"') {
// 1. Append "\" to output.
output += '\\';
}
// 2. Append char to output.
output += char;
}
// 5. Append DQUOTE to output.
output += '"';
// 6. Return output.
return output;
};
/**
* Given a Token as input_token, return an ASCII string suitable for use in an HTTP field value.
* @param {symbol|string|String|Symbol} inputToken
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-token 4.1.7. Serializing a Token
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens 5.6.2. Tokens
*/
/*export*/
const serializeToken = inputToken => {
inputToken = unwrap(inputToken);
if (isSymbol(inputToken)) {
inputToken = inputToken.description;
}
// 1. Convert input_token into a sequence of ASCII characters; if conversion fails, fail serialization.
if (!isAsciiString(inputToken)) {
throw new SyntaxError('value is not a valid ASCII string');
}
// 2. If the first character of input_token is not ALPHA or "*", or the remaining portion contains a character not in tchar, ":", or "/", fail serialization.
if (!/^[A-Za-z*]/.test(inputToken)) {
throw new SyntaxError('First character of value must be ALPHA or "*"');
}
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
if (!/^.[!#$%&'*+\-.^_`|~\dA-Za-z:/]*$/.test(inputToken)) {
throw new SyntaxError('Remaining portion of value contains invalid characters');
}
// 3. Let output be an empty string.
let output = '';
// 4. Append input_token to output.
output += inputToken;
// 5. Return output.
return output;
};
/**
* Given a Byte Sequence as input_bytes, return an ASCII string suitable for use in an HTTP field value.
* The encoded data is required to be padded with "=", as per [RFC4648], Section 3.2.
* Likewise, encoded data SHOULD have pad bits set to zero, as per [RFC4648], Section 3.5, unless it is not possible to do so due to implementation constraints.
* @param {Uint8Array} inputBytes
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-byte-sequence 4.1.8. Serializing a Byte Sequence
*/
/*export*/
const serializeByteSequence = inputBytes => {
// 1. If input_bytes is not a sequence of bytes, fail serialization.
if (!isUint8Array(inputBytes)) {
throw new TypeError('value is not a sequence of bytes');
}
// 2. Let output be an empty string.
let output = '';
// 3. Append ":" to output.
output += ':';
// 4. Append the result of base64-encoding input_bytes as per [RFC4648], Section 4, taking account of the requirements below.
output += inputBytes.toBase64()
// 5. Append ":" to output.
output += ':';
// 6. Return output.
return output;
};
/**
* Given a Boolean as input_boolean, return an ASCII string suitable for use in an HTTP field value.
* @param {boolean|Boolean} inputBoolean
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-boolean 4.1.9. Serializing a Boolean
*/
/*export*/
const serializeBoolean = inputBoolean => {
inputBoolean = unwrap(inputBoolean);
// 1. If input_boolean is not a boolean, fail serialization.
if (!isBoolean(inputBoolean)) {
throw new TypeError('value is not a boolean');
}
// 2. Let output be an empty string.
let output = '';
// 3. Append "?" to output.
output += '?';
// 4. If input_boolean is true, append "1" to output.
if (inputBoolean) {
output += '1';
}
// 5. If input_boolean is false, append "0" to output.
else {
output += '0';
}
// 6. Return output.
return output;
};
/**
* Given a Date as input_date, return an ASCII string suitable for use in an HTTP field value.
* @param {Date} input_date
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-date 4.1.10. Serializing a Date
*/
/*export*/
const serializeDate = inputDate => {
if (inputDate instanceof Date) {
inputDate = Math.floor(inputDate.getTime() / 1000);
}
// 1. Let output be "@".
let output = '@';
// 2. Append to output the result of running Serializing an Integer with input_date (Section 4.1.4).
output += serializeInteger(inputDate)
// 3. Return output.
return output;
};
/**
* Given a sequence of Unicode code points as input_sequence, return an ASCII string suitable for use in an HTTP field value.
* @param {string|String} inputSequence
* @returns {string}
* @see https://www.rfc-editor.org/rfc/rfc9651.html#name-serializing-a-display-strin 4.1.11. Serializing a Display String
*/
/*export*/
const serializeDisplayString = inputSequence => {
inputSequence = unwrap(inputSequence);
// 1. If input_sequence is not a sequence of Unicode code points, fail serialization.
if (!isString(inputSequence)) {
throw new SyntaxError('value is not a sequence of Unicode code points');
}
// 2. Let byte_array be the result of applying UTF-8 encoding (Section 3 of [UTF8]) to input_sequence. If encoding fails, fail serialization.
const byteArray = new TextEncoder().encode(inputSequence);
// 3. Let encoded_string be a string containing "%" followed by DQUOTE.
let encodedString = '%"';
// 4. For each byte in byte_array:
for (const byte of byteArray) {
// 1. If byte is %x25 ("%"), %x22 (DQUOTE), or in the ranges %x00-1f or %x7f-ff:
if (byte === 0x25 || byte === 0x22 || (byte >= 0x00 && byte <= 0x1F) || (byte >= 0x7F && byte <= 0xFF)) {
// 1. Append "%" to encoded_string.
encodedString += '%';
// 2. Let encoded_byte be the result of applying base16 encoding (Section 8 of [RFC4648]) to byte, with any alphabetic characters converted to lowercase.
const encoded_byte = byte.toString(16).padStart(2, '0');
// 3. Append encoded_byte to encoded_string.
encodedString += encoded_byte
}
// 2. Otherwise, decode byte as an ASCII character and append the result to encoded_string.
else {
encodedString += String.fromCharCode(byte);
}
}
// 5. Append DQUOTE to encoded_string.
encodedString += '"';
// 6. Return encoded_string.
return encodedString;
};
//
// Parsers
//
/**
* Given an array of bytes as inputString that represent the chosen field's field-value (which is empty if that field is not present) and field_type (one of "dictionary", "list", or "item"), return the parsed field value.
* @param {string|Iterable<string>} inputString
* @param {string|FieldType} fieldType
* @returns {List|Dictionary|Item}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-structured-fields 4.2. Parsing Structured Fields
*/
const parse = (inputString, fieldType) => {
// When generating input_bytes, parsers MUST combine all field lines in the same section (header or trailer) that case-insensitively match the field name into one comma-separated field-value, as per Section 5.2 of [HTTP]; this assures that the entire field value is processed correctly.
if (isIterable(inputString)) {
inputString = Array.from(inputString).join(', ');
}
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Convert input_bytes into an ASCII string input_string; if conversion fails, fail parsing.
if (!isAsciiString(inputString)) {
throw new SyntaxError('Input is not a valid ASCII string');
}
// 2. Discard any leading SP characters from input_string.
inputString = skipSP(inputString);
let output;
// 3. If field_type is "list", let output be the result of running Parsing a List (Section 4.2.1) with input_string.
if (fieldType === FieldType.LIST) {
[output, inputString] = parseList(inputString);
}
// 4. If field_type is "dictionary", let output be the result of running Parsing a Dictionary (Section 4.2.2) with input_string.
if (fieldType === FieldType.DICTIONARY) {
[output, inputString] = parseDictionary(inputString);
}
// 5. If field_type is "item", let output be the result of running Parsing an Item (Section 4.2.3) with input_string.
if (fieldType === FieldType.ITEM) {
[output, inputString] = parseItem(inputString);
}
// 6. Discard any leading SP characters from input_string.
inputString = skipSP(inputString);
// 7. If input_string is not empty, fail parsing.
if (inputString.length !== 0) {
throw new SyntaxError('Invalid trailing characters');
}
// 8. Otherwise, return output.
return output;
};
/**
* Given an ASCII string as input_string, return an array of (item_or_inner_list, parameters) tuples. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: Item[], rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-list 4.2.1. Parsing a List
*/
const parseList = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Let members be an empty array.
const members = [];
// 2. While input_string is not empty:
while (inputString.length > 0) {
// 1. Append the result of running Parsing an Item or Inner List (Section 4.2.1.1) with input_string to members.
let itemOrInnerList;
[itemOrInnerList, inputString] = parseItemOrInnerList(inputString);
members.push(itemOrInnerList);
// 2. Discard any leading OWS characters from input_string.
inputString = skipOWS(inputString);
// 3. If input_string is empty, return members.
if (inputString.length === 0) {
return [new List(members), inputString];
}
// 4. Consume the first character of input_string; if it is not ",", fail parsing.
if (inputString[0] !== ',') {
throw new SyntaxError('Invalid list: input string does not start with ","');
}
inputString = inputString.slice(1);
// 5. Discard any leading OWS characters from input_string.
inputString = skipOWS(inputString);
// 6. If input_string is empty, there is a trailing comma; fail parsing.
if (inputString.length === 0) {
throw new SyntaxError('Invalid list: input string does not start with ","');
}
}
// 3. No structured data has been found; return members (which is empty).
return [new List(members), inputString];
};
/**
* Given an ASCII string as input_string, return the tuple (item_or_inner_list, parameters), where item_or_inner_list can be either a single bare item or an array of (bare_item, parameters) tuples. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: InnerList<Item>, rest: string]|[value: Item, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-an-item-or-inner-li 4.2.1.1. Parsing an Item or Inner List
*/
const parseItemOrInnerList = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. If the first character of input_string is "(", return the result of running Parsing an Inner List (Section 4.2.1.2) with input_string.
if (inputString[0] === '(') {
return parseInnerList(inputString);
}
// 2. Return the result of running Parsing an Item (Section 4.2.3) with input_string.
return parseItem(inputString);
};
/**
* Given an ASCII string as input_string, return the tuple (inner_list, parameters), where inner_list is an array of (bare_item, parameters) tuples. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: InnerList<Item>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-an-inner-list 4.2.1.2. Parsing an Inner List
*/
const parseInnerList = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Consume the first character of input_string; if it is not "(", fail parsing.
if (inputString[0] !== '(') {
throw new SyntaxError('Invalid inner list: missing opening parenthesis');
}
inputString = inputString.slice(1);
// 2. Let inner_list be an empty array.
const innerList = [];
// 3. While input_string is not empty:
while (inputString.length > 0) {
// 1. Discard any leading SP characters from input_string.
inputString = skipSP(inputString);
// 2. If the first character of input_string is ")":
if (inputString[0] === ')') {
// 1. Consume the first character of input_string.
inputString = inputString.slice(1);
// 2. Let parameters be the result of running Parsing Parameters (Section 4.2.3.2) with input_string.
let parameters;
[parameters, inputString] = parseParameters(inputString);
// 3. Return the tuple (inner_list, parameters).
return [new InnerList(innerList, parameters), inputString];
}
// 3. Let item be the result of running Parsing an Item (Section 4.2.3) with input_string.
let item;
[item, inputString] = parseItem(inputString);
// 4. Append item to inner_list.
innerList.push(item);
// 5. If the first character of input_string is not SP or ")", fail parsing.
if (inputString[0] !== ' ' && inputString[0] !== ')') {
throw new SyntaxError('Invalid inner list: expected space or closing parenthesis');
}
}
// 4. The end of the Inner List was not found; fail parsing.
throw new SyntaxError('Invalid inner list: missing closing parenthesis');
};
/**
* Given an ASCII string as input_string, return an ordered map whose values are (item_or_inner_list, parameters) tuples. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: Dictionary, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-dictionary 4.2.2. Parsing a Dictionary
*/
const parseDictionary = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Let dictionary be an empty, ordered map.
const dictionary = {};
// 2. While input_string is not empty:
while (inputString.length > 0) {
let thisKey;
let member;
// 1. Let this_key be the result of running Parsing a Key (Section 4.2.3.3) with input_string.
[thisKey, inputString] = parseKey(inputString);
// 2. If the first character of input_string is "=":
if (inputString[0] === '=') {
// 1. Consume the first character of input_string.
inputString = inputString.slice(1);
// 2. Let member be the result of running Parsing an Item or Inner List (Section 4.2.1.1) with input_string.
[member, inputString] = parseItemOrInnerList(inputString);
}
// 3. Otherwise:
else {
// 1. Let value be Boolean true.
const value = true;
// 2. Let parameters be the result of running Parsing Parameters (Section 4.2.3.2) with input_string.
let parameters;
[parameters, inputString] = parseParameters(inputString);
// 3. Let member be the tuple (value, parameters).
member = toBooleanItem(value, parameters);
}
// 4. If dictionary already contains a key this_key (comparing character for character), overwrite its value with member.
// 5. Otherwise, append key this_key with value member to dictionary.
dictionary[thisKey] = member;
// 6. Discard any leading OWS characters from input_string.
inputString = skipOWS(inputString);
// 7. If input_string is empty, return dictionary.
if (inputString.length === 0) {
return [new Dictionary(dictionary), inputString];
}
// 8. Consume the first character of input_string; if it is not ",", fail parsing.
if (inputString[0] !== ',') {
throw new SyntaxError('Invalid dictionary: expected ","');
}
inputString = inputString.slice(1);
// 9. Discard any leading OWS characters from input_string.
inputString = skipOWS(inputString);
// 10. If input_string is empty, there is a trailing comma; fail parsing.
if (inputString.length === 0) {
throw new SyntaxError('Invalid dictionary: trailing comma');
}
}
// 3. No structured data has been found; return dictionary (which is empty).
return [new Dictionary(dictionary), inputString];
};
/**
* Given an ASCII string as input_string, return a (bare_item, parameters) tuple. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: Item, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-an-item 4.2.3. Parsing an Item
*/
const parseItem = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Let bare_item be the result of running Parsing a Bare Item (Section 4.2.3.1) with input_string.
let bareItem;
[bareItem, inputString] = parseBareItem(inputString);
// 2. Let parameters be the result of running Parsing Parameters (Section 4.2.3.2) with input_string.
let parameters;
[parameters, inputString] = parseParameters(inputString);
// 3. Return the tuple (bare_item, parameters).
return [new Item(bareItem, parameters, true), inputString];
};
/**
* Given an ASCII string as input_string, return a bare Item. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-bare-item 4.2.3.1. Parsing a Bare Item
*/
const parseBareItem = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. If the first character of input_string is a "-" or a DIGIT, return the result of running Parsing an Integer or Decimal (Section 4.2.4) with input_string.
if (/^[\-\d]/.test(inputString)) {
return parseIntegerOrDecimal(inputString);
}
// 2. If the first character of input_string is a DQUOTE, return the result of running Parsing a String (Section 4.2.5) with input_string.
if (inputString[0] === '"') {
return parseString(inputString);
}
// 3. If the first character of input_string is an ALPHA or "*", return the result of running Parsing a Token (Section 4.2.6) with input_string.
if (/^[A-Za-z*]/.test(inputString)) {
return parseToken(inputString);
}
// 4. If the first character of input_string is ":", return the result of running Parsing a Byte Sequence (Section 4.2.7) with input_string.
if (inputString[0] === ':') {
return parseByteSequence(inputString);
}
// 5. If the first character of input_string is "?", return the result of running Parsing a Boolean (Section 4.2.8) with input_string.
if (inputString[0] === '?') {
return parseBoolean(inputString);
}
// 6. If the first character of input_string is "@", return the result of running Parsing a Date (Section 4.2.9) with input_string.
if (inputString[0] === '@') {
return parseDate(inputString);
}
// 7. If the first character of input_string is "%", return the result of running Parsing a Display String (Section 4.2.10) with input_string.
if (inputString[0] === '%') {
return parseDisplayString(inputString);
}
// 8. Otherwise, the item type is unrecognized; fail parsing.
throw new TypeError('Unrecognized item type');
};
/**
* Given an ASCII string as input_string, return an ordered map whose values are bare Items. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: Parameters, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-parameters 4.2.3.2. Parsing Parameters
*/
const parseParameters = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Let parameters be an empty, ordered map.
const parameters = {};
// 2. While input_string is not empty:
while (inputString.length > 0) {
// 1. If the first character of input_string is not ";", exit the loop.
if (inputString[0] !== ';') {
break;
}
// 2. Consume the ";" character from the beginning of input_string.
inputString = inputString.slice(1);
// 3. Discard any leading SP characters from input_string.
inputString = skipSP(inputString);
// 4. Let param_key be the result of running Parsing a Key (Section 4.2.3.3) with input_string.
let paramKey;
[paramKey, inputString] = parseKey(inputString);
// 5.Let param_value be Boolean true.
let paramValue = wrap(true, DataType.BOOLEAN);
// 6. If the first character of input_string is "=":
if (inputString[0] === '=') {
// 1. Consume the "=" character at the beginning of input_string.
inputString = inputString.slice(1);
// 2. Let param_value be the result of running Parsing a Bare Item (Section 4.2.3.1) with input_string.
[paramValue, inputString] = parseBareItem(inputString);
}
// 7. If parameters already contains a key param_key (comparing character for character), overwrite its value with param_value.
// 8. Otherwise, append key param_key with value param_value to parameters.
parameters[paramKey] = paramValue;
}
// 3. Return parameters.
return [new Parameters(parameters), inputString];
};
/**
* Given an ASCII string as input_string, return a key. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: string, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-key 4.2.3.3. Parsing a Key
*/
const parseKey = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. If the first character of input_string is not lcalpha or "*", fail parsing.
if (!/^[a-z*]/.test(inputString)) {
throw new SyntaxError('Invalid key: first character must be lcalpha or "*"');
}
// 2. Let output_string be an empty string.
let outputString = '';
// 3. While input_string is not empty:
while (inputString.length > 0) {
// 1. If the first character of input_string is not one of lcalpha, DIGIT, "_", "-", ".", or "*", return output_string.
if (!/^[a-z\d_\-.*]/.test(inputString)) {
return [outputString, inputString];
}
// 2. Let char be the result of consuming the first character of input_string.
const char = inputString[0];
inputString = inputString.slice(1);
// 3. Append char to output_string.
outputString += char;
}
// 4. Return output_string.
return [outputString, inputString];
};
/**
* Given an ASCII string as input_string, return an Integer or Decimal. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject<number>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-an-integer-or-decim 4.2.4. Parsing an Integer or Decimal
*/
const parseIntegerOrDecimal = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Let type be "integer".
let type = 'integer';
// 2. Let sign be 1.
let sign = 1;
// 3. Let input_number be an empty string.
let inputNumber = '';
// 4. If the first character of input_string is "-", consume it and set sign to -1.
if (inputString[0] === '-') {
inputString = inputString.slice(1);
sign = -1;
}
// 5. If input_string is empty, there is an empty integer; fail parsing.
if (inputString.length === 0) {
throw new SyntaxError('Invalid integer or decimal: empty string');
}
// 6. If the first character of input_string is not a DIGIT, fail parsing.
if (!/^\d/.test(inputString)) {
throw new SyntaxError('Invalid integer or decimal: first character is not a digit');
}
// 7. While input_string is not empty:
while (inputString.length > 0) {
// 1. Let char be the result of consuming the first character of input_string.
const char = inputString[0];
inputString = inputString.slice(1);
// 2. If char is a DIGIT, append it to input_number.
if (/^\d/.test(char)) {
inputNumber += char;
}
// 3. Else, if type is "integer" and char is ".":
else if (type === 'integer' && char === '.') {
// 1. If input_number contains more than 12 characters, fail parsing.
if (inputNumber.length > 12) {
throw new SyntaxError('Invalid integer or decimal: too many decimal places');
}
// 2. Otherwise, append char to input_number and set type to "decimal".
inputNumber += char;
type = 'decimal';
}
// 4. Otherwise, prepend char to input_string, and exit the loop.
else {
inputString = char + inputString;
break;
}
// 5. If type is "integer" and input_number contains more than 15 characters, fail parsing.
if (type === 'integer' && inputNumber.length > 15) {
throw new SyntaxError('Invalid integer or decimal: too many digits');
}
// 6. If type is "decimal" and input_number contains more than 16 characters, fail parsing.
if (type === 'decimal' && inputNumber.length > 16) {
throw new SyntaxError('Invalid integer or decimal: too many decimal places');
}
}
let outputNumber
// 8. If type is "integer":
if (type === 'integer') {
// 1. Let output_number be an Integer that is the result of parsing input_number as an integer.
outputNumber = parseInt(inputNumber, 10);
if (isNaN(outputNumber)) {
throw new SyntaxError('Invalid integer: not a number');
}
}
// 9. Otherwise:
else {
// 1. If the final character of input_number is ".", fail parsing.
if (inputNumber[inputNumber.length - 1] === '.') {
throw new SyntaxError('Invalid decimal: final character is a decimal point');
}
// 2. If the number of characters after "." in input_number is greater than three, fail parsing.
const dotIndex = inputNumber.indexOf('.');
if (dotIndex !== -1 && inputNumber.length - dotIndex - 1 > 3) {
throw new SyntaxError('Invalid decimal: too many decimal places');
}
// 3. Let output_number be a Decimal that is the result of parsing input_number as a decimal number.
outputNumber = parseFloat(inputNumber);
if (isNaN(outputNumber)) {
throw new SyntaxError('Invalid decimal: not a number');
}
}
// 10. Let output_number be the product of output_number and sign.
outputNumber = outputNumber * sign;
// 11. Return output_number.
return [
wrap(outputNumber, type === 'integer' ? DataType.INTEGER : DataType.DECIMAL),
inputString,
];
};
/**
* Given an ASCII string as input_string, return an unquoted String. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject<string>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-string 4.2.5. Parsing a String
*/
const parseString = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. Let output_string be an empty string.
let outputString = '';
// 2. If the first character of input_string is not DQUOTE, fail parsing.
if (inputString[0] !== '"') {
throw new SyntaxError('Invalid string: first character is not a double quote');
}
// 3. Discard the first character of input_string.
inputString = inputString.slice(1);
// 4. While input_string is not empty:
while (inputString.length > 0) {
// 1. Let char be the result of consuming the first character of input_string.
const char = inputString[0];
inputString = inputString.slice(1);
// 2. If char is a backslash ("\"):
if (char === '\\') {
// 1. If input_string is now empty, fail parsing.
if (inputString.length === 0) {
throw new SyntaxError('Invalid string: backslash at end of string');
}
// 2. Let next_char be the result of consuming the first character of input_string.
const nextChar = inputString[0];
inputString = inputString.slice(1);
// 3. If next_char is not DQUOTE or "\", fail parsing.
if (nextChar !== '"' && nextChar !== '\\') {
throw new SyntaxError('Invalid string: backslash followed by invalid character');
}
// 4. Append next_char to output_string.
outputString += nextChar;
}
// 3. Else, if char is DQUOTE, return output_string.
else if (char === '"') {
return [wrap(outputString, DataType.STRING), inputString];
}
// 4. Else, if char is in the range %x00-1f or %x7f-ff (i.e., it is not in VCHAR or SP), fail parsing.
else if (char < ' ' || char > '~') {
throw new SyntaxError('Invalid string: character is not a valid VCHAR');
}
// 5. Else, append char to output_string.
else {
outputString += char;
}
}
// 5. Reached the end of input_string without finding a closing DQUOTE; fail parsing.
throw new SyntaxError('Invalid string: no closing double quote');
};
/**
* Given an ASCII string as input_string, return a Token. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject<symbol>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-token 4.2.6. Parsing a Token
* @see https://www.rfc-editor.org/rfc/rfc9110.html#name-tokens 5.6.2. Tokens
*/
const parseToken = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. If the first character of input_string is not ALPHA or "*", fail parsing.
if (!/^[A-Za-z*]/.test(inputString)) {
throw new SyntaxError('Invalid token: first character is not a letter or asterisk');
}
// 2. Let output_string be an empty string.
let outputString = '';
// 3. While input_string is not empty:
while (inputString.length > 0) {
// 1. If the first character of input_string is not in tchar, ":", or "/", return output_string.
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA
if (!/^[!#$%&'*+\-.^_`|~\dA-Za-z:/]/.test(inputString)) {
return [wrap(outputString, DataType.TOKEN), inputString];
}
// 2. Let char be the result of consuming the first character of input_string.
const char = inputString[0];
inputString = inputString.slice(1);
// 3. Append char to output_string.
outputString += char;
}
// 4. Return output_string.
return [wrap(outputString, DataType.TOKEN), inputString];
};
/**
* Given an ASCII string as input_string, return a Byte Sequence. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject<Uint8Array>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-byte-sequence 4.2.7. Parsing a Byte Sequence
*/
const parseByteSequence = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. If the first character of input_string is not ":", fail parsing.
if (!inputString.startsWith(':')) {
throw new SyntaxError('Invalid byte sequence: first character is not a colon');
}
// 2. Discard the first character of input_string.
inputString = inputString.slice(1);
// 3. If there is not a ":" character before the end of input_string, fail parsing.
if (!inputString.includes(':')) {
throw new SyntaxError('Invalid byte sequence: no colon before end of string');
}
// 4. Let b64_content be the result of consuming content of input_string up to but not including the first instance of the character ":".
const colonIndex = inputString.indexOf(':');
const b64Content = inputString.slice(0, colonIndex);
inputString = inputString.slice(colonIndex);
// 5. Consume the ":" character at the beginning of input_string.
inputString = inputString.slice(1);
// 6. If b64_content contains a character not included in ALPHA, DIGIT, "+", "/", and "=", fail parsing.
if (!/^[A-Za-z\d+/=]*$/.test(b64Content)) {
throw new SyntaxError('Invalid byte sequence: contains invalid characters');
}
// 7. Let binary_content be the result of base64-decoding [RFC4648] b64_content, synthesizing padding if necessary (note the requirements about recipient behavior below). If base64 decoding fails, parsing fails.
const binaryContent = Uint8Array.fromBase64(b64Content);
// 8. Return binary_content.
return [wrap(binaryContent, DataType.BYTE_SEQUENCE), inputString];
};
/**
* Given an ASCII string as input_string, return a Boolean. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject<boolean>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-boolean 4.2.8. Parsing a Boolean
*/
const parseBoolean = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. If the first character of input_string is not "?", fail parsing.
if (!inputString.startsWith('?')) {
throw new SyntaxError('Invalid boolean: first character is not a question mark');
}
// 2, Discard the first character of input_string.
inputString = inputString.slice(1);
// 3. If the first character of input_string matches "1", discard the first character, and return true.
if (inputString.startsWith('1')) {
inputString = inputString.slice(1);
return [wrap(true, DataType.BOOLEAN), inputString];
}
// 4. If the first character of input_string matches "0", discard the first character, and return false.
if (inputString.startsWith('0')) {
inputString = inputString.slice(1);
return [wrap(false, DataType.BOOLEAN), inputString];
}
// 5. No value has matched; fail parsing.
throw new SyntaxError('Invalid boolean: first character is not a 0 or 1');
};
/**
* Given an ASCII string as input_string, return a Date. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject<Date>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-date 4.2.9. Parsing a Date
*/
const parseDate = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1. If the first character of input_string is not "@", fail parsing.
if (!inputString.startsWith('@')) {
throw new SyntaxError('Invalid date: first character is not an at sign');
}
// 2. Discard the first character of input_string.
inputString = inputString.slice(1);
// 3. Let output_date be the result of running Parsing an Integer or Decimal (Section 4.2.4) with input_string.
let outputDate
[outputDate, inputString] = parseIntegerOrDecimal(inputString);
// 4. If output_date is a Decimal, fail parsing.
if (outputDate[dataTypeProperty] === DataType.DECIMAL) {
throw new SyntaxError('Invalid date: date value must be an integer');
}
// 5. Return output_date.
return [wrap(new Date(unwrap(outputDate) * 1000), DataType.DATE), inputString];
};
/**
* Given an ASCII string as input_string, return a sequence of Unicode code points. input_string is modified to remove the parsed value.
* @param {string} inputString
* @returns {[value: BareItemObject<string>, rest: string]}
* @see https://www.rfc-editor.org/rfc/rfc9651#name-parsing-a-display-string 4.2.11. Parsing a Display String
*/
const parseDisplayString = inputString => {
if (!isString(inputString)) {
throw new TypeError('Input is not a string');
}
// 1, If the first two characters of input_string are not "%" followed by DQUOTE, fail parsing.
if (!inputString.startsWith('%"')) {
throw new SyntaxError('Invalid display string: first two characters are not a percent sign followed by a double quote');
}
// 2. Discard the first two characters of input_string.
inputString = inputString.slice(2);
// 3. Let byte_array be an empty byte array.
/** @type {number[]} */
const bytes = [];
// 4. While input_string is not empty:
while (inputString.length > 0) {
// 1. Let char be the result of consuming the first character of input_string.
const char = inputString[0]
inputString = inputString.slice(1);
// 2, If char is in the range %x00-1f or %x7f-ff (i.e., it is not in VCHAR or SP), fail parsing.
if (char < ' ' || char > '~') {
throw new SyntaxError('Invalid display string: character is not a valid VCHAR');
}
// 3. If char is "%":
if (char === '%') {
// 1. Let octet_hex be the result of consuming two characters from input_string. If there are not two characters, fail parsing.
const octetHex = inputString.slice(0, 2);
if (octetHex.length !== 2) {
throw new SyntaxError('Invalid display string: not enough characters to consume two characters');
}
inputString = inputString.slice(2);
// 2. If octet_hex contains characters outside the range %x30-39 or %x61-66 (i.e., it is not in 0-9 or lowercase a-f), fail parsing.
if (!/^[0-9a-f]{2}$/.test(octetHex)) {
throw new SyntaxError('Invalid display string: contains invalid characters');
}
// 3. Let octet be the result of hex decoding octet_hex (Section 8 of [RFC4648]).
const octet = parseInt(octetHex, 16);
// 4. Append octet to byte_array.
bytes.push(octet);
}
// 4. If char is DQUOTE:
if (char === '"') {
// 1. Let unicode_sequence be the result of decoding byte_array as a UTF-8 string (Section 3 of [UTF8]). Fail parsing if decoding fails.
const unicodeSequence = new TextDecoder('utf-8', {fatal: true}).decode(Uint8Array.from(bytes));
// 2, Return unicode_sequence.
return [wrap(unicodeSequence, DataType.DISPLAY_STRING), inputString];
}
// 5. Otherwise, if char is not "%" or DQUOTE:
if (char !== '%' && char !== '"') {
// 1, Let byte be the result of applying ASCII encoding to char.
const byte = char.charCodeAt(0);
// 2. Append byte to byte_array.
bytes.push(byte);
}
}
// 5. Reached the end of input_string without finding a closing DQUOTE; fail parsing.
throw new SyntaxError('Invalid display string: no closing double quote');
};
/**
* JSON replacer for IETF HTTP Working Group Structured Field Tests JSON Schema support
* @param {string} key
* @param value
* @return {ListMember[]|[Item, Parameters][]|[string, BareItemObject][]|[string, MemberValue][]|[BareItemObject, Parameters]|number|string|symbol|boolean|{__type: string, value: string|number}|*}
* @see https://github.com/httpwg/structured-field-tests
*/
const replacer = function (key, value) {
if (value instanceof List) {
return [...value];
}
if (value instanceof InnerList) {
return [[...value], value.parameters];
}
if (value instanceof Parameters) {
return Object.entries(value);
}
if (value instanceof Dictionary) {
return Object.entries(value);
}
if (value instanceof Item) {
return [value.bareItem, value.parameters];
}
if (isWrapper(value)) {
switch (value[dataTypeProperty]) {
case DataType.INTEGER:
case DataType.DECIMAL:
case DataType.STRING:
case DataType.BOOLEAN:
return value.valueOf();
case DataType.TOKEN:
return {__type: 'token', value: value.valueOf().description};
case DataType.BYTE_SEQUENCE:
return {__type: 'binary', value: base32.encode(value)};
case DataType.DATE:
return {__type: 'date', value: Math.floor(value.getTime() / 1000)};
case DataType.DISPLAY_STRING:
return {__type: 'displaystring', value: value.valueOf()};
}
}
return value;
};
// dictionary: [ [ string, inner_list | item ][] ]
// list: ( inner_list | Item )[]
// inner_list: [ item[], parameters[] ]
// item: [ bare_item, parameters[] ]
// parameters: [ string, bare_item ]
// bare_item: string | number | boolean | {__type: string, value: string | number}
// cf. https://github.com/httpwg/structured-field-tests/blob/main/schema/expected.schema.json
const isJSONItem = v => Array.isArray(v) && v.length === 2 && isJSONBareItemOrKey(v[0]) && isJSONParameters(v[1]);
const isJSONParameters = v => !isTypedItem(v) && Array.isArray(v) && v.every(isJSONParamValue);
const isJSONParamValue = v => !isTypedItem(v) && Array.isArray(v) && v.length === 2 && isString(v[0]) && isJSONBareItemOrKey(v[1]);
const isJSONBareItemOrKey = v => !isTypedItem(v) && ['string', 'number', 'boolean'].includes(typeof v) || isObject(v) && v.__type !== undefined
const reviveBareItem = value => {
if (isTypedItem(value)) {
return value;
}
switch (true) {
// integer
case isInteger(value):
return wrap(value, DataType.INTEGER);
// decimal
case isFinite(value):
return wrap(value, DataType.DECIMAL);
// string
case isString(value):
return wrap(value, DataType.STRING);
// token
case isObject(value) && value.__type === 'token':
return wrap(Symbol(value.value), DataType.TOKEN);
// binary
case isObject(value) && value.__type === 'binary':
return wrap(Uint8Array.from(base32.decode(value.value, {lastChunkHandling: 'strict'})), DataType.BYTE_SEQUENCE);
// boolean
case isBoolean(value):
return wrap(value, DataType.BOOLEAN);
// date
case isObject(value) && value.__type === 'date':
return wrap(new Date(value.value * 1000), DataType.DATE);
// displaystring
case isObject(value) && value.__type === 'displaystring':
return wrap(value.value, DataType.DISPLAY_STRING);
}
throw new SyntaxError(`Invalid bare item: ${value}`);
};
const reviveParameters = v => new Parameters(v.map(([key, value]) => [key, reviveBareItem(value)]));
/**
* Create JSON reviver of the field type for IETF HTTP Working Group Structured Field Tests JSON Schema support
* @param {FieldType} fieldType
* @return {(this: any, key: string, value: any, context?: any) => any}
*/
const reviver = fieldType => {
switch (fieldType) {
case FieldType.DICTIONARY:
return dictionaryReviver;
case FieldType.LIST:
return listReviver;
case FieldType.ITEM:
return itemReviver;
default:
throw new Error(`Unknown field type: ${fieldType}`);
}
};
/**
* List JSON reviver
* @param {string} key
* @param value
* @return {Item|InnerList|List|*}
* @see https://github.com/httpwg/structured-field-tests
*/
const listReviver = function (key, value) {
if (isTypedItem(value)) {
return value;
}
if (key !== '') {
// Revive items
if (isJSONItem(value)) {
// noinspection JSCheckFunctionSignatures
return new Item(reviveBareItem(value[0]), reviveParameters(value[1]));
}
// Revive inner lists: items always revived
if (
Array.isArray(value) && value.length === 2
&& Array.isArray(value[0]) && value[0].every(v => v instanceof Item) // array of items
&& isJSONParameters(value[1]) // parameters
) {
// noinspection JSCheckFunctionSignatures
return new InnerList(value[0], reviveParameters(value[1]));
}
}
// Revive lists: items and inner lists always revived
if (
key === '' // root
&& Array.isArray(value) && value.every(member => member instanceof Item || member instanceof InnerList)
) {
return new List(value);
}
return value;
};
/**
* Dictionary JSON reviver
* @param {string} key
* @param value
* @return {Item|InnerList|Dictionary|*}
* @see https://github.com/httpwg/structured-field-tests
*/
const dictionaryReviver = function (key, value) {
if (isTypedItem(value)) {
return value;
}
if (key !== '') {
// Revive items
if (isJSONItem(value)) {
// noinspection JSCheckFunctionSignatures
return new Item(reviveBareItem(value[0]), reviveParameters(value[1]));
}
// Revive inner lists: items always revived
if (
Array.isArray(value) && value.length === 2
&& Array.isArray(value[0]) && value[0].every(v => v instanceof Item) // array of items
&& isJSONParameters(value[1]) // parameters
) {
// noinspection JSCheckFunctionSignatures
return new InnerList(value[0], reviveParameters(value[1]));
}
}
// Revive dictionaries: items and inner lists always revived
if (
key === '' // root
&& Array.isArray(value) && value.every(member => {
return Array.isArray(member) && member.length === 2
&& isString(member[0]) && (member[1] instanceof Item || member[1] instanceof InnerList)
}) // array of dict-member
) {
return new Dictionary(value);
}
return value;
};
/**
* Item JSON reviver
* @param {string} key
* @param value
* @return {Item|*}
* @see https://github.com/httpwg/structured-field-tests
*/
const itemReviver = function (key, value) {
if (isTypedItem(value)) {
return value;
}
// Revive items
if (
key === '' // root
&& isJSONItem(value)
) {
// noinspection JSCheckFunctionSignatures
return new Item(reviveBareItem(value[0]), reviveParameters(value[1]));
}
return value;
};
//
// Internals
//
const isInteger = value => Number.isInteger(value);
const isFinite = value => Number.isFinite(value);
const isString = value => typeof value === 'string';
const isSymbol = value => typeof value === 'symbol';
const isUint8Array = value => value instanceof Uint8Array;
const isBoolean = value => typeof value === 'boolean';
const isDate = value => value instanceof Date;
const isObject = value => typeof value === 'object' && value !== null;
const isIterable = value => isObject(value) && typeof value[Symbol.iterator] === 'function';
const isAsciiString = value => isString(value) && /^[\x00-\x7F]*$/.test(value);
const isListLike = value => isIterable(value) && !(value instanceof Map) && !(value instanceof Uint8Array) && !(value instanceof String);
const isDictionaryLike = value => isObject(value) && (value instanceof Map || !isListLike(value) && !isItemLike(value));
const isItemLike = value => value instanceof Item
|| ['number', 'string', 'symbol', 'boolean'].includes(typeof value)
|| value instanceof Number || value instanceof String || value instanceof Uint8Array || value instanceof Boolean || value instanceof Date || value instanceof Symbol;
const fieldTypeProperty = Symbol('RFC9651.FieldType');
const dataTypeProperty = Symbol('RFC9651.DataType');
const isBareItem = value => isObject(value) && value[dataTypeProperty] !== undefined;
const isFieldItem = value => isObject(value) && value[fieldTypeProperty] !== undefined;
const isTypedItem = value => value instanceof List || value instanceof Dictionary || value instanceof Item ||
value instanceof InnerList || value instanceof Parameters || isBareItem(value);
/**
* @param {BareItemObject|BareItem} value
* @return {BareItem}
*/
const unwrap = value =>
(value instanceof Number || value instanceof String || value instanceof Symbol || value instanceof Boolean)
? value.valueOf() : value;
/**
* @param {BareItemObject|BareItem} item
* @return {boolean}
*/
const isWrapper = item => item[dataTypeProperty] !== undefined;
/**
* @param {BareItemObject|BareItem} value
* @param {DataType} [type]
* @returns {BareItemObject}
*/
const wrap = (value, type = undefined) => {
type = (type || detectBareItemType(value)); // ??=
if (isWrapper(value)) {
if (value[dataTypeProperty] !== type) {
throw new TypeError('Inconsistent bare item type');
}
return value;
}
value = unwrap(value);
/** @type {BareItemObject} */
let bareItem;
/** @type {(BareItemObject) => string} */
let serializer;
switch (type) {
case DataType.INTEGER:
if (!isInteger(value)) {
throw new TypeError('Invalid integer value');
}
// noinspection JSPrimitiveTypeWrapperUsage
bareItem = new Number(value);
serializer = serializeInteger;
break;
case DataType.DECIMAL:
if (!isFinite(value)) {
throw new TypeError('Invalid decimal value');
}
// noinspection JSPrimitiveTypeWrapperUsage
bareItem = new Number(value);
serializer = serializeDecimal;
break;
case DataType.STRING:
if (!isAsciiString(value)) {
throw new TypeError('Invalid string value');
}
// noinspection JSPrimitiveTypeWrapperUsage
bareItem = new String(value);
serializer = serializeString;
break;
case DataType.TOKEN:
if (isString(value)) {
// noinspection JSCheckFunctionSignatures
value = Symbol(value);
}
if (!isSymbol(value)) {
throw new TypeError('Invalid token value');
}
bareItem = Object(value); // Symbol
serializer = serializeToken;
break;
case DataType.BYTE_SEQUENCE:
if (!isUint8Array(value)) {
throw new TypeError('Invalid byte sequence value');
}
bareItem = new Uint8Array(value);
serializer = serializeByteSequence;
break;
case DataType.BOOLEAN:
if (!isBoolean(value)) {
throw new TypeError('Invalid boolean value');
}
// noinspection JSPrimitiveTypeWrapperUsage
bareItem = new Boolean(value);
serializer = serializeBoolean;
break;
case DataType.DATE:
if (isFinite(value)) {
value = new Date(value * 1000);
}
if (!isDate(value)) {
throw new TypeError('Invalid date value');
}
// noinspection JSCheckFunctionSignatures
bareItem = new Date(value);
serializer = serializeDate;
break;
case DataType.DISPLAY_STRING:
if (!isString(value)) {
throw new TypeError('Invalid display string value');
}
// noinspection JSPrimitiveTypeWrapperUsage
bareItem = new String(value);
serializer = serializeDisplayString;
break;
default:
throw new TypeError('Invalid bare item type');
}
const toString = () => serializer(value);
const toJSON = key => replacer.call(bareItem, key, bareItem);
return Object.defineProperties(bareItem, {
[dataTypeProperty]: {
value: type,
writable: false,
},
toString: {
value: toString,
writable: false,
},
toJSON: {
value: toJSON,
writable: false,
},
// for convenience
type: {
value: type,
writable: false,
},
// for convenience
value: {
value,
writable: false,
},
});
}
/**
* @param {BareItemObject|BareItem} value
* @return {DataType}
*/
const detectBareItemType = value => {
if (isWrapper(value)) {
return value[dataTypeProperty];
}
value = unwrap(value);
switch (true) {
case isInteger(value):
return DataType.INTEGER;
case isFinite(value):
return DataType.DECIMAL;
case isSymbol(value):
return DataType.TOKEN;
case isUint8Array(value):
return DataType.BYTE_SEQUENCE;
case isBoolean(value):
return DataType.BOOLEAN;
case isDate(value):
return DataType.DATE;
case isString(value):
return isAsciiString(value) ? DataType.STRING : DataType.DISPLAY_STRING;
default:
throw new TypeError('Invalid bare item value');
}
}
//
// Helpers
//
/**
* @param {ItemLike} item
* @returns {[bareItem: BareItemObject|BareItem, parameters: ParametersLike]}
*/
const destructureItemLike = item => {
return item instanceof Item ? [item.bareItem, item.parameters] : [item, {}];
}
/**
* @param {BareItemObject|BareItem} value
* @returns {[value: BareItem, type: DataType]}
*/
const destructureBareItemLike = value => {
const type = detectBareItemType(value);
return [unwrap(value), type];
}
/**
* Iterate over a list-like structure, yielding each member of the list as a tuple of (member_value, more)
* @param {ListLike} iterable
* @returns {Generator<[value: ListMemberLike|ItemLike, more: boolean]>}
*/
const eachListLike = function* (iterable) {
const iterator = iterable[Symbol.iterator]();
let previous = iterator.next();
while (!previous.done) {
const current = iterator.next();
yield [previous.value, !current.done]; // value, more
previous = current;
}
};
/**
* Iterate over a dictionary-like structure, yielding each member of the dictionary as a tuple of ((member_key, member_value), more)
* @template T
* @param {DictionaryLike} dictionary
* @returns {Iterable<[value: [key: string, value: T], more: boolean]>}
*/
const eachDictionaryLike = dictionary => eachListLike(isIterable(dictionary) ? dictionary : Object.entries(dictionary));
const skipSP = s => {
let i = 0;
while (i < s.length && s.charCodeAt(i) === 0x20) {
++i;
}
return s.slice(i);
};
const skipOWS = s => {
let i = 0;
while (i < s.length) {
const c = s.charCodeAt(i);
if (c === 0x20 || c === 0x09) {
++i;
} else {
break;
}
}
return s.slice(i);
};
/**
* @param {ListMemberLike} member
* @return {InnerList<Item>|Item}
*/
const toListMember = member => isListLike(member) ? toInnerList(member) : toItem(member);
/**
* @template {ListMember} T
*/
class List extends Array {
/**
* Acts as a constructor for the built-in Array constructor.
* @returns {ArrayConstructor}
*/
static get [Symbol.species]() {
return Array;
}
/**
* @param {ListLike} members
*/
constructor(members) {
super(0);
for (const member of members) {
this.push(toListMember(member));
}
Object.freeze(this);
}
get [fieldTypeProperty]() {
return FieldType.LIST;
}
toString() {
return serializeList(this);
}
// noinspection JSUnusedGlobalSymbols
toJSON(key) {
return replacer.call(this, key, this);
}
}
/**
* @extends {Object<string, BareItemObject>}
*/
class Parameters {
/**
* @param {ParametersLike} parameters
*/
constructor(parameters) {
for (const [key, value] of isIterable(parameters) ? parameters : Object.entries(parameters)) {
this[String(key)] = wrap(value);
}
Object.freeze(this);
}
toString() {
return serializeParameters(this);
}
// noinspection JSUnusedGlobalSymbols
toJSON(key) {
return replacer.call(this, key, this);
}
}
/**
* @template {Item} T
*/
class InnerList extends Array {
/**
* Acts as a constructor for the built-in Array constructor.
* @returns {ArrayConstructor}
*/
static get [Symbol.species]() {
return Array;
}
/**
* @param {InnerListLike} items
* @param {ParametersLike} parameters
*/
constructor(items, parameters = {}) {
if (!isIterable(items)) {
throw new TypeError('items must be iterable');
}
super(0);
for (const item of items) {
this.push(toItem(item));
}
this.parameters = toParameters(parameters);
Object.freeze(this);
}
toString() {
return serializeInnerList(this, this.parameters);
}
// noinspection JSUnusedGlobalSymbols
toJSON(key) {
return replacer.call(this, key, this);
}
}
/**
* @param {MemberValueLike} member
* @return {InnerList<Item>|Item}
*/
const toMemberValue = member => isListLike(member) ? toInnerList(member) : toItem(member);
/**
* @extends {Object<string, MemberValue>}
*/
class Dictionary {
/**
* @param {DictionaryLike} dictionary
*/
constructor(dictionary) {
for (const [key, value] of isIterable(dictionary) ? dictionary : Object.entries(dictionary)) {
this[String(key)] = toMemberValue(value);
}
Object.freeze(this);
}
get [fieldTypeProperty]() {
return FieldType.DICTIONARY;
}
toString() {
return serializeDictionary(this);
}
// noinspection JSUnusedGlobalSymbols
toJSON(key) {
return replacer.call(this, key, this);
}
}
/**
* @template {BareItem} T
*/
class Item {
/**
* @param {BareItemObject|BareItem} bareItem
* @param {ParametersLike} [parameters]
* @param {boolean} [skipCanonicalization]
*/
constructor(bareItem, parameters = {}, skipCanonicalization = false) {
/** @type {BareItemObject} */
this.bareItem = skipCanonicalization ? bareItem : wrap(bareItem);
/** @type {Parameters} */
this.parameters = skipCanonicalization ? parameters : toParameters(parameters);
Object.freeze(this);
}
get [fieldTypeProperty]() {
return FieldType.ITEM;
}
get type() {
return this.bareItem[dataTypeProperty];
}
get value() {
return unwrap(this.bareItem);
}
toString() {
return serializeItem(this.bareItem, this.parameters);
}
// noinspection JSUnusedGlobalSymbols
toJSON(key) {
return replacer.call(this, key, this);
}
}
module.exports = {
// Types
FieldType,
DataType,
// Type converters
list: toList,
innerList: toInnerList,
dictionary: toDictionary,
integer: toIntegerItem,
decimal: toDecimalItem,
string: toStringItem,
token: toTokenItem,
byteSequence: toByteSequenceItem,
boolean: toBooleanItem,
date: toDateItem,
displayString: toDisplayStringItem,
item: toItem, // with type detection
bareItem: toBareItem, // for typed parameters
isFieldItem,
isBareItem,
isTypedItem,
// Compliant serializers
serialize,
serializeList,
serializeInnerList,
serializeParameters,
serializeKey,
serializeDictionary,
serializeItem,
serializeBareItem,
serializeInteger,
serializeDecimal,
serializeString,
serializeToken,
serializeByteSequence,
serializeBoolean,
serializeDate,
serializeDisplayString,
// Compliant parsers
parse,
parseList,
parseInnerList,
parseDictionary,
parseItem,
parseParameters,
parseKey,
parseIntegerOrDecimal,
parseString,
parseToken,
parseByteSequence,
parseBoolean,
parseDate,
parseDisplayString,
// IETF HTTP Working Group Structured Field Tests JSON Schema support
replacer,
reviver,
listReviver,
dictionaryReviver,
itemReviver,
};
'use strict';
const {
serialize,
parse,
innerList,
dictionary,
integer,
decimal,
string,
token,
byteSequence,
boolean,
date,
displayString,
isBareItem,
replacer,
dictionaryReviver,
} = require('../src/http-structured-field');
describe('RFC 9651: Structured Field Values for HTTP', () => {
describe('Serialization', () => {
describe('with a Dictionary', () => {
it('serializes a dictionary with booleans, items, inner lists, and parameters', () => {
const dict = {
a: true,
b: integer(42, {foo: true}),
c: innerList(['a', 1], {baz: Symbol('tok')}),
d: string('val', {foo: Symbol('bar')}),
};
expect(serialize(dict)).toBe('a, b=42;foo, c=("a" 1);baz=tok, d="val";foo=bar');
});
});
describe('with a List', () => {
it('serializes a list of items and an inner list with parameters', () => {
const list = [
1,
string('a', {foo: true}),
innerList([false, Symbol('tst')], {bar: 1})
];
expect(serialize(list)).toBe('1, "a";foo, (?0 tst);bar=1');
});
});
describe('with a Integer', () => {
it('serializes positive and negative integers', () => {
expect(serialize(0)).toBe('0');
expect(serialize(42)).toBe('42');
expect(serialize(-100)).toBe('-100');
expect(serialize(999999999999999)).toBe('999999999999999');
expect(serialize(-999999999999999)).toBe('-999999999999999');
});
it('causes error when out of range', () => {
expect(() => serialize(1000000000000000)).toThrow();
expect(() => serialize(-1000000000000000)).toThrow();
});
});
describe('with a Decimal', () => {
it('serializes decimal numbers with proper formatting and precision', () => {
expect(serialize(4.5)).toBe('4.5');
expect(serialize(3.14159)).toBe('3.142');
expect(serialize(-0.5)).toBe('-0.5');
expect(serialize(999999999999.9994)).toBe('999999999999.999');
expect(serialize(decimal(1))).toBe('1.0');
});
});
describe('with a String', () => {
it('serializes strings with proper escaping', () => {
expect(serialize('foo')).toBe('"foo"');
expect(serialize("hello world")).toBe('"hello world"');
expect(serialize("")).toBe('""');
expect(serialize('a "quote"')).toBe('"a \\\"quote\\\""');
expect(serialize('backslash \\ test')).toBe('"backslash \\\\ test"');
});
it("causes error with invalid characters", () => {
expect(() => serialize("bad\nline")).toThrow();
expect(() => serialize("caf\0e")).toThrow();
expect(() => serialize(String.fromCharCode(0x7F))).toThrow();
});
});
describe('with a Token', () => {
it('serializes tokens represented as Symbols', () => {
expect(serialize(Symbol('foo'))).toBe('foo');
expect(serialize(Symbol('*abc'))).toBe('*abc');
expect(serialize(Symbol('foo/bar:baz'))).toBe('foo/bar:baz');
expect(serialize(Symbol('foo123/456'))).toBe('foo123/456');
expect(serialize(Symbol("A-._:~"))).toBe('A-._:~');
});
it("causes error with invalid token formats", () => {
expect(() => serialize(Symbol('1abc'))).toThrow();
expect(() => serialize(Symbol(''))).toThrow();
expect(() => serialize(Symbol('bad space'))).toThrow();
expect(() => serialize(Symbol('bad@char'))).toThrow();
});
});
describe('with a Byte Sequence', () => {
it('serializes byte sequences as base64 within colons', () => {
expect(serialize(new Uint8Array([0x01, 0x02, 0x03]))).toBe(':AQID:');
expect(serialize(new Uint8Array([0xDE, 0xAD, 0xBE, 0xEF]))).toBe(':3q2+7w==:');
expect(serialize(new Uint8Array([0, 27, 42, 255]))).toBe(':ABsq/w==:');
expect(serialize(new TextEncoder().encode('foo bar'))).toBe(':Zm9vIGJhcg==:');
});
});
describe('with a Boolean', () => {
it('serializes booleans as ?1 and ?0', () => {
expect(serialize(true)).toBe('?1');
expect(serialize(false)).toBe('?0');
});
});
describe('with a Date', () => {
it('serializes dates as @ followed by epoch seconds', () => {
expect(serialize(new Date(0))).toBe('@0');
expect(serialize(new Date(1000))).toBe('@1');
});
it('floors milliseconds to seconds', () => {
expect(serialize(new Date(1500))).toBe('@1');
});
});
describe('with a DisplayString', () => {
it('serializes ASCII safely without encoding', () => {
expect(serialize(displayString('Hello'))).toBe('%"Hello"');
});
it('serializes display strings with percent-encoding for non-ASCII, % and DQUOTE', () => {
expect(serialize('café')).toBe('%"caf%c3%a9"');
expect(serialize(displayString('a"b%c'))).toBe('%"a%22b%25c"');
});
it('serializes display strings when used as an Item', () => {
expect(serialize(displayString('café'))).toBe('%"caf%c3%a9"');
});
it('encodes DQUOTE and percent sign', () => {
expect(serialize(displayString('He"llo'))).toBe('%"He%22llo"');
expect(serialize(displayString('100% sure'))).toBe('%"100%25 sure"');
});
it('encodes control characters', () => {
expect(serialize(displayString('\n'))).toBe('%"%0a"');
});
it('encodes non-ASCII Unicode using UTF-8 bytes in lowercase hex', () => {
expect(serialize('あ')).toBe('%"%e3%81%82"');
expect(serialize('A☕B')).toBe('%"A%e2%98%95B"');
});
});
});
describe('Parsing', () => {
// RFC 9651 examples
describe('with a List', () => {
it('parses a simple list of tokens', () => {
const list = parse('sugar, tea, rum', 'list');
expect(Array.isArray(list)).toBe(true);
expect(list.length).toBe(3);
expect(list[0].value.description).toBe('sugar');
expect(list[1].value.description).toBe('tea');
expect(list[2].value.description).toBe('rum');
});
it('parses a list of inner lists with strings', () => {
const list = parse('("foo" "bar"), ("baz"), ("bat" "one"), ()', 'list');
expect(Array.isArray(list)).toBe(true);
expect(list.length).toBe(4);
expect(list[0].length).toBe(2);
expect(list[0][0].value).toBe('foo');
expect(list[0][1].value).toBe('bar');
expect(list[1].length).toBe(1);
expect(list[1][0].value).toBe('baz');
expect(list[2].length).toBe(2);
expect(list[2][0].value).toBe('bat');
expect(list[2][1].value).toBe('one');
expect(list[3].length).toBe(0);
});
it('parses inner lists with parameters', () => {
const list = parse('("foo"; a=1;b=2);lvl=5, ("bar" "baz");lvl=1', 'list');
expect(Array.isArray(list)).toBe(true);
expect(list.length).toBe(2);
expect(list[0].length).toBe(1);
expect(list[0][0].value).toBe('foo');
expect(list[0][0].parameters.a.value).toBe(1);
expect(list[0][0].parameters.b.value).toBe(2);
expect(list[0].parameters.lvl.value).toBe(5);
expect(list[1].length).toBe(2);
expect(list[1][0].value).toBe('bar');
expect(list[1][1].value).toBe('baz');
expect(list[1].parameters.lvl.value).toBe(1);
});
});
describe('with Parameters', () => {
it('parses parameters with multiple values and inner lists', () => {
const list = parse('abc;a=1;b=2; cde_456, (ghi;jk=4 l);q="9";r=w', 'list');
expect(Array.isArray(list)).toBe(true);
expect(list.length).toBe(2);
expect(list[0].value.description).toBe('abc');
expect(list[0].parameters.a.value).toBe(1);
expect(list[0].parameters.b.value).toBe(2);
expect(list[0].parameters.cde_456.value).toBe(true);
expect(list[1].length).toBe(2);
expect(list[1][0].value.description).toBe('ghi');
expect(list[1][0].parameters.jk.value).toBe(4);
expect(list[1][1].value.description).toBe('l');
expect(list[1].parameters.q.value).toBe('9');
expect(list[1].parameters.r.value.description).toBe('w');
});
it('parses boolean parameters', () => {
const item = parse('1; a; b=?0', 'item');
expect(item.value).toBe(1);
expect(item.parameters.a.value).toBe(true);
expect(item.parameters.b.value).toBe(false);
});
});
describe('with a Dictionary', () => {
it('parses dictionary with strings and byte sequences', () => {
const dictionary = parse('en="Applepie", da=:w4ZibGV0w6ZydGU=:', 'dictionary');
expect(dictionary.en.value).toBe('Applepie');
expect(dictionary.da.value).toEqual(Uint8Array.fromBase64('w4ZibGV0w6ZydGU='));
});
it('parses dictionary with boolean values and parameters', () => {
const dictionary = parse('a=?0, b, c; foo=bar', 'dictionary');
expect(dictionary.a.value).toBe(false);
expect(dictionary.b.value).toBe(true);
expect(dictionary.c.value).toBe(true);
expect(dictionary.c.parameters.foo.value.description).toBe('bar');
});
it('parses dictionary with decimal and inner list values', () => {
const dictionary = parse('rating=1.5, feelings=(joy sadness)', 'dictionary');
expect(dictionary.rating.value).toBe(1.5);
expect(dictionary.feelings.length).toBe(2);
expect(dictionary.feelings[0].value.description).toBe('joy');
expect(dictionary.feelings[1].value.description).toBe('sadness');
});
it('parses complex dictionary with inner lists, parameters and multiple value types', () => {
const dictionary = parse('a=(1 2), b=3, c=4;aa=bb, d=(5 6);valid', 'dictionary');
expect(dictionary.a.length).toBe(2);
expect(dictionary.a[0].value).toBe(1);
expect(dictionary.a[1].value).toBe(2);
expect(dictionary.b.value).toBe(3);
expect(dictionary.c.value).toBe(4);
expect(dictionary.c.parameters.aa.value.description).toBe('bb');
expect(dictionary.d.length).toBe(2);
expect(dictionary.d[0].value).toBe(5);
expect(dictionary.d[1].value).toBe(6);
expect(dictionary.d.parameters.valid.value).toBe(true);
});
it('parses simple dictionary with integer values', () => {
const dictionary = parse('foo=1, bar=2', 'dictionary');
expect(dictionary.foo.value).toBe(1);
expect(dictionary.bar.value).toBe(2);
});
});
describe('with an Item', () => {
it('parses item with parameters', () => {
const item = parse('5; foo=bar', 'item');
expect(item.value).toBe(5);
expect(item.parameters.foo.value.description).toBe('bar');
});
it('parses integer items with various formats', () => {
expect(parse('5', 'item').value).toBe(5);
expect(parse('42', 'item').value).toBe(42);
expect(parse('-999999999999999', 'item').value).toBe(-999999999999999);
expect(parse('999999999999999', 'item').value).toBe(999999999999999);
expect(parse('0002', 'item').value).toBe(2);
expect(parse('-01', 'item').value).toBe(-1);
expect(parse('-0', 'item').value).toBe(-0);
});
it('parses decimal numbers with various precisions', () => {
expect(parse('4.5', 'item').value).toBeCloseTo(4.5);
expect(parse('0002.5', 'item').value).toBeCloseTo(2.5);
expect(parse('-01.334', 'item').value).toBeCloseTo(-1.334);
expect(parse('5.230', 'item').value).toBeCloseTo(5.23);
expect(parse('-0.40', 'item').value).toBeCloseTo(-0.4);
});
it('parses quoted string items', () => {
expect(parse('"hello world"', 'item').value).toBe('hello world');
});
it('parses token items', () => {
expect(parse('foo123/456', 'item').value.description).toBe('foo123/456');
});
it('parses base64 encoded byte sequences', () => {
expect(parse(':cHJldGVuZCB0aGlzIGlzIGJpbmFyeSBjb250ZW50Lg==:', 'item').value).toEqual(new TextEncoder().encode('pretend this is binary content.'));
});
it('parses boolean values', () => {
expect(parse('?1', 'item').value).toBe(true);
expect(parse('?0', 'item').value).toEqual(false);
});
it('parses dates as milliseconds since epoch', () => {
expect(parse('@1659578233', 'item').value.getTime()).toBe(1659578233 * 1000);
expect(parse('@-62135596800', 'item').value.getTime()).toBe(-62135596800 * 1000);
expect(parse('@253402214400', 'item').value.getTime()).toBe(253402214400 * 1000);
});
it('parses percent-encoded display strings', () => {
expect(parse('%"This is intended for display to %c3%bcsers."', 'item').value).toBe('This is intended for display to üsers.');
});
});
});
describe('Strict type conversion', () => {
const example = dictionary({
a: innerList([42, 'foo'], {a: true, b: false, c: Symbol('bar')}),
b: token(('*:/a0!#$%&\'+-.^_`|~')),
c: decimal(17),
d: byteSequence(Uint8Array.fromBase64('abcdefgh')),
e: date(1758370472),
f: boolean(true),
g: displayString('Hello, world!'),
})
it('creates and validates a dictionary with various field types and parameters', () => {
expect(example.constructor.name).toBe('Dictionary');
expect(example.a.constructor.name).toBe('InnerList');
expect(example.a[0].type).toBe('integer');
expect(example.a[1].type).toBe('string');
expect(example.a.parameters.a.type).toBe('boolean');
expect(example.a.parameters.b.type).toBe('boolean');
expect(example.a.parameters.c.type).toBe('token');
expect(example.b.type).toBe('token');
expect(example.c.type).toBe('decimal');
expect(example.d.type).toBe('binary');
expect(example.e.type).toBe('date');
expect(example.f.type).toBe('boolean');
expect(example.g.type).toBe('displaystring');
expect(example.b.value.description).toBe('*:/a0!#$%&\'+-.^_`|~');
expect(example.c.value).toBe(17);
expect(example.d.value).toEqual(Uint8Array.fromBase64('abcdefgh'));
expect(example.e.value.getTime()).toBe(1758370472 * 1000);
expect(example.f.value).toBe(true);
expect(example.g.value).toBe('Hello, world!');
expect(serialize(example)).toBe('a=(42 "foo");a;b=?0;c=bar, b=*:/a0!#$%&\'+-.^_`|~, c=17.0, d=:abcdefgh:, e=@1758370472, f, g=%"Hello, world!"');
});
beforeEach(() => {
// String bare-item comparison accidentally fails with jasmine
jasmine.addCustomEqualityTester((a, b) => {
if (isBareItem(a) && isBareItem(b)
&& typeof a.value === 'string' && typeof b.value === 'string') {
return a.value === b.value;
}
return undefined;
});
})
it('validates IETF HTTP Working Group Structured Field Tests JSON Schema compatibility', () => {
const ietfStructure = [
[
"a",
[
[[42, []], ["foo", []]],
[["a", true], ["b", false], ["c", {"__type": "token", "value": "bar"}]],
],
],
["b", [{"__type": "token", "value": "*:/a0!#$%&'+-.^_`|~"}, []]],
["c", [17, []]],
["d", [{"__type": "binary", "value": "NG3R26PYEE======"}, []]],
["e", [{"__type": "date", "value": 1758370472}, []]],
["f", [true, []]],
["g", [{"__type": "displaystring", "value": "Hello, world!"}, []]],
];
// with replacer
expect(JSON.parse(JSON.stringify(example, replacer))).toEqual(ietfStructure);
// without replacer
expect(JSON.parse(JSON.stringify(example))).toEqual(ietfStructure);
// with reviver
const ietfStructureJSON = JSON.stringify(ietfStructure);
const revived = JSON.parse(ietfStructureJSON, dictionaryReviver);
expect(revived).toEqual(example);
});
});
});
'use strict';
// 独自実装した RFC 9651 が IETF HTTP-WG のテストケースに適合していることを確認するテスト。
// cf. https://github.com/httpwg/structured-field-tests
//
// IETF HTTP-WG のテストケースでは JSON Schema を使って構造を定義しているため型厳密な独実装との相互運用のテストも行う。
const {parse, serialize, replacer, reviver, isBareItem} = require('../../src/http-structured-field');
const fs = require('fs');
const testDir = 'spec/external/httpwg.structured-field-tests'
const loadTestCases = name => JSON.parse(fs.readFileSync(`${testDir}/${name}.json`, 'UTF-8'));
const file = name => {
for (const test of loadTestCases(name)) {
spec(test)
}
}
const spec = testCase => {
if (testCase.raw) {
// raw キーがあるものはパーザーのテスト
// cf. https://github.com/httpwg/structured-field-tests/blob/main/schema/parse.schema.json
if (testCase.must_fail) {
// must_fail なら失敗するテストケース
it(`causes error with ${testCase.name}`, () => {
expect(() => parse(testCase.raw, testCase.header_type)).toThrow();
});
} else {
it(`parses and serializes ${testCase.name}`, () => {
// 独自構造にパーズ
const structure = parse(testCase.raw, testCase.header_type);
// それを IETF HTTP-WG JSON Schema に沿って JSON 化し、それを復元した構造が expected と一致すればパーザー OK
// (本来は expected を独自構造に組み直してテストするのが筋だが大変なので独自実装の toJSON には IETF HTTP-WG JSON Schema 互換機能を組み込んでいる)
expect(JSON.parse(JSON.stringify(structure))).toEqual(testCase.expected);
// 独自構造をシリアライズしたものが canonical または raw と一致すればシリアライザ OK
const expected = (testCase.canonical || testCase.raw).join(', ');
expect(serialize(structure)).toEqual(expected);
});
it(`validates schema compatibility with ${testCase.name}`, () => {
// IETF HTTP-WG JSON schema 互換性
// 独自構造を用意(これ自体のテストは別)
const structure = parse(testCase.raw, testCase.header_type);
// IETF HTTP-WG JSON schema 互換のための replacer を使って JSON 化し、それを復元した構造が expected と一致すれば replacer OK
expect(JSON.parse(JSON.stringify(structure, replacer))).toEqual(testCase.expected);
// expected を JSON 化したものを、IETF HTTP-WG JSON schema 互換のための reviver を使って復元した構造が独自構造と一致すれば reviver OK
expect(JSON.parse(JSON.stringify(testCase.expected), reviver(testCase.header_type))).toEqual(structure);
});
}
} else {
// raw キーがないものはとりあえずシリアライザのテスト
// cf. https://github.com/httpwg/structured-field-tests/blob/main/schema/serialize.schema.json
if (testCase.must_fail) {
// must_fail なら失敗するテストケース
it(`causes serialization error with ${testCase.name}`, () => {
const structure = JSON.parse(JSON.stringify(testCase.expected), reviver(testCase.header_type));
expect(() => serialize(structure, testCase.header_type)).toThrow();
});
} else {
it(`serializes ${testCase.name}`, () => {
// 独自構造を用意(これ自体のテストは別)
const structure = JSON.parse(JSON.stringify(testCase.expected), reviver(testCase.header_type));
// 独自構造をシリアライズしたものが canonical または raw と一致すればシリアライザ OK
const expected = (testCase.canonical || testCase.raw).join(', ');
expect(serialize(structure)).toEqual(expected);
});
}
it(`validates schema compatibility with ${testCase.name}`, () => {
// IETF HTTP-WG JSON schema 互換性
// 独自構造を用意(これ自体のテストは別)
const structure = JSON.parse(JSON.stringify(testCase.expected), reviver(testCase.header_type));
// IETF HTTP-WG JSON schema 互換のための replacer を使って JSON 化し、それを復元した構造が expected と一致すれば replacer OK
expect(JSON.parse(JSON.stringify(structure, replacer))).toEqual(testCase.expected);
// expected を JSON 化したものを、IETF HTTP-WG JSON schema 互換のための reviver を使って復元した構造が独自構造と一致すれば reviver OK
expect(JSON.parse(JSON.stringify(testCase.expected), reviver(testCase.header_type))).toEqual(structure);
});
}
}
describe('IETF HTTP Working Group Structured Field Tests', () => {
beforeEach(() => {
// String bare-item comparison accidentally fails with jasmine
jasmine.addCustomEqualityTester((a, b) => {
if (isBareItem(a) && isBareItem(b)
&& typeof a.value === 'string' && typeof b.value === 'string') {
return a.value === b.value;
}
return undefined;
});
})
describe('for example', () => {
file('examples');
});
describe('for a List', () => {
file('list');
file('listlist');
file('param-list');
file('param-listlist');
});
describe('for a Dictionary', () => {
file('dictionary');
file('param-dict');
});
describe('for a Item', () => {
file('item');
});
describe('for a Number', () => {
file('number');
file('number-generated');
});
describe('for a String', () => {
file('string');
file('string-generated');
});
describe('for a Token', () => {
file('token');
file('token-generated');
});
describe('for a Byte Sequence', () => {
file('binary');
});
describe('for a Boolean', () => {
file('boolean');
});
describe('for a Date', () => {
file('date');
});
describe('for a Display String', () => {
file('display-string');
});
describe('for a internal test', () => {
file('key-generated');
file('large-generated');
});
describe('for a serialization test', () => {
file('serialisation-tests/key-generated');
file('serialisation-tests/number');
file('serialisation-tests/string-generated');
file('serialisation-tests/token-generated');
});
});
@iso2022jp
Copy link
Author

iso2022jp commented Nov 4, 2025

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment