A quick Google search of Java REST frameworks will lead you to tons of existing frameworks. For example you can easily find tons of Spring Boot REST examples which have very few lines of code. However, what they do have is lots of annotations and lots of magic. Also, most of them don't do very much, they tend leave out exception handling, logging and metrics. Here is an example of a lightweight REST server with a lot of the appropriate middleware, no magic and not that many lines of custom code. This example does not include validation or databases, we will save that for a later date.

Model / Pojo

Our service will be very simple and only handle CRUD operations for a User class.

public class User {
    private final String email;
    private final Set<Role> roles;
    private final LocalDate dateCreated;

    public User(
            @JsonProperty("email") String email,
            @JsonProperty("roles") Set<Role> roles,
            @JsonProperty("dateCreated") LocalDate dateCreated) {
        super();
        this.email = email;
        this.roles = roles;
        this.dateCreated = dateCreated;
    }

    public String getEmail() {
        return email;
    }

    public Set<Role> getRoles() {
        return roles;
    }

    public LocalDate getDateCreated() {
        return dateCreated;
    }

    public static enum Role {
        USER, ADMIN
    }

    private static final TypeReference<User> typeRef = new TypeReference<User>() {};
    public static TypeReference<User> typeRef() {
        return typeRef;
    }
    private static final TypeReference<List<User>> listTypeRef = new TypeReference<List<User>>() {};
    public static TypeReference<List<User>> listTypeRef() {
        return listTypeRef;
    }
}

In Memory Dao

Simple in memory dao just for an example.

/*
 * In memory Dao. Less than ideal but just for an example.
 */
public class UserDao {
    private final ConcurrentMap<String, User> userMap;

    public UserDao() {
        this.userMap = new ConcurrentHashMap<>();
    }

    public User create(String email, Set<User.Role> roles) {
        User user = new User(email, roles, LocalDate.now());

        // If we get a non null value that means the user already exists in the Map.
        if (null != userMap.putIfAbsent(user.getEmail(), user)) {
            return null;
        }
        return user;
    }

    public User get(String email) {
        return userMap.get(email);
    }

    // Alternate implementation to throw exceptions instead of return nulls for not found.
    public User getThrowNotFound(String email) {
        User user = userMap.get(email);
        if (null == user) {
            throw Exceptions.notFound(String.format("User %s not found", email));
        }
        return user;
    }

    public User update(User user) {
        // This means no user existed so update failed. return null
        if (null == userMap.replace(user.getEmail(), user)) {
            return null;
        }
        // Update succeeded return the user
        return user;
    }

    public boolean delete(String email) {
        return null != userMap.remove(email);
    }

    public List<User> listUsers() {
        return userMap.values()
                      .stream()
                      .sorted(Comparator.comparing((User u) -> u.getEmail()))
                      .collect(Collectors.toList());
    }
}

Request Utilities

