Skip to content

Object Mapping

HTTP bodies are often mappable to high-level entities that your code understands. Java's HttpClient was designed with that in mind. However, available BodyPublisher & BodySubscriber implementations are too basic, and implementing your own can be tricky. Methanol builds upon these APIs with an extensible and easy-to-use object mapping mechanism that treats your objects as first-citizen HTTP bodies.

Setup

Before sending and receiving objects over HTTP, Methanol needs to adapt to your desired mapping schemes. Adapters for the most popular serialization libraries are provided in separate modules.

Adapters are dynamically located using Java's ServiceLoader. You can find clear installation steps in each module. We'll see how to implement custom adapters as well.

If you want to run examples presented here, get started by installing your favorite JSON adapter!

Receiving Objects

To get an HttpResponse<T>, give MoreBodyHandlers a T.class and it'll give you a BodyHandler<T> in return.

final Methanol client = Methanol.newBuilder()
    .baseUri("https://api.github.com/")
    .defaultHeader("Accept", "application/vnd.github.v3+json")
    .build();

GitHubUser getUser(String username) throws IOException, InterruptedException {
  var request = MutableRequest.GET("user/" + username);
  var response = client.send(request, MoreBodyHandlers.ofObject(GitHubUser.class));

  return response.body();
}

public static final class GitHubUser {
  public String login;
  public long id;
  public String url;

  // Other fields omitted. 
  // Annotate with @JsonIgnoreProperties(ignoreUnknown = true) to run with Jackson.
}

If you want to get fancier with generics, use a TypeRef<T>.

final Methanol client = Methanol.newBuilder()
    .baseUri("https://api.github.com/")
    .defaultHeader("Accept", "application/vnd.github.v3+json")
    .build();

List<GitHubIssue> getIssuesForRepo(String owner, String repo) throws IOException, InterruptedException {
  var request = MutableRequest.GET("repos/" + owner + "/" + repo +  "/issues");
  var response = client.send(
    request, MoreBodyHandlers.ofObject(new TypeRef<List<GitHubIssue>>() {}));

  return response.body();
}

public static final class GitHubIssue {
  public String title;
  public GitHubUser user;
  public String body;

  // Other fields omitted. 
  // Annotate with @JsonIgnoreProperties(ignoreUnknown = true) to run with Jackson.
}

public static final class GitHubUser {
  public String login;
  public long id;
  public String url;

  // Other fields omitted. 
  // Annotate with @JsonIgnoreProperties(ignoreUnknown = true) to run with Jackson.
}

The right adapter is selected based on response's Content-Type. For instance, a response with Content-Type: application/json causes Methanol to look for a JSON adapter. If such lookup fails, an UnsupportedOperationException is thrown.

Sending Objects

Get a BodyPubilsher for whatever object you've got by passing it in along with a MediaType describing which adapter you prefer selected.

final Methanol client = Methanol.newBuilder()
    .baseUri("https://api.github.com/")
    .defaultHeader("Accept", "application/vnd.github.v3+json")
    .build();

String renderMarkdown(RenderRequest renderRequest) throws IOException, InterruptedException {
  var requestBody = MoreBodyPublishers.ofObject(renderRequest, MediaType.APPLICATION_JSON);
  var request = MutableRequest.POST("markdown", requestBody);
  var response = client.send(request, BodyHandlers.ofString());

  return response.body();
}

public static final class RenderRequest {
  public String text, mode, context;
}

Adapters

An adapter provides Encoder and/or Decoder implementations. Both interfaces implement BodyAdapter, which defines the methods necessary for Methanol to know which object types the adapter believes it can handle, and in what scheme. An Encoder creates a BodyPublisher that streams a given object's serialized form. Similarly, a Decoder supplies BodySubscriber<T> instances for a given TypeRef<T> that convert the response body into T. An optional MediaType is passed to encoders & decoders to further describe the desired mapping scheme (e.g. specify a character set).

Example - An HTML Adapter

Here's an adapter that uses Jsoup to convert HTML bodies to parsed Document objects and vise versa. When you're writing adapters, extend from AbstractBodyAdapter to get free media type matching & other helpful functions.

public abstract class JsoupAdapter extends AbstractBodyAdapter {
  JsoupAdapter() {
    super(MediaType.TEXT_HTML);
  }

  @Override
  public boolean supportsType(TypeRef<?> type) {
    return type.rawType() == Document.class;
  }

