/* * Copyright 2015 Netflix, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ package io.reactivex.netty.protocol.http.sse.client; import io.netty.buffer.ByteBuf; import io.netty.channel.ChannelHandlerContext; import io.netty.handler.codec.MessageToMessageDecoder; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.LastHttpContent; import io.netty.util.ByteProcessor; import io.reactivex.netty.protocol.http.sse.ServerSentEvent; import io.reactivex.netty.protocol.http.sse.ServerSentEvent.Type; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.charset.Charset; import java.util.List; /** * A decoder to decode <a href="http://www.w3.org/TR/eventsource/">Server sent events</a> into {@link ServerSentEvent} */ public class ServerSentEventDecoder extends MessageToMessageDecoder<HttpContent> { private static final Logger logger = LoggerFactory.getLogger(ServerSentEventDecoder.class); public static final int DEFAULT_MAX_FIELD_LENGTH = 100; private static final char[] EVENT_ID_FIELD_NAME = "event".toCharArray(); private static final char[] DATA_FIELD_NAME = "data".toCharArray(); private static final char[] ID_FIELD_NAME = "id".toCharArray(); protected static final ByteProcessor SKIP_TILL_LINE_DELIMITER_PROCESSOR = new ByteProcessor() { @Override public boolean process(byte value) throws Exception { return !isLineDelimiter((char) value); } }; protected static final ByteProcessor SKIP_LINE_DELIMITERS_AND_SPACES_PROCESSOR = new ByteProcessor() { @Override public boolean process(byte value) throws Exception { return isLineDelimiter((char) value) || (char) value == ' '; } }; protected static final ByteProcessor SKIP_COLON_AND_WHITE_SPACE_PROCESSOR = new ByteProcessor() { @Override public boolean process(byte value) throws Exception { char valueChar = (char) value; return valueChar == ':' || valueChar == ' '; } }; protected static final ByteProcessor SCAN_COLON_PROCESSOR = new ByteProcessor() { @Override public boolean process(byte value) throws Exception { return (char) value != ':'; } }; protected static final ByteProcessor SCAN_EOL_PROCESSOR = new ByteProcessor() { @Override public boolean process(byte value) throws Exception { return !isLineDelimiter((char) value); } }; private static Charset sseEncodingCharset; static { try { sseEncodingCharset = Charset.forName("UTF-8"); } catch (Exception e) { logger.error("UTF-8 charset not available. Since SSE only contains UTF-8 data, we can not read SSE data."); sseEncodingCharset = null; } } private enum State { SkipColonAndWhiteSpaces,// Skip colon and all whitespaces after reading field name. SkipLineDelimitersAndSpaces,// Skip all line delimiters after field value end. DiscardTillEOL,// On recieving an illegal/unidentified field, ignore everything till EOL. ReadFieldName, // Read till a colon to get the name of the field. ReadFieldValue // Read value till the line delimiter. } /** * Release of these buffers happens in the following ways: * * 1) If this was a data buffer, it is released when ServerSentEvent is released. * 2) If this was an eventId buffer, it is released when next Id arrives or when the connection * is closed. * 3) If this was an eventType buffer, it is released when next type arrives or when the connection * is closed. */ private ByteBuf lastEventId; private ByteBuf lastEventType; private ByteBuf incompleteData; // Can be field value of name, according to the current state. private Type currentFieldType; private State state = State.ReadFieldName; @Override protected void decode(ChannelHandlerContext ctx, HttpContent httpContent, List<Object> out) throws Exception { final ByteBuf in = httpContent.content(); if (null == sseEncodingCharset) { throw new IllegalArgumentException("Can not read SSE data as UTF-8 charset is not available."); } while (in.isReadable()) { final int readerIndexAtStart = in.readerIndex(); switch (state) { case SkipColonAndWhiteSpaces: if (skipColonAndWhiteSpaces(in)) { state = State.ReadFieldValue; } break; case SkipLineDelimitersAndSpaces: if (skipLineDelimiters(in)) { state = State.ReadFieldName; } break; case DiscardTillEOL: if(skipTillEOL(in)) { state = State.SkipLineDelimitersAndSpaces; } break; case ReadFieldName: final int indexOfColon = scanAndFindColon(in); if (-1 == indexOfColon) { // No colon found // Accumulate data into the field name buffer. if (null == incompleteData) { incompleteData = ctx.alloc().buffer(); } // accumulate into incomplete data buffer to be used when the full data arrives. incompleteData.writeBytes(in); } else { int fieldNameLengthInTheCurrentBuffer = indexOfColon - readerIndexAtStart; ByteBuf fieldNameBuffer; if (null != incompleteData) { // Read the remaining data into the temporary buffer in.readBytes(incompleteData, fieldNameLengthInTheCurrentBuffer); fieldNameBuffer = incompleteData; incompleteData = null; } else { // Consume the data from the input buffer. fieldNameBuffer = ctx.alloc().buffer(fieldNameLengthInTheCurrentBuffer, fieldNameLengthInTheCurrentBuffer); in.readBytes(fieldNameBuffer, fieldNameLengthInTheCurrentBuffer); } state = State.SkipColonAndWhiteSpaces; // We have read the field name, next we should skip colon & WS. try { currentFieldType = readCurrentFieldTypeFromBuffer(fieldNameBuffer); } finally { if (null == currentFieldType) { state = State.DiscardTillEOL; // Ignore this event completely. } fieldNameBuffer.release(); } } break; case ReadFieldValue: final int endOfLineStartIndex = scanAndFindEndOfLine(in); if (-1 == endOfLineStartIndex) { // End of line not found, accumulate data into a temporary buffer. if (null == incompleteData) { incompleteData = ctx.alloc().buffer(in.readableBytes()); } // accumulate into incomplete data buffer to be used when the full data arrives. incompleteData.writeBytes(in); } else { // Read the data till end of line into the value buffer. final int bytesAvailableInThisIteration = endOfLineStartIndex - readerIndexAtStart; if (null == incompleteData) { incompleteData = ctx.alloc().buffer(bytesAvailableInThisIteration, bytesAvailableInThisIteration); } incompleteData.writeBytes(in, bytesAvailableInThisIteration); switch (currentFieldType) { case Data: if (incompleteData.isReadable()) { out.add(ServerSentEvent.withEventIdAndType(lastEventId, lastEventType, incompleteData)); } else { incompleteData.release(); } break; case Id: if (incompleteData.isReadable()) { lastEventId = incompleteData; } else { incompleteData.release(); lastEventId = null; } break; case EventType: if (incompleteData.isReadable()) { lastEventType = incompleteData; } else { incompleteData.release(); lastEventType = null; } break; } /* * Since all data is read, reset the incomplete data to null. Release of this buffer happens in * the following ways * 1) If this was a data buffer, it is released when ServerSentEvent is released. * 2) If this was an eventId buffer, it is released when next Id arrives or when the connection * is closed. * 3) If this was an eventType buffer, it is released when next type arrives or when the connection * is closed. */ incompleteData = null; state = State.SkipLineDelimitersAndSpaces; // Skip line delimiters after reading a field value completely. } break; } } if (httpContent instanceof LastHttpContent) { ctx.fireChannelRead(httpContent); // Since the content is already consumed above (by the SSEDecoder), this is just // as sending just trailing headers. This is critical to mark the end of stream. } } private static ServerSentEvent.Type readCurrentFieldTypeFromBuffer(final ByteBuf fieldNameBuffer) { /* * This code tries to eliminate the need of creating a string from the ByteBuf as the field names are very * constrained. The algorithm is as follows: * * -- Scan the bytes in the buffer. * -- Ignore an leading whitespaces * -- If the first byte matches the expected field names then use the matching field name char array to verify * the rest of the field name. * -- If the first byte does not match, reject the field name. * -- After the first byte, exact match the rest of the field name with the expected field name, byte by byte. * -- If the name does not exactly match the expected value, then reject the field name. */ ServerSentEvent.Type toReturn = ServerSentEvent.Type.Data; skipLineDelimiters(fieldNameBuffer); int readableBytes = fieldNameBuffer.readableBytes(); final int readerIndexAtStart = fieldNameBuffer.readerIndex(); char[] fieldNameToVerify = DATA_FIELD_NAME; boolean verified = false; int actualFieldNameIndexToCheck = 0; // Starts with 1 as the first char is validated by equality. for (int i = readerIndexAtStart; i < readerIndexAtStart + readableBytes; i++) { final char charAtI = (char) fieldNameBuffer.getByte(i); if (i == readerIndexAtStart) { switch (charAtI) { // See which among the known field names this buffer belongs. case 'e': fieldNameToVerify = EVENT_ID_FIELD_NAME; toReturn = ServerSentEvent.Type.EventType; break; case 'd': fieldNameToVerify = DATA_FIELD_NAME; toReturn = ServerSentEvent.Type.Data; break; case 'i': fieldNameToVerify = ID_FIELD_NAME; toReturn = ServerSentEvent.Type.Id; break; default: return null; } } else { if (++actualFieldNameIndexToCheck >= fieldNameToVerify.length || charAtI != fieldNameToVerify[actualFieldNameIndexToCheck]) { // If the character does not match or the buffer is bigger than the expected name, then discard. verified = false; break; } else { // Verified till now. If all characters are matching then this stays as verified, else changed to false. verified = true; } } } if (verified) { return toReturn; } else { return null; } } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { super.channelInactive(ctx); if (null != lastEventId) { lastEventId.release(); } if (null != lastEventType) { lastEventType.release(); } if (null != incompleteData) { incompleteData.release(); } } protected static int scanAndFindColon(ByteBuf byteBuf) { return byteBuf.forEachByte(SCAN_COLON_PROCESSOR); } protected static int scanAndFindEndOfLine(ByteBuf byteBuf) { return byteBuf.forEachByte(SCAN_EOL_PROCESSOR); } protected static boolean skipLineDelimiters(ByteBuf byteBuf) { return skipTillMatching(byteBuf, SKIP_LINE_DELIMITERS_AND_SPACES_PROCESSOR); } protected static boolean skipColonAndWhiteSpaces(ByteBuf byteBuf) { return skipTillMatching(byteBuf, SKIP_COLON_AND_WHITE_SPACE_PROCESSOR); } private static boolean skipTillEOL(ByteBuf in) { return skipTillMatching(in, SKIP_TILL_LINE_DELIMITER_PROCESSOR); } protected static boolean skipTillMatching(ByteBuf byteBuf, ByteProcessor processor) { final int lastIndexProcessed = byteBuf.forEachByte(processor); if (-1 == lastIndexProcessed) { byteBuf.readerIndex(byteBuf.readerIndex() + byteBuf.readableBytes()); // If all the remaining bytes are to be ignored, discard the buffer. } else { byteBuf.readerIndex(lastIndexProcessed); } return -1 != lastIndexProcessed; } protected static boolean isLineDelimiter(char c) { return c == '\r' || c == '\n'; } }