1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139
|
# frozen_string_literal: true
# Released under the MIT License.
# Copyright, 2019-2025, by Samuel Williams.
require_relative "wrapper"
require "zlib"
module Protocol
module HTTP
module Body
# A body which compresses or decompresses the contents using the DEFLATE or GZIP algorithm.
class ZStream < Wrapper
# The default compression level.
DEFAULT_LEVEL = 7
# The DEFLATE window size.
DEFLATE = -Zlib::MAX_WBITS
# The GZIP window size.
GZIP = Zlib::MAX_WBITS | 16
# The supported encodings.
ENCODINGS = {
"deflate" => DEFLATE,
"gzip" => GZIP,
}
# Initialize the body with the given stream.
#
# @parameter body [Readable] the body to wrap.
# @parameter stream [Zlib::Deflate | Zlib::Inflate] the stream to use for compression or decompression.
def initialize(body, stream)
super(body)
@stream = stream
@input_length = 0
@output_length = 0
end
# Close the stream.
#
# @parameter error [Exception | Nil] the error that caused the stream to be closed.
def close(error = nil)
if stream = @stream
@stream = nil
stream.close unless stream.closed?
end
super
end
# The length of the output, if known. Generally, this is not known due to the nature of compression.
def length
# We don't know the length of the output until after it's been compressed.
nil
end
# @attribute [Integer] input_length the total number of bytes read from the input.
attr :input_length
# @attribute [Integer] output_length the total number of bytes written to the output.
attr :output_length
# The compression ratio, according to the input and output lengths.
#
# @returns [Float] the compression ratio, e.g. 0.5 for 50% compression.
def ratio
if @input_length != 0
@output_length.to_f / @input_length.to_f
else
1.0
end
end
# Convert the body to a hash suitable for serialization.
#
# @returns [Hash] The body as a hash.
def as_json(...)
super.merge(
input_length: @input_length,
output_length: @output_length,
compression_ratio: (ratio * 100).round(2)
)
end
# Inspect the body, including the compression ratio.
#
# @returns [String] a string representation of the body.
def inspect
"#{super} | #<#{self.class} #{(ratio*100).round(2)}%>"
end
end
# A body which compresses the contents using the DEFLATE or GZIP algorithm.
class Deflate < ZStream
# Create a new body which compresses the given body using the GZIP algorithm by default.
#
# @parameter body [Readable] the body to wrap.
# @parameter window_size [Integer] the window size to use for compression.
# @parameter level [Integer] the compression level to use.
# @returns [Deflate] the wrapped body.
def self.for(body, window_size = GZIP, level = DEFAULT_LEVEL)
self.new(body, Zlib::Deflate.new(level, window_size))
end
# Read a chunk from the underlying body and compress it. If the body is finished, the stream is flushed and finished, and the remaining data is returned.
#
# @returns [String | Nil] the compressed chunk or `nil` if the stream is closed.
def read
return if @stream.finished?
# The stream might have been closed while waiting for the chunk to come in.
while chunk = super
unless chunk.empty?
@input_length += chunk.bytesize
chunk = @stream.deflate(chunk, Zlib::SYNC_FLUSH)
@output_length += chunk.bytesize
return chunk
end
end
if !@stream.closed?
chunk = @stream.finish
@output_length += chunk.bytesize
return chunk.empty? ? nil : chunk
end
end
end
end
end
end
|