Format JSON for Duration and Timestamp.

This is taking an approach of putting all the logic in JsonFormatter. That's helpful in terms of concealing the details of whether or not to wrap the value in quotes, but it does lack flexibility. I don't *think* we want to allow user-defined formatting of messages, so that much shouldn't be a problem.
This commit is contained in:
Jon Skeet 2015-07-31 13:22:15 +01:00
parent 80f89b4ecf
commit 16e272e0c4
3 changed files with 182 additions and 11 deletions

View File

@ -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));
}
/// <summary>
/// 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

View File

@ -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,126 @@ namespace Google.Protobuf
/// <summary>
/// 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.
/// </summary>
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.MessageType.FindFieldByNumber(1), value);
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)
{
MaybeWrapInString(builder, value, WriteDuration, inField);
return;
}
WriteMessage(builder, (IMessage) value);
}
/// <summary>
/// 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.
/// </summary>
private void MaybeWrapInString(StringBuilder builder, object value, Action<StringBuilder, IMessage> 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));
if (normalized.Nanos != 0)
{
builder.Append('.');
// Output to 3, 6 or 9 digits.
if (normalized.Nanos % 1000000 == 0)
{
builder.Append((normalized.Nanos / 1000000).ToString("d", CultureInfo.InvariantCulture));
}
else if (normalized.Nanos % 1000 == 0)
{
builder.Append((normalized.Nanos / 1000).ToString("d", CultureInfo.InvariantCulture));
}
else
{
builder.Append((normalized.Nanos).ToString("d", CultureInfo.InvariantCulture));
}
}
builder.Append('Z');
}
private void WriteDuration(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[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));
nanos = Math.Abs(normalized.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 (normalized.Nanos % 1000 == 0)
{
builder.Append((nanos / 1000).ToString("d", CultureInfo.InvariantCulture));
}
else
{
builder.Append(nanos.ToString("d", CultureInfo.InvariantCulture));
}
}
builder.Append('s');
}
private void WriteList(StringBuilder builder, IFieldAccessor accessor, IList list)
{
builder.Append("[ ");
@ -537,7 +647,7 @@ namespace Google.Protobuf
}
}
builder.Append('"');
}
}
private const string Hex = "0123456789abcdef";
private static void HexEncodeUtf16CodeUnit(StringBuilder builder, char c)

View File

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