lib/protocol/rack/body/enumerable.rb
# frozen_string_literal: true # Released under the MIT License. # Copyright, 2022-2025, by Samuel Williams. require "protocol/http/body/readable" require "protocol/http/body/file" module Protocol module Rack module Body # Wraps a Rack response body that responds to `each`. # The body must only yield `String` values and may optionally respond to `close`. # This class provides both streaming and buffered access to the response body. class Enumerable < ::Protocol::HTTP::Body::Readable # The content-length header key. CONTENT_LENGTH = "content-length".freeze # Wraps a Rack response body into an {Enumerable} instance. # If the body is an Array, its total size is calculated automatically. # # @parameter body [Object] The Rack response body that responds to `each`. # @parameter length [Integer] Optional content length of the response body. # @returns [Enumerable] A new enumerable body instance. def self.wrap(body, length = nil) if body.is_a?(Array) length ||= body.sum(&:bytesize) return self.new(body, length) else return self.new(body, length) end end # Initialize the enumerable body wrapper. # # @parameter body [Object] The Rack response body that responds to `each`. # @parameter length [Integer] The content length of the response body. def initialize(body, length) @length = length @body = body @chunks = nil end # @attribute [Object] The wrapped Rack response body. attr :body # @attribute [Integer] The total size of the response body in bytes. attr :length # Check if the response body is empty. # A body is considered empty if its length is 0 or if it responds to `empty?` and is empty. # # @returns [Boolean] True if the body is empty. def empty? @length == 0 or (@body.respond_to?(:empty?) and @body.empty?) end # Check if the response body can be read immediately. # A body is ready if it's an Array or responds to `to_ary`. # # @returns [Boolean] True if the body can be read immediately. def ready? body.is_a?(Array) or body.respond_to?(:to_ary) end # Close the response body. # If the body responds to `close`, it will be called. # # @parameter error [Exception] Optional error that occurred during processing. def close(error = nil) if @body and @body.respond_to?(:close) @body.close end @body = nil @chunks = nil super end # Enumerate the response body. # Each chunk yielded must be a String. # The body is automatically closed after enumeration. # # @yields {|chunk| ...} # @parameter chunk [String] A chunk of the response body. def each(&block) @body.each(&block) rescue => error raise ensure self.close(error) end # Check if the body is a streaming response. # A body is streaming if it doesn't respond to `each`. # # @returns [Boolean] True if the body is streaming. def stream? !@body.respond_to?(:each) end # Stream the response body to the given stream. # The body is automatically closed after streaming. # # @parameter stream [Object] The stream to write the body to. def call(stream) @body.call(stream) rescue => error raise ensure self.close(error) end # Read the next chunk from the response body. # Returns nil when there are no more chunks. # # @returns [String | Nil] The next chunk or nil if there are no more chunks. def read @chunks ||= @body.to_enum(:each) return @chunks.next rescue StopIteration return nil end # Get a string representation of the body. # # @returns [String] A string describing the body's class and length. def inspect "\#<#{self.class} length=#{@length.inspect} body=#{@body.class}>" end end end end end