diff --git a/README.md b/README.md index 24357f97..008d4573 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ If bundler is not being used to manage dependencies, install the gem by executin $ gem install json -## Usage +## Basic Usage To use JSON you can @@ -52,9 +52,70 @@ You can also use the `pretty_generate` method (which formats the output more verbosely and nicely) or `fast_generate` (which doesn't do any of the security checks generate performs, e. g. nesting deepness checks). +## Casting non native types + +JSON documents can only support Hashes, Arrays, Strings, Integers and Floats. + +By default if you attempt to serialize something else, `JSON.generate` will +search for a `#to_json` method on that object: + +```ruby +Position = Struct.new(:latitude, :longitude) do + def to_json(state = nil, *) + JSON::State.from_state(state).generate({ + latitude: latitude, + longitude: longitude, + }) + end +end + +JSON.generate([ + Position.new(12323.234, 435345.233), + Position.new(23434.676, 159435.324), +]) # => [{"latitude":12323.234,"longitude":435345.233},{"latitude":23434.676,"longitude":159435.324}] +``` + +If a `#to_json` method isn't defined on the object, `JSON.generate` will fallback to call `#to_s`: + +```ruby +JSON.generate(Object.new) # => "#" +``` + +Both of these behavior can be disabled using the `strict: true` option: + +```ruby +JSON.generate(Object.new, strict: true) # => Object not allowed in JSON (JSON::GeneratorError) +JSON.generate(Position.new(1, 2)) # => Position not allowed in JSON (JSON::GeneratorError) +``` + +## JSON::Coder + +Since `#to_json` methods are global, it can sometimes be problematic if you need a given type to be +serialized in different ways in different locations. + +Instead it is recommended to use the newer `JSON::Coder` API: + +```ruby +module MyApp + API_JSON_CODER = JSON::Coder.new do |object| + case object + when Time + object.iso8601(3) + else + object + end + end +end + +puts MyApp::API_JSON_CODER.dump(Time.now.utc) # => "2025-01-21T08:41:44.286Z" +``` + +The provided block is called for all objects that don't have a native JSON equivalent, and +must return a Ruby object that has a native JSON equivalent. + ## Combining JSON fragments -To combine JSON fragments to build a bigger JSON document, you can use `JSON::Fragment`: +To combine JSON fragments into a bigger JSON document, you can use `JSON::Fragment`: ```ruby posts_json = cache.fetch_multi(post_ids) do |post_id| @@ -64,7 +125,7 @@ posts_json.map { |post_json| JSON::Fragment.new(post_json) } JSON.generate({ posts: posts_json, count: posts_json.count }) ``` -## Handling arbitrary types +## Round-tripping arbitrary types > [!CAUTION] > You should never use `JSON.unsafe_load` nor `JSON.parse(str, create_additions: true)` to parse untrusted user input, diff --git a/benchmark/encoder.rb b/benchmark/encoder.rb index 5f3de6f5..92464cea 100644 --- a/benchmark/encoder.rb +++ b/benchmark/encoder.rb @@ -17,8 +17,10 @@ def implementations(ruby_obj) state = JSON::State.new(JSON.dump_default_options) + coder = JSON::Coder.new { json: ["json", proc { JSON.generate(ruby_obj) }], + json_coder: ["json_coder", proc { coder.dump(ruby_obj) }], oj: ["oj", proc { Oj.dump(ruby_obj) }], } end diff --git a/benchmark/parser.rb b/benchmark/parser.rb index bacb8e9e..8bf30c0f 100644 --- a/benchmark/parser.rb +++ b/benchmark/parser.rb @@ -15,9 +15,11 @@ def benchmark_parsing(name, json_output) puts "== Parsing #{name} (#{json_output.size} bytes)" + coder = JSON::Coder.new Benchmark.ips do |x| x.report("json") { JSON.parse(json_output) } if RUN[:json] + x.report("json_coder") { coder.load(json_output) } if RUN[:json_coder] x.report("oj") { Oj.load(json_output) } if RUN[:oj] x.report("Oj::Parser") { Oj::Parser.new(:usual).parse(json_output) } if RUN[:oj] x.report("rapidjson") { RapidJSON.parse(json_output) } if RUN[:rapidjson] diff --git a/ext/json/ext/generator/generator.c b/ext/json/ext/generator/generator.c index 62c0c420..bc08c010 100644 --- a/ext/json/ext/generator/generator.c +++ b/ext/json/ext/generator/generator.c @@ -12,6 +12,7 @@ typedef struct JSON_Generator_StateStruct { VALUE space_before; VALUE object_nl; VALUE array_nl; + VALUE as_json; long max_nesting; long depth; @@ -30,8 +31,8 @@ typedef struct JSON_Generator_StateStruct { static VALUE mJSON, cState, cFragment, mString_Extend, eGeneratorError, eNestingError, Encoding_UTF_8; static ID i_to_s, i_to_json, i_new, i_pack, i_unpack, i_create_id, i_extend, i_encode; -static ID sym_indent, sym_space, sym_space_before, sym_object_nl, sym_array_nl, sym_max_nesting, sym_allow_nan, - sym_ascii_only, sym_depth, sym_buffer_initial_length, sym_script_safe, sym_escape_slash, sym_strict; +static VALUE sym_indent, sym_space, sym_space_before, sym_object_nl, sym_array_nl, sym_max_nesting, sym_allow_nan, + sym_ascii_only, sym_depth, sym_buffer_initial_length, sym_script_safe, sym_escape_slash, sym_strict, sym_as_json; #define GET_STATE_TO(self, state) \ @@ -648,6 +649,7 @@ static void State_mark(void *ptr) rb_gc_mark_movable(state->space_before); rb_gc_mark_movable(state->object_nl); rb_gc_mark_movable(state->array_nl); + rb_gc_mark_movable(state->as_json); } static void State_compact(void *ptr) @@ -658,6 +660,7 @@ static void State_compact(void *ptr) state->space_before = rb_gc_location(state->space_before); state->object_nl = rb_gc_location(state->object_nl); state->array_nl = rb_gc_location(state->array_nl); + state->as_json = rb_gc_location(state->as_json); } static void State_free(void *ptr) @@ -714,6 +717,7 @@ static void vstate_spill(struct generate_json_data *data) RB_OBJ_WRITTEN(vstate, Qundef, state->space_before); RB_OBJ_WRITTEN(vstate, Qundef, state->object_nl); RB_OBJ_WRITTEN(vstate, Qundef, state->array_nl); + RB_OBJ_WRITTEN(vstate, Qundef, state->as_json); } static inline VALUE vstate_get(struct generate_json_data *data) @@ -982,6 +986,8 @@ static void generate_json_fragment(FBuffer *buffer, struct generate_json_data *d static void generate_json(FBuffer *buffer, struct generate_json_data *data, JSON_Generator_State *state, VALUE obj) { VALUE tmp; + bool as_json_called = false; +start: if (obj == Qnil) { generate_json_null(buffer, data, state, obj); } else if (obj == Qfalse) { @@ -1025,7 +1031,13 @@ static void generate_json(FBuffer *buffer, struct generate_json_data *data, JSON default: general: if (state->strict) { - raise_generator_error(obj, "%"PRIsVALUE" not allowed in JSON", CLASS_OF(obj)); + if (RTEST(state->as_json) && !as_json_called) { + obj = rb_proc_call_with_block(state->as_json, 1, &obj, Qnil); + as_json_called = true; + goto start; + } else { + raise_generator_error(obj, "%"PRIsVALUE" not allowed in JSON", CLASS_OF(obj)); + } } else if (rb_respond_to(obj, i_to_json)) { tmp = rb_funcall(obj, i_to_json, 1, vstate_get(data)); Check_Type(tmp, T_STRING); @@ -1126,6 +1138,7 @@ static VALUE cState_init_copy(VALUE obj, VALUE orig) objState->space_before = origState->space_before; objState->object_nl = origState->object_nl; objState->array_nl = origState->array_nl; + objState->as_json = origState->as_json; return obj; } @@ -1277,6 +1290,28 @@ static VALUE cState_array_nl_set(VALUE self, VALUE array_nl) return Qnil; } +/* + * call-seq: as_json() + * + * This string is put at the end of a line that holds a JSON array. + */ +static VALUE cState_as_json(VALUE self) +{ + GET_STATE(self); + return state->as_json; +} + +/* + * call-seq: as_json=(as_json) + * + * This string is put at the end of a line that holds a JSON array. + */ +static VALUE cState_as_json_set(VALUE self, VALUE as_json) +{ + GET_STATE(self); + RB_OBJ_WRITE(self, &state->as_json, rb_convert_type(as_json, T_DATA, "Proc", "to_proc")); + return Qnil; +} /* * call-seq: check_circular? @@ -1498,6 +1533,7 @@ static int configure_state_i(VALUE key, VALUE val, VALUE _arg) else if (key == sym_script_safe) { state->script_safe = RTEST(val); } else if (key == sym_escape_slash) { state->script_safe = RTEST(val); } else if (key == sym_strict) { state->strict = RTEST(val); } + else if (key == sym_as_json) { state->as_json = rb_convert_type(val, T_DATA, "Proc", "to_proc"); } return ST_CONTINUE; } @@ -1589,6 +1625,8 @@ void Init_generator(void) rb_define_method(cState, "object_nl=", cState_object_nl_set, 1); rb_define_method(cState, "array_nl", cState_array_nl, 0); rb_define_method(cState, "array_nl=", cState_array_nl_set, 1); + rb_define_method(cState, "as_json", cState_as_json, 0); + rb_define_method(cState, "as_json=", cState_as_json_set, 1); rb_define_method(cState, "max_nesting", cState_max_nesting, 0); rb_define_method(cState, "max_nesting=", cState_max_nesting_set, 1); rb_define_method(cState, "script_safe", cState_script_safe, 0); @@ -1610,6 +1648,7 @@ void Init_generator(void) rb_define_method(cState, "buffer_initial_length", cState_buffer_initial_length, 0); rb_define_method(cState, "buffer_initial_length=", cState_buffer_initial_length_set, 1); rb_define_method(cState, "generate", cState_generate, -1); + rb_define_alias(cState, "generate_new", "generate"); // :nodoc: rb_define_singleton_method(cState, "generate", cState_m_generate, 3); @@ -1680,6 +1719,7 @@ void Init_generator(void) sym_script_safe = ID2SYM(rb_intern("script_safe")); sym_escape_slash = ID2SYM(rb_intern("escape_slash")); sym_strict = ID2SYM(rb_intern("strict")); + sym_as_json = ID2SYM(rb_intern("as_json")); usascii_encindex = rb_usascii_encindex(); utf8_encindex = rb_utf8_encindex(); diff --git a/java/src/json/ext/Generator.java b/java/src/json/ext/Generator.java index 4ab92805..b67c0508 100644 --- a/java/src/json/ext/Generator.java +++ b/java/src/json/ext/Generator.java @@ -5,6 +5,8 @@ */ package json.ext; +import json.ext.RuntimeInfo; + import org.jcodings.Encoding; import org.jcodings.specific.ASCIIEncoding; import org.jcodings.specific.USASCIIEncoding; @@ -115,6 +117,11 @@ private static Handler getHandlerFor(Ruby run case HASH : if (Helpers.metaclass(object) != runtime.getHash()) break; return (Handler) HASH_HANDLER; + case STRUCT : + RuntimeInfo info = RuntimeInfo.forRuntime(runtime); + RubyClass fragmentClass = info.jsonModule.get().getClass("Fragment"); + if (Helpers.metaclass(object) != fragmentClass) break; + return (Handler) FRAGMENT_HANDLER; } return GENERIC_HANDLER; } @@ -481,6 +488,28 @@ static RubyString ensureValidEncoding(ThreadContext context, RubyString str) { static final Handler NIL_HANDLER = new KeywordHandler<>("null"); + /** + * The default handler (Object#to_json): coerces the object + * to string using #to_s, and serializes that string. + */ + static final Handler FRAGMENT_HANDLER = + new Handler() { + @Override + RubyString generateNew(ThreadContext context, Session session, IRubyObject object) { + GeneratorState state = session.getState(context); + IRubyObject result = object.callMethod(context, "to_json", state); + if (result instanceof RubyString) return (RubyString)result; + throw context.runtime.newTypeError("to_json must return a String"); + } + + @Override + void generate(ThreadContext context, Session session, IRubyObject object, OutputStream buffer) throws IOException { + RubyString result = generateNew(context, session, object); + ByteList bytes = result.getByteList(); + buffer.write(bytes.unsafeBytes(), bytes.begin(), bytes.length()); + } + }; + /** * The default handler (Object#to_json): coerces the object * to string using #to_s, and serializes that string. @@ -510,6 +539,14 @@ void generate(ThreadContext context, Session session, IRubyObject object, Output RubyString generateNew(ThreadContext context, Session session, IRubyObject object) { GeneratorState state = session.getState(context); if (state.strict()) { + if (state.getAsJSON() != null ) { + IRubyObject value = state.getAsJSON().call(context, object); + Handler handler = getHandlerFor(context.runtime, value); + if (handler == GENERIC_HANDLER) { + throw Utils.buildGeneratorError(context, object, value + " returned by as_json not allowed in JSON").toThrowable(); + } + return handler.generateNew(context, session, value); + } throw Utils.buildGeneratorError(context, object, object + " not allowed in JSON").toThrowable(); } else if (object.respondsTo("to_json")) { IRubyObject result = object.callMethod(context, "to_json", state); diff --git a/java/src/json/ext/GeneratorState.java b/java/src/json/ext/GeneratorState.java index 92d0c49a..ec944646 100644 --- a/java/src/json/ext/GeneratorState.java +++ b/java/src/json/ext/GeneratorState.java @@ -14,6 +14,7 @@ import org.jruby.RubyInteger; import org.jruby.RubyNumeric; import org.jruby.RubyObject; +import org.jruby.RubyProc; import org.jruby.RubyString; import org.jruby.anno.JRubyMethod; import org.jruby.runtime.Block; @@ -22,6 +23,7 @@ import org.jruby.runtime.Visibility; import org.jruby.runtime.builtin.IRubyObject; import org.jruby.util.ByteList; +import org.jruby.util.TypeConverter; /** * The JSON::Ext::Generator::State class. @@ -58,6 +60,8 @@ public class GeneratorState extends RubyObject { */ private ByteList arrayNl = ByteList.EMPTY_BYTELIST; + private RubyProc asJSON; + /** * The maximum level of nesting of structures allowed. * 0 means disabled. @@ -211,6 +215,7 @@ public IRubyObject initialize_copy(ThreadContext context, IRubyObject vOrig) { this.spaceBefore = orig.spaceBefore; this.objectNl = orig.objectNl; this.arrayNl = orig.arrayNl; + this.asJSON = orig.asJSON; this.maxNesting = orig.maxNesting; this.allowNaN = orig.allowNaN; this.asciiOnly = orig.asciiOnly; @@ -227,7 +232,7 @@ public IRubyObject initialize_copy(ThreadContext context, IRubyObject vOrig) { * the result. If no valid JSON document can be created this method raises * a GeneratorError exception. */ - @JRubyMethod + @JRubyMethod(alias="generate_new") public IRubyObject generate(ThreadContext context, IRubyObject obj, IRubyObject io) { IRubyObject result = Generator.generateJson(context, obj, this, io); RuntimeInfo info = RuntimeInfo.forRuntime(context.runtime); @@ -247,7 +252,7 @@ public IRubyObject generate(ThreadContext context, IRubyObject obj, IRubyObject return resultString; } - @JRubyMethod + @JRubyMethod(alias="generate_new") public IRubyObject generate(ThreadContext context, IRubyObject obj) { return generate(context, obj, context.nil); } @@ -353,6 +358,22 @@ public IRubyObject array_nl_set(ThreadContext context, return arrayNl; } + public RubyProc getAsJSON() { + return asJSON; + } + + @JRubyMethod(name="as_json") + public IRubyObject as_json_get(ThreadContext context) { + return asJSON == null ? context.getRuntime().getFalse() : asJSON; + } + + @JRubyMethod(name="as_json=") + public IRubyObject as_json_set(ThreadContext context, + IRubyObject asJSON) { + this.asJSON = (RubyProc)TypeConverter.convertToType(asJSON, context.getRuntime().getProc(), "to_proc"); + return asJSON; + } + @JRubyMethod(name="check_circular?") public RubyBoolean check_circular_p(ThreadContext context) { return RubyBoolean.newBoolean(context, maxNesting != 0); @@ -487,6 +508,8 @@ public IRubyObject _configure(ThreadContext context, IRubyObject vOpts) { ByteList arrayNl = opts.getString("array_nl"); if (arrayNl != null) this.arrayNl = arrayNl; + this.asJSON = opts.getProc("as_json"); + ByteList objectNl = opts.getString("object_nl"); if (objectNl != null) this.objectNl = objectNl; @@ -522,6 +545,7 @@ public RubyHash to_h(ThreadContext context) { result.op_aset(context, runtime.newSymbol("space_before"), space_before_get(context)); result.op_aset(context, runtime.newSymbol("object_nl"), object_nl_get(context)); result.op_aset(context, runtime.newSymbol("array_nl"), array_nl_get(context)); + result.op_aset(context, runtime.newSymbol("as_json"), as_json_get(context)); result.op_aset(context, runtime.newSymbol("allow_nan"), allow_nan_p(context)); result.op_aset(context, runtime.newSymbol("ascii_only"), ascii_only_p(context)); result.op_aset(context, runtime.newSymbol("max_nesting"), max_nesting_get(context)); diff --git a/java/src/json/ext/OptionsReader.java b/java/src/json/ext/OptionsReader.java index ff976c38..985bc018 100644 --- a/java/src/json/ext/OptionsReader.java +++ b/java/src/json/ext/OptionsReader.java @@ -10,10 +10,12 @@ import org.jruby.RubyClass; import org.jruby.RubyHash; import org.jruby.RubyNumeric; +import org.jruby.RubyProc; import org.jruby.RubyString; import org.jruby.runtime.ThreadContext; import org.jruby.runtime.builtin.IRubyObject; import org.jruby.util.ByteList; +import org.jruby.util.TypeConverter; final class OptionsReader { private final ThreadContext context; @@ -110,4 +112,10 @@ public RubyHash getHash(String key) { if (value == null || value.isNil()) return new RubyHash(runtime); return (RubyHash) value; } + + RubyProc getProc(String key) { + IRubyObject value = get(key); + if (value == null) return null; + return (RubyProc)TypeConverter.convertToType(value, runtime.getProc(), "to_proc"); + } } diff --git a/lib/json/common.rb b/lib/json/common.rb index ea15b706..dfb9f580 100644 --- a/lib/json/common.rb +++ b/lib/json/common.rb @@ -174,7 +174,18 @@ class MissingUnicodeSupport < JSONError; end # This allows to easily assemble multiple JSON fragments that have # been peristed somewhere without having to parse them nor resorting # to string interpolation. + # + # Note: no validation is performed on the provided string. it is the + # responsability of the caller to ensure the string contains valid JSON. Fragment = Struct.new(:json) do + def initialize(json) + unless string = String.try_convert(json) + raise TypeError, " no implicit conversion of #{json.class} into String" + end + + super(string) + end + def to_json(state = nil, *) json end @@ -851,6 +862,82 @@ def merge_dump_options(opts, strict: NOT_SET) class << self private :merge_dump_options end + + # JSON::Coder holds a parser and generator configuration. + # + # module MyApp + # JSONC_CODER = JSON::Coder.new( + # allow_trailing_comma: true + # ) + # end + # + # MyApp::JSONC_CODER.load(document) + # + class Coder + # :call-seq: + # JSON.new(options = nil, &block) + # + # Argument +options+, if given, contains a \Hash of options for both parsing and generating. + # See {Parsing Options}[#module-JSON-label-Parsing+Options], and {Generating Options}[#module-JSON-label-Generating+Options]. + # + # For generation, the strict: true option is always set. When a Ruby object with no native \JSON counterpart is + # encoutered, the block provided to the initialize method is invoked, and must return a Ruby object that has a native + # \JSON counterpart: + # + # module MyApp + # API_JSON_CODER = JSON::Coder.new do |object| + # case object + # when Time + # object.iso8601(3) + # else + # object # Unknown type, will raise + # end + # end + # end + # + # puts MyApp::API_JSON_CODER.dump(Time.now.utc) # => "2025-01-21T08:41:44.286Z" + # + def initialize(options = nil, &as_json) + if options.nil? + options = { strict: true } + else + options = options.dup + options[:strict] = true + end + options[:as_json] = as_json if as_json + options[:create_additions] = false unless options.key?(:create_additions) + + @state = State.new(options).freeze + @parser_config = Ext::Parser::Config.new(options) + end + + # call-seq: + # dump(object) -> String + # dump(object, io) -> io + # + # Serialize the given object into a \JSON document. + def dump(object, io = nil) + @state.generate_new(object, io) + end + alias_method :generate, :dump + + # call-seq: + # load(string) -> Object + # + # Parse the given \JSON document and return an equivalent Ruby object. + def load(source) + @parser_config.parse(source) + end + alias_method :parse, :load + + # call-seq: + # load(path) -> Object + # + # Parse the given \JSON document and return an equivalent Ruby object. + def load_file(path) + load(File.read(path, encoding: Encoding::UTF_8)) + end + end end module ::Kernel diff --git a/lib/json/ext/generator/state.rb b/lib/json/ext/generator/state.rb index 6cd9496e..d40c3b5e 100644 --- a/lib/json/ext/generator/state.rb +++ b/lib/json/ext/generator/state.rb @@ -58,6 +58,7 @@ def to_h space_before: space_before, object_nl: object_nl, array_nl: array_nl, + as_json: as_json, allow_nan: allow_nan?, ascii_only: ascii_only?, max_nesting: max_nesting, diff --git a/lib/json/truffle_ruby/generator.rb b/lib/json/truffle_ruby/generator.rb index f73263cd..be4daa91 100644 --- a/lib/json/truffle_ruby/generator.rb +++ b/lib/json/truffle_ruby/generator.rb @@ -105,16 +105,17 @@ def self.generate(obj, opts = nil, io = nil) # an unconfigured instance. If _opts_ is a State object, it is just # returned. def self.from_state(opts) - case - when self === opts - opts - when opts.respond_to?(:to_hash) - new(opts.to_hash) - when opts.respond_to?(:to_h) - new(opts.to_h) - else - SAFE_STATE_PROTOTYPE.dup + if opts + case + when self === opts + return opts + when opts.respond_to?(:to_hash) + return new(opts.to_hash) + when opts.respond_to?(:to_h) + return new(opts.to_h) + end end + SAFE_STATE_PROTOTYPE.dup end # Instantiates a new State object, configured by _opts_. @@ -142,6 +143,7 @@ def initialize(opts = nil) @array_nl = '' @allow_nan = false @ascii_only = false + @as_json = false @depth = 0 @buffer_initial_length = 1024 @script_safe = false @@ -167,6 +169,9 @@ def initialize(opts = nil) # This string is put at the end of a line that holds a JSON array. attr_accessor :array_nl + # This proc converts unsupported types into native JSON types. + attr_accessor :as_json + # This integer returns the maximum level of data structure nesting in # the generated JSON, max_nesting = 0 if no maximum is checked. attr_accessor :max_nesting @@ -251,6 +256,7 @@ def configure(opts) @object_nl = opts[:object_nl] || '' if opts.key?(:object_nl) @array_nl = opts[:array_nl] || '' if opts.key?(:array_nl) @allow_nan = !!opts[:allow_nan] if opts.key?(:allow_nan) + @as_json = opts[:as_json].to_proc if opts.key?(:as_json) @ascii_only = opts[:ascii_only] if opts.key?(:ascii_only) @depth = opts[:depth] || 0 @buffer_initial_length ||= opts[:buffer_initial_length] @@ -312,6 +318,10 @@ def generate(obj, anIO = nil) end end + def generate_new(obj, anIO = nil) # :nodoc: + dup.generate(obj, anIO) + end + # Handles @allow_nan, @buffer_initial_length, other ivars must be the default value (see above) private def generate_json(obj, buf) case obj @@ -403,8 +413,20 @@ module Object # it to a JSON string, and returns the result. This is a fallback, if no # special method #to_json was defined for some object. def to_json(state = nil, *) - if state && State.from_state(state).strict? - raise GeneratorError.new("#{self.class} not allowed in JSON", self) + state = State.from_state(state) if state + if state&.strict? + value = self + if state.strict? && !(false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value || Fragment === value) + if state.as_json + value = state.as_json.call(value) + unless false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value || Fragment === value + raise GeneratorError.new("#{value.class} returned by #{state.as_json} not allowed in JSON", value) + end + value.to_json(state) + else + raise GeneratorError.new("#{value.class} not allowed in JSON", value) + end + end else to_s.to_json end @@ -454,8 +476,16 @@ def json_transform(state) end result = +"#{result}#{key_json}#{state.space_before}:#{state.space}" - if state.strict? && !(false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value) - raise GeneratorError.new("#{value.class} not allowed in JSON", value) + if state.strict? && !(false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value || Fragment === value) + if state.as_json + value = state.as_json.call(value) + unless false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value || Fragment === value + raise GeneratorError.new("#{value.class} returned by #{state.as_json} not allowed in JSON", value) + end + result << value.to_json(state) + else + raise GeneratorError.new("#{value.class} not allowed in JSON", value) + end elsif value.respond_to?(:to_json) result << value.to_json(state) else @@ -507,8 +537,16 @@ def json_transform(state) each { |value| result << delim unless first result << state.indent * depth if indent - if state.strict? && !(false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value) - raise GeneratorError.new("#{value.class} not allowed in JSON", value) + if state.strict? && !(false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value || Fragment === value) + if state.as_json + value = state.as_json.call(value) + unless false == value || true == value || nil == value || String === value || Array === value || Hash === value || Integer === value || Float === value || Fragment === value + raise GeneratorError.new("#{value.class} returned by #{state.as_json} not allowed in JSON", value) + end + result << value.to_json(state) + else + raise GeneratorError.new("#{value.class} not allowed in JSON", value) + end elsif value.respond_to?(:to_json) result << value.to_json(state) else diff --git a/test/json/json_coder_test.rb b/test/json/json_coder_test.rb new file mode 100755 index 00000000..37331c4e --- /dev/null +++ b/test/json/json_coder_test.rb @@ -0,0 +1,38 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require_relative 'test_helper' + +class JSONCoderTest < Test::Unit::TestCase + def test_json_coder_with_proc + coder = JSON::Coder.new do |object| + "[Object object]" + end + assert_equal %(["[Object object]"]), coder.dump([Object.new]) + end + + def test_json_coder_with_proc_with_unsupported_value + coder = JSON::Coder.new do |object| + Object.new + end + assert_raise(JSON::GeneratorError) { coder.dump([Object.new]) } + end + + def test_json_coder_options + coder = JSON::Coder.new(array_nl: "\n") do |object| + 42 + end + + assert_equal "[\n42\n]", coder.dump([Object.new]) + end + + def test_json_coder_load + coder = JSON::Coder.new + assert_equal [1,2,3], coder.load("[1,2,3]") + end + + def test_json_coder_load_options + coder = JSON::Coder.new(symbolize_names: true) + assert_equal({a: 1}, coder.load('{"a":1}')) + end +end diff --git a/test/json/json_generator_test.rb b/test/json/json_generator_test.rb index 824de2c1..7eb95c62 100755 --- a/test/json/json_generator_test.rb +++ b/test/json/json_generator_test.rb @@ -200,6 +200,7 @@ def test_pretty_state assert_equal({ :allow_nan => false, :array_nl => "\n", + :as_json => false, :ascii_only => false, :buffer_initial_length => 1024, :depth => 0, @@ -218,6 +219,7 @@ def test_safe_state assert_equal({ :allow_nan => false, :array_nl => "", + :as_json => false, :ascii_only => false, :buffer_initial_length => 1024, :depth => 0, @@ -236,6 +238,7 @@ def test_fast_state assert_equal({ :allow_nan => false, :array_nl => "", + :as_json => false, :ascii_only => false, :buffer_initial_length => 1024, :depth => 0, @@ -665,5 +668,11 @@ def test_nonutf8_encoding def test_fragment fragment = JSON::Fragment.new(" 42") assert_equal '{"number": 42}', JSON.generate({ number: fragment }) + assert_equal '{"number": 42}', JSON.generate({ number: fragment }, strict: true) + end + + def test_json_generate_as_json_convert_to_proc + object = Object.new + assert_equal object.object_id.to_json, JSON.generate(object, strict: true, as_json: :object_id) end end