├── .rspec ├── .document ├── .yardopts ├── bin ├── setup └── console ├── lib ├── multi_json │ ├── version.rb │ ├── adapter_error.rb │ ├── adapters │ │ ├── gson.rb │ │ ├── yajl.rb │ │ ├── jr_jackson.rb │ │ ├── ok_json.rb │ │ ├── fast_jsonparser.rb │ │ ├── json_gem.rb │ │ ├── oj_common.rb │ │ └── oj.rb │ ├── parse_error.rb │ ├── convertible_hash_keys.rb │ ├── options.rb │ ├── options_cache.rb │ ├── adapter.rb │ ├── adapter_selector.rb │ └── vendor │ │ └── okjson.rb └── multi_json.rb ├── .standard.yml ├── spec ├── multi_json │ ├── adapters │ │ ├── gson_spec.rb │ │ ├── yajl_spec.rb │ │ ├── ok_json_spec.rb │ │ ├── jr_jackson_spec.rb │ │ ├── fast_jsonparser_spec.rb │ │ ├── oj_spec.rb │ │ └── json_gem_spec.rb │ └── options_cache_spec.rb ├── shared │ ├── json_common_adapter.rb │ ├── options.rb │ └── adapter.rb ├── spec_helper.rb └── multi_json_spec.rb ├── .github └── workflows │ ├── linter.yml │ └── tests.yml ├── .gitignore ├── benchmark.rb ├── Gemfile ├── Rakefile ├── LICENSE.md ├── multi_json.gemspec ├── .rubocop.yml ├── CONTRIBUTING.md ├── README.md └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --order random 3 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | LICENSE.md 2 | README.md 3 | bin/* 4 | features/**/*.feature 5 | lib/**/*.rb 6 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | - 3 | CHANGELOG.md 4 | CONTRIBUTING.md 5 | LICENSE.md 6 | README.md 7 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | -------------------------------------------------------------------------------- /lib/multi_json/version.rb: -------------------------------------------------------------------------------- 1 | require "rubygems/version" 2 | 3 | module MultiJson 4 | VERSION = Gem::Version.create("2.0.0") 5 | end 6 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | ruby_version: 3.2 2 | ignore: 3 | - ".git/**/*" 4 | - "lib/multi_json/vendor/**/*.rb" 5 | - "tmp/**/*" 6 | - "vendor/**/*" 7 | -------------------------------------------------------------------------------- /spec/multi_json/adapters/gson_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | return unless RSpec.configuration.gson? 3 | 4 | require "shared/adapter" 5 | require "multi_json/adapters/gson" 6 | 7 | RSpec.describe MultiJson::Adapters::Gson, :gson do 8 | it_behaves_like "an adapter", described_class 9 | end 10 | -------------------------------------------------------------------------------- /spec/multi_json/adapters/yajl_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | return unless RSpec.configuration.yajl? 3 | 4 | require "shared/adapter" 5 | require "multi_json/adapters/yajl" 6 | 7 | RSpec.describe MultiJson::Adapters::Yajl, :yajl do 8 | it_behaves_like "an adapter", described_class 9 | end 10 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "multi_json" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | require "irb" 10 | IRB.start(__FILE__) 11 | -------------------------------------------------------------------------------- /spec/multi_json/adapters/ok_json_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | return unless RSpec.configuration.ok_json? 3 | 4 | require "shared/adapter" 5 | require "multi_json/adapters/ok_json" 6 | 7 | RSpec.describe MultiJson::Adapters::OkJson, :ok_json do 8 | it_behaves_like "an adapter", described_class 9 | end 10 | -------------------------------------------------------------------------------- /spec/multi_json/adapters/jr_jackson_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | return unless RSpec.configuration.jrjackson? 3 | 4 | require "shared/adapter" 5 | require "multi_json/adapters/jr_jackson" 6 | 7 | RSpec.describe MultiJson::Adapters::JrJackson, :jrjackson do 8 | it_behaves_like "an adapter", described_class 9 | end 10 | -------------------------------------------------------------------------------- /.github/workflows/linter.yml: -------------------------------------------------------------------------------- 1 | name: linter 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | - uses: ruby/setup-ruby@v1 9 | with: 10 | ruby-version: "3.2" 11 | bundler-cache: true 12 | - run: bundle exec rake lint 13 | -------------------------------------------------------------------------------- /spec/multi_json/adapters/fast_jsonparser_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | return unless RSpec.configuration.respond_to?(:fast_jsonparser?) && RSpec.configuration.fast_jsonparser? 3 | 4 | require "shared/adapter" 5 | require "multi_json/adapters/fast_jsonparser" 6 | 7 | RSpec.describe MultiJson::Adapters::FastJsonparser, :fast_jsonparser do 8 | it_behaves_like "an adapter", described_class 9 | end 10 | -------------------------------------------------------------------------------- /lib/multi_json/adapter_error.rb: -------------------------------------------------------------------------------- 1 | module MultiJson 2 | class AdapterError < ArgumentError 3 | def initialize(message = nil, cause: nil) 4 | super(message) 5 | set_backtrace(cause.backtrace) if cause 6 | end 7 | 8 | def self.build(original_exception) 9 | message = "Did not recognize your adapter specification (#{original_exception.message})." 10 | new(message, cause: original_exception) 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## TEXTMATE 2 | *.tmproj 3 | tmtags 4 | 5 | ## EMACS 6 | *~ 7 | \#* 8 | .\#* 9 | 10 | ## VIM 11 | *.swp 12 | 13 | ## IDEA 14 | .idea 15 | 16 | ## PROJECT::GENERAL 17 | .yardoc 18 | coverage 19 | doc 20 | rdoc 21 | log 22 | 23 | ## BUNDLER 24 | Gemfile.lock 25 | *.gem 26 | .bundle 27 | pkg 28 | gemfiles/*.lock 29 | 30 | ## RBENV 31 | .ruby-version 32 | .rbenv* 33 | 34 | ## RCOV 35 | coverage.data 36 | 37 | tmp 38 | 39 | ## RUBINIUS 40 | *.rbc 41 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/gson.rb: -------------------------------------------------------------------------------- 1 | require "gson" 2 | require_relative "../adapter" 3 | 4 | module MultiJson 5 | module Adapters 6 | # Use the gson.rb library to dump/load. 7 | class Gson < Adapter 8 | ParseError = ::Gson::DecodeError 9 | 10 | def load(string, options = {}) 11 | ::Gson::Decoder.new(options).decode(string) 12 | end 13 | 14 | def dump(object, options = {}) 15 | ::Gson::Encoder.new(options).encode(object) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/multi_json/parse_error.rb: -------------------------------------------------------------------------------- 1 | module MultiJson 2 | class ParseError < StandardError 3 | attr_reader :data 4 | 5 | def initialize(message = nil, data: nil, cause: nil) 6 | super(message) 7 | @data = data 8 | set_backtrace(cause.backtrace) if cause 9 | end 10 | 11 | def self.build(original_exception, data) 12 | new(original_exception.message, data: data, cause: original_exception) 13 | end 14 | end 15 | 16 | DecodeError = LoadError = ParseError # Legacy support 17 | end 18 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/yajl.rb: -------------------------------------------------------------------------------- 1 | require "yajl" 2 | require_relative "../adapter" 3 | 4 | module MultiJson 5 | module Adapters 6 | # Use the Yajl-Ruby library to dump/load. 7 | class Yajl < Adapter 8 | ParseError = ::Yajl::ParseError 9 | 10 | def load(string, options = {}) 11 | ::Yajl::Parser.new(symbolize_keys: options[:symbolize_keys]).parse(string) 12 | end 13 | 14 | def dump(object, options = {}) 15 | ::Yajl::Encoder.encode(object, options) 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /benchmark.rb: -------------------------------------------------------------------------------- 1 | libx = File.expand_path("lib", __dir__) 2 | $LOAD_PATH.unshift(libx) unless $LOAD_PATH.include?(libx) 3 | 4 | require "oj" 5 | require "fast_jsonparser" 6 | require "multi_json" 7 | require "benchmark/ips" 8 | 9 | MultiJson.use :oj 10 | 11 | Benchmark.ips do |x| 12 | x.time = 10 13 | x.warmup = 1 14 | x.report("oj") { MultiJson.load(MultiJson.dump(a: 1, b: 2, c: 3)) } 15 | end 16 | 17 | MultiJson.use :fast_jsonparser 18 | 19 | Benchmark.ips do |x| 20 | x.time = 10 21 | x.warmup = 1 22 | x.report("fast_jsonparser") { MultiJson.load(MultiJson.dump(a: 1, b: 2, c: 3)) } 23 | end 24 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activesupport", require: false 4 | gem "json", "~> 2.0", require: false 5 | 6 | gem "rake", ">= 13.1" 7 | gem "rspec", ">= 3.13" 8 | gem "rubocop", ">= 1.62.1" 9 | gem "rubocop-performance", ">= 1.20.2" 10 | gem "rubocop-rake", ">= 0.6.0" 11 | gem "rubocop-rspec", ">= 2.27.1" 12 | gem "standard", ">= 1.35.1" 13 | 14 | gem "fast_jsonparser", "~> 0.6", platforms: %i[ruby windows], require: false 15 | gem "gson", ">= 0.6", platforms: [:jruby], require: false 16 | gem "jrjackson", ">= 0.4.18", platforms: [:jruby], require: false 17 | gem "oj", "~> 3.0", platforms: %i[ruby windows], require: false 18 | gem "yajl-ruby", "~> 1.3", platforms: %i[ruby windows], require: false 19 | 20 | gemspec 21 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/jr_jackson.rb: -------------------------------------------------------------------------------- 1 | require "jrjackson" unless defined?(JrJackson) 2 | require_relative "../adapter" 3 | 4 | module MultiJson 5 | module Adapters 6 | # Use the jrjackson.rb library to dump/load. 7 | class JrJackson < Adapter 8 | ParseError = ::JrJackson::ParseError 9 | 10 | def load(string, options = {}) # :nodoc: 11 | ::JrJackson::Json.load(string, options) 12 | end 13 | 14 | if ::JrJackson::Json.method(:dump).arity == 1 15 | def dump(object, _) 16 | ::JrJackson::Json.dump(object) 17 | end 18 | else 19 | def dump(object, options = {}) 20 | ::JrJackson::Json.dump(object, options) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/ok_json.rb: -------------------------------------------------------------------------------- 1 | require_relative "../adapter" 2 | require_relative "../convertible_hash_keys" 3 | require_relative "../vendor/okjson" 4 | 5 | module MultiJson 6 | module Adapters 7 | class OkJson < Adapter 8 | include ConvertibleHashKeys 9 | 10 | ParseError = ::MultiJson::OkJson::Error 11 | 12 | def load(string, options = {}) 13 | result = ::MultiJson::OkJson.decode("[#{string}]").first 14 | options[:symbolize_keys] ? symbolize_keys(result) : result 15 | rescue ArgumentError # invalid byte sequence in UTF-8 16 | raise ParseError 17 | end 18 | 19 | def dump(object, _ = {}) 20 | ::MultiJson::OkJson.valenc(stringify_keys(object)) 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/fast_jsonparser.rb: -------------------------------------------------------------------------------- 1 | require "fast_jsonparser" 2 | require "oj" 3 | require_relative "../adapter" 4 | require_relative "oj_common" 5 | 6 | module MultiJson 7 | module Adapters 8 | # Use the FastJsonparser library to load and Oj to dump. 9 | class FastJsonparser < Adapter 10 | include OjCommon 11 | 12 | defaults :load, symbolize_keys: false 13 | defaults :dump, mode: :compat, time_format: :ruby, use_to_json: true 14 | 15 | ParseError = ::FastJsonparser::ParseError 16 | 17 | def load(string, options = {}) 18 | ::FastJsonparser.parse(string, symbolize_keys: options[:symbolize_keys]) 19 | end 20 | 21 | def dump(object, options = {}) 22 | ::Oj.dump(object, prepare_dump_options(options)) 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - "**/*.md" 8 | pull_request: 9 | branches: [main] 10 | paths-ignore: 11 | - "**/*.md" 12 | workflow_dispatch: 13 | 14 | jobs: 15 | test: 16 | strategy: 17 | matrix: 18 | ruby-version: ["3.2", "3.3", "3.4", "jruby-10.0"] 19 | platform: [ubuntu-latest, macos-latest, windows-latest] 20 | runs-on: ${{ matrix.platform }} 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Set up Ruby 25 | uses: ruby/setup-ruby@v1 26 | with: 27 | ruby-version: ${{ matrix.ruby-version }} 28 | bundler-cache: false # Do not bundle install yet, need recent versions for Windows 29 | - name: Run tests 30 | run: | 31 | gem install bundler 32 | bundle install 33 | bundle exec rake 34 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler" 2 | Bundler::GemHelper.install_tasks 3 | 4 | require "standard/rake" 5 | require "rubocop/rake_task" 6 | RuboCop::RakeTask.new(:rubocop) 7 | 8 | require "rspec/core/rake_task" 9 | RSpec::Core::RakeTask.new(:base_spec) do |task| 10 | task.pattern = "spec/{multi_json_spec.rb,multi_json/options_cache_spec.rb}" 11 | end 12 | 13 | namespace :adapters do 14 | Dir["spec/multi_json/adapters/*_spec.rb"].each do |adapter_spec| 15 | adapter_name = adapter_spec[/(\w+)_spec/, 1] 16 | desc "Run #{adapter_name} adapter specs" 17 | RSpec::Core::RakeTask.new(adapter_name) do |task| 18 | task.pattern = adapter_spec 19 | end 20 | end 21 | end 22 | 23 | desc "Run the full test suite" 24 | task spec: %w[ 25 | base_spec 26 | adapters:fast_jsonparser 27 | adapters:oj 28 | adapters:yajl 29 | adapters:json_gem 30 | adapters:ok_json 31 | adapters:gson 32 | adapters:jr_jackson 33 | ] 34 | 35 | desc "Alias for spec" 36 | task test: :spec 37 | 38 | desc "Run linters" 39 | task lint: %i[rubocop standard] 40 | 41 | desc "Run the default task" 42 | task default: %i[spec lint] 43 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/json_gem.rb: -------------------------------------------------------------------------------- 1 | require_relative "../adapter" 2 | require "json" 3 | 4 | module MultiJson 5 | module Adapters 6 | # Use the JSON gem to dump/load. 7 | class JsonGem < Adapter 8 | ParseError = ::JSON::ParserError 9 | 10 | defaults :load, create_additions: false, quirks_mode: true 11 | 12 | PRETTY_STATE_PROTOTYPE = { 13 | indent: " ", 14 | space: " ", 15 | object_nl: "\n", 16 | array_nl: "\n" 17 | }.freeze 18 | private_constant :PRETTY_STATE_PROTOTYPE 19 | 20 | def load(string, options = {}) 21 | string = string.dup.force_encoding(Encoding::UTF_8) if string.encoding != Encoding::UTF_8 22 | 23 | options[:symbolize_names] = true if options.delete(:symbolize_keys) 24 | ::JSON.parse(string, options) 25 | end 26 | 27 | def dump(object, options = {}) 28 | opts = options.dup 29 | 30 | if opts.delete(:pretty) 31 | opts = PRETTY_STATE_PROTOTYPE.merge(opts) 32 | return ::JSON.pretty_generate(object, opts) 33 | end 34 | 35 | ::JSON.generate(object, opts) 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/oj_common.rb: -------------------------------------------------------------------------------- 1 | require "rubygems/version" 2 | 3 | module MultiJson 4 | module Adapters 5 | module OjCommon 6 | OJ_VERSION = Gem::Version.new(::Oj::VERSION) 7 | OJ_V2 = OJ_VERSION.segments.first == 2 8 | OJ_V3 = OJ_VERSION.segments.first == 3 9 | private_constant :OJ_VERSION, :OJ_V2, :OJ_V3 10 | 11 | if OJ_V3 12 | PRETTY_STATE_PROTOTYPE = { 13 | indent: " ", 14 | space: " ", 15 | space_before: "", 16 | object_nl: "\n", 17 | array_nl: "\n", 18 | ascii_only: false 19 | }.freeze 20 | private_constant :PRETTY_STATE_PROTOTYPE 21 | end 22 | 23 | private 24 | 25 | def prepare_dump_options(options) 26 | if OJ_V2 27 | options[:indent] = 2 if options[:pretty] 28 | options[:indent] = options[:indent].to_i if options[:indent] 29 | elsif OJ_V3 30 | options.merge!(PRETTY_STATE_PROTOTYPE.dup) if options.delete(:pretty) 31 | else 32 | raise "Unsupported Oj version: #{::Oj::VERSION}" 33 | end 34 | 35 | options 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010-2025 Michael Bleigh, Josh Kalderimis, Erik Berlin, Pavel Pravosud 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/multi_json/convertible_hash_keys.rb: -------------------------------------------------------------------------------- 1 | module MultiJson 2 | module ConvertibleHashKeys 3 | SIMPLE_OBJECT_CLASSES = [String, Numeric, TrueClass, FalseClass, NilClass].freeze 4 | private_constant :SIMPLE_OBJECT_CLASSES 5 | 6 | private 7 | 8 | def symbolize_keys(value) 9 | convert_hash_keys(value) { |key| key.respond_to?(:to_sym) ? key.to_sym : key } 10 | end 11 | 12 | def stringify_keys(value) 13 | convert_hash_keys(value) { |key| key.respond_to?(:to_s) ? key.to_s : key } 14 | end 15 | 16 | def convert_hash_keys(value, &key_modifier) 17 | case value 18 | when Hash 19 | value.to_h { |k, v| [key_modifier.call(k), convert_hash_keys(v, &key_modifier)] } 20 | when Array 21 | value.map { |v| convert_hash_keys(v, &key_modifier) } 22 | else 23 | convert_simple_object(value) 24 | end 25 | end 26 | 27 | def convert_simple_object(obj) 28 | return obj if simple_object?(obj) || obj.respond_to?(:to_json) 29 | 30 | obj.respond_to?(:to_s) ? obj.to_s : obj 31 | end 32 | 33 | def simple_object?(obj) 34 | SIMPLE_OBJECT_CLASSES.any? { |klass| obj.is_a?(klass) } 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/multi_json/adapters/oj_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | return unless RSpec.configuration.oj? 3 | 4 | require "shared/adapter" 5 | require "multi_json/adapters/oj" 6 | 7 | RSpec.describe MultiJson::Adapters::Oj, :oj do 8 | it_behaves_like "an adapter", described_class 9 | 10 | describe ".dump" do 11 | describe "#dump_options" do 12 | around { |example| with_default_options(&example) } 13 | 14 | it "ensures indent is a Fixnum" do 15 | expect { MultiJson.dump(42, indent: "") }.not_to raise_error 16 | end 17 | end 18 | end 19 | 20 | it "Oj does not create symbols on parse" do 21 | MultiJson.load('{"json_class":"ZOMG"}') 22 | 23 | expect do 24 | MultiJson.load('{"json_class":"OMG"}') 25 | end.not_to(change { Symbol.all_symbols.count }) 26 | end 27 | 28 | context "with Oj.default_settings" do 29 | around do |example| 30 | options = Oj.default_options 31 | Oj.default_options = {symbol_keys: true} 32 | example.call 33 | Oj.default_options = options 34 | end 35 | 36 | it "ignores global settings" do 37 | example = '{"a": 1, "b": 2}' 38 | expected = {"a" => 1, "b" => 2} 39 | expect(MultiJson.load(example)).to eq(expected) 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /multi_json.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("lib/multi_json/version.rb", __dir__) 2 | 3 | Gem::Specification.new do |spec| 4 | spec.authors = ["Michael Bleigh", "Josh Kalderimis", "Erik Berlin", "Pavel Pravosud"] 5 | spec.description = "A common interface to multiple JSON libraries, " \ 6 | "including fast_jsonparser, Oj, Yajl, the JSON gem " \ 7 | "(with C-extensions), gson, JrJackson, and OkJson." 8 | spec.email = %w[sferik@gmail.com] 9 | spec.files = Dir["*.md", "lib/**/*"] 10 | spec.homepage = "https://github.com/sferik/multi_json" 11 | spec.license = "MIT" 12 | spec.name = "multi_json" 13 | spec.require_path = "lib" 14 | spec.required_ruby_version = ">= 3.2" 15 | spec.summary = "A common interface to multiple JSON libraries." 16 | spec.version = MultiJson::VERSION 17 | 18 | spec.metadata = { 19 | "bug_tracker_uri" => "https://github.com/sferik/multi_json/issues", 20 | "changelog_uri" => "https://github.com/sferik/multi_json/blob/v#{spec.version}/CHANGELOG.md", 21 | "documentation_uri" => "https://www.rubydoc.info/gems/multi_json/#{spec.version}", 22 | "rubygems_mfa_required" => "true", 23 | "source_code_uri" => "https://github.com/sferik/multi_json/tree/v#{spec.version}", 24 | "wiki_uri" => "https://github.com/sferik/multi_json/wiki" 25 | } 26 | end 27 | -------------------------------------------------------------------------------- /spec/multi_json/options_cache_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe MultiJson::OptionsCache do 4 | before do 5 | described_class.reset 6 | max = described_class::MAX_CACHE_SIZE 7 | 8 | (max + 1).times do |i| 9 | described_class.dump.fetch(key: i) { {foo: i} } 10 | described_class.load.fetch(key: i) { {foo: i} } 11 | end 12 | end 13 | 14 | it "doesn't leak memory" do 15 | caches = [described_class.dump, described_class.load].map do |cache| 16 | cache.instance_variable_get(:@cache).length 17 | end 18 | 19 | expect(caches).to all(eq(described_class::MAX_CACHE_SIZE)) 20 | end 21 | 22 | it "stores value in current cache after reset" do 23 | described_class.load.fetch(:foo) do 24 | described_class.reset 25 | :bar 26 | end 27 | 28 | expect(described_class.load.fetch(:foo, :baz)).to eq(:baz) 29 | end 30 | 31 | it "does not store the default value" do 32 | described_class.dump.fetch(:foo, :bar) 33 | expect(described_class.dump.fetch(:foo, :baz)).to eq(:baz) 34 | end 35 | 36 | it "executes block only once per key in concurrent access" do 37 | described_class.reset 38 | counter = 0 39 | Array.new(5) { Thread.new { described_class.dump.fetch(:foo) { counter += 1 } } }.each(&:join) 40 | expect(counter).to eq(1) 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/shared/json_common_adapter.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "JSON-like adapter" do |adapter| 2 | before { MultiJson.use adapter } 3 | 4 | describe ".dump" do 5 | before { MultiJson.dump_options = MultiJson.adapter.dump_options = nil } 6 | 7 | describe "with :pretty option set to true" do 8 | it "passes default pretty options" do 9 | object = "foo" 10 | options = {indent: " ", space: " ", object_nl: "\n", array_nl: "\n"} 11 | allow(JSON).to receive(:pretty_generate).and_call_original 12 | MultiJson.dump(object, pretty: true) 13 | expect(JSON).to have_received(:pretty_generate).with(object, options) 14 | end 15 | 16 | it "retains pretty formatting when options are cached" do 17 | object = {foo: "bar"} 18 | pretty_output = JSON.pretty_generate(object) 19 | 20 | outputs = 2.times.map { MultiJson.dump(object, pretty: true) } 21 | 22 | expect(outputs).to all(eq(pretty_output)) 23 | end 24 | end 25 | 26 | describe "with :indent option" do 27 | it "passes it on dump" do 28 | object = "foo" 29 | allow(JSON).to receive(:generate).and_call_original 30 | MultiJson.dump(object, indent: "\t") 31 | expect(JSON).to have_received(:generate).with(object, {indent: "\t"}) 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/multi_json/options.rb: -------------------------------------------------------------------------------- 1 | module MultiJson 2 | module Options 3 | def load_options=(options) 4 | OptionsCache.reset 5 | @load_options = options 6 | end 7 | 8 | def dump_options=(options) 9 | OptionsCache.reset 10 | @dump_options = options 11 | end 12 | 13 | def load_options(...) 14 | (defined?(@load_options) && get_options(@load_options, ...)) || default_load_options 15 | end 16 | 17 | def dump_options(...) 18 | (defined?(@dump_options) && get_options(@dump_options, ...)) || default_dump_options 19 | end 20 | 21 | def default_load_options 22 | @default_load_options ||= {}.freeze 23 | end 24 | 25 | def default_dump_options 26 | @default_dump_options ||= {}.freeze 27 | end 28 | 29 | private 30 | 31 | def get_options(options, ...) 32 | return handle_callable_options(options, ...) if options_callable?(options) 33 | 34 | handle_hashable_options(options) 35 | end 36 | 37 | def options_callable?(options) 38 | options.respond_to?(:call) 39 | end 40 | 41 | def handle_callable_options(options, ...) 42 | options.arity.zero? ? options.call : options.call(...) 43 | end 44 | 45 | def handle_hashable_options(options) 46 | options.respond_to?(:to_hash) ? options.to_hash : nil 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/multi_json/options_cache.rb: -------------------------------------------------------------------------------- 1 | module MultiJson 2 | module OptionsCache 3 | # Normally MultiJson is used with a few option sets for both dump/load 4 | # methods. When options are generated dynamically though, every call would 5 | # cause a cache miss and the cache would grow indefinitely. To prevent this, 6 | # we reset the cache every time the number of keys outgrows 1000. 7 | MAX_CACHE_SIZE = 1000 8 | 9 | class Store 10 | def initialize 11 | @cache = {} 12 | @mutex = Mutex.new 13 | end 14 | 15 | def reset 16 | @mutex.synchronize { @cache.clear } 17 | end 18 | 19 | def fetch(key, default = nil) 20 | return @cache[key] if @cache.key?(key) 21 | 22 | @mutex.synchronize do 23 | return @cache[key] if @cache.key?(key) 24 | 25 | if block_given? 26 | store_value(key, yield) 27 | else 28 | default 29 | end 30 | end 31 | end 32 | 33 | private 34 | 35 | def store_value(key, value) 36 | return @cache[key] if @cache.key?(key) 37 | 38 | @cache.shift if @cache.size >= MAX_CACHE_SIZE 39 | @cache[key] = value 40 | end 41 | end 42 | 43 | class << self 44 | attr_reader :dump, :load 45 | 46 | def reset 47 | @dump = Store.new 48 | @load = Store.new 49 | end 50 | end 51 | 52 | reset 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/multi_json/adapters/json_gem_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "shared/adapter" 3 | require "shared/json_common_adapter" 4 | require "multi_json/adapters/json_gem" 5 | require "open3" 6 | require "rbconfig" 7 | require "tempfile" 8 | 9 | RSpec.describe MultiJson::Adapters::JsonGem, :json do 10 | it_behaves_like "an adapter", described_class 11 | it_behaves_like "JSON-like adapter", described_class 12 | 13 | context "when active_support/json is loaded" do 14 | let(:data) { {a: 1, b: 2, c: {d: {f: 2}}} } 15 | let(:expected_output) { "#{JSON.pretty_generate(data)}\n" } 16 | 17 | let(:script) do 18 | <<~RUBY 19 | $LOAD_PATH.unshift File.expand_path("../../lib", __dir__) 20 | require "multi_json" 21 | require "multi_json/adapters/json_gem" 22 | require "active_support/json" 23 | 24 | data = {a: 1, b: 2, c: {d: {f: 2}}} 25 | 26 | puts MultiJson.dump(data, pretty: true, adapter: :json_gem) 27 | RUBY 28 | end 29 | 30 | def run_script(script_content) 31 | Tempfile.create(["multi_json_json_gem", ".rb"]) do |file| 32 | file.write(script_content) 33 | file.flush 34 | file.close 35 | 36 | Open3.capture2(RbConfig.ruby, file.path) 37 | end 38 | end 39 | 40 | it "prettifies output when :pretty is true" do 41 | output, status = run_script(script) 42 | 43 | expect([status.success?, output]).to eq([true, expected_output]) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/multi_json/adapters/oj.rb: -------------------------------------------------------------------------------- 1 | require "oj" 2 | require_relative "../adapter" 3 | require_relative "oj_common" 4 | 5 | module MultiJson 6 | module Adapters 7 | # Use the Oj library to dump/load. 8 | class Oj < Adapter 9 | include OjCommon 10 | 11 | defaults :load, mode: :strict, symbolize_keys: false 12 | defaults :dump, mode: :compat, time_format: :ruby, use_to_json: true 13 | 14 | # In certain cases OJ gem may throw JSON::ParserError exception instead 15 | # of its own class. Also, we can't expect ::JSON::ParserError and 16 | # ::Oj::ParseError to always be defined, since it's often not the case. 17 | # Because of this, we can't reference those classes directly and have to 18 | # do string comparison instead. This will not catch subclasses, but it 19 | # shouldn't be a problem since the library is not known to be using it 20 | # (at least for now). 21 | class ParseError < ::SyntaxError 22 | WRAPPED_CLASSES = %w[Oj::ParseError JSON::ParserError].freeze 23 | private_constant :WRAPPED_CLASSES 24 | 25 | def self.===(exception) 26 | exception.is_a?(::SyntaxError) || WRAPPED_CLASSES.include?(exception.class.to_s) 27 | end 28 | end 29 | 30 | def load(string, options = {}) 31 | options[:symbol_keys] = options[:symbolize_keys] 32 | ::Oj.load(string, options) 33 | end 34 | 35 | def dump(object, options = {}) 36 | ::Oj.dump(object, prepare_dump_options(options)) 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - standard 3 | 4 | plugins: 5 | - rubocop-performance 6 | - rubocop-rake 7 | - rubocop-rspec 8 | - standard-performance 9 | 10 | AllCops: 11 | Exclude: 12 | - ".git/**/*" 13 | - "lib/multi_json/vendor/**/*.rb" 14 | - "tmp/**/*" 15 | - "vendor/**/*" 16 | NewCops: enable 17 | TargetRubyVersion: 3.2 18 | 19 | Layout/ArgumentAlignment: 20 | EnforcedStyle: with_fixed_indentation 21 | IndentationWidth: 2 22 | 23 | Layout/ArrayAlignment: 24 | Enabled: true 25 | EnforcedStyle: with_fixed_indentation 26 | 27 | Layout/CaseIndentation: 28 | EnforcedStyle: end 29 | 30 | Layout/EndAlignment: 31 | EnforcedStyleAlignWith: start_of_line 32 | 33 | Layout/ExtraSpacing: 34 | AllowForAlignment: false 35 | 36 | Layout/LineLength: 37 | Max: 140 38 | 39 | Layout/MultilineMethodCallIndentation: 40 | EnforcedStyle: indented 41 | 42 | Layout/ParameterAlignment: 43 | EnforcedStyle: with_fixed_indentation 44 | IndentationWidth: 2 45 | 46 | Layout/SpaceInsideHashLiteralBraces: 47 | EnforcedStyle: no_space 48 | 49 | Metrics/ParameterLists: 50 | CountKeywordArgs: false 51 | 52 | Style/Alias: 53 | EnforcedStyle: prefer_alias_method 54 | 55 | Style/Documentation: 56 | Enabled: false 57 | 58 | Style/FrozenStringLiteralComment: 59 | EnforcedStyle: never 60 | 61 | Style/ModuleFunction: 62 | Enabled: false 63 | 64 | Style/OpenStructUse: 65 | Enabled: false 66 | 67 | Style/RescueStandardError: 68 | EnforcedStyle: implicit 69 | 70 | Style/StringLiterals: 71 | EnforcedStyle: double_quotes 72 | 73 | Style/StringLiteralsInInterpolation: 74 | EnforcedStyle: double_quotes 75 | 76 | Style/TernaryParentheses: 77 | EnforcedStyle: require_parentheses_when_complex 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | In the spirit of [free software][free-sw], **everyone** is encouraged to help 3 | improve this project. 4 | 5 | [free-sw]: http://www.fsf.org/licensing/essays/free-sw.html 6 | 7 | Here are some ways *you* can contribute: 8 | 9 | * by using alpha, beta, and prerelease versions 10 | * by reporting bugs 11 | * by suggesting new features 12 | * by writing or editing documentation 13 | * by writing specifications 14 | * by writing code (**no patch is too small**: fix typos, add comments, clean up 15 | inconsistent whitespace) 16 | * by refactoring code 17 | * by closing [issues][] 18 | * by reviewing patches 19 | 20 | [issues]: https://github.com/sferik/multi_json/issues 21 | 22 | ## Submitting an Issue 23 | We use the [GitHub issue tracker][issues] to track bugs and features. Before 24 | submitting a bug report or feature request, check to make sure it hasn't 25 | already been submitted. When submitting a bug report, please include a [Gist][] 26 | that includes a stack trace and any details that may be necessary to reproduce 27 | the bug, including your gem version, Ruby version, and operating system. 28 | Ideally, a bug report should include a pull request with failing specs. 29 | 30 | [gist]: https://gist.github.com/ 31 | 32 | ## Submitting a Pull Request 33 | 1. [Fork the repository.][fork] 34 | 2. [Create a topic branch.][branch] 35 | 3. Add specs for your unimplemented feature or bug fix. 36 | 4. Run `bundle exec rake spec`. If your specs pass, return to step 3. 37 | 5. Implement your feature or bug fix. 38 | 6. Run `bundle exec rake spec`. If your specs fail, return to step 5. 39 | 7. Run `open coverage/index.html`. If your changes are not completely covered 40 | by your tests, return to step 3. 41 | 8. Add, commit, and push your changes. 42 | 9. [Submit a pull request.][pr] 43 | 44 | [fork]: http://help.github.com/fork-a-repo/ 45 | [branch]: http://learn.github.com/p/branching.html 46 | [pr]: http://help.github.com/send-pull-requests/ 47 | -------------------------------------------------------------------------------- /lib/multi_json/adapter.rb: -------------------------------------------------------------------------------- 1 | require "singleton" 2 | require_relative "options" 3 | 4 | module MultiJson 5 | class Adapter 6 | extend Options 7 | include Singleton 8 | 9 | class << self 10 | BLANK_RE = /\A\s*\z/ 11 | private_constant :BLANK_RE 12 | 13 | def inherited(subclass) 14 | super 15 | subclass.instance_variable_set(:@default_load_options, @default_load_options) if instance_variable_defined?(:@default_load_options) 16 | subclass.instance_variable_set(:@default_dump_options, @default_dump_options) if instance_variable_defined?(:@default_dump_options) 17 | end 18 | 19 | def defaults(action, value) 20 | instance_variable_set("@default_#{action}_options", value.freeze) 21 | end 22 | 23 | def load(string, options = {}) 24 | string = string.read if string.respond_to?(:read) 25 | return nil if blank?(string) 26 | 27 | instance.load(string, cached_load_options(options)) 28 | end 29 | 30 | def dump(object, options = {}) 31 | instance.dump(object, cached_dump_options(options)) 32 | end 33 | 34 | private 35 | 36 | def blank?(input) 37 | input.nil? || BLANK_RE.match?(input) 38 | rescue ArgumentError # invalid byte sequence in UTF-8 39 | false 40 | end 41 | 42 | def cached_dump_options(options) 43 | opts = options_without_adapter(options) 44 | OptionsCache.dump.fetch(opts) do 45 | dump_options(opts).merge(MultiJson.dump_options(opts)).merge!(opts) 46 | end 47 | end 48 | 49 | def cached_load_options(options) 50 | opts = options_without_adapter(options) 51 | OptionsCache.load.fetch(opts) do 52 | load_options(opts).merge(MultiJson.load_options(opts)).merge!(opts) 53 | end 54 | end 55 | 56 | def options_without_adapter(options) 57 | options.except(:adapter).freeze 58 | end 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/multi_json/adapter_selector.rb: -------------------------------------------------------------------------------- 1 | module MultiJson 2 | module AdapterSelector 3 | extend self 4 | 5 | ALIASES = {"jrjackson" => "jr_jackson"}.freeze 6 | 7 | def default_adapter 8 | return @default_adapter if defined?(@default_adapter) 9 | 10 | adapter = loaded_adapter || installable_adapter 11 | @default_adapter = adapter || fallback_adapter 12 | end 13 | 14 | private 15 | 16 | def fallback_adapter 17 | unless @default_adapter_warning_shown 18 | Kernel.warn( 19 | "[WARNING] MultiJson is using the default adapter (ok_json). " \ 20 | "We recommend loading a different JSON library to improve performance." 21 | ) 22 | @default_adapter_warning_shown = true 23 | end 24 | :ok_json 25 | end 26 | 27 | def load_adapter(new_adapter) 28 | case new_adapter 29 | when String, Symbol then load_adapter_from_string_name new_adapter.to_s 30 | when NilClass, FalseClass then load_adapter default_adapter 31 | when Class, Module then new_adapter 32 | else raise ::LoadError, new_adapter 33 | end 34 | rescue ::LoadError => e 35 | raise(AdapterError.build(e), cause: e) 36 | end 37 | 38 | def loaded_adapter 39 | return :fast_jsonparser if defined?(::FastJsonparser) 40 | return :oj if defined?(::Oj) 41 | return :yajl if defined?(::Yajl) 42 | return :jr_jackson if defined?(::JrJackson) 43 | return :json_gem if defined?(::JSON::Ext::Parser) 44 | return :gson if defined?(::Gson) 45 | 46 | nil 47 | end 48 | 49 | def installable_adapter 50 | MultiJson::REQUIREMENT_MAP.each do |adapter, library| 51 | require library 52 | return adapter 53 | rescue ::LoadError 54 | # ignore and try next 55 | end 56 | nil 57 | end 58 | 59 | def load_adapter_from_string_name(name) 60 | normalized_name = ALIASES.fetch(name, name).to_s 61 | require_relative "adapters/#{normalized_name.downcase}" 62 | klass_name = normalized_name.split("_").map(&:capitalize).join 63 | MultiJson::Adapters.const_get(klass_name) 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require "multi_json" 2 | require "rspec" 3 | 4 | RSpec.configure do |config| 5 | config.expect_with :rspec do |c| 6 | c.syntax = :expect 7 | end 8 | 9 | # You must run 'bundle exec rake' for this to work properly 10 | loaded_specs = Gem.loaded_specs 11 | 12 | config.add_setting :java, default: RUBY_PLATFORM == "java" 13 | config.add_setting :gson, default: loaded_specs.key?("gson") 14 | config.add_setting :json, default: loaded_specs.key?("json") 15 | config.add_setting :jrjackson, default: loaded_specs.key?("jrjackson") 16 | config.add_setting :ok_json, default: loaded_specs.key?("ok_json") 17 | config.add_setting :oj, default: loaded_specs.key?("oj") 18 | config.add_setting :yajl, default: loaded_specs.key?("yajl") 19 | config.add_setting :fast_jsonparser, default: loaded_specs.key?("fast_jsonparser") 20 | 21 | config.filter_run_excluding(:jrjackson) unless config.jrjackson? 22 | config.filter_run_excluding(:json) unless config.json? 23 | config.filter_run_excluding(:ok_json) unless config.ok_json? 24 | config.filter_run_excluding(:oj) unless config.oj? 25 | config.filter_run_excluding(:yajl) unless config.yajl? 26 | config.filter_run_excluding(:fast_jsonparser) unless config.fast_jsonparser? 27 | 28 | unless config.java? 29 | config.filter_run_excluding(:java) 30 | config.filter_run_excluding(:gson) unless config.gson? 31 | end 32 | end 33 | 34 | def silence_warnings 35 | old_verbose = $VERBOSE 36 | $VERBOSE = nil 37 | yield 38 | ensure 39 | $VERBOSE = old_verbose 40 | end 41 | 42 | def undefine_constants(*consts) 43 | RSpec::Mocks.with_temporary_scope do 44 | consts.each { |const| hide_const(const.to_s) } 45 | yield 46 | end 47 | end 48 | 49 | def break_requirements 50 | requirements = MultiJson::REQUIREMENT_MAP 51 | replacements = requirements.transform_values { |library| "foo/#{library}" } 52 | 53 | RSpec::Mocks.with_temporary_scope do 54 | stub_const("MultiJson::REQUIREMENT_MAP", replacements) 55 | yield 56 | end 57 | ensure 58 | RSpec::Mocks.with_temporary_scope do 59 | stub_const("MultiJson::REQUIREMENT_MAP", requirements) 60 | end 61 | end 62 | 63 | def simulate_no_adapters(&) 64 | break_requirements do 65 | undefine_constants(:JSON, :Oj, :Yajl, :Gson, :JrJackson, :FastJsonparser, &) 66 | end 67 | end 68 | 69 | def get_exception(exception_class = StandardError) 70 | yield 71 | rescue exception_class => e 72 | e 73 | end 74 | 75 | def with_default_options 76 | adapter = MultiJson.adapter 77 | adapter.load_options = adapter.dump_options = MultiJson.load_options = MultiJson.dump_options = nil 78 | yield 79 | ensure 80 | adapter.load_options = adapter.dump_options = MultiJson.load_options = MultiJson.dump_options = nil 81 | end 82 | -------------------------------------------------------------------------------- /lib/multi_json.rb: -------------------------------------------------------------------------------- 1 | require_relative "multi_json/options" 2 | require_relative "multi_json/version" 3 | require_relative "multi_json/adapter_error" 4 | require_relative "multi_json/parse_error" 5 | require_relative "multi_json/options_cache" 6 | require_relative "multi_json/adapter_selector" 7 | 8 | module MultiJson 9 | extend Options 10 | extend AdapterSelector 11 | 12 | module_function 13 | 14 | def default_options=(value) 15 | Kernel.warn "MultiJson.default_options setter is deprecated\nUse MultiJson.load_options and MultiJson.dump_options instead" 16 | 17 | self.load_options = self.dump_options = value 18 | end 19 | 20 | def default_options 21 | Kernel.warn "MultiJson.default_options is deprecated\nUse MultiJson.load_options or MultiJson.dump_options instead" 22 | 23 | load_options 24 | end 25 | 26 | %w[cached_options reset_cached_options!].each do |method_name| 27 | define_method method_name do |*| 28 | Kernel.warn "MultiJson.#{method_name} method is deprecated and no longer used." 29 | end 30 | end 31 | 32 | ALIASES = AdapterSelector::ALIASES 33 | 34 | REQUIREMENT_MAP = { 35 | fast_jsonparser: "fast_jsonparser", 36 | oj: "oj", 37 | yajl: "yajl", 38 | jr_jackson: "jrjackson", 39 | json_gem: "json", 40 | gson: "gson" 41 | }.freeze 42 | 43 | class << self 44 | alias_method :default_engine, :default_adapter 45 | end 46 | 47 | # Get the current adapter class. 48 | def adapter 49 | return @adapter if defined?(@adapter) && @adapter 50 | 51 | use nil # load default adapter 52 | 53 | @adapter 54 | end 55 | alias_method :engine, :adapter 56 | 57 | def use(new_adapter) 58 | @adapter = load_adapter(new_adapter) 59 | ensure 60 | OptionsCache.reset 61 | end 62 | alias_method :adapter=, :use 63 | alias_method :engine=, :use 64 | module_function :adapter=, :engine= 65 | 66 | def load(string, options = {}) 67 | adapter = current_adapter(options) 68 | begin 69 | adapter.load(string, options) 70 | rescue adapter::ParseError => e 71 | raise(ParseError.build(e, string), cause: e) 72 | end 73 | end 74 | alias_method :decode, :load 75 | 76 | def current_adapter(options = {}) 77 | if (new_adapter = options[:adapter]) 78 | load_adapter(new_adapter) 79 | else 80 | adapter 81 | end 82 | end 83 | 84 | # Encodes a Ruby object as JSON. 85 | def dump(object, options = {}) 86 | current_adapter(options).dump(object, options) 87 | end 88 | alias_method :encode, :dump 89 | 90 | # Executes passed block using specified adapter. 91 | def with_adapter(new_adapter) 92 | old_adapter = adapter 93 | self.adapter = new_adapter 94 | yield 95 | ensure 96 | self.adapter = old_adapter 97 | end 98 | alias_method :with_engine, :with_adapter 99 | module_function :with_engine 100 | end 101 | -------------------------------------------------------------------------------- /spec/shared/options.rb: -------------------------------------------------------------------------------- 1 | shared_examples_for "has options" do |object| 2 | subject { object.respond_to?(:call) ? object.call : object } 3 | 4 | describe "dump options" do 5 | before do 6 | subject.dump_options = nil 7 | end 8 | 9 | after do 10 | subject.dump_options = nil 11 | end 12 | 13 | it "returns default options if not set" do 14 | expect(subject.dump_options).to eq(subject.default_dump_options) 15 | end 16 | 17 | it "returns frozen default options" do 18 | expect(subject.default_dump_options).to be_frozen 19 | end 20 | 21 | it "allows hashes" do 22 | subject.dump_options = {foo: "bar"} 23 | expect(subject.dump_options).to eq(foo: "bar") 24 | end 25 | 26 | it "allows objects that implement #to_hash" do 27 | value = Object.new 28 | allow(value).to receive(:to_hash).and_return(foo: "bar") 29 | subject.dump_options = value 30 | expect(subject.dump_options).to eq(foo: "bar") 31 | end 32 | 33 | it "evaluates lambda returning options (with args)" do 34 | subject.dump_options = ->(a1, a2) { {a1 => a2} } 35 | expect(subject.dump_options("1", "2")).to eq("1" => "2") 36 | end 37 | 38 | it "evaluates lambda returning options (with no args)" do 39 | subject.dump_options = -> { {foo: "bar"} } 40 | expect(subject.dump_options).to eq(foo: "bar") 41 | end 42 | 43 | it "returns empty hash in all other cases" do 44 | [true, false, 10, nil].each do |val| 45 | subject.dump_options = val 46 | expect(subject.dump_options).to eq(subject.default_dump_options) 47 | end 48 | end 49 | end 50 | 51 | describe "load options" do 52 | before do 53 | subject.load_options = nil 54 | end 55 | 56 | after do 57 | subject.load_options = nil 58 | end 59 | 60 | it "returns default options if not set" do 61 | expect(subject.load_options).to eq(subject.default_load_options) 62 | end 63 | 64 | it "returns frozen default options" do 65 | expect(subject.default_load_options).to be_frozen 66 | end 67 | 68 | it "allows hashes" do 69 | subject.load_options = {foo: "bar"} 70 | expect(subject.load_options).to eq(foo: "bar") 71 | end 72 | 73 | it "allows objects that implement #to_hash" do 74 | value = Object.new 75 | allow(value).to receive(:to_hash).and_return(foo: "bar") 76 | subject.load_options = value 77 | expect(subject.load_options).to eq(foo: "bar") 78 | end 79 | 80 | it "evaluates lambda returning options (with args)" do 81 | subject.load_options = ->(a1, a2) { {a1 => a2} } 82 | expect(subject.load_options("1", "2")).to eq("1" => "2") 83 | end 84 | 85 | it "evaluates lambda returning options (with no args)" do 86 | subject.load_options = -> { {foo: "bar"} } 87 | expect(subject.load_options).to eq(foo: "bar") 88 | end 89 | 90 | it "returns empty hash in all other cases" do 91 | [true, false, 10, nil].each do |val| 92 | subject.load_options = val 93 | expect(subject.load_options).to eq(subject.default_load_options) 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MultiJSON 2 | 3 | [![Gem Version](http://img.shields.io/gem/v/multi_json.svg)][gem] 4 | [![Build Status](https://github.com/sferik/multi_json/actions/workflows/tests.yml/badge.svg)][build] 5 | [![Maintainability](https://qlty.sh/badges/fde3f4a8-c331-44be-b1e6-45842137def9/maintainability.svg)][qlty] 6 | 7 | Lots of Ruby libraries parse JSON and everyone has their favorite JSON coder. 8 | Instead of choosing a single JSON coder and forcing users of your library to be 9 | stuck with it, you can use MultiJSON instead, which will simply choose the 10 | fastest available JSON coder. Here's how to use it: 11 | 12 | ```ruby 13 | require 'multi_json' 14 | 15 | MultiJson.load('{"abc":"def"}') #=> {"abc" => "def"} 16 | MultiJson.load('{"abc":"def"}', :symbolize_keys => true) #=> {:abc => "def"} 17 | MultiJson.dump({:abc => 'def'}) # convert Ruby back to JSON 18 | MultiJson.dump({:abc => 'def'}, :pretty => true) # encoded in a pretty form (if supported by the coder) 19 | ``` 20 | 21 | When loading invalid JSON, MultiJSON will throw a `MultiJson::ParseError`. `MultiJson::DecodeError` and `MultiJson::LoadError` are aliases for backwards compatibility. 22 | 23 | ```ruby 24 | begin 25 | MultiJson.load('{invalid json}') 26 | rescue MultiJson::ParseError => exception 27 | exception.data # => "{invalid json}" 28 | exception.cause # => JSON::ParserError: 795: unexpected token at '{invalid json}' 29 | end 30 | ``` 31 | 32 | `ParseError` instance has `cause` reader which contains the original exception. 33 | It also has `data` reader with the input that caused the problem. 34 | 35 | The `use` method, which sets the MultiJSON adapter, takes either a symbol or a 36 | class (to allow for custom JSON parsers) that responds to both `.load` and `.dump` 37 | at the class level. 38 | 39 | When MultiJSON fails to load the specified adapter, it'll throw `MultiJson::AdapterError` 40 | which inherits from `ArgumentError`. 41 | 42 | MultiJSON tries to have intelligent defaulting. That is, if you have any of the 43 | supported engines already loaded, it will utilize them before attempting to 44 | load any. When loading, libraries are ordered by speed. First fast_jsonparser, 45 | then Oj, then Yajl, then the JSON gem. If no other JSON library is available, 46 | MultiJSON falls back to [OkJson][], a simple, vendorable JSON parser. 47 | 48 | ## Supported JSON Engines 49 | 50 | - [fast_jsonparser][fast_jsonparser] Fast JSON parser by Anil Maurya 51 | - [Oj][oj] Optimized JSON by Peter Ohler 52 | - [Yajl][yajl] Yet Another JSON Library by Brian Lopez 53 | - [JSON][json-gem] The default JSON gem with C-extensions (ships with Ruby 1.9+) 54 | - [gson.rb][gson] A Ruby wrapper for google-gson library (JRuby only) 55 | - [JrJackson][jrjackson] JRuby wrapper for Jackson (JRuby only) 56 | - [OkJson][okjson] A simple, vendorable JSON parser 57 | 58 | ## Supported Ruby Versions 59 | 60 | This library aims to support and is [tested against](https://github.com/sferik/multi_json/actions/workflows/ci.yml) the following Ruby 61 | implementations: 62 | 63 | - Ruby 3.2 64 | - Ruby 3.3 65 | - Ruby 3.4 66 | - [JRuby][jruby] 10.0 (targets Ruby 3.4 compatibility) 67 | 68 | If something doesn't work in one of these implementations, it's a bug. 69 | 70 | This library may inadvertently work (or seem to work) on other Ruby 71 | implementations, however support will only be provided for the versions listed 72 | above. 73 | 74 | If you would like this library to support another Ruby version, you may 75 | volunteer to be a maintainer. Being a maintainer entails making sure all tests 76 | run and pass on that implementation. When something breaks on your 77 | implementation, you will be responsible for providing patches in a timely 78 | fashion. If critical issues for a particular implementation exist at the time 79 | of a major release, support for that Ruby version may be dropped. 80 | 81 | ## Versioning 82 | 83 | This library aims to adhere to [Semantic Versioning 2.0.0][semver]. Violations 84 | of this scheme should be reported as bugs. Specifically, if a minor or patch 85 | version is released that breaks backward compatibility, that version should be 86 | immediately yanked and/or a new version should be immediately released that 87 | restores compatibility. Breaking changes to the public API will only be 88 | introduced with new major versions. As a result of this policy, you can (and 89 | should) specify a dependency on this gem using the [Pessimistic Version 90 | Constraint][pvc] with two digits of precision. For example: 91 | 92 | ```ruby 93 | spec.add_dependency 'multi_json', '~> 1.0' 94 | ``` 95 | 96 | ## Copyright 97 | 98 | Copyright (c) 2010-2025 Michael Bleigh, Josh Kalderimis, Erik Berlin, 99 | and Pavel Pravosud. See [LICENSE][] for details. 100 | 101 | [build]: https://github.com/sferik/multi_json/actions/workflows/tests.yml 102 | [gem]: https://rubygems.org/gems/multi_json 103 | [gson]: https://github.com/avsej/gson.rb 104 | [jrjackson]: https://github.com/guyboertje/jrjackson 105 | [jruby]: http://www.jruby.org/ 106 | [json-gem]: https://github.com/flori/json 107 | [license]: LICENSE.md 108 | [macruby]: http://www.macruby.org/ 109 | [oj]: https://github.com/ohler55/oj 110 | [okjson]: https://github.com/kr/okjson 111 | [fast_jsonparser]: https://github.com/anilmaurya/fast_jsonparser 112 | [pvc]: http://docs.rubygems.org/read/chapter/16#page74 113 | [qlty]: https://qlty.sh/gh/sferik/projects/multi_json 114 | [semver]: http://semver.org/ 115 | [yajl]: https://github.com/brianmario/yajl-ruby 116 | -------------------------------------------------------------------------------- /spec/multi_json_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | require "shared/options" 3 | 4 | RSpec.configure do |config| 5 | config.before(:suite) do 6 | # make sure all available libs are required 7 | MultiJson::REQUIREMENT_MAP.each_value do |library| 8 | require library 9 | rescue LoadError 10 | next 11 | end 12 | end 13 | end 14 | 15 | RSpec.describe MultiJson do 16 | let(:config) { RSpec.configuration } 17 | 18 | before do 19 | skip "java based implementations" if config.java? 20 | described_class.use :oj 21 | described_class.remove_instance_variable(:@default_adapter) if described_class.instance_variable_defined?(:@default_adapter) 22 | end 23 | 24 | context "when no other json implementations are available" do 25 | around do |example| 26 | simulate_no_adapters { example.call } 27 | end 28 | 29 | before do 30 | if described_class.instance_variable_defined?(:@default_adapter_warning_shown) 31 | described_class.remove_instance_variable(:@default_adapter_warning_shown) 32 | end 33 | end 34 | 35 | after do 36 | if described_class.instance_variable_defined?(:@default_adapter_warning_shown) 37 | described_class.remove_instance_variable(:@default_adapter_warning_shown) 38 | end 39 | end 40 | 41 | it "defaults to ok_json if no other JSON implementations are available" do 42 | silence_warnings do 43 | expect(described_class.default_adapter).to eq(:ok_json) 44 | end 45 | end 46 | 47 | it "prints a warning" do 48 | allow(Kernel).to receive(:warn) 49 | described_class.default_adapter 50 | expect(Kernel).to have_received(:warn).with(/warning/i) 51 | end 52 | 53 | it "warns only once" do 54 | allow(Kernel).to receive(:warn) 55 | described_class.default_adapter 56 | described_class.default_adapter 57 | expect(Kernel).to have_received(:warn).once 58 | end 59 | end 60 | 61 | context "when JSON pure is already loaded" do 62 | let(:ext_parser) { defined?(JSON::Ext::Parser) ? JSON::Ext::Parser : nil } 63 | 64 | before do 65 | skip "JSON pure is not loaded" unless JSON::JSON_LOADED 66 | ext_parser # memoize before hiding the constant 67 | hide_const("JSON::Ext::Parser") 68 | allow(described_class).to receive(:require) 69 | end 70 | 71 | after { stub_const("JSON::Ext::Parser", ext_parser) if ext_parser } 72 | 73 | it "default_adapter tries to require each adapter in turn", :aggregate_failures do 74 | undefine_constants(:Oj, :Yajl, :Gson, :JrJackson, :FastJsonparser) do 75 | described_class.default_adapter 76 | expect(described_class).to have_received(:require) 77 | end 78 | end 79 | end 80 | 81 | context "when caching" do 82 | before { described_class.use adapter } 83 | 84 | let(:adapter) { MultiJson::Adapters::JsonGem } 85 | let(:json_string) { '{"abc":"def"}' } 86 | 87 | it "busts caches on global options change", :aggregate_failures do 88 | described_class.load_options = {symbolize_keys: true} 89 | expect(described_class.load(json_string)).to eq(abc: "def") 90 | described_class.load_options = nil 91 | expect(described_class.load(json_string)).to eq("abc" => "def") 92 | end 93 | 94 | it "busts caches on per-adapter options change", :aggregate_failures do 95 | adapter.load_options = {symbolize_keys: true} 96 | expect(described_class.load(json_string)).to eq(abc: "def") 97 | adapter.load_options = nil 98 | expect(described_class.load(json_string)).to eq("abc" => "def") 99 | end 100 | end 101 | 102 | context "when automatically loading adapter" do 103 | before do 104 | described_class.send(:remove_instance_variable, :@adapter) if described_class.instance_variable_defined?(:@adapter) 105 | end 106 | 107 | let(:expected_adapter) do 108 | if config.java? && config.jrjackson? 109 | "MultiJson::Adapters::JrJackson" 110 | elsif config.java? && config.json? 111 | "MultiJson::Adapters::JsonGem" 112 | elsif config.fast_jsonparser? 113 | "MultiJson::Adapters::FastJsonparser" 114 | else 115 | "MultiJson::Adapters::Oj" 116 | end 117 | end 118 | 119 | it "defaults to the best available gem" do 120 | expect(described_class.adapter.to_s).to eq(expected_adapter) 121 | end 122 | 123 | it "looks for adapter even if @adapter variable is nil" do 124 | allow(described_class).to receive(:default_adapter).and_return(:ok_json) 125 | expect(described_class.adapter).to eq(MultiJson::Adapters::OkJson) 126 | end 127 | end 128 | 129 | it "is settable via a symbol" do 130 | described_class.use :json_gem 131 | expect(described_class.adapter).to eq(MultiJson::Adapters::JsonGem) 132 | end 133 | 134 | it "is settable via a case-insensitive string" do 135 | described_class.use "Json_Gem" 136 | expect(described_class.adapter).to eq(MultiJson::Adapters::JsonGem) 137 | end 138 | 139 | it "is settable via a class" do 140 | adapter = Class.new 141 | described_class.use adapter 142 | expect(described_class.adapter).to eq(adapter) 143 | end 144 | 145 | it "is settable via a module" do 146 | adapter = Module.new 147 | described_class.use adapter 148 | expect(described_class.adapter).to eq(adapter) 149 | end 150 | 151 | it "throws AdapterError on bad input" do 152 | expect { described_class.use "bad adapter" }.to raise_error(MultiJson::AdapterError, /bad adapter/) 153 | end 154 | 155 | it "gives access to original error when raising AdapterError", :aggregate_failures do 156 | exception = get_exception(MultiJson::AdapterError) { described_class.use "foobar" } 157 | expect(exception.cause).to be_instance_of(LoadError) 158 | expect(exception.message).to match(%r{adapters/foobar}) 159 | expect(exception.message).to include("Did not recognize your adapter specification") 160 | end 161 | 162 | context "with one-shot parser" do 163 | before do 164 | allow(MultiJson::Adapters::OkJson).to receive_messages(dump: "dump_something", load: "load_something") 165 | described_class.use :json_gem 166 | end 167 | 168 | it "uses the defined parser just for the call", :aggregate_failures do 169 | expect(described_class.dump("", adapter: :ok_json)).to eq("dump_something") 170 | expect(described_class.load("", adapter: :ok_json)).to eq("load_something") 171 | expect(MultiJson::Adapters::OkJson).to have_received(:dump) 172 | expect(MultiJson::Adapters::OkJson).to have_received(:load) 173 | expect(described_class.adapter).to eq(MultiJson::Adapters::JsonGem) 174 | end 175 | end 176 | 177 | it "can set adapter for a block", :aggregate_failures do 178 | described_class.with_adapter(:json_gem) do 179 | described_class.with_engine(:ok_json) { expect(described_class.adapter).to eq(MultiJson::Adapters::OkJson) } 180 | expect(described_class.adapter).to eq(MultiJson::Adapters::JsonGem) 181 | end 182 | expect(described_class.adapter).to eq(MultiJson::Adapters::Oj) 183 | end 184 | 185 | it "restores adapter after an exception", :aggregate_failures do 186 | described_class.use :json_gem 187 | expect do 188 | expect { described_class.with_adapter(:oj) { raise StandardError } }.to raise_error(StandardError) 189 | end.not_to change(described_class, :adapter) 190 | end 191 | 192 | it "JSON gem does not create symbols on parse" do 193 | described_class.with_engine(:json_gem) do 194 | described_class.load('{"json_class":"ZOMG"}') 195 | expect { described_class.load('{"json_class":"OMG"}') }.not_to(change { Symbol.all_symbols.count }) 196 | end 197 | end 198 | 199 | describe "default options" do 200 | around do |example| 201 | example.run 202 | described_class.load_options = described_class.dump_options = nil 203 | end 204 | 205 | it "is deprecated" do 206 | allow(Kernel).to receive(:warn) 207 | silence_warnings { described_class.default_options = {foo: "bar"} } 208 | expect(Kernel).to have_received(:warn).with(/deprecated/i) 209 | end 210 | 211 | it "sets both load and dump options", :aggregate_failures do 212 | allow(described_class).to receive(:dump_options=) 213 | allow(described_class).to receive(:load_options=) 214 | silence_warnings { described_class.default_options = {foo: "bar"} } 215 | expect(described_class).to have_received(:dump_options=).with({foo: "bar"}) 216 | expect(described_class).to have_received(:load_options=).with({foo: "bar"}) 217 | end 218 | end 219 | 220 | it_behaves_like "has options", described_class 221 | 222 | describe "aliases", :jrjackson do 223 | describe "jrjackson" do 224 | it "allows jrjackson alias as symbol", :aggregate_failures do 225 | expect { described_class.use :jrjackson }.not_to raise_error 226 | expect(described_class.adapter).to eq(MultiJson::Adapters::JrJackson) 227 | end 228 | 229 | it "allows jrjackson alias as string", :aggregate_failures do 230 | expect { described_class.use "jrjackson" }.not_to raise_error 231 | expect(described_class.adapter).to eq(MultiJson::Adapters::JrJackson) 232 | end 233 | end 234 | end 235 | end 236 | -------------------------------------------------------------------------------- /spec/shared/adapter.rb: -------------------------------------------------------------------------------- 1 | require "shared/options" 2 | require "stringio" 3 | 4 | shared_examples_for "an adapter" do |adapter| 5 | before { MultiJson.use adapter } 6 | 7 | it_behaves_like "has options", adapter 8 | 9 | it "does not modify argument hashes when loading" do 10 | options = {symbolize_keys: true, pretty: false, adapter: :ok_json} 11 | expect { MultiJson.load("{}", options) }.not_to(change { options }) 12 | end 13 | 14 | it "does not modify argument hashes when dumping" do 15 | options = {symbolize_keys: true, pretty: false, adapter: :ok_json} 16 | expect { MultiJson.dump([42], options) }.not_to(change { options }) 17 | end 18 | 19 | describe ".dump" do 20 | describe "#dump_options" do 21 | before do 22 | MultiJson.dump_options = MultiJson.adapter.dump_options = {} 23 | MultiJson.adapter.dump_options = {foo: "foo"} 24 | MultiJson.dump_options = {foo: "bar"} 25 | allow(MultiJson.adapter.instance).to receive(:dump).and_call_original 26 | end 27 | 28 | after do 29 | MultiJson.dump_options = MultiJson.adapter.dump_options = nil 30 | end 31 | 32 | it "respects global dump options", :aggregate_failures do 33 | MultiJson.dump_options = {foo: "bar"} 34 | expect(MultiJson.dump_options).to eq({foo: "bar"}) 35 | allow(MultiJson.adapter.instance).to receive(:dump).and_call_original 36 | MultiJson.dump(1, fizz: "buzz") 37 | expect(MultiJson.adapter.instance).to have_received(:dump).with(1, {foo: "bar", fizz: "buzz"}) 38 | end 39 | 40 | it "respects per-adapter dump options", :aggregate_failures do 41 | MultiJson.adapter.dump_options = {foo: "bar"} 42 | expect(MultiJson.adapter.dump_options).to eq({foo: "bar"}) 43 | allow(MultiJson.adapter.instance).to receive(:dump).and_call_original 44 | MultiJson.dump(1, fizz: "buzz") 45 | expect(MultiJson.adapter.instance).to have_received(:dump).with(1, {foo: "bar", fizz: "buzz"}) 46 | end 47 | 48 | it "adapter-specific are overridden by global options", :aggregate_failures do 49 | MultiJson.dump(1, fizz: "buzz") 50 | expect(MultiJson.adapter.dump_options).to eq({foo: "foo"}) 51 | expect(MultiJson.dump_options).to eq({foo: "bar"}) 52 | expect(MultiJson.adapter.instance).to have_received(:dump).with(1, {foo: "bar", fizz: "buzz"}) 53 | end 54 | end 55 | 56 | it "writes decodable JSON" do 57 | examples = [{"abc" => "def"}, [], 1, "2", true, false, nil] 58 | examples.each { |e| expect(MultiJson.load(MultiJson.dump(e))).to eq(e) } 59 | end 60 | 61 | it "dumps time in correct format" do 62 | time = Time.at(1_355_218_745).utc 63 | 64 | dumped_json = MultiJson.dump(time) 65 | expected = "2012-12-11 09:39:05 UTC" 66 | expect(MultiJson.load(dumped_json)).to eq(expected) 67 | end 68 | 69 | it "dumps symbol and fixnum keys as strings" do 70 | ex = [[{foo: {bar: "baz"}}, {"foo" => {"bar" => "baz"}}], 71 | [[{foo: {bar: "baz"}}], [{"foo" => {"bar" => "baz"}}]], 72 | [{foo: [{bar: "baz"}]}, {"foo" => [{"bar" => "baz"}]}], 73 | [{1 => {2 => {3 => "bar"}}}, {"1" => {"2" => {"3" => "bar"}}}]] 74 | ex.each { |v, exp| expect(MultiJson.load(MultiJson.dump(v))).to eq(exp) } 75 | end 76 | 77 | it "dumps rootless JSON", :aggregate_failures do 78 | expect(MultiJson.dump("random rootless string")).to eq('"random rootless string"') 79 | expect(MultiJson.dump(123)).to eq("123") 80 | end 81 | 82 | it "passes options to the adapter" do 83 | allow(MultiJson.adapter).to receive(:dump).and_call_original 84 | MultiJson.dump("foo", {bar: :baz}) 85 | expect(MultiJson.adapter).to have_received(:dump).with("foo", {bar: :baz}) 86 | end 87 | 88 | it "dumps custom objects that implement to_json" do 89 | pending "not supported" if adapter.name == "MultiJson::Adapters::Gson" 90 | klass = Class.new { def to_json(*) = '"foobar"' } 91 | expect(MultiJson.dump(klass.new)).to eq('"foobar"') 92 | end 93 | 94 | it "allows to dump JSON values" do 95 | expect(MultiJson.dump(42)).to eq("42") 96 | end 97 | 98 | it "allows to dump JSON with UTF-8 characters" do 99 | expect(MultiJson.dump("color" => "żółć")).to eq('{"color":"żółć"}') 100 | end 101 | end 102 | 103 | describe ".load" do 104 | describe "#load_options" do 105 | before do 106 | MultiJson.load_options = MultiJson.adapter.load_options = {} 107 | MultiJson.adapter.load_options = {foo: "foo"} 108 | MultiJson.load_options = {foo: "bar"} 109 | allow(MultiJson.adapter.instance).to receive(:load).and_call_original 110 | end 111 | 112 | after do 113 | MultiJson.load_options = MultiJson.adapter.load_options = nil 114 | end 115 | 116 | it "respects global load options", :aggregate_failures do 117 | MultiJson.load_options = {foo: "bar"} 118 | expect(MultiJson.load_options).to eq({foo: "bar"}) 119 | allow(MultiJson.adapter.instance).to receive(:load).and_call_original 120 | MultiJson.load("1", fizz: "buzz") 121 | expect(MultiJson.adapter.instance).to have_received(:load).with("1", hash_including(foo: "bar", fizz: "buzz")) 122 | end 123 | 124 | it "respects per-adapter load options", :aggregate_failures do 125 | MultiJson.adapter.load_options = {foo: "bar"} 126 | expect(MultiJson.adapter.load_options).to eq({foo: "bar"}) 127 | allow(MultiJson.adapter.instance).to receive(:load).and_call_original 128 | MultiJson.load("1", fizz: "buzz") 129 | expect(MultiJson.adapter.instance).to have_received(:load).with("1", hash_including(foo: "bar", fizz: "buzz")) 130 | end 131 | 132 | it "adapter-specific are overridden by global options", :aggregate_failures do 133 | MultiJson.load("1", fizz: "buzz") 134 | expect(MultiJson.adapter.load_options).to eq({foo: "foo"}) 135 | expect(MultiJson.load_options).to eq({foo: "bar"}) 136 | expect(MultiJson.adapter.instance).to have_received(:load).with("1", hash_including(foo: "bar", fizz: "buzz")) 137 | end 138 | end 139 | 140 | it "does not modify input" do 141 | input = %(\n\n {"foo":"bar"} \n\n\t) 142 | expect do 143 | MultiJson.load(input) 144 | end.not_to(change { input }) 145 | end 146 | 147 | it "does not modify input encoding" do 148 | input = "[123]" 149 | input.force_encoding("iso-8859-1") 150 | 151 | expect do 152 | MultiJson.load(input) 153 | end.not_to(change { input.encoding }) 154 | end 155 | 156 | it "properly loads valid JSON" do 157 | expect(MultiJson.load('{"abc":"def"}')).to eq("abc" => "def") 158 | end 159 | 160 | it "returns nil on blank input" do 161 | [nil, "", " ", "\t\t\t", "\n", StringIO.new("")].each do |input| 162 | expect(MultiJson.load(input)).to be_nil 163 | end 164 | end 165 | 166 | examples = ['{"abc"}', "\x82\xAC\xEF"].tap do |arr| 167 | arr.pop if adapter.name.include?("Gson") 168 | end 169 | 170 | examples.each do |input| 171 | it "raises MultiJson::ParseError on invalid input: #{input.inspect}" do 172 | expect { MultiJson.load(input) }.to raise_error(MultiJson::ParseError) 173 | end 174 | end 175 | 176 | it "raises MultiJson::ParseError with data on invalid JSON", :aggregate_failures do 177 | data = "{invalid}" 178 | exception = get_exception(MultiJson::ParseError) { MultiJson.load data } 179 | expect(exception.data).to eq(data) 180 | expect(exception.cause).to match(adapter::ParseError) 181 | end 182 | 183 | it "catches MultiJson::DecodeError for legacy support", :aggregate_failures do 184 | data = "{invalid}" 185 | exception = get_exception(MultiJson::DecodeError) { MultiJson.load data } 186 | expect(exception.data).to eq(data) 187 | expect(exception.cause).to match(adapter::ParseError) 188 | end 189 | 190 | it "catches MultiJson::LoadError for legacy support", :aggregate_failures do 191 | data = "{invalid}" 192 | exception = get_exception(MultiJson::LoadError) { MultiJson.load data } 193 | expect(exception.data).to eq(data) 194 | expect(exception.cause).to match(adapter::ParseError) 195 | end 196 | 197 | it "stringifys symbol keys when encoding" do 198 | dumped_json = MultiJson.dump(a: 1, b: {c: 2}) 199 | loaded_json = MultiJson.load(dumped_json) 200 | expect(loaded_json).to eq("a" => 1, "b" => {"c" => 2}) 201 | end 202 | 203 | it "properly loads valid JSON in StringIOs" do 204 | json = StringIO.new('{"abc":"def"}') 205 | expect(MultiJson.load(json)).to eq("abc" => "def") 206 | end 207 | 208 | it "allows for symbolization of keys" do 209 | ex = [['{"abc":{"def":"hgi"}}', {abc: {def: "hgi"}}], 210 | ['[{"abc":{"def":"hgi"}}]', [{abc: {def: "hgi"}}]], 211 | ['{"abc":[{"def":"hgi"}]}', {abc: [{def: "hgi"}]}]] 212 | ex.each { |json, expected| expect(MultiJson.load(json, symbolize_keys: true)).to eq(expected) } 213 | end 214 | 215 | it "allows to load JSON values" do 216 | expect(MultiJson.load("42")).to eq(42) 217 | end 218 | 219 | it "allows to load JSON with UTF-8 characters" do 220 | expect(MultiJson.load('{"color":"żółć"}')).to eq("color" => "żółć") 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 1.18.0 2 | ------ 3 | * [Fix conflict between JSON gem and ActiveSupport](https://github.com/intridea/multi_json/issues/222) 4 | 5 | 1.17.0 6 | ------ 7 | * [Revert minimum ruby version requirement](https://github.com/sferik/multi_json/pull/16) 8 | 9 | 1.16.0 10 | ------ 11 | * [Remove `NSJSONSerialization`](https://github.com/sferik/multi_json/commit/0423d3b5886e93405f4c2221687b7e3329bd2940) 12 | * [Stop referencing `JSON::PRETTY_STATE_PROTOTYPE`](https://github.com/sferik/multi_json/commit/58094d7a0583bf1f5052886806a032c00f16ffc5) 13 | * [Drop support for Ruby versions < 3.2](https://github.com/sferik/multi_json/commit/ff3b42c4bc26cd6512914b7e5321976e948985dc) 14 | * [Move repo from @intridea to @sferik](https://github.com/sferik/multi_json/commit/e87aeadbc9b9aa6df79818fa01bfc5fa959d8474) 15 | * [`JsonCommon`: force encoding to UTF-8, not binary](https://github.com/sferik/multi_json/commit/34dd0247de07f2703c7d42a42d4cefc73635f3cc) 16 | * [Stop setting defaults in `JsonCommon`](https://github.com/sferik/multi_json/commit/d5f9e6e72b99a7def695f430f72c8365998de625) 17 | * [Make `json_pure` an alias of `json_gem`](https://github.com/sferik/multi_json/commit/9ff7c3dcbe3650e712b38e636ad19061a4c08d1a) 18 | 19 | 1.15.0 20 | ------ 21 | * [Improve detection of json_gem adapter](https://github.com/sferik/multi_json/commit/62d54019b17ebf83b28c8deb871a02a122e7d9cf) 22 | 23 | 1.14.1 24 | ------ 25 | * [Fix a warning in Ruby 2.7](https://github.com/sferik/multi_json/commit/26a94ab8c78a394cc237e2ea292c1de4f6ed30d7) 26 | 27 | 1.14.0 28 | ------ 29 | * [Support Oj 3.x gem](https://github.com/sferik/multi_json/commit/5d8febdbebc428882811b90d514f3628617a61d5) 30 | 31 | 1.13.1 32 | ------ 33 | * [Fix missing stdlib set dependency in oj adapter](https://github.com/sferik/multi_json/commit/c4ff66e7bee6fb4f45e54429813d7fada1c152b8) 34 | 35 | 1.13.0 36 | ----- 37 | * [Make Oj adapter handle JSON::ParseError correctly](https://github.com/sferik/multi_json/commit/275e3ffd8169797c510d23d9ef5b8b07e64c3b42) 38 | 39 | 1.12.2 40 | ------ 41 | * [Renew gem certificate](https://github.com/sferik/multi_json/commit/57922d898c6eb587cc9a28ba5724c11e81724700) 42 | 43 | 1.12.1 44 | ------ 45 | * [Prevent memory leak in OptionsCache](https://github.com/sferik/multi_json/commit/aa7498199ad272f3d4a13750d7c568a66047e2ee) 46 | 47 | 1.12.0 48 | ------ 49 | * [Introduce global options cache to improve peroformance](https://github.com/sferik/multi_json/commit/7aaef2a1bc2b83c95e4208b12dad5d1d87ff20a6) 50 | 51 | 1.11.2 52 | ------ 53 | * [Only pass one argument to JrJackson when two is not supported](https://github.com/sferik/multi_json/commit/e798fa517c817fc706982d3f3c61129b6651d601) 54 | 55 | 1.11.1 56 | ------ 57 | * [Dump method passes options throught for JrJackson adapter](https://github.com/sferik/multi_json/commit/3c730fd12135c3e7bf212f878958004908f13909) 58 | 59 | 1.11.0 60 | ------ 61 | * [Make all adapters read IO object before load](https://github.com/sferik/multi_json/commit/167f559e18d4efee05e1f160a2661d16dbb215d4) 62 | 63 | 1.10.1 64 | ------ 65 | * [Explicitly require stringio for Gson adapter](https://github.com/sferik/multi_json/commit/623ec8142d4a212fa0db763bb71295789a119929) 66 | * [Do not read StringIO object before passing it to JrJackson](https://github.com/sferik/multi_json/commit/a6dc935df08e7b3d5d701fbb9298384c96df0fde) 67 | 68 | 1.10.0 69 | ------ 70 | * [Performance tweaks](https://github.com/sferik/multi_json/commit/58724acfed31866d079eaafb1cd824e341ade287) 71 | 72 | 1.9.3 73 | ----- 74 | * [Convert indent option to Fixnum before passing to Oj](https://github.com/sferik/multi_json/commit/826fc5535b863b74fc9f981dfdda3e26f1ee4e5b) 75 | 76 | 1.9.2 77 | ----- 78 | * [Enable use_to_json option for Oj adapter by default](https://github.com/sferik/multi_json/commit/76a4aaf697b10bbabd5d535d83cf1149efcfe5c7) 79 | 80 | 1.9.1 81 | ----- 82 | * [Remove unused LoadError file](https://github.com/sferik/multi_json/commit/65dedd84d59baeefc25c477fedf0bbe85e7ce2cd) 83 | 84 | 1.9.0 85 | ---- 86 | * [Rename LoadError to ParseError](https://github.com/sferik/multi_json/commit/4abb98fe3a90b2a7b3d1594515c8a06042b4a27d) 87 | * [Adapter load failure throws AdapterError instead of ArgumentError](https://github.com/sferik/multi_json/commit/4da612b617bd932bb6fa1cc4c43210327f98f271) 88 | 89 | 1.8.4 90 | ----- 91 | * [Make Gson adapter explicitly read StringIO object](https://github.com/sferik/multi_json/commit/b58b498747ff6e94f41488c971b2a30a98760ef2) 92 | 93 | 1.8.3 94 | ----- 95 | * [Make JrJackson explicitly read StringIO objects](https://github.com/sferik/multi_json/commit/e1f162d5b668e5e4db5afa175361a601a8aa2b05) 96 | * [Prevent calling #downcase on alias symbols](https://github.com/sferik/multi_json/commit/c1cf075453ce0110f7decc4f906444b1233bb67c) 97 | 98 | 1.8.2 99 | ----- 100 | * [Downcase adapter string name for OS compatibility](https://github.com/sferik/multi_json/commit/b8e15a032247a63f1410d21a18add05035f3fa66) 101 | 102 | 1.8.1 103 | ----- 104 | * [Let the adapter handle strings with invalid encoding](https://github.com/sferik/multi_json/commit/6af2bf87b89f44eabf2ae9ca96779febc65ea94b) 105 | 106 | 1.8.0 107 | ----- 108 | * [Raise MultiJson::LoadError on blank input](https://github.com/sferik/multi_json/commit/c44f9c928bb25fe672246ad394b3e5b991be32e6) 109 | 110 | 1.7.9 111 | ----- 112 | * [Explicitly require json gem code even when constant is defined](https://github.com/sferik/multi_json/commit/36f7906c66477eb4b55b7afeaa3684b6db69eff2) 113 | 114 | 1.7.8 115 | ----- 116 | * [Reorder JrJackson before json_gem](https://github.com/sferik/multi_json/commit/315b6e460b6e4dcdb6c82e04e4be8ee975d395da) 117 | * [Update vendored OkJson to version 43](https://github.com/sferik/multi_json/commit/99a6b662f6ef4036e3ee94d7eb547fa72fb2ab50) 118 | 119 | 1.7.7 120 | ----- 121 | * [Fix options caching issues](https://github.com/sferik/multi_json/commit/a3f14c3661688c5927638fa6088c7b46a67e875e) 122 | 123 | 1.7.6 124 | ----- 125 | * [Bring back MultiJson::VERSION constant](https://github.com/sferik/multi_json/commit/31b990c2725e6673bf8ce57540fe66b57a751a72) 126 | 127 | 1.7.5 128 | ----- 129 | * [Fix warning '*' interpreted as argument prefix](https://github.com/sferik/multi_json/commit/b698962c7f64430222a1f06430669706a47aff89) 130 | * [Remove stdlib warning](https://github.com/sferik/multi_json/commit/d06eec6b7996ac8b4ff0e2229efd835379b0c30f) 131 | 132 | 1.7.4 133 | ----- 134 | * [Cache options for better performance](https://github.com/sferik/multi_json/commit/8a26ee93140c4bed36194ed9fb887a1b6919257b) 135 | 136 | 1.7.3 137 | ----- 138 | * [Require json/ext to ensure extension version gets loaded for json_gem](https://github.com/sferik/multi_json/commit/942686f7e8597418c6f90ee69e1d45242fac07b1) 139 | * [Rename JrJackson](https://github.com/sferik/multi_json/commit/078de7ba8b6035343c3e96b4767549e9ec43369a) 140 | * [Prefer JrJackson to JSON gem if present](https://github.com/sferik/multi_json/commit/af8bd9799a66855f04b3aff1c488485950cec7bf) 141 | * [Print a warning if outdated gem versions are used](https://github.com/sferik/multi_json/commit/e7438e7ba2be0236cfa24c2bb9ad40ee821286d1) 142 | * [Loosen required_rubygems_version for compatibility with Ubuntu 10.04](https://github.com/sferik/multi_json/commit/59fad014e8fe41dbc6f09485ea0dc21fc42fd7a7) 143 | 144 | 1.7.2 145 | ----- 146 | * [Rename Jrjackson adapter to JrJackson](https://github.com/sferik/multi_json/commit/b36dc915fc0e6548cbad06b5db6f520e040c9c8b) 147 | * [Implement jrjackson -> jr_jackson alias for back-compatability](https://github.com/sferik/multi_json/commit/aa50ab8b7bb646b8b75d5d65dfeadae8248a4f10) 148 | * [Update vendored OkJson module](https://github.com/sferik/multi_json/commit/30a3f474e17dd86a697c3fab04f468d1a4fd69d7) 149 | 150 | 1.7.1 151 | ----- 152 | * [Fix capitalization of JrJackson class](https://github.com/sferik/multi_json/commit/5373a5e38c647f02427a0477cb8e0e0dafad1b8d) 153 | 154 | 1.7.0 155 | ----- 156 | * [Add load_options/dump_options to MultiJson](https://github.com/sferik/multi_json/commit/a153956be6b0df06ea1705ce3c1ff0b5b0e27ea5) 157 | * [MultiJson does not modify arguments](https://github.com/sferik/multi_json/commit/58525b01c4c2f6635ba2ac13d6fd987b79f3962f) 158 | * [Enable quirks_mode by default for json_gem/json_pure adapters](https://github.com/sferik/multi_json/commit/1fd4e6635c436515b7d7d5a0bee4548de8571520) 159 | * [Add JrJackson adapter](https://github.com/sferik/multi_json/commit/4dd86fa96300aaaf6d762578b9b31ea82adb056d) 160 | * [Raise ArgumentError on bad adapter input](https://github.com/sferik/multi_json/commit/911a3756bdff2cb5ac06497da3fa3e72199cb7ad) 161 | 162 | 1.6.1 163 | ----- 164 | * [Revert "Use JSON.generate instead of #to_json"](https://github.com/sferik/multi_json/issues/86) 165 | 166 | 1.6.0 167 | ----- 168 | * [Add gson.rb support](https://github.com/intridea/multi_json/pull/71) 169 | * [Add MultiJson.default_options](https://github.com/intridea/multi_json/pull/70) 170 | * [Add MultiJson.with_adapter](https://github.com/intridea/multi_json/pull/67) 171 | * [Stringify all possible keys for ok_json](https://github.com/intridea/multi_json/pull/66) 172 | * [Use JSON.generate instead of #to_json](https://github.com/sferik/multi_json/issues/73) 173 | * [Alias `MultiJson::DecodeError` to `MultiJson::LoadError`](https://github.com/intridea/multi_json/pull/79) 174 | 175 | 1.5.1 176 | ----- 177 | * [Do not allow Oj or JSON to create symbols by searching for classes](https://github.com/sferik/multi_json/commit/193e28cf4dc61b6e7b7b7d80f06f74c76df65c41) 178 | 179 | 1.5.0 180 | ----- 181 | * [Add `MultiJson.with_adapter` method](https://github.com/sferik/multi_json/commit/d14c5d28cae96557a0421298621b9499e1f28104) 182 | * [Stringify all possible keys for `ok_json`](https://github.com/sferik/multi_json/commit/73998074058e1e58c557ffa7b9541d486d6041fa) 183 | 184 | 1.4.0 185 | ----- 186 | * [Allow `load`/`dump` of JSON fragments](https://github.com/sferik/multi_json/commit/707aae7d48d39c85b38febbd2c210ba87f6e4a36) 187 | 188 | 1.3.7 189 | ----- 190 | * [Fix rescue clause for MagLev](https://github.com/sferik/multi_json/commit/39abdf50199828c50e85b2ce8f8ba31fcbbc9332) 191 | * [Remove unnecessary check for string version of options key](https://github.com/sferik/multi_json/commit/660101b70e962b3c007d0b90d45944fa47d13ec4) 192 | * [Explicitly set default adapter when adapter is set to `nil` or `false`](https://github.com/sferik/multi_json/commit/a9e587d5a63eafb4baee9fb211265e4dd96a26bc) 193 | * [Fix Oj `ParseError` mapping for Oj 1.4.0](https://github.com/sferik/multi_json/commit/7d9045338cc9029401c16f3c409d54ce97f275e2) 194 | 195 | 1.3.6 196 | ----- 197 | * [Allow adapter-specific options to be passed through to Oj](https://github.com/sferik/multi_json/commit/d0e5feeebcba0bc69400dd203a295f5c30971223) 198 | 199 | 1.3.5 200 | ----- 201 | * [Add pretty support to Oj adapter](https://github.com/sferik/multi_json/commit/0c8f75f03020c53bcf4c6be258faf433d24b2c2b) 202 | 203 | 1.3.4 204 | ----- 205 | * [Use `class << self` instead of `module_function` to create aliases](https://github.com/sferik/multi_json/commit/ba1451c4c48baa297e049889be241a424cb05980) 206 | 207 | 1.3.3 208 | ----- 209 | * [Remove deprecation warnings](https://github.com/sferik/multi_json/commit/36b524e71544eb0186826a891bcc03b2820a008f) 210 | 211 | 1.3.2 212 | ----- 213 | * [Add ability to use adapter per call](https://github.com/sferik/multi_json/commit/106bbec469d5d0a832bfa31fffcb8c0f0cdc9bd3) 214 | * [Add and deprecate `default_engine` method](https://github.com/sferik/multi_json/commit/fc3df0c7a3e2ab9ce0c2c7e7617a4da97dd13f6e) 215 | 216 | 1.3.1 217 | ----- 218 | * [Only warn once for each instance a deprecated method is called](https://github.com/sferik/multi_json/commit/e21d6eb7da74b3f283995c1d27d5880e75f0ae84) 219 | 220 | 1.3.0 221 | ----- 222 | * [Implement `load`/`dump`; deprecate `decode`/`encode`](https://github.com/sferik/multi_json/commit/e90fd6cb1b0293eb0c73c2f4eb0f7a1764370216) 223 | * [Rename engines to adapters](https://github.com/sferik/multi_json/commit/ae7fd144a7949a9c221dcaa446196ec23db908df) 224 | 225 | 1.2.0 226 | ----- 227 | * [Add support for Oj](https://github.com/sferik/multi_json/commit/acd06b233edabe6c44f226873db7b49dab560c60) 228 | 229 | 1.1.0 230 | ----- 231 | * [`NSJSONSerialization` support for MacRuby](https://github.com/sferik/multi_json/commit/f862e2fc966cac8867fe7da3997fc76e8a6cf5d4) 232 | 233 | 1.0.4 234 | ----- 235 | * [Set data context to `DecodeError` exception](https://github.com/sferik/multi_json/commit/19ddafd44029c6681f66fae2a0f6eabfd0f85176) 236 | * [Allow `ok_json` to fallback to `to_json`](https://github.com/sferik/multi_json/commit/c157240b1193b283d06d1bd4d4b5b06bcf3761f8) 237 | * [Add warning when using `ok_json`](https://github.com/sferik/multi_json/commit/dd4b68810c84f826fb98f9713bfb29ab96888d57) 238 | * [Options can be passed to an engine on encode](https://github.com/sferik/multi_json/commit/e0a7ff5d5ff621ffccc61617ed8aeec5816e81f7) 239 | 240 | 1.0.3 241 | ----- 242 | * [`Array` support for `stringify_keys`](https://github.com/sferik/multi_json/commit/644d1c5c7c7f6a27663b11668527b346094e38b9) 243 | * [`Array` support for `symbolize_keys`](https://github.com/sferik/multi_json/commit/c885377d47a2aa39cb0d971fea78db2d2fa479a7) 244 | 245 | 1.0.2 246 | ----- 247 | * [Allow encoding of rootless JSON when `ok_json` is used](https://github.com/sferik/multi_json/commit/d1cde7de97cb0f6152aef8daf14037521cdce8c6) 248 | 249 | 1.0.1 250 | ----- 251 | * [Correct an issue with `ok_json` not being returned as the default engine](https://github.com/sferik/multi_json/commit/d33c141619c54cccd770199694da8fd1bd8f449d) 252 | 253 | 1.0.0 254 | ----- 255 | * [Remove `ActiveSupport::JSON` support](https://github.com/sferik/multi_json/commit/c2f4140141d785a24b3f56e58811b0e561b37f6a) 256 | * [Fix `@engine` ivar warning](https://github.com/sferik/multi_json/commit/3b978a8995721a8dffedc3b75a7f49e5494ec553) 257 | * [Only `rescue` from parsing errors during decoding, not any `StandardError`](https://github.com/sferik/multi_json/commit/391d00b5e85294d42d41347605d8d46b4a7f66cc) 258 | * [Rename `okjson` engine and vendored lib to `ok_json`](https://github.com/sferik/multi_json/commit/5bd1afc977a8208ddb0443e1d57cb79665c019f1) 259 | * [Add `StringIO` support to `json` gem and `ok_json`](https://github.com/sferik/multi_json/commit/1706b11568db7f50af451fce5f4d679aeb3bbe8f) 260 | 261 | 0.0.5 262 | ----- 263 | * [Trap all JSON decoding errors; raise `MultiJson::DecodeError`](https://github.com/sferik/multi_json/commit/dea9a1aef6dd1212aa1e5a37ab1669f9b045b732) 264 | 265 | 0.0.4 266 | ----- 267 | * [Fix default_engine check for `json` gem](https://github.com/sferik/multi_json/commit/caced0c4e8c795922a109ebc00c3c4fa8635bed8) 268 | * [Make requirement mapper an `Array` to preserve order in Ruby versions < 1.9](https://github.com/sferik/multi_json/commit/526f5f29a42131574a088ad9bbb43d7f48439b2c) 269 | 270 | 0.0.3 271 | ----- 272 | * [Improve defaulting and documentation](https://github.com/sferik/twitter/commit/3a0e41b9e4b0909201045fa47704b78c9d949b73) 273 | 274 | 0.0.2 275 | ----- 276 | * [Rename to `multi_json`](https://github.com/sferik/twitter/commit/461ab89ce071c8c9fabfc183581e0ec523788b62) 277 | 278 | 0.0.1 279 | ----- 280 | * [Initial commit](https://github.com/sferik/twitter/commit/518c21ab299c500527491e6c049ab2229e22a805) 281 | -------------------------------------------------------------------------------- /lib/multi_json/vendor/okjson.rb: -------------------------------------------------------------------------------- 1 | # Copyright 2011, 2012 Keith Rarick 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in 11 | # all copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | # THE SOFTWARE. 20 | 21 | # See https://github.com/kr/okjson for updates. 22 | 23 | require "stringio" 24 | 25 | module MultiJson 26 | # Some parts adapted from 27 | # https://golang.org/src/encoding/json/decode.go and 28 | # https://golang.org/src/unicode/utf8/utf8.go 29 | module OkJson 30 | Upstream = "45" 31 | extend self 32 | 33 | # Decodes a json document in string s and 34 | # returns the corresponding ruby value. 35 | # String s must be valid UTF-8. If you have 36 | # a string in some other encoding, convert 37 | # it first. 38 | # 39 | # String values in the resulting structure 40 | # will be UTF-8. 41 | def decode(s) 42 | ts = lex(s) 43 | v, ts = textparse(ts) 44 | raise Error, "trailing garbage" unless ts.empty? 45 | 46 | v 47 | end 48 | 49 | # Encodes x into a json text. It may contain only 50 | # Array, Hash, String, Numeric, true, false, nil. 51 | # (Note, this list excludes Symbol.) 52 | # X itself must be an Array or a Hash. 53 | # No other value can be encoded, and an error will 54 | # be raised if x contains any other value, such as 55 | # Nan, Infinity, Symbol, and Proc, or if a Hash key 56 | # is not a String. 57 | # Strings contained in x must be valid UTF-8. 58 | def encode(x) 59 | case x 60 | when Hash then objenc(x) 61 | when Array then arrenc(x) 62 | else 63 | raise Error, "root value must be an Array or a Hash" 64 | end 65 | end 66 | 67 | def valenc(x) 68 | case x 69 | when Hash then objenc(x) 70 | when Array then arrenc(x) 71 | when String then strenc(x) 72 | when Numeric then numenc(x) 73 | when true then "true" 74 | when false then "false" 75 | when nil then "null" 76 | else 77 | raise Error, "cannot encode #{x.class}: #{x.inspect}" unless x.respond_to?(:to_json) 78 | 79 | x.to_json 80 | end 81 | end 82 | 83 | private 84 | 85 | # Parses a "json text" in the sense of RFC 4627. 86 | # Returns the parsed value and any trailing tokens. 87 | # Note: this is almost the same as valparse, 88 | # except that it does not accept atomic values. 89 | def textparse(ts) 90 | raise Error, "empty" if ts.length <= 0 91 | 92 | typ, _, val = ts[0] 93 | case typ 94 | when "{" then objparse(ts) 95 | when "[" then arrparse(ts) 96 | else 97 | raise Error, "unexpected #{val.inspect}" 98 | end 99 | end 100 | 101 | # Parses a "value" in the sense of RFC 4627. 102 | # Returns the parsed value and any trailing tokens. 103 | def valparse(ts) 104 | raise Error, "empty" if ts.length <= 0 105 | 106 | typ, _, val = ts[0] 107 | case typ 108 | when "{" then objparse(ts) 109 | when "[" then arrparse(ts) 110 | when :val, :str then [val, ts[1..]] 111 | else 112 | raise Error, "unexpected #{val.inspect}" 113 | end 114 | end 115 | 116 | # Parses an "object" in the sense of RFC 4627. 117 | # Returns the parsed value and any trailing tokens. 118 | def objparse(ts) 119 | ts = eat("{", ts) 120 | obj = {} 121 | 122 | return obj, ts[1..] if ts[0][0] == "}" 123 | 124 | k, v, ts = pairparse(ts) 125 | obj[k] = v 126 | 127 | return obj, ts[1..] if ts[0][0] == "}" 128 | 129 | loop do 130 | ts = eat(",", ts) 131 | 132 | k, v, ts = pairparse(ts) 133 | obj[k] = v 134 | 135 | return obj, ts[1..] if ts[0][0] == "}" 136 | end 137 | end 138 | 139 | # Parses a "member" in the sense of RFC 4627. 140 | # Returns the parsed values and any trailing tokens. 141 | def pairparse(ts) 142 | (typ, _, k) = ts[0] 143 | ts = ts[1..] 144 | raise Error, "unexpected #{k.inspect}" if typ != :str 145 | 146 | ts = eat(":", ts) 147 | v, ts = valparse(ts) 148 | [k, v, ts] 149 | end 150 | 151 | # Parses an "array" in the sense of RFC 4627. 152 | # Returns the parsed value and any trailing tokens. 153 | def arrparse(ts) 154 | ts = eat("[", ts) 155 | arr = [] 156 | 157 | return arr, ts[1..] if ts[0][0] == "]" 158 | 159 | v, ts = valparse(ts) 160 | arr << v 161 | 162 | return arr, ts[1..] if ts[0][0] == "]" 163 | 164 | loop do 165 | ts = eat(",", ts) 166 | 167 | v, ts = valparse(ts) 168 | arr << v 169 | 170 | return arr, ts[1..] if ts[0][0] == "]" 171 | end 172 | end 173 | 174 | def eat(typ, ts) 175 | raise Error, "expected #{typ} (got #{ts[0].inspect})" if ts[0][0] != typ 176 | 177 | ts[1..] 178 | end 179 | 180 | # Scans s and returns a list of json tokens, 181 | # excluding white space (as defined in RFC 4627). 182 | def lex(s) 183 | ts = [] 184 | until s.empty? 185 | typ, lexeme, val = tok(s) 186 | raise Error, "invalid character at #{s[0, 10].inspect}" if typ.nil? 187 | 188 | ts << [typ, lexeme, val] if typ != :space 189 | s = s[lexeme.length..] 190 | end 191 | ts 192 | end 193 | 194 | # Scans the first token in s and 195 | # returns a 3-element list, or nil 196 | # if s does not begin with a valid token. 197 | # 198 | # The first list element is one of 199 | # '{', '}', ':', ',', '[', ']', 200 | # :val, :str, and :space. 201 | # 202 | # The second element is the lexeme. 203 | # 204 | # The third element is the value of the 205 | # token for :val and :str, otherwise 206 | # it is the lexeme. 207 | def tok(s) 208 | case s[0] 209 | when "{" then ["{", s[0, 1], s[0, 1]] 210 | when "}" then ["}", s[0, 1], s[0, 1]] 211 | when ":" then [":", s[0, 1], s[0, 1]] 212 | when "," then [",", s[0, 1], s[0, 1]] 213 | when "[" then ["[", s[0, 1], s[0, 1]] 214 | when "]" then ["]", s[0, 1], s[0, 1]] 215 | when "n" then nulltok(s) 216 | when "t" then truetok(s) 217 | when "f" then falsetok(s) 218 | when '"' then strtok(s) 219 | when Spc, "\t", "\n", "\r" then [:space, s[0, 1], s[0, 1]] 220 | else 221 | numtok(s) 222 | end 223 | end 224 | 225 | def nulltok(s) = (s[0, 4] == "null") ? [:val, "null", nil] : [] 226 | 227 | def truetok(s) = (s[0, 4] == "true") ? [:val, "true", true] : [] 228 | 229 | def falsetok(s) = (s[0, 5] == "false") ? [:val, "false", false] : [] 230 | 231 | def numtok(s) 232 | m = /(-?(?:[1-9][0-9]+|[0-9]))([.][0-9]+)?([eE][+-]?[0-9]+)?/.match(s) 233 | if m&.begin(0)&.zero? 234 | if !m[2] && !m[3] 235 | [:val, m[0], Integer(m[0])] 236 | elsif m[2] 237 | [:val, m[0], Float(m[0])] 238 | else 239 | [:val, m[0], Integer(m[1]) * (10**m[3][1..].to_i(10))] 240 | end 241 | else 242 | [] 243 | end 244 | end 245 | 246 | def strtok(s) 247 | m = %r{"([^"\\]|\\["/\\bfnrt]|\\u[0-9a-fA-F]{4})*"}.match(s) 248 | raise Error, "invalid string literal at #{abbrev(s)}" unless m 249 | 250 | [:str, m[0], unquote(m[0])] 251 | end 252 | 253 | def abbrev(s) 254 | t = s[0, 10] 255 | p = t["`"] 256 | t = t[0, p] if p 257 | t += "..." if t.length < s.length 258 | "`" + t + "`" 259 | end 260 | 261 | # Converts a quoted json string literal q into a UTF-8-encoded string. 262 | # The rules are different than for Ruby, so we cannot use eval. 263 | # Unquote will raise an error if q contains control characters. 264 | def unquote(q) 265 | q = q[1...-1] 266 | a = q.dup # allocate a big enough string 267 | # In ruby >= 1.9, a[w] is a codepoint, not a byte. 268 | a.force_encoding("UTF-8") if rubydoesenc? 269 | r = 0 270 | w = 0 271 | while r < q.length 272 | c = q[r] 273 | if c == "\\" 274 | r += 1 275 | raise Error, "string literal ends with a \"\\\": \"#{q}\"" if r >= q.length 276 | 277 | case q[r] 278 | when '"', "\\", "/", "'" 279 | a[w] = q[r] 280 | r += 1 281 | w += 1 282 | when "b", "f", "n", "r", "t" 283 | a[w] = Unesc[q[r]] 284 | r += 1 285 | w += 1 286 | when "u" 287 | r += 1 288 | uchar = begin 289 | hexdec4(q[r, 4]) 290 | rescue RuntimeError => e 291 | raise Error, "invalid escape sequence \\u#{q[r, 4]}: #{e}" 292 | end 293 | r += 4 294 | if surrogate?(uchar) && (q.length >= r + 6) 295 | uchar1 = hexdec4(q[r + 2, 4]) 296 | uchar = subst(uchar, uchar1) 297 | if uchar != Ucharerr 298 | # A valid pair; consume. 299 | r += 6 300 | end 301 | end 302 | if rubydoesenc? 303 | a[w] = "" << uchar 304 | w += 1 305 | else 306 | w += ucharenc(a, w, uchar) 307 | end 308 | else 309 | raise Error, "invalid escape char #{q[r]} in \"#{q}\"" 310 | end 311 | elsif c == '"' || c < Spc 312 | raise Error, "invalid character in string literal \"#{q}\"" 313 | else 314 | # Copy anything else byte-for-byte. 315 | # Valid UTF-8 will remain valid UTF-8. 316 | # Invalid UTF-8 will remain invalid UTF-8. 317 | # In ruby >= 1.9, c is a codepoint, not a byte, 318 | # in which case this is still what we want. 319 | a[w] = c 320 | r += 1 321 | w += 1 322 | end 323 | end 324 | a[0, w] 325 | end 326 | 327 | # Encodes unicode character u as UTF-8 328 | # bytes in string a at position i. 329 | # Returns the number of bytes written. 330 | def ucharenc(a, i, u) 331 | if u <= Uchar1max 332 | a[i] = (u & 0xff).chr 333 | 1 334 | elsif u <= Uchar2max 335 | a[i + 0] = (Utag2 | ((u >> 6) & 0xff)).chr 336 | a[i + 1] = (Utagx | (u & Umaskx)).chr 337 | 2 338 | elsif u <= Uchar3max 339 | a[i + 0] = (Utag3 | ((u >> 12) & 0xff)).chr 340 | a[i + 1] = (Utagx | ((u >> 6) & Umaskx)).chr 341 | a[i + 2] = (Utagx | (u & Umaskx)).chr 342 | 3 343 | else 344 | a[i + 0] = (Utag4 | ((u >> 18) & 0xff)).chr 345 | a[i + 1] = (Utagx | ((u >> 12) & Umaskx)).chr 346 | a[i + 2] = (Utagx | ((u >> 6) & Umaskx)).chr 347 | a[i + 3] = (Utagx | (u & Umaskx)).chr 348 | 4 349 | end 350 | end 351 | 352 | def hexdec4(s) 353 | raise Error, "short" if s.length != 4 354 | 355 | (nibble(s[0]) << 12) | (nibble(s[1]) << 8) | (nibble(s[2]) << 4) | nibble(s[3]) 356 | end 357 | 358 | def subst(u1, u2) 359 | return ((u1 - Usurr1) << 10) | ((u2 - Usurr2) + Usurrself) if u1 >= Usurr1 && u1 < Usurr2 && u2 >= Usurr2 && u2 < Usurr3 360 | 361 | Ucharerr 362 | end 363 | 364 | def surrogate?(u) 365 | u >= Usurr1 && u < Usurr3 366 | end 367 | 368 | def nibble(c) 369 | if c >= "0" && c <= "9" then c.ord - "0".ord 370 | elsif c >= "a" && c <= "z" then c.ord - "a".ord + 10 371 | elsif c >= "A" && c <= "Z" then c.ord - "A".ord + 10 372 | else 373 | raise Error, "invalid hex code #{c}" 374 | end 375 | end 376 | 377 | def objenc(x) 378 | "{" + x.map { |k, v| keyenc(k) + ":" + valenc(v) }.join(",") + "}" 379 | end 380 | 381 | def arrenc(a) 382 | "[" + a.map { |x| valenc(x) }.join(",") + "]" 383 | end 384 | 385 | def keyenc(k) 386 | case k 387 | when String then strenc(k) 388 | else 389 | raise Error, "Hash key is not a string: #{k.inspect}" 390 | end 391 | end 392 | 393 | def strenc(s) 394 | t = StringIO.new 395 | t.putc('"') 396 | r = 0 397 | 398 | while r < s.length 399 | case s[r] 400 | when '"' then t.print('\\"') 401 | when "\\" then t.print("\\\\") 402 | when "\b" then t.print('\\b') 403 | when "\f" then t.print('\\f') 404 | when "\n" then t.print('\\n') 405 | when "\r" then t.print('\\r') 406 | when "\t" then t.print('\\t') 407 | else 408 | c = s[r] 409 | # In ruby >= 1.9, s[r] is a codepoint, not a byte. 410 | if rubydoesenc? 411 | begin 412 | # c.ord will raise an error if c is invalid UTF-8 413 | c = "\\u%04x" % [c.ord] if c.ord < Spc.ord 414 | t.write(c) 415 | rescue 416 | t.write(Ustrerr) 417 | end 418 | elsif c < Spc 419 | t.write("\\u%04x" % c) 420 | elsif c >= Spc && c <= "~" 421 | t.putc(c) 422 | else 423 | n = ucharcopy(t, s, r) # ensure valid UTF-8 output 424 | r += n - 1 # r is incremented below 425 | end 426 | end 427 | r += 1 428 | end 429 | t.putc('"') 430 | t.string 431 | end 432 | 433 | def numenc(x) 434 | raise Error, "Numeric cannot be represented: #{x}" if (x.nan? || x.infinite? rescue false) 435 | 436 | x.to_s 437 | end 438 | 439 | # Copies the valid UTF-8 bytes of a single character 440 | # from string s at position i to I/O object t, and 441 | # returns the number of bytes copied. 442 | # If no valid UTF-8 char exists at position i, 443 | # ucharcopy writes Ustrerr and returns 1. 444 | def ucharcopy(t, s, i) 445 | n = s.length - i 446 | raise Utf8Error if n < 1 447 | 448 | c0 = s[i].ord 449 | 450 | # 1-byte, 7-bit sequence? 451 | if c0 < Utagx 452 | t.putc(c0) 453 | return 1 454 | end 455 | 456 | raise Utf8Error if c0 < Utag2 # unexpected continuation byte? 457 | 458 | raise Utf8Error if n < 2 # need continuation byte 459 | 460 | c1 = s[i + 1].ord 461 | raise Utf8Error if c1 < Utagx || c1 >= Utag2 462 | 463 | # 2-byte, 11-bit sequence? 464 | if c0 < Utag3 465 | raise Utf8Error if (((c0 & Umask2) << 6) | (c1 & Umaskx)) <= Uchar1max 466 | 467 | t.putc(c0) 468 | t.putc(c1) 469 | return 2 470 | end 471 | 472 | # need second continuation byte 473 | raise Utf8Error if n < 3 474 | 475 | c2 = s[i + 2].ord 476 | raise Utf8Error if c2 < Utagx || c2 >= Utag2 477 | 478 | # 3-byte, 16-bit sequence? 479 | if c0 < Utag4 480 | u = ((c0 & Umask3) << 12) | ((c1 & Umaskx) << 6) | (c2 & Umaskx) 481 | raise Utf8Error if u <= Uchar2max 482 | 483 | t.putc(c0) 484 | t.putc(c1) 485 | t.putc(c2) 486 | return 3 487 | end 488 | 489 | # need third continuation byte 490 | raise Utf8Error if n < 4 491 | 492 | c3 = s[i + 3].ord 493 | raise Utf8Error if c3 < Utagx || c3 >= Utag2 494 | 495 | # 4-byte, 21-bit sequence? 496 | if c0 < Utag5 497 | u = ((c0 & Umask4) << 18) | ((c1 & Umaskx) << 12) | ((c2 & Umaskx) << 6) | (c3 & Umaskx) 498 | raise Utf8Error if u <= Uchar3max 499 | 500 | t.putc(c0) 501 | t.putc(c1) 502 | t.putc(c2) 503 | t.putc(c3) 504 | return 4 505 | end 506 | 507 | raise Utf8Error 508 | rescue Utf8Error 509 | t.write(Ustrerr) 510 | 1 511 | end 512 | 513 | def rubydoesenc? 514 | ::String.method_defined?(:force_encoding) 515 | end 516 | 517 | class Utf8Error < ::StandardError 518 | end 519 | 520 | class Error < ::StandardError 521 | end 522 | 523 | Utagx = 0b1000_0000 524 | Utag2 = 0b1100_0000 525 | Utag3 = 0b1110_0000 526 | Utag4 = 0b1111_0000 527 | Utag5 = 0b1111_1000 528 | Umaskx = 0b0011_1111 529 | Umask2 = 0b0001_1111 530 | Umask3 = 0b0000_1111 531 | Umask4 = 0b0000_0111 532 | Uchar1max = (1 << 7) - 1 533 | Uchar2max = (1 << 11) - 1 534 | Uchar3max = (1 << 16) - 1 535 | Ucharerr = 0xFFFD # unicode "replacement char" 536 | Ustrerr = "\xef\xbf\xbd" # unicode "replacement char" 537 | Usurrself = 0x10000 538 | Usurr1 = 0xd800 539 | Usurr2 = 0xdc00 540 | Usurr3 = 0xe000 541 | 542 | Spc = " "[0] 543 | Unesc = {"b" => "\b", "f" => "\f", "n" => "\n", "r" => "\r", "t" => "\t"} 544 | end 545 | end 546 | --------------------------------------------------------------------------------