Source: cloudevent.js

/*
 * Copyright 2018-2022 the original author or authors.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
'use strict'

/**
 * CloudEvent:
 * this module exports some useful definition and utility related to CloudEvents.
 */

/**
 * Reference to cloudevent Validator class.
 * @private
 * @see Validator
 */
const V = require('./validator') // get validator from here

/**
 * Reference to cloudevent Transformer class.
 * @private
 * @see Transformer
 */
const T = require('./transformer') // get transformer from here

/**
 * CloudEvent implementation.
 *
 * @see https://github.com/cloudevents/spec/blob/master/json-format.md
 */
class CloudEvent {
  /**
   * Create a new instance of a CloudEvent object.
   * @param {!string} id the ID of the event (unique), mandatory
   * @param {!string} type the type of the event (usually prefixed with a reverse-DNS name), mandatory
   * @param {!uri} source the source uri of the event (use '/' if empty), mandatory
   * @param {?(object|Map|Set|string)} data the real event data
   * @param {object} [options={}] optional attributes of the event; some has default values chosen here:
   *        - time (timestamp in string ISO representation, from a date, default now),
   *        - datainbase64 (string) base64 encoded value for the data (data attribute must not be present when this is defined),
   *        - datacontenttype (string, default 'application/json') is the content type of the data attribute,
   *        - dataschema (uri) optional, reference to the schema that data adheres to,
   *        - subject (string) optional, describes the subject of the event in the context of the event producer (identified by source),
   *        - strict (boolean, default false) tell if object instance will be validated in a more strict way
   * @param {?object} extensions optional, contains extension properties (each extension as a key/value property, and no nested objects) but if given any object must contain at least 1 property
   * @throws {Error} if strict is true and id or type is undefined or null
   * @throws {Error} if data and data_base64 are defined
   */
  constructor (id, type, source, data, {
    time = new Date(),
    datainbase64,
    datacontenttype = CloudEvent.datacontenttypeDefault(),
    dataschema,
    subject,
    strict = false
  } = {},
  extensions
  ) {
    if (strict === true) {
      if (!id || !type || !source) {
        throw new Error('Unable to create CloudEvent instance, mandatory attributes missing')
      }
      if (V.isDefinedAndNotNull(data) && V.isDefinedAndNotNull(datainbase64)) {
        throw new Error('Unable to create CloudEvent instance, data and data_base64 attributes are exclusive')
      }
    }

    /**
     * The event ID.
     * @type {string}
     * @private
     */
    this.id = id
    /**
     * The event type.
     * @type {string}
     * @private
     */
    this.type = type
    /**
     * The source URI of the event.
     * @type {uri}
     * @private
     */
    this.source = source
    /**
     * The real event data.
     * Usually it's an object, but could be even a Map or a Set, or a string.
     * Copy the original object to avoid changing objects that could be shared.
     * @type {(object|Map|Set)}
     * @private
     */
    if (V.isString(data)) {
      // handle an edge case: if the given data is a String, I need to clone in a different way ...
      this.data = data.slice()
    } else if (V.isObjectOrCollectionOrString(data)) {
      // normal case
      this.data = { ...data }
    } else {
      // anything other, assign as is (and let validator complain later if needed)
      this.data = data
    }

    /**
     * The CloudEvent specification version.
     * @type {string}
     * @private
     */
    this.specversion = this.constructor.version()

    /**
     * The real event data, but encoded in base64 format.
     * @type {string}
     * @private
     */
    this.data_base64 = datainbase64
    /**
     * The MIME Type for the encoding of the data attribute, when serialized.
     * If null, default value will be set.
     * @type {string}
     * @private
     */
    this.datacontenttype = (!V.isNull(datacontenttype)) ? datacontenttype : CloudEvent.datacontenttypeDefault()
    /**
     * The URI of the schema for event data, if any.
     * @type {uri}
     * @private
     */
    this.dataschema = dataschema
    /**
     * The event timestamp.
     * If null, current timestamp will be set
     * but directly transformed into a string representation.
     * If a not empty string is passed here, it will be set
     * (then later validator could check it).
     * See under for more details.
     * @type {object}
     * @private
     */
    this.time = (V.isNull(time)) ? T.timestampToString(new Date()) : null
    /**
     * The subject of the event in the context of the event producer.
     * @type {string}
     * @private
     */
    this.subject = subject

    // set time depending on the given input type, for be safer
    if (V.isDate(time)) {
      // convert the given timestamp in the right string representation
      this.time = T.timestampToString(time)
    } else if (V.isStringNotEmpty(time)) {
      // assign the value directly, maybe the validator will check later
      this.time = time
    }

    // add strict to extensions, but only when defined
    if (strict === true) {
      this.constructor.setStrictExtensionInEvent(this, strict)

      const extensionsSize = V.getSize(extensions)
      if (extensionsSize < 1) {
        throw new Error('Unable to create CloudEvent instance, extensions must contain at least 1 property')
      }
      if (V.doesObjectContainsStandardProperty(extensions, CloudEvent.isStandardProperty)) {
        throw new Error('Unable to create CloudEvent instance, extensions contains standard properties')
      }
    }

    // set extensions
    this.constructor.setExtensionsInEvent(this, extensions)
  }

