Skip to main content

Call an existing REST API with a typed proxy

What you'll build​

A typed Java client for a books REST API. You describe the API's endpoints as a Java interface, the next-generation RestClient implements the interface for you, and your code calls ordinary methods that send HTTP requests and return beans. So the guide runs anywhere, step 2 starts a small stand-in API on the JDK's built-in HTTP server; point the client at your real API instead when you have one. The client prints five lines, ending with Book 99: 404 Not Found.

Prerequisites and time​

  • Java 17 or newer and Maven 3.9 or newer.
  • org.apache.juneau:juneau-rest-client:10.0.0-SNAPSHOT. It sends requests with the JDK's HttpClient, so there is no other HTTP library to add.
  • Port 8080 free, for the stand-in API.
  • About 15 minutes.

10.0.0-SNAPSHOT needs the Apache snapshots repository, which the pom.xml below declares; Maven Central has only the 9.2.0 release today (see Install Juneau and Downloads).

Steps​

1. Create the project​

Make an empty directory and save this as pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>books</artifactId>
<version>1.0-SNAPSHOT</version>

<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<juneau.version>10.0.0-SNAPSHOT</juneau.version>
</properties>

<repositories>
<repository>
<id>apache-snapshots</id>
<url>https://repository.apache.org/snapshots/</url>
<releases><enabled>false</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>

<dependencies>
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-client</artifactId>
<version>${juneau.version}</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.6.4</version>
</plugin>
</plugins>
</build>
</project>

Put the Java files from the next steps in src/main/java/com/example/books/, and start each one with package com.example.books;.

2. Start a stand-in API​

BookStubServer.java answers GET /books, GET /books/1 and POST /books with fixed JSON, and 404 for anything else. Skip this step if you already have an API to call:

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

import java.io.*;
import java.net.*;

import com.sun.net.httpserver.*;

/**
* A stand-in for the real books API, on the JDK's built-in HTTP server. Answers with fixed JSON, and echoes a
* posted book back with id 3.
*/
public class BookStubServer {

/**
* Serves the stand-in API on port 8080 until the process is stopped.
*
* @param args Unused.
* @throws IOException If the port cannot be bound.
*/
public static void main(String[] args) throws IOException {
start(8080);
System.out.println("Books API stand-in on http://localhost:8080/books");
}

/**
* Starts the server.
*
* @param port The port, or {@code 0} for any free port.
* @return The running server; call {@code stop(0)} to shut it down.
* @throws IOException If the port cannot be bound.
*/
public static HttpServer start(int port) throws IOException {
var server = HttpServer.create(new InetSocketAddress("localhost", port), 0);
server.createContext("/books", x -> {
var path = x.getRequestURI().getPath();
var method = x.getRequestMethod();
if (method.equals("GET") && path.equals("/books"))
reply(x, 200, "[{\"id\":1,\"title\":\"Dune\",\"author\":\"Frank Herbert\"},"
+ "{\"id\":2,\"title\":\"Emma\",\"author\":\"Jane Austen\"}]");
else if (method.equals("GET") && path.equals("/books/1"))
reply(x, 200, "{\"id\":1,\"title\":\"Dune\",\"author\":\"Frank Herbert\"}");
else if (method.equals("POST") && path.equals("/books"))
reply(x, 200, new String(x.getRequestBody().readAllBytes(), UTF_8).replace("\"id\":0", "\"id\":3"));
else
reply(x, 404, "{\"error\":\"Not found\"}");
});
server.start();
return server;
}

private static void reply(HttpExchange x, int status, String json) throws IOException {
var bytes = json.getBytes(UTF_8);
x.getResponseHeaders().set("Content-Type", "application/json");
x.sendResponseHeaders(status, bytes.length);
try (var out = x.getResponseBody()) {
out.write(bytes);
}
}
}

3. Write the bean​

Book.java matches the JSON the API sends:

/**
* A book, as the books API sends it: {@code {"id":1,"title":"Dune","author":"Frank Herbert"}}.
*/
public class Book {

/** The id the API assigned. */
public int id;

/** The title. */
public String title;

/** The author. */
public String author;

/** Used by the parser. */
public Book() {}

/**
* Creates a book that has no id yet.
*
* @param title The title.
* @param author The author.
*/
public Book(String title, String author) {
this.title = title;
this.author = author;
}
}

