jettyembedded-jettyjetty-9jetty-12

Jetty Server 9 to 12 migration


I had a class: MockServerHandler I use in my itests using Jetty 9 which I had to migrate to Jetty 12. Before the migration (Jetty 9):

public class MockServerHandler extends DefaultHandler {
    @Override
    public void handle(String target, Request baseRequest, HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
        try {
            log.debug("Got request for: {} request type {} ", target, request.getMethod());
            handleDownload(request, response);
        } catch (Exception e) {
            log.error("Mock handler caught and exception", e);
        }
    }

    private void handleDownload(HttpServletRequest request, HttpServletResponse response) {
        byte[] byteBuffer = getContentToReturn();
        long lastModified = getLastModified();
        String contentType = getContentType();
        int returnCode = getReturnCode();

        response.setStatus(returnCode);
        if (lastModified > 0L) {
            response.setDateHeader(HttpHeader.LAST_MODIFIED.asString(), lastModified);
        }
        response.setContentType(contentType);        
        response.setContentLength(byteBuffer.length);

        OutputStream outputStream = response.getOutputStream();
        outputStream.write(byteBuffer, 0, byteBuffer.length);
        response.flushBuffer();
        outputStream.close();
    }
}

After bumping to Jetty 12:

public class MockServerHandler extends DefaultHandler {
    @Override
    public boolean handle(Request request, Response response, Callback callback)
            throws IOException, ServletException {
        try {
            String target = request.getHttpURI().getPath();
            log.debug("Got request for: {} request type {} ", target, request.getMethod());
            handleDownload(request, response);
            callback.succeeded();
        } catch (Exception e) {
            callback.failed(e);
            response.setStatus(HttpStatus.SC_INTERNAL_SERVER_ERROR);
            log.error("Mock handler caught and exception", e);
        }
        return true;
    }

    private void handleDownload(Request request, Response response) {
        byte[] byteBuffer = getContentToReturn();
        long lastModified = getLastModified();
        String contentType = getContentType();
        int returnCode = getReturnCode();

        response.setStatus(returnCode);
        if (lastModified > 0L) {
            response.getHeaders().addDateField(HttpHeader.LAST_MODIFIED.asString(), lastModified);
        }
        response.getHeaders().add(HttpHeader.CONTENT_TYPE, contentType);
response.getHeaders().add(HttpHeader.CONTENT_LENGTH, byteBuffer.length);

        OutputStream outputStream = Content.Sink.asOutputStream(response);
        outputStream.write(byteBuffer, 0, byteBuffer.length);
        outputStream.flush();
        outputStream.close();
    }
}

After these changes my integration tests started to fail. Some due to read timeout, others expect UTF-8 encoded response body and suddenly fail complaining the first byte isn't compliant, and more. I'm clearly missing something, as I tried to make the least amount of changes possible to retain behavior.

