@sap/odata-v4
Version:
OData V4.0 server library
713 lines (630 loc) • 25.3 kB
JavaScript
'use strict';
const ContentTypeInfo = require('../format/ContentTypeInfo');
const AcceptTypeInfo = require('../format/AcceptTypeInfo');
const CharsetInfo = require('../format/CharsetInfo');
const HeaderInfo = require('./HeaderInfo');
const Preferences = require('./Preferences');
const CustomPreference = require('./CustomPreference');
const Q_PATTERN = new RegExp('^(?:(?:0(?:\\.\\d{0,3})?)|(?:1(?:\\.0{0,3})?))$');
const PreferenceNames = Preferences.Names;
/**
* Reads header values as defined in RFCs 7231, 7230, 7240, and 5234.
* Currently only the headers prefer (only 'odata.continue-on-error', 'odata.maxpagesize', 'return'
* and everything allowed by http://tools.ietf.org/html/draft-snell-http-prefer-18),
* content-type and accept are supported.
*
* Used syntax:
*
* https://tools.ietf.org/html/rfc7230#section-3.2
* header-field = field-name ":" OWS field-value OWS
* field-name = token
* field-value = *( field-content / obs-fold ) !!! obs-fold is not supported by this parser
* field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ]
* field-vchar = VCHAR / obs-text
* obs-fold = CRLF 1*( SP / HTAB ) !!! obs-fold is not supported by this parser
* ; obsolete line folding
* ; see Section 3.2.4
*
* https://tools.ietf.org/html/rfc7231#section-3.1.1.1
* Content-Type = media-type
* media-type = type "/" subtype *( OWS ";" OWS parameter )
* parameter = token "=" ( token / quoted-string )
* type = token
*
* Accept = #( media-range [ accept-params ] )
* media-range = ( "*/*"
* / ( type "/" "*" )
* / ( type "/" subtype )
* ) *( OWS ";" OWS parameter )
* accept-params = weight *( accept-ext )
* accept-ext = OWS ";" OWS token [ "=" ( token / quoted-string ) ]
* weight = OWS ";" OWS "q=" qvalue
* qvalue = ( "0" [ "." 0*3DIGIT ] ) / ( "1" [ "." 0*3("0") ] )
*
* https://tools.ietf.org/html/rfc7240#section-2
* Errata to RFC 7240 (Errata ID: 4439)
* Prefer = "Prefer" ":" 1#preference
* preference = preference-parameter *( OWS ";" [ OWS preference-parameter ] )
* preference-parameter = parameter / token
*
* https://tools.ietf.org/html/rfc7230#section-3.2.6
* quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
* quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
* qdtext = HTAB / SP /%x21 / %x23-5B / %x5D-7E / obs-text
* obs-text = %x80-FF
* token = 1*tchar
* tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
* / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
* / DIGIT / ALPHA
*
* https://tools.ietf.org/html/rfc5234#appendix-B.1
* ALPHA = %x41-5A / %x61-7A ; A-Z / a-z
* DIGIT = %x30-39
* DQUOTE = %x22
* VCHAR = %x21-7E
*/
class HttpHeaderReader {
/**
* Constructor
* @param {Buffer} source Buffer do be parsed
*/
constructor(source) {
this._origSource = source;
this._source = source;
this._index = 0;
this._length = source.length;
}
/**
* Read a character at the read position, if the character doesn't matches the expectedChar an error is thrown
* If the character matches the read position is increased, other wise the read position is not increased
*
* @param {string} expChar Character to be expected at read position
* @returns {string}
* @throws {Error}
* @private
*/
_readChar(expChar) {
let code = this._source[this._index];
if (code !== expChar.codePointAt(0)) {
throw new Error(
'Expected "' + expChar + '" at index ' + this._index + ' but found ' + this._getCurrentChar());
}
this._index++;
return expChar;
}
/**
* Check if the character at the read position is a given character
*
* @param {string} expChar character to be compared with the read position
* @returns {boolean} true if characters are equal, false other wise
* @throws {Error}
* @private
*/
_checkChar(expChar) {
let code = this._source[this._index];
if (code === expChar.charCodeAt(0)) {
this._index++;
return true;
}
return false;
}
_isVCHAR(code) {
return (code >= 0x21) && (code <= 0x7E);
}
/**
* Consumes optional whitespaces and increases the read position
* @private
*/
_readOWS() {
let code = this._source[this._index];
while (code === 0x20 || // SP
code === 0x09) { // HTAB
this._index++;
code = this._source[this._index];
}
}
/**
* Returns character at read position
*
* @returns {string} Character at read position
* @private
*/
_getCurrentChar() {
const code = this._source[this._index];
let char;
if (code) {
if (this._isVCHAR(code)) {
char = this._source.toString('latin1', this._index, this._index + 1);
} else {
char = '0x' + this._source.toString('hex', this._index, this._index + 1);
}
}
return char ? '"' + char + '"' : 'no char';
}
/**
* Consumes a quoted string and increases the read position
*
* @returns {string} String (without leading and trailing quotes, deescaped)
* @throws {Error}
* @private
*/
_readQuotedString() {
// " is already consumed so start
let s = '';
let code = this._source[this._index];
while (code >= 0x23 && code <= 0x5B
|| code >= 0x5D && code <= 0x7E
|| code >= 0x80 && code <= 0xFF // obs-text
|| code === 0x09 // HTAB
|| code === 0x20 // SP
|| code === 0x21 // %x21
|| code === 0x5C) { // "\" quoted-pair start
if (code === 0x5C) {
// quoted-pair char
this._index++;
// Don't add "\" to string because:
// Recipients that process the value of a quoted-string MUST handle a quoted-pair
// as if it were replaced by the octet following the backslash.
code = this._source[this._index];
if ((code >= 0x21) && (code <= 0x7E) || // VCHAR
(code >= 0x80) && (code <= 0xFF) || // obs-text
(code === 0x09) || // HTAB
(code === 0x20)// SP
) {
s = s + this._source.toString('latin1', this._index, this._index + 1);
this._index++;
code = this._source[this._index];
} else {
throw new Error('Illegal character ' + this._getCurrentChar()
+ ' after "\\" at position ' + this._index);
}
} else {
// normal char
s = s + this._source.toString('latin1', this._index, this._index + 1);
this._index++;
code = this._source[this._index];
}
}
this._readChar('"');
return s;
}
/**
* Consumes a token and increases the read position
*
* @returns {string} Token build from (latin1) decoded buffer part
* @throws {Error}
* @private
*/
_readToken() {
let token = '';
let code = this._source[this._index];
while ((code >= 0x41) && (code <= 0x61) ||
(code >= 0x61) && (code <= 0x7A) ||
(code >= 0x30) && (code <= 0x39) ||
(code === 0x21) || // "!"
(code === 0x23) || // "#"
(code === 0x24) || // "$"
(code === 0x25) || // "%"
(code === 0x26) || // "&"
(code === 0x27) || // "'"
(code === 0x2A) || // "*"
(code === 0x2B) || // "+"
(code === 0x2D) || // "-"
(code === 0x2E) || // "."
(code === 0x5E) || // "^"
(code === 0x5F) || // "_"
(code === 0x60) || // "`"
(code === 0x7C) || // "|"
(code === 0x7E)) { // "~"
token = token + this._source.toString('latin1', this._index, this._index + 1);
this._index++;
code = this._source[this._index];
}
return token;
}
/**
* Reads the field name
*
* @returns {string} Field name
* @private
*/
_readFieldName() {
const fieldName = this._readToken();
if (fieldName.length === 0) {
throw new Error('Expected valid field-name ' + this._index + ' but found ' + this._getCurrentChar());
}
return fieldName;
}
/**
* Reads a field value
* @param {string} fieldName field name
* @returns {HeaderInfo} Field value
* @private
*/
_readFieldValue(fieldName) {
let fieldValue = '';
let code = this._source[this._index];
while ((code >= 0x21) && (code <= 0x7E) || // VCHAR
(code >= 0x80) && (code <= 0xFF) || // obs-text
(code === 0x09) || // HTAB
(code === 0x20)) {
fieldValue += this._source.toString('latin1', this._index, this._index + 1);
this._index++;
code = this._source[this._index];
}
// check if all chars are consumed
if (this._index !== this._length) {
throw new Error('Illegal character ' + this._getCurrentChar()
+ ' after field-value at index ' + this._index);
}
return new HeaderInfo(fieldName, fieldValue, this._origSource);
}
/**
* Reads an unsigned integer value (maximum of 15 characters) and increases the read position.
* Returns an empty string if the number is negative or does not start with a digit (0-9).
* @returns {string} the parsed value as string.
* @private
*/
_readUnsignedInteger() {
const maxIntegerLength = 15;
let code = this._source[this._index];
let startIndex = this._index;
while ((code >= 0x30) && (code <= 0x39)) { // digit 0-9
if (this._index - startIndex + 1 > maxIntegerLength) {
break;
}
code = this._source[++this._index];
}
return this._source.toString('latin1', startIndex, this._index + 1);
}
/**
* Consumes all parameters and increases the read position
*
* @returns {Array.<{name:string, value:string}>} Array with parameters
* @throws {Error}
* @private
*/
_readParameters() {
let params = [];
this._readOWS();
let name;
let value;
while (this._checkChar(';')) {
this._readOWS();
name = this._readToken();
if (name.length === 0) {
throw new Error('Expected valid parameter name at index ' + this._index
+ ' but found ' + this._getCurrentChar());
}
this._readChar('=');
if (this._checkChar('"')) {
value = this._readQuotedString();
} else {
value = this._readToken();
}
if (value.length === 0) {
throw new Error('Expected valid parameter value at index ' + this._index
+ ' but found ' + this._getCurrentChar());
}
params.push({ name: name, value: value });
this._readOWS();
}
return params;
}
/**
* Consumes all parameters and increases the read position.
* Sets 'true' as value for a parameter if it has no specified value.
* Ignores (Does not throw an error) parameter dividers (';') with no parameter following.
* @returns {Map.<string, (string|boolean)>}
* @private
*/
_readPreferenceParameters() {
let params = new Map();
this._readOWS();
while (this._checkChar(';')) {
this._readOWS();
const name = this._readToken();
if (name.length === 0) { // Ignore ';' character if no token follows
continue;
}
let value = true;
if (this._checkChar('=')) {
value = this._readParameterValue();
}
if (!params.has(name.toLowerCase())) params.set(name.toLowerCase(), value);
this._readOWS();
}
return params;
}
/**
* Reads the value of a parameter.
* @returns {string|boolean} the value as a string or true if an empty string value was given.
* @throws {Error} if the read value is not a quoted string and has the length of 0.
* @private
*/
_readParameterValue() {
let value;
if (this._checkChar('"')) {
value = this._readQuotedString();
if (value.length === 0) value = true;
} else {
value = this._readToken();
if (value.length === 0) {
throw new Error('Expected identifier after character "="');
}
}
return value;
}
/**
* Read the content type and increase the read position
*
* @param {?ContentTypeInfo} [contentTypeInfo] - Specific content type information (e.g. JsonContentTypeInfo)
* @param {string} [fieldName] - Field name, can be provided to preserve case
* @returns {ContentTypeInfo}
*/
readContentType(contentTypeInfo = null, fieldName) {
const fieldValueString = this._source.slice(this._index).toString('latin1');
const type = this._readToken();
let ret = contentTypeInfo || new ContentTypeInfo();
if (type.length === 0) {
const c = this._getCurrentChar();
throw new Error('Expected valid type at index ' + this._index + ' but found ' + c);
}
this._readChar('/');
const subtype = this._readToken();
if (subtype.length === 0) {
throw new Error('Expected valid subtype at index ' + this._index
+ ' but found ' + this._getCurrentChar());
}
const parameters = this._readParameters();
if (this._index !== this._length) {
// not all chars are consumed
throw new Error('Illegal character ' + this._getCurrentChar()
+ ' after content type at index ' + this._index);
}
ret.setName(fieldName || 'content-type'); // preserve case if field name is provided
ret.setValue(fieldValueString);
ret.setRaw(this._origSource);
ret.setMimeType(type + '/' + subtype);
for (const parameter of parameters) {
ret.addParameter(parameter.name, parameter.value);
}
return ret;
}
/**
* Read the accept header value.
* @returns {AcceptTypeInfo[]}
*/
readAccept() {
let result = [];
while (this._index < this._length) {
const type = this._checkChar('*') ? '*' : this._readToken();
if (!type) {
throw new Error('Expected valid type at index ' + this._index + ' but found ' + this._getCurrentChar());
}
this._readChar('/');
let subtype;
if (type === '*') subtype = this._readChar('*');
if (!subtype) subtype = this._checkChar('*') ? '*' : this._readToken();
if (!subtype) {
throw new Error(
'Expected valid subtype at index ' + this._index + ' but found ' + this._getCurrentChar());
}
let acceptTypeInfo = new AcceptTypeInfo(type + '/' + subtype);
const parameters = this._readParameters();
for (const parameter of parameters) {
if (parameter.name === 'q') {
if (Q_PATTERN.test(parameter.value)) {
acceptTypeInfo.setQuality(Number(parameter.value));
} else {
throw new Error("Invalid q parameter '" + parameter.value + "'");
}
// Treat all parameters after "q" as accept extension parameters according to RFC 7231.
// Those extension parameters are simply discarded.
break;
} else {
acceptTypeInfo.addParameter(parameter.name, parameter.value);
}
}
result.push(acceptTypeInfo);
this._readOWS();
if (this._index < this._length) this._readChar(',');
this._readOWS();
}
return result;
}
/**
* reads the Accep-Charset header
* @returns {CharsetInfo[]}
*/
readAcceptCharset() {
let result = [];
// *( "," OWS )
while ((this._index < this._length) && this._checkChar(',')) {
this._readOWS();
}
// ( ( charset / "*" ) [ weight ] )
let charsetInfo = this._readCharsetAndParameter();
if (charsetInfo === null) {
const c = this._getCurrentChar();
throw new Error(`Expected token or "*" at position ${this._index} but found ${c}`);
} else {
result.push(charsetInfo);
}
// *( OWS "," [ OWS ( ( charset / "*" ) [ weight ] ) ] )
while (this._index < this._length) {
this._readOWS();
this._readChar(',');
this._readOWS();
charsetInfo = this._readCharsetAndParameter();
if (charsetInfo) {
result.push(charsetInfo);
}
}
return result;
}
/**
* Read charset token and parameters
* ( charset / "*" ) [ weight ]
*
* @returns {CharsetInfo|null}
* @private
*/
_readCharsetAndParameter() {
const charset = this._checkChar('*') ? '*' : this._readToken();
let charsetInfo = null;
if (charset) {
charsetInfo = new CharsetInfo(charset);
const parameters = this._readParameters();
for (const parameter of parameters) {
if (parameter.name === 'q') {
if (Q_PATTERN.test(parameter.value)) {
charsetInfo.setQuality(Number(parameter.value));
} else {
throw new Error("Invalid q parameter '" + parameter.value + "'");
}
// Only the parameter q is allowed for Accept-Charset
} else {
throw new Error(`Parameter ${parameter.name} is not allowed for header field accept-charset`);
}
}
}
return charsetInfo;
}
/**
* Parses the prefer header value.
* Only 'odata.continue-on-error', 'odata.maxpagesize', 'return'
* and everything allowed by http://tools.ietf.org/html/draft-snell-http-prefer-18 is supported.
* "ABNF List Extension: #rule" from RFC7230 is applied to the comma separated prefer header.
* @returns {Preferences} the parsed preference header
*/
readPrefer() {
const preferences = new Preferences();
this._readOWS();
let hasReadToken = false; // At least one token has to be read
while (this._index < this._length) {
const token = this._readToken().toLowerCase();
if (token.length > 0) hasReadToken = true;
switch (token) {
case PreferenceNames.ALLOW_ENTITYREFERENCES: {
preferences.setOdataAllowEntityReferences(true);
break;
}
case PreferenceNames.CALLBACK: {
this._readOWS();
this._readChar(';');
this._readOWS();
this._readChar('u');
this._readChar('r');
this._readChar('l');
this._readChar('=');
this._readChar('"');
preferences.setOdataCallback(this._readQuotedString()); // URI in final implementation
break;
}
case PreferenceNames.CONTINUE_ON_ERROR: {
preferences.setOdataContinueOnError(true);
break;
}
// currently unsupported odata header.
case PreferenceNames.INCLUDE_ANNOTATIONS: {
// includeAnnotationsPreference = "odata.include-annotations" EQ-h DQUOTE annotationsList DQUOTE
// annotationsList = annotationIdentifier *(COMMA annotationIdentifier)
// annotationIdentifier = [ excludeOperator ]
// ( STAR
// / namespace "." ( termName / STAR )
// )
// [ "#" odataIdentifier ]
// excludeOperator = "-"
this._readChar('=');
this._readChar('"');
this._readQuotedString();
preferences.setOdataIncludeAnnotations(null);
break;
}
case PreferenceNames.MAXPAGESIZE: {
this._readChar('=');
const maxPagesize = this._readUnsignedInteger();
if (!maxPagesize) {
throw new Error('the value of odata.maxpagesize must be an unsigned number');
}
if (preferences.getOdataMaxPageSize() === null) {
preferences.setOdataMaxPageSize(parseInt(maxPagesize, 10));
}
break;
}
case PreferenceNames.RESPOND_ASYNC: {
preferences.setRespondAsync(true);
break;
}
case PreferenceNames.RETURN: {
this._readChar('=');
const preferenceToken = this._readToken();
if (preferenceToken === Preferences.ReturnValues.REPRESENTATION
|| preferenceToken === Preferences.ReturnValues.MINIMAL) {
if (!preferences.getReturn()) preferences.setReturn(preferenceToken);
} else {
throw new Error(`"${preferenceToken}" is not a valid option for prefer header "return"`);
}
break;
}
case PreferenceNames.TRACK_CHANGES: {
preferences.setOdataTrackChanges(true);
break;
}
case PreferenceNames.WAIT: {
this._readChar('=');
const wait = this._readUnsignedInteger();
if (!wait) {
throw new Error('the value of wait must be an unsigned number');
}
if (preferences.getWait() == null) {
preferences.setWait(parseInt(wait, 10));
}
break;
}
default: {
// Custom header parser (BWS is not accepted)
if (token.length > 0) {
const preferenceName = token;
let value = true;
if (this._checkChar('=')) {
value = this._readParameterValue();
}
const parameters = this._readPreferenceParameters();
// If any preference is specified more than once,
// only the first instance is to be considered. (RFC 7240)
if (!preferences.getCustomPreferenceValue(preferenceName)) {
const customPreference = new CustomPreference(preferenceName, value, parameters);
preferences.setCustomPreference(customPreference);
}
}
}
}
// White space (OWS) in between preferences is allowed: https://issues.oasis-open.org/browse/ODATA-1152
this._readOWS();
if (this._index < this._length) {
this._readChar(',');
this._readOWS();
}
}
if (!hasReadToken) throw new Error('Invalid Prefer header, expected at least one token');
return preferences;
}
/**
* Read a full header line
*
* @param {boolean} detailed - Parse the content type header into instance of ContentTypeInfo
* @returns {HeaderInfo|ContentTypeInfo}
*/
readHeaderLine(detailed) {
const fieldName = this._readFieldName();
this._readChar(':');
this._readOWS();
if (detailed && fieldName.toLowerCase() === 'content-type') {
return this.readContentType(null, fieldName);
}
return this._readFieldValue(fieldName);
}
}
module.exports = HttpHeaderReader;