Introduce JSON::Coder

Co-authored-by: Jean Boussier <jean.boussier@gmail.com>
This commit is contained in:
Étienne Barrié 2024-12-16 12:25:48 +01:00 committed by Hiroshi SHIBATA
parent 53cf2170f9
commit 89e316ad06
No known key found for this signature in database
GPG Key ID: F9CF13417264FAC2
5 changed files with 177 additions and 3 deletions

View File

@ -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) {
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();

View File

@ -174,7 +174,18 @@ module JSON
# 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 @@ module JSON
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 <tt>strict: true</tt> 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

View File

@ -58,6 +58,7 @@ module JSON
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,

38
test/json/json_coder_test.rb Executable file
View File

@ -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

View File

@ -200,6 +200,7 @@ class JSONGeneratorTest < Test::Unit::TestCase
assert_equal({
:allow_nan => false,
:array_nl => "\n",
:as_json => false,
:ascii_only => false,
:buffer_initial_length => 1024,
:depth => 0,
@ -218,6 +219,7 @@ class JSONGeneratorTest < Test::Unit::TestCase
assert_equal({
:allow_nan => false,
:array_nl => "",
:as_json => false,
:ascii_only => false,
:buffer_initial_length => 1024,
:depth => 0,
@ -236,6 +238,7 @@ class JSONGeneratorTest < Test::Unit::TestCase
assert_equal({
:allow_nan => false,
:array_nl => "",
:as_json => false,
:ascii_only => false,
:buffer_initial_length => 1024,
:depth => 0,
@ -666,4 +669,9 @@ class JSONGeneratorTest < Test::Unit::TestCase
fragment = JSON::Fragment.new(" 42")
assert_equal '{"number": 42}', JSON.generate({ number: fragment })
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