Source: jsonbatch.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'

/**
 * JSONBatch:
 * this module exports some useful definition and utility related to
 * the JSONBatch definition for CloudEvents.
 */

/**
 * Get a reference to cloudevent CloudEvent class.
 * @see Validator
 * @private
 */
const CloudEvent = require('./cloudevent') // get cloudevent from here

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

/**
 * JSONBatch implementation.
 *
 * @see https://github.com/cloudevents/spec/blob/master/json-format.md#4-json-batch-format
 */
class JSONBatch {
  /**
   * Create a new instance of a JSONBatch object.
   * Note that instancing is not allowed for this class because all its methods are static.
   *
   * @throws {Error} instancing not allowed for this class
   * @hideconstructor
   */
  constructor () {
    throw new Error('Instancing not allowed for this class')
  }

  /**
   * Return the MIME Type for CloudEvent intances
   * batched into a single JSON document (array),
   * using the JSON Batch Format
   *
   * @static
   * @return {string} the value
   */
  static mediaType () {
    return 'application/cloudevents-batch+json'
  }

  /**
   * Validate the given JSONBatch.
   *
   * @static
   * @param {!object[]|object} batch the JSONBatch (array) to validate, or a single CloudEvent instance
   * @param {object} [options={}] containing: strict (boolean, default false) to validate it in a more strict way
   * @return {object[]} an array of (flattened, non null) validation errors, or at least an empty array
   */
  static validateBatch (batch, { strict = false } = {}) {
    if (V.isUndefinedOrNull(batch)) {
      return [new Error('JSONBatch undefined or null')]
    }

    // standard validation
    const ve = [] // validation errors
    if (V.isArray(batch)) {
      batch.forEach((i, index) => {
        if (V.isDefinedAndNotNull(i) || strict === true) {
          // additional validation (and on all items) in strict mode
          const ceValidation = CloudEvent.validateEvent(i, { strict })
          if (ceValidation.length > 0) {
            // append validation errors found
            ve.push(...ceValidation)
          }
        }
      })
    } else if (CloudEvent.isCloudEvent(batch)) {
      // validate the given (single) CloudEvent instance or subclass
      // in strict mode this is a validation error anyway, so add it
      if (strict === true) {
        ve.push(new TypeError("The argument 'batch' must be an array, instead got a CloudEvent instance (or a subclass)"))
      }
      ve.push(...CloudEvent.validateEvent(batch, { strict }))
    } else {
      return [new TypeError(`The argument 'batch' must be an array or a CloudEvent instance (or a subclass), instead got a '${typeof batch}'`)]
    }

    const veFiltered = ve.filter((i) => {
      return (V.isArray(i) || V.isError(i))
    }).reduce((acc, x) => acc.concat(x), []) // same as flat/flatMap

    return veFiltered
  }

  /**
   * Tell the given JSONBatch, if it's valid.
   *
   * See {@link CloudEvent.validateBatch}.
   *
   * @static
   * @param {!object} batch the JSONBatch to validate
   * @param {object} [options={}] containing: strict (boolean, default false) to validate it in a more strict way
   * @return {boolean} true if valid, otherwise false
   */
  static isValidBatch (batch, { strict = false } = {}) {
    const validationErrors = JSONBatch.validateBatch(batch, { strict })
    const size = V.getSize(validationErrors)
    return (size === 0)
  }

  /**
   * Tell the given object, if it's a JSONBatch (or at least an empty one).
   *
   * @static
   * @param {!object} batch the JSONBatch to check
   * @return {boolean} true if it's an array, otherwise false
   * @throws {Error} if batch is undefined or null
   */
  static isJSONBatch (batch) {
    if (V.isUndefinedOrNull(batch)) {
      throw new Error('JSONBatch undefined or null')
    }
    return V.isArray(batch)
  }

  /**
   * Generator to iterate across all CloudEvent instances in the JSONBatch.
   *
   * @static
   * @generator
   * @param {!object} batch the JSONBatch to iterate
   * @param {object} [options={}] optional processing attributes:
   *        - onlyValid (boolean, default false) to extract only valid instances
   *        - strict (boolean, default false) to validate it in a more strict way
   * @return {object} a CloudEvent (if any)
   * @throws {Error} if batch is undefined or null
   * @throws {TypeError} if batch is not a JSONBatch
   */
  static * getEvent (batch, {
    onlyValid = false,
    strict = false
  } = {}) {
    if (!JSONBatch.isJSONBatch(batch)) {
      throw new TypeError('The given batch is not a JSONBatch')
    }

    const itemsFiltered = batch.filter((i) => V.isDefinedAndNotNull(i) && CloudEvent.isCloudEvent(i))
    for (const i of itemsFiltered) {
      if (onlyValid === false) {
        yield i
      } else {
        // return only if it's a valid instance
        if (CloudEvent.isValidEvent(i, { strict })) {
          yield i
        }
      }
    }
  }

  /**
   * Return any not null CloudEvent instance from the given object.
   *
   * @static
   * @param {!object} batch the JSONBatch to extract CloudEvent instances (if any)
   * @param {object} [options={}] optional processing attributes:
   *        - onlyValid (boolean, default false) to extract only valid instances
   *        - strict (boolean, default false) to validate it in a more strict way
   * @return {object[]} processed events, as an array
   * @throws {Error} if batch is undefined or null, or an option is undefined/null/wrong
   * @throws {TypeError} if batch is not a JSONBatch
   */
  static getEvents (batch, {
    onlyValid = false,
    strict = false
  } = {}) {
    if (!JSONBatch.isJSONBatch(batch)) {
      throw new TypeError('The given batch is not a JSONBatch')
    }

    const ce = [] // CloudEvent instances
    // get values from the generator function, to simplify logic here
    for (const val of JSONBatch.getEvent(batch, { onlyValid, strict })) {
      ce.push(val)
    }

    return ce
  }

