001/*
002 * Copyright 2002-2016 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.InputStreamReader;
021import java.io.OutputStreamWriter;
022import java.lang.reflect.Method;
023import java.nio.charset.Charset;
024import java.util.concurrent.ConcurrentHashMap;
025
026import com.google.protobuf.ExtensionRegistry;
027import com.google.protobuf.Message;
028import com.google.protobuf.TextFormat;
029import com.googlecode.protobuf.format.HtmlFormat;
030import com.googlecode.protobuf.format.JsonFormat;
031import com.googlecode.protobuf.format.ProtobufFormatter;
032import com.googlecode.protobuf.format.XmlFormat;
033
034import org.springframework.http.HttpInputMessage;
035import org.springframework.http.HttpOutputMessage;
036import org.springframework.http.MediaType;
037import org.springframework.http.converter.AbstractHttpMessageConverter;
038import org.springframework.http.converter.HttpMessageNotReadableException;
039import org.springframework.http.converter.HttpMessageNotWritableException;
040import org.springframework.util.FileCopyUtils;
041
042/**
043 * An {@code HttpMessageConverter} that reads and writes {@link com.google.protobuf.Message}s
044 * using <a href="https://developers.google.com/protocol-buffers/">Google Protocol Buffers</a>.
045 *
046 * <p>By default, it supports {@code "application/x-protobuf"}, {@code "text/plain"},
047 * {@code "application/json"}, {@code "application/xml"}, while also writing {@code "text/html"}.
048 *
049 * <p>To generate {@code Message} Java classes, you need to install the {@code protoc} binary.
050 *
051 * <p>Requires Protobuf 2.6 and Protobuf Java Format 1.4, as of Spring 4.3.
052 *
053 * @author Alex Antonov
054 * @author Brian Clozel
055 * @author Juergen Hoeller
056 * @since 4.1
057 */
058public class ProtobufHttpMessageConverter extends AbstractHttpMessageConverter<Message> {
059
060        public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");
061
062        public static final MediaType PROTOBUF = new MediaType("application", "x-protobuf", DEFAULT_CHARSET);
063
064        public static final String X_PROTOBUF_SCHEMA_HEADER = "X-Protobuf-Schema";
065
066        public static final String X_PROTOBUF_MESSAGE_HEADER = "X-Protobuf-Message";
067
068
069        private static final ProtobufFormatter JSON_FORMAT = new JsonFormat();
070
071        private static final ProtobufFormatter XML_FORMAT = new XmlFormat();
072
073        private static final ProtobufFormatter HTML_FORMAT = new HtmlFormat();
074
075
076        private static final ConcurrentHashMap<Class<?>, Method> methodCache = new ConcurrentHashMap<Class<?>, Method>();
077
078        private final ExtensionRegistry extensionRegistry = ExtensionRegistry.newInstance();
079
080
081        /**
082         * Construct a new instance.
083         */
084        public ProtobufHttpMessageConverter() {
085                this(null);
086        }
087
088        /**
089         * Construct a new instance with an {@link ExtensionRegistryInitializer}
090         * that allows the registration of message extensions.
091         */
092        public ProtobufHttpMessageConverter(ExtensionRegistryInitializer registryInitializer) {
093                super(PROTOBUF, MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON, MediaType.APPLICATION_XML);
094                if (registryInitializer != null) {
095                        registryInitializer.initializeExtensionRegistry(this.extensionRegistry);
096                }
097        }
098
099
100        @Override
101        protected boolean supports(Class<?> clazz) {
102                return Message.class.isAssignableFrom(clazz);
103        }
104
105        @Override
106        protected MediaType getDefaultContentType(Message message) {
107                return PROTOBUF;
108        }
109
110        @Override
111        protected Message readInternal(Class<? extends Message> clazz, HttpInputMessage inputMessage)
112                        throws IOException, HttpMessageNotReadableException {
113
114                MediaType contentType = inputMessage.getHeaders().getContentType();
115                if (contentType == null) {
116                        contentType = PROTOBUF;
117                }
118                Charset charset = contentType.getCharset();
119                if (charset == null) {
120                        charset = DEFAULT_CHARSET;
121                }
122
123                try {
124                        Message.Builder builder = getMessageBuilder(clazz);
125                        if (MediaType.TEXT_PLAIN.isCompatibleWith(contentType)) {
126                                InputStreamReader reader = new InputStreamReader(inputMessage.getBody(), charset);
127                                TextFormat.merge(reader, this.extensionRegistry, builder);
128                        }
129                        else if (MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
130                                JSON_FORMAT.merge(inputMessage.getBody(), charset, this.extensionRegistry, builder);
131                        }
132                        else if (MediaType.APPLICATION_XML.isCompatibleWith(contentType)) {
133                                XML_FORMAT.merge(inputMessage.getBody(), charset, this.extensionRegistry, builder);
134                        }
135                        else {
136                                builder.mergeFrom(inputMessage.getBody(), this.extensionRegistry);
137                        }
138                        return builder.build();
139                }
140                catch (Exception ex) {
141                        throw new HttpMessageNotReadableException("Could not read Protobuf message: " + ex.getMessage(), ex);
142                }
143        }
144
145        /**
146         * This method overrides the parent implementation, since this HttpMessageConverter
147         * can also produce {@code MediaType.HTML "text/html"} ContentType.
148         */
149        @Override
150        protected boolean canWrite(MediaType mediaType) {
151                return (super.canWrite(mediaType) || MediaType.TEXT_HTML.isCompatibleWith(mediaType));
152        }
153
154        @Override
155        protected void writeInternal(Message message, HttpOutputMessage outputMessage)
156                        throws IOException, HttpMessageNotWritableException {
157
158                MediaType contentType = outputMessage.getHeaders().getContentType();
159                if (contentType == null) {
160                        contentType = getDefaultContentType(message);
161                }
162                Charset charset = contentType.getCharset();
163                if (charset == null) {
164                        charset = DEFAULT_CHARSET;
165                }
166
167                if (MediaType.TEXT_PLAIN.isCompatibleWith(contentType)) {
168                        OutputStreamWriter outputStreamWriter = new OutputStreamWriter(outputMessage.getBody(), charset);
169                        TextFormat.print(message, outputStreamWriter);
170                        outputStreamWriter.flush();
171                }
172                else if (MediaType.APPLICATION_JSON.isCompatibleWith(contentType)) {
173                        JSON_FORMAT.print(message, outputMessage.getBody(), charset);
174                }
175                else if (MediaType.APPLICATION_XML.isCompatibleWith(contentType)) {
176                        XML_FORMAT.print(message, outputMessage.getBody(), charset);
177                }
178                else if (MediaType.TEXT_HTML.isCompatibleWith(contentType)) {
179                        HTML_FORMAT.print(message, outputMessage.getBody(), charset);
180                }
181                else if (PROTOBUF.isCompatibleWith(contentType)) {
182                        setProtoHeader(outputMessage, message);
183                        FileCopyUtils.copy(message.toByteArray(), outputMessage.getBody());
184                }
185        }
186
187        /**
188         * Set the "X-Protobuf-*" HTTP headers when responding with a message of
189         * content type "application/x-protobuf"
190         * <p><b>Note:</b> <code>outputMessage.getBody()</code> should not have been called
191         * before because it writes HTTP headers (making them read only).</p>
192         */
193        private void setProtoHeader(HttpOutputMessage response, Message message) {
194                response.getHeaders().set(X_PROTOBUF_SCHEMA_HEADER, message.getDescriptorForType().getFile().getName());
195                response.getHeaders().set(X_PROTOBUF_MESSAGE_HEADER, message.getDescriptorForType().getFullName());
196        }
197
198
199        /**
200         * Create a new {@code Message.Builder} instance for the given class.
201         * <p>This method uses a ConcurrentHashMap for caching method lookups.
202         */
203        private static Message.Builder getMessageBuilder(Class<? extends Message> clazz) throws Exception {
204                Method method = methodCache.get(clazz);
205                if (method == null) {
206                        method = clazz.getMethod("newBuilder");
207                        methodCache.put(clazz, method);
208                }
209                return (Message.Builder) method.invoke(clazz);
210        }
211
212}