  /**
   * Return the version of the CloudEvent Specification implemented here
   *
   * @static
   * @return {string} the value
   */
  static version () {
    return '1.0'
  }

  /**
   * Return the default data content Type for a CloudEvent
   *
   * @static
   * @return {string} the value
   */
  static datacontenttypeDefault () {
    return 'application/json'
  }

  /**
   * Return the MIME Type for a CloudEvent
   *
   * @static
   * @return {string} the value
   */
  static mediaType () {
    return 'application/cloudevents+json'
  }

  /**
   * Tell the data content Type for a CloudEvent,
   * if is a JSON-derived format,
   * so data must be encoded/decoded accordingly.
   *
   * @static
   * @param {!object} event the CloudEvent to validate
   * @return {boolean} true if data content type is JSON-like, otherwise false
   * @throws {TypeError} if event is not a CloudEvent instance or subclass
   * @throws {Error} if event is undefined or null
   */
  static isDatacontenttypeJSONEvent (event) {
    if (!CloudEvent.isCloudEvent(event)) {
      throw new TypeError('The given event is not a CloudEvent instance')
    }
    return (
      (event.datacontenttype === CloudEvent.datacontenttypeDefault()) ||
      (event.datacontenttype.endsWith('/json')) ||
      (event.datacontenttype.endsWith('+json'))
    )
  }

  /**
   * Tell if the object has the strict flag enabled.
   *
   * @static
   * @param {!object} event the CloudEvent to validate
   * @return {boolean} true if strict, otherwise false
   * @throws {TypeError} if event is not a CloudEvent instance or subclass
   * @throws {Error} if event is undefined or null
   */
  static isStrictEvent (event) {
    if (!CloudEvent.isCloudEvent(event)) {
      throw new TypeError('The given event is not a CloudEvent instance')
    }
    if (V.isDefinedAndNotNull(event.strictvalidation)) {
      return event.strictvalidation === true
    } else {
      return false
    }
  }

  /**
   * Set the strict flag into the given extensions object.
   * Should not be used outside CloudEvent constructor.
   *
   * @private
   * @static
   * @param {object} [obj={}] the object with extensions to fill (maybe already populated), that will be enhanced inplace
   * @param {boolean} [strict=false] the flag to set (default false)
   * @throws {TypeError} if obj is not an object, or strict is not a flag
   * @throws {Error} if obj is undefined or null, or strict is undefined or null
   */
  static setStrictExtensionInEvent (obj = {}, strict = false) {
    if (!V.isObject(obj)) {
      throw new TypeError('The given extensions is not an object instance')
    }
    if (!V.isBoolean(strict)) {
      throw new TypeError('The given strict flag is not a boolean instance')
    }
    obj.strictvalidation = strict
  }

  /**
   * Get the strict flag from the given extensions object.
   * Should not be used outside CloudEvent.
   *
   * @private
   * @static
   * @param {object} [obj={}] the object with extensions to check
   * @return {boolean} the strict flag value, or false if not found
   * @throws {TypeError} if obj is not an object, or strict is not a flag
   * @throws {Error} if obj is undefined or null
   * @throws {Error} if strictvalidation property is undefined or null
   */
  static getStrictExtensionOfEvent (obj = {}) {
    if (!V.isObject(obj)) {
      throw new TypeError('The given extensions is not an object instance')
    }
    const myExtensionStrict = obj.strictvalidation || false
    if (!V.isBoolean(myExtensionStrict)) {
      throw new TypeError("Extension property 'strictvalidation' has not a boolean value")
    }
    return myExtensionStrict
  }

  /**
   * Set all extensions into the given object.
   * Should not be used outside CloudEvent constructor.
   *
   * @private
   * @static
   * @param {object} [obj={}] the object to fill, that will be enhanced inplace
   * @param {object} [extensions=null] the extensions to fill (each extension as a key/value property, and no nested properties)
   * @throws {TypeError} if obj is not an object, or strict is not a flag
   * @throws {Error} if obj is undefined or null, or strict is undefined or null
   */
  static setExtensionsInEvent (obj = {}, extensions = null) {
    if (!V.isObject(obj)) {
      throw new TypeError('The given obj is not an object instance')
    }
    if (!V.isDefinedAndNotNull(extensions)) {
      return
    }
    if (V.isObject(extensions)) {
      const exts = Object.entries(extensions).filter(i => !V.doesStringIsStandardProperty(i[0], CloudEvent.isStandardProperty))
      // add filtered extensions to the given obj
      for (const [key, value] of exts) {
        obj[key] = value
      }
    } else {
      throw new TypeError('Unsupported extensions: not an object or a string')
    }
  }