I noticed DefaultHandler passes super(InvocationType.NON_BLOCKING); which I did not expect (it wasn't non-blocking on Jetty 9, right?). I tried extending Handler.Abstract directly but that didn't change anything.

Am I violating the new Jetty contract somehow? I've read the docs carefully but couldn't see anything I am doing blatantly wrong. Any advice to understand how it works and what I'm missing is very much appreciated.


Solution

  • If you have a complete byte[] array of your content, don't use a blocking OutputStream, just send it as is, there's no need to get overly complicated with the OutputStream class.

    Your Jetty 12 implementation doesn't even handle content-length properly.

    Separate your concerns.

    1. Get the response data (fail
    2. Prepare the response metadata (headers, status codes)
    3. Write the response body content (and manage the Callback)

    The minute you mix these you will have a messy set of code that becomes hard to maintain.

    package handlers;
    
    import java.nio.ByteBuffer;
    import java.nio.charset.StandardCharsets;
    
    import org.eclipse.jetty.http.HttpHeader;
    import org.eclipse.jetty.server.Handler;
    import org.eclipse.jetty.server.Request;
    import org.eclipse.jetty.server.Response;
    import org.eclipse.jetty.util.Callback;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    public abstract class MockDownloadHandler extends Handler.Abstract
    {
        private static final Logger LOG = LoggerFactory.getLogger(MockDownloadHandler.class);
    
        @Override
        public boolean handle(Request request, Response response, Callback callback) throws Exception
        {
            String target = Request.getPathInContext(request);
            LOG.debug("Got request for: {} request type {} ", target, request.getMethod());
    
            // Get response content
            ByteBuffer responseBody;
    
            try
            {
                responseBody = ByteBuffer.wrap(getContentToReturn());
            }
            catch (Throwable e)
            {
                callback.failed(e);
                return true; // you handled the failure.
            }
    
            // Prepare response metadata
            long lastModified = getLastModified();
            //   The contentType string here should have the `;charset=` section defined too.
            //   Example: "text/html;charset=utf-8" or "text/html;charset=iso8859-1"
            //   Note: not all content-types support a `;charset=` here, some are implied.
            //         for example json is implied to be `utf-8` but never reported in this
            //         content-type as `;charset=utf-8`, that would, in fact, be a violation
            //         of the json spec to include the `;charset=` entry.
            String contentType = getContentType();
            int returnCode = getReturnCode();
    
            response.setStatus(returnCode);
            response.getHeaders().add(HttpHeader.CONTENT_TYPE, contentType);
            // If you leave this out, you are subject to possible Transfer-Encoding: chunked responses.
            response.getHeaders().add(HttpHeader.CONTENT_LENGTH, responseBody.remaining());
            if (lastModified > 0)
                response.getHeaders().addDateField(HttpHeader.LAST_MODIFIED.asString(), lastModified);
    
            // Send the response
            response.write(true, responseBody, callback);
            return true;
        }
    
        protected abstract int getReturnCode();
    
        protected abstract String getContentType();
    
        protected abstract long getLastModified();
    
        public byte[] getContentToReturn()
        {
            // Be careful of proper conversion to bytes.
            // If the data is already binary, no need to do anything to encode it.
            // But if the data starts out as text, the conversion to bytes needs to be encoded
            // based on the same charset that you are going to report back on the `Content-Type`
            // response header.
    
            // Example: a string to byte conversion using UTF-8
            return "Hello € euro".getBytes(StandardCharsets.UTF_8);
        }
    }
    

    You probably want your getContentToReturn() step also be mime-type and charset aware, so that you can perform the correct behaviors to the content conversion to bytes, and also carry over the proper content-type (with charset) too.

    Also be aware of MimeTypes object, it can do a few things for you. Like let you know about assumed vs inferred charsets.

    Example.

    package mimetypes;
    
    import java.nio.charset.Charset;
    
    import org.eclipse.jetty.http.MimeTypes;
    
    public class MimeTypeDemo
    {
        public static void main(String[] args)
        {
            MimeTypes mimeTypes = new MimeTypes();
    
            dump(mimeTypes, "text/html");
            dump(mimeTypes, "text/html;charset=iso8859-1");
            dump(mimeTypes, "image/jpeg");
            dump(mimeTypes, "application/json");
            dump(mimeTypes, "application/json;charset=UTF-8");
            dump(mimeTypes, "application/json;charset=us-ascii");
        }
    
        public static void dump(MimeTypes mimeTypes, String applicationSpecifiedContentType)
        {
            System.out.printf("%n-- dump: \"%s\" --%n", applicationSpecifiedContentType);
            System.out.printf("  mime-type = %s%n", MimeTypes.getContentTypeWithoutCharset(applicationSpecifiedContentType));
            System.out.printf("  charset (string) = %s%n", MimeTypes.getCharsetFromContentType(applicationSpecifiedContentType));
            System.out.printf("  charset (class) = %s%n", mimeTypes.getCharset(applicationSpecifiedContentType));
            System.out.printf("  assumed charset = %s%n", mimeTypes.getAssumedCharset(applicationSpecifiedContentType));
            System.out.printf("  inferred charset = %s%n", mimeTypes.getInferredCharset(applicationSpecifiedContentType));
            System.out.printf("  charset inferred from content-type = %s%n", mimeTypes.getCharsetInferredFromContentType(applicationSpecifiedContentType));
            String contentTypeHeaderValue = toHeaderValue(mimeTypes, applicationSpecifiedContentType);
            System.out.printf("  Content-Type (clean header to send) = %s%n", contentTypeHeaderValue);
        }
    
        private static String toHeaderValue(MimeTypes mimeTypes, String rawContentType)
        {
            // Get the mimetype from the raw content-type string
            String mimeType = MimeTypes.getContentTypeWithoutCharset(rawContentType);
    
            // Figure out the charset, normalized if found, add if missing and not assumed.
            String rawCharset = MimeTypes.getCharsetFromContentType(rawContentType);
            if (rawCharset == null)
            {
                Charset inferredCharset = mimeTypes.getInferredCharset(mimeType);
                if (inferredCharset != null)
                    rawCharset = inferredCharset.name();
            }
    
            if (rawCharset != null)
                return String.format("%s;charset=%s", mimeType, rawCharset);
            else
                return mimeType;
        }
    }
    

    With output of ...

    -- dump: "text/html" --
      mime-type = text/html
      charset (string) = null
      charset (class) = UTF-8
      assumed charset = null
      inferred charset = UTF-8
      charset inferred from content-type = UTF-8
      Content-Type (clean header to send) = text/html;charset=UTF-8
    
    -- dump: "text/html;charset=iso8859-1" --
      mime-type = text/html
      charset (string) = iso8859-1
      charset (class) = ISO-8859-1
      assumed charset = null
      inferred charset = null
      charset inferred from content-type = null
      Content-Type (clean header to send) = text/html;charset=iso8859-1
    
    -- dump: "image/jpeg" --
      mime-type = image/jpeg
      charset (string) = null
      charset (class) = null
      assumed charset = null
      inferred charset = null
      charset inferred from content-type = null
      Content-Type (clean header to send) = image/jpeg
    
    -- dump: "application/json" --
      mime-type = application/json
      charset (string) = null
      charset (class) = UTF-8
      assumed charset = UTF-8
      inferred charset = null
      charset inferred from content-type = null
      Content-Type (clean header to send) = application/json
    
    -- dump: "application/json;charset=UTF-8" --
      mime-type = application/json
      charset (string) = utf-8
      charset (class) = UTF-8
      assumed charset = null
      inferred charset = null
      charset inferred from content-type = null
      Content-Type (clean header to send) = application/json;charset=utf-8
    
    -- dump: "application/json;charset=us-ascii" --
      mime-type = application/json
      charset (string) = us-ascii
      charset (class) = US-ASCII
      assumed charset = null
      inferred charset = null
      charset inferred from content-type = null
      Content-Type (clean header to send) = application/json;charset=us-ascii
    

    This shows now the application provided Content-Type (with optional charset) can be interrogated and also resolved into a string that is appropriate to use on HTTP protocol for the Content-Type header value.