Gumdrop

Building Microservices with Gumdrop

This guide explains how to build your own HTTP microservices using the Gumdrop framework. Unlike the servlet container, file server, or WebSocket service—which are ready-to-use implementations—this page shows you how to create custom services from scratch using the HTTP Server API. The examples use streaming, event-driven parsing with Gonzalez (XML) and jsonparser (JSON) for maximum efficiency.

Contents

Overview

A Gumdrop microservice is an HTTPService with a custom HTTPRequestHandlerFactory. The factory creates HTTPRequestHandler instances for each request. Handlers receive events in sequence:

headers()              // request headers (:method, :path, content-type, etc.)
startRequestBody()     // body is starting (POST, PUT, etc.)
requestBodyContent()   // body data chunks (may be called many times)
endRequestBody()       // body complete
requestComplete()      // stream closed

For efficient parsing, feed each requestBodyContent(ByteBuffer) chunk directly into a streaming parser. Do not accumulate the entire body in memory first. Both Gonzalez (XML) and jsonparser (JSON) support receive(ByteBuffer) for incremental parsing.

Example 1: XML Microservice

This example parses a small application/xml request body and responds with XML. It uses the Gonzalez push parser with a SAX ContentHandler to extract data as it arrives.

Step 1: Create a SAX ContentHandler

The handler receives parsing events. For a simple request like <echo>Hello</echo>, we capture the text content:

import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;

class EchoContentHandler extends DefaultHandler {
    private final StringBuilder text = new StringBuilder();
    private String rootElement;

    @Override
    public void startElement(String uri, String localName, String qName,
                             Attributes atts) throws SAXException {
        if (rootElement == null) {
            rootElement = localName;
        }
        text.setLength(0);
    }

    @Override
    public void characters(char[] ch, int start, int length) {
        text.append(ch, start, length);
    }

    String getRootElement() { return rootElement; }
    String getText() { return text.toString().trim(); }
}

Step 2: Create the HTTP Request Handler

The handler feeds each body chunk to the Gonzalez parser. Create the parser once, call receive(data) for each chunk, and close() when the body is complete:

import org.bluezoo.gonzalez.Parser;
import org.bluezoo.gumdrop.http.*;

import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;

import static java.nio.charset.StandardCharsets.UTF_8;

class XmlEchoHandler extends DefaultHTTPRequestHandler {
    private final Parser parser = new Parser();
    private final EchoContentHandler contentHandler = new EchoContentHandler();
    private boolean bodyStarted;

    @Override
    public void headers(HTTPResponseState state, Headers headers) {
        if (!"POST".equals(headers.getMethod())) {
            sendError(state, 405, "Method Not Allowed");
            return;
        }
        String ct = headers.getValue("content-type");
        if (ct == null || !ct.startsWith("application/xml")) {
            sendError(state, 415, "Unsupported Media Type");
            return;
        }
        bodyStarted = false;
        parser.setContentHandler(contentHandler);
        try {
            parser.reset();
        } catch (Exception e) {
            sendError(state, 500, e.getMessage());
        }
    }

    @Override
    public void startRequestBody(HTTPResponseState state) {
        bodyStarted = true;
    }

    @Override
    public void requestBodyContent(HTTPResponseState state, ByteBuffer data) {
        if (!bodyStarted) return;
        try {
            parser.receive(data);
        } catch (Exception e) {
            sendError(state, 400, "Invalid XML: " + e.getMessage());
        }
    }

    @Override
    public void endRequestBody(HTTPResponseState state) {
        if (!bodyStarted) return;
        try {
            parser.close();
            sendXmlResponse(state);
        } catch (Exception e) {
            sendError(state, 400, "Invalid XML: " + e.getMessage());
        }
    }

    private void sendXmlResponse(HTTPResponseState state) throws IOException {
        String root = contentHandler.getRootElement();
        String text = contentHandler.getText();

        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        org.bluezoo.gonzalez.XMLWriter xml = new org.bluezoo.gonzalez.XMLWriter(baos);
        xml.writeStartElement(root != null ? root : "response");
        xml.writeDefaultNamespace("urn:example:echo");
        if (text != null && !text.isEmpty()) {
            xml.writeCharacters(text);
        }
        xml.writeEndElement();
        xml.close();

        Headers response = new Headers();
        response.status(HTTPStatus.OK);
        response.add("content-type", "application/xml");
        state.headers(response);
        state.startResponseBody();
        state.responseBodyContent(ByteBuffer.wrap(baos.toByteArray()));
        state.endResponseBody();
        state.complete();
    }

    private void sendError(HTTPResponseState state, int code, String message) {
        Headers response = new Headers();
        response.status(HTTPStatus.fromCode(code));
        response.add("content-type", "text/plain");
        state.headers(response);
        state.startResponseBody();
        state.responseBodyContent(ByteBuffer.wrap(message.getBytes(UTF_8)));
        state.endResponseBody();
        state.complete();
    }
}

Step 3: Create the Service and Factory