  /**
   * Get all extensions (non standard) properties from the given object.
   * Should not be used outside CloudEvent.
   *
   * @private
   * @static
   * @param {object} [obj={}] the object to check
   * @return {object} an object containins all extensions (non standard properties) found
   * @throws {TypeError} if obj is not an object
   * @throws {Error} if obj is undefined or null
   */
  static getExtensionsOfEvent (obj = {}) {
    const extensions = {}
    if (V.isObject(obj)) {
      const exts = Object.entries(obj).filter(i => !V.doesStringIsStandardProperty(i[0], CloudEvent.isStandardProperty))
      if (exts.length > 0) {
        // add filtered extensions to the given extensions
        for (const [key, value] of exts) {
          extensions[key] = value
        }
      } else {
        // no extensions found, so return a null value
        // (extensions defined but empty are not valid extensions)
        return null
      }
    } else {
      throw new TypeError('Unsupported extensions: not an object or a string')
    }
    return extensions
  }

  /**
   * Validate the given CloudEvent.
   *
   * @static
   * @param {!object} event the CloudEvent to validate
   * @param {object} [options={}] containing:
   *        - strict (boolean, default null so no override) to validate it in a more strict way (if null it will be used strict mode in the given event),
   *        - dataschemavalidator (function(data, dataschema) boolean, optional) a function to validate data of current CloudEvent instance with its dataschema
   *        - timezoneOffset (number, default 0) to apply a different timezone offset
   * @return {object[]} an array of (non null) validation errors, or at least an empty array
   */
  static validateEvent (event, {
    strict = null,
    dataschemavalidator = null,
    timezoneOffset = 0
  } = {}) {
    if (V.isUndefinedOrNull(event)) {
      return [new Error('CloudEvent undefined or null')]
    }
    if (!CloudEvent.isCloudEvent(event)) {
      return [new TypeError(`The argument must be a CloudEvent (or a subclass), instead got a '${typeof event}'`)]
    }
    const ve = [] // validation errors

    // standard validation
    // note that some properties are not checked here because I assign a default value, and I check them in strict mode, like:
    // data, time, extensions, datacontenttype ...
    // ve.push(V.ensureIsStringNotEmpty(event.specversion, 'specversion')) // no more a public attribute
    ve.push(V.ensureIsStringNotEmpty(event.id, 'id'))
    ve.push(V.ensureIsStringNotEmpty(event.type, 'type'))
    ve.push(V.ensureIsStringNotEmpty(event.source, 'source'))
    if (V.isDefinedAndNotNull(event.dataschema)) {
      ve.push(V.ensureIsStringNotEmpty(event.dataschema, 'dataschema'))
    }
    if (V.isDefinedAndNotNull(event.subject)) {
      ve.push(V.ensureIsStringNotEmpty(event.subject, 'subject'))
    }
    if (V.isDefinedAndNotNull(event.data_base64)) {
      ve.push(V.ensureIsStringNotEmpty(event.data_base64, 'data_base64'))
      if (V.isDefinedAndNotNull(event.data)) {
        ve.push(new Error('data and data_base64 attributes are exclusive'))
      }
    }

    // additional validation if strict mode enabled, or if enabled in the event ...
    if (strict === true || (strict === null && CloudEvent.isStrictEvent(event) === true)) {
      ve.push(V.ensureIsVersion(event.specversion, 'specversion'))
      if (V.isDefinedAndNotNull(event.data)) {
        if (event.datacontenttype === CloudEvent.datacontenttypeDefault()) {
          // if it's a string, ensure it's a valid JSON representation,
          // otherwise ensure data is a plain object or collection, but not a string in this case
          if (V.isString(event.data)) {
            try {
              JSON.parse(event.data)
            } catch (e) {
              ve.push(new Error('data is not a valid JSON string'))
            }
          } else {
            ve.push(CloudEvent.ensureTypeOfDataIsRight(event))
          }
          // end of default datacontenttype
        } else {
          // ensure data is a plain object or collection,
          // or even a value (string or boolean or number) in this case
          // because in serialization/deserialization some validation can occur on the transformed object
          ve.push(CloudEvent.ensureTypeOfDataIsRight(event))
        }
      }
      ve.push(V.ensureIsURI(event.source, null, 'source'))
      ve.push(V.ensureIsDatePast(T.timestampFromString(event.time, timezoneOffset), 'time'))
      ve.push(V.ensureIsStringNotEmpty(event.datacontenttype, 'datacontenttype'))
      if (V.isDefinedAndNotNull(event.dataschema)) {
        ve.push(V.ensureIsURI(event.dataschema, null, 'dataschema'))
      }
      if (V.isFunction(dataschemavalidator)) {
        try {
          const success = dataschemavalidator(event.data, event.dataschema)
          if (success === false) throw Error()
        } catch (e) {
          ve.push(new Error(`data does not respect the dataschema '${event.dataschema}' for the given validator`))
        }
      }
      if (V.isDefinedAndNotNull(event.extensions)) {
        // get extensions via its getter
        ve.push(V.ensureIsObjectOrCollectionNotString(event.extensions, 'extensions'))
        // error for extensions defined but empty (without properties), moved in constructor
        // then check for each extension name and value
        for (const [key, value] of Object.entries(event.extensions)) {
          if (!CloudEvent.isExtensionNameValid(key)) ve.push(new Error(`extension name '${key}' not valid`))
          if (!CloudEvent.isExtensionValueValid(value)) ve.push(new Error(`extension value '${value}' not valid for extension '${key}'`))
        }
      }
    }

    return ve.filter((i) => i)
  }