  public static final class Decoder extends JsoupAdapter implements BodyAdapter.Decoder {
    @Override
    public <T> BodySubscriber<T> toObject(TypeRef<T> type, @Nullable MediaType mediaType) {
      requireSupport(type);
      requireCompatibleOrNull(mediaType);

      var charset = charsetOrUtf8(mediaType);
      var subscriber = BodySubscribers.mapping(BodySubscribers.ofString(charset), Jsoup::parse);
      return BodySubscribers.mapping(subscriber, type.exactRawType()::cast); // Safely cast Document to T
    }
  }

  public static final class Encoder extends JsoupAdapter implements BodyAdapter.Encoder {
    @Override
    public BodyPublisher toBody(Object object, @Nullable MediaType mediaType) {
      requireSupport(object.getClass());
      requireCompatibleOrNull(mediaType);

      var charset = charsetOrUtf8(mediaType);
      var publisher = BodyPublishers.ofString(((Document) object).outerHtml(), charset);
      return attachMediaType(publisher, mediaType);
    }
  }
}

Tip

Make sure your encoders call AbstractBodyAdapter::attachMediaType so the created BodyPublisher is converted to a MimeBodyPublisher if the given media type isn't null. That way, requests get the correct Content-Type header added by Methanol.

Registration

Declare your encoder & decoder implementations as service-providers in the manner specified by Java's ServiceLoader. Here's the appropriate provider declarations for our Jsoup adapter to put in module-info.java.

module my.module {
  ...

  provides BodyAdapter.Decoder with JsoupAdapter.Decoder;
  provides BodyAdapter.Encoder with JsoupAdapter.Encoder;
}

See any of the supported adapters for more registration methods.

Usage

Now Methanol can send and receive HTML Documents!

final Methanol client = Methanol.create();

HttpResponse<Document> downloadHtml(String url) throws IOException, InterruptedException {
  var request = MutableRequest.GET(url).header("Accept", "text/html");

  return client.send(request, MoreBodyHandlers.ofObject(Document.class));
}

<T> HttpResponse<T> uploadHtml(String url, Document htmlDoc, BodyHandler<T> responseHandler) 
    throws IOException, InterruptedException {
  var requestBody = MoreBodyPublishers.ofObject(htmlDoc, MediaType.TEXT_HTML);
  var request = MutableRequest.POST(url, requestBody);

  return client.send(request, responseHandler);
}

Buffering vs Streaming

MoreBodyHandlers::ofObject creates handlers that use MoreBodySubscribers::ofObject to obtain the appropriate BodySubscriber<T> from a chosen adapter. Such subscriber typically loads the whole response into memory then decodes from there. If your responses tend to have large bodies, or you'd prefer the memory efficiency afforded by streaming sources, MoreBodyHandlers::ofDeferredObject is the way to go.

final Methanol client = Methanol.newBuilder()
    .baseUri("https://api.github.com/")
    .defaultHeader("Accept", "application/vnd.github.v3+json")
    .build();

GitHubUser getUser(String username) throws IOException, InterruptedException {
  var request = MutableRequest.GET("user/" + username);
  var response = client.send(request, MoreBodyHandlers.ofDeferredObject(GitHubUser.class));

  return response.body().get();
}

public static final class GitHubUser {
  public String login;
  public long id;
  public String url;

  // Other fields omitted. 
  // Annotate with @JsonIgnoreProperties(ignoreUnknown = true) to run with Jackson.
}

The handler results in an HttpResponse<Supplier<T>>. The response is completed as soon as all headers are read. If the chosen decoder's toDeferredObject is implemented correctly, processing is deferred till you invoke the supplier and the body is decoded from a streaming source, typically an InputStream or a Reader.

The Decoder interface has a naive default implementation for toDeferredObject that doesn't read from a streaming source. Here's how it'd be properly implemented for our HTML adapter's decoder.

@Override
public <T> BodySubscriber<Supplier<T>> toDeferredObject(
    TypeRef<T> type, @Nullable MediaType mediaType) {
  requireSupport(type);
  requireCompatibleOrNull(mediaType);

  var charset = charsetOrUtf8(mediaType);
  BodySubscriber<Supplier<Document>> subscriber = BodySubscribers.mapping(
      MoreBodySubscribers.ofReader(charset),
      reader -> () -> Parser.htmlParser().parseInput(new BufferedReader(reader), "")); // Note the deferred parsing
  return BodySubscribers.mapping(
      subscriber,
      supplier -> () -> type.exactRawType().cast(supplier.get())); // Safely cast Document to T
}