├── .gitignore ├── .ruby-version ├── .travis.yml ├── CHANGELOG.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── json-write-stream.gemspec ├── lib ├── json-write-stream.rb └── json-write-stream │ ├── stateful.rb │ ├── version.rb │ └── yielding.rb └── spec ├── json-write-stream_spec.rb ├── shared_examples.rb ├── spec_helper.rb ├── stateful_spec.rb └── yielding_spec.rb /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Gemfile.lock 3 | pkg/ 4 | .bundle/ 5 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.5.3 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.5.3 4 | - 2.6.0 5 | script: 'bundle exec rspec' 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 2.0.0 2 | * Added the 'pretty' and 'indent_size' options to the stateful writer to generate "pretty" (i.e. more human-readable) JSON. 3 | * Removed depdency on the rr mocking library. 4 | * Moved History.txt to CHANGELOG.md. 5 | * Refactored the stateful writer out of some weird inheritance. 6 | 7 | # 1.2.0 8 | * Add way of inserting text between key/value pairs. 9 | 10 | # 1.1.0 11 | * Add way of inserting text after commas. 12 | 13 | # 1.0.2 14 | * Add flush methods 15 | 16 | # 1.0.1 17 | * Support custom encodings (UTF-8, UTF-16, etc). 18 | 19 | # 1.0.0 20 | * Birthday! 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gemspec 4 | 5 | group :development, :test do 6 | gem 'pry-byebug' 7 | gem 'rake' 8 | end 9 | 10 | group :test do 11 | gem 'rspec' 12 | end 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Cameron Dutro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | json-write-stream 2 | ================= 3 | 4 | [![Build Status](https://travis-ci.org/camertron/json-write-stream.svg?branch=master)](http://travis-ci.org/camertron/json-write-stream) 5 | 6 | An easy, streaming way to generate JSON. 7 | 8 | ## Installation 9 | 10 | `gem install json-write-stream` 11 | 12 | ## Usage 13 | 14 | ```ruby 15 | require 'json-write-stream' 16 | ``` 17 | 18 | ### Examples for the Impatient 19 | 20 | There are two types of JSON write stream: one that uses blocks and `yield` to delimit arrays and objects, and one that's purely stateful. Here are two examples that produce the same output: 21 | 22 | Yielding: 23 | 24 | ```ruby 25 | stream = StringIO.new 26 | JsonWriteStream.from_stream(stream) do |writer| 27 | writer.write_object do |obj_writer| 28 | obj_writer.write_key_value('foo', 'bar') 29 | obj_writer.write_array('baz') do |arr_writer| 30 | arr_writer.write_element('goo') 31 | end 32 | end 33 | end 34 | ``` 35 | 36 | Stateful: 37 | 38 | ```ruby 39 | stream = StringIO.new 40 | writer = JsonWriteStream.from_stream(stream) 41 | writer.write_object 42 | writer.write_key_value('foo', 'bar') 43 | writer.write_array('baz') 44 | writer.write_element('goo') 45 | writer.close # automatically adds closing punctuation for all nested types 46 | ``` 47 | 48 | Output: 49 | 50 | ```ruby 51 | stream.string # => {"foo":"bar","baz":["goo"]} 52 | ``` 53 | 54 | ### Yielding Writers 55 | 56 | As far as yielding writers go, the example above contains everything you need. The stream will be automatically closed when the outermost block terminates. 57 | 58 | ### Stateful Writers 59 | 60 | Stateful writers have a number of additional methods: 61 | 62 | ```ruby 63 | stream = StringIO.new 64 | writer = JsonWriteStream.from_stream(stream) 65 | writer.write_object 66 | 67 | writer.in_object? # => true, currently writing an object 68 | writer.in_array? # => false, not currently writing an array 69 | writer.eos? # => false, the stream is open and the outermost object hasn't been closed yet 70 | 71 | writer.close_object # explicitly close the current object 72 | writer.eos? # => true, the outermost object has been closed 73 | 74 | writer.write_array # => raises JsonWriteStream::EndOfStreamError 75 | writer.close_array # => raises JsonWriteStream::NotInArrayError 76 | 77 | writer.closed? # => false, the stream is still open 78 | writer.close # close the stream 79 | writer.closed? # => true, the stream has been closed 80 | ``` 81 | 82 | ### Writing to a File 83 | 84 | JsonWriteStream also supports streaming to a file via the `open` method: 85 | 86 | Yielding: 87 | 88 | ```ruby 89 | JsonWriteStream.open('path/to/file.json') do |writer| 90 | writer.write_object do |obj_writer| 91 | ... 92 | end 93 | end 94 | ``` 95 | 96 | Stateful: 97 | 98 | ```ruby 99 | writer = JsonWriteStream.open('path/to/file.json') 100 | writer.write_object 101 | ... 102 | writer.close 103 | ``` 104 | 105 | ### Options 106 | 107 | JsonWriteStream supports generating "pretty" JSON, i.e. JSON formatted in a more human-readable way. Currently only the stateful writer supports pretty generation. Example: 108 | 109 | ```ruby 110 | stream = StringIO.new 111 | writer = JsonWriteStream.from_stream(stream, pretty: true) 112 | writer.write_object 113 | writer.write_key_value('foo', 'bar') 114 | writer.write_array('baz') 115 | writer.write_element('goo') 116 | writer.close 117 | ``` 118 | 119 | Now `stream.string` will contain 120 | 121 | ```json 122 | { 123 | "foo": "bar", 124 | "baz": [ 125 | "goo" 126 | ] 127 | } 128 | ``` 129 | 130 | ## Requirements 131 | 132 | No external requirements. 133 | 134 | ## Running Tests 135 | 136 | `bundle exec rake` should do the trick. Alternatively you can run `bundle exec rspec`, which does the same thing. 137 | 138 | ## Authors 139 | 140 | * Cameron C. Dutro: http://github.com/camertron 141 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'rubygems' unless ENV['NO_RUBYGEMS'] 4 | 5 | require 'bundler' 6 | require 'rspec/core/rake_task' 7 | require 'rubygems/package_task' 8 | 9 | require './lib/json-write-stream' 10 | 11 | Bundler::GemHelper.install_tasks 12 | 13 | task :default => :spec 14 | 15 | desc 'Run specs' 16 | RSpec::Core::RakeTask.new do |t| 17 | t.pattern = './spec/**/*_spec.rb' 18 | end 19 | -------------------------------------------------------------------------------- /json-write-stream.gemspec: -------------------------------------------------------------------------------- 1 | $:.unshift File.join(File.dirname(__FILE__), 'lib') 2 | require 'json-write-stream/version' 3 | 4 | Gem::Specification.new do |s| 5 | s.name = "json-write-stream" 6 | s.version = ::JsonWriteStream::VERSION 7 | s.authors = ["Cameron Dutro"] 8 | s.email = ["camertron@gmail.com"] 9 | s.homepage = "http://github.com/camertron" 10 | 11 | s.description = s.summary = "An easy, streaming way to generate JSON." 12 | 13 | s.add_dependency 'json_pure', '~> 1.8.0' 14 | 15 | s.platform = Gem::Platform::RUBY 16 | s.has_rdoc = true 17 | 18 | s.require_path = 'lib' 19 | s.files = Dir["{lib,spec}/**/*", "Gemfile", "History.txt", "README.md", "Rakefile", "json-write-stream.gemspec"] 20 | end 21 | -------------------------------------------------------------------------------- /lib/json-write-stream.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'json' 4 | require 'json-write-stream/yielding' 5 | require 'json-write-stream/stateful' 6 | 7 | class JsonWriteStream 8 | DEFAULT_ENCODING = Encoding::UTF_8 9 | 10 | class << self 11 | def from_stream(stream, options = {}) 12 | encoding = options.fetch(:encoding, DEFAULT_ENCODING) 13 | stream.set_encoding(encoding) 14 | 15 | if block_given? 16 | yield writer = YieldingWriter.new(stream) 17 | writer.close 18 | else 19 | StatefulWriter.new(stream, options) 20 | end 21 | end 22 | 23 | def open(file, options = {}) 24 | encoding = options.fetch(:encoding, DEFAULT_ENCODING) 25 | handle = File.open(file, 'w') 26 | handle.set_encoding(encoding) 27 | 28 | if block_given? 29 | yield writer = YieldingWriter.new(handle) 30 | writer.close 31 | else 32 | StatefulWriter.new(handle, options) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/json-write-stream/stateful.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'json' 4 | 5 | class JsonWriteStream 6 | class NotInObjectError < StandardError; end 7 | class NotInArrayError < StandardError; end 8 | class EndOfStreamError < StandardError; end 9 | 10 | class StatefulWriter 11 | attr_reader :stream, :stack, :index, :closed, :options 12 | alias_method :closed?, :closed 13 | 14 | def initialize(stream, options = {}) 15 | @stream = stream 16 | @stack = [] 17 | @closed = false 18 | @options = options 19 | @index = 0 20 | end 21 | 22 | def write_object(*args) 23 | check_eos 24 | new_indent_level = 1 25 | 26 | if current 27 | current.write_object(*args) 28 | new_indent_level = current.indent_level + 1 29 | end 30 | 31 | stack.push(StatefulObjectWriter.new(self, new_indent_level)) 32 | end 33 | 34 | def write_array(*args) 35 | check_eos 36 | 37 | new_indent_level = 1 38 | 39 | if current 40 | current.write_array(*args) 41 | new_indent_level = current.indent_level + 1 42 | end 43 | 44 | stack.push(StatefulArrayWriter.new(self, new_indent_level)) 45 | end 46 | 47 | def write_key_value(*args) 48 | check_eos 49 | current.write_key_value(*args) 50 | end 51 | 52 | def write_element(*args) 53 | check_eos 54 | current.write_element(*args) 55 | end 56 | 57 | def close_object 58 | if in_object? 59 | stack.pop.close 60 | current.increment if current 61 | increment 62 | else 63 | raise NotInObjectError, 'not currently writing an object.' 64 | end 65 | end 66 | 67 | def close_array 68 | if in_array? 69 | stack.pop.close 70 | current.increment if current 71 | increment 72 | else 73 | raise NotInArrayError, 'not currently writing an array.' 74 | end 75 | end 76 | 77 | def flush 78 | until stack.empty? 79 | if in_object? 80 | close_object 81 | else 82 | close_array 83 | end 84 | end 85 | 86 | @closed = true 87 | nil 88 | end 89 | 90 | def close 91 | flush 92 | stream.close 93 | nil 94 | end 95 | 96 | def in_object? 97 | current ? current.is_object? : false 98 | end 99 | 100 | def in_array? 101 | current ? current.is_array? : false 102 | end 103 | 104 | def eos? 105 | (stack.size == 0 && index > 0) || closed? 106 | end 107 | 108 | def pretty? 109 | options.fetch(:pretty, false) 110 | end 111 | 112 | def indent_size 113 | options.fetch(:indent_size, 2) 114 | end 115 | 116 | protected 117 | 118 | def increment 119 | @index += 1 120 | end 121 | 122 | def check_eos 123 | if eos? 124 | raise EndOfStreamError, 'end of stream.' 125 | end 126 | end 127 | 128 | def current 129 | stack.last 130 | end 131 | end 132 | 133 | class BaseWriter 134 | attr_reader :writer, :indent_level, :index 135 | 136 | def initialize(writer, indent_level) 137 | @writer = writer 138 | @indent_level = indent_level 139 | @index = 0 140 | after_initialize 141 | end 142 | 143 | def after_initialize 144 | end 145 | 146 | def stream 147 | writer.stream 148 | end 149 | 150 | def increment 151 | @index += 1 152 | end 153 | 154 | def indent(level = indent_level) 155 | stream.write(' ' * indent_size * level) if pretty? 156 | end 157 | 158 | def indent_size 159 | writer.indent_size 160 | end 161 | 162 | def escape(str) 163 | JSON.generate([str])[1..-2] 164 | end 165 | 166 | def write_comma 167 | if index > 0 168 | stream.write(",") 169 | write_newline 170 | end 171 | end 172 | 173 | def write_colon 174 | stream.write(':') 175 | stream.write(' ') if pretty? 176 | end 177 | 178 | def write_newline 179 | stream.write("\n") if pretty? 180 | end 181 | 182 | def pretty? 183 | writer.pretty? 184 | end 185 | end 186 | 187 | class StatefulObjectWriter < BaseWriter 188 | def after_initialize 189 | stream.write("{") 190 | write_newline 191 | end 192 | 193 | # prep work (array is written afterwards) 194 | def write_array(key) 195 | write_comma 196 | increment 197 | indent 198 | write_key(key) 199 | write_colon 200 | end 201 | 202 | # prep work (object is written afterwards) 203 | def write_object(key) 204 | write_comma 205 | increment 206 | indent 207 | write_key(key) 208 | write_colon 209 | end 210 | 211 | def write_key_value(key, value) 212 | write_comma 213 | increment 214 | indent 215 | write_key(key) 216 | write_colon 217 | stream.write(escape(value)) 218 | end 219 | 220 | def close 221 | write_newline 222 | indent(indent_level - 1) 223 | stream.write("}") 224 | end 225 | 226 | def is_object? 227 | true 228 | end 229 | 230 | def is_array? 231 | false 232 | end 233 | 234 | private 235 | 236 | def write_key(key) 237 | case key 238 | when String 239 | stream.write(escape(key)) 240 | else 241 | raise ArgumentError, "'#{key}' must be a string" 242 | end 243 | end 244 | end 245 | 246 | class StatefulArrayWriter < BaseWriter 247 | def after_initialize 248 | stream.write("[") 249 | write_newline 250 | end 251 | 252 | def write_element(element) 253 | write_comma 254 | increment 255 | indent 256 | stream.write(escape(element)) 257 | end 258 | 259 | # prep work 260 | def write_array 261 | write_comma 262 | increment 263 | indent 264 | end 265 | 266 | # prep work 267 | def write_object 268 | write_comma 269 | increment 270 | indent 271 | end 272 | 273 | def close 274 | write_newline 275 | indent(indent_level - 1) 276 | stream.write("]") 277 | end 278 | 279 | def is_object? 280 | false 281 | end 282 | 283 | def is_array? 284 | true 285 | end 286 | end 287 | end 288 | -------------------------------------------------------------------------------- /lib/json-write-stream/version.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | class JsonWriteStream 4 | VERSION = '2.0.0' 5 | end 6 | -------------------------------------------------------------------------------- /lib/json-write-stream/yielding.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | class JsonWriteStream 4 | class YieldingWriter 5 | attr_reader :stream, :index, :options 6 | 7 | def initialize(stream, options = {}) 8 | @stream = stream 9 | @index = 0 10 | @options = options 11 | after_initialize 12 | end 13 | 14 | def after_initialize 15 | end 16 | 17 | def write_object(comma_written = false) 18 | unless comma_written 19 | write_comma 20 | increment 21 | end 22 | 23 | yield writer = YieldingObjectWriter.new(stream) 24 | writer.close 25 | end 26 | 27 | def write_array(comma_written = false) 28 | unless comma_written 29 | write_comma 30 | increment 31 | end 32 | 33 | yield writer = YieldingArrayWriter.new(stream) 34 | writer.close 35 | end 36 | 37 | def flush 38 | end 39 | 40 | def close 41 | stream.close 42 | end 43 | 44 | protected 45 | 46 | def escape(str) 47 | JSON.generate([str])[1..-2] 48 | end 49 | 50 | def write_comma 51 | stream.write(',') if index > 0 52 | end 53 | 54 | def increment 55 | @index += 1 56 | end 57 | end 58 | 59 | class YieldingObjectWriter < YieldingWriter 60 | def after_initialize 61 | stream.write('{') 62 | end 63 | 64 | def write_array(key) 65 | write_comma 66 | increment 67 | write_key(key) 68 | stream.write(':') 69 | super(true) 70 | end 71 | 72 | def write_object(key) 73 | write_comma 74 | increment 75 | write_key(key) 76 | stream.write(':') 77 | super(true) 78 | end 79 | 80 | def write_key_value(key, value) 81 | write_comma 82 | increment 83 | write_key(key) 84 | stream.write(':') 85 | stream.write(escape(value)) 86 | end 87 | 88 | def close 89 | stream.write('}') 90 | end 91 | 92 | private 93 | 94 | def write_key(key) 95 | stream.write(escape(key.to_s)) 96 | end 97 | end 98 | 99 | class YieldingArrayWriter < YieldingWriter 100 | def after_initialize 101 | stream.write('[') 102 | end 103 | 104 | def write_element(element) 105 | write_comma 106 | increment 107 | stream.write(escape(element)) 108 | end 109 | 110 | def close 111 | stream.write(']') 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/json-write-stream_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'spec_helper' 4 | require 'tempfile' 5 | 6 | describe JsonWriteStream do 7 | let(:yielding_writer) { JsonWriteStream::YieldingWriter } 8 | let(:stateful_writer) { JsonWriteStream::StatefulWriter } 9 | let(:stream_writer) { JsonWriteStream } 10 | let(:tempfile) { Tempfile.new('temp') } 11 | let(:stream) { StringIO.new } 12 | 13 | describe '#from_stream' do 14 | it 'yields a yielding stream if given a block' do 15 | stream_writer.from_stream(stream) do |writer| 16 | expect(writer).to be_a(yielding_writer) 17 | expect(writer.stream).to equal(stream) 18 | end 19 | end 20 | 21 | it 'returns a stateful writer if not given a block' do 22 | writer = stream_writer.from_stream(stream) 23 | expect(writer).to be_a(stateful_writer) 24 | expect(writer.stream).to equal(stream) 25 | end 26 | 27 | it 'supports specifying a different encoding' do 28 | stream_writer.from_stream(stream, encoding: Encoding::UTF_16BE) do |writer| 29 | writer.write_object do |obj_writer| 30 | obj_writer.write_key_value('foo', 'bar') 31 | end 32 | end 33 | 34 | expect(stream.string.bytes.to_a).to_not eq('{"foo":"bar"}'.bytes.to_a) 35 | expect(stream.string.encode(Encoding::UTF_8).bytes.to_a).to eq('{"foo":"bar"}'.bytes.to_a) 36 | end 37 | end 38 | 39 | describe '#open' do 40 | it 'opens a file and yields a yielding stream if given a block' do 41 | expect(File).to receive(:open).with(tempfile, 'w').and_return(tempfile) 42 | stream_writer.open(tempfile) do |writer| 43 | expect(writer).to be_a(yielding_writer) 44 | expect(writer.stream.path).to eq(tempfile.path) 45 | end 46 | end 47 | 48 | it 'opens a file and returns a stateful writer if not given a block' do 49 | expect(File).to receive(:open).with(tempfile, 'w').and_return(tempfile) 50 | writer = stream_writer.open(tempfile) 51 | expect(writer).to be_a(stateful_writer) 52 | expect(writer.stream.path).to eq(tempfile.path) 53 | end 54 | 55 | it 'supports specifying a different encoding' do 56 | stream_writer.open(tempfile, encoding: Encoding::UTF_16BE) do |writer| 57 | writer.write_object do |obj_writer| 58 | obj_writer.write_key_value('foo', 'bar') 59 | end 60 | end 61 | 62 | written = tempfile.read 63 | written.force_encoding(Encoding::UTF_16BE) 64 | 65 | expect(written.bytes.to_a).to_not eq('{"foo":"bar"}'.bytes.to_a) 66 | expect(written.encode(Encoding::UTF_8).bytes.to_a).to eq('{"foo":"bar"}'.bytes.to_a) 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/shared_examples.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | shared_examples 'a json stream' do |options = {}| 4 | it 'handles a simple array' do 5 | check_roundtrip(['abc'], options) 6 | end 7 | 8 | it 'handles a simple object' do 9 | check_roundtrip({ 'foo' => 'bar' }, options) 10 | end 11 | 12 | it 'handles one level of array nesting' do 13 | check_roundtrip([['def'],'abc'], options) 14 | check_roundtrip(['abc',['def']], options) 15 | end 16 | 17 | it 'handles one level of object nesting' do 18 | check_roundtrip({ 'foo' => { 'bar' => 'baz' } }, options) 19 | end 20 | 21 | it 'handles one level of mixed nesting' do 22 | check_roundtrip({ 'foo' => ['bar', 'baz'] }, options) 23 | check_roundtrip([{ 'foo' => 'bar' }], options) 24 | end 25 | 26 | it 'handles multiple levels of mixed nesting' do 27 | check_roundtrip({'foo' => ['bar', { 'baz' => 'moo', 'gaz' => ['doo'] }, 'kal'], 'jim' => ['jill', ['john']] }, options) 28 | check_roundtrip(['foo', { 'bar' => 'baz', 'moo' => ['gaz', ['jim', ['jill']], 'jam'] }], options) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'rspec' 4 | require 'json-write-stream' 5 | require 'shared_examples' 6 | require 'pry-byebug' 7 | 8 | RSpec.configure do |config| 9 | end 10 | 11 | class RoundtripChecker 12 | class << self 13 | include RSpec::Matchers 14 | 15 | def check_roundtrip(obj, options = {}) 16 | stream = StringIO.new 17 | writer = create_writer(stream, options) 18 | serialize(obj, writer) 19 | writer.close 20 | new_obj = JSON.parse(stream.string) 21 | compare(obj, new_obj) 22 | end 23 | 24 | private 25 | 26 | def compare(old_obj, new_obj) 27 | expect(old_obj.class).to equal(new_obj.class) 28 | 29 | case old_obj 30 | when Hash 31 | expect(old_obj.keys).to eq(new_obj.keys) 32 | 33 | old_obj.each_pair do |key, old_val| 34 | compare(old_val, new_obj[key]) 35 | end 36 | when Array 37 | old_obj.each_with_index do |old_element, idx| 38 | compare(old_element, new_obj[idx]) 39 | end 40 | else 41 | expect(old_obj).to eq(new_obj) 42 | end 43 | end 44 | end 45 | end 46 | 47 | class YieldingRoundtripChecker < RoundtripChecker 48 | class << self 49 | protected 50 | 51 | def create_writer(stream, options = {}) 52 | JsonWriteStream::YieldingWriter.new(stream, options) 53 | end 54 | 55 | def serialize(obj, writer) 56 | case obj 57 | when Hash 58 | writer.write_object do |object_writer| 59 | serialize_object(obj, object_writer) 60 | end 61 | when Array 62 | writer.write_array do |array_writer| 63 | serialize_array(obj, array_writer) 64 | end 65 | end 66 | end 67 | 68 | def serialize_object(obj, writer) 69 | obj.each_pair do |key, val| 70 | case val 71 | when Hash 72 | writer.write_object(key) do |object_writer| 73 | serialize_object(val, object_writer) 74 | end 75 | when Array 76 | writer.write_array(key) do |array_writer| 77 | serialize_array(val, array_writer) 78 | end 79 | else 80 | writer.write_key_value(key, val) 81 | end 82 | end 83 | end 84 | 85 | def serialize_array(obj, writer) 86 | obj.each do |element| 87 | case element 88 | when Hash 89 | writer.write_object do |object_writer| 90 | serialize_object(element, object_writer) 91 | end 92 | when Array 93 | writer.write_array do |array_writer| 94 | serialize_array(element, array_writer) 95 | end 96 | else 97 | writer.write_element(element) 98 | end 99 | end 100 | end 101 | end 102 | end 103 | 104 | class StatefulRoundtripChecker < RoundtripChecker 105 | class << self 106 | protected 107 | 108 | def create_writer(stream, options = {}) 109 | JsonWriteStream::StatefulWriter.new(stream, options) 110 | end 111 | 112 | def serialize(obj, writer) 113 | case obj 114 | when Hash 115 | writer.write_object 116 | serialize_object(obj, writer) 117 | writer.close_object 118 | when Array 119 | writer.write_array 120 | serialize_array(obj, writer) 121 | writer.close_array 122 | end 123 | end 124 | 125 | def serialize_object(obj, writer) 126 | obj.each_pair do |key, val| 127 | case val 128 | when Hash 129 | writer.write_object(key) 130 | serialize_object(val, writer) 131 | writer.close_object 132 | when Array 133 | writer.write_array(key) 134 | serialize_array(val, writer) 135 | writer.close_array 136 | else 137 | writer.write_key_value(key, val) 138 | end 139 | end 140 | end 141 | 142 | def serialize_array(obj, writer) 143 | obj.each do |element| 144 | case element 145 | when Hash 146 | writer.write_object 147 | serialize_object(element, writer) 148 | writer.close_object 149 | when Array 150 | writer.write_array 151 | serialize_array(element, writer) 152 | writer.close_array 153 | else 154 | writer.write_element(element) 155 | end 156 | end 157 | end 158 | end 159 | end 160 | -------------------------------------------------------------------------------- /spec/stateful_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'spec_helper' 4 | 5 | describe JsonWriteStream::YieldingWriter do 6 | let(:stream) do 7 | StringIO.new.tap do |io| 8 | io.set_encoding(Encoding::UTF_8) 9 | end 10 | end 11 | 12 | let(:options) { {} } 13 | let(:stream_writer) { JsonWriteStream::StatefulWriter.new(stream, options) } 14 | 15 | def check_roundtrip(obj, options = {}) 16 | StatefulRoundtripChecker.check_roundtrip(obj, options) 17 | end 18 | 19 | def utf8(str) 20 | str.encode(Encoding::UTF_8) 21 | end 22 | 23 | it_behaves_like 'a json stream' 24 | 25 | context 'with the pretty option' do 26 | let(:options) { { pretty: true } } 27 | 28 | it_behaves_like 'a json stream', pretty: true 29 | 30 | it 'prettifies a basic array' do 31 | stream_writer.write_array 32 | stream_writer.write_element('foo') 33 | stream_writer.close 34 | expect(stream.string).to eq(<<~END.strip) 35 | [ 36 | "foo" 37 | ] 38 | END 39 | end 40 | 41 | it 'prettifies a basic object' do 42 | stream_writer.write_object 43 | stream_writer.write_key_value('foo', 'bar') 44 | stream_writer.close 45 | expect(stream.string).to eq(<<~END.strip) 46 | { 47 | "foo": "bar" 48 | } 49 | END 50 | end 51 | 52 | it 'prettifies a complex structure' do 53 | stream_writer.write_object 54 | stream_writer.write_array('foo') 55 | stream_writer.write_element('bar') 56 | stream_writer.write_object 57 | stream_writer.write_key_value('baz', 'moo') 58 | stream_writer.write_array('gaz') 59 | stream_writer.write_element('doo') 60 | stream_writer.close_array 61 | stream_writer.close_object 62 | stream_writer.write_element('kal') 63 | stream_writer.close_array 64 | stream_writer.write_array('jim') 65 | stream_writer.write_element('jill') 66 | stream_writer.write_array 67 | stream_writer.write_element('john') 68 | stream_writer.close 69 | expect(stream.string).to eq(<<~END.strip) 70 | { 71 | "foo": [ 72 | "bar", 73 | { 74 | "baz": "moo", 75 | "gaz": [ 76 | "doo" 77 | ] 78 | }, 79 | "kal" 80 | ], 81 | "jim": [ 82 | "jill", 83 | [ 84 | "john" 85 | ] 86 | ] 87 | } 88 | END 89 | end 90 | 91 | context 'and the indent_size option' do 92 | let(:options) { super().merge(indent_size: 4) } 93 | 94 | it 'indents a basic object correctly' do 95 | stream_writer.write_object 96 | stream_writer.write_key_value('foo', 'bar') 97 | stream_writer.close 98 | expect(stream.string).to eq(<<~END.strip) 99 | { 100 | "foo": "bar" 101 | } 102 | END 103 | end 104 | 105 | it 'indents a more complicated object correctly' do 106 | stream_writer.write_object 107 | stream_writer.write_array('foo') 108 | stream_writer.write_element('bar') 109 | stream_writer.write_object 110 | stream_writer.write_key_value('baz', 'moo') 111 | stream_writer.close 112 | expect(stream.string).to eq(<<~END.strip) 113 | { 114 | "foo": [ 115 | "bar", 116 | { 117 | "baz": "moo" 118 | } 119 | ] 120 | } 121 | END 122 | end 123 | end 124 | end 125 | 126 | describe '#close' do 127 | it 'unwinds the stack, adds appropriate closing punctuation for each unclosed item, and closes the stream' do 128 | stream_writer.write_array 129 | stream_writer.write_element('abc') 130 | stream_writer.write_object 131 | stream_writer.write_key_value('def', 'ghi') 132 | stream_writer.close 133 | 134 | expect(stream.string).to eq(utf8('["abc",{"def":"ghi"}]')) 135 | expect(stream_writer).to be_closed 136 | expect(stream).to be_closed 137 | end 138 | end 139 | 140 | describe '#closed?' do 141 | it 'returns false if the stream is still open' do 142 | expect(stream_writer).to_not be_closed 143 | end 144 | 145 | it 'returns true if the stream is closed' do 146 | stream_writer.close 147 | expect(stream_writer).to be_closed 148 | end 149 | end 150 | 151 | describe '#in_object?' do 152 | it 'returns true if the writer is currently writing an object' do 153 | stream_writer.write_object 154 | expect(stream_writer).to be_in_object 155 | end 156 | 157 | it 'returns false if the writer is not currently writing an object' do 158 | expect(stream_writer).to_not be_in_object 159 | stream_writer.write_array 160 | expect(stream_writer).to_not be_in_object 161 | end 162 | end 163 | 164 | describe '#in_array?' do 165 | it 'returns true if the writer is currently writing an array' do 166 | stream_writer.write_array 167 | expect(stream_writer).to be_in_array 168 | end 169 | 170 | it 'returns false if the writer is not currently writing an array' do 171 | expect(stream_writer).to_not be_in_array 172 | stream_writer.write_object 173 | expect(stream_writer).to_not be_in_array 174 | end 175 | end 176 | 177 | describe '#eos?' do 178 | it 'returns false if nothing has been written yet' do 179 | expect(stream_writer).to_not be_eos 180 | end 181 | 182 | it 'returns false if the writer is in the middle of writing' do 183 | stream_writer.write_object 184 | expect(stream_writer).to_not be_eos 185 | end 186 | 187 | it "returns true if the writer has finished it's top-level" do 188 | stream_writer.write_object 189 | stream_writer.close_object 190 | expect(stream_writer).to be_eos 191 | end 192 | 193 | it 'returns true if the writer is closed' do 194 | stream_writer.close 195 | expect(stream_writer).to be_eos 196 | end 197 | end 198 | 199 | describe '#close_object' do 200 | it 'raises an error if an object is not currently being written' do 201 | stream_writer.write_array 202 | expect(-> { stream_writer.close_object }).to raise_error(JsonWriteStream::NotInObjectError) 203 | end 204 | end 205 | 206 | describe '#close_array' do 207 | it 'raises an error if an array is not currently being written' do 208 | stream_writer.write_object 209 | expect(-> { stream_writer.close_array }).to raise_error(JsonWriteStream::NotInArrayError) 210 | end 211 | end 212 | 213 | context 'with a closed stream writer' do 214 | before(:each) do 215 | stream_writer.close 216 | end 217 | 218 | describe '#write_object' do 219 | it 'raises an error if eos' do 220 | expect(-> { stream_writer.write_object }).to raise_error(JsonWriteStream::EndOfStreamError) 221 | end 222 | end 223 | 224 | describe '#write_array' do 225 | it 'raises an error if eos' do 226 | expect(-> { stream_writer.write_object }).to raise_error(JsonWriteStream::EndOfStreamError) 227 | end 228 | end 229 | 230 | describe '#write_key_value' do 231 | it 'raises an error if eos' do 232 | expect(-> { stream_writer.write_key_value('abc', 'def') }).to raise_error(JsonWriteStream::EndOfStreamError) 233 | end 234 | end 235 | 236 | describe '#write_element' do 237 | it 'raises an error if eos' do 238 | expect(-> { stream_writer.write_element('foo') }).to raise_error(JsonWriteStream::EndOfStreamError) 239 | end 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /spec/yielding_spec.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | require 'spec_helper' 4 | 5 | describe JsonWriteStream::YieldingWriter do 6 | let(:stream) do 7 | StringIO.new.tap do |io| 8 | io.set_encoding(Encoding::UTF_8) 9 | end 10 | end 11 | 12 | let(:stream_writer) { JsonWriteStream::YieldingWriter.new(stream) } 13 | 14 | def check_roundtrip(obj, options = {}) 15 | YieldingRoundtripChecker.check_roundtrip(obj, options) 16 | end 17 | 18 | def utf8(str) 19 | str.encode(Encoding::UTF_8) 20 | end 21 | 22 | it_behaves_like 'a json stream' 23 | it_behaves_like 'a json stream', pretty: true 24 | 25 | describe '#write_key_value' do 26 | it 'converts all keys to strings' do 27 | stream_writer.write_object do |object_writer| 28 | object_writer.write_key_value(123, 'abc') 29 | end 30 | 31 | expect(stream.string).to eq(utf8('{"123":"abc"}')) 32 | end 33 | 34 | it 'supports non-string values' do 35 | stream_writer.write_object do |object_writer| 36 | object_writer.write_key_value('abc', 123) 37 | object_writer.write_key_value('def', true) 38 | end 39 | 40 | expect(stream.string).to eq(utf8('{"abc":123,"def":true}')) 41 | end 42 | end 43 | 44 | describe '#close' do 45 | it 'closes the underlying stream' do 46 | stream_writer.close 47 | expect(stream).to be_closed 48 | end 49 | end 50 | end 51 | --------------------------------------------------------------------------------