Extend HTTPService and provide a handler factory. The factory routes requests (here, all POST requests to /echo get the XML handler):

import org.bluezoo.gumdrop.http.*;

public class XmlEchoService extends HTTPService {

    @Override
    protected void initService() {
        // No resources to initialise
    }

    @Override
    protected HTTPRequestHandlerFactory getHandlerFactory() {
        return new XmlEchoHandlerFactory();
    }

    private static class XmlEchoHandlerFactory implements HTTPRequestHandlerFactory {
        @Override
        public HTTPRequestHandler createHandler(HTTPResponseState state,
                                                Headers headers) {
            String path = headers.getValue(":path");
            if ("/echo".equals(path) && "POST".equals(headers.getMethod())) {
                return new XmlEchoHandler();
            }
            return null;  // 404
        }
    }
}

Step 4: Wire and Start

Add the service to Gumdrop and configure a listener. See Configuration for XML-based setup.

Gumdrop gumdrop = Gumdrop.getInstance();
XmlEchoService service = new XmlEchoService();
HTTPListener listener = new HTTPListener();
listener.setPort(9090);
service.addListener(listener);
gumdrop.addService(service);
gumdrop.start();

// Block until shutdown (Ctrl+C); JVM shutdown hook calls gumdrop.shutdown()
try {
    gumdrop.join();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

Example 2: Service That Calls Another Microservice

This example extends the first: the service receives a JSON request, calls a downstream microservice (also JSON), and returns the aggregated response. It demonstrates:

Step 1: JSON Request Handler (Streaming Parse)

Extend JSONDefaultHandler to capture the fields you need. The parser receives body chunks incrementally:

import org.bluezoo.json.JSONDefaultHandler;
import org.bluezoo.json.JSONException;

class LookupRequestHandler extends JSONDefaultHandler {
    private String currentKey;
    private String query;

    @Override
    public void key(String key) throws JSONException {
        currentKey = key;
    }

    @Override
    public void stringValue(String value) throws JSONException {
        if ("query".equals(currentKey)) {
            query = value;
        }
    }

    @Override
    public void endObject() throws JSONException {
        currentKey = null;
    }

    String getQuery() { return query; }
}

Step 2: HTTP Handler That Calls Downstream

The handler parses the JSON request in responseBodyContent, then in endRequestBody initiates an outbound HTTP call. Use state.getSelectorLoop() for SelectorLoop affinity so the client connection shares the same I/O thread as the server connection:

import org.bluezoo.gumdrop.SelectorLoop;
import org.bluezoo.gumdrop.SecurityInfo;
import org.bluezoo.gumdrop.http.*;
import org.bluezoo.gumdrop.http.client.*;
import org.bluezoo.json.*;

import java.nio.ByteBuffer;

import static java.nio.charset.StandardCharsets.UTF_8;

class LookupHandler extends DefaultHTTPRequestHandler {
    private final String downstreamHost;
    private final int downstreamPort;
    private final JSONParser jsonParser = new JSONParser();
    private final LookupRequestHandler requestHandler = new LookupRequestHandler();
    private boolean parserInitialised;
    private HTTPResponseState pendingState;

    LookupHandler(String host, int port) {
        this.downstreamHost = host;
        this.downstreamPort = port;
    }

    @Override
    public void headers(HTTPResponseState state, Headers headers) {
        if (!"POST".equals(headers.getMethod())) {
            sendError(state, 405, "Method Not Allowed");
            return;
        }
        pendingState = state;
        parserInitialised = false;
        jsonParser.setContentHandler(requestHandler);
        try {
            jsonParser.reset();
        } catch (Exception e) {
            sendError(state, 500, e.getMessage());
        }
    }

    @Override
    public void startRequestBody(HTTPResponseState state) {
        // Body starting
    }

    @Override
    public void requestBodyContent(HTTPResponseState state, ByteBuffer data) {
        if (!parserInitialised) {
            jsonParser.setContentHandler(requestHandler);
            parserInitialised = true;
        }
        try {
            jsonParser.receive(data);
        } catch (JSONException e) {
            sendError(state, 400, "Invalid JSON: " + e.getMessage());
        }
    }

    @Override
    public void endRequestBody(HTTPResponseState state) {
        try {
            jsonParser.close();
        } catch (JSONException e) {
            sendError(state, 400, "Invalid JSON: " + e.getMessage());
            return;
        }

        String query = requestHandler.getQuery();
        if (query == null || query.isEmpty()) {
            sendError(state, 400, "Missing 'query' field");
            return;
        }

        callDownstream(state, query);
    }

    private void callDownstream(HTTPResponseState state, String query) {
        SelectorLoop loop = state.getSelectorLoop();
        HTTPClient client = (loop != null)
            ? new HTTPClient(loop, downstreamHost, downstreamPort)
            : new HTTPClient(downstreamHost, downstreamPort);

        // Propagate trace context so the distributed trace remains connected
        client.setTrace(state.getTrace());

        client.connect(new HTTPClientHandler() {
            @Override
            public void onConnected(Endpoint endpoint) {
                HTTPRequest request = client.post("/lookup");
                request.header("Content-Type", "application/json");
                request.header("Accept", "application/json");

                DefaultHTTPResponseHandler responseHandler =
                    new DefaultHTTPResponseHandler() {
                    private final JSONParser responseParser = new JSONParser();
                    private final LookupResponseHandler jsonHandler =
                        new LookupResponseHandler();
                    private boolean parserInit;

                    @Override
                    public void ok(HTTPResponse response) {
                        responseParser.setContentHandler(jsonHandler);
                        try { responseParser.reset(); } catch (Exception e) { /* ignore */ }
                        parserInit = true;
                    }

                    @Override
                    public void responseBodyContent(ByteBuffer data) {
                        if (parserInit) {
                            try {
                                responseParser.receive(data);
                            } catch (JSONException e) {
                                // Log and continue
                            }
                        }
                    }

                    @Override
                    public void close() {
                        try {
                            if (parserInit) responseParser.close();
                        } catch (Exception e) { /* ignore */ }
                        String result = jsonHandler.getResult();
                        sendJsonResponse(pendingState, result);
                    }

                    @Override
                    public void failed(Exception ex) {
                        sendError(pendingState, 502, "Downstream error: " + ex.getMessage());
                    }
                };

                String body = "{\"query\":\"" + escapeJson(query) + "\"}";
                request.startRequestBody(responseHandler);
                request.requestBodyContent(ByteBuffer.wrap(body.getBytes(UTF_8)));
                request.endRequestBody();
            }

            @Override
            public void onError(Exception cause) {
                sendError(pendingState, 502, "Connection failed: " + cause.getMessage());
            }

            @Override
            public void onSecurityEstablished(SecurityInfo info) { }

            @Override
            public void onDisconnected() { }
        });
    }

    private void sendJsonResponse(HTTPResponseState state, String result) {
        String json = "{\"result\":\"" + escapeJson(result != null ? result : "") + "\"}";
        Headers response = new Headers();
        response.status(HTTPStatus.OK);
        response.add("content-type", "application/json");
        state.headers(response);
        state.startResponseBody();
        state.responseBodyContent(ByteBuffer.wrap(json.getBytes(UTF_8)));
        state.endResponseBody();
        state.complete();
    }

    private void sendError(HTTPResponseState state, int code, String message) {
        Headers response = new Headers();
        response.status(HTTPStatus.fromCode(code));
        response.add("content-type", "text/plain");
        state.headers(response);
        state.startResponseBody();
        state.responseBodyContent(ByteBuffer.wrap(message.getBytes(UTF_8)));
        state.endResponseBody();
        state.complete();
    }

    private static String escapeJson(String s) {
        return s.replace("\\", "\\\\").replace("\"", "\\\"");
    }
}

Step 3: Response Handler for Downstream JSON

class LookupResponseHandler extends JSONDefaultHandler {
    private String currentKey;
    private String result;

    @Override
    public void key(String key) throws JSONException {
        currentKey = key;
    }

    @Override
    public void stringValue(String value) throws JSONException {
        if ("result".equals(currentKey)) {
            result = value;
        }
    }

    @Override
    public void endObject() throws JSONException {
        currentKey = null;
    }

    String getResult() { return result; }
}

Step 4: Service with Downstream Configuration

public class LookupService extends HTTPService {
    private String downstreamHost = "localhost";
    private int downstreamPort = 9091;

    public void setDownstreamHost(String host) { downstreamHost = host; }
    public void setDownstreamPort(int port) { downstreamPort = port; }

    @Override
    protected HTTPRequestHandlerFactory getHandlerFactory() {
        return (state, headers) -> {
            if ("/lookup".equals(headers.getPath())) {
                return new LookupHandler(downstreamHost, downstreamPort);
            }
            return null;
        };
    }
}

SelectorLoop Affinity

When creating the HTTPClient, pass state.getSelectorLoop() so the outbound connection uses the same worker thread as the inbound request. This avoids context switches and keeps I/O on a single thread. If getSelectorLoop() returns null (e.g. in tests), the client falls back to Gumdrop's default worker assignment.

Configuration

Microservices can be configured via gumdroprc like any other service. Example for the XML echo service:

<service id="echo" class="com.example.XmlEchoService">
  <listener class="org.bluezoo.gumdrop.http.HTTPListener">
    <property name="port">9090</property>
  </listener>
</service>

For the lookup service with downstream configuration:

<service id="lookup" class="com.example.LookupService">
  <property name="downstream-host">api.internal</property>
  <property name="downstream-port">9091</property>
  <listener class="org.bluezoo.gumdrop.http.HTTPListener">
    <property name="port">9092</property>
  </listener>
</service>

See Configuration for property naming conventions (downstream-host maps to setDownstreamHost) and the full dependency injection framework.

Summary

For more on the HTTP API, see HTTP Server & Client. For the HTTP client in detail, see the HTTP Client section.

Gumdrop