//
// ========================================================================
// Copyright (c) 1995-2020 Mort Bay Consulting Pty Ltd and others.
//
// This program and the accompanying materials are made available under
// the terms of the Eclipse Public License 2.0 which is available at
// https://www.eclipse.org/legal/epl-2.0
//
// This Source Code may also be made available under the following
// Secondary Licenses when the conditions for such availability set
// forth in the Eclipse Public License, v. 2.0 are satisfied:
// the Apache License v2.0 which is available at
// https://www.apache.org/licenses/LICENSE-2.0
//
// SPDX-License-Identifier: EPL-2.0 OR Apache-2.0
// ========================================================================
//

package org.eclipse.jetty.server.handler;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;

import jakarta.servlet.ServletContext;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.MimeTypes;
import org.eclipse.jetty.http.pathmap.PathSpecSet;
import org.eclipse.jetty.server.HttpChannel;
import org.eclipse.jetty.server.HttpOutput;
import org.eclipse.jetty.server.HttpOutput.Interceptor;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.Response;
import org.eclipse.jetty.util.BufferUtil;
import org.eclipse.jetty.util.Callback;
import org.eclipse.jetty.util.IncludeExclude;
import org.eclipse.jetty.util.IteratingCallback;
import org.eclipse.jetty.util.StringUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

Buffered Response Handler

A Handler that can apply a Interceptor mechanism to buffer the entire response content until the output is closed. This allows the commit to be delayed until the response is complete and thus headers and response status can be changed while writing the body.

Note that the decision to buffer is influenced by the headers and status at the first write, and thus subsequent changes to those headers will not influence the decision to buffer or not.

Note also that there are no memory limits to the size of the buffer, thus this handler can represent an unbounded memory commitment if the content generated can also be unbounded.