Simple helpers to reduce boilerplate and reuse defaults / string literals across routes. In Spring its not uncommon to see something like @PathParam("userId") littered all over the code, or worse some say userId some say id and it can be inconsistent. Of course its possible to write your own custom annotations in Spring for reuse but who wants to do that for every single param? (There are other and better approaches out there but they don't seem to be followed very often).

public class UserRequests {

    public String email(HttpServerExchange exchange) {
        return Exchange.pathParams().pathParam(exchange, "email").orElse(null);
    }

    public User user(HttpServerExchange exchange) {
        return Exchange.body().parseJson(exchange, User.typeRef());
    }

    public void exception(HttpServerExchange exchange) {
        boolean exception = Exchange.queryParams()
                                    .queryParamAsBoolean(exchange, "exception")
                                    .orElse(false);
        if (exception) {
            throw new RuntimeException("Some random exception. Could be anything!");
        }
    }
}

Handlers

public class UserRoutes {
    private static final UserRequests userRequests = new UserRequests();
    private static final UserDao userDao = new UserDao();

    public static void createUser(HttpServerExchange exchange) {
        User userInput = userRequests.user(exchange);
        User user = userDao.create(userInput.getEmail(), userInput.getRoles());
        if (null == user) {
            ApiHandlers.badRequest(exchange, String.format("User %s already exists.", userInput.getEmail()));
            return;
        }
        exchange.setStatusCode(StatusCodes.CREATED);
        Exchange.body().sendJson(exchange, user);
    }

    public static void getUser(HttpServerExchange exchange) {
        String email = userRequests.email(exchange);
        User user = userDao.get(email);
        if (null == user) {
            ApiHandlers.notFound(exchange, String.format("User %s not found.", email));
            return;
        }
        Exchange.body().sendJson(exchange, user);
    }

    // Alternative Not Found by throwing / handling Exceptions.
    public static void getUserThrowNotFound(HttpServerExchange exchange) {
        String email = userRequests.email(exchange);
        User user = userDao.getThrowNotFound(email);
        Exchange.body().sendJson(exchange, user);
    }

    public static void updateUser(HttpServerExchange exchange) {
        User userInput = userRequests.user(exchange);
        User user = userDao.update(userInput);
        if (null == user) {
            ApiHandlers.notFound(exchange, String.format("User {} not found.", userInput.getEmail()));
            return;
        }
        Exchange.body().sendJson(exchange, user);
    }

    public static void deleteUser(HttpServerExchange exchange) {
        String email = userRequests.email(exchange);

        // If you care about it you can handle it.
        if (false == userDao.delete(email)) {
            ApiHandlers.notFound(exchange, String.format("User {} not found.", email));
            return;
        }
        exchange.setStatusCode(StatusCodes.NO_CONTENT);
        exchange.endExchange();
    }

    public static void listUsers(HttpServerExchange exchange) {
        List<User> users = userDao.listUsers();
        Exchange.body().sendJson(exchange, users);
    }
}

Notice how there are two options for handling NotFound in the server. You can throw / handle exceptions or handle it directy in the route since we already know its not found. Spring and most frameworks tend to push for the throw / handle exception model. It is easy to do that, just remember throwing and catching exceptions can be expensive, in these cases it is easily avoidable so pick what makes sense for your business domain.

Routing / Server

Notice we have all the rest routes as well as exception handling / logging / metrics middleware.

public static final RoutingHandler ROUTES = new RoutingHandler()
    .get("/users", timed("listUsers", UserRoutes::listUsers))
    .get("/users/{email}", timed("getUser", UserRoutes::getUser))
    .get("/users/{email}/exception", timed("getUser", UserRoutes::getUserThrowNotFound))
    .post("/users", timed("createUser", UserRoutes::createUser))
    .put("/users", timed("updateUser", UserRoutes::updateUser))
    .delete("/users/{email}", timed("deleteUser", UserRoutes::deleteUser))
    .get("/metrics", timed("metrics", CustomHandlers::metrics))
    .get("/health", timed("health", CustomHandlers::health))
    .setFallbackHandler(timed("notFound", RoutingHandlers::notFoundHandler))
;

/*
 *  Small wrapper to mimic throwing exceptions. Just add &exception=true
 *  to any route and this will throw an exception. Notice it throws a RuntimeException
 *  not an API exception. This will be handled by the global ExceptionHandler.
 */
private static final HttpHandler EXCEPTION_THROWER = (HttpServerExchange exchange) -> {
    new UserRequests().exception(exchange);
    ROUTES.handleRequest(exchange);
};

public static final HttpHandler ROOT = CustomHandlers.exception(EXCEPTION_THROWER)
    .addExceptionHandler(ApiException.class, ApiHandlers::handleApiException)
    .addExceptionHandler(Throwable.class, ApiHandlers::serverError)
;
public static void main(String[] args) {
    // Once again pull in a bunch of common middleware.
    SimpleServer server = SimpleServer.simpleServer(Middleware.common(ROOT));
    server.start();
}

Examples

Create User

curl -X POST "localhost:8080/users" -d '
{
  "email": "user1@test.com",
  "roles": ["USER"]
}
';
{"email":"user1@test.com","roles":["USER"],"dateCreated":"2017-01-16"}

curl -X POST "localhost:8080/users" -d '
{
  "email": "user2@test.com",
  "roles": ["ADMIN"]
}
';
{"email":"user2@test.com","roles":["ADMIN"],"dateCreated":"2017-01-16"}

Update User

curl -X PUT "localhost:8080/users" -d '
{
  "email": "user2@test.com",
  "roles": ["USER", "ADMIN"]
}
';
{"email":"user2@test.com","roles":["ADMIN","USER"]}

List Users

curl -X GET "localhost:8080/users"
[{"email":"user1@test.com","roles":["USER"],"dateCreated":"2017-01-16"},{"email":"user2@test.com","roles":["ADMIN","USER"]}]

Get User

We are using both styles of get user here.

curl -X GET "localhost:8080/users/user1@test.com"
{"email":"user1@test.com","roles":["USER"],"dateCreated":"2017-01-16"}

curl -X GET "localhost:8080/users/user1@test.com/exception"
{"email":"user1@test.com","roles":["USER"],"dateCreated":"2017-01-16"}

Delete User

curl -v -X DELETE "localhost:8080/users/user1@test.com"
* Connected to localhost (127.0.0.1) port 8080 (#0)
> DELETE /users/user1@test.com HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.49.1
> Accept: */*
>
< HTTP/1.1 204 No Content
< Date: Mon, 16 Jan 2017 18:45:52 GMT

Get User 404

Both approaches respond in the same way however the exception handling version will be more expensive. It has to unroll the stack trace and whatnot. If that is acceptable by all means go for it. If you need maxiumim performance don't throw exceptions when you don't need to.

curl -X GET "localhost:8080/users/user1@test.com"
{"statusCode":404,"message":"User user1@test.com not found."}

curl -X GET "localhost:8080/users/user1@test.com/exception"
{"statusCode":404,"message":"User user1@test.com not found"}

List Users Again

curl -X GET "localhost:8080/users"
[{"email":"user2@test.com","roles":["ADMIN","USER"]}]

Handle Unknown Exception

Adding exception=true to any route will throw a RuntimeException. Notice this exception is handled with the fallback handler that handles any Throwable.class.

curl -X GET "localhost:8080/users?exception=true"
{"statusCode":500,"message":"Internal Server Error"}

Metrics

Once again we have all of our metrics from our middleware logic.

{
  "version": "3.0.0",
  "gauges": {},
  "counters": {},
  "histograms": {},
  "meters": {
    "status.code.200": {
      "count": 6,
      "m15_rate": 17.219082651255942,
      "m1_rate": 0.5436625964502019,
      "m5_rate": 8.9579872747367,
      "mean_rate": 1.1273159803060255,
      "units": "events/minute"
    },
    "status.code.201": {
      "count": 1,
      "m15_rate": 9.397673938878663,
      "m1_rate": 0.3067383984780894,
      "m5_rate": 5.7636636130775925,
      "mean_rate": 0.2625854307076081,
      "units": "events/minute"
    },
    "status.code.204": {
      "count": 1,
      "m15_rate": 10.101505049683713,
      "m1_rate": 0.9062621341052866,
      "m5_rate": 7.158067076339619,
      "mean_rate": 0.3709252334705832,
      "units": "events/minute"
    },
    "status.code.400": {
      "count": 1,
      "m15_rate": 10.858049016431512,
      "m1_rate": 2.6775619217811597,
      "m5_rate": 8.889818648180613,
      "mean_rate": 0.6165310747609489,
      "units": "events/minute"
    },
    "status.code.404": {
      "count": 2,
      "m15_rate": 10.68590211257018,
      "m1_rate": 2.878023977404449,
      "m5_rate": 8.514829995177887,
      "mean_rate": 1.033923244541407,
      "units": "events/minute"
    },
    "status.code.500": {
      "count": 1,
      "m15_rate": 11.542290915278693,
      "m1_rate": 6.696421749240567,
      "m5_rate": 10.678581251856285,
      "mean_rate": 1.3928552880390135,
      "units": "events/minute"
    }
  },
  "timers": {
    "createUser": {
      "count": 2,
      "max": 468.563385,
      "mean": 230.19748354543808,
      "min": 2.3202819999999997,
      "p50": 2.3202819999999997,
      "p75": 468.563385,
      "p95": 468.563385,
      "p98": 468.563385,
      "p99": 468.563385,
      "p999": 468.563385,
      "stddev": 233.06255505190282,
      "m15_rate": 0.0939588952884057,
      "m1_rate": 0.010507188050111582,
      "m5_rate": 0.13998158006315464,
      "mean_rate": 0.36653143968815943,
      "duration_units": "milliseconds",
      "rate_units": "calls/minute"
    },
    "deleteUser": {
      "count": 1,
      "max": 4.8252109999999995,
      "mean": 4.8252109999999995,
      "min": 4.8252109999999995,
      "p50": 4.8252109999999995,
      "p75": 4.8252109999999995,
      "p95": 4.8252109999999995,
      "p98": 4.8252109999999995,
      "p99": 4.8252109999999995,
      "p999": 4.8252109999999995,
      "stddev": 0,
      "m15_rate": 0.055963873354550935,
      "m1_rate": 0.0724607194316669,
      "m5_rate": 0.11831244221923884,
      "mean_rate": 0.18326783459947024,
      "duration_units": "milliseconds",
      "rate_units": "calls/minute"
    },
    "getUser": {
      "count": 4,
      "max": 10.177731,
      "mean": 4.490080459374723,
      "min": 1.042088,
      "p50": 1.575589,
      "p75": 10.177731,
      "p95": 10.177731,
      "p98": 10.177731,
      "p99": 10.177731,
      "p999": 10.177731,
      "stddev": 4.276904527854368,
      "m15_rate": 0.22399902400953256,
      "m1_rate": 0.42427789694811446,
      "m5_rate": 0.47991807604468867,
      "mean_rate": 0.7330560454806502,
      "duration_units": "milliseconds",
      "rate_units": "calls/minute"
    },
    "listUsers": {
      "count": 2,
      "max": 18.343650999999998,
      "mean": 1.9680702759999467,
      "min": 1.244971,
      "p50": 1.244971,
      "p75": 1.244971,
      "p95": 1.244971,
      "p98": 18.343650999999998,
      "p99": 18.343650999999998,
      "p999": 18.343650999999998,
      "stddev": 3.441100196972346,
      "m15_rate": 0.1111005918141424,
      "m1_rate": 0.3354051301588863,
      "m5_rate": 0.2403451569244876,
      "mean_rate": 0.3665140477638753,
      "duration_units": "milliseconds",
      "rate_units": "calls/minute"
    },
    "metrics": {
      "count": 0,
      "max": 0,
      "mean": 0,
      "min": 0,
      "p50": 0,
      "p75": 0,
      "p95": 0,
      "p98": 0,
      "p99": 0,
      "p999": 0,
      "stddev": 0,
      "m15_rate": 0,
      "m1_rate": 0,
      "m5_rate": 0,
      "mean_rate": 0,
      "duration_units": "milliseconds",
      "rate_units": "calls/minute"
    },
    "notFound": {
      "count": 1,
      "max": 6.1470899999999995,
      "mean": 6.1470899999999995,
      "min": 6.1470899999999995,
      "p50": 6.1470899999999995,
      "p75": 6.1470899999999995,
      "p95": 6.1470899999999995,
      "p98": 6.1470899999999995,
      "p99": 6.1470899999999995,
      "p999": 6.1470899999999995,
      "stddev": 0,
      "m15_rate": 0.06648182394123926,
      "m1_rate": 0.9594670244481206,
      "m5_rate": 0.1983425541405901,
      "mean_rate": 0.18326906627298534,
      "duration_units": "milliseconds",
      "rate_units": "calls/minute"
    },
    "updateUser": {
      "count": 1,
      "max": 2.389231,
      "mean": 2.389231,
      "min": 2.389231,
      "p50": 2.389231,
      "p75": 2.389231,
      "p95": 2.389231,
      "p98": 2.389231,
      "p99": 2.389231,
      "p999": 2.389231,
      "stddev": 0,
      "m15_rate": 0.04710994577423798,
      "m1_rate": 0.005472367185912239,
      "m5_rate": 0.07057403311423895,
      "mean_rate": 0.18326677583386106,
      "duration_units": "milliseconds",
      "rate_units": "calls/minute"
    }
  }
}

Building an Executable Fat JAR

Check out our post on Multi-project builds with Gradle and Fat Jars with Shadow

Java Client with OkHttp

cURL is great for debugging and testing, however, you will probably want programatic access to your API with a Java HTTP Client (OkHttp)