diff --git a/logstash-core/src/main/java/org/logstash/ObjectMappers.java b/logstash-core/src/main/java/org/logstash/ObjectMappers.java index 970ce549315..cdc8943a058 100644 --- a/logstash-core/src/main/java/org/logstash/ObjectMappers.java +++ b/logstash-core/src/main/java/org/logstash/ObjectMappers.java @@ -37,11 +37,12 @@ import com.fasterxml.jackson.databind.ser.std.StdSerializer; import com.fasterxml.jackson.dataformat.cbor.CBORFactory; import com.fasterxml.jackson.dataformat.cbor.CBORGenerator; -import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; + import java.io.IOException; import java.math.BigDecimal; import java.math.BigInteger; import java.util.HashMap; +import org.apache.logging.log4j.core.jackson.Log4jJsonObjectMapper; import org.jruby.RubyBignum; import org.jruby.RubyBoolean; import org.jruby.RubyFixnum; @@ -51,11 +52,14 @@ import org.jruby.RubySymbol; import org.jruby.ext.bigdecimal.RubyBigDecimal; import org.logstash.ext.JrubyTimestampExtLibrary; +import org.logstash.log.RubyBasicObjectSerializer; public final class ObjectMappers { + static final String RUBY_SERIALIZERS_MODULE_ID = "RubySerializers"; + private static final SimpleModule RUBY_SERIALIZERS = - new SimpleModule("RubySerializers") + new SimpleModule(RUBY_SERIALIZERS_MODULE_ID) .addSerializer(RubyString.class, new RubyStringSerializer()) .addSerializer(RubySymbol.class, new RubySymbolSerializer()) .addSerializer(RubyFloat.class, new RubyFloatSerializer()) @@ -71,7 +75,15 @@ public final class ObjectMappers { .addDeserializer(RubyNil.class, new RubyNilDeserializer()); public static final ObjectMapper JSON_MAPPER = - new ObjectMapper().registerModule(RUBY_SERIALIZERS); + new ObjectMapper().registerModule(RUBY_SERIALIZERS); + + static String RUBY_BASIC_OBJECT_SERIALIZERS_MODULE_ID = "RubyBasicObjectSerializers"; + + // The RubyBasicObjectSerializer must be registered first, so it has a lower priority + // over other more specific serializers. + public static final ObjectMapper LOG4J_JSON_MAPPER = new Log4jJsonObjectMapper() + .registerModule(new SimpleModule(RUBY_BASIC_OBJECT_SERIALIZERS_MODULE_ID).addSerializer(new RubyBasicObjectSerializer())) + .registerModule(RUBY_SERIALIZERS); /* TODO use this validator instead of LaissezFaireSubTypeValidator public static final PolymorphicTypeValidator TYPE_VALIDATOR = BasicPolymorphicTypeValidator.builder() diff --git a/logstash-core/src/main/java/org/logstash/log/CustomLogEventSerializer.java b/logstash-core/src/main/java/org/logstash/log/CustomLogEventSerializer.java index cfda88a3e59..b52c14a6f12 100644 --- a/logstash-core/src/main/java/org/logstash/log/CustomLogEventSerializer.java +++ b/logstash-core/src/main/java/org/logstash/log/CustomLogEventSerializer.java @@ -21,17 +21,26 @@ package org.logstash.log; import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.io.SegmentedStringWriter; +import com.fasterxml.jackson.core.util.BufferRecycler; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.JsonSerializer; import com.fasterxml.jackson.databind.SerializerProvider; - +import com.google.common.primitives.Primitives; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.io.IOException; import java.util.Map; +import static org.logstash.ObjectMappers.LOG4J_JSON_MAPPER; + /** * Json serializer for logging messages, use in json appender. - * */ + */ public class CustomLogEventSerializer extends JsonSerializer { + + private static final Logger LOGGER = LogManager.getLogger(CustomLogEventSerializer.class); + @Override public void serialize(CustomLogEvent event, JsonGenerator generator, SerializerProvider provider) throws IOException { generator.writeStartObject(); @@ -41,20 +50,9 @@ public void serialize(CustomLogEvent event, JsonGenerator generator, SerializerP generator.writeObjectField("thread", event.getThreadName()); generator.writeFieldName("logEvent"); generator.writeStartObject(); - if (event.getMessage() instanceof StructuredMessage) { - StructuredMessage message = (StructuredMessage) event.getMessage(); - generator.writeStringField("message", message.getMessage()); - if (message.getParams() != null) { - for (Map.Entry entry : message.getParams().entrySet()) { - Object value = entry.getValue(); - try { - generator.writeObjectField(entry.getKey().toString(), value); - } catch (JsonMappingException e) { - generator.writeObjectField(entry.getKey().toString(), value.toString()); - } - } - } + if (event.getMessage() instanceof StructuredMessage) { + writeStructuredMessage((StructuredMessage) event.getMessage(), generator); } else { generator.writeStringField("message", event.getMessage().getFormattedMessage()); } @@ -63,9 +61,48 @@ public void serialize(CustomLogEvent event, JsonGenerator generator, SerializerP generator.writeEndObject(); } + private void writeStructuredMessage(StructuredMessage message, JsonGenerator generator) throws IOException { + generator.writeStringField("message", message.getMessage()); + + if (message.getParams() == null || message.getParams().isEmpty()) { + return; + } + + for (final Map.Entry entry : message.getParams().entrySet()) { + final String paramName = entry.getKey().toString(); + final Object paramValue = entry.getValue(); + + try { + if (isValueSafeToWrite(paramValue)) { + generator.writeObjectField(paramName, paramValue); + continue; + } + + // Create a new Jackson's generator for each entry, that way, the main generator is not compromised/invalidated + // in case any key/value fails to write. It also uses the JSON_LOGGER_MAPPER instead of the default Log4's one, + // leveraging all necessary custom Ruby serializers. + try (final SegmentedStringWriter entryJsonWriter = new SegmentedStringWriter(new BufferRecycler()); + final JsonGenerator entryGenerator = LOG4J_JSON_MAPPER.getFactory().createGenerator(entryJsonWriter)) { + entryGenerator.writeObject(paramValue); + generator.writeFieldName(paramName); + generator.writeRawValue(entryJsonWriter.getAndClear()); + } + } catch (JsonMappingException e) { + LOGGER.debug("Failed to serialize message param type {}", paramValue.getClass(), e); + generator.writeObjectField(paramName, paramValue.toString()); + } + } + } + + private boolean isValueSafeToWrite(Object value) { + return value == null || + value instanceof String || + value.getClass().isPrimitive() || + Primitives.isWrapperType(value.getClass()); + } + @Override public Class handledType() { - return CustomLogEvent.class; } } diff --git a/logstash-core/src/main/java/org/logstash/log/RubyBasicObjectSerializer.java b/logstash-core/src/main/java/org/logstash/log/RubyBasicObjectSerializer.java new file mode 100644 index 00000000000..16a48fe8a4a --- /dev/null +++ b/logstash-core/src/main/java/org/logstash/log/RubyBasicObjectSerializer.java @@ -0,0 +1,116 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.logstash.log; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import com.fasterxml.jackson.databind.util.ClassUtil; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.jruby.RubyBasicObject; +import org.jruby.RubyMethod; +import org.jruby.RubyString; +import org.jruby.exceptions.NameError; +import org.logstash.RubyUtil; + +import java.io.IOException; +import java.util.Optional; + +/** + * Default serializer for {@link org.jruby.RubyBasicObject} since Jackson can't handle that type natively. + * Arrays, Collections and Maps are delegated to the default Jackson's serializer - which might end-up invoking this + * serializer for its elements. + * Values which inspect method is implemented and owned by the LogStash module will be serialized using this method return. + * If none of the above conditions match, it gets the serialized value by invoking the Ruby's {@code to_s} method, falling back + * to {@link RubyBasicObject#to_s()} and {@link RubyBasicObject#anyToString()} in case of errors. + */ +public final class RubyBasicObjectSerializer extends StdSerializer { + + private static final long serialVersionUID = -5557562960691452054L; + private static final Logger LOGGER = LogManager.getLogger(RubyBasicObjectSerializer.class); + private static final String METHOD_INSPECT = "inspect"; + private static final String METHOD_TO_STRING = "to_s"; + + public RubyBasicObjectSerializer() { + super(RubyBasicObject.class); + } + + @Override + public void serialize(final RubyBasicObject value, final JsonGenerator gen, final SerializerProvider provider) throws IOException { + final Optional> serializer = findTypeSerializer(value, provider); + if (serializer.isPresent()) { + try { + serializer.get().serialize(value, gen, provider); + return; + } catch (IOException e) { + LOGGER.debug("Failed to serialize value type {} using default serializer {}", value.getClass(), serializer.get().getClass(), e); + } + } + + if (isCustomInspectMethodDefined(value)) { + try { + gen.writeString(value.callMethod(METHOD_INSPECT).asJavaString()); + return; + } catch (Exception e) { + LOGGER.debug("Failed to serialize value type {} using the custom `inspect` method", value.getMetaClass(), e); + } + } + + try { + gen.writeString(value.callMethod(METHOD_TO_STRING).asJavaString()); + return; + } catch (Exception e) { + LOGGER.debug("Failed to serialize value type {} using `to_s` method", value.getMetaClass(), e); + } + + try { + gen.writeString(value.to_s().asJavaString()); + } catch (Exception e) { + LOGGER.debug("Failed to serialize value type {} using `RubyBasicObject#to_s()` method", value.getMetaClass(), e); + gen.writeString(value.anyToString().asJavaString()); + } + } + + private Optional> findTypeSerializer(final RubyBasicObject value, final SerializerProvider provider) { + if (ClassUtil.isCollectionMapOrArray(value.getClass())) { + try { + // Delegates the serialization to the Jackson's default serializers, which might + // end up using this serializer for its elements. + return Optional.ofNullable(provider.findTypedValueSerializer(value.getJavaClass(), false, null)); + } catch (JsonMappingException e) { + // Ignored + } + } + + return Optional.empty(); + } + + private boolean isCustomInspectMethodDefined(final RubyBasicObject value) { + try { + final Object candidate = value.method(RubyString.newString(RubyUtil.RUBY, METHOD_INSPECT)); + return candidate instanceof RubyMethod && ((RubyMethod) candidate).owner(RubyUtil.RUBY.getCurrentContext()).toString().toLowerCase().startsWith("logstash"); + } catch (NameError e) { + return false; + } + } +} diff --git a/logstash-core/src/test/java/org/logstash/ObjectMappersTest.java b/logstash-core/src/test/java/org/logstash/ObjectMappersTest.java new file mode 100644 index 00000000000..f89b394cdfb --- /dev/null +++ b/logstash-core/src/test/java/org/logstash/ObjectMappersTest.java @@ -0,0 +1,52 @@ +package org.logstash; + +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ser.BeanSerializerFactory; +import com.fasterxml.jackson.databind.ser.Serializers; +import com.fasterxml.jackson.databind.type.TypeFactory; +import org.jruby.RubyBasicObject; +import org.junit.Test; +import org.logstash.log.RubyBasicObjectSerializer; + +import java.util.LinkedList; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.logstash.ObjectMappers.RUBY_BASIC_OBJECT_SERIALIZERS_MODULE_ID; +import static org.logstash.ObjectMappers.RUBY_SERIALIZERS_MODULE_ID; + +public class ObjectMappersTest { + + @Test + public void testLog4jOMRegisterRubySerializersModule() { + assertTrue(ObjectMappers.LOG4J_JSON_MAPPER.getRegisteredModuleIds().contains(RUBY_SERIALIZERS_MODULE_ID)); + } + + @Test + public void testLog4jOMRegisterRubyBasicObjectSerializersModule() { + assertTrue(ObjectMappers.LOG4J_JSON_MAPPER.getRegisteredModuleIds().contains(RUBY_BASIC_OBJECT_SERIALIZERS_MODULE_ID)); + } + + @Test + public void testLog4jOMRegisterRubyBasicObjectSerializersFirst() { + final ObjectMapper mapper = ObjectMappers.LOG4J_JSON_MAPPER; + final BeanSerializerFactory factory = (BeanSerializerFactory) mapper.getSerializerFactory(); + + final LinkedList list = new LinkedList<>(); + for (Serializers serializer : factory.getFactoryConfig().serializers()) { + list.add(serializer); + } + + // RubyBasicObjectSerializer + Log4jJsonModule + assertTrue(list.size() > 1); + + final Serializers rubyBasicObjectSerializer = list.get(list.size() - 2); + final JavaType valueType = TypeFactory.defaultInstance().constructType(RubyBasicObject.class); + final JsonSerializer found = rubyBasicObjectSerializer.findSerializer(mapper.getSerializationConfig(), valueType, null); + + assertNotNull(found); + assertTrue("RubyBasicObjectSerializer must be registered before others non-default serializers", found instanceof RubyBasicObjectSerializer); + } +} \ No newline at end of file diff --git a/logstash-core/src/test/java/org/logstash/log/CustomLogEventTests.java b/logstash-core/src/test/java/org/logstash/log/CustomLogEventTests.java index a2af82ea437..c340fb921de 100644 --- a/logstash-core/src/test/java/org/logstash/log/CustomLogEventTests.java +++ b/logstash-core/src/test/java/org/logstash/log/CustomLogEventTests.java @@ -43,27 +43,32 @@ import java.util.HashMap; import java.util.List; import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.junit.LoggerContextRule; import org.apache.logging.log4j.test.appender.ListAppender; +import org.jruby.RubyHash; +import org.jruby.runtime.builtin.IRubyObject; import org.junit.ClassRule; import org.junit.Test; import org.logstash.ObjectMappers; +import org.logstash.RubyUtil; +import static junit.framework.TestCase.assertFalse; import static junit.framework.TestCase.assertEquals; import static junit.framework.TestCase.assertNotNull; public class CustomLogEventTests { private static final String CONFIG = "log4j2-test1.xml"; - private ListAppender appender; @ClassRule public static LoggerContextRule CTX = new LoggerContextRule(CONFIG); @Test public void testPatternLayout() { - appender = CTX.getListAppender("EventLogger").clear(); + ListAppender appender = CTX.getListAppender("EventLogger").clear(); Logger logger = LogManager.getLogger("EventLogger"); logger.info("simple message"); logger.warn("complex message", Collections.singletonMap("foo", "bar")); @@ -82,7 +87,7 @@ public void testPatternLayout() { @Test @SuppressWarnings("unchecked") public void testJSONLayout() throws Exception { - appender = CTX.getListAppender("JSONEventLogger").clear(); + ListAppender appender = CTX.getListAppender("JSONEventLogger").clear(); Logger logger = LogManager.getLogger("JSONEventLogger"); logger.info("simple message"); logger.warn("complex message", Collections.singletonMap("foo", "bar")); @@ -133,4 +138,40 @@ public void testJSONLayout() throws Exception { logEvent = Collections.singletonMap("message", "ignored params 4"); assertEquals(logEvent, fifthMessage.get("logEvent")); } + + @Test + @SuppressWarnings("unchecked") + public void testJSONLayoutWithRubyObjectArgument() throws JsonProcessingException { + final ListAppender appender = CTX.getListAppender("JSONEventLogger").clear(); + final Logger logger = LogManager.getLogger("JSONEventLogger"); + + final IRubyObject fooRubyObject = RubyUtil.RUBY.evalScriptlet("Class.new do def initialize\n @foo = true\n end\n def to_s\n 'foo_value'\n end end.new"); + final Map arguments = RubyHash.newHash(RubyUtil.RUBY); + arguments.put("foo", fooRubyObject); + arguments.put("bar", "bar_value"); + arguments.put("one", 1); + + final Map mapArgValue = RubyHash.newHash(RubyUtil.RUBY); + mapArgValue.put("first", 1); + mapArgValue.put("second", 2); + arguments.put("map", mapArgValue); + + logger.error("Error with hash: {}", arguments); + + final List loggedMessages = appender.getMessages(); + assertFalse(loggedMessages.isEmpty()); + assertFalse(loggedMessages.get(0).isEmpty()); + + final Map message = ObjectMappers.JSON_MAPPER.readValue(loggedMessages.get(0), Map.class); + final Map logEvent = (Map) message.get("logEvent"); + + assertEquals("Error with hash: {}", logEvent.get("message")); + assertEquals("foo_value", logEvent.get("foo")); + assertEquals("bar_value", logEvent.get("bar")); + assertEquals(1, logEvent.get("one")); + + final Map logEventMapValue = (Map) logEvent.get("map"); + assertEquals(1, logEventMapValue.get("first")); + assertEquals(2, logEventMapValue.get("second")); + } } diff --git a/logstash-core/src/test/java/org/logstash/log/RubyBasicObjectSerializerTest.java b/logstash-core/src/test/java/org/logstash/log/RubyBasicObjectSerializerTest.java new file mode 100644 index 00000000000..2bca316545e --- /dev/null +++ b/logstash-core/src/test/java/org/logstash/log/RubyBasicObjectSerializerTest.java @@ -0,0 +1,195 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + + +package org.logstash.log; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; +import org.jruby.Ruby; +import org.jruby.RubyArray; +import org.jruby.RubyHash; +import org.jruby.RubySymbol; +import org.jruby.runtime.builtin.IRubyObject; +import org.junit.Test; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import static junit.framework.TestCase.assertEquals; +import static junit.framework.TestCase.assertTrue; +import static org.logstash.RubyUtil.*; + +public class RubyBasicObjectSerializerTest { + private final ObjectMapper mapper = new ObjectMapper().registerModule(new SimpleModule().addSerializer(new RubyBasicObjectSerializer())); + + @Test + public void testSerializerPriority() throws JsonProcessingException { + final String expectedOutput = "value_from_custom_serializer"; + + mapper.registerModule(new SimpleModule().addSerializer(new StdSerializer<>(RubySymbol.class) { + private static final long serialVersionUID = 2531452116050620859L; + + @Override + public void serialize(RubySymbol value, JsonGenerator gen, SerializerProvider provider) throws IOException { + gen.writeRawValue(expectedOutput); + } + })); + + final RubySymbol symbol = RubySymbol.newSymbol(RUBY, "value"); + final String serializedValue = mapper.writeValueAsString(symbol); + + assertEquals(expectedOutput, serializedValue); + } + + @Test + public void testSerializationWithJavaListValue() throws JsonProcessingException { + final String listSerializedValue = mapper.writeValueAsString(List.of(RubySymbol.newSymbol(RUBY, "foo"), RubySymbol.newSymbol(RUBY, "bar"))); + + final List values = mapper.readerForListOf(String.class).readValue(listSerializedValue); + assertEquals(2, values.size()); + assertTrue(values.containsAll(List.of("foo", "bar"))); + } + + @Test + public void testSerializationWithRubyArrayValue() throws JsonProcessingException { + final RubyArray rubyArray = new RubyArray<>(RUBY, 2); + rubyArray.push(RubySymbol.newSymbol(RUBY, "one")); + rubyArray.push(RubySymbol.newSymbol(RUBY, "two")); + + final String listSerializedValue = mapper.writeValueAsString(rubyArray); + + final List values = mapper.readerForListOf(String.class).readValue(listSerializedValue); + assertEquals(2, values.size()); + assertTrue(values.containsAll(List.of("one", "two"))); + } + + @Test + public void testSerializationWithArrayValue() throws JsonProcessingException { + final RubySymbol[] array = new RubySymbol[]{RubySymbol.newSymbol(RUBY, "one"), RubySymbol.newSymbol(RUBY, "two")}; + + final String arraySerializedValue = mapper.writeValueAsString(array); + + final List values = mapper.readerForListOf(String.class).readValue(arraySerializedValue); + assertEquals(2, values.size()); + assertTrue(values.containsAll(List.of("one", "two"))); + } + + @Test + public void testSerializationWithRubyMapValue() throws JsonProcessingException { + final RubyHash rubyHash = RubyHash.newHash(RUBY); + rubyHash.put("1", RubySymbol.newSymbol(RUBY, "one")); + rubyHash.put("2", RubySymbol.newSymbol(RUBY, "two")); + + final String listSerializedValue = mapper.writeValueAsString(rubyHash); + + final Map values = mapper.readerForMapOf(String.class).readValue(listSerializedValue); + + assertEquals(2, values.size()); + assertEquals("one", values.get("1")); + assertEquals("two", values.get("2")); + } + + @Test + public void testValueWithNoCustomInspectMethod() throws JsonProcessingException { + final IRubyObject rubyObject = createRubyObject(null, "'value_from_to_s'", null); + + final String result = mapper.writeValueAsString(rubyObject); + + assertEquals("\"value_from_to_s\"", result); + } + + @Test + public void testLogstashOwnedValueWithNoCustomInspectMethod() throws JsonProcessingException { + final IRubyObject rubyObject = createRubyObject("Logstash", "'value_from_to_s'", null); + + final String result = mapper.writeValueAsString(rubyObject); + + assertEquals("\"value_from_to_s\"", result); + } + + @Test + public void testLogstashOwnedValueWithCustomInspectMethod() throws JsonProcessingException { + final IRubyObject rubyObject = createRubyObject("Logstash", "'value_from_to_s'", "'value_from_inspect'"); + + final String result = mapper.writeValueAsString(rubyObject); + + assertEquals("\"value_from_inspect\"", result); + } + + @Test + public void testFailingInspectMethodFallback() throws JsonProcessingException { + final IRubyObject rubyObject = createRubyObject("Logstash", "'value_from_to_s'", "@called = true\n raise 'not ok'"); + + final String result = mapper.writeValueAsString(rubyObject); + + boolean inspectCalled = rubyObject.getInstanceVariables().getInstanceVariable("@called").toJava(Boolean.class); + + assertTrue(inspectCalled); + assertEquals("\"value_from_to_s\"", result); + } + + @Test + public void testFailingToSMethodFallback() throws JsonProcessingException { + final IRubyObject rubyObject = createRubyObject("Logstash", "@called = true\n raise 'mayday!'", null); + + final String result = mapper.writeValueAsString(rubyObject); + + boolean toSCalled = rubyObject.getInstanceVariables().getInstanceVariable("@called").toJava(Boolean.class); + + assertTrue(toSCalled); + assertTrue(result.startsWith("\"#