Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce JSON::Coder #718

Merged
merged 2 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 64 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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) # => "#<Object:0x000000011e768b98>"
```

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|
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions benchmark/encoder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions benchmark/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
46 changes: 43 additions & 3 deletions ext/json/ext/generator/generator.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) \
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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?
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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);
Expand All @@ -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);

Expand Down Expand Up @@ -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();
Expand Down
37 changes: 37 additions & 0 deletions java/src/json/ext/Generator.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -115,6 +117,11 @@ private static <T extends IRubyObject> Handler<? super T> getHandlerFor(Ruby run
case HASH :
if (Helpers.metaclass(object) != runtime.getHash()) break;
return (Handler<T>) HASH_HANDLER;
case STRUCT :
RuntimeInfo info = RuntimeInfo.forRuntime(runtime);
RubyClass fragmentClass = info.jsonModule.get().getClass("Fragment");
if (Helpers.metaclass(object) != fragmentClass) break;
return (Handler<T>) FRAGMENT_HANDLER;
}
return GENERIC_HANDLER;
}
Expand Down Expand Up @@ -481,6 +488,28 @@ static RubyString ensureValidEncoding(ThreadContext context, RubyString str) {
static final Handler<IRubyObject> NIL_HANDLER =
new KeywordHandler<>("null");

/**
* The default handler (<code>Object#to_json</code>): coerces the object
* to string using <code>#to_s</code>, and serializes that string.
*/
static final Handler<IRubyObject> FRAGMENT_HANDLER =
new Handler<IRubyObject>() {
@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 (<code>Object#to_json</code>): coerces the object
* to string using <code>#to_s</code>, and serializes that string.
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading