Web API - softwareconstruction240/softwareconstruction GitHub Wiki
🖥️ Slides: Server Implementation Tips
Now that you understand how HTTP works at a theoretical level you can write Java code to make requests from an HTTP client and respond from an HTTP server.
For our server code, we will use a library called JavaSpark. JavaSpark
makes it very easy to write an HTTP server that handles multiple endpoint requests. An endpoint is the code that handles a specific HTTP resource request. You can think of the service endpoints as being the public methods of the service interface.
As an example, let's write an HTTP service named name list
that maintains a list of names. To make the service useful we will provide the following endpoints.
Endpoint | HTTP Method | HTTP path | Purpose |
---|---|---|---|
addName | POST | /name/:name | Add the name represented by the name path variable |
listNames | GET | /name | Get the list of names |
deleteName | DELETE | /name/:name | Delete the name represented by the name path variable |
When you define an endpoint with JavaSpark
, you supply the HTTP method, path, and a Functional Interface method implementation that is called when the matching HTTP request is made. The path definition may contain variables, designated with a :
prefix, that are assigned to the values provided by the caller. For example, you would register the endpoint to add a name with the following implementation.
private void run() {
Spark.post("/name/:name", new Route() {
public Object handle(Request req, Response res) {
names.add(req.params(":name"));
return listNames(req, res);
}
});
}
private Object listNames(Request req, Response res) {
res.type("application/json");
return new Gson().toJson(Map.of("name", names));
}
In the above example, the Spark.post
method is called to handle HTTP POST requests for the /name/:name
path. The Spark.post
method takes two parameters, the HTTP path and an anonymous class implementation of the functional interface spark.Route
. The interface has one method named route
that is called when the HTTP method and path is matched by an incoming HTTP request. The route
method receives the Request
and Response
objects that represent the HTTP request and response. Our implementation then reads the path name
variable from the request and adds the name to an internal list of names.
The return value for the endpoint is generated by calling the listNames
method. This sets the Content-Type
HTTP header to application/json
and then serializes the current name list out as the response body of the HTTP response by returning a JSON string.
We can simplify the representation of our route handler by using a lambda function to call a method that implements the addName
endpoint.
private void run() {
Spark.post("/name/:name", (req, res) -> addName(req, res));
}
private Object addName(Request req, Response res) {
names.add(req.params(":name"));
return listNames(req, res);
}
private Object listNames(Request req, Response res) {
res.type("application/json");
return new Gson().toJson(Map.of("name", names));
}
Finally, since our lambda function is simply a passthrough to another function, we can replace it with the Java method reference
syntax.
Spark.post("/name/:name", this::addName);
An HTTP resource can represent anything. In the above example we are representing an in memory representation of a name list, but we can also represent a directory structure of files in persistent storage. JavaSpark
makes it easy to do this by calling a method named staticFiles.location
with the name of a storage directory that contains files we want to return over HTTP. Once the location is registered, Spark will look in the directory for a file that matches the URL path. If it is found then it returns the contents of the file. Spark will even examine the file to determine what Content-Type
header to set.
Spark.staticFiles.location("web");
By adding the above code to your server you can now make a request to the server with a path like /index.html
and it will return the index.html
file found in a directory named public
that is found in your application directory.
Here is the complete listing of server code for hosting static files and the name list
service endpoints.
import com.google.gson.Gson;
import spark.*;
import java.util.*;
public class ServerExample {
private ArrayList<String> names = new ArrayList<>();
public static void main(String[] args) {
new ServerExample().run();
}
private void run() {
// Specify the port you want the server to listen on
Spark.port(8080);
// Register a directory for hosting static files
Spark.externalStaticFileLocation("public");
// Register handlers for each endpoint using the method reference syntax
Spark.post("/name/:name", this::addName);
Spark.get("/name", this::listNames);
Spark.delete("/name/:name", this::deleteName);
}
private Object addName(Request req, Response res) {
names.add(req.params(":name"));
return listNames(req, res);
}
private Object listNames(Request req, Response res) {
res.type("application/json");
return new Gson().toJson(Map.of("name", names));
}
private Object deleteName(Request req, Response res) {
names.remove(req.params(":name"));
return listNames(req, res);
}
}
You can experiment with this code by doing the following.
- Create a directory name
public
and put anindex.html
file in it that contains the text:<h1>Hello World</h1>
. - Run the code from a directory relative to the directory that contains the
public
directory. - Open your browser and point it to
localhost:8080
. This should display the contents of yourindex.html
file. - Run the following commands with Curl
-
curl localhost:8080/name
, returns{"name":[]}
-
curl -X POST localhost:8080/name/cow
, returns{"name":["cow"]}
-
curl -X POST localhost:8080/name/dog
, returns{"name":["cow","dog"]}
-
curl localhost:8080/name
, returns{"name":["cow","dog"]}
-
curl -X DELETE localhost:8080/name/dog
, returns{"name":["cow"]}
-
curl localhost:8080/name
, returns{"name":["cow"]}
-
JSON is commonly used to send serialized objects over HTTP requests. Therefore you will want to use Gson to parse the body of HTTP requests into objects, and to create JSON that represents your response. The following is an example of a server with an echo
endpoint. It parses the request body into a Java Map
object and then serializes it back into the endpoint response.
public class ServerEchoExample {
public static void main(String[] args) {
new ServerEchoExample().run();
}
private void run() {
Spark.port(8080);
Spark.post("/echo", this::echoBody);
}
private Object echoBody(Request req, Response res) {
var bodyObj = getBody(req, Map.class);
res.type("application/json");
return new Gson().toJson(bodyObj);
}
private static <T> T getBody(Request request, Class<T> clazz) {
var body = new Gson().fromJson(request.body(), clazz);
if (body == null) {
throw new RuntimeException("missing required body");
}
return body;
}
}
The getBody
method is a generic method that will parse the request body into an object of the class that you specify. This pattern of combining generics, Gson, and HTTP bodies makes it easy to get data in and out of your service.
Build this code and try it out. Use curl to make your requests. Set breakpoints in your code and walk through what is happening. If you want to understand how Spark or Gson works then step into that code.
➜ curl localhost:8080/echo -d '{"name":"dog", "count":3}'
{"name":"dog","count":3.0}
Experiment with writing a Gson type adapter to control how objects are serialized.
In addition to representing endpoints, Spark provides methods for handling error cases. This includes the Spark.exception
method for when an unhandled exception is thrown, and the Spark.notFound
for when an unknown request is made. With both methods you provide the implementation of a functional interface for handling the error. The following code demonstrates how this is done.
public class ServerErrorsExample {
public static void main(String[] args) {
new ServerErrorsExample().run();
}
private void run() {
// Specify the port you want the server to listen on
Spark.port(8080);
// Register handlers for each endpoint using the method reference syntax
Spark.get("/error", this::throwError);
Spark.exception(Exception.class, this::errorHandler);
Spark.notFound((req, res) -> {
var msg = String.format("[%s] %s not found", req.requestMethod(), req.pathInfo());
return errorHandler(new Exception(msg), req, res);
});
}
private Object throwError(Request req, Response res) {
throw new RuntimeException("Server on fire");
}
public Object errorHandler(Exception e, Request req, Response res) {
var body = new Gson().toJson(Map.of("message", String.format("Error: %s", e.getMessage()), "success", false));
res.type("application/json");
res.status(500);
res.body(body);
return body;
}
}
When this server is running you will get the following results when you make requests using Curl.
➜ curl -X GET localhost:8080/unknownendpoint
{"success":false,"message":"Error: [GET] /unknownendpoint not found"}%
➜ curl -X GET localhost:8080/error
{"success":false,"message":"Error: Server on fire"}%
For our client code, we can use the standard JDK java.net
library to make HTTP requests. The following example hard codes the URL in order to simplify the essential pieces of the request.
public class ClientExample {
public static void main(String[] args) throws Exception {
// Specify the desired endpoint
URI uri = new URI("http://localhost:8080/name");
HttpURLConnection http = (HttpURLConnection) uri.toURL().openConnection();
http.setRequestMethod("GET");
// Make the request
http.connect();
// Output the response body
try (InputStream respBody = http.getInputStream()) {
InputStreamReader inputStreamReader = new InputStreamReader(respBody);
System.out.println(new Gson().fromJson(inputStreamReader, Map.class));
}
}
}
If you first run the name list
service defined above, then you can run the ClientExample
and see the full round trip HTTP request being handled by your Java code.
➜ java -cp ../../lib/gson-2.10.1.jar ClientExample.java
{name=["dog", "cat"]}
The http.getResponseCode()
method tells us what the HTTP status code was for the response. If you receive a non-2XX response then you need to use the getErrorStream()
method instead of getInputStream()
to read the response body. This is demonstrated in the following example:
public class ClientAdvancedExample {
public static void main(String[] args) throws Exception {
URI uri = new URI("http://localhost:8080/error");
HttpURLConnection http = (HttpURLConnection) uri.toURL().openConnection();
http.setRequestMethod("GET");
http.connect();
// Handle bad HTTP status
var status = http.getResponseCode();
if ( status >= 200 && status < 300) {
try (InputStream in = http.getInputStream()) {
System.out.println(new Gson().fromJson(new InputStreamReader(in), Map.class));
}
} else {
try (InputStream in = http.getErrorStream()) {
System.out.println(new Gson().fromJson(new InputStreamReader(in), Map.class));
}
}
}
}
To send an HTTP body or header using the HttpURLConnection
class you must first specify http.setDoOutput
to true. You can then set a header using addRequestProperty
, or send a body using the stream returned from getOutputStream
.
// Specify that we are going to write out data
http.setDoOutput(true);
// Write out a header
http.addRequestProperty("Content-Type", "application/json");
// Write out the body
var body = Map.of("name", "joe", "type", "cat");
try (var outputStream = http.getOutputStream()) {
var jsonBody = new Gson().toJson(body);
outputStream.write(jsonBody.getBytes());
}
We can expand our Web Client example to implement a simple version of Curl. This example reads the HTTP method, URL, and body from the command line parameters. Using that information, it makes an HTTP request and receives a response.
public class ClientCurlExample {
public static void main(String[] args) throws Exception {
if (args.length >= 2) {
var method = args[0];
var url = args[1];
var body = args.length == 3 ? args[2] : "";
HttpURLConnection http = sendRequest(url, method, body);
receiveResponse(http);
} else {
System.out.println("ClientCurlExample <method> <url> [<body>]");
}
}
private static HttpURLConnection sendRequest(String url, String method, String body) throws URISyntaxException, IOException {
URI uri = new URI(url);
HttpURLConnection http = (HttpURLConnection) uri.toURL().openConnection();
http.setRequestMethod(method);
writeRequestBody(body, http);
http.connect();
System.out.printf("= Request =========\n[%s] %s\n\n%s\n\n", method, url, body);
return http;
}
private static void writeRequestBody(String body, HttpURLConnection http) throws IOException {
if (!body.isEmpty()) {
http.setDoOutput(true);
try (var outputStream = http.getOutputStream()) {
outputStream.write(body.getBytes());
}
}
}
private static void receiveResponse(HttpURLConnection http) throws IOException {
var statusCode = http.getResponseCode();
var statusMessage = http.getResponseMessage();
Object responseBody = readResponseBody(http);
System.out.printf("= Response =========\n[%d] %s\n\n%s\n\n", statusCode, statusMessage, responseBody);
}
private static Object readResponseBody(HttpURLConnection http) throws IOException {
Object responseBody = "";
try (InputStream respBody = http.getInputStream()) {
InputStreamReader inputStreamReader = new InputStreamReader(respBody);
responseBody = new Gson().fromJson(inputStreamReader, Map.class);
}
return responseBody;
}
}
If we start up the Echo Server
example that we created above, we can make a wide variety of HTTP service request using our simple Curl client.
➜ webapi git:(master) ✗
java -cp ../../lib/gson-2.10.1.jar ClientCurlExample.java POST 'http://localhost:8080/echo' '{"name":"joe", "count":3}'
= Request =========
[POST] http://localhost:8080/echo
{"name":"joe", "count":3}
= Response =========
[200] OK
{name=joe, count=3.0}
- Server code example (Ticket to Ride)
- Writing the main Server class
- Writing HTTP handlers for GET and POST requests
- Implementing the Test Web Page using a FileHandler
- Writing a web client