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'sHttpClient, 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@Remotepath and the method's path, fills{id}from the@Pathargument, serializes the@Contentargument 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
getBookdeclaresthrows NotFound, a 404 response is thrown asNotFound. 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(...)anddefaultParser(...). - 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 withallowPrivateUrls(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
- NG Remote Proxies: constant headers and query values, retries, timeouts and multipart bodies on the same interface.
- Test an endpoint with MockRestClient: test the server side in-process.
- HTTP Transport Adapters: swap the JDK
HttpClientfor Apache HttpClient, OkHttp or Jetty.