UNPKG

@sap/odata-v4

Version:

OData V4.0 server library

713 lines (630 loc) 25.3 kB
'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 = ( "&#42;/&#42;" * / ( type "/" "&#42;" ) * / ( 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;