  /**
   * Tell the given CloudEvent, if it's valid.
   *
   * See {@link CloudEvent.validateEvent}.
   *
   * @static
   * @param {!object} event the CloudEvent to validate
   * @param {object} [options={}] containing:
   *        - strict (boolean, default null so no override) to validate it in a more strict way (if null it will be used strict mode in the given event),
   *        - dataschemavalidator (function(data, dataschema) boolean, optional) a function to validate data of current CloudEvent instance with its dataschema
   *        - printDebugInfo (boolean, default false) to print some debug info to the console,
   *        - timezoneOffset (number, default 0) to apply a different timezone offset
   * @return {boolean} true if valid, otherwise false
   */
  static isValidEvent (event, {
    strict = null,
    dataschemavalidator = null,
    printDebugInfo = false,
    timezoneOffset = 0
  } = {}) {
    const validationErrors = CloudEvent.validateEvent(event, { strict, dataschemavalidator, timezoneOffset })
    const size = V.getSize(validationErrors)
    if (printDebugInfo === true) { // print some debug info
      console.log(`DEBUG | validation errors found: ${size}, details: ${JSON.stringify(validationErrors)}`)
    }
    return (size === 0)
  }

  /**
   * Tell the given CloudEvent, if it's instance of the CloudEvent class or a subclass of it.
   *
   * @static
   * @param {!object} event the CloudEvent to check
   * @return {boolean} true if it's an instance (or a subclass), otherwise false
   * @throws {Error} if event is undefined or null
   */
  static isCloudEvent (event) {
    if (V.isUndefinedOrNull(event)) {
      throw new Error('CloudEvent undefined or null')
    }
    return V.isClass(event, CloudEvent)
  }

  /**
   * Serialize the given CloudEvent in JSON format.
   * Note that here standard serialization to JSON is used (no additional libraries).
   * Note that the result of encoder function is assigned to encoded data.
   *
   * @static
   * @param {!object} event the CloudEvent to serialize
   * @param {object} [options={}] optional serialization attributes:
   *        - encoder (function, no default) a function that takes data and returns encoded data as a string,
   *        - encodedData (string, no default) already encoded data (but consistency with the datacontenttype is not checked),
   *        - onlyValid (boolean, default false) to serialize only if it's a valid instance,
   *        - onlyIfLessThan64KB (boolean, default false) to return the serialized string only if it's less than 64 KB,
   *        - printDebugInfo (boolean, default false) to print some debug info to the console,
   *        - timezoneOffset (number, default 0) to apply a different timezone offset
   * @return {string} the serialized event, as a string
   * @throws {Error} if event is undefined or null, or an option is undefined/null/wrong
   */
  static serializeEvent (event, {
    encoder, encodedData,
    onlyValid = false, onlyIfLessThan64KB = false,
    printDebugInfo = false,
    timezoneOffset = 0
  } = {}) {
    if (V.isUndefinedOrNull(event)) throw new Error('CloudEvent undefined or null')
    if (printDebugInfo === true) {
      console.log(`DEBUG | trying to serialize ce: ${JSON.stringify(event)}`)
    }
    if (event.datacontenttype === CloudEvent.datacontenttypeDefault()) {
      if ((onlyValid === false) || (onlyValid === true && CloudEvent.isValidEvent(event, { timezoneOffset }) === true)) {
        const ser = JSON.stringify(event, function replacer (key, value) {
          switch (key) {
            case 'extensions':
              // filtering out top level extensions (if any)
              return undefined
            default:
              return value
          }
        })
        if (printDebugInfo === true) {
          console.log(`DEBUG | ce successfully serialized as: ${ser}`)
        }
        if ((onlyIfLessThan64KB === false) || (onlyIfLessThan64KB === true && V.getSizeInBytes(ser) < 65536)) return ser
        else throw new Error('Unable to return a serialized CloudEvent bigger than 64 KB.')
      } else throw new Error('Unable to serialize a not valid CloudEvent.')
    }
    // else (non defaut datacontenttype)
    if (V.isDefinedAndNotNull(encoder)) {
      if (!V.isFunction(encoder)) throw new Error(`Missing or wrong encoder function: '${encoder}' for the given content type: '${event.datacontenttype}'.`)
      encodedData = encoder(event.payload)
    } else {
      // encoder not defined, check encodedData
      // but mandatory only for non-value data
      if (!V.isValue(event.data) && !V.isDefinedAndNotNull(encodedData)) throw new Error(`Missing encoder function: use encoder function or already encoded data with the given data content type: '${event.datacontenttype}'.`)
      if (V.isValue(event.data) && !V.isDefinedAndNotNull(encodedData)) {
        encodedData = `${event.data}`
      }
    }
    if (!V.isStringNotEmpty(encodedData)) throw new Error(`Missing or wrong encoded data: '${encodedData}' for the given data content type: '${event.datacontenttype}'.`)
    const newEvent = T.mergeObjects(event, { data: encodedData })
    if ((onlyValid === false) || (onlyValid === true && CloudEvent.isValidEvent(newEvent, { timezoneOffset }) === true)) {
      const ser = JSON.stringify(newEvent)
      if (printDebugInfo === true) {
        console.log(`DEBUG | ce successfully serialized as: ${ser}`)
      }
      if ((onlyIfLessThan64KB === false) || (onlyIfLessThan64KB === true && V.getSizeInBytes(ser) < 65536)) return ser
      else throw new Error('Unable to return a serialized CloudEvent bigger than 64 KB.')
    } else throw new Error('Unable to serialize a not valid CloudEvent.')
  }

