Web API - softwareconstruction240/softwareconstruction GitHub Wiki

🖥️ Slides: Server

🖥️ Slides: Server Implementation Tips

🖥️ Slides: Client

🖥️ Lecture Videos

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.

Web 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

Implementing Endpoints

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);

Serving Static Files

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.

Complete Server Example

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.

  1. Create a directory name public and put an index.html file in it that contains the text: <h1>Hello World</h1>.
  2. Run the code from a directory relative to the directory that contains the public directory.
  3. Open your browser and point it to localhost:8080. This should display the contents of your index.html file.
  4. Run the following commands with Curl
    1. curl localhost:8080/name, returns {"name":[]}
    2. curl -X POST localhost:8080/name/cow, returns {"name":["cow"]}
    3. curl -X POST localhost:8080/name/dog, returns {"name":["cow","dog"]}
    4. curl localhost:8080/name, returns {"name":["cow","dog"]}
    5. curl -X DELETE localhost:8080/name/dog, returns {"name":["cow"]}
    6. curl localhost:8080/name, returns {"name":["cow"]}

Serializing Requests and Responses

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.

Server Error Handling

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"}%

Web Client

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"]}

Client Error Handling

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));
            }
        }
    }
}

Writing a Request Body and Headers

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());
}

Implementing a Simple Curl

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}

Things to Understand

  • 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

Videos (1:14:27)

Demonstration code

📁 Client Web API

📁 Server Web API

⚠️ **GitHub.com Fallback** ⚠️