/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:info AT sonarsource DOT com
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
package org.sonar.core.util;
import com.google.protobuf.Descriptors;
import com.google.protobuf.MapEntry;
import com.google.protobuf.Message;
import java.io.StringWriter;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import org.sonar.api.utils.text.JsonWriter;
/**
* Converts a Protocol Buffers message to JSON. Unknown fields, binary fields and groups
* are not supported. Absent fields are ignored, so it's possible to distinguish
* null strings (field is absent) and empty strings (field is present with value {@code ""}).
*
* <h3>Example</h3>
* <pre>
* // protobuf specification
* message Foo {
* string name = 1;
* int32 count = 2;
* repeated string colors = 3;
* }
*
* // generated JSON
* {
* "name": "hello",
* "count": 32,
* "colors": ["blue", "red"]
* }
* </pre>
*
* <h3>Absent versus empty arrays</h3>
* <p>
* Protobuf does not support absent repeated field. The default value is empty. A pattern
* is defined by {@link ProtobufJsonFormat} to support the difference between absent and
* empty arrays when generating JSON. An intermediary message wrapping the array must be defined.
* It is automatically inlined and does not appear in the generated JSON. This wrapper message must:
* </p>
* <ul>
* <li>contain a single repeated field</li>
* <li>has the same name (case-insensitive) as the field</li>
* </ul>
*
*
* <p>Example:</p>
* <pre>
* // protobuf specification
* message Continent {
* string name = 1;
* Countries countries = 2;
* }
*
* // the message name ("Countries") is the same as the field 1 ("countries")
* message Countries {
* repeated string countries = 1;
* }
*
* // example of generated JSON if field 2 is not present
* {
* "name": "Europe",
* }
*
* // example of generated JSON if field 2 is present but inner array is empty.
* // The intermediary "countries" message is inline.
* {
* "name": "Europe",
* "countries": []
* }
*
* // example of generated JSON if field 2 is present and inner array is not empty
* // The intermediary "countries" message is inline.
* {
* "name": "Europe",
* "countries": ["Spain", "Denmark"]
* }
* </pre>
*
* <h3>Array or map in map values</h3>
* <p>
* Map fields cannot be repeated. Values are scalar types or messages, but not arrays nor maps. In order
* to support multimaps (maps of lists) and maps of maps in JSON, the same pattern as for absent arrays
* can be used:
* </p>
* <p>Example:</p>
* <pre>
* // protobuf specification
* message Continent {
* string name = 1;
* map<string,Countries> countries_by_currency = 2;
* }
*
* // the message name ("Countries") is the same as the field 1 ("countries")
* message Countries {
* repeated string countries = 1;
* }
*
* // example of generated JSON. The intermediary "countries" message is inline.
* {
* "name": "Europe",
* "countries_by_currency": {
* "eur": ["Spain", "France"],
* "dkk": ["Denmark"]
* }
* }
* </pre>
*/
public class ProtobufJsonFormat {
private ProtobufJsonFormat() {
// only statics
}
static class MessageType {
private static final Map<Class<? extends Message>, MessageType> TYPES_BY_CLASS = new HashMap<>();
private final Descriptors.FieldDescriptor[] fieldDescriptors;
private final boolean doesWrapRepeated;
private MessageType(Descriptors.Descriptor descriptor) {
this.fieldDescriptors = descriptor.getFields().toArray(new Descriptors.FieldDescriptor[descriptor.getFields().size()]);
this.doesWrapRepeated = fieldDescriptors.length == 1 && fieldDescriptors[0].isRepeated() && descriptor.getName().equalsIgnoreCase(fieldDescriptors[0].getName());
}
static MessageType of(Message message) {
MessageType type = TYPES_BY_CLASS.get(message.getClass());
if (type == null) {
type = new MessageType(message.getDescriptorForType());
TYPES_BY_CLASS.put(message.getClass(), type);
}
return type;
}
}
public static void write(Message message, JsonWriter writer) {
writer.setSerializeNulls(false).setSerializeEmptys(true);
writer.beginObject();
writeMessage(message, writer);
writer.endObject();
}
public static String toJson(Message message) {
StringWriter json = new StringWriter();
try (JsonWriter jsonWriter = JsonWriter.of(json)) {
write(message, jsonWriter);
}
return json.toString();
}
private static void writeMessage(Message message, JsonWriter writer) {
MessageType type = MessageType.of(message);
for (Descriptors.FieldDescriptor fieldDescriptor : type.fieldDescriptors) {
if (fieldDescriptor.isRepeated()) {
writer.name(fieldDescriptor.getName());
if (fieldDescriptor.isMapField()) {
writeMap((Collection<MapEntry>) message.getField(fieldDescriptor), writer);
} else {
writeArray(writer, fieldDescriptor, (Collection) message.getField(fieldDescriptor));
}
} else if (message.hasField(fieldDescriptor)) {
writer.name(fieldDescriptor.getName());
Object fieldValue = message.getField(fieldDescriptor);
writeFieldValue(fieldDescriptor, fieldValue, writer);
}
}
}
private static void writeArray(JsonWriter writer, Descriptors.FieldDescriptor fieldDescriptor, Collection array) {
writer.beginArray();
for (Object o : array) {
writeFieldValue(fieldDescriptor, o, writer);
}
writer.endArray();
}
private static void writeMap(Collection<MapEntry> mapEntries, JsonWriter writer) {
writer.beginObject();
for (MapEntry mapEntry : mapEntries) {
// Key fields are always double-quoted in json
writer.name(mapEntry.getKey().toString());
Descriptors.FieldDescriptor valueDescriptor = mapEntry.getDescriptorForType().findFieldByName("value");
writeFieldValue(valueDescriptor, mapEntry.getValue(), writer);
}
writer.endObject();
}
private static void writeFieldValue(Descriptors.FieldDescriptor fieldDescriptor, Object value, JsonWriter writer) {
switch (fieldDescriptor.getJavaType()) {
case INT:
writer.value((Integer) value);
break;
case LONG:
writer.value((Long) value);
break;
case DOUBLE:
writer.value((Double) value);
break;
case BOOLEAN:
writer.value((Boolean) value);
break;
case STRING:
writer.value((String) value);
break;
case ENUM:
writer.value(((Descriptors.EnumValueDescriptor) value).getName());
break;
case MESSAGE:
writeMessageValue((Message) value, writer);
break;
default:
throw new IllegalStateException(String.format("JSON format does not support type '%s' of field '%s'", fieldDescriptor.getJavaType(), fieldDescriptor.getName()));
}
}
private static void writeMessageValue(Message message, JsonWriter writer) {
MessageType messageType = MessageType.of(message);
if (messageType.doesWrapRepeated) {
Descriptors.FieldDescriptor repeatedDescriptor = messageType.fieldDescriptors[0];
if (repeatedDescriptor.isMapField()) {
writeMap((Collection<MapEntry>) message.getField(repeatedDescriptor), writer);
} else {
writeArray(writer, repeatedDescriptor, (Collection) message.getField(repeatedDescriptor));
}
} else {
writer.beginObject();
writeMessage(message, writer);
writer.endObject();
}
}
}