  /**
   * Deserialize/parse the given CloudEvent from JSON format.
   * Note that here standard parse from JSON is used (no additional libraries).
   * Note that the result of decoder function is assigned to decoded data.
   *
   * @static
   * @param {!string} ser the serialized CloudEvent to parse/deserialize
   * @param {object} [options={}] optional deserialization attributes:
   *        - decoder (function, no default) a function that takes data and returns decoder data as a string,
   *        - decodedData (string, no default) already decoded data (but consistency with the datacontenttype is not checked),
   *        - onlyValid (boolean, default false) to deserialize only if it's a valid instance,
   *        - onlyIfLessThan64KB (boolean, default false) to return the deserialized string only if it's less than 64 KB,
   *        - printDebugInfo (boolean, default false) to print some debug info to the console,
   *        - timezoneOffset (number, default 0) to apply a different timezone offset
   * @return {object} the deserialized event as a CloudEvent instance
   * @throws {Error} if ser is undefined or null, or an option is undefined/null/wrong
   * @throws {Error} in case of JSON parsing error
   */
  static deserializeEvent (ser, {
    decoder, decodedData,
    onlyValid = false, onlyIfLessThan64KB = false,
    printDebugInfo = false,
    timezoneOffset = 0
  } = {}) {
    if (V.isUndefinedOrNull(ser)) throw new Error('Serialized CloudEvent undefined or null')
    if (!V.isStringNotEmpty(ser)) throw new Error(`Missing or wrong serialized data: '${ser}' must be a string and not a: '${typeof ser}'.`)
    if (printDebugInfo === true) {
      console.log(`DEBUG | trying to deserialize as ce: ${ser}`)
    }
    // deserialize standard attributes, always in JSON format
    const parsed = JSON.parse(ser)
    // ensure it's an object (single), and not a string neither a collection or an array
    if (!V.isObject(parsed) || V.isArray(parsed)) throw new Error(`Wrong deserialized data: '${ser}' must represent an object and not an array or a string or other.`)
    if (!V.isStringNotEmpty(parsed.specversion) || parsed.specversion !== CloudEvent.version()) throw new Error(`Unable to deserialize, not compatible specversion: got '${parsed.specversion}' expected '${CloudEvent.version()}'.`)

    const strict = CloudEvent.getStrictExtensionOfEvent(parsed)
    const extensions = CloudEvent.getExtensionsOfEvent(parsed)

    // fill a new CludEvent instance with parsed data
    const ce = new CloudEvent(parsed.id,
      parsed.type,
      parsed.source,
      parsed.data,
      { // options
        time: parsed.time,
        datainbase64: parsed.data_base64,
        datacontenttype: parsed.datacontenttype,
        dataschema: parsed.dataschema,
        subject: parsed.subject,
        strict
      },
      extensions
    )
    // depending on the datacontenttype, decode the data attribute (the payload)
    if (parsed.datacontenttype === CloudEvent.datacontenttypeDefault()) {
      // return ce, depending on its validation option
      if (printDebugInfo === true) {
        console.log(`DEBUG | ce successfully deserialized as: ${JSON.stringify(ce)}`)
      }
      if ((onlyValid === false) || (onlyValid === true && CloudEvent.isValidEvent(ce, { timezoneOffset }) === true)) {
        if ((onlyIfLessThan64KB === false) || (onlyIfLessThan64KB === true && V.getSizeInBytes(ser) < 65536)) return ce
        else throw new Error('Unable to return a deserialized CloudEvent bigger than 64 KB.')
      } else throw new Error('Unable to deserialize a not valid CloudEvent.')
    }
    // else (non defaut datacontenttype)
    if (V.isDefinedAndNotNull(decoder)) {
      if (!V.isFunction(decoder)) throw new Error(`Missing or wrong decoder function: '${decoder}' for the given data content type: '${parsed.datacontenttype}'.`)
      decodedData = decoder(parsed.data)
    } else {
      // decoder not defined, so decodedData must be defined
      // but mandatory only for non-value data
      if (!V.isValue(parsed.data) && !V.isDefinedAndNotNull(decodedData)) throw new Error(`Missing decoder function: use decoder function or already decoded data with the given data content type: '${parsed.datacontenttype}'.`)
      if (V.isValue(parsed.data) && !V.isDefinedAndNotNull(decodedData)) {
        decodedData = `${parsed.data}`
      }
    }
    if (!V.isObjectOrCollectionOrArrayOrValue(decodedData)) throw new Error(`Missing or wrong decoded data: '${decodedData}' for the given data content type: '${parsed.datacontenttype}'.`)
    // overwrite data with decodedData before returning it
    ce.data = decodedData
    // return ce, depending on its validation option
    if ((onlyValid === false) || (onlyValid === true && CloudEvent.isValidEvent(ce, { timezoneOffset }) === true)) {
      if (printDebugInfo === true) {
        console.log(`DEBUG | ce successfully deserialized as: ${JSON.stringify(ce)}`)
      }
      if ((onlyIfLessThan64KB === false) || (onlyIfLessThan64KB === true && V.getSizeInBytes(ser) < 65536)) return ce
      else throw new Error('Unable to return a deserialized CloudEvent bigger than 64 KB.')
    } else throw new Error('Unable to deserialize a not valid CloudEvent.')
  }

