Simple REST server written in native Java
Sometimes it’s good to see how things work or look like without using a framework that hide/ease these things. That’s why I wanted to write a simple REST service in Java following the next objectives:
- Using no framework at all
- Using TDD, clean code and SOLID patterns
- Taking into account the concurrent users that it can support.
Moreover, I won’t be using any data layer at all, but will be using an in-memory storage instead.
Starting point
I will be using Maven to build my solution and the following test dependencies to write test scenarios:
- junit 5: which is almost the standard for writing unit tests in Java.
- rest-assured: to test REST interfaces easily
Note that you can download the source code in here.
Where the main class org.sgitario.Application
is:
package org.sgitario;
public class Application {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
When building the application using mvn clean install
and then running the jar file using java -jar target/pure-java-rest-api-1.0-SNAPSHOT.jar
, we will see Hello World!
.
1: Functional Requirements
What do we want to design?
- GET method
/api/books
that returns the list of books we have in our store. - PUT method
/api/books/{title}
that adds a new book with its author. - GET method
/api/books/{title}
that returns the author of the book - DELETE method
/api/books/{title}
that deletes an existing book. - POST method
/api/books
that creates/updates the book’s author.
2: Implement GET method /api/books
Let’s first write the test:
class GetBooksIntegrationTest extends BaseIntegrationTest {
@Test
void testGetBooksWhenNoBooksThenItShouldReturnEmptyList() {
get("/api/books")
.then()
.statusCode(200)
.body(is("[]"));
}
}
Update the application to start our server:
public class Application {
public static final int SERVER_PORT = 8000;
private HttpServer server;
public void start() throws IOException {
var controllers = ServiceLoader.load(RestController.class);
server = HttpServer.create(new InetSocketAddress(SERVER_PORT), 0);
server.createContext("/", (exchange -> {
boolean handled = false;
for (var controller : controllers) {
if (exchange.getRequestURI().getPath().matches(controller.path())
&& exchange.getRequestMethod().equalsIgnoreCase(controller.method().name())) {
String response = controller.handle(exchange);
if (response != null) {
OutputStream output = exchange.getResponseBody();
output.write(response.getBytes());
output.flush();
}
exchange.close();
handled = true;
break;
}
}
if (!handled) {
exchange.sendResponseHeaders(404, -1);
}
}));
server.setExecutor(Executors.newCachedThreadPool()); // creates a default executor
server.start();
}
public void stop() {
if (server != null) {
server.stop(0);
server = null;
}
}
public static void main(String[] args) throws IOException {
new Application().start();
}
}
Notes:
- We have used the ServiceLoader API from Java, so we can easily add/modify the REST controllers we’ll support
- We have implemented the start/stop methods, so we can easily start and stop the server in the tests
Next, the controller:
public class GetBooksRestController implements RestController {
private final BookService bookService;
public GetBooksRestController() {
this(new BookService());
}
public GetBooksRestController(BookService bookService) {
this.bookService = bookService;
}
@Override
public String path() {
return "/api/books$";
}
@Override
public Method method() {
return Method.GET;
}
@Override
public String handle(HttpExchange exchange) throws IOException {
String response = bookService.getBooks().stream().map(Book::title).toList().toString();
exchange.sendResponseHeaders(200, response.getBytes().length);
return response;
}
}
Notes:
- The simplest approach I could think of to implement the REST controller
- Two constructors: one for app and another one for the test. This is because we’re not using any CDI framework.
And the repository:
public class BookRepository {
private static final Map<String, Book> BOOKS = new ConcurrentHashMap<>();
public List<Book> getBooks() {
return new ArrayList<>(BOOKS.values());
}
}
Notes:
- Like I said earlier, we’re not going to use any database, so let’s keep the books in an in-memory collection.
- Using
ConcurrentHashMap
implementation to not lock atget
andupdate
levels (by compared with sync hash map).
With the above changes, now our test will pass.
3: Implement PUT method /api/books/{title}
The test:
class AddBookByTitleIntegrationTest extends BaseIntegrationTest {
@Test
void testAddBookWhenEmptyRequestShouldReturn400() {
RestAssured.given()
.put("/api/books/Quijote")
.then()
.statusCode(400);
}
@Test
void testAddBookShouldReturn201() {
RestAssured.given().body("Cervantes")
.put("/api/books/Quijote")
.then()
.statusCode(201);
}
}
The controller:
public class AddBookByTitleRestController implements RestController {
private final BookService bookService;
public AddBookByTitleRestController() {
this(new BookService());
}
public AddBookByTitleRestController(BookService bookService) {
this.bookService = bookService;
}
@Override
public String path() {
return "/api/books/(\\w)+$";
}
@Override
public Method method() {
return Method.PUT;
}
@Override
public String handle(HttpExchange exchange) throws IOException {
var parts = exchange.getRequestURI().getPath().split("/");
String title = parts[parts.length - 1];
String author = new String(exchange.getRequestBody().readAllBytes());
if (author.isEmpty()) {
exchange.sendResponseHeaders(400, 0);
return null;
}
bookService.addBook(new Book(title, author));
exchange.sendResponseHeaders(201, 0);
return null;
}
}
The “addBook” in the book service:
public void addBook(Book book) {
bookRepository.addBook(book);
}
And the one in the book repository:
public void addBook(Book book) {
BOOKS.put(book.title(), book);
}
With these changes, the test will now pass.
4: Implement GET method /api/books/{title}
The test:
class GetAuthorByBookIntegrationTest extends BaseIntegrationTest {
@Test
void testWhenBookDoesNotExistThenReturns404() {
get("/api/books/Quijote")
.then()
.statusCode(404);
}
@Test
void testWhenBookExistsThenReturnsAuthor() {
givenExistingBook("Quijote", "Cervantes");
get("/api/books/Quijote")
.then()
.statusCode(200)
.body(is("Cervantes"));
}
}
The controller:
public class GetAuthorByBookRestController implements RestController {
private final BookService bookService;
public GetAuthorByBookRestController() {
this(new BookService());
}
public GetAuthorByBookRestController(BookService bookService) {
this.bookService = bookService;
}
@Override
public String path() {
return "/api/books/(\\w)+$";
}
@Override
public Method method() {
return Method.GET;
}
@Override
public String handle(HttpExchange exchange) throws IOException {
var parts = exchange.getRequestURI().getPath().split("/");
String title = parts[parts.length - 1];
String response = bookService.getAuthorByBook(title);
if (response == null) {
exchange.sendResponseHeaders(404, -1);
} else {
exchange.sendResponseHeaders(200, response.getBytes().length);
}
return response;
}
}
And the new method “getAuthorByBook” in the service:
public String getAuthorByBook(String title) {
return getBooks().stream().filter(book -> book.title().equals(title))
.map(Book::author)
.findFirst()
.orElse(null);
}
With these changes, the test will now pass.
5: Implement DELETE method /api/books/{title}
The integration test:
class DeleteBookByTitleIntegrationTest extends BaseIntegrationTest {
@Test
void testWhenBookDoesNotExistThenReturns404() {
RestAssured.given()
.delete("/api/books/Quijote")
.then()
.statusCode(404);
}
@Test
void testWhenBookExistsThenReturn204() {
RestAssured.given()
.delete("/api/books/Quijote")
.then()
.statusCode(204);
}
}
The controller:
public class DeleteAuthorByBookRestController implements RestController {
private final BookService bookService;
public DeleteAuthorByBookRestController() {
this(new BookService());
}
public DeleteAuthorByBookRestController(BookService bookService) {
this.bookService = bookService;
}
@Override
public String path() {
return "/api/books/(\\w)+$";
}
@Override
public Method method() {
return Method.DELETE;
}
@Override
public String handle(HttpExchange exchange) throws IOException {
var parts = exchange.getRequestURI().getPath().split("/");
String title = parts[parts.length - 1];
boolean deleted = bookService.deleteBookByTitle(title);
if (deleted) {
exchange.sendResponseHeaders(204, -1);
} else {
exchange.sendResponseHeaders(404, -1);
}
return null;
}
}
The new method “deleteBookByTitle” in the service:
public boolean deleteBookByTitle(String title) {
return bookRepository.deleteBookByTitle(title);
}
And the one in the repository:
public boolean deleteBookByTitle(String title) {
var book = BOOKS.remove(title);
return book != null;
}
6: Implement POST method /api/books
The integration test:
class AddBooksIntegrationTest extends BaseIntegrationTest {
@Test
void testAddBookWhenEmptyRequestShouldReturn400() {
RestAssured.given()
.post("/api/books/Quijote")
.then()
.statusCode(400);
}
@Test
void testAddBookWhenWrongRequestShouldReturn400() {
RestAssured.given().body("Quijote")
.post("/api/books")
.then()
.statusCode(400);
}
@Test
void testAddBookShouldReturn201() {
RestAssured.given().body("Quijote-Cervantes")
.post("/api/books")
.then()
.statusCode(201);
}
}
The new controller:
public class AddBooksRestController implements RestController {
private final BookService bookService;
public AddBooksRestController() {
this(new BookService());
}
public AddBooksRestController(BookService bookService) {
this.bookService = bookService;
}
@Override
public String path() {
return "/api/books$";
}
@Override
public Method method() {
return Method.POST;
}
@Override
public String handle(HttpExchange exchange) throws IOException {
String request = new String(exchange.getRequestBody().readAllBytes());
String[] parts = request.split("-");
if (parts.length != 2 || parts[0].isEmpty() || parts[1].isEmpty()) {
exchange.sendResponseHeaders(400, 0);
return null;
}
bookService.addBook(new Book(parts[0], parts[1]));
exchange.sendResponseHeaders(201, 0);
return null;
}
}
With these changes, the test now will pass.
Conclusion
There are a lot of points of improvement here, but I wanted to only give the simplest approach of a framework-less REST server using TDD and CLEAN patterns.
The source code of the repository can be found here.