/** * Copyright 2016 LinkedIn Corp. All rights reserved. * * 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. */ package com.github.ambry.rest; import com.github.ambry.router.AsyncWritableChannel; import com.github.ambry.router.Callback; import io.netty.channel.Channel; import io.netty.handler.codec.http.DefaultHttpContent; import io.netty.handler.codec.http.HttpContent; import io.netty.handler.codec.http.HttpRequest; import io.netty.handler.codec.http.LastHttpContent; import io.netty.handler.codec.http.multipart.DefaultHttpDataFactory; import io.netty.handler.codec.http.multipart.FileUpload; import io.netty.handler.codec.http.multipart.HttpDataFactory; import io.netty.handler.codec.http.multipart.HttpPostMultipartRequestDecoder; import io.netty.handler.codec.http.multipart.HttpPostRequestDecoder; import io.netty.handler.codec.http.multipart.InterfaceHttpData; import io.netty.util.ReferenceCountUtil; import java.nio.ByteBuffer; import java.nio.channels.ClosedChannelException; import java.util.Collections; import java.util.Queue; import java.util.concurrent.Future; import java.util.concurrent.LinkedBlockingQueue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * An extension of {@link NettyRequest} that can handle multipart requests. * <p/> * However, unlike {@link NettyRequest} and because of the limitations of multipart decoding offered by Netty, this does * not allow on-demand streaming. The full content of the request is held in memory and decoded on {@link #prepare()}. * Do not call {@link #prepare()} in I/O bound threads as decoding is a costly operation. * </p> * Multipart decoding also creates copies of the data. This affects latency and increases memory pressure. */ class NettyMultipartRequest extends NettyRequest { private final Queue<HttpContent> rawRequestContents = new LinkedBlockingQueue<>(); private final Logger logger = LoggerFactory.getLogger(getClass()); private boolean readyForRead = false; private boolean hasBlob = false; /** * Wraps the {@code request} in a NettyMultipartRequest so that other layers can understand the request. * @param request the {@link HttpRequest} that needs to be wrapped. * @param channel the {@link Channel} over which the {@code request} has been received. * @param nettyMetrics the {@link NettyMetrics} instance to use. * @throws IllegalArgumentException if {@code request} is null or if the HTTP method defined in {@code request} is * anything other than POST. * @throws RestServiceException if the HTTP method defined in {@code request} is not recognized as a * {@link RestMethod}. */ NettyMultipartRequest(HttpRequest request, Channel channel, NettyMetrics nettyMetrics) throws RestServiceException { super(request, channel, nettyMetrics); // reset auto read state. channel.config().setRecvByteBufAllocator(savedAllocator); setAutoRead(true); if (!getRestMethod().equals(RestMethod.POST) && !getRestMethod().equals(RestMethod.PUT)) { throw new IllegalArgumentException("NettyMultipartRequest cannot be created for " + getRestMethod()); } } @Override public void close() { super.close(); logger.trace("Closing NettyMultipartRequest with {} raw content chunks unread", rawRequestContents.size()); HttpContent content = rawRequestContents.poll(); while (content != null) { ReferenceCountUtil.release(content); content = rawRequestContents.poll(); } } /** * {@inheritDoc} * @param asyncWritableChannel the {@link AsyncWritableChannel} to read the data into. * @param callback the {@link Callback} that will be invoked either when all the data in the channel has been emptied * into the {@code asyncWritableChannel} or if there is an exception in doing so. This can be null. * @return the {@link Future} that will eventually contain the result of the operation. * @throws IllegalStateException if an attempt is made to read the channel before calling {@link #prepare()} or if * this function is called more than once. */ @Override public Future<Long> readInto(AsyncWritableChannel asyncWritableChannel, Callback<Long> callback) { if (callbackWrapper != null) { throw new IllegalStateException("ReadableStreamChannel cannot be read more than once"); } else if (!readyForRead) { throw new IllegalStateException("The channel cannot be read yet"); } callbackWrapper = new ReadIntoCallbackWrapper(callback); if (!isOpen()) { nettyMetrics.multipartRequestAlreadyClosedError.inc(); callbackWrapper.invokeCallback(new ClosedChannelException()); } HttpContent content = requestContents.poll(); while (content != null) { try { writeContent(asyncWritableChannel, callbackWrapper, content); } finally { ReferenceCountUtil.release(content); } content = requestContents.poll(); } return callbackWrapper.futureResult; } /** * Adds content that will be decoded on a call to {@link #prepare()}. * </p> * All content has to be added before {@link #prepare()} can be called. * @param httpContent the {@link HttpContent} that needs to be added. * @throws RestServiceException if request channel has been closed. */ @Override public void addContent(HttpContent httpContent) throws RestServiceException { if (!isOpen()) { nettyMetrics.multipartRequestAlreadyClosedError.inc(); throw new RestServiceException("The request has been closed and is not accepting content", RestServiceErrorCode.RequestChannelClosed); } else { rawRequestContents.add(ReferenceCountUtil.retain(httpContent)); } } /** * {@inheritDoc} * <p/> * Prepares the request for reading by decoding all the content added via {@link #addContent(HttpContent)}. * @throws RestServiceException if request channel is closed or if the request could not be decoded/prepared. */ @Override public void prepare() throws RestServiceException { if (!isOpen()) { nettyMetrics.multipartRequestAlreadyClosedError.inc(); throw new RestServiceException("Request is closed", RestServiceErrorCode.RequestChannelClosed); } else if (!readyForRead) { // make sure data is held in memory. HttpDataFactory httpDataFactory = new DefaultHttpDataFactory(false); HttpPostMultipartRequestDecoder postRequestDecoder = new HttpPostMultipartRequestDecoder(httpDataFactory, request); try { HttpContent httpContent = rawRequestContents.poll(); while (httpContent != null) { try { // if the request is also an instance of HttpContent, the HttpPostMultipartRequestDecoder does the offer // automatically at the time of construction. We should not add it again. if (httpContent != request) { postRequestDecoder.offer(httpContent); } } finally { ReferenceCountUtil.release(httpContent); } httpContent = rawRequestContents.poll(); } for (InterfaceHttpData part : postRequestDecoder.getBodyHttpDatas()) { processPart(part); } allArgsReadOnly = Collections.unmodifiableMap(allArgs); requestContents.add(LastHttpContent.EMPTY_LAST_CONTENT); readyForRead = true; } catch (HttpPostRequestDecoder.ErrorDataDecoderException e) { nettyMetrics.multipartRequestDecodeError.inc(); throw new RestServiceException("There was an error decoding the request", e, RestServiceErrorCode.MalformedRequest); } finally { postRequestDecoder.destroy(); } } } /** * Processes a single decoded part in a multipart request. Exposes the data in the part either through the channel * itself (if it is the blob part) or via {@link #getArgs()}. * @param part the {@link InterfaceHttpData} that needs to be processed. * @throws RestServiceException if the request channel is closed, if there is more than one part of the same name, if * the size obtained from the headers does not match the actual size of the blob part or * if {@code part} is not of the expected type ({@link FileUpload}). */ private void processPart(InterfaceHttpData part) throws RestServiceException { if (part.getHttpDataType() == InterfaceHttpData.HttpDataType.FileUpload) { FileUpload fileUpload = (FileUpload) part; if (fileUpload.getName().equals(RestUtils.MultipartPost.BLOB_PART)) { // this is actual data. if (hasBlob) { nettyMetrics.repeatedPartsError.inc(); throw new RestServiceException("Request has more than one " + RestUtils.MultipartPost.BLOB_PART, RestServiceErrorCode.BadRequest); } else { hasBlob = true; if (getSize() != -1 && fileUpload.length() != getSize()) { nettyMetrics.multipartRequestSizeMismatchError.inc(); throw new RestServiceException( "Request size [" + fileUpload.length() + "] does not match Content-Length [" + getSize() + "]", RestServiceErrorCode.BadRequest); } else { contentLock.lock(); try { if (isOpen()) { requestContents.add(new DefaultHttpContent(ReferenceCountUtil.retain(fileUpload.content()))); } else { nettyMetrics.multipartRequestAlreadyClosedError.inc(); throw new RestServiceException("Request is closed", RestServiceErrorCode.RequestChannelClosed); } } finally { contentLock.unlock(); } } } } else { // this is any kind of data. (For ambry, this will be user metadata). // TODO: find a configurable way of rejecting unexpected file parts. String name = fileUpload.getName(); if (allArgs.containsKey(name)) { nettyMetrics.repeatedPartsError.inc(); throw new RestServiceException("Request already has a component named " + name, RestServiceErrorCode.BadRequest); } else { ByteBuffer buffer = ByteBuffer.allocate(fileUpload.content().readableBytes()); // TODO: Possible optimization - Upgrade ByteBufferReadableStreamChannel to take a list of ByteBuffer. This // TODO: will avoid the copy. fileUpload.content().readBytes(buffer); buffer.flip(); allArgs.put(name, buffer); } } } else { nettyMetrics.unsupportedPartError.inc(); throw new RestServiceException("Unexpected HTTP data", RestServiceErrorCode.BadRequest); } } }