  /**
   * Tell the given property, if it's a standard CloudEvent property/attribute.
   *
   * @static
   * @param {!string} property the property/attribute to check
   * @return {boolean} true if it's standard otherwise false
   */
  static isStandardProperty (property) {
    return CloudEvent.standardProps.includes(property)
  }

  /**
   * Tell the given property, if it's an extension CloudEvent property/attribute.
   *
   * @static
   * @param {!string} property the property/attribute to check
   * @return {boolean} true if it's an extension (not standard) otherwise false
   */
  static isExtensionProperty (property) {
    return !CloudEvent.standardProps.includes(property)
  }

  /**
   * Tell if the given extension name is valid, to respect the spec.
   * Should not be used outside CloudEvent.
   *
   * @private
   * @static
   * @param {!object|!string} name the name to check
   * @return {boolean} true if it's an extension name valid, otherwise false
   * @throws {TypeError} if name is not a string
   * @throws {Error} if name is undefined or null
   */
  static isExtensionNameValid (name) {
    if (V.isUndefinedOrNull(name)) throw new Error('Extension name undefined or null')
    if (!V.isString(name)) throw new TypeError('Extension name must be a string')
    return name.match(/^[a-z0-9]{1,20}$/)
  }

  /**
   * Tell if the given extension value is valid, to respect the spec.
   * Should not be used outside CloudEvent.
   *
   * @private
   * @static
   * @param {!string|!boolean|!number} value the object to check
   * @return {boolean} true if it's an extension value valid, otherwise false
   * @throws {Error} if value is undefined
   */
  static isExtensionValueValid (value) {
    if (V.isUndefined(value)) throw new Error('Extension value undefined')
    if (!V.isString(value) && !V.isBoolean(value) && !V.isNumber(value) && !V.isNull(value)) return false
    return true
  }

  /**
   * Get the JSON Schema for a CloudEvent.
   * Note that it's not used in standard serialization to JSON,
   * but only in some serialization libraries.
   * Note that schema definitions for data and extensions are right,
   * but I need to keep them commented here and to set the flag
   * additionalProperties to true,
   * or when used both data and extensions will be empty in JSON output.
   *
   * See JSON Schema.
   *
   * @static
   * @return {object} the JSON Schema
   */
  static getJSONSchema () {
    // define a schema for serializing a CloudEvent object to JSON
    // note that properties not in the schema will be ignored
    // (in json output) by some json serialization libraries, if additionalProperties is false
    return {
      title: 'CloudEvent Schema with required fields',
      type: 'object',
      properties: {
        specversion: { type: 'string', minLength: 1 },
        id: { type: 'string', minLength: 1 },
        type: { type: 'string', minLength: 1 },
        source: { type: 'string', format: 'uri-reference' },
        datacontenttype: { type: ['string', 'null'], minLength: 1 },
        data: { type: ['object', 'string', 'number', 'array', 'boolean', 'null'] },
        data_base64: { type: ['string', 'null'], contentEncoding: 'base64' },
        dataschema: { type: ['string', 'null'], format: 'uri', minLength: 1 },
        time: { type: ['string', 'null'], format: 'date-time', minLength: 1 },
        subject: { type: ['string', 'null'], minLength: 1 }
      },
      required: ['specversion', 'id', 'type', 'source'],
      additionalProperties: true // to handle data, and maybe other (non-standard) properties (extensions)
    }
  }