  /**
   * Serialize the given JSONBatch in JSON format.
   * Note that standard CloudEvent serialization will be called
   * for any CloudEvent instance, nothing other;
   * so options are the same used in CloudEvent related method.
   *
   * See {@link CloudEvent.serializeEvent}.
   *
   * @static
   * @param {!object[]} batch the JSONBatch (so a CloudEvent array instance) to serialize
   * @param {object} [options={}] optional serialization attributes
   *        Additional options valid here:
   *        - logError (boolean, default false) to log to console serialization errors
   *        - throwError (boolean, default false) to throw serialization errors
   * @param {object} callback a callback with usual arguments (err, data) to notify after processing each item
   * @return {string} the serialized JSONBatch, as a string
   * @throws {Error} if batch is undefined or null, or an option is undefined/null/wrong or callback is not a function
   * @throws {TypeError} if batch is not a JSONBatch
   */
  static serializeEvents (batch, options = {}, callback) {
    if (!JSONBatch.isJSONBatch(batch)) {
      throw new TypeError('The given batch is not a JSONBatch')
    }
    if (V.isDefinedAndNotNull(callback) && !V.isFunction(callback)) {
      throw new Error('The given callback is not a function')
    }

    let ser = '[' // serialized CloudEvent instances
    let num = 0 // number of serialized CloudEvent

    // get values from the generator function, to simplify logic here
    for (const val of JSONBatch.getEvent(batch, options)) {
      ser += ((num > 0) ? ', ' : '')
      ser += ((options.prettyPrint === true) ? '\n' : '')
      try {
        ser += CloudEvent.serializeEvent(val, options)
        if (V.isFunction(callback)) callback(null, { item: val, num })
        num++
      } catch (e) {
        ser += 'null' // as a fallback placeholder
        if (V.isFunction(callback)) callback(e, { item: null, num })
        if (options.logError === true) {
          console.error(e)
        }
        if (options.throwError === true) {
          const msg = `Unable to serialize CloudEvent instance number ${num}, error detail: ${e.message}`
          throw new Error(msg)
        }
      }
    }

    if (options.prettyPrint === true) {
      ser += '\n'
    }
    ser += ']'

    return ser
  }

  /**
   * Deserialize/parse the given JSONBatch from JSON format.
   * Note that standard CloudEvent deserialization will be called
   * for any CloudEvent instance, nothing other;
   * so options are the same used in CloudEvent related method.
   *
   * See {@link CloudEvent.deserializeEvent}.
   *
   * @static
   * @param {!string} ser the serialized JSONBatch to parse/deserialize
   * @param {object} [options={}] optional deserialization attributes
   *        Additional options valid here:
   *        - logError (boolean, default false) to log to console deserialization errors
   *        - throwError (boolean, default false) to throw serialization errors
   * @param {object} callback a callback with usual arguments (err, data) to notify after processing each item
   * @return {object[]} the deserialized batch as a JSONBatch (so a CloudEvent array instance)
   * @throws {Error} if ser is undefined or null, or an option is undefined/null/wrong
   * @throws {Error} in case of JSON parsing error
   * @throws {TypeError} if ser is not a JSONBatch representation
   */
  static deserializeEvents (ser, options = {}, callback) {
    if (!V.isStringNotEmpty(ser)) throw new Error(`Missing or wrong serialized data: '${ser}' must be a string and not a: '${typeof ser}'.`)
    if (V.isDefinedAndNotNull(callback) && !V.isFunction(callback)) {
      throw new Error('The given callback is not a function')
    }

    // first deserialize to normal object instances
    let deser = null
    try {
      deser = JSON.parse(ser)
    } catch (e) {
      if (options.logError === true) {
        console.error(e)
      }
      if (options.throwError === true) {
        const msg = `Unable to deserialize the given string to JSONBatch, error detail: ${e.message}`
        throw new Error(msg)
      }
    }
    if (!V.isArray(deser)) {
      throw new TypeError('The given string is not an array representation')
    }

    // then build CloudEvent instances from any not null object that seems compatible
    const itemsFiltered = deser.filter((i) => V.isDefinedAndNotNull(i) && V.isObjectPlain(i))
    const batch = []
    let num = 0 // number of created CloudEvent
    for (const i of itemsFiltered) {
      // create a CloudEvent instance from the current object (if possible)
      try {
        const extensions = V.getObjectFilteredProperties(i, CloudEvent.isExtensionProperty)
        // note that strict is handled both as strict and inside extensions, but it's good the same
        const ce = new CloudEvent(i.id, i.type, i.source, i.data, {
          time: i.time,
          data_base64: i.data_base64,
          datacontenttype: i.datacontenttype,
          dataschema: i.dataschema,
          subject: i.subject,
          strict: CloudEvent.getStrictExtensionOfEvent(i) || options.strict
        },
        extensions
        )
        if (V.isUndefinedOrNull(options.onlyValid) ||
          options.onlyValid === false ||
          (options.onlyValid === true && CloudEvent.isValidEvent(ce, { strict: options.strict }))
        ) {
          batch.push(ce)
          if (V.isFunction(callback)) callback(null, { item: ce, num })
          num++
        }
      } catch (e) {
        if (V.isFunction(callback)) callback(e, { item: null, num })
        if (options.logError === true) {
          console.error(e)
        }
        if (options.throwError === true) {
          const msg = `Unable to create CloudEvent instance number ${num}, error detail: ${e.message}`
          throw new Error(msg)
        }
      }
    }

    return batch
  }
}

module.exports = JSONBatch