001/*
002 * Copyright 2002-2019 the original author or authors.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *      https://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package org.springframework.http.converter.protobuf;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.InputStreamReader;
022import java.io.OutputStream;
023import java.io.OutputStreamWriter;
024import java.lang.reflect.Method;
025import java.nio.charset.Charset;
026import java.nio.charset.StandardCharsets;
027import java.util.Arrays;
028import java.util.Map;
029
030import com.google.protobuf.CodedOutputStream;
031import com.google.protobuf.ExtensionRegistry;
032import com.google.protobuf.Message;
033import com.google.protobuf.TextFormat;
034import com.google.protobuf.util.JsonFormat;
035import com.googlecode.protobuf.format.FormatFactory;
036import com.googlecode.protobuf.format.ProtobufFormatter;
037
038import org.springframework.http.HttpInputMessage;
039import org.springframework.http.HttpOutputMessage;
040import org.springframework.http.MediaType;
041import org.springframework.http.converter.AbstractHttpMessageConverter;
042import org.springframework.http.converter.HttpMessageConversionException;
043import org.springframework.http.converter.HttpMessageNotReadableException;
044import org.springframework.http.converter.HttpMessageNotWritableException;
045import org.springframework.lang.Nullable;
046import org.springframework.util.Assert;
047import org.springframework.util.ClassUtils;
048import org.springframework.util.ConcurrentReferenceHashMap;
049
050import static org.springframework.http.MediaType.APPLICATION_JSON;
051import static org.springframework.http.MediaType.APPLICATION_XML;
052import static org.springframework.http.MediaType.TEXT_HTML;
053import static org.springframework.http.MediaType.TEXT_PLAIN;
054
055/**
056 * An {@code HttpMessageConverter} that reads and writes
057 * {@link com.google.protobuf.Message com.google.protobuf.Messages} using
058 * <a href="https://developers.google.com/protocol-buffers/">Google Protocol Buffers</a>.
059 *
060 * <p>To generate {@code Message} Java classes, you need to install the {@code protoc} binary.
061 *
062 * <p>This converter supports by default {@code "application/x-protobuf"} and {@code "text/plain"}
063 * with the official {@code "com.google.protobuf:protobuf-java"} library. Other formats can be
064 * supported with one of the following additional libraries on the classpath:
065 * <ul>
066 * <li>{@code "application/json"}, {@code "application/xml"}, and {@code "text/html"} (write-only)
067 * with the {@code "com.googlecode.protobuf-java-format:protobuf-java-format"} third-party library
068 * <li>{@code "application/json"} with the official {@code "com.google.protobuf:protobuf-java-util"}
069 * for Protobuf 3 (see {@link ProtobufJsonFormatHttpMessageConverter} for a configurable variant)
070 * </ul>
071 *
072 * <p>Requires Protobuf 2.6 or higher (and Protobuf Java Format 1.4 or higher for formatting).
073 * This converter will auto-adapt to Protobuf 3 and its default {@code protobuf-java-util} JSON
074 * format if the Protobuf 2 based {@code protobuf-java-format} isn't present; however, for more
075 * explicit JSON setup on Protobuf 3, consider {@link ProtobufJsonFormatHttpMessageConverter}.
076 *
077 * @author Alex Antonov
078 * @author Brian Clozel
079 * @author Juergen Hoeller
080 * @author Sebastien Deleuze
081 * @since 4.1
082 * @see FormatFactory
083 * @see JsonFormat
084 * @see ProtobufJsonFormatHttpMessageConverter
085 */
086public class ProtobufHttpMessageConverter extends AbstractHttpMessageConverter<Message> {
087
088        /**
089         * The default charset used by the converter.
090         */
091        public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
092
093        /**
094         * The media-type for protobuf {@code application/x-protobuf}.
095         */
096        public static final MediaType PROTOBUF = new MediaType("application", "x-protobuf", DEFAULT_CHARSET);
097
098        /**
099         * The HTTP header containing the protobuf schema.
100         */
101        public static final String X_PROTOBUF_SCHEMA_HEADER = "X-Protobuf-Schema";
102
103        /**
104         * The HTTP header containing the protobuf message.
105         */
106        public static final String X_PROTOBUF_MESSAGE_HEADER = "X-Protobuf-Message";
107
108
109        private static final Map<Class<?>, Method> methodCache = new ConcurrentReferenceHashMap<>();
110
111        final ExtensionRegistry extensionRegistry;
112
113        @Nullable
114        private final ProtobufFormatSupport protobufFormatSupport;
115
116
117        /**
118         * Construct a new {@code ProtobufHttpMessageConverter}.
119         */
120        public ProtobufHttpMessageConverter() {
121                this(null, null);
122        }
123
124        /**
125         * Construct a new {@code ProtobufHttpMessageConverter} with an
126         * initializer that allows the registration of message extensions.
127         * @param registryInitializer an initializer for message extensions
128         * @deprecated as of Spring Framework 5.1, use {@link #ProtobufHttpMessageConverter(ExtensionRegistry)} instead
129         */
130        @Deprecated
131        public ProtobufHttpMessageConverter(@Nullable ExtensionRegistryInitializer registryInitializer) {
132                this(null, null);
133                if (registryInitializer != null) {
134                        registryInitializer.initializeExtensionRegistry(this.extensionRegistry);
135                }
136        }
137
138        /**
139         * Construct a new {@code ProtobufHttpMessageConverter} with a registry that specifies
140         * protocol message extensions.
141         * @param extensionRegistry the registry to populate
142         */
143        public ProtobufHttpMessageConverter(ExtensionRegistry extensionRegistry) {
144                this(null, extensionRegistry);
145        }
146
147        ProtobufHttpMessageConverter(@Nullable ProtobufFormatSupport formatSupport,
148                        @Nullable ExtensionRegistry extensionRegistry) {
149
150                if (formatSupport != null) {
151                        this.protobufFormatSupport = formatSupport;
152                }
153                else if (ClassUtils.isPresent("com.googlecode.protobuf.format.FormatFactory", getClass().getClassLoader())) {
154                        this.protobufFormatSupport = new ProtobufJavaFormatSupport();
155                }
156                else if (ClassUtils.isPresent("com.google.protobuf.util.JsonFormat", getClass().getClassLoader())) {
157                        this.protobufFormatSupport = new ProtobufJavaUtilSupport(null, null);
158                }
159                else {
160                        this.protobufFormatSupport = null;
161                }
162
163                setSupportedMediaTypes(Arrays.asList(this.protobufFormatSupport != null ?
164                                this.protobufFormatSupport.supportedMediaTypes() : new MediaType[] {PROTOBUF, TEXT_PLAIN}));
165
166                this.extensionRegistry = (extensionRegistry == null ? ExtensionRegistry.newInstance() : extensionRegistry);
167        }
168
169
170        @Override
171        protected boolean supports(Class<?> clazz) {
172                return Message.class.isAssignableFrom(clazz);
173        }
174
175        @Override
176        protected MediaType getDefaultContentType(Message message) {
177                return PROTOBUF;
178        }
179
180        @Override
181        protected Message readInternal(Class<? extends Message> clazz, HttpInputMessage inputMessage)
182                        throws IOException, HttpMessageNotReadableException {
183
184                MediaType contentType = inputMessage.getHeaders().getContentType();
185                if (contentType == null) {
186                        contentType = PROTOBUF;
187                }
188                Charset charset = contentType.getCharset();
189                if (charset == null) {
190                        charset = DEFAULT_CHARSET;
191                }
192
193                Message.Builder builder = getMessageBuilder(clazz);
194                if (PROTOBUF.isCompatibleWith(contentType)) {
195                        builder.mergeFrom(inputMessage.getBody(), this.extensionRegistry);
196                }
197                else if (TEXT_PLAIN.isCompatibleWith(contentType)) {
198                        InputStreamReader reader = new InputStreamReader(inputMessage.getBody(), charset);
199                        TextFormat.merge(reader, this.extensionRegistry, builder);
200                }
201                else if (this.protobufFormatSupport != null) {
202                        this.protobufFormatSupport.merge(
203                                        inputMessage.getBody(), charset, contentType, this.extensionRegistry, builder);
204                }
205                return builder.build();
206        }
207
208        /**
209         * Create a new {@code Message.Builder} instance for the given class.
210         * <p>This method uses a ConcurrentReferenceHashMap for caching method lookups.
211         */
212        private Message.Builder getMessageBuilder(Class<? extends Message> clazz) {
213                try {
214                        Method method = methodCache.get(clazz);
215                        if (method == null) {
216                                method = clazz.getMethod("newBuilder");
217                                methodCache.put(clazz, method);
218                        }
219                        return (Message.Builder) method.invoke(clazz);
220                }
221                catch (Exception ex) {
222                        throw new HttpMessageConversionException(
223                                        "Invalid Protobuf Message type: no invocable newBuilder() method on " + clazz, ex);
224                }
225        }
226
227
228        @Override
229        protected boolean canWrite(@Nullable MediaType mediaType) {
230                return (super.canWrite(mediaType) ||
231                                (this.protobufFormatSupport != null && this.protobufFormatSupport.supportsWriteOnly(mediaType)));
232        }
233
234        @SuppressWarnings("deprecation")
235        @Override
236        protected void writeInternal(Message message, HttpOutputMessage outputMessage)
237                        throws IOException, HttpMessageNotWritableException {
238
239                MediaType contentType = outputMessage.getHeaders().getContentType();
240                if (contentType == null) {
241                        contentType = getDefaultContentType(message);
242                        Assert.state(contentType != null, "No content type");
243                }
244                Charset charset = contentType.getCharset();
245                if (charset == null) {
246                        charset = DEFAULT_CHARSET;
247                }
248
249                if (PROTOBUF.isCompatibleWith(contentType)) {
250                        setProtoHeader(outputMessage, message);
251                        CodedOutputStream codedOutputStream = CodedOutputStream.newInstance(outputMessage.getBody());
252                        message.writeTo(codedOutputStream);
253                        codedOutputStream.flush();
254                }
255                else if (TEXT_PLAIN.isCompatibleWith(contentType)) {
256                        OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputMessage.getBody(), charset);
257                        TextFormat.print(message, outputStreamWriter);  // deprecated on Protobuf 3.9
258                        outputStreamWriter.flush();
259                        outputMessage.getBody().flush();
260                }
261                else if (this.protobufFormatSupport != null) {
262                        this.protobufFormatSupport.print(message, outputMessage.getBody(), contentType, charset);
263                        outputMessage.getBody().flush();
264                }
265        }
266
267        /**
268         * Set the "X-Protobuf-*" HTTP headers when responding with a message of
269         * content type "application/x-protobuf"
270         * <p><b>Note:</b> <code>outputMessage.getBody()</code> should not have been called
271         * before because it writes HTTP headers (making them read only).</p>
272         */
273        private void setProtoHeader(HttpOutputMessage response, Message message) {
274                response.getHeaders().set(X_PROTOBUF_SCHEMA_HEADER, message.getDescriptorForType().getFile().getName());
275                response.getHeaders().set(X_PROTOBUF_MESSAGE_HEADER, message.getDescriptorForType().getFullName());
276        }
277
278
279        /**
280         * Protobuf format support.
281         */
282        interface ProtobufFormatSupport {
283
284                MediaType[] supportedMediaTypes();
285
286                boolean supportsWriteOnly(@Nullable MediaType mediaType);
287
288                void merge(InputStream input, Charset charset, MediaType contentType,
289                                ExtensionRegistry extensionRegistry, Message.Builder builder)
290                                throws IOException, HttpMessageConversionException;
291
292                void print(Message message, OutputStream output, MediaType contentType, Charset charset)
293                                throws IOException, HttpMessageConversionException;
294        }
295
296
297        /**
298         * {@link ProtobufFormatSupport} implementation used when
299         * {@code com.googlecode.protobuf.format.FormatFactory} is available.
300         */
301        static class ProtobufJavaFormatSupport implements ProtobufFormatSupport {
302
303                private final ProtobufFormatter jsonFormatter;
304
305                private final ProtobufFormatter xmlFormatter;
306
307                private final ProtobufFormatter htmlFormatter;
308
309                public ProtobufJavaFormatSupport() {
310                        FormatFactory formatFactory = new FormatFactory();
311                        this.jsonFormatter = formatFactory.createFormatter(FormatFactory.Formatter.JSON);
312                        this.xmlFormatter = formatFactory.createFormatter(FormatFactory.Formatter.XML);
313                        this.htmlFormatter = formatFactory.createFormatter(FormatFactory.Formatter.HTML);
314                }
315
316                @Override
317                public MediaType[] supportedMediaTypes() {
318                        return new MediaType[] {PROTOBUF, TEXT_PLAIN, APPLICATION_XML, APPLICATION_JSON};
319                }
320
321                @Override
322                public boolean supportsWriteOnly(@Nullable MediaType mediaType) {
323                        return TEXT_HTML.isCompatibleWith(mediaType);
324                }
325
326                @Override
327                public void merge(InputStream input, Charset charset, MediaType contentType,
328                                ExtensionRegistry extensionRegistry, Message.Builder builder)
329                                throws IOException, HttpMessageConversionException {
330
331                        if (contentType.isCompatibleWith(APPLICATION_JSON)) {
332                                this.jsonFormatter.merge(input, charset, extensionRegistry, builder);
333                        }
334                        else if (contentType.isCompatibleWith(APPLICATION_XML)) {
335                                this.xmlFormatter.merge(input, charset, extensionRegistry, builder);
336                        }
337                        else {
338                                throw new HttpMessageConversionException(
339                                                "protobuf-java-format does not support parsing " + contentType);
340                        }
341                }
342
343                @Override
344                public void print(Message message, OutputStream output, MediaType contentType, Charset charset)
345                                throws IOException, HttpMessageConversionException {
346
347                        if (contentType.isCompatibleWith(APPLICATION_JSON)) {
348                                this.jsonFormatter.print(message, output, charset);
349                        }
350                        else if (contentType.isCompatibleWith(APPLICATION_XML)) {
351                                this.xmlFormatter.print(message, output, charset);
352                        }
353                        else if (contentType.isCompatibleWith(TEXT_HTML)) {
354                                this.htmlFormatter.print(message, output, charset);
355                        }
356                        else {
357                                throw new HttpMessageConversionException(
358                                                "protobuf-java-format does not support printing " + contentType);
359                        }
360                }
361        }
362
363
364        /**
365         * {@link ProtobufFormatSupport} implementation used when
366         * {@code com.google.protobuf.util.JsonFormat} is available.
367         */
368        static class ProtobufJavaUtilSupport implements ProtobufFormatSupport {
369
370                private final JsonFormat.Parser parser;
371
372                private final JsonFormat.Printer printer;
373
374                public ProtobufJavaUtilSupport(@Nullable JsonFormat.Parser parser, @Nullable JsonFormat.Printer printer) {
375                        this.parser = (parser != null ? parser : JsonFormat.parser());
376                        this.printer = (printer != null ? printer : JsonFormat.printer());
377                }
378
379                @Override
380                public MediaType[] supportedMediaTypes() {
381                        return new MediaType[] {PROTOBUF, TEXT_PLAIN, APPLICATION_JSON};
382                }
383
384                @Override
385                public boolean supportsWriteOnly(@Nullable MediaType mediaType) {
386                        return false;
387                }
388
389                @Override
390                public void merge(InputStream input, Charset charset, MediaType contentType,
391                                ExtensionRegistry extensionRegistry, Message.Builder builder)
392                                throws IOException, HttpMessageConversionException {
393
394                        if (contentType.isCompatibleWith(APPLICATION_JSON)) {
395                                InputStreamReader reader = new InputStreamReader(input, charset);
396                                this.parser.merge(reader, builder);
397                        }
398                        else {
399                                throw new HttpMessageConversionException(
400                                                "protobuf-java-util does not support parsing " + contentType);
401                        }
402                }
403
404                @Override
405                public void print(Message message, OutputStream output, MediaType contentType, Charset charset)
406                                throws IOException, HttpMessageConversionException {
407
408                        if (contentType.isCompatibleWith(APPLICATION_JSON)) {
409                                OutputStreamWriter writer = new OutputStreamWriter(output, charset);
410                                this.printer.appendTo(message, writer);
411                                writer.flush();
412                        }
413                        else {
414                                throw new HttpMessageConversionException(
415                                                "protobuf-java-util does not support printing " + contentType);
416                        }
417                }
418        }
419
420}