  /**
   * Tell the type of data of the CloudEvent,
   * if it's right (depending even on related datacontenttype),
   * from the validator point of view.
   *
   * @static
   * @param {!object} ce the CloudEvent to validate
   * @param {object} [options={}] optional validation options
   * @param {string} [name='data'] the name to assign in the returned error string (if any), or 'data' as default value
   * @return {string|null} error message if the given data type is not right, otherwise null
   * @throws {TypeError} if event is not a CloudEvent instance or subclass
   * @throws {Error} if event is undefined or null
   */
  static ensureTypeOfDataIsRight (ce, options = {}, name = 'data') {
    if (!CloudEvent.isCloudEvent(ce)) throw new TypeError('The given event is not a CloudEvent instance')
    let ve
    if (V.isUndefinedOrNull(ce.data)) {
      ve = null // it's impossible to verify its type
    } else if (ce.datacontenttype === CloudEvent.datacontenttypeDefault()) {
      ve = V.ensureIsObjectOrCollectionOrArrayNotValue(ce.data, name) || null
    } else {
      // for example with: datacontenttype 'text/plain':
      // ensure data is a plain object or collection,
      // or even a string or boolean or number in this case
      // because in serialization/deserialization some validation can occur on the transformed object
      ve = V.ensureIsObjectOrCollectionOrArrayOrValue(ce.data, name) || null
    }
    return ve
  }

  /**
   * Utility function that return a dump of validation results
   * on the given CloudEvent.
   *
   * @static
   * @param {(?object)} ce the CloudEvent object to dump
   * @param {object} [options={}] optional validation options
   * @param {string} [name='noname'] the name to assign in the returned string, or 'noname' as default value
   * @return {string} the dump of the object or a message when obj is undefined/null/not a CloudEvent
   */
  static dumpValidationResults (ce, options = {}, name = 'noname') {
    if (V.isUndefined(ce)) {
      return `${name}: undefined`
    } else if (V.isNull(ce)) {
      return `${name}: null`
    } else if (CloudEvent.isCloudEvent(ce)) {
      const opts = options ?? {}
      const ve = CloudEvent.validateEvent(ce, opts)
      return `${name}, validation with options (${JSON.stringify(options)}): ${JSON.stringify(ve.map((i) => i.message))}`
    } else {
      return `${name}: 'is not a CloudEvent, no validation possible'`
    }
  }

  /**
   * Getter method to return the list of standard property names, as an array of strings.
   *
   * @type {Array}
   * @static
   */
  static get standardProps () {
    return [
      'specversion',
      'id', 'type', 'source', 'data',
      'time', 'data_base64', 'datacontenttype',
      'dataschema', 'subject'
    ]
  }

  /**
   * Serialize the current CloudEvent.
   *
   * See {@link CloudEvent.serializeEvent}.
   *
   * @param {object} [options={}] optional serialization attributes:
   *        - encoder (function, default null) a function that takes data and returns encoded data,
   *        - encodedData (string, default null) already encoded data (but consistency with the datacontenttype is not checked),
   * @return {string} the serialized event, as a string
   */
  serialize ({ encoder, encodedData } = {}) {
    return this.constructor.serializeEvent(this, { encoder, encodedData })
  }

  /**
   * Validate the current CloudEvent.
   *
   * See {@link CloudEvent.validateEvent}.
   *
   * @param {object} [options={}] containing:
   *        - strict (boolean, default null so no override) to validate it in a more strict way (if null it will be used strict mode in the given event),
   *        - dataschemavalidator (function(data, dataschema) boolean, optional) a function to validate data of current CloudEvent instance with its dataschema
   * @return {object[]} an array of (non null) validation errors, or at least an empty array
   */
  validate ({ strict = null, dataschemavalidator = null } = {}) {
    return this.constructor.validateEvent(this, { strict, dataschemavalidator })
  }