4. Describe the API​

BookApi.java is the typed proxy interface. @Remote(path) is the common prefix, and each method says which request it sends:

import java.util.*;

import org.apache.juneau.http.*;
import org.apache.juneau.http.remote.*;
import org.apache.juneau.http.response.*;

/**
* The books API, described as a Java interface. {@code RestClient.remote(BookApi.class)} implements it.
*/
@Remote(path = "/books")
public interface BookApi {

/**
* Calls {@code GET /books}.
*
* @return Every book.
*/
@RemoteGet
List<Book> listBooks();

/**
* Calls {@code GET /books/{id}}.
*
* @param id The book id.
* @return The book.
* @throws NotFound If the API answers 404.
*/
@RemoteGet("/{id}")
Book getBook(@Path("id") int id) throws NotFound;

/**
* Calls {@code POST /books} with the book as a JSON body.
*
* @param book The book to add.
* @return The stored book, with its id.
*/
@RemotePost
Book addBook(@Content Book book);
}

5. Call it​

BookClient.java builds the client, asks it for a BookApi and calls the methods:

import java.io.*;

import org.apache.juneau.http.response.*;
import org.apache.juneau.marshall.json.*;
import org.apache.juneau.rest.client.*;

/**
* Calls the books API through {@link BookApi} and prints what comes back.
*/
public class BookClient {

/**
* Runs against the API root given as the first argument, or {@code http://localhost:8080}.
*
* @param args The API root URL, optional.
* @throws IOException If the API cannot be reached.
*/
public static void main(String[] args) throws IOException {
run(args.length > 0 ? args[0] : "http://localhost:8080", System.out);
}

/**
* Makes the calls and prints the results.
*
* @param rootUrl The API root URL.
* @param out Where to print.
* @throws IOException If the API cannot be reached.
*/
public static void run(String rootUrl, PrintStream out) throws IOException {
try (var client = RestClient.builder()
.rootUrl(rootUrl)
.defaultSerializer(JsonSerializer.DEFAULT)
.defaultParser(JsonParser.DEFAULT)
.allowPrivateUrls(true) // Only because the stand-in API runs on localhost.
.build()) {

BookApi books = client.remote(BookApi.class);

for (var b : books.listBooks())
out.println(b.id + ": " + b.title + " by " + b.author);

var added = books.addBook(new Book("Kindred", "Octavia E. Butler"));
out.println("Added " + added.title + " as id " + added.id);

out.println("Book 1 is " + books.getBook(1).title);

try {
books.getBook(99);
} catch (NotFound e) {
out.println("Book 99: 404 Not Found");
}
}
}
}

Run it​

Start the stand-in API in one terminal:

mvn compile exec:java -Dexec.mainClass=com.example.books.BookStubServer

Run the client in a second terminal:

mvn exec:java -Dexec.mainClass=com.example.books.BookClient

It prints:

1: Dune by Frank Herbert
2: Emma by Jane Austen
Added Kindred as id 3
Book 1 is Dune
Book 99: 404 Not Found

The client also logs one INFO line per request, with its status, method and URL. To call your own API, pass its root URL with -Dexec.args=https://api.example.com and remove the allowPrivateUrls(true) line.

How it works​

  • The proxy. client.remote(BookApi.class) returns a dynamic proxy. Each call joins the client's root URL, the @Remote path and the method's path, fills {id} from the @Path argument, serializes the @Content argument as the body and parses the response into the return type. The annotations are listed in Remote Proxy Interfaces and explained in REST Proxies.
  • Errors. Because getBook declares throws NotFound, a 404 response is thrown as NotFound. Declare the error types your code wants to handle.
  • Formats are explicit. The 10.0 client has no implicit JSON fallback, so the builder names the serializer and parser it uses with defaultSerializer(...) and defaultParser(...).
  • Private addresses are refused by default. Proxy calls to loopback, private and cloud-metadata addresses are blocked to guard against server-side request forgery. The stand-in runs on localhost, so the client opts out with allowPrivateUrls(true). See NG Client Security.
  • Which client. This is the next-generation client, org.apache.juneau.rest.client.RestClient, the recommended one for new code; Choosing a REST Client compares it with the classic client. Its proxies also support constant headers, timeouts, retries and more; see Declarative Remote-Proxy Features.

Next steps​