/** * Buffered Response Handler * <p> * A Handler that can apply a {@link org.eclipse.jetty.server.HttpOutput.Interceptor} * mechanism to buffer the entire response content until the output is closed. * This allows the commit to be delayed until the response is complete and thus * headers and response status can be changed while writing the body. * <p> * Note that the decision to buffer is influenced by the headers and status at the * first write, and thus subsequent changes to those headers will not influence the * decision to buffer or not. * <p> * Note also that there are no memory limits to the size of the buffer, thus * this handler can represent an unbounded memory commitment if the content * generated can also be unbounded. * </p> */
public class BufferedResponseHandler extends HandlerWrapper { static final Logger LOG = LoggerFactory.getLogger(BufferedResponseHandler.class); private final IncludeExclude<String> _methods = new IncludeExclude<>(); private final IncludeExclude<String> _paths = new IncludeExclude<>(PathSpecSet.class); private final IncludeExclude<String> _mimeTypes = new IncludeExclude<>(); public BufferedResponseHandler() { // include only GET requests _methods.include(HttpMethod.GET.asString()); // Exclude images, aduio and video from buffering for (String type : MimeTypes.getKnownMimeTypes()) { if (type.startsWith("image/") || type.startsWith("audio/") || type.startsWith("video/")) _mimeTypes.exclude(type); } LOG.debug("{} mime types {}", this, _mimeTypes); } public IncludeExclude<String> getMethodIncludeExclude() { return _methods; } public IncludeExclude<String> getPathIncludeExclude() { return _paths; } public IncludeExclude<String> getMimeIncludeExclude() { return _mimeTypes; } @Override public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { final ServletContext context = baseRequest.getServletContext(); final String path = baseRequest.getPathInContext(); LOG.debug("{} handle {} in {}", this, baseRequest, context); HttpOutput out = baseRequest.getResponse().getHttpOutput(); // Are we already being gzipped? HttpOutput.Interceptor interceptor = out.getInterceptor(); while (interceptor != null) { if (interceptor instanceof BufferedInterceptor) { LOG.debug("{} already intercepting {}", this, request); _handler.handle(target, baseRequest, request, response); return; } interceptor = interceptor.getNextInterceptor(); } // If not a supported method - no Vary because no matter what client, this URI is always excluded if (!_methods.test(baseRequest.getMethod())) { LOG.debug("{} excluded by method {}", this, request); _handler.handle(target, baseRequest, request, response); return; } // If not a supported URI- no Vary because no matter what client, this URI is always excluded // Use pathInfo because this is be if (!isPathBufferable(path)) { LOG.debug("{} excluded by path {}", this, request); _handler.handle(target, baseRequest, request, response); return; } // If the mime type is known from the path, then apply mime type filtering String mimeType = context == null ? MimeTypes.getDefaultMimeByExtension(path) : context.getMimeType(path); if (mimeType != null) { mimeType = MimeTypes.getContentTypeWithoutCharset(mimeType); if (!isMimeTypeBufferable(mimeType)) { LOG.debug("{} excluded by path suffix mime type {}", this, request); // handle normally without setting vary header _handler.handle(target, baseRequest, request, response); return; } } // install interceptor and handle out.setInterceptor(new BufferedInterceptor(baseRequest.getHttpChannel(), out.getInterceptor())); if (_handler != null) _handler.handle(target, baseRequest, request, response); } protected boolean isMimeTypeBufferable(String mimetype) { return _mimeTypes.test(mimetype); } protected boolean isPathBufferable(String requestURI) { if (requestURI == null) return true; return _paths.test(requestURI); } private class BufferedInterceptor implements HttpOutput.Interceptor { final Interceptor _next; final HttpChannel _channel; final Queue<ByteBuffer> _buffers = new ConcurrentLinkedQueue<>(); Boolean _aggregating; ByteBuffer _aggregate; public BufferedInterceptor(HttpChannel httpChannel, Interceptor interceptor) { _next = interceptor; _channel = httpChannel; } @Override public void resetBuffer() { _buffers.clear(); _aggregating = null; _aggregate = null; } ; @Override public void write(ByteBuffer content, boolean last, Callback callback) { if (LOG.isDebugEnabled()) LOG.debug("{} write last={} {}", this, last, BufferUtil.toDetailString(content)); // if we are not committed, have to decide if we should aggregate or not if (_aggregating == null) { Response response = _channel.getResponse(); int sc = response.getStatus(); if (sc > 0 && (sc < 200 || sc == 204 || sc == 205 || sc >= 300)) _aggregating = Boolean.FALSE; // No body else { String ct = response.getContentType(); if (ct == null) _aggregating = Boolean.TRUE; else { ct = MimeTypes.getContentTypeWithoutCharset(ct); _aggregating = isMimeTypeBufferable(StringUtil.asciiToLowerCase(ct)); } } } // If we are not aggregating, then handle normally if (!_aggregating.booleanValue()) { getNextInterceptor().write(content, last, callback); return; } // If last if (last) { // Add the current content to the buffer list without a copy if (BufferUtil.length(content) > 0) _buffers.add(content); if (LOG.isDebugEnabled()) LOG.debug("{} committing {}", this, _buffers.size()); commit(_buffers, callback); } else { if (LOG.isDebugEnabled()) LOG.debug("{} aggregating", this); // Aggregate the content into buffer chain while (BufferUtil.hasContent(content)) { // Do we need a new aggregate buffer if (BufferUtil.space(_aggregate) == 0) { int size = Math.max(_channel.getHttpConfiguration().getOutputBufferSize(), BufferUtil.length(content)); _aggregate = BufferUtil.allocate(size); // TODO use a buffer pool _buffers.add(_aggregate); } BufferUtil.append(_aggregate, content); } callback.succeeded(); } } @Override public Interceptor getNextInterceptor() { return _next; } protected void commit(Queue<ByteBuffer> buffers, Callback callback) { // If only 1 buffer if (_buffers.size() == 0) getNextInterceptor().write(BufferUtil.EMPTY_BUFFER, true, callback); else if (_buffers.size() == 1) // just flush it with the last callback getNextInterceptor().write(_buffers.remove(), true, callback); else { // Create an iterating callback to do the writing IteratingCallback icb = new IteratingCallback() { @Override protected Action process() throws Exception { ByteBuffer buffer = _buffers.poll(); if (buffer == null) return Action.SUCCEEDED; getNextInterceptor().write(buffer, _buffers.isEmpty(), this); return Action.SCHEDULED; } @Override protected void onCompleteSuccess() { // Signal last callback callback.succeeded(); } @Override protected void onCompleteFailure(Throwable cause) { // Signal last callback callback.failed(cause); } }; icb.iterate(); } } } }