  /**
   * Tell the current CloudEvent, if it's valid.
   *
   * See {@link CloudEvent.isValidEvent}.
   *
   * @param {object} [options={}] containing:
   *        - strict (boolean, default null so no override) to validate it in a more strict way (if null it will be used strict mode in the given event),
   *        - dataschemavalidator (function(data, dataschema) boolean, optional) a function to validate data of current CloudEvent instance with its dataschema
   *        - printDebugInfo (boolean, default false) to print some debug info to the console,
   *        - timezoneOffset (number, default 0) to apply a different timezone offset
   * @return {boolean} true if valid, otherwise false
   */
  isValid ({ strict = null, dataschemavalidator = null, printDebugInfo = false, timezoneOffset = 0 } = {}) {
    return this.constructor.isValidEvent(this, { strict, dataschemavalidator, printDebugInfo, timezoneOffset })
  }

  /**
   * Getter method to tell if data content type is a JSON-derived format,
   * so data must be encoded/decoded accordingly.
   *
   * See {@link CloudEvent.isDatacontenttypeJSONEvent}.
   *
   * @type {boolean}
   */
  get isDatacontenttypeJSON () {
    return this.constructor.isDatacontenttypeJSONEvent(this)
  }

  /**
   * Getter method to tell if the object has the strict flag enabled.
   *
   * See {@link CloudEvent.isStrictEvent}.
   *
   * @type {boolean}
   */
  get isStrict () {
    return this.constructor.isStrictEvent(this)
  }

  /**
   * Getter method to return JSON Schema for a CloudEvent.
   *
   * See {@link CloudEvent.getJSONSchema}.
   *
   * @type {object}
   */
  get schema () {
    return this.constructor.getJSONSchema()
  }

  /**
   * Getter method to return the CloudEvent time but as a Date object.
   *
   * See {@link CloudEvent.time}.
   *
   * @type {Date}
   */
  get timeAsDate () {
    return T.timestampFromString(this.time)
  }

  /**
   * Getter method to return a copy of CloudEvent data attribute (or data_base64 if defined),
   * but transformed/decoded if possible.
   *
   * See {@link CloudEvent.data}, {@link CloudEvent.data_base64}.
   *
   * @type {(object|Map|Set|Array|string|boolean|number)}
   */
  get payload () {
    if (V.isDefinedAndNotNull(this.data) && !V.isDefinedAndNotNull(this.data_base64)) {
      if (this.isDatacontenttypeJSON) {
        try {
          return JSON.parse(this.data)
        } catch (e) {
          // fallback in case of bad data (not parseable)
          if (V.isString(this.data)) {
            return this.data.slice()
          } else if (V.isArray(this.data)) {
            return this.data.map((i) => i)
          } else {
            return { ...this.data }
          }
        }
        // end of this.isDatacontenttypeJSON
      } else if (V.isString(this.data)) {
        return this.data.slice()
      } else if (V.isArray(this.data)) {
        return this.data.map((i) => i)
      } else if (V.isBoolean(this.data) || V.isNumber(this.data)) {
        return this.data
      } else {
        return { ...this.data }
      }
    } else if (V.isDefinedAndNotNull(this.data_base64)) {
      return T.stringFromBase64(this.data_base64)
    }
    // else return the same empty object
    return this.data
  }

  /**
   * Getter method to tell if CloudEvent data is text or binary,
   * or unknown if not clear.
   *
   * @type {string}
   */
  get dataType () {
    if (V.isDefinedAndNotNull(this.data) && !V.isDefinedAndNotNull(this.data_base64)) {
      return 'Text'
    } else if (V.isDefinedAndNotNull(this.data_base64)) {
      return 'Binary'
    }
    // else return an unknown/wrong data type
    return 'Unknown'
  }

  /**
   * Getter method to return a copy of CloudEvent extensions.
   *
   * See {@link CloudEvent.getExtensionsOfEvent}.
   *
   * @type {object}
   */
  get extensions () {
    return this.constructor.getExtensionsOfEvent(this)
  }

  /**
   * Override the usual toString method,
   * to show a summary (only some info) on current instance.
   * Note that the representation of the 'data' attribute is
   * limited to 1024 chars (arbitrary limit, set here including the trim marker),
   * to avoid too much overhead with instances with a big 'data' attribute.
   *
   * See {@link Object.toString}.
   *
   * @return {string} a string representation for object instance
   */
  toString () {
    const payload = this.payload
    const payloadDump = T.dumpObject(payload, 'payload')
    let payloadSummary = (payloadDump.length < 1024) ? payloadDump : (payloadDump.substring(0, 1021) + '...')
    if (V.isString(payload)) {
      payloadSummary = payloadSummary + '\''
    }
    return `CloudEvent[specversion:${this.specversion}, id:'${this.id}', type:'${this.type}', source:'${this.source}', datacontenttype:'${this.datacontenttype}', ${payloadSummary}, ...]`
  }

  /**
   * Gives a string valued property that is used in the creation of the default string description of an object.
   *
   * See {@link Symbol.toStringTag}.
   *
   * @return {string} a string representation of the object type
   */
  get [Symbol.toStringTag] () {
    return 'CloudEvent'
  }
}

module.exports = CloudEvent