stdlib/promise.rb

Summary

Maintainability
B
4 hrs
Test Coverage
# {Promise} is used to help structure asynchronous code.
#
# It is available in the Opal standard library, and can be required in any Opal
# application:
#
#     require 'promise'
#
# ## Basic Usage
#
# Promises are created and returned as objects with the assumption that they
# will eventually be resolved or rejected, but never both. A {Promise} has
# a {#then} and {#fail} method (or one of their aliases) that can be used to
# register a block that gets called once resolved or rejected.
#
#     promise = Promise.new
#
#     promise.then {
#       puts "resolved!"
#     }.fail {
#       puts "rejected!"
#     }
#
#     # some time later
#     promise.resolve
#
#     # => "resolved!"
#
# It is important to remember that a promise can only be resolved or rejected
# once, so the block will only ever be called once (or not at all).
#
# ## Resolving Promises
#
# To resolve a promise, means to inform the {Promise} that it has succeeded
# or evaluated to a useful value. {#resolve} can be passed a value which is
# then passed into the block handler:
#
#     def get_json
#       promise = Promise.new
#
#       HTTP.get("some_url") do |req|
#         promise.resolve req.json
#       end
#
#       promise
#     end
#
#     get_json.then do |json|
#       puts "got some JSON from server"
#     end
#
# ## Rejecting Promises
#
# Promises are also designed to handle error cases, or situations where an
# outcome is not as expected. Taking the previous example, we can also pass
# a value to a {#reject} call, which passes that object to the registered
# {#fail} handler:
#
#     def get_json
#       promise = Promise.new
#
#       HTTP.get("some_url") do |req|
#         if req.ok?
#           promise.resolve req.json
#         else
#           promise.reject req
#         end
#
#       promise
#     end
#
#     get_json.then {
#       # ...
#     }.fail { |req|
#       puts "it went wrong: #{req.message}"
#     }
#
# ## Chaining Promises
#
# Promises become even more useful when chained together. Each {#then} or
# {#fail} call returns a new {Promise} which can be used to chain more and more
# handlers together.
#
#     promise.then { wait_for_something }.then { do_something_else }
#
# Rejections are propagated through the entire chain, so a "catch all" handler
# can be attached at the end of the tail:
#
#     promise.then { ... }.then { ... }.fail { ... }
#
# ## Composing Promises
#
# {Promise.when} can be used to wait for more than one promise to resolve (or
# reject). Using the previous example, we could request two different json
# requests and wait for both to finish:
#
#     Promise.when(get_json, get_json2).then |first, second|
#       puts "got two json payloads: #{first}, #{second}"
#     end
#
class Promise
  def self.value(value)
    new.resolve(value)
  end

  def self.error(value)
    new.reject(value)
  end

  def self.when(*promises)
    When.new(promises)
  end

  attr_reader :error, :prev, :next

  def initialize(action = {})
    @action = action

    @realized  = false
    @exception = false
    @value     = nil
    @error     = nil
    @delayed   = false

    @prev = nil
    @next = []
  end

  def value
    if Promise === @value
      @value.value
    else
      @value
    end
  end

  def act?
    @action.key?(:success) || @action.key?(:always)
  end

  def action
    @action.keys
  end

  def exception?
    @exception
  end

  def realized?
    @realized != false
  end

  def resolved?
    @realized == :resolve
  end

  def rejected?
    @realized == :reject
  end

  def ^(promise)
    promise << self
    self >> promise

    promise
  end

  def <<(promise)
    @prev = promise

    self
  end

  def >>(promise)
    @next << promise

    if exception?
      promise.reject(@delayed[0])
    elsif resolved?
      promise.resolve(@delayed ? @delayed[0] : value)
    elsif rejected?
      if !@action.key?(:failure) || Promise === (@delayed ? @delayed[0] : @error)
        promise.reject(@delayed ? @delayed[0] : error)
      elsif promise.action.include?(:always)
        promise.reject(@delayed ? @delayed[0] : error)
      end
    end

    self
  end

  def resolve(value = nil)
    if realized?
      raise ArgumentError, 'the promise has already been realized'
    end

    if Promise === value
      return (value << @prev) ^ self
    end

    begin
      block = @action[:success] || @action[:always]
      if block
        value = block.call(value)
      end

      resolve!(value)
    rescue Exception => e
      exception!(e)
    end

    self
  end

  def resolve!(value)
    @realized = :resolve
    @value    = value

    if @next.any?
      @next.each { |p| p.resolve(value) }
    else
      @delayed = [value]
    end
  end

  def reject(value = nil)
    if realized?
      raise ArgumentError, 'the promise has already been realized'
    end

    if Promise === value
      return (value << @prev) ^ self
    end

    begin
      block = @action[:failure] || @action[:always]
      if block
        value = block.call(value)
      end

      if @action.key?(:always)
        resolve!(value)
      else
        reject!(value)
      end
    rescue Exception => e
      exception!(e)
    end

    self
  end

  def reject!(value)
    @realized = :reject
    @error    = value

    if @next.any?
      @next.each { |p| p.reject(value) }
    else
      @delayed = [value]
    end
  end

  def exception!(error)
    @exception = true

    reject!(error)
  end

  def then(&block)
    self ^ Promise.new(success: block)
  end

  def then!(&block)
    there_can_be_only_one!
    self.then(&block)
  end

  def fail(&block)
    self ^ Promise.new(failure: block)
  end

  def fail!(&block)
    there_can_be_only_one!
    fail(&block)
  end

  def always(&block)
    self ^ Promise.new(always: block)
  end

  def always!(&block)
    there_can_be_only_one!
    always(&block)
  end

  def trace(depth = nil, &block)
    self ^ Trace.new(depth, block)
  end

  def trace!(*args, &block)
    there_can_be_only_one!
    trace(*args, &block)
  end

  def there_can_be_only_one!
    if @next.any?
      raise ArgumentError, 'a promise has already been chained'
    end
  end

  def inspect
    result = "#<#{self.class}(#{object_id})"

    if @next.any?
      result += " >> #{@next.inspect}"
    end

    result += if realized?
                ": #{(@value || @error).inspect}>"
              else
                '>'
              end

    result
  end

  def to_v2
    v2 = PromiseV2.new

    self.then { |i| v2.resolve(i) }.rescue { |i| v2.reject(i) }

    v2
  end

  # PromiseV1 is not a native construct, we must convert it to a v2 promise
  alias await to_v2

  alias catch fail
  alias catch! fail!
  alias do then
  alias do! then!
  alias ensure always
  alias ensure! always!
  alias finally always
  alias finally! always!
  alias rescue fail
  alias rescue! fail!
  alias to_n to_v2
  alias to_v1 itself

  class Trace < self
    def self.it(promise)
      current = []

      if promise.act? || promise.prev.nil?
        current.push(promise.value)
      end

      prev = promise.prev
      if prev
        current.concat(it(prev))
      else
        current
      end
    end

    def initialize(depth, block)
      @depth = depth

      super success: proc {
        trace = Trace.it(self).reverse
        trace.pop

        if depth && depth <= trace.length
          trace.shift(trace.length - depth)
        end

        block.call(*trace)
      }
    end
  end

  class When < self
    def initialize(promises = [])
      super()

      @wait = []

      promises.each do |promise|
        wait promise
      end
    end

    def each(&block)
      raise ArgumentError, 'no block given' unless block

      self.then do |values|
        values.each(&block)
      end
    end

    def collect(&block)
      raise ArgumentError, 'no block given' unless block

      self.then do |values|
        When.new(values.map(&block))
      end
    end

    def inject(*args, &block)
      self.then do |values|
        values.reduce(*args, &block)
      end
    end

    def wait(promise)
      unless Promise === promise
        promise = Promise.value(promise)
      end

      if promise.act?
        promise = promise.then
      end

      @wait << promise

      promise.always do
        try if @next.any?
      end

      self
    end

    def >>(*)
      super.tap do
        try
      end
    end

    def try
      if @wait.all?(&:realized?)
        promise = @wait.find(&:rejected?)
        if promise
          reject(promise.error)
        else
          resolve(@wait.map(&:value))
        end
      end
    end

    alias map collect
    alias reduce inject
    alias and wait
  end
end

PromiseV1 = Promise