diff --git a/transmart-rest-api/grails-app/conf/application.groovy b/transmart-rest-api/grails-app/conf/application.groovy index 1a94d9a82..8a16d9e3f 100644 --- a/transmart-rest-api/grails-app/conf/application.groovy +++ b/transmart-rest-api/grails-app/conf/application.groovy @@ -54,7 +54,8 @@ grails.mime.types = [ rss : 'application/rss+xml', text : 'text/plain', hal : ['application/hal+json', 'application/hal+xml'], - xml : ['text/xml', 'application/xml'] + xml : ['text/xml', 'application/xml'], + protobuf : 'application/x-protobuf', ] grails.mime.use.accept.header = true diff --git a/transmart-rest-api/grails-app/controllers/org/transmartproject/rest/QueryController.groovy b/transmart-rest-api/grails-app/controllers/org/transmartproject/rest/QueryController.groovy index 9894ca75b..87adfd885 100644 --- a/transmart-rest-api/grails-app/controllers/org/transmartproject/rest/QueryController.groovy +++ b/transmart-rest-api/grails-app/controllers/org/transmartproject/rest/QueryController.groovy @@ -4,23 +4,17 @@ import grails.converters.JSON import groovy.util.logging.Slf4j import org.grails.web.converters.exceptions.ConverterException import org.springframework.beans.factory.annotation.Autowired -import org.transmartproject.core.dataquery.highdim.HighDimensionDataTypeResource -import org.transmartproject.core.dataquery.highdim.HighDimensionResource -import org.transmartproject.core.dataquery.highdim.dataconstraints.DataConstraint -import org.transmartproject.core.dataquery.highdim.projections.Projection import org.transmartproject.core.exceptions.InvalidArgumentsException import org.transmartproject.core.users.UsersResource -import org.transmartproject.db.clinical.MultidimensionalDataResourceService import org.transmartproject.db.dataquery2.HypercubeImpl -import org.transmartproject.db.dataquery.highdim.DefaultHighDimensionTabularResult import org.transmartproject.db.dataquery.highdim.HighDimensionResourceService -import org.transmartproject.db.dataquery.highdim.mrna.MrnaModule import org.transmartproject.db.dataquery2.QueryService import org.transmartproject.db.dataquery2.query.* import org.transmartproject.db.user.User import org.transmartproject.rest.misc.CurrentUser import org.transmartproject.rest.misc.LazyOutputStreamDecorator import org.transmartproject.rest.protobuf.HighDimBuilder +import org.transmartproject.rest.protobuf.ObservationsSerializer @Slf4j class QueryController { @@ -108,22 +102,38 @@ class QueryController { * @return a hypercube representing the observations that satisfy the constraint. */ def hypercube() { + ObservationsSerializer.Format format = ObservationsSerializer.Format.NONE + withFormat { + json { + format = ObservationsSerializer.Format.JSON + } + protobuf { + format = ObservationsSerializer.Format.PROTOBUF + } + } + if (format == ObservationsSerializer.Format.NONE) { + throw new InvalidArgumentsException("Format not supported.") + } + Constraint constraint = bindConstraint() if (constraint == null) { return } User user = (User) usersResource.getUserFromUsername(currentUser.username) HypercubeImpl result = queryService.retrieveClinicalData(constraint, user) + + log.info "Writing to format: ${format}" OutputStream out = new LazyOutputStreamDecorator( outputStreamProducer: { -> - response.contentType = 'application/json' + response.contentType = format.toString() response.outputStream }) try { - multidimensionalDataSerialisationService.writeData(result, "json", out) + multidimensionalDataSerialisationService.serialise(result, format, out) } finally { out.close() } + return false } /** diff --git a/transmart-rest-api/grails-app/services/org/transmartproject/rest/MultidimensionalDataSerialisationService.groovy b/transmart-rest-api/grails-app/services/org/transmartproject/rest/MultidimensionalDataSerialisationService.groovy index a8cffb434..fa25049fe 100644 --- a/transmart-rest-api/grails-app/services/org/transmartproject/rest/MultidimensionalDataSerialisationService.groovy +++ b/transmart-rest-api/grails-app/services/org/transmartproject/rest/MultidimensionalDataSerialisationService.groovy @@ -8,17 +8,14 @@ import org.transmartproject.rest.protobuf.ObservationsSerializer class MultidimensionalDataSerialisationService { /** - * Serialise hypercube data to out. + * Serialises hypercube data to out. * * @param hypercube the hypercube to serialise. - * @param format the output format. Currently only 'json' is supported. + * @param format the output format. Supports JSON and PROTOBUF. * @param out the stream to serialise to. */ - def writeData(HypercubeImpl hypercube, String format = "json", OutputStream out) { - if (!format.equalsIgnoreCase("json")) { - throw new UnsupportedEncodingException("Serialization format ${format} is not supported") - } - ObservationsSerializer builder = new ObservationsSerializer(hypercube) - builder.writeTo(out, format) + void serialise(HypercubeImpl hypercube, ObservationsSerializer.Format format, OutputStream out) { + ObservationsSerializer builder = new ObservationsSerializer(hypercube, format) + builder.write(out) } } diff --git a/transmart-rest-api/src/main/groovy/org/transmartproject/rest/protobuf/ObservationsSerializer.groovy b/transmart-rest-api/src/main/groovy/org/transmartproject/rest/protobuf/ObservationsSerializer.groovy index 4a2323818..be8ef6d48 100644 --- a/transmart-rest-api/src/main/groovy/org/transmartproject/rest/protobuf/ObservationsSerializer.groovy +++ b/transmart-rest-api/src/main/groovy/org/transmartproject/rest/protobuf/ObservationsSerializer.groovy @@ -1,7 +1,8 @@ package org.transmartproject.rest.protobuf -import com.google.protobuf.util.JsonFormat +import com.google.protobuf.Message import groovy.util.logging.Slf4j +import org.transmartproject.core.exceptions.InvalidArgumentsException import org.transmartproject.db.dataquery2.AssayDimension import org.transmartproject.db.dataquery2.BioMarkerDimension import org.transmartproject.db.dataquery2.DimensionImpl @@ -10,6 +11,7 @@ import org.transmartproject.db.dataquery2.HypercubeValueImpl import org.transmartproject.db.dataquery2.ProjectionDimension import org.transmartproject.db.dataquery2.query.DimensionMetadata +import static com.google.protobuf.util.JsonFormat.* import static org.transmartproject.rest.hypercubeProto.ObservationsProto.* import static org.transmartproject.rest.hypercubeProto.ObservationsProto.FieldDefinition.* @@ -19,14 +21,81 @@ import static org.transmartproject.rest.hypercubeProto.ObservationsProto.FieldDe @Slf4j public class ObservationsSerializer { - HypercubeImpl cube - JsonFormat.Printer jsonPrinter - Map> dimensionElements = [:] - Map> dimensionFields = [:] + enum Format { + JSON('application/json'), + PROTOBUF('application/x-protobuf'), + NONE('none') - ObservationsSerializer(HypercubeImpl cube) { - jsonPrinter = JsonFormat.printer() + private String format + + Format(String format) { + this.format = format + } + + private static final Map mapping = Format.values().collectEntries { + [(it.format): it] + } + + public static Format from(String format) { + if (mapping.containsKey(format)) { + return mapping[format] + } else { + throw new Exception("Unknown format: ${format}") + } + } + + public String toString() { + format + } + } + + protected HypercubeImpl cube + protected Printer jsonPrinter + protected Writer writer + protected Format format + + protected Map> dimensionElements = [:] + protected Map> dimensionFields = [:] + + ObservationsSerializer(HypercubeImpl cube, Format format) { this.cube = cube + if (format == Format.NONE) { + throw new InvalidArgumentsException("No format selected.") + } else if (format == Format.JSON) { + jsonPrinter = printer() + } + this.format = format + } + + protected boolean first = true + + protected void begin(OutputStream out) { + first = true + if (format == Format.JSON) { + writer = new PrintWriter(new BufferedOutputStream(out)) + writer.print('[') + } + } + + protected void writeMessage(OutputStream out, Message message) { + if (format == Format.JSON) { + if (!first) { + writer.print(', ') + } + jsonPrinter.appendTo(message, writer) + } else { + message.writeTo(out) + } + if (first) { + first = false + } + } + + protected void end(OutputStream out) { + if (format == Format.JSON) { + writer.print(']') + writer.flush() + } } static ColumnType getFieldType(Class type) { @@ -46,7 +115,7 @@ public class ObservationsSerializer { } } - def getDimensionsDefs() { + protected getDimensionsDefs() { def dimensionDeclarations = cube.dimensions.collect { dim -> def builder = DimensionDeclaration.newBuilder() String dimensionName = dim.toString() @@ -88,21 +157,21 @@ public class ObservationsSerializer { dimensionDeclarations } - def writeHeader(BufferedWriter out, String format = "json") { - def header = Header.newBuilder().addAllDimensionDeclarations(dimensionsDefs) - jsonPrinter. appendTo(header, out) + protected Header buildHeader() { + Header.newBuilder().addAllDimensionDeclarations(dimensionsDefs).build() } - def writeCells(BufferedWriter out) { + protected void writeCells(OutputStream out) { Iterator it = cube.iterator while (it.hasNext()) { HypercubeValueImpl value = it.next() - Observation observation = createCell(value) - jsonPrinter.appendTo(observation, out) + def cell = createCell(value) + cell.last = !it.hasNext() + writeMessage(out, cell.build()) } } - Observation createCell(HypercubeValueImpl value) { + protected Observation.Builder createCell(HypercubeValueImpl value) { Observation.Builder builder = Observation.newBuilder() if (value.value instanceof Number) { builder.numericValue = value.value @@ -117,7 +186,7 @@ public class ObservationsSerializer { builder.addDimensionIndexes(determineFooterIndex(dim, dimElement)) } } - builder.build() + builder } static buildValue(FieldDefinition field, Object value) { @@ -223,7 +292,7 @@ public class ObservationsSerializer { builder.build() } - private DimensionElement buildSparseCell(DimensionImpl dim, Object dimElement) { + protected DimensionElement buildSparseCell(DimensionImpl dim, Object dimElement) { def builder = DimensionElement.newBuilder() for (FieldDefinition field: dimensionFields[dim]) { builder.addFields(buildValue(field, dimElement.getAt(field.name))) @@ -231,7 +300,7 @@ public class ObservationsSerializer { builder.build() } - def getFooter() { + protected getFooter() { cube.dimensions.findAll({ it.density != DimensionImpl.Density.SPARSE }).collect { dim -> def fields = dimensionFields[dim] ?: [] def elementsBuilder = DimensionElements.newBuilder() @@ -248,12 +317,11 @@ public class ObservationsSerializer { } } - def writeFooter(BufferedWriter out) { - def footer = Footer.newBuilder().addAllDimension(footer) - jsonPrinter.appendTo(footer, out) + protected Footer buildFooter() { + Footer.newBuilder().addAllDimension(footer).build() } - int determineFooterIndex(DimensionImpl dim, Object element) { + protected int determineFooterIndex(DimensionImpl dim, Object element) { if (dimensionElements[dim] == null) { dimensionElements[dim] = [] } @@ -265,14 +333,12 @@ public class ObservationsSerializer { index } - void writeTo(OutputStream out, String format = "json") { - Writer writer = new OutputStreamWriter(out) - BufferedWriter bufferedWriter = new BufferedWriter(writer) - writeHeader(bufferedWriter) - writeCells(bufferedWriter) - writeFooter(bufferedWriter) - bufferedWriter.flush() - bufferedWriter.close() + void write(OutputStream out) { + begin(out) + writeMessage(out, buildHeader()) + writeCells(out) + writeMessage(out, buildFooter()) + end(out) } } diff --git a/transmart-rest-api/src/test/groovy/org/transmartproject/rest/protobug/ObservationsBuilderTests.groovy b/transmart-rest-api/src/test/groovy/org/transmartproject/rest/protobug/ObservationsBuilderTests.groovy index f5397b6d4..f6b137690 100644 --- a/transmart-rest-api/src/test/groovy/org/transmartproject/rest/protobug/ObservationsBuilderTests.groovy +++ b/transmart-rest-api/src/test/groovy/org/transmartproject/rest/protobug/ObservationsBuilderTests.groovy @@ -2,12 +2,12 @@ package org.transmartproject.rest.protobug import grails.test.mixin.integration.Integration import grails.transaction.Rollback +import groovy.json.JsonSlurper import org.junit.Test import org.springframework.beans.factory.annotation.Autowired import org.transmartproject.db.clinical.MultidimensionalDataResourceService import org.transmartproject.db.dataquery.clinical.ClinicalTestData import org.transmartproject.db.dataquery2.DimensionImpl -import org.transmartproject.db.dataquery2.HypercubeValueImpl import org.transmartproject.db.dataquery2.query.Constraint import org.transmartproject.db.dataquery2.query.StudyConstraint import org.transmartproject.db.metadata.DimensionDescription @@ -15,7 +15,7 @@ import org.transmartproject.db.TestData import org.transmartproject.rest.protobuf.ObservationsSerializer import static org.hamcrest.MatcherAssert.assertThat -import static org.hamcrest.core.IsNull.notNullValue +import static org.hamcrest.Matchers.* /** @@ -33,22 +33,48 @@ class ObservationsBuilderTests { MultidimensionalDataResourceService queryResource @Test - public void testSerialization() throws Exception { + public void testJsonSerialization() throws Exception { setupData() Constraint constraint = new StudyConstraint(studyId: clinicalData.longitudinalStudy.studyId) def mockedCube = queryResource.retrieveData('clinical', [clinicalData.longitudinalStudy], constraint: constraint) - def builder = new ObservationsSerializer(mockedCube) - def blob = builder.getDimensionsDefs() - assertThat(blob, notNullValue()) - Iterator it = mockedCube.iterator - def cellMsgs = [] - while (it.hasNext()) { - HypercubeValueImpl value = it.next() - cellMsgs.add(builder.createCell(value)) - } - assertThat(cellMsgs, notNullValue()) - def footer = builder.getFooter() - assertThat(footer, notNullValue()) + def builder = new ObservationsSerializer(mockedCube, ObservationsSerializer.Format.JSON) + + def out = new ByteArrayOutputStream() + builder.write(out) + out.flush() + Collection result = new JsonSlurper().parse(out.toByteArray()) + + assertThat result.size(), is(14) + assertThat result, contains( + hasKey('dimensionDeclarations'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimensionIndexes'), + hasKey('dimension') + ) + } + + @Test + public void testProtobufSerialization() throws Exception { + setupData() + Constraint constraint = new StudyConstraint(studyId: clinicalData.longitudinalStudy.studyId) + def mockedCube = queryResource.retrieveData('clinical', [clinicalData.longitudinalStudy], constraint: constraint) + def builder = new ObservationsSerializer(mockedCube, ObservationsSerializer.Format.PROTOBUF) + + def out = new ByteArrayOutputStream() + builder.write(out) + out.flush() + + assertThat out.toByteArray(), not(empty()) } void setupData() { diff --git a/transmartApp/grails-app/conf/application.groovy b/transmartApp/grails-app/conf/application.groovy index 50f69ec57..9ca3d4c15 100644 --- a/transmartApp/grails-app/conf/application.groovy +++ b/transmartApp/grails-app/conf/application.groovy @@ -51,7 +51,8 @@ grails.mime.types = [html : [ ], form : 'application/x-www-form-urlencoded', multipartForm: 'multipart/form-data', - jnlp : 'application/x-java-jnlp-file' + jnlp : 'application/x-java-jnlp-file', + protobuf : 'application/x-protobuf', ] // The default codec used to encode data with ${} grails.views.javascript.library="jquery"