AsyncClientServerBrotliRoundTrip.java

/*
 * ====================================================================
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you 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.
 * ====================================================================
 *
 * This software consists of voluntary contributions made by many
 * individuals on behalf of the Apache Software Foundation.  For more
 * information on the Apache Software Foundation, please see
 * <http://www.apache.org/>.
 *
 */
package org.apache.hc.client5.http.examples;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Future;

import com.aayushatharva.brotli4j.Brotli4jLoader;
import com.aayushatharva.brotli4j.encoder.BrotliOutputStream;

import org.apache.commons.compress.compressors.CompressorException;
import org.apache.commons.compress.compressors.CompressorInputStream;
import org.apache.commons.compress.compressors.CompressorStreamFactory;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
import org.apache.hc.core5.http.ClassicHttpRequest;
import org.apache.hc.core5.http.ClassicHttpResponse;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpHeaders;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.Message;
import org.apache.hc.core5.http.impl.bootstrap.HttpServer;
import org.apache.hc.core5.http.impl.bootstrap.ServerBootstrap;
import org.apache.hc.core5.http.io.HttpRequestHandler;
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityConsumer;
import org.apache.hc.core5.http.nio.entity.BasicAsyncEntityProducer;
import org.apache.hc.core5.http.nio.support.BasicRequestProducer;
import org.apache.hc.core5.http.nio.support.BasicResponseConsumer;
import org.apache.hc.core5.http.protocol.HttpContext;
import org.apache.hc.core5.io.CloseMode;

/**
 * Async client/server demo with Brotli in both directions:
 * <p>
 * - Client sends a Brotli-compressed request body (Content-Encoding: br)
 * - Server decompresses request, then responds with a Brotli-compressed body
 * - Client checks the response Content-Encoding and decompresses if needed
 * <p>
 * Notes:
 * - Encoding uses brotli4j (native JNI); make sure matching native dependency is on the runtime classpath.
 * - Decoding here uses Commons Compress via CompressorStreamFactory("br").
 */
public final class AsyncClientServerBrotliRoundTrip {

    static {
        Brotli4jLoader.ensureAvailability();
    }

    private static final String BR = "br";

    public static void main(final String[] args) throws Exception {
        final HttpServer server = ServerBootstrap.bootstrap()
                .setListenerPort(0)
                .setCanonicalHostName("localhost")
                .register("/echo", new EchoHandler())
                .create();
        server.start();
        final int port = server.getLocalPort();
        final String url = "http://localhost:" + port + "/echo";

        try (final CloseableHttpAsyncClient client = HttpAsyncClients.createDefault()) {
            client.start();

            final String requestBody = "Hello Brotli world (round-trip)!";
            System.out.println("Request (plain): " + requestBody);

            // --- client compresses request ---
            final byte[] reqCompressed = brotliCompress(requestBody.getBytes(StandardCharsets.UTF_8));

            final SimpleHttpRequest post = SimpleRequestBuilder.post(url)
                    .setHeader(HttpHeaders.CONTENT_TYPE, ContentType.TEXT_PLAIN.toString())
                    .setHeader(HttpHeaders.CONTENT_ENCODING, BR)
                    .build();

            final Future<Message<HttpResponse, byte[]>> f = client.execute(
                    new BasicRequestProducer(post,
                            new BasicAsyncEntityProducer(reqCompressed, ContentType.APPLICATION_OCTET_STREAM)),
                    new BasicResponseConsumer<>(new BasicAsyncEntityConsumer()),
                    null);

            final Message<HttpResponse, byte[]> msg = f.get();
            final HttpResponse head = msg.getHead();
            final byte[] respBodyRaw = msg.getBody() != null ? msg.getBody() : new byte[0];

            System.out.println("Status           : " + head.getCode());
            final Header ce = head.getFirstHeader(HttpHeaders.CONTENT_ENCODING);
            final boolean isBr = ce != null && BR.equalsIgnoreCase(ce.getValue());
            System.out.println("Response C-E     : " + (isBr ? BR : "(none)"));

            final byte[] respPlain = isBr ? brotliDecompress(respBodyRaw) : respBodyRaw;
            System.out.println("Response (plain) : " + new String(respPlain, StandardCharsets.UTF_8));
        } finally {
            server.close(CloseMode.GRACEFUL);
        }
    }

