diff --git a/csharp/protos/extest/unittest_issues.proto b/csharp/protos/extest/unittest_issues.proto index b66da47184..376eee8431 100644 --- a/csharp/protos/extest/unittest_issues.proto +++ b/csharp/protos/extest/unittest_issues.proto @@ -97,7 +97,10 @@ message TestJsonFieldOrdering { // ordering. // TestFieldOrderings in unittest_proto3.proto is similar, // but doesn't include oneofs. - // TODO: Consider adding + // TODO: Consider adding oneofs to TestFieldOrderings, although + // that will require fixing other tests in multiple platforms. + // Alternatively, consider just adding this to + // unittest_proto3.proto if multiple platforms want it. int32 plain_int32 = 4; diff --git a/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs b/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs index ac33a750f9..82094ea35b 100644 --- a/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs +++ b/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs @@ -34,6 +34,7 @@ using System; using Google.Protobuf.TestProtos; using NUnit.Framework; using UnitTest.Issues.TestProtos; +using Google.Protobuf.WellKnownTypes; namespace Google.Protobuf { @@ -310,6 +311,66 @@ namespace Google.Protobuf AssertJson("{ 'plainString': 'plain', 'o1String': '', 'plainInt32': 10, 'o2Int32': 0 }", formatter.Format(message)); } + [Test] + public void TimestampStandalone() + { + Assert.AreEqual("1970-01-01T00:00:00Z", new Timestamp().ToString()); + Assert.AreEqual("1970-01-01T00:00:00.100Z", new Timestamp { Nanos = 100000000 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.120Z", new Timestamp { Nanos = 120000000 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.123Z", new Timestamp { Nanos = 123000000 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.123400Z", new Timestamp { Nanos = 123400000 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.123450Z", new Timestamp { Nanos = 123450000 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.123456Z", new Timestamp { Nanos = 123456000 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.123456700Z", new Timestamp { Nanos = 123456700 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.123456780Z", new Timestamp { Nanos = 123456780 }.ToString()); + Assert.AreEqual("1970-01-01T00:00:00.123456789Z", new Timestamp { Nanos = 123456789 }.ToString()); + + // One before and one after the Unix epoch + Assert.AreEqual("1673-06-19T12:34:56Z", + new DateTime(1673, 6, 19, 12, 34, 56, DateTimeKind.Utc).ToTimestamp().ToString()); + Assert.AreEqual("2015-07-31T10:29:34Z", + new DateTime(2015, 7, 31, 10, 29, 34, DateTimeKind.Utc).ToTimestamp().ToString()); + } + + [Test] + public void TimestampField() + { + var message = new TestWellKnownTypes { TimestampField = new Timestamp() }; + AssertJson("{ 'timestampField': '1970-01-01T00:00:00Z' }", JsonFormatter.Default.Format(message)); + } + + [Test] + [TestCase(0, 0, "0s")] + [TestCase(1, 0, "1s")] + [TestCase(-1, 0, "-1s")] + [TestCase(0, 100000000, "0.100s")] + [TestCase(0, 120000000, "0.120s")] + [TestCase(0, 123000000, "0.123s")] + [TestCase(0, 123400000, "0.123400s")] + [TestCase(0, 123450000, "0.123450s")] + [TestCase(0, 123456000, "0.123456s")] + [TestCase(0, 123456700, "0.123456700s")] + [TestCase(0, 123456780, "0.123456780s")] + [TestCase(0, 123456789, "0.123456789s")] + [TestCase(0, -100000000, "-0.100s")] + [TestCase(1, 100000000, "1.100s")] + [TestCase(-1, -100000000, "-1.100s")] + // Non-normalized examples + [TestCase(1, 2123456789, "3.123456789s")] + [TestCase(1, -100000000, "0.900s")] + public void DurationStandalone(long seconds, int nanoseconds, string expected) + { + Assert.AreEqual(expected, new Duration { Seconds = seconds, Nanos = nanoseconds }.ToString()); + } + + [Test] + public void DurationField() + { + var message = new TestWellKnownTypes { DurationField = new Duration() }; + AssertJson("{ 'durationField': '0s' }", JsonFormatter.Default.Format(message)); + + } + /// /// Checks that the actual JSON is the same as the expected JSON - but after replacing /// all apostrophes in the expected JSON with double quotes. This basically makes the tests easier diff --git a/csharp/src/Google.Protobuf/JsonFormatter.cs b/csharp/src/Google.Protobuf/JsonFormatter.cs index d9783fc479..099fb6a14f 100644 --- a/csharp/src/Google.Protobuf/JsonFormatter.cs +++ b/csharp/src/Google.Protobuf/JsonFormatter.cs @@ -122,10 +122,14 @@ namespace Google.Protobuf { Preconditions.CheckNotNull(message, "message"); StringBuilder builder = new StringBuilder(); - // TODO(jonskeet): Handle well-known types here. - // Our reflection support needs improving so that we can get at the descriptor - // to find out whether *this* message is a well-known type. - WriteMessage(builder, message); + if (message.Descriptor.IsWellKnownType) + { + WriteWellKnownTypeValue(builder, message.Descriptor, message, false); + } + else + { + WriteMessage(builder, message); + } return builder.ToString(); } @@ -356,7 +360,7 @@ namespace Google.Protobuf case FieldType.Group: // Never expect to get this, but... if (descriptor.MessageType.IsWellKnownType) { - WriteWellKnownTypeValue(builder, descriptor, value); + WriteWellKnownTypeValue(builder, descriptor.MessageType, value, true); } else { @@ -370,20 +374,115 @@ namespace Google.Protobuf /// /// Central interception point for well-known type formatting. Any well-known types which - /// don't need special handling can fall back to WriteMessage. + /// don't need special handling can fall back to WriteMessage. We avoid assuming that the + /// values are using the embedded well-known types, in order to allow for dynamic messages + /// in the future. /// - private void WriteWellKnownTypeValue(StringBuilder builder, FieldDescriptor descriptor, object value) + private void WriteWellKnownTypeValue(StringBuilder builder, MessageDescriptor descriptor, object value, bool inField) { // For wrapper types, the value will be the (possibly boxed) "native" value, // so we can write it as if we were unconditionally writing the Value field for the wrapper type. - if (descriptor.MessageType.File == Int32Value.Descriptor.File && value != null) + if (descriptor.File == Int32Value.Descriptor.File && value != null) + { + WriteSingleValue(builder, descriptor.FindFieldByNumber(1), value); + return; + } + if (descriptor.FullName == Timestamp.Descriptor.FullName && value != null) + { + MaybeWrapInString(builder, value, WriteTimestamp, inField); + return; + } + if (descriptor.FullName == Duration.Descriptor.FullName && value != null) { - WriteSingleValue(builder, descriptor.MessageType.FindFieldByNumber(1), value); + MaybeWrapInString(builder, value, WriteDuration, inField); return; } WriteMessage(builder, (IMessage) value); } + /// + /// Some well-known types end up as string values... so they need wrapping in quotes, but only + /// when they're being used as fields within another message. + /// + private void MaybeWrapInString(StringBuilder builder, object value, Action action, bool inField) + { + if (inField) + { + builder.Append('"'); + action(builder, (IMessage) value); + builder.Append('"'); + } + else + { + action(builder, (IMessage) value); + } + } + + private void WriteTimestamp(StringBuilder builder, IMessage value) + { + // TODO: In the common case where this *is* using the built-in Timestamp type, we could + // avoid all the reflection at this point, by casting to Timestamp. In the interests of + // avoiding subtle bugs, don't do that until we've implemented DynamicMessage so that we can prove + // it still works in that case. + int nanos = (int) value.Descriptor.Fields[Timestamp.NanosFieldNumber].Accessor.GetValue(value); + long seconds = (long) value.Descriptor.Fields[Timestamp.SecondsFieldNumber].Accessor.GetValue(value); + + // Even if the original message isn't using the built-in classes, we can still build one... and then + // rely on it being normalized. + Timestamp normalized = Timestamp.Normalize(seconds, nanos); + // Use .NET's formatting for the value down to the second, including an opening double quote (as it's a string value) + DateTime dateTime = normalized.ToDateTime(); + builder.Append(dateTime.ToString("yyyy'-'MM'-'dd'T'HH:mm:ss", CultureInfo.InvariantCulture)); + AppendNanoseconds(builder, Math.Abs(normalized.Nanos)); + builder.Append('Z'); + } + + private void WriteDuration(StringBuilder builder, IMessage value) + { + // TODO: Same as for WriteTimestamp + int nanos = (int) value.Descriptor.Fields[Duration.NanosFieldNumber].Accessor.GetValue(value); + long seconds = (long) value.Descriptor.Fields[Duration.SecondsFieldNumber].Accessor.GetValue(value); + + // Even if the original message isn't using the built-in classes, we can still build one... and then + // rely on it being normalized. + Duration normalized = Duration.Normalize(seconds, nanos); + + // The seconds part will normally provide the minus sign if we need it, but not if it's 0... + if (normalized.Seconds == 0 && normalized.Nanos < 0) + { + builder.Append('-'); + } + + builder.Append(normalized.Seconds.ToString("d", CultureInfo.InvariantCulture)); + AppendNanoseconds(builder, Math.Abs(normalized.Nanos)); + builder.Append('s'); + } + + /// + /// Appends a number of nanoseconds to a StringBuilder. Either 0 digits are added (in which + /// case no "." is appended), or 3 6 or 9 digits. + /// + private static void AppendNanoseconds(StringBuilder builder, int nanos) + { + if (nanos != 0) + { + builder.Append('.'); + // Output to 3, 6 or 9 digits. + if (nanos % 1000000 == 0) + { + builder.Append((nanos / 1000000).ToString("d", CultureInfo.InvariantCulture)); + } + else if (nanos % 1000 == 0) + { + builder.Append((nanos / 1000).ToString("d", CultureInfo.InvariantCulture)); + } + else + { + builder.Append(nanos.ToString("d", CultureInfo.InvariantCulture)); + } + } + } + private void WriteList(StringBuilder builder, IFieldAccessor accessor, IList list) { builder.Append("[ "); diff --git a/csharp/src/Google.Protobuf/WellKnownTypes/TimestampPartial.cs b/csharp/src/Google.Protobuf/WellKnownTypes/TimestampPartial.cs index f682d093a7..1aa392c72e 100644 --- a/csharp/src/Google.Protobuf/WellKnownTypes/TimestampPartial.cs +++ b/csharp/src/Google.Protobuf/WellKnownTypes/TimestampPartial.cs @@ -147,7 +147,7 @@ namespace Google.Protobuf.WellKnownTypes return FromDateTime(dateTimeOffset.UtcDateTime); } - private static Timestamp Normalize(long seconds, int nanoseconds) + internal static Timestamp Normalize(long seconds, int nanoseconds) { int extraSeconds = nanoseconds / Duration.NanosecondsPerSecond; seconds += extraSeconds;