Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/console/output/encoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

module Console
module Output
# @deprecated With no replacement.
class Encoder
def initialize(output, encoding = ::Encoding::UTF_8)
@output = output
Expand Down
92 changes: 87 additions & 5 deletions lib/console/output/json.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,98 @@
# Copyright, 2021-2022, by Samuel Williams.

require_relative '../serialized/logger'
require_relative 'encoder'

module Console
module Output
module JSON
# This is a safe JSON serializer that will not raise exceptions.
class Safe
def initialize(limit: 8, encoding: ::Encoding::UTF_8)
@limit = limit
@encoding = encoding
end

def dump(object)
::JSON.dump(object, @limit)
rescue => error
::JSON.dump(safe_dump(object, error))
end

private

def default_objects
Hash.new.compare_by_identity
end

def safe_dump(object, error)
object = safe_dump_recurse(object)

object[:truncated] = true
object[:error] = {
class: safe_dump_recurse(error.class.name),
message: safe_dump_recurse(error.message),
}

return object
end

def replacement_for(object)
case object
when Array
"[...]"
when Hash
"{...}"
else
"..."
end
end

# This will recursively generate a safe version of the object.
# Nested hashes and arrays will be transformed recursively.
# Strings will be encoded with the given encoding.
# Primitive values will be returned as-is.
# Other values will be converted using `as_json` if available, otherwise `to_s`.
def safe_dump_recurse(object, limit = @limit, objects = default_objects)
if limit <= 0 || objects[object]
return replacement_for(object)
end

case object
when Hash
objects[object] = true

object.to_h do |key, value|
[
String(key).encode(@encoding, invalid: :replace, undef: :replace),
safe_dump_recurse(value, limit - 1, objects)
]
end
when Array
objects[object] = true

object.map do |value|
safe_dump_recurse(value, limit - 1, objects)
end
when String
object.encode(@encoding, invalid: :replace, undef: :replace)
when Numeric, TrueClass, FalseClass, NilClass
object
else
objects[object] = true

# We could do something like this but the chance `as_json` will blow up.
# We'd need to be extremely careful about it.
# if object.respond_to?(:as_json)
# safe_dump_recurse(object.as_json, limit - 1, objects)
# else

safe_dump_recurse(object.to_s, limit - 1, objects)
end
end
end

def self.new(output, **options)
# The output encoder can prevent encoding issues (e.g. invalid UTF-8):
Output::Encoder.new(
Serialized::Logger.new(output, format: ::JSON, **options)
)
Serialized::Logger.new(output, format: Safe.new, **options)
end
end
end
Expand Down
93 changes: 57 additions & 36 deletions test/console/output.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,76 +8,97 @@
require 'my_custom_output'

describe Console::Output do
let(:capture) {StringIO.new}
let(:env) {Hash.new}
let(:output) {Console::Output.new(capture, env)}

describe '.new' do
with 'output to a file' do
let(:env) {Hash.new}
let(:output) {File.open('/tmp/console.log', 'w')}
let(:capture) {File.open('/tmp/console.log', 'w')}

it 'should use a serialized format' do
expect(Console::Output.new(output, env).output).to be_a(Console::Serialized::Logger)
expect(output).to be_a(Console::Serialized::Logger)
end
end

with 'output to $stderr' do
let(:env) {Hash.new}
let(:output) {$stderr}
let(:capture) {$stderr}

it 'should use a terminal format' do
expect($stderr).to receive(:tty?).and_return(true)

expect(Console::Output.new($stderr, env)).to be_a Console::Terminal::Logger
expect(output).to be_a Console::Terminal::Logger
end
end

it 'can set output to Serialized and format to JSON by ENV' do
output = Console::Output.new(StringIO.new, {'CONSOLE_OUTPUT' => 'JSON'})
expect(output).to be_a(Console::Output::Encoder)

output = output.output
expect(output).to be_a Console::Serialized::Logger
expect(output.format).to be == JSON
with env: {'CONSOLE_OUTPUT' => 'JSON'} do
it 'can set output to Serialized and format to JSON' do
expect(output).to be_a Console::Serialized::Logger
expect(output.format).to be_a(Console::Output::JSON::Safe)
end
end

it 'can set output to Serialized using custom format by ENV' do
output = Console::Output.new(StringIO.new, {'CONSOLE_OUTPUT' => 'MyCustomOutput'})

expect(output).to be_a MyCustomOutput
with env: {'CONSOLE_OUTPUT' => 'MyCustomOutput'} do
it 'can set output to Serialized using custom format by ENV' do
expect(output).to be_a MyCustomOutput
end
end

it 'raises error until the given format class is available' do
expect {
Console::Output.new(nil, {'CONSOLE_OUTPUT' => 'InvalidOutput'})
}.to raise_exception(NameError, message: be =~ /Console::Output::InvalidOutput/)
with env: {'CONSOLE_OUTPUT' => 'InvalidOutput'} do
it 'raises error until the given format class is available' do
expect{output}.to raise_exception(NameError, message: be =~ /Console::Output::InvalidOutput/)
end
end

it 'can force format to XTerm for non tty output by ENV' do
io = StringIO.new
expect(Console::Terminal).not.to receive(:for)
output = Console::Output.new(io, {'CONSOLE_OUTPUT' => 'XTerm'})
expect(output).to be_a Console::Terminal::Logger
expect(output.terminal).to be_a Console::Terminal::XTerm
with env: {'CONSOLE_OUTPUT' => 'XTerm'} do
it 'can force format to XTerm for non tty output by ENV' do
expect(Console::Terminal).not.to receive(:for)
expect(output).to be_a Console::Terminal::Logger
expect(output.terminal).to be_a Console::Terminal::XTerm
end
end

it 'can force format to text for tty output by ENV using Text' do
io = StringIO.new
expect(Console::Terminal).not.to receive(:for)
output = Console::Output.new(io, {'CONSOLE_OUTPUT' => 'Text'})
expect(output).to be_a Console::Terminal::Logger
expect(output.terminal).to be_a Console::Terminal::Text
with env: {'CONSOLE_OUTPUT' => 'Text'} do
it 'can force format to text for tty output by ENV using Text' do
expect(Console::Terminal).not.to receive(:for)
expect(output).to be_a Console::Terminal::Logger
expect(output.terminal).to be_a Console::Terminal::Text
end
end
end

with "invalid UTF-8" do
let(:capture) {StringIO.new}

it "should replace invalid characters" do
expect(capture).to receive(:tty?).and_return(false)
output = Console::Output.new(capture, {})

output.call("Hello \xFF")

message = JSON.parse(capture.string)
expect(message['subject']).to be == "Hello \uFFFD"
end
end

with "recursive arguments" do
it "should replace top level recursion" do
arguments = []
arguments << arguments

output.call("Hello", arguments)

message = JSON.parse(capture.string)
expect(message['truncated']).to be == true
expect(message['message']).to be == ["[...]"]
end

it "should replace nested recursion" do
arguments = {}
arguments[:arguments] = arguments

output.call("Hello", arguments)

message = JSON.parse(capture.string)
expect(message['truncated']).to be == true
expect(message['message']).to be == {"arguments"=>"{...}"}
end
end
end