    /**
     * Server handler:
     * - If request has Content-Encoding: br, decompress it
     * - Echo the text back, but re-encode the response with Brotli (Content-Encoding: br)
     */
    private static final class EchoHandler implements HttpRequestHandler {
        @Override
        public void handle(
                final ClassicHttpRequest request,
                final ClassicHttpResponse response,
                final HttpContext context) throws IOException {

            final HttpEntity entity = request.getEntity();
            if (entity == null) {
                response.setCode(HttpStatus.SC_BAD_REQUEST);
                response.setEntity(new StringEntity("Missing request body", StandardCharsets.UTF_8));
                return;
            }

            try {
                final byte[] requestPlain;
                final Header ce = request.getFirstHeader(HttpHeaders.CONTENT_ENCODING);
                if (ce != null && BR.equalsIgnoreCase(ce.getValue())) {
                    try (final InputStream in = entity.getContent();
                         final CompressorInputStream bin =
                                 new CompressorStreamFactory().createCompressorInputStream(BR, in)) {
                        requestPlain = readAll(bin);
                    }
                } else {
                    try (final InputStream in = entity.getContent()) {
                        requestPlain = readAll(in);
                    }
                }

                final String echoed = new String(requestPlain, StandardCharsets.UTF_8);

                // --- server compresses response with Brotli ---
                final byte[] respCompressed = brotliCompress(echoed.getBytes(StandardCharsets.UTF_8));
                response.setCode(HttpStatus.SC_OK);
                response.addHeader(HttpHeaders.CONTENT_TYPE, ContentType.TEXT_PLAIN.toString());
                response.addHeader(HttpHeaders.CONTENT_ENCODING, BR);
                response.setEntity(new ByteArrayEntity(respCompressed, ContentType.APPLICATION_OCTET_STREAM));

            } catch (final CompressorException ex) {
                response.setCode(HttpStatus.SC_BAD_REQUEST);
                response.setEntity(new StringEntity("Invalid Brotli payload", StandardCharsets.UTF_8));
            } catch (final Exception ex) {
                response.setCode(HttpStatus.SC_INTERNAL_SERVER_ERROR);
                response.setEntity(new StringEntity("Server error", StandardCharsets.UTF_8));
            }
        }
    }

    /**
     * Utility: read entire stream into a byte[] (demo-only).
     */
    private static byte[] readAll(final InputStream in) throws IOException {
        final ByteArrayOutputStream bos = new ByteArrayOutputStream();
        final byte[] buf = new byte[8192];
        int n;
        while ((n = in.read(buf)) != -1) {
            bos.write(buf, 0, n);
        }
        return bos.toByteArray();
    }

    /**
     * Compress a byte[] with Brotli using brotli4j.
     */
    private static byte[] brotliCompress(final byte[] plain) throws IOException {
        final ByteArrayOutputStream baos = new ByteArrayOutputStream();
        try (final BrotliOutputStream out = new BrotliOutputStream(baos)) {
            out.write(plain);
        }
        return baos.toByteArray();
    }

    /**
     * Decompress a Brotli-compressed byte[] using Commons Compress.
     */
    private static byte[] brotliDecompress(final byte[] compressed) throws IOException {
        try (final InputStream in = new ByteArrayInputStream(compressed);
             final CompressorInputStream bin = new CompressorStreamFactory().createCompressorInputStream(BR, in)) {
            return readAll(bin);
        } catch (final CompressorException e) {
            throw new IOException("Failed to decompress Brotli data", e